GrSteps

Package: @feugene/granularitycoreGroup: navigation

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 GrFormSection no longer saves it;
  • a process with a check at every stepbeforeLeave will not let you go further until the current step adds up.

When to take something else

NeedTake
Switch the panels of one screen, the order does not matterGrTabs
Show a history of events rather than go through a processGrTimeline
Show where the user is in a hierarchy of pagesGrBreadcrumbs
The share of what is done as a number, without stagesGrProgressBar / GrProgressCircle
Group the fields of one form with headingsGrFormSection

This is navigation, not tabs

The root is a <nav> with an <ol> list, and there are no roles on the items at all. role="tab" is not applicable here: a tablist without a tabpanel is a broken pattern, and the panel of a step carries no role — it is ordinary markup of the application.

Hence the keyboard as well: every available step is a Tab stop of its own, and the arrows are not involved. The “a composite widget is one stop” rule concerns widgets that select a value (GrTabs, GrSegmented); steps are closer to GrBreadcrumbs and GrBottomNav, where every item is tabbed separately. The practical consideration is the same: the future steps are unavailable, and a ring of arrows would walk mostly over what is switched off.

Three states give three different pieces of markup:

StepThe tagThe mark
passed, available<button>
current<span>aria-current="step"
future or switched off<span> outside the tab orderaria-disabled on a switched-off one

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 GrButton and call next()/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…

Code
<GrSteps />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
variantGrStepsVariant | undefinedundefinedA compact look for a narrow column: the label of the current step and a progress bar.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
orientationGrStepsOrientation | undefinedundefined
clickableboolean | undefinedundefinedA move by a click on a step. Off and the ribbon becomes an indicator only.
linearboolean | undefinedundefinedForward — 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>) | undefinedundefinedThe 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()`.
modelValuerequiredstring
stepsrequiredGrStep[]

Slots

SlotTypeDescription
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

EventTypeDescription
update:modelValue[value: string]
change[value: string]

Methods / Expose

Methods / ExposeTypeDescription
next() => Promise<boolean>
back() => Promise<boolean>
goTo(value: string) => Promise<boolean>
isFirstboolean
isLastboolean

Examples 3

Wizard

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

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

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

Full keyboard contract of the package

Component documentationAll components