Перейти к содержимому

steps шаги —— Навигация

Шаги — это «индикатор прогресса процесса»: «Корзина → Заполнение данных → Оплата → Готово» в корзине покупок, форма-мастер. Отличие от tabs — в семантике порядка: шаги идут строго друг за другом, а состояние выводится из позиции (выполнен/выполняется/ожидает). core/steps отвечает за вывод состояния и за одну легко допускающую ошибку деталь раскладки: при горизонтальном переносе шагов соединительную линию в конце строки нужно скрыть.

  • Реализация <LhSteps> / <LhStep> на всех концах: процесс заказа, форма-мастер, прогресс задачи, многошаговая настройка.
  • Процесс имеет чёткий порядок, и пользователю нужно знать «на каком мы этапе».
  • Не следует использовать: переключение однорангового содержимого (используйте tabs — нет семантики порядка); простой прогресс в процентах (используйте progress); иерархическая навигация (используйте menu или breadcrumb).
import {
getStepStatus,
getStepsClassNames,
getStepClassNames,
getStepsLineLastIndices,
getStepAria,
} from '@lh-ui/share/core/steps'
import type {
LhStepStatus, LhStepsDirection, LhStepStatusState, LhStepClassState,
} from '@lh-ui/share/core/steps'

Состояние выводится из позиции: основное правило getStepStatus — index < current → finish, === → process, > → wait. В большинстве сценариев достаточно передать current, и состояния вычисляются автоматически — UI-слою не нужно явно объявлять состояние каждого шага.

forced — это общее переопределение: состояние forced уровня Steps (например, error) делает все шаги такими. Это семантика «процесс целиком failed»: при неудаче оплаты неудачей считается не один шаг, а весь процесс становится недоступным для продолжения. Обратите внимание: его приоритет наивысший (возврат идёт до вывода состояния из позиции).

Последний элемент не получает класс — это берёт на себя :last-child: самое тонкое место модуля. В комментарии указана причина: дочерние элементы рендерятся на стадии своего собственного setup, и в этот момент последующие братья ещё не зарегистрированы, поэтому isLast, вычисленный по общему числу зарегистрированных, на первом экране (включая SSR/SSG) ошибочно посчитает последним каждый элемент. Структурный селектор :last-child вычисляется при отрисовке CSS и потому всегда корректен — расплата в том, что стиль соединительной линии должен быть реализован через структурный псевдокласс.

getStepsLineLastIndices решает проблему переноса: когда горизонтальные шаги фиксированной ширины переносятся на несколько строк, соединительная линия последнего элемента каждой строки будет указывать на пустое место в конце строки (начало следующей строки) — визуальный разрыв. Функция находит точки переноса по разнице offsetTop соседних элементов: offsetTops[i+1] > offsetTops[i] означает, что i+1 перешёл на следующую строку, и i — конец строки. UI-слой добавляет элементу в конце строки (включая глобально последний) класс line-last, скрывающий соединительную линию.

lineLast и isLast — разные вещи: isLast (глобально последний) берёт на себя :last-child; lineLast (элемент в конце строки) вычисляется — UI-слой после рендера должен прочитать offsetTop и заполнить класс — это нужно только в сценарии горизонтального переноса.

Функция Сигнатура Возврат Назначение
getStepStatus (state: LhStepStatusState) => LhStepStatus состояние переопределение forced / вывод из позиции
getStepsClassNames (direction: LhStepsDirection) => string[] классы направление
getStepClassNames (state: LhStepClassState) => string[] классы состояние + кликабельность + конец строки
getStepsLineLastIndices (offsetTops: number[]) => number[] индексы концов строк поиск переносов в горизонтальном режиме
getStepAria (status: LhStepStatus) => LhAriaAttrs aria-пакет aria-current=“step”
export type LhStepStatus = 'wait' | 'process' | 'finish' | 'error'
export type LhStepsDirection = 'horizontal' | 'vertical'
export interface LhStepStatusState {
index: number // индекс шага (с 0)
current: number // индекс текущего шага
forced?: LhStepStatus // состояние общего переопределения уровня Steps
}
export interface LhStepClassState {
status: LhStepStatus
clickable: boolean
lineLast?: boolean // последний ли элемент в текущей строке (измеряется UI-слоем при горизонтальном переносе)
}
getStepAria('process') // { 'aria-current': 'step' }
getStepAria('finish') // {}
getStepAria('error') // {}
  • aria-current=“step” отмечает текущий шаг: это специальная семантика WAI-ARIA, скринридер озвучивает «текущий шаг» — это стандартнее, чем текстовая подсказка «выполняется». Выводится только в состоянии process.
  • Семантика упорядоченного списка: шаги рекомендуется рендерить через <ol> — семантика порядка встроена, скринридер озвучит «пункт 2 из 4». Если реализация на <div>, добавьте role="list" и каждому элементу role="listitem".
  • Состояние — текст, а не только цвет: галочка для finish, цифра для process, серый для wait — пользователь с дальтонизмом их не различит. Полными будут только заголовок каждого шага («Заполнение данных») в сочетании с значком.
  • Озвучивание шага с ошибкой: состояние ошибки следует в UI-слое выражать через aria-invalid или добавлением после заголовка шага текста «(не выполнено)», одного красного значка недостаточно.
  • Кнопочная семантика кликабельных шагов: при clickable: true заголовок шага следует рендерить как <button> (откат к выполненным шагам), доступный с клавиатуры; некликабельный — обычный текст.
