reui
Stepper
Stepper — кастомный компонент, портированный из ReUI (keenthemes/reui, MIT).
Загрузка превью…
src/reui/stepper/Stepper.vue
<script setup lang="ts">
/**
* Порт ReUI Stepper (registry-reui/bases/radix/reui/stepper.tsx, MIT).
*
* `value`/`defaultValue`/`onValueChange` из оригинала реализованы вручную
* через defineProps + defineEmits("update:value"), а не макросом
* defineModel. Причина — буквальное сохранение гейта оригинала
* `value === undefined` (см. docs/PORTING.md, 4a "Верность важнее
* улучшений"): defineModel сливает входящий проп и внутреннее состояние в
* один ref, и запись в него после первого вызова стирает информацию о
* том, был ли компонент управляемым снаружи. Ручной вариант читает
* `props.value` как есть (никогда не перезаписывая сам проп) и держит
* отдельный внутренний ref для неуправляемого режима — ровно как в
* оригинале `value ?? activeStep`. Снаружи компонент выглядит так же,
* как если бы использовался defineModel: `v-model:value` работает,
* потому что имя пропа/события то же самое.
*
* Расхождение API (осознанное): React принимает проп `indicators`
* (объект ReactNode на каждое состояние). Во Vue это именованные слоты
* `indicator-active` / `indicator-completed` / `indicator-inactive` /
* `indicator-loading`, см. context.ts и StepperIndicator.vue, а также
* docs/PORTING.md.
*/
import type { HTMLAttributes } from "vue"
import { computed, provide, ref, useSlots } from "vue"
import { cn } from "@/lib/utils"
import { StepperContextKey, type StepperOrientation } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
defaultValue?: number
value?: number
orientation?: StepperOrientation
}>(),
{
defaultValue: 1,
orientation: "horizontal",
}
)
const emit = defineEmits<{ "update:value": [step: number] }>()
const slots = useSlots()
const internalStep = ref(props.defaultValue)
const activeStep = computed(() => props.value ?? internalStep.value)
function handleSetActiveStep(step: number) {
if (props.value === undefined) {
internalStep.value = step
}
emit("update:value", step)
}
// Регистрация/снятие триггеров для клавиатурной навигации — перенесено
// буква в букву из оригинального setTriggerNodes(prev => ...), включая
// ветку удаления узла, которая в оригинале не срабатывает никогда (см.
// StepperTrigger.vue: registerTrigger вызывается только с непустым узлом).
const triggerNodes = ref<HTMLButtonElement[]>([])
function registerTrigger(node: HTMLButtonElement | null) {
const prev = triggerNodes.value
if (node && !prev.includes(node)) {
triggerNodes.value = [...prev, node]
} else if (!node && prev.includes(node as unknown as HTMLButtonElement)) {
triggerNodes.value = prev.filter((n) => n !== node)
}
}
function focusTrigger(idx: number) {
triggerNodes.value[idx]?.focus()
}
function focusNext(currentIdx: number) {
focusTrigger((currentIdx + 1) % triggerNodes.value.length)
}
function focusPrev(currentIdx: number) {
focusTrigger(
(currentIdx - 1 + triggerNodes.value.length) % triggerNodes.value.length
)
}
function focusFirst() {
focusTrigger(0)
}
function focusLast() {
focusTrigger(triggerNodes.value.length - 1)
}
// stepsCount — см. комментарий в context.ts.
const itemIds = ref<symbol[]>([])
function registerItem(id: symbol) {
itemIds.value = [...itemIds.value, id]
}
function unregisterItem(id: symbol) {
itemIds.value = itemIds.value.filter((existing) => existing !== id)
}
const stepsCount = computed(() => itemIds.value.length)
const orientation = computed(() => props.orientation)
provide(StepperContextKey, {
activeStep,
setActiveStep: handleSetActiveStep,
stepsCount,
orientation,
registerTrigger,
triggerNodes,
focusNext,
focusPrev,
focusFirst,
focusLast,
registerItem,
unregisterItem,
indicatorSlots: slots,
})
</script>
<template>
<div
role="tablist"
:aria-orientation="props.orientation"
data-slot="stepper"
:class="cn('w-full', props.class)"
:data-orientation="props.orientation"
>
<slot />
</div>
</template>
src/reui/stepper/StepperContent.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useStepper } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
value: number
forceMount?: boolean
}>(),
{
forceMount: false,
}
)
const { activeStep } = useStepper()
</script>
<template>
<div
v-if="props.forceMount || props.value === activeStep"
data-slot="stepper-content"
:data-state="activeStep"
:class="
cn(
'w-full',
props.class,
props.value !== activeStep && props.forceMount && 'hidden'
)
"
:hidden="props.value !== activeStep && props.forceMount"
>
<slot />
</div>
</template>
src/reui/stepper/StepperDescription.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useStepItem } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { state } = useStepItem()
</script>
<template>
<div
data-slot="stepper-description"
:data-state="state"
:class="cn('text-muted-foreground text-sm', props.class)"
>
<slot />
</div>
</template>
src/reui/stepper/StepperIndicator.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed } from "vue"
import { cn } from "@/lib/utils"
import { useStepItem, useStepper } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { state, isLoading } = useStepItem()
const stepper = useStepper()
/**
* Расхождение с оригиналом (см. context.ts): вместо пропа `indicators`
* (объект ReactNode) — именованные слоты Stepper'а. Порядок приоритета
* веток и откат на children/`<slot />` — те же, что и в оригинале.
*/
const indicatorSlot = computed(() => {
const slots = stepper.indicatorSlots
return (
(isLoading.value && slots["indicator-loading"]) ||
(state.value === "completed" && slots["indicator-completed"]) ||
(state.value === "active" && slots["indicator-active"]) ||
(state.value === "inactive" && slots["indicator-inactive"]) ||
undefined
)
})
</script>
<template>
<div
data-slot="stepper-indicator"
:data-state="state"
:class="
cn(
'border-background bg-accent text-accent-foreground data-[state=completed]:bg-primary data-[state=completed]:text-primary-foreground data-[state=active]:bg-primary data-[state=active]:text-primary-foreground relative flex size-6 shrink-0 items-center justify-center overflow-hidden',
'rounded-full text-xs',
props.class
)
"
>
<div class="absolute">
<component :is="indicatorSlot" v-if="indicatorSlot" />
<slot v-else />
</div>
</div>
</template>
src/reui/stepper/StepperItem.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { computed, onBeforeUnmount, onMounted, provide } from "vue"
import { cn } from "@/lib/utils"
import { StepItemContextKey, useStepper, type StepState } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
step: number
completed?: boolean
disabled?: boolean
loading?: boolean
}>(),
{
completed: false,
disabled: false,
loading: false,
}
)
const stepper = useStepper()
const state = computed<StepState>(() => {
if (props.completed || props.step < stepper.activeStep.value) {
return "completed"
}
return stepper.activeStep.value === props.step ? "active" : "inactive"
})
const isLoading = computed(
() => props.loading && props.step === stepper.activeStep.value
)
const isDisabled = computed(() => props.disabled)
provide(StepItemContextKey, {
step: props.step,
state,
isDisabled,
isLoading,
})
// stepsCount на Stepper'е считается через регистрацию — см. context.ts.
const registrationId = Symbol("stepper-item")
onMounted(() => stepper.registerItem(registrationId))
onBeforeUnmount(() => stepper.unregisterItem(registrationId))
</script>
<template>
<div
data-slot="stepper-item"
:class="
cn(
'group/step flex items-center justify-center not-last:flex-1 group-data-[orientation=horizontal]/stepper-nav:flex-row group-data-[orientation=vertical]/stepper-nav:flex-col',
props.class
)
"
:data-state="state"
:data-loading="isLoading ? true : undefined"
>
<slot />
</div>
</template>
src/reui/stepper/StepperNav.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useStepper } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { activeStep, orientation } = useStepper()
</script>
<template>
<nav
data-slot="stepper-nav"
:data-state="activeStep"
:data-orientation="orientation"
:class="
cn(
'group/stepper-nav inline-flex data-[orientation=horizontal]:w-full data-[orientation=horizontal]:flex-row data-[orientation=vertical]:flex-col',
props.class
)
"
>
<slot />
</nav>
</template>
src/reui/stepper/StepperPanel.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useStepper } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { activeStep } = useStepper()
</script>
<template>
<div
data-slot="stepper-panel"
:data-state="activeStep"
:class="cn('w-full', props.class)"
>
<slot />
</div>
</template>
src/reui/stepper/StepperSeparator.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useStepItem } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { state } = useStepItem()
</script>
<template>
<div
data-slot="stepper-separator"
:data-state="state"
:class="
cn(
'bg-muted style-vega:rounded-sm style-nova:rounded-sm style-maia:rounded-full style-lyra:rounded-none style-mira:rounded-sm style-luma:rounded-full style-rhea:rounded-full style-sera:rounded-none m-0.5 group-data-[orientation=horizontal]/stepper-nav:h-0.5 group-data-[orientation=horizontal]/stepper-nav:flex-1 group-data-[orientation=vertical]/stepper-nav:h-12 group-data-[orientation=vertical]/stepper-nav:w-0.5',
props.class
)
"
/>
</template>
src/reui/stepper/StepperTitle.vue
<script setup lang="ts">
import type { HTMLAttributes } from "vue"
import { cn } from "@/lib/utils"
import { useStepItem } from "./context"
const props = defineProps<{ class?: HTMLAttributes["class"] }>()
const { state } = useStepItem()
</script>
<template>
<h3
data-slot="stepper-title"
:data-state="state"
:class="cn('text-sm leading-none font-medium', props.class)"
>
<slot />
</h3>
</template>
src/reui/stepper/StepperTrigger.vue
<script setup lang="ts">
/**
* `asChild` из оригинала (Radix `Slot`) -> Reka UI `Primitive` с
* `as-child`, как договорено в docs/PORTING.md. Реальный DOM-узел (нужен
* и для регистрации в клавиатурной навигации, и для .focus()) достаётся
* через useForwardExpose() — стандартный хелпер Reka UI для получения
* реального элемента из-под `Primitive`/`asChild` (см. напр. DrawerClose.vue
* в самом Reka UI).
*/
import type { HTMLAttributes } from "vue"
import { Primitive, useForwardExpose } from "reka-ui"
import { computed, onMounted } from "vue"
import { cn } from "@/lib/utils"
import { useStepItem, useStepper } from "./context"
const props = withDefaults(
defineProps<{
class?: HTMLAttributes["class"]
asChild?: boolean
}>(),
{
asChild: false,
}
)
const { state, isLoading, step, isDisabled } = useStepItem()
const stepper = useStepper()
const isSelected = computed(() => stepper.activeStep.value === step)
const id = `stepper-tab-${step}`
const panelId = `stepper-panel-${step}`
const { forwardRef, currentElement } = useForwardExpose()
// Регистрация этого триггера для клавиатурной навигации. В оригинале
// это useEffect БЕЗ функции очистки — узел регистрируется один раз при
// маунте и никогда не снимается с учёта, даже когда компонент
// размонтирован. Это дословно повторено здесь (см. также
// registerTrigger в Stepper.vue: ветка удаления узла оттуда в
// результате не задействуется).
onMounted(() => {
if (currentElement.value) {
stepper.registerTrigger(currentElement.value as HTMLButtonElement)
}
})
const myIdx = computed(() =>
stepper.triggerNodes.value.findIndex((n) => n === currentElement.value)
)
function handleKeyDown(e: KeyboardEvent) {
switch (e.key) {
case "ArrowRight":
case "ArrowDown":
e.preventDefault()
if (myIdx.value !== -1) stepper.focusNext(myIdx.value)
break
case "ArrowLeft":
case "ArrowUp":
e.preventDefault()
if (myIdx.value !== -1) stepper.focusPrev(myIdx.value)
break
case "Home":
e.preventDefault()
stepper.focusFirst()
break
case "End":
e.preventDefault()
stepper.focusLast()
break
case "Enter":
case " ":
e.preventDefault()
stepper.setActiveStep(step)
break
}
}
</script>
<template>
<Primitive
:ref="forwardRef"
as="button"
:as-child="props.asChild"
role="tab"
:id="id"
:aria-selected="isSelected"
:aria-controls="panelId"
:tabindex="isSelected ? 0 : -1"
data-slot="stepper-trigger"
:data-state="state"
:data-loading="isLoading"
:class="
cn(
'focus-visible:border-ring focus-visible:ring-ring/50 inline-flex cursor-pointer items-center outline-none focus-visible:z-10 focus-visible:ring-3 disabled:pointer-events-none disabled:opacity-60',
'gap-2.5 rounded-full',
props.class
)
"
:disabled="isDisabled"
@click="stepper.setActiveStep(step)"
@keydown="handleKeyDown"
>
<slot />
</Primitive>
</template>
src/reui/stepper/context.ts
import type { ComputedRef, InjectionKey, Ref, Slots } from "vue"
import { inject } from "vue"
/**
* Соответствие React `createContext`/`useContext` из stepper.tsx.
* Два независимых контекста в оригинале — StepperContext (на весь
* Stepper) и StepItemContext (на конкретный StepperItem) — здесь два
* provide/inject-ключа с одноимёнными композаблами useStepper()/
* useStepItem(), которые бросают те же сообщения об ошибке при
* использовании вне провайдера.
*/
export type StepperOrientation = "horizontal" | "vertical"
export type StepState = "active" | "completed" | "inactive" | "loading"
export interface StepperContextValue {
activeStep: ComputedRef<number>
setActiveStep: (step: number) => void
/**
* В оригинале считается через `Children.toArray(children)` и фильтр по
* `displayName === "StepperItem"` при каждом рендере. Во Vue у
* компонентов нет `displayName`, поэтому счётчик реализован через
* регистрацию: каждый StepperItem вызывает registerItem()/unregisterItem()
* в onMounted/onBeforeUnmount (см. StepperItem.vue).
*/
stepsCount: ComputedRef<number>
orientation: ComputedRef<StepperOrientation>
registerTrigger: (node: HTMLButtonElement | null) => void
triggerNodes: Ref<HTMLButtonElement[]>
focusNext: (currentIdx: number) => void
focusPrev: (currentIdx: number) => void
focusFirst: () => void
focusLast: () => void
registerItem: (id: symbol) => void
unregisterItem: (id: symbol) => void
/**
* Расхождение с оригиналом (осознанное, см. docs/PORTING.md): React
* принимает проп `indicators` — объект ReactNode на каждое состояние
* индикатора. У Vue нет прямого аналога "пропа с готовыми нодами",
* поэтому индикаторы задаются именованными слотами Stepper'а
* (`indicator-active`, `indicator-completed`, `indicator-inactive`,
* `indicator-loading`). Сюда прокидывается сырой объект слотов
* Stepper'а (useSlots()), а StepperIndicator сам выбирает нужный слот
* по своему состоянию — с тем же порядком приоритета, что и в
* оригинале, и с тем же откатом на children/`<slot />`, если
* подходящего слота нет.
*/
indicatorSlots: Slots
}
export interface StepItemContextValue {
step: number
state: ComputedRef<StepState>
isDisabled: ComputedRef<boolean>
isLoading: ComputedRef<boolean>
}
export const StepperContextKey: InjectionKey<StepperContextValue> =
Symbol("StepperContext")
export const StepItemContextKey: InjectionKey<StepItemContextValue> =
Symbol("StepItemContext")
export function useStepper(): StepperContextValue {
const context = inject(StepperContextKey)
if (!context) {
throw new Error("useStepper must be used within a Stepper")
}
return context
}
export function useStepItem(): StepItemContextValue {
const context = inject(StepItemContextKey)
if (!context) {
throw new Error("useStepItem must be used within a StepperItem")
}
return context
}
src/reui/stepper/index.ts
export { default as Stepper } from "./Stepper.vue"
export { default as StepperContent } from "./StepperContent.vue"
export { default as StepperDescription } from "./StepperDescription.vue"
export { default as StepperIndicator } from "./StepperIndicator.vue"
export { default as StepperItem } from "./StepperItem.vue"
export { default as StepperNav } from "./StepperNav.vue"
export { default as StepperPanel } from "./StepperPanel.vue"
export { default as StepperSeparator } from "./StepperSeparator.vue"
export { default as StepperTitle } from "./StepperTitle.vue"
export { default as StepperTrigger } from "./StepperTrigger.vue"
export {
useStepItem,
useStepper,
StepItemContextKey,
StepperContextKey,
type StepItemContextValue,
type StepperContextValue,
type StepperOrientation,
type StepState,
} from "./context"
Установка
npx shadcn-vue@latest add https://revueui.rootapi.dev/r/stepper.jsonЗависимости реестра
npm-зависимости
- reka-ui
Источник: порт из ReUI (Keenthemes, MIT)