feat: Stepper (навигация по шагам, логика в stepperLogic) — 0.5.0

This commit is contained in:
Тимур Городецкий
2026-08-17 09:08:00 +05:00
parent e3a9b5360c
commit d7bebe0a2f
12 changed files with 288 additions and 10 deletions
+71
View File
@@ -0,0 +1,71 @@
import * as React from 'react';
import styles from './Stepper.module.scss';
import { Icon } from '../Icon/Icon';
import { IStepperStep, highestReachableIndex } from './stepperLogic';
// Логику и тип держим в stepperLogic.ts (чистые, тестируемые), здесь — реэкспорт
// для единой точки импорта компонента.
export type { IStepperStep } from './stepperLogic';
export { highestReachableIndex } from './stepperLogic';
export interface IStepperProps {
steps: IStepperStep[];
/** Текущий (активный) шаг. */
currentId: string | number;
/** Завершённые шаги (показываются галочкой). */
completedIds: Array<string | number>;
/**
* Максимально достигнутый шаг. Кликабельны шаги ДО него включительно —
* защита от «прыжка вперёд» на шаг, до которого ещё не дошли.
*/
highestId: string | number;
onStepClick: (id: string | number) => void;
}
/**
* Степпер прогресса: кружки с номером/галочкой, подпись, коннекторы между.
* Кликабельны только достигнутые шаги (см. highestId). Презентационный и
* управляемый: состояние (current/completed/highest) приходит пропсами, клик —
* наружу. Шапку формы (номер/статус) держать ОТДЕЛЬНО (FormHeader), не тут.
*/
export function Stepper({ steps, currentId, completedIds, highestId, onStepClick }: IStepperProps): JSX.Element {
const highestIdx = highestReachableIndex(steps, highestId);
return (
<nav className={styles.stepper} aria-label="Прогресс">
{steps.map((s, index) => {
const isCompleted = completedIds.indexOf(s.id) !== -1;
const isCurrent = s.id === currentId;
const isNavigable = index <= highestIdx;
const circleClass = [
styles.circle,
isCompleted ? styles.completed : '',
isCurrent ? styles.current : ''
]
.filter(Boolean)
.join(' ');
return (
<React.Fragment key={s.id}>
{index > 0 && <span className={styles.connector} aria-hidden="true" />}
<button
type="button"
className={styles.step}
disabled={!isNavigable}
aria-current={isCurrent ? 'step' : undefined}
onClick={() => onStepClick(s.id)}
>
<span className={circleClass}>
{isCompleted && !isCurrent ? <Icon name="check" size="sm" /> : index + 1}
</span>
<span className={[styles.label, isCurrent ? styles.labelCurrent : ''].filter(Boolean).join(' ')}>
{s.label}
</span>
</button>
</React.Fragment>
);
})}
</nav>
);
}