GrSteps

Пакет: @feugene/granularityядроГруппа: Навигация

Берут, когда оформление заказа или регистрация.

Когда брать

  • оформление заказа или регистрация — несколько экранов с формой, между которыми надо ходить и видеть, сколько осталось;
  • импорт данных — загрузка, сопоставление колонок, предпросмотр, запуск: каждый этап зависит от предыдущего;
  • длинная форма, разбитая на этапы — когда одна страница на сорок полей пугает, а GrFormSection уже не спасает;
  • процесс с проверкой на каждом шагеbeforeLeave не пустит дальше, пока текущий шаг не сойдётся.

Когда взять другое

НужноБерите
Переключить панели одного экрана, порядок не важенGrTabs
Показать историю событий, а не пройти процессGrTimeline
Показать, где пользователь в иерархии страницGrBreadcrumbs
Долю выполненного числом, без этаповGrProgressBar / GrProgressCircle
Сгруппировать поля одной формы заголовкамиGrFormSection

Это навигация, а не вкладки

Корень — <nav> со списком <ol>, и ролей на пунктах нет вовсе. role="tab" здесь неприменим: tablist без tabpanel — сломанный паттерн, а панель шага никакой роли не несёт, это обычная разметка приложения.

Отсюда и клавиатура: каждый доступный шаг — своя остановка Tab, стрелки не задействованы. Правило «составной виджет — одна остановка» относится к виджетам, выбирающим значение (GrTabs, GrSegmented); шаги ближе к GrBreadcrumbs и GrBottomNav, где каждый пункт табируется отдельно. Практическое соображение то же: будущие шаги недоступны, и кольцо стрелок ходило бы в основном по выключенному.

Три состояния дают три разные разметки:

ШагТегМетка
пройденный, доступный<button>
текущий<span>aria-current="step"
будущий или выключенный<span> вне таб-порядкаaria-disabled у выключенного

Контент шага компонент не рисует

Панели у мастера нет и не будет. У вкладок она оправдана role="tabpanel" и связкой aria-labelledby, а здесь содержимое шага — обычный v-if в разметке потребителя, и отдельный компонент добавил бы вторую сущность с синхронизируемым idBase ради нуля семантики.

<GrSteps v-model="step" :steps="steps" />

<GrForm v-if="step === 'delivery'" :model="form" :rules="rules">

</GrForm>

<GrForm v-else-if="step === 'payment'" :model="form" :rules="rules">

</GrForm>

Валидация шага — через `beforeLeave`, а не через знание о форме

GrSteps про GrForm ничего не знает: он не читает её контекст и не лезет в её поля. Проверку ставит приложение — в гейт, который блокирует переход:

async function validateStep(from: string, to: string): Promise<boolean> {
  // Назад пускаем всегда: правка заполненного не должна упираться в валидацию.
  if (stepIndex(to) < stepIndex(from))
    return true
  return formRef.value!.validate()
}

Через гейт идут клик по шагу, next(), back() и goTo() — всё, что инициирует сам компонент. Прямая смена v-model снаружи через него не идёт: это уже решение приложения, и перехватывать его было бы враньём.

Статус `error` ставится снаружи

Три статуса выводятся из позиции: до текущего — complete, текущий — current, после — upcoming. Четвёртый, error, вывести неоткуда: ошибка живёт в форме, а не в порядке шагов. Ставьте его сами, когда шаг пройден, но не сошёлся, — иначе мастер не умеет сказать «на втором шаге остались ошибки».

Шаг с ошибкой не считается краем пройденного: при linear дальше него не пустит.

`linear` ограничивает только движение вперёд

Назад можно всегда — вернуться и поправить заполненное это правка, а не обход правила. Вперёд — не дальше первого непройденного, но уже пройденные шаги впереди остаются достижимыми: пометьте их status: 'complete', и пользователь сможет прыгнуть обратно на тот шаг, откуда вернулся, не проходя всё заново.

Компактный вариант для узкой колонки

Семь шагов не помещаются в боковую панель и не помещаются на телефон. variant="compact" показывает подпись текущего шага, счётчик и полосу вместо ленты. Полоса декоративна (aria-hidden): то же самое уже сказано текстом рядом и скрытым живым регионом, а второй progressbar в дереве заставил бы диктора прочитать прогресс дважды.

Границы

  • своих кнопок «Назад» и «Далее» у компонента нет. Их ставит приложение из GrButton и зовёт next()/back() через ref: у мастера они живут в подвале, вместе с «Отменить» и «Сохранить черновик», и рисовать их внутри индикатора значило бы диктовать раскладку страницы;
  • компонент не хранит данные шагов — модель формы целиком ваша;
  • горизонтальная лента не прокручивается. Много шагов в узком месте — это variant="compact", а не скроллер, из которого половина этапов не видна.

Playground 5

Загружается…

Код
<GrSteps />

Установка

npm i @feugene/granularity

Импорт

import { GrSteps } from '@feugene/granularity/components/GrSteps'

API

Props

PropTypeпо умолчаниюОписание
variantGrStepsVariant | undefinedundefinedКомпактный вид для узкой колонки: подпись текущего шага и полоса прогресса.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
orientationGrStepsOrientation | undefinedundefined
clickableboolean | undefinedundefinedПереход кликом по шагу. Выключен — лента становится только индикатором.
linearboolean | undefinedundefinedВперёд — не дальше первого непройденного. Назад свободно всегда: вернуться и поправить заполненное — обычный сценарий, а не обход правила.
beforeLeave((from: string, to: string) => boolean | Promise<boolean>) | undefinedundefinedГейт перехода. Вернул `false` — перехода нет. Сюда потребитель кладёт валидацию шага: `GrSteps` про `GrForm` ничего не знает и знать не должен, а форма уже отдаёт `validate()`/`validateField()`.
modelValueобязательныйstring
stepsобязательныйGrStep[]

Slots

SlotTypeОписание
step{ step: GrStep; index: number; status: GrStepStatus; enterable: boolean; }Своя разметка пункта. Корневой тег, `aria-current` и клик остаются за компонентом — иначе доступность шага пришлось бы собирать заново.

Events

EventTypeОписание
update:modelValue[value: string]
change[value: string]

Methods / Expose

Methods / ExposeTypeОписание
next() => Promise<boolean>
back() => Promise<boolean>
goTo(value: string) => Promise<boolean>
isFirstboolean
isLastboolean

Примеры 3

Мастер с проверкой шага

Шаг не отпускает, пока его поля не сойдутся; шаг с ошибкой помечен.

Wizard
<script setup lang="ts">
import { computed, ref, useTemplateRef } from 'vue'

import { GrButton, GrCard, GrForm, GrFormField, GrInput, GrSteps } from '@feugene/granularity'
import type { GrStep } from '@feugene/granularity'

// Мастер оформления: шаг не отпускает, пока его поля не сойдутся.
const steps = ref<GrStep[]>([
  { value: 'contacts', label: 'Контакты', description: 'Куда писать' },
  { value: 'delivery', label: 'Доставка', description: 'Адрес и срок' },
  { value: 'done', label: 'Готово' },
])

const step = ref('contacts')
const model = ref({ email: '', address: '' })

const rules = {
  email: [{ required: true, message: 'Укажите почту' }, { type: 'email' as const, message: 'Похоже на опечатку' }],
  address: [{ required: true, message: 'Укажите адрес' }],
}

const formRef = useTemplateRef('formRef')
const stepsRef = useTemplateRef('stepsRef')

const fieldsByStep: Record<string, string[]> = {
  contacts: ['email'],
  delivery: ['address'],
  done: [],
}

const isLast = computed(() => step.value === 'done')

/**
 * Гейт перехода. `GrSteps` про форму ничего не знает — проверку ставит
 * приложение, а назад пускает всегда: правка заполненного не должна упираться
 * в валидацию.
 */
async function beforeLeave(from: string, to: string): Promise<boolean> {
  const order = steps.value.map(item => item.value)
  if (order.indexOf(to) < order.indexOf(from))
    return true

  const names = fieldsByStep[from] ?? []
  const results = await Promise.all(names.map(name => formRef.value!.validateField(name)))
  const passed = results.every(Boolean)

  // Шаг с ошибкой помечается явно: вывести это из позиции неоткуда.
  steps.value = steps.value.map(item => (item.value === from
    ? { ...item, status: passed ? ('complete' as const) : ('error' as const) }
    : item))

  return passed
}
</script>

<template>
  <GrCard class="grid gap-5 p-5">
    <GrSteps ref="stepsRef" v-model="step" :steps="steps" :before-leave="beforeLeave" />

    <GrForm ref="formRef" :model="model" :rules="rules">
      <GrFormField v-if="step === 'contacts'" name="email" label="Почта">
        <GrInput v-model="model.email" name="email" type="email" placeholder="you@example.com" />
      </GrFormField>

      <GrFormField v-else-if="step === 'delivery'" name="address" label="Адрес">
        <GrInput v-model="model.address" name="address" placeholder="Город, улица, дом" />
      </GrFormField>

      <p v-else class="text-sm text-[var(--gr-muted-fg)]">
        Заказ готов к отправке: {{ model.email }}, {{ model.address }}.
      </p>
    </GrForm>

    <div class="flex justify-end gap-2">
      <GrButton variant="outline" @click="stepsRef?.back()">
        Назад
      </GrButton>
      <GrButton :disabled="isLast" @click="stepsRef?.next()">
        Далее
      </GrButton>
    </div>
  </GrCard>
</template>

GrSteps про GrForm ничего не знает: проверку ставит приложение в beforeLeave.

Горизонтально и вертикально

Лента для шапки мастера и колонка для боковой панели.

Orientation
<script setup lang="ts">
import { ref } from 'vue'

import { GrSteps } from '@feugene/granularity'
import type { GrStep } from '@feugene/granularity'

const steps: GrStep[] = [
  { value: 'upload', label: 'Загрузка', description: 'CSV или XLSX' },
  { value: 'map', label: 'Сопоставление', description: 'Колонки к полям' },
  { value: 'preview', label: 'Предпросмотр', status: 'error' },
  { value: 'run', label: 'Запуск' },
]

const current = ref('map')
</script>

<template>
  <div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_240px]">
    <GrSteps v-model="current" :steps="steps" aria-label="Импорт данных" />

    <GrSteps
      v-model="current"
      :steps="steps"
      orientation="vertical"
      aria-label="Импорт данных, вертикально"
    />
  </div>
</template>

Компактный вид

Семь этапов в узкой колонке: подпись, счётчик и полоса вместо ленты.

Compact
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrCard, GrSteps } from '@feugene/granularity'
import type { GrStep } from '@feugene/granularity'

// Семь этапов не помещаются в боковую колонку: там лента вырождается в подпись
// с полосой, а не в скроллер, из которого половина шагов не видна.
const steps: GrStep[] = [
  { value: 'a', label: 'Организация' },
  { value: 'b', label: 'Реквизиты' },
  { value: 'c', label: 'Сотрудники' },
  { value: 'd', label: 'Роли' },
  { value: 'e', label: 'Интеграции' },
  { value: 'f', label: 'Уведомления' },
  { value: 'g', label: 'Проверка' },
]

const current = ref('c')
const stepsRef = ref<InstanceType<typeof GrSteps> | null>(null)
</script>

<template>
  <GrCard class="grid max-w-xs gap-4 p-4">
    <GrSteps ref="stepsRef" v-model="current" :steps="steps" variant="compact" />

    <div class="flex gap-2">
      <GrButton size="sm" variant="outline" @click="stepsRef?.back()">
        Назад
      </GrButton>
      <GrButton size="sm" @click="stepsRef?.next()">
        Далее
      </GrButton>
    </div>
  </GrCard>
</template>

Доступность

Паттерн APG
Клавиши
Своего кольца стрелок нет: это навигация, а не составной виджет выбора. Каждый доступный шаг — своя остановка Tab, Enter/Space активируют, недоступный и выключенный из обхода выпадают. Тем же живут GrBreadcrumbs и GrBottomNav; кольцо здесь ходило бы в основном по недоступному

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты