feat(Stepper): new component (#2733)

Co-authored-by: Benjamin Canac <canacb1@gmail.com>
This commit is contained in:
Romain Hamel
2024-12-05 12:20:45 +01:00
committed by GitHub
parent d539109357
commit 6484d010a1
15 changed files with 2899 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref } from 'vue'
const items = [
{
slot: 'address',
title: 'Address',
description: 'Add your address here',
icon: 'i-lucide-house'
}, {
slot: 'shipping',
title: 'Shipping',
description: 'Set your preferred shipping method',
icon: 'i-lucide-truck'
}, {
slot: 'checkout',
title: 'Checkout',
description: 'Confirm your order'
}
]
const stepper = ref()
</script>
<template>
<UStepper
ref="stepper"
:items="items"
>
<template #content="{ item }">
<Placeholder class="size-full min-h-60 min-w-60">
{{ item.title }}
</Placeholder>
<div class="flex gap-2 justify-between mt-2">
<UButton variant="outline" :disabled="!stepper?.hasPrevious" leading-icon="i-lucide-arrow-left" @click="stepper.previous()">
Back
</UButton>
<UButton :disabled="!stepper?.hasNext" trailing-icon="i-lucide-arrow-right" @click="stepper.next()">
Next
</UButton>
</div>
</template>
</UStepper>
</template>

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { tv, type VariantProps } from 'tailwind-variants'
import type { StepperRootProps, StepperRootEmits } from 'reka-ui'
import type { AppConfig } from '@nuxt/schema'
import _appConfig from '#build/app.config'
import theme from '#build/ui/stepper'
import type { DynamicSlots } from '../types/utils'
const appConfig = _appConfig as AppConfig & { ui: { stepper: Partial<typeof theme> } }
const stepper = tv({ extend: tv(theme), ...(appConfig.ui?.stepper || {}) })
type StepperVariants = VariantProps<typeof stepper>
export interface StepperItem {
slot?: string
value?: string
title?: string
description?: string
icon?: string
content?: string
disabled?: boolean
}
export interface StepperProps<T extends StepperItem> extends Pick<StepperRootProps, 'linear'> {
/**
* The element or component this component should render as.
* @defaultValue 'div'
*/
as?: any
items: T[]
size?: StepperVariants['size']
color?: StepperVariants['color']
orientation?: StepperVariants['orientation']
/**
* The value of the step that should be active when initially rendered. Use when you do not need to control the state of the steps.
*/
defaultValue?: string | number
disabled?: boolean
ui?: Partial<typeof stepper.slots>
class?: any
}
export type StepperEmits<T> = Omit<StepperRootEmits, 'update:modelValue'> & {
next: [payload: T]
prev: [payload: T]
}
type SlotProps<T extends StepperItem> = (props: { item: T }) => any
export type StepperSlots<T extends StepperItem> = {
indicator: SlotProps<T>
title: SlotProps<T>
description: SlotProps<T>
content: SlotProps<T>
} & DynamicSlots<T, SlotProps<T>>
extendDevtoolsMeta({ example: 'StepperExample' })
</script>
<script setup lang="ts" generic="T extends StepperItem">
import { computed } from 'vue'
import { StepperRoot, StepperItem, StepperTrigger, StepperIndicator, StepperSeparator, StepperTitle, StepperDescription, useForwardProps } from 'reka-ui'
import { reactivePick } from '@vueuse/core'
import UIcon from './Icon.vue'
const props = withDefaults(defineProps<StepperProps<T>>(), {
linear: true
})
const emits = defineEmits<StepperEmits<T>>()
const slots = defineSlots<StepperSlots<T>>()
const modelValue = defineModel<string | number>()
const rootProps = useForwardProps(reactivePick(props, 'as', 'orientation', 'linear'))
const ui = computed(() => stepper({
orientation: props.orientation,
size: props.size,
color: props.color
}))
const currentStepIndex = computed({
get() {
const value = modelValue.value ?? props.defaultValue
return ((typeof value === 'string')
? props.items.findIndex(item => item.value === value)
: value) ?? 0
},
set(value: number) {
modelValue.value = props.items?.[value]?.value ?? value
}
})
const currentStep = computed(() => props.items?.[currentStepIndex.value] as T)
const hasNext = computed(() => currentStepIndex.value < props.items?.length - 1)
const hasPrev = computed(() => currentStepIndex.value > 0)
defineExpose({
next() {
if (hasNext.value) {
currentStepIndex.value += 1
emits('next', currentStep.value)
}
},
prev() {
if (hasPrev.value) {
currentStepIndex.value -= 1
emits('prev', currentStep.value)
}
},
hasNext,
hasPrev
})
</script>
<template>
<StepperRoot v-bind="rootProps" v-model="currentStepIndex" :class="ui.root({ class: [props.class, props.ui?.root] })">
<div :class="ui.header({ class: props.ui?.header })">
<StepperItem
v-for="(item, count) in items"
:key="item.value ?? count"
:step="count"
:disabled="item.disabled || props.disabled"
:class="ui.item({ class: props.ui?.item })"
>
<div :class="ui.container({ class: props.ui?.container })">
<StepperTrigger :class="ui.trigger({ class: props.ui?.trigger })">
<StepperIndicator :class="ui.indicator({ class: props.ui?.indicator })">
<slot name="indicator" :item="item">
<UIcon v-if="item.icon" :name="item.icon" :class="ui.icon({ class: props.ui?.indicator })" />
<template v-else>
{{ count + 1 }}
</template>
</slot>
</StepperIndicator>
</StepperTrigger>
<StepperSeparator
v-if="count < items.length - 1"
:class="ui.separator({ class: props.ui?.separator })"
/>
</div>
<div :class="ui.wrapper({ class: props.ui?.wrapper })">
<StepperTitle :class="ui.title({ class: props.ui?.title })">
<slot name="title" :item="currentStep">
{{ item.title }}
</slot>
</StepperTitle>
<StepperDescription :class="ui.description({ class: props.ui?.description })">
<slot name="description" :item="currentStep">
{{ item.description }}
</slot>
</StepperDescription>
</div>
</StepperItem>
</div>
<div v-if="currentStep?.content || !!slots.content || (currentStep?.slot && !!slots[currentStep.slot]) || (currentStep?.value && !!slots[currentStep.value])" :class="ui.content({ class: props.ui?.description })">
<slot
:name="!!slots[currentStep?.slot ?? currentStep.value] ? currentStep.slot ?? currentStep.value : 'content'"
:item="currentStep"
>
{{ currentStep?.content }}
</slot>
</div>
</StepperRoot>
</template>

