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:
J-Michalek
2025-05-30 15:27:11 +02:00
committed by GitHub
parent 536b7afcc1
commit 80177679f2
20 changed files with 3462 additions and 1 deletions

View 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>

View File

@@ -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'

View File

@@ -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
View 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'
}
})