mirror of
https://github.com/ArthurDanjou/ui.git
synced 2026-01-14 12:14:41 +01:00
feat(Timeline): new component (#4215)
Co-authored-by: Benjamin Canac <canacb1@gmail.com> Co-authored-by: Jakub <jakub.michalek@freelo.io>
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type { TimelineItem } from '@nuxt/ui'
|
||||
|
||||
const items: TimelineItem[] = [{
|
||||
date: 'Mar 15, 2025',
|
||||
title: 'Project Kickoff',
|
||||
icon: 'i-lucide-rocket',
|
||||
value: 'kickoff'
|
||||
}, {
|
||||
date: 'Mar 22, 2025',
|
||||
title: 'Design Phase',
|
||||
icon: 'i-lucide-palette',
|
||||
value: 'design'
|
||||
}, {
|
||||
date: 'Mar 29, 2025',
|
||||
title: 'Development Sprint',
|
||||
icon: 'i-lucide-code',
|
||||
value: 'development'
|
||||
}, {
|
||||
date: 'Apr 5, 2025',
|
||||
title: 'Testing & Deployment',
|
||||
icon: 'i-lucide-check-circle',
|
||||
value: 'deployment'
|
||||
}]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTimeline
|
||||
:items="items"
|
||||
:ui="{ item: 'even:flex-row-reverse even:-translate-x-[calc(100%-2rem)] even:text-right' }"
|
||||
:default-value="2"
|
||||
class="w-full translate-x-[calc(50%-2rem)]"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import type { TimelineItem } from '@nuxt/ui'
|
||||
|
||||
const items = [{
|
||||
date: 'Mar 15, 2025',
|
||||
title: 'Project Kickoff',
|
||||
subtitle: 'Project Initiation',
|
||||
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.',
|
||||
icon: 'i-lucide-rocket',
|
||||
value: 'kickoff'
|
||||
}, {
|
||||
date: 'Mar 22, 2025',
|
||||
title: 'Design Phase',
|
||||
description: 'User research and design workshops. Created wireframes and prototypes for user testing.',
|
||||
icon: 'i-lucide-palette',
|
||||
value: 'design'
|
||||
}, {
|
||||
date: 'Mar 29, 2025',
|
||||
title: 'Development Sprint',
|
||||
description: 'Frontend and backend development. Implemented core features and integrated with APIs.',
|
||||
icon: 'i-lucide-code',
|
||||
value: 'development',
|
||||
slot: 'development' as const,
|
||||
developers: [
|
||||
{
|
||||
src: 'https://github.com/J-Michalek.png'
|
||||
}, {
|
||||
src: 'https://github.com/benjamincanac.png'
|
||||
}
|
||||
]
|
||||
}, {
|
||||
date: 'Apr 5, 2025',
|
||||
title: 'Testing & Deployment',
|
||||
description: 'QA testing and performance optimization. Deployed the application to production.',
|
||||
icon: 'i-lucide-check-circle',
|
||||
value: 'deployment'
|
||||
}] satisfies TimelineItem[]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTimeline :items="items" :default-value="2" class="w-96">
|
||||
<template #development-title="{ item }">
|
||||
<div class="flex items-center gap-1">
|
||||
<span>{{ item.title }}</span>
|
||||
|
||||
<UAvatarGroup size="2xs">
|
||||
<UAvatar v-for="(developer, index) of item.developers" :key="index" v-bind="developer" />
|
||||
</UAvatarGroup>
|
||||
</div>
|
||||
</template>
|
||||
</UTimeline>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { TimelineItem } from '@nuxt/ui'
|
||||
|
||||
const items: TimelineItem[] = [{
|
||||
date: 'Mar 15, 2025',
|
||||
title: 'Project Kickoff',
|
||||
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.',
|
||||
icon: 'i-lucide-rocket',
|
||||
value: 'kickoff'
|
||||
}, {
|
||||
date: 'Mar 22, 2025',
|
||||
title: 'Design Phase',
|
||||
description: 'User research and design workshops. Created wireframes and prototypes for user testing.',
|
||||
icon: 'i-lucide-palette',
|
||||
value: 'design'
|
||||
}, {
|
||||
date: 'Mar 29, 2025',
|
||||
title: 'Development Sprint',
|
||||
description: 'Frontend and backend development. Implemented core features and integrated with APIs.',
|
||||
icon: 'i-lucide-code',
|
||||
value: 'development'
|
||||
}, {
|
||||
date: 'Apr 5, 2025',
|
||||
title: 'Testing & Deployment',
|
||||
description: 'QA testing and performance optimization. Deployed the application to production.',
|
||||
icon: 'i-lucide-check-circle',
|
||||
value: 'deployment'
|
||||
}]
|
||||
|
||||
const active = ref(0)
|
||||
|
||||
// Note: This is for demonstration purposes only. Don't do this at home.
|
||||
onMounted(() => {
|
||||
setInterval(() => {
|
||||
active.value = (active.value + 1) % items.length
|
||||
}, 2000)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTimeline v-model="active" :items="items" class="w-96" />
|
||||
</template>
|
||||
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TimelineItem } from '@nuxt/ui'
|
||||
import { useTimeAgo } from '@vueuse/core'
|
||||
|
||||
const items = [{
|
||||
username: 'J-Michalek',
|
||||
date: '2025-05-24T14:58:55Z',
|
||||
action: 'opened this',
|
||||
avatar: {
|
||||
src: 'https://github.com/J-Michalek.png'
|
||||
}
|
||||
}, {
|
||||
username: 'J-Michalek',
|
||||
date: '2025-05-26T19:30:14+02:00',
|
||||
action: 'marked this pull request as ready for review',
|
||||
icon: 'i-lucide-check-circle'
|
||||
}, {
|
||||
username: 'benjamincanac',
|
||||
date: '2025-05-27T11:01:20Z',
|
||||
action: 'commented on this',
|
||||
description: 'I\'ve made a few changes, let me know what you think! Basically I updated the design, removed unnecessary divs, used Avatar component for the indicator since it supports icon already.',
|
||||
avatar: {
|
||||
src: 'https://github.com/benjamincanac.png'
|
||||
}
|
||||
}, {
|
||||
username: 'J-Michalek',
|
||||
date: '2025-05-27T11:01:20Z',
|
||||
action: 'commented on this',
|
||||
description: 'Looks great! Good job on cleaning it up.',
|
||||
avatar: {
|
||||
src: 'https://github.com/J-Michalek.png'
|
||||
}
|
||||
}, {
|
||||
username: 'benjamincanac',
|
||||
date: '2025-05-27T11:01:20Z',
|
||||
action: 'merged this',
|
||||
icon: 'i-lucide-git-merge'
|
||||
}] satisfies TimelineItem[]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTimeline
|
||||
:items="items"
|
||||
size="xs"
|
||||
class="w-96"
|
||||
:ui="{
|
||||
date: 'float-end ms-1',
|
||||
description: 'px-3 py-2 ring ring-default mt-2 rounded-md text-default'
|
||||
}"
|
||||
>
|
||||
<template #title="{ item }">
|
||||
<span>{{ item.username }}</span>
|
||||
<span class="font-normal text-muted"> {{ item.action }}</span>
|
||||
</template>
|
||||
|
||||
<template #date="{ item }">
|
||||
{{ useTimeAgo(new Date(item.date)) }}
|
||||
</template>
|
||||
</UTimeline>
|
||||
</template>
|
||||
@@ -200,6 +200,10 @@ Use the `#content` slot to customize the content of each item.
|
||||
|
||||
Use the `slot` property to customize a specific item.
|
||||
|
||||
You will have access to the following slots:
|
||||
|
||||
- `#{{ item.slot }}`{lang="ts-type"}
|
||||
|
||||
:component-example{name="stepper-custom-slot-example"}
|
||||
|
||||
## API
|
||||
|
||||
@@ -222,6 +222,10 @@ Use the `#content` slot to customize the content of each item.
|
||||
|
||||
Use the `slot` property to customize a specific item.
|
||||
|
||||
You will have access to the following slots:
|
||||
|
||||
- `#{{ item.slot }}`{lang="ts-type"}
|
||||
|
||||
:component-example{name="tabs-custom-slot-example"}
|
||||
|
||||
## API
|
||||
|
||||
228
docs/content/3.components/timeline.md
Normal file
228
docs/content/3.components/timeline.md
Normal file
@@ -0,0 +1,228 @@
|
||||
---
|
||||
title: Timeline
|
||||
description: 'A component that displays a sequence of events with dates, titles, icons or avatars.'
|
||||
category: data
|
||||
links:
|
||||
- label: GitHub
|
||||
icon: i-simple-icons-github
|
||||
to: https://github.com/nuxt/ui/tree/v3/src/runtime/components/Timeline.vue
|
||||
navigation.badge: Soon
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Items
|
||||
|
||||
Use the `items` prop as an array of objects with the following properties:
|
||||
|
||||
- `date?: string`{lang="ts-type"}
|
||||
- `title?: string`{lang="ts-type"}
|
||||
- `description?: AvatarProps`{lang="ts-type"}
|
||||
- `icon?: string`{lang="ts-type"}
|
||||
- `avatar?: AvatarProps`{lang="ts-type"}
|
||||
- `value?: string | number`{lang="ts-type"}
|
||||
- [`slot?: string`{lang="ts-type"}](#with-custom-slot)
|
||||
- `class?: any`{lang="ts-type"}
|
||||
- `ui?: { item?: ClassNameValue, container?: ClassNameValue, indicator?: ClassNameValue, separator?: ClassNameValue, wrapper?: ClassNameValue, separator?: ClassNameValue, date?: ClassNameValue, title?: ClassNameValue, description?: ClassNameValue }`{lang="ts-type"}
|
||||
|
||||
::component-code
|
||||
---
|
||||
ignore:
|
||||
- items
|
||||
- class
|
||||
- defaultValue
|
||||
external:
|
||||
- items
|
||||
externalTypes:
|
||||
- TimelineItem[]
|
||||
props:
|
||||
defaultValue: 2
|
||||
items:
|
||||
- date: 'Mar 15, 2025'
|
||||
title: 'Project Kickoff'
|
||||
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.'
|
||||
icon: 'i-lucide-rocket'
|
||||
- date: 'Mar 22 2025'
|
||||
title: 'Design Phase'
|
||||
description: 'User research and design workshops. Created wireframes and prototypes for user testing.'
|
||||
icon: 'i-lucide-palette'
|
||||
- date: 'Mar 29 2025'
|
||||
title: 'Development Sprint'
|
||||
description: 'Frontend and backend development. Implemented core features and integrated with APIs.'
|
||||
icon: 'i-lucide-code'
|
||||
- date: 'Apr 5 2025'
|
||||
title: 'Testing & Deployment'
|
||||
description: 'QA testing and performance optimization. Deployed the application to production.'
|
||||
icon: 'i-lucide-check-circle'
|
||||
class: 'w-96'
|
||||
---
|
||||
::
|
||||
|
||||
### Color
|
||||
|
||||
Use the `color` prop to change the color of the active items in a Timeline.
|
||||
|
||||
::component-code
|
||||
---
|
||||
ignore:
|
||||
- items
|
||||
- class
|
||||
- defaultValue
|
||||
external:
|
||||
- items
|
||||
externalTypes:
|
||||
- TimelineItem[]
|
||||
props:
|
||||
color: neutral
|
||||
defaultValue: 2
|
||||
items:
|
||||
- date: 'Mar 15, 2025'
|
||||
title: 'Project Kickoff'
|
||||
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.'
|
||||
icon: 'i-lucide-rocket'
|
||||
- date: 'Mar 22 2025'
|
||||
title: 'Design Phase'
|
||||
description: 'User research and design workshops. Created wireframes and prototypes for user testing.'
|
||||
icon: 'i-lucide-palette'
|
||||
- date: 'Mar 29 2025'
|
||||
title: 'Development Sprint'
|
||||
description: 'Frontend and backend development. Implemented core features and integrated with APIs.'
|
||||
icon: 'i-lucide-code'
|
||||
- date: 'Apr 5 2025'
|
||||
title: 'Testing & Deployment'
|
||||
description: 'QA testing and performance optimization. Deployed the application to production.'
|
||||
icon: 'i-lucide-check-circle'
|
||||
class: 'w-96'
|
||||
---
|
||||
::
|
||||
|
||||
### Size
|
||||
|
||||
Use the `size` prop to change the size of the Timeline.
|
||||
|
||||
::component-code
|
||||
---
|
||||
ignore:
|
||||
- items
|
||||
- class
|
||||
- defaultValue
|
||||
external:
|
||||
- items
|
||||
externalTypes:
|
||||
- TimelineItem[]
|
||||
props:
|
||||
size: xs
|
||||
defaultValue: 2
|
||||
items:
|
||||
- date: 'Mar 15, 2025'
|
||||
title: 'Project Kickoff'
|
||||
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.'
|
||||
icon: 'i-lucide-rocket'
|
||||
- date: 'Mar 22 2025'
|
||||
title: 'Design Phase'
|
||||
description: 'User research and design workshops. Created wireframes and prototypes for user testing.'
|
||||
icon: 'i-lucide-palette'
|
||||
- date: 'Mar 29 2025'
|
||||
title: 'Development Sprint'
|
||||
description: 'Frontend and backend development. Implemented core features and integrated with APIs.'
|
||||
icon: 'i-lucide-code'
|
||||
- date: 'Apr 5 2025'
|
||||
title: 'Testing & Deployment'
|
||||
description: 'QA testing and performance optimization. Deployed the application to production.'
|
||||
icon: 'i-lucide-check-circle'
|
||||
class: 'w-96'
|
||||
---
|
||||
::
|
||||
|
||||
### Orientation
|
||||
|
||||
Use the `orientation` prop to change the orientation of the Timeline. Defaults to `vertical`.
|
||||
|
||||
::component-code
|
||||
---
|
||||
ignore:
|
||||
- items
|
||||
- class
|
||||
- defaultValue
|
||||
external:
|
||||
- items
|
||||
externalTypes:
|
||||
- TimelineItem[]
|
||||
props:
|
||||
orientation: 'horizontal'
|
||||
defaultValue: 2
|
||||
items:
|
||||
- date: 'Mar 15, 2025'
|
||||
title: 'Project Kickoff'
|
||||
description: 'Kicked off the project with team alignment.'
|
||||
icon: 'i-lucide-rocket'
|
||||
- date: 'Mar 22 2025'
|
||||
title: 'Design Phase'
|
||||
description: 'User research and design workshops.'
|
||||
icon: 'i-lucide-palette'
|
||||
- date: 'Mar 29 2025'
|
||||
title: 'Development Sprint'
|
||||
description: 'Frontend and backend development.'
|
||||
icon: 'i-lucide-code'
|
||||
- date: 'Apr 5 2025'
|
||||
title: 'Testing & Deployment'
|
||||
description: 'QA testing and performance optimization.'
|
||||
icon: 'i-lucide-check-circle'
|
||||
class: 'w-full'
|
||||
---
|
||||
::
|
||||
|
||||
## Examples
|
||||
|
||||
### Control active item
|
||||
|
||||
You can control the active item by using the `default-value` prop or the `v-model` directive with the index of the item.
|
||||
|
||||
:component-example{name="timeline-model-value-example" prettier}
|
||||
|
||||
::tip
|
||||
You can also pass the `value` of one of the items if provided.
|
||||
::
|
||||
|
||||
### With alternating layout
|
||||
|
||||
Use the `ui` prop to create a Timeline with alternating layout.
|
||||
|
||||
:component-example{name="timeline-alternating-layout-example" prettier}
|
||||
|
||||
### With custom slot
|
||||
|
||||
Use the `slot` property to customize a specific item.
|
||||
|
||||
You will have access to the following slots:
|
||||
|
||||
- `#{{ item.slot }}-indicator`{lang="ts-type"}
|
||||
- `#{{ item.slot }}-date`{lang="ts-type"}
|
||||
- `#{{ item.slot }}-title`{lang="ts-type"}
|
||||
- `#{{ item.slot }}-description`{lang="ts-type"}
|
||||
|
||||
:component-example{name="timeline-custom-slot-example" prettier}
|
||||
|
||||
### With slots
|
||||
|
||||
Use the available slots to create a more complex Timeline.
|
||||
|
||||
:component-example{name="timeline-slots-example" prettier}
|
||||
|
||||
## API
|
||||
|
||||
### Props
|
||||
|
||||
:component-props
|
||||
|
||||
### Slots
|
||||
|
||||
:component-slots
|
||||
|
||||
### Emits
|
||||
|
||||
:component-emits
|
||||
|
||||
## Theme
|
||||
|
||||
:component-theme
|
||||
@@ -407,7 +407,14 @@ This lets you select a parent item without expanding or collapsing its children.
|
||||
|
||||
### With custom slot
|
||||
|
||||
Use the `item.slot` property to customize a specific item.
|
||||
Use the `slot` property to customize a specific item.
|
||||
|
||||
You will have access to the following slots:
|
||||
|
||||
- `#{{ item.slot }}`{lang="ts-type"}
|
||||
- `#{{ item.slot }}-leading`{lang="ts-type"}
|
||||
- `#{{ item.slot }}-label`{lang="ts-type"}
|
||||
- `#{{ item.slot }}-trailing`{lang="ts-type"}
|
||||
|
||||
::component-example
|
||||
---
|
||||
|
||||
BIN
docs/public/components/dark/timeline.png
Normal file
BIN
docs/public/components/dark/timeline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
docs/public/components/light/timeline.png
Normal file
BIN
docs/public/components/light/timeline.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -61,6 +61,7 @@ const components = [
|
||||
'tabs',
|
||||
'table',
|
||||
'textarea',
|
||||
'timeline',
|
||||
'toast',
|
||||
'tooltip',
|
||||
'tree'
|
||||
|
||||
@@ -61,6 +61,7 @@ const components = [
|
||||
'tabs',
|
||||
'table',
|
||||
'textarea',
|
||||
'timeline',
|
||||
'toast',
|
||||
'tooltip',
|
||||
'tree'
|
||||
|
||||
60
playground/app/pages/components/timeline.vue
Normal file
60
playground/app/pages/components/timeline.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script lang="ts" setup>
|
||||
import type { TimelineItem } from '@nuxt/ui'
|
||||
import theme from '#build/ui/timeline'
|
||||
|
||||
const sizes = Object.keys(theme.variants.size)
|
||||
const colors = Object.keys(theme.variants.color)
|
||||
const orientations = Object.keys(theme.variants.orientation)
|
||||
|
||||
const orientation = ref('vertical' as const)
|
||||
const color = ref('primary' as const)
|
||||
const size = ref('md' as const)
|
||||
|
||||
const items = [{
|
||||
date: 'Mar 15, 2025',
|
||||
title: 'Project Kickoff',
|
||||
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.',
|
||||
icon: 'i-lucide-rocket',
|
||||
value: 'kickoff'
|
||||
}, {
|
||||
date: 'Mar 22, 2025',
|
||||
title: 'Design Phase',
|
||||
description: 'User research and design workshops. Created wireframes and prototypes for user testing',
|
||||
icon: 'i-lucide-palette',
|
||||
value: 'design'
|
||||
}, {
|
||||
date: 'Mar 29, 2025',
|
||||
title: 'Development Sprint',
|
||||
description: 'Frontend and backend development. Implemented core features and integrated with APIs.',
|
||||
icon: 'i-lucide-code',
|
||||
value: 'development'
|
||||
}, {
|
||||
date: 'Apr 5, 2025',
|
||||
title: 'Testing & Deployment',
|
||||
description: 'QA testing and performance optimization. Deployed the application to production.',
|
||||
icon: 'i-lucide-check-circle',
|
||||
value: 'deployment'
|
||||
}] satisfies TimelineItem[]
|
||||
|
||||
const value = ref('kickoff')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-10">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<USelect v-model="color" :items="colors" placeholder="Color" />
|
||||
<USelect v-model="orientation" :items="orientations" placeholder="Orientation" />
|
||||
<USelect v-model="size" :items="sizes" placeholder="Size" />
|
||||
<USelect v-model="value" :items="items.map(item => item.value)" placeholder="Value" />
|
||||
</div>
|
||||
|
||||
<UTimeline
|
||||
v-model="value"
|
||||
:color="color"
|
||||
:orientation="orientation"
|
||||
:size="size"
|
||||
:items="items"
|
||||
class="data-[orientation=horizontal]:w-full data-[orientation=vertical]:w-96"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
129
src/runtime/components/Timeline.vue
Normal file
129
src/runtime/components/Timeline.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<!-- eslint-disable vue/block-tag-newline -->
|
||||
<script lang="ts">
|
||||
import type { AppConfig } from '@nuxt/schema'
|
||||
import theme from '#build/ui/timeline'
|
||||
import type { AvatarProps } from '../types'
|
||||
import type { DynamicSlots, ComponentConfig } from '../types/utils'
|
||||
|
||||
type Timeline = ComponentConfig<typeof theme, AppConfig, 'timeline'>
|
||||
|
||||
export interface TimelineItem {
|
||||
date?: string
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: string
|
||||
avatar?: AvatarProps
|
||||
value?: string | number
|
||||
slot?: string
|
||||
class?: any
|
||||
ui?: Pick<Timeline['slots'], 'item' | 'container' | 'indicator' | 'separator' | 'wrapper' | 'date' | 'title' | 'description'>
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface TimelineProps<T extends TimelineItem = TimelineItem> {
|
||||
/**
|
||||
* The element or component this component should render as.
|
||||
* @defaultValue 'div'
|
||||
*/
|
||||
as?: any
|
||||
items: T[]
|
||||
/**
|
||||
* @defaultValue 'md'
|
||||
*/
|
||||
size?: Timeline['variants']['size']
|
||||
/**
|
||||
* @defaultValue 'primary'
|
||||
*/
|
||||
color?: Timeline['variants']['color']
|
||||
/**
|
||||
* The orientation of the Timeline.
|
||||
* @defaultValue 'vertical'
|
||||
*/
|
||||
orientation?: Timeline['variants']['orientation']
|
||||
defaultValue?: string | number
|
||||
class?: any
|
||||
ui?: Timeline['slots']
|
||||
}
|
||||
|
||||
type SlotProps<T extends TimelineItem> = (props: { item: T }) => any
|
||||
|
||||
export type TimelineSlots<T extends TimelineItem = TimelineItem> = {
|
||||
indicator: SlotProps<T>
|
||||
date: SlotProps<T>
|
||||
title: SlotProps<T>
|
||||
description: SlotProps<T>
|
||||
} & DynamicSlots<T, 'indicator' | 'date' | 'title' | 'description', { item: T }>
|
||||
|
||||
</script>
|
||||
|
||||
<script setup lang="ts" generic="T extends TimelineItem">
|
||||
import { computed } from 'vue'
|
||||
import { Primitive, Separator } from 'reka-ui'
|
||||
import { useAppConfig } from '#imports'
|
||||
import { tv } from '../utils/tv'
|
||||
import UAvatar from './Avatar.vue'
|
||||
|
||||
const props = withDefaults(defineProps<TimelineProps<T>>(), {
|
||||
orientation: 'vertical'
|
||||
})
|
||||
const slots = defineSlots<TimelineSlots<T>>()
|
||||
|
||||
const modelValue = defineModel<string | number>()
|
||||
|
||||
const appConfig = useAppConfig() as Timeline['AppConfig']
|
||||
|
||||
const ui = computed(() => tv({ extend: tv(theme), ...(appConfig.ui?.timeline || {}) })({
|
||||
orientation: props.orientation,
|
||||
size: props.size,
|
||||
color: props.color
|
||||
}))
|
||||
|
||||
const currentStepIndex = computed(() => {
|
||||
const value = modelValue.value ?? props.defaultValue
|
||||
|
||||
return ((typeof value === 'string')
|
||||
? props.items.findIndex(item => item.value === value)
|
||||
: value) ?? -1
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive :as="as" :data-orientation="orientation" :class="ui.root({ class: [props.ui?.root, props.class] })">
|
||||
<div
|
||||
v-for="(item, index) in items"
|
||||
:key="item.value ?? index"
|
||||
:class="ui.item({ class: [props.ui?.item, item.ui?.item, item.class] })"
|
||||
:data-state="index < currentStepIndex ? 'completed' : index === currentStepIndex ? 'active' : undefined"
|
||||
>
|
||||
<div :class="ui.container({ class: [props.ui?.container, item.ui?.container] })">
|
||||
<UAvatar :size="size" :icon="item.icon" v-bind="typeof item.avatar === 'object' ? item.avatar : {}" :class="ui.indicator({ class: [props.ui?.indicator, item.ui?.indicator] })" :ui="{ icon: 'text-inherit', fallback: 'text-inherit' }">
|
||||
<slot :name="((item.slot ? `${item.slot}-indicator` : 'indicator') as keyof TimelineSlots<T>)" :item="(item as Extract<T, { slot: string; }>)" />
|
||||
</UAvatar>
|
||||
|
||||
<Separator
|
||||
v-if="index < items.length - 1"
|
||||
:class="ui.separator({ class: [props.ui?.separator, item.ui?.separator] })"
|
||||
:orientation="props.orientation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div :class="ui.wrapper({ class: [props.ui?.wrapper, item.ui?.wrapper] })">
|
||||
<div v-if="item.date" :class="ui.date({ class: [props.ui?.date, item.ui?.date] })">
|
||||
<slot :name="((item.slot ? `${item.slot}-date` : 'date') as keyof TimelineSlots<T>)" :item="(item as Extract<T, { slot: string; }>)">
|
||||
{{ item.date }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="item.title || !!slots.title" :class="ui.title({ class: [props.ui?.title, item.ui?.title] })">
|
||||
<slot :name="((item.slot ? `${item.slot}-title` : 'title') as keyof TimelineSlots<T>)" :item="(item as Extract<T, { slot: string; }>)">
|
||||
{{ item.title }}
|
||||
</slot>
|
||||
</div>
|
||||
<div v-if="item.description || !!slots.description" :class="ui.description({ class: [props.ui?.description, item.ui?.description] })">
|
||||
<slot :name="((item.slot ? `${item.slot}-description` : 'description') as keyof TimelineSlots<T>)" :item="(item as Extract<T, { slot: string; }>)">
|
||||
{{ item.description }}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -46,6 +46,7 @@ export * from '../components/Switch.vue'
|
||||
export * from '../components/Table.vue'
|
||||
export * from '../components/Tabs.vue'
|
||||
export * from '../components/Textarea.vue'
|
||||
export * from '../components/Timeline.vue'
|
||||
export * from '../components/Toast.vue'
|
||||
export * from '../components/Toaster.vue'
|
||||
export * from '../components/Tooltip.vue'
|
||||
|
||||
@@ -44,6 +44,7 @@ export { default as switch } from './switch'
|
||||
export { default as table } from './table'
|
||||
export { default as tabs } from './tabs'
|
||||
export { default as textarea } from './textarea'
|
||||
export { default as timeline } from './timeline'
|
||||
export { default as toast } from './toast'
|
||||
export { default as toaster } from './toaster'
|
||||
export { default as tooltip } from './tooltip'
|
||||
|
||||
168
src/theme/timeline.ts
Normal file
168
src/theme/timeline.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { ModuleOptions } from '../module'
|
||||
|
||||
export default (options: Required<ModuleOptions>) => ({
|
||||
slots: {
|
||||
root: 'flex gap-1.5',
|
||||
item: 'group relative flex flex-1 gap-3',
|
||||
container: 'relative flex items-center gap-1.5',
|
||||
indicator: 'group-data-[state=completed]:text-inverted group-data-[state=active]:text-inverted text-muted',
|
||||
separator: 'flex-1 rounded-full bg-elevated',
|
||||
wrapper: 'w-full',
|
||||
date: 'text-dimmed text-xs/5',
|
||||
title: 'font-medium text-highlighted text-sm',
|
||||
description: 'text-muted text-wrap text-sm'
|
||||
},
|
||||
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal: {
|
||||
root: 'flex-row w-full',
|
||||
item: 'flex-col',
|
||||
separator: 'h-0.5'
|
||||
},
|
||||
vertical: {
|
||||
root: 'flex-col',
|
||||
container: 'flex-col',
|
||||
separator: 'w-0.5'
|
||||
}
|
||||
},
|
||||
|
||||
color: {
|
||||
...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, {
|
||||
indicator: `group-data-[state=completed]:bg-${color} group-data-[state=active]:bg-${color}`,
|
||||
separator: `group-data-[state=completed]:bg-${color}`
|
||||
}])),
|
||||
neutral: {
|
||||
indicator: 'group-data-[state=completed]:bg-inverted group-data-[state=active]:bg-inverted',
|
||||
separator: 'group-data-[state=completed]:bg-inverted'
|
||||
}
|
||||
},
|
||||
|
||||
size: {
|
||||
'3xs': '',
|
||||
'2xs': '',
|
||||
'xs': '',
|
||||
'sm': '',
|
||||
'md': '',
|
||||
'lg': '',
|
||||
'xl': '',
|
||||
'2xl': '',
|
||||
'3xl': ''
|
||||
}
|
||||
},
|
||||
|
||||
compoundVariants: [{
|
||||
orientation: 'horizontal',
|
||||
size: '3xs',
|
||||
class: {
|
||||
wrapper: 'pe-4.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'horizontal',
|
||||
size: '2xs',
|
||||
class: {
|
||||
wrapper: 'pe-5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'horizontal',
|
||||
size: 'xs',
|
||||
class: {
|
||||
wrapper: 'pe-5.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'horizontal',
|
||||
size: 'sm',
|
||||
class: {
|
||||
wrapper: 'pe-6'
|
||||
}
|
||||
}, {
|
||||
orientation: 'horizontal',
|
||||
size: 'md',
|
||||
class: {
|
||||
wrapper: 'pe-6.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'horizontal',
|
||||
size: 'lg',
|
||||
class: {
|
||||
wrapper: 'pe-7'
|
||||
}
|
||||
}, {
|
||||
orientation: 'horizontal',
|
||||
size: 'xl',
|
||||
class: {
|
||||
wrapper: 'pe-7.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'horizontal',
|
||||
size: '2xl',
|
||||
class: {
|
||||
wrapper: 'pe-8'
|
||||
}
|
||||
}, {
|
||||
orientation: 'horizontal',
|
||||
size: '3xl',
|
||||
class: {
|
||||
wrapper: 'pe-8.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: '3xs',
|
||||
class: {
|
||||
wrapper: '-mt-0.5 pb-4.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: '2xs',
|
||||
class: {
|
||||
wrapper: 'pb-5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: 'xs',
|
||||
class: {
|
||||
wrapper: 'mt-0.5 pb-5.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: 'sm',
|
||||
class: {
|
||||
wrapper: 'mt-1 pb-6'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: 'md',
|
||||
class: {
|
||||
wrapper: 'mt-1.5 pb-6.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: 'lg',
|
||||
class: {
|
||||
wrapper: 'mt-2 pb-7'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: 'xl',
|
||||
class: {
|
||||
wrapper: 'mt-2.5 pb-7.5'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: '2xl',
|
||||
class: {
|
||||
wrapper: 'mt-3 pb-8'
|
||||
}
|
||||
}, {
|
||||
orientation: 'vertical',
|
||||
size: '3xl',
|
||||
class: {
|
||||
wrapper: 'mt-3.5 pb-8.5'
|
||||
}
|
||||
}],
|
||||
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'primary'
|
||||
}
|
||||
})
|
||||
57
test/components/Timeline.spec.ts
Normal file
57
test/components/Timeline.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import Timeline, { type TimelineProps, type TimelineSlots } from '../../src/runtime/components/Timeline.vue'
|
||||
import ComponentRender from '../component-render'
|
||||
import theme from '#build/ui/timeline'
|
||||
|
||||
describe('Timeline', () => {
|
||||
const sizes = Object.keys(theme.variants.size) as any
|
||||
|
||||
const items = [{
|
||||
date: 'Mar 15, 2025',
|
||||
title: 'Project Kickoff',
|
||||
description: 'Kicked off the project with team alignment. Set up project milestones and allocated resources.',
|
||||
icon: 'i-lucide-rocket',
|
||||
value: 'kickoff'
|
||||
}, {
|
||||
date: 'Mar 22, 2025',
|
||||
title: 'Design Phase',
|
||||
description: 'User research and design workshops. Created wireframes and prototypes for user testing',
|
||||
icon: 'i-lucide-palette',
|
||||
value: 'design'
|
||||
}, {
|
||||
date: 'Mar 29, 2025',
|
||||
title: 'Development Sprint',
|
||||
description: 'Frontend and backend development. Implemented core features and integrated with APIs.',
|
||||
icon: 'i-lucide-code',
|
||||
value: 'development'
|
||||
}, {
|
||||
date: 'Apr 5, 2025',
|
||||
title: 'Testing & Deployment',
|
||||
description: 'QA testing and performance optimization. Deployed the application to production.',
|
||||
icon: 'i-lucide-check-circle',
|
||||
value: 'deployment'
|
||||
}]
|
||||
|
||||
const props = { items }
|
||||
|
||||
it.each([
|
||||
// Props
|
||||
['with items', { props }],
|
||||
['with modelValue', { props: { ...props, modelValue: 'assigned' } }],
|
||||
['with defaultValue', { props: { ...props, defaultValue: 'assigned' } }],
|
||||
['with neutral color', { props: { ...props, color: 'neutral' } }],
|
||||
...sizes.map((size: string) => [`with size ${size} horizontal`, { props: { ...props, size } }]),
|
||||
...sizes.map((size: string) => [`with size ${size} vertical`, { props: { ...props, size, orientation: 'vertical' } }]),
|
||||
['with as', { props: { ...props, as: 'section' } }],
|
||||
['with class', { props: { ...props, class: 'gap-8' } }],
|
||||
['with ui', { props: { ...props, ui: { title: 'font-bold' } } }],
|
||||
// Slots
|
||||
['with indicator slot', { props, slots: { indicator: () => 'Indicator slot' } }],
|
||||
['with date slot', { props, slots: { date: () => 'Date slot' } }],
|
||||
['with title slot', { props, slots: { title: () => 'Title slot' } }],
|
||||
['with description slot', { props, slots: { description: () => 'Description slot' } }]
|
||||
])('renders %s correctly', async (nameOrHtml: string, options: { props?: TimelineProps, slots?: Partial<TimelineSlots> }) => {
|
||||
const html = await ComponentRender(nameOrHtml, options, Timeline)
|
||||
expect(html).toMatchSnapshot()
|
||||
})
|
||||
})
|
||||
1306
test/components/__snapshots__/Timeline-vue.spec.ts.snap
Normal file
1306
test/components/__snapshots__/Timeline-vue.spec.ts.snap
Normal file
File diff suppressed because it is too large
Load Diff
1306
test/components/__snapshots__/Timeline.spec.ts.snap
Normal file
1306
test/components/__snapshots__/Timeline.spec.ts.snap
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user