View File

@@ -37,6 +37,7 @@ export * from '../components/Separator.vue'
export * from '../components/Skeleton.vue'
export * from '../components/Slideover.vue'
export * from '../components/Slider.vue'
export * from '../components/Stepper.vue'
export * from '../components/Switch.vue'
export * from '../components/Table.vue'
export * from '../components/Tabs.vue'

View File

@@ -37,6 +37,7 @@ export { default as separator } from './separator'
export { default as skeleton } from './skeleton'
export { default as slideover } from './slideover'
export { default as slider } from './slider'
export { default as stepper } from './stepper'
export { default as switch } from './switch'
export { default as table } from './table'
export { default as tabs } from './tabs'

131
src/theme/stepper.ts Normal file
View File

@@ -0,0 +1,131 @@
import type { ModuleOptions } from '../module'
export default (options: Required<ModuleOptions>) => ({
slots: {
root: 'flex gap-4',
header: 'flex',
item: 'group text-center relative w-full',
container: 'relative',
trigger: 'rounded-full font-medium text-center align-middle flex items-center justify-center font-semibold group-data-[state=completed]:text-[var(--ui-bg)] group-data-[state=active]:text-[var(--ui-bg)] text-[var(--ui-text-muted)] bg-[var(--ui-bg-elevated)] focus-visible:outline-2 focus-visible:outline-offset-2',
indicator: 'flex items-center justify-center size-full',
icon: 'shrink-0',
separator: 'absolute rounded-full group-data-[disabled]:opacity-75 bg-[var(--ui-border-accented)]',
wrapper: '',
title: 'font-medium text-[var(--ui-text)]',
description: 'text-[var(--ui-text-muted)] text-wrap',
content: 'size-full'
},
variants: {
orientation: {
horizontal: {
root: 'flex-col',
container: 'flex justify-center',
separator: 'top-[calc(50%-2px)] h-0.5',
wrapper: 'mt-1'
},
vertical: {
header: 'flex-col gap-4',
item: 'flex text-left',
separator: 'left-[calc(50%-1px)] -bottom-[10px] w-0.5'
}
},
size: {
xs: {
trigger: 'size-6 text-xs',
icon: 'size-3',
title: 'text-xs',
description: 'text-xs',
wrapper: 'mt-1.5'
},
sm: {
trigger: 'size-8 text-sm',
icon: 'size-4',
title: 'text-xs',
description: 'text-xs',
wrapper: 'mt-2'
},
md: {
trigger: 'size-10 text-base',
icon: 'size-5',
title: 'text-sm',
description: 'text-sm',
wrapper: 'mt-2.5'
},
lg: {
trigger: 'size-12 text-lg',
icon: 'size-6',
title: 'text-base',
description: 'text-base',
wrapper: 'mt-3'
},
xl: {
trigger: 'size-14 text-xl',
icon: 'size-7',
title: 'text-lg',
description: 'text-lg',
wrapper: 'mt-3.5'
}
},
color: {
...Object.fromEntries((options.theme.colors || []).map((color: string) => [color, {
trigger: `group-data-[state=completed]:bg-[var(--ui-${color})] group-data-[state=active]:bg-[var(--ui-${color})] focus-visible:outline-[var(--ui-${color})]`,
separator: `group-data-[state=completed]:bg-[var(--ui-${color})]`
}])),
neutral: {
trigger: `group-data-[state=completed]:bg-[var(--ui-bg-inverted)] group-data-[state=active]:bg-[var(--ui-bg-inverted)] focus-visible:outline-[var(--ui-border-inverted)]`,
separator: `group-data-[state=completed]:bg-[var(--ui-bg-inverted)]`
}
}
},
compoundVariants: [{
orientation: 'horizontal',
size: 'xs',
class: { separator: 'left-[calc(50%+16px)] right-[calc(-50%+16px)]' }
}, {
orientation: 'horizontal',
size: 'sm',
class: { separator: 'left-[calc(50%+20px)] right-[calc(-50%+20px)]' }
}, {
orientation: 'horizontal',
size: 'md',
class: { separator: 'left-[calc(50%+28px)] right-[calc(-50%+28px)]' }
}, {
orientation: 'horizontal',
size: 'lg',
class: { separator: 'left-[calc(50%+32px)] right-[calc(-50%+32px)]' }
}, {
orientation: 'horizontal',
size: 'xl',
class: { separator: 'left-[calc(50%+36px)] right-[calc(-50%+36px)]' }
}, {
orientation: 'vertical',
size: 'xs',
class: { separator: 'top-[30px]', item: 'gap-1.5' }
}, {
orientation: 'vertical',
size: 'sm',
class: { separator: 'top-[38px]', item: 'gap-2' }
}, {
orientation: 'vertical',
size: 'md',
class: { separator: 'top-[46px]', item: 'gap-2.5' }
}, {
orientation: 'vertical',
size: 'lg',
class: { separator: 'top-[54px]', item: 'gap-3' }
}, {
orientation: 'vertical',
size: 'xl',
class: { separator: 'top-[62px]', item: 'gap-3.5' }
}],
defaultVariants: {
orientation: 'horizontal',
size: 'md',
color: 'primary'
}
})