import { getStepStatus, getStepsLineLastIndices } from '@lh-ui/share/core/steps'
getStepStatus({ index: 0, current: 2 }) // 'finish'
getStepStatus({ index: 2, current: 2 }) // 'process'
getStepStatus({ index: 3, current: 2 }) // 'wait'
getStepStatus({ index: 0, current: 2, forced: 'error' }) // 'error' (forced переопределяет все)
getStepStatus({ index: 2, current: 2, forced: 'wait' }) // 'wait'
// Горизонтальный перенос (по 2 элемента в строке, 4 элемента в 2 строки):
getStepsLineLastIndices([10, 10, 60, 60]) // [1, 3] (у индекса 2 offsetTop больше → 1 конец строки; 3 — глобально последний)
getStepsLineLastIndices([10, 10, 10]) // [2] (переноса нет, только глобально последний)
getStepsLineLastIndices([]) // []
// UI-слой: после рендера измеряем offsetTop и заполняем класс lineLast
// (нужно только для горизонтального режима)
const items = ref<HTMLElement[]>([])
const lineLastSet = ref<Set<number>>(new Set())
onMounted(() => {
const offsetTops = items.value.map((el) => el.offsetTop)
lineLastSet.value = new Set(getStepsLineLastIndices(offsetTops))
})
// Слушаем resize окна: точки переноса меняются с шириной
<!-- Vue3: процесс заказа -->
<template>
<LhSteps :current="1">
<LhStep title="Корзина" description="Выбрано 3 товара" />
<LhStep title="Заполнение данных" description="Адрес доставки и счёт" />
<LhStep title="Оплата" />
<LhStep title="Готово" />
</LhSteps>
</template>
<!-- Общая неудача (при ошибке оплаты весь процесс отмечается красным) -->
<template>
<LhSteps :current="2" status="error">
<LhStep title="Корзина" /><LhStep title="Заполнение данных" />
<LhStep title="Оплата" description="Время оплаты истекло, повторите попытку" />
<LhStep title="Готово" />
</LhSteps>
</template>
// React: useLayoutEffect для измерения offsetTop (избежание мерцания первого кадра),
// плюс слушатель resize для пересчёта
// uniapp: <view> + flex; при горизонтальном переносе на стороне мини-программ
// top получается через запрос узлов createSelectorQuery
Класс Семантика
.lh-steps корневой контейнер (flex)
.lh-steps--{horizontal|vertical} направление
.lh-steps__item элемент шага
.lh-steps__item--{wait|process|finish|error} состояние (цвет кружка + цвет текста + цвет соединительной линии)
.lh-steps__item--clickable кликабельный (подсветка при hover + указатель)
.lh-steps__item--line-last элемент в конце строки (скрывает линию, ведущую в пустоту)
.lh-steps__circle / __title / __description кружок / заголовок / описание
.lh-steps__line соединительная линия (через :last-child скрывается линия глобально последнего элемента)

Цвета состояний: wait --lh-text-placeholder, process --lh-primary-color, finish --lh-success-color, error --lh-danger-color; соединительная линия обычно --lh-border-color, выполненный отрезок — цветом finish.

  • lineLast нужен только при горизонтальном переносе: в вертикальных шагах каждый элемент занимает строку, и соединительная линия идёт строго вниз — проблемы конца строки нет. Горизонтальные шаги фиксированной ширины (без переноса) тоже не нуждаются в измерении — только когда фиксированная ширина + разрешён перенос.
  • Момент измерения: offsetTop нужно читать после отрисовки DOM (onMounted / useLayoutEffect) и пересчитывать при resize. В первом кадре возможно мерцание (линия сначала показывается, потом скрывается) — это допустимо; в строгих сценариях показ откладывается через visibility.
  • forced — это общее переопределение, а не одного шага: при передаче status = error все шаги становятся error, включая выполненные — это семантика «весь процесс failed». Чтобы отметить только один шаг, передайте этому LhStep отдельный status (UI-слой объединяет пошаговый status в пошаговую версию forced либо напрямую переопределяет класс элемента).
  • current может выходить за границы: при current: 5 и всего 4 шагах все станут finish (все index < 5) — используется для финального отображения «процесс завершён», отдельная проверка не нужна.
  • Ловушка последнего элемента в SSR/SSG: именно поэтому getStepClassNames не принимает параметр isLast. Любая реализация, вычисляющая последний элемент на стадии setup по «общему числу зарегистрированных», ошибётся на первом экране для всех элементов — используйте :last-child.
  • Бизнес-ограничение кликабельного отката: clickable обычно включается только для выполненных шагов (просмотр информации), а текущие и ожидающие шаги недоступны для перехода. Разрешён ли переход на произвольный шаг, определяет бизнес, core выдаёт только имена классов.
  • Пространство под description: у горизонтальных шагов с описанием увеличивается высота, а высота строки при переносе определяется самым высоким элементом — перед измерением offsetTop убедитесь, что все элементы отрисованы.
@lh-ui/share/core/steps → getStepStatus( )
返回值
(填好参数后点运行)

Copyright © 2024-2026 FengMo WenXue