GrSteps
Shows where the user is in a multi-step process and can hold them back until the step checks out.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- a checkout or a registration — several screens with a form, between which one has to move while seeing how much is left;
- an import of data — the upload, the mapping of columns, the preview, the run: every stage depends on the previous one;
- a long form broken into stages — when a single page of forty fields frightens and
GrFormSectionno longer saves it; - a process with a check at every step —
beforeLeavewill not let you go further until the current step adds up.
When to take something else
| Need | Take |
|---|---|
| Switch the panels of one screen, the order does not matter | GrTabs |
| Show a history of events rather than go through a process | GrTimeline |
| Show where the user is in a hierarchy of pages | GrBreadcrumbs |
| The share of what is done as a number, without stages | GrProgressBar / GrProgressCircle |
| Group the fields of one form with headings | GrFormSection |
The component does not draw the content of a step
The wizard has no panel and will not have one. In tabs it is justified by role="tabpanel" and the
aria-labelledby pairing, while here the content of a step is an ordinary v-if in the consumer’s
markup, and a separate component would add a second entity with a synchronised idBase for the
sake of zero semantics.
<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>The validation of a step goes through `beforeLeave` rather than through knowledge of the form
GrSteps knows nothing about GrForm: it does not read its context and does not
climb into its fields. The check is set by the application — into the gate that blocks the
transition:
async function validateStep(from: string, to: string): Promise<boolean> {
// Going back is always allowed: editing what is filled in must not run into validation.
if (stepIndex(to) < stepIndex(from))
return true
return formRef.value!.validate()
}
A click on a step, next(), back() and goTo() go through the gate — everything the component
initiates itself. A direct change of v-model from the outside does not go through it: that is
already a decision of the application, and intercepting it would be a lie.
The `error` status is set from the outside
Three statuses are inferred from the position: before the current one it is complete, the current
one is current, after it upcoming. The fourth, error, has nowhere to be inferred from: an
error lives in the form rather than in the order of the steps. Set it yourself when a step has been
passed but does not add up — otherwise the wizard cannot say “there are still errors on the second
step”.
A step with an error does not count as the edge of what has been passed: with linear it will not
let you go past it.
`linear` limits movement forward only
Going back is always allowed — returning to fix what was filled in is an edit rather than a
circumvention of the rule. Forward — no further than the first unpassed one, but steps already
passed that lie ahead remain reachable: mark them status: 'complete', and the user will be able
to jump back to the step they came back from without going through everything again.
A compact variant for a narrow column
Seven steps do not fit into a side panel and do not fit onto a phone.
variant="compact" shows the label of the current step, a counter and a bar instead of the strip.
The bar is decorative (aria-hidden): the same thing has already been said by the text beside it
and by a hidden live region, and a second progressbar in the tree would make a screen reader read
the progress twice.
Limits
- the component has no “Back” and “Next” buttons of its own. They are placed by the application
from
GrButtonand callnext()/back()through a ref: in a wizard they live in the footer, together with “Cancel” and “Save draft”, and drawing them inside the indicator would mean dictating the layout of the page; - the component does not store the data of the steps — the model of the form is entirely yours;
- the horizontal strip does not scroll. Many steps in a narrow place is
variant="compact"rather than a scroller half of whose stages are invisible.
Playground 5
Loading…
<GrSteps />Install
npm i @feugene/granularityImport
import { GrSteps } from '@feugene/granularity/components/GrSteps'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
variant | GrStepsVariant | undefined | undefined | A compact look for a narrow column: the label of the current step and a progress bar. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
orientation | GrStepsOrientation | undefined | undefined | — |
clickable | boolean | undefined | undefined | A move by a click on a step. Off and the ribbon becomes an indicator only. |
linear | boolean | undefined | undefined | Forward — no further than the first one not passed. Backwards is always free: going back and correcting what has been filled in is an ordinary scenario rather than a way round the rule. |
beforeLeave | ((from: string, to: string) => boolean | Promise<boolean>) | undefined | undefined | The gate of the move. Returned `false` — there is no move. This is where the consumer puts the validation of the step: `GrSteps` knows nothing about `GrForm` and should not, and the form already offers `validate()`/`validateField()`. |
modelValuerequired | string | — | — |
stepsrequired | GrStep[] | — | — |
Slots
| Slot | Type | Description |
|---|---|---|
step | { step: GrStep; index: number; status: GrStepStatus; enterable: boolean; } | Markup of your own for an item. The root tag, `aria-current` and the click stay with the component — otherwise the accessibility of a step would have to be assembled anew. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: string] | — |
change | [value: string] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
next | () => Promise<boolean> | — |
back | () => Promise<boolean> | — |
goTo | (value: string) => Promise<boolean> | — |
isFirst | boolean | — |
isLast | boolean | — |
Examples 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>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>Accessibility
- APG pattern
—