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)