GrSteps
Берут, когда оформление заказа или регистрация.
Когда брать
- оформление заказа или регистрация — несколько экранов с формой, между которыми надо ходить и видеть, сколько осталось;
- импорт данных — загрузка, сопоставление колонок, предпросмотр, запуск: каждый этап зависит от предыдущего;
- длинная форма, разбитая на этапы — когда одна страница на сорок полей
пугает, а
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
variant | GrStepsVariant | undefined | undefined | Компактный вид для узкой колонки: подпись текущего шага и полоса прогресса. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
orientation | GrStepsOrientation | undefined | undefined | — |
clickable | boolean | undefined | undefined | Переход кликом по шагу. Выключен — лента становится только индикатором. |
linear | boolean | undefined | undefined | Вперёд — не дальше первого непройденного. Назад свободно всегда: вернуться и поправить заполненное — обычный сценарий, а не обход правила. |
beforeLeave | ((from: string, to: string) => boolean | Promise<boolean>) | undefined | undefined | Гейт перехода. Вернул `false` — перехода нет. Сюда потребитель кладёт валидацию шага: `GrSteps` про `GrForm` ничего не знает и знать не должен, а форма уже отдаёт `validate()`/`validateField()`. |
modelValueобязательный | string | — | — |
stepsобязательный | GrStep[] | — | — |
Slots
| Slot | Type | Описание |
|---|---|---|
step | { step: GrStep; index: number; status: GrStepStatus; enterable: boolean; } | Своя разметка пункта. Корневой тег, `aria-current` и клик остаются за компонентом — иначе доступность шага пришлось бы собирать заново. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: string] | — |
change | [value: string] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
next | () => Promise<boolean> | — |
back | () => Promise<boolean> | — |
goTo | (value: string) => Promise<boolean> | — |
isFirst | boolean | — |
isLast | boolean | — |
Примеры 3
Мастер с проверкой шага
Шаг не отпускает, пока его поля не сойдутся; шаг с ошибкой помечен.
<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.
Горизонтально и вертикально
Лента для шапки мастера и колонка для боковой панели.
<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>Компактный вид
Семь этапов в узкой колонке: подпись, счётчик и полоса вместо ленты.
<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; кольцо здесь ходило бы в основном по недоступному