feat: Stepper (навигация по шагам, логика в stepperLogic) — 0.5.0
This commit is contained in:
+10
@@ -140,6 +140,16 @@ Generic Badge в kit (variant: neutral/muted/info/success/warning/danger/primary
|
|||||||
StatusBadge переписан в тонкий маппер REQUEST_STATUS→variant поверх Badge; StatusBadge.module.scss
|
StatusBadge переписан в тонкий маппер REQUEST_STATUS→variant поверх Badge; StatusBadge.module.scss
|
||||||
удалён. Это шаблон для будущего разделения пикеров (group 3): вид в kit, привязка к данным в проекте.
|
удалён. Это шаблон для будущего разделения пикеров (group 3): вид в kit, привязка к данным в проекте.
|
||||||
|
|
||||||
|
### Stepper + тесты (не опубликовано, будет 0.5.0)
|
||||||
|
- Добавлен generic `Stepper` (степпер шагов) — эталон kps Breadcrumbs, но чистый (без шапки),
|
||||||
|
логика навигации по индексу (highestReachableIndex, вынесена в stepperLogic.ts — тестируемо).
|
||||||
|
Кликабельны шаги до highestId включительно (защита от прыжка вперёд). Адаптивный (подписи
|
||||||
|
прячутся < sm). Экспортирован из index.
|
||||||
|
- Заведён юнит-тест: `npm test` (vitest, node-окружение, vitest.config.ts — ОТДЕЛЬНО от
|
||||||
|
storybook-browser-проекта в vite.config). Первый тест stepperLogic.test.ts (4 кейса).
|
||||||
|
`*.test.ts` исключены из сборки пакета (vite dts exclude + tsconfig exclude).
|
||||||
|
- ПРАКТИКА тестов: чистую логику выносить в отдельные файлы (без React/scss) и покрывать в node.
|
||||||
|
|
||||||
### Порядок работ
|
### Порядок работ
|
||||||
1. [СДЕЛАНО в 0.1.0] Быстрая пачка (20 компонентов): вся Группа 1 (кроме ErrorOverlay/
|
1. [СДЕЛАНО в 0.1.0] Быстрая пачка (20 компонентов): вся Группа 1 (кроме ErrorOverlay/
|
||||||
StatusBadge — оказались связаны с diagnostics/statuses, отложены) + ConfirmDialog/
|
StatusBadge — оказались связаны с diagnostics/statuses, отложены) + ConfirmDialog/
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@tnsu/ui-kit",
|
"name": "@tnsu/ui-kit",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"description": "TNSU shared UI components for SPFx web parts",
|
"description": "TNSU shared UI components for SPFx web parts",
|
||||||
"author": "Timur Gorodetskiy",
|
"author": "Timur Gorodetskiy",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"dev": "vite build --watch",
|
"dev": "vite build --watch",
|
||||||
|
"test": "vitest run --config vitest.config.ts",
|
||||||
"storybook": "storybook dev -p 6006",
|
"storybook": "storybook dev -p 6006",
|
||||||
"build-storybook": "storybook build"
|
"build-storybook": "storybook build"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { DataTable, IDataTableColumn } from './DataTable';
|
import { DataTable, IDataTableColumn } from './DataTable';
|
||||||
import { Badge } from '../Badge/Badge';
|
import { Badge } from '../Badge/Badge';
|
||||||
|
import { TextInput } from '../TextInput/TextInput';
|
||||||
|
|
||||||
const meta: Meta = {
|
const meta: Meta = {
|
||||||
title: 'Таблицы/DataTable',
|
title: 'Таблицы/DataTable',
|
||||||
@@ -72,17 +73,13 @@ export const Editable: StoryObj = {
|
|||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
title: 'Наименование',
|
title: 'Наименование',
|
||||||
render: (r, i) => (
|
render: (r, i) => <TextInput value={r.name} onChange={(v) => setCell(i, 'name', v)} />
|
||||||
<input value={r.name} onChange={(e) => setCell(i, 'name', e.target.value)} style={{ width: '100%' }} />
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'qty',
|
key: 'qty',
|
||||||
title: 'Кол-во',
|
title: 'Кол-во',
|
||||||
align: 'right',
|
width: '120px',
|
||||||
render: (r, i) => (
|
render: (r, i) => <TextInput value={r.qty} onChange={(v) => setCell(i, 'qty', v)} />
|
||||||
<input value={r.qty} onChange={(e) => setCell(i, 'qty', e.target.value)} style={{ width: '80px' }} />
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
return <DataTable columns={columns} rows={items} />;
|
return <DataTable columns={columns} rows={items} />;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
@import '../tokens/tokens';
|
||||||
|
|
||||||
|
.stepper {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
width: 100%;
|
||||||
|
// Безопасность на любой ширине: если шаги с подписями не влезают — горизонтальная
|
||||||
|
// прокрутка, а не сломанная вёрстка. padding — чтобы золотое свечение текущего
|
||||||
|
// шага не обрезалось областью прокрутки.
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-family: $font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.circle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: $r-full;
|
||||||
|
border: 1.5px solid $gray-300;
|
||||||
|
background: $white;
|
||||||
|
color: $gray-500;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: .18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Завершённый — синий с галочкой.
|
||||||
|
.completed {
|
||||||
|
background: $navy-800;
|
||||||
|
border-color: $navy-800;
|
||||||
|
color: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Текущий — золотой акцент (перебивает completed, если шаг и активный, и завершён).
|
||||||
|
.current {
|
||||||
|
border-color: $gold-500;
|
||||||
|
background: $grad-gold;
|
||||||
|
color: $navy-950;
|
||||||
|
box-shadow: $sh-gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connector {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 2px;
|
||||||
|
margin-top: 15px; // по центру кружка (30/2)
|
||||||
|
background: $gray-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: $fs-caption;
|
||||||
|
color: $gray-500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.labelCurrent {
|
||||||
|
color: $navy-900;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// На узких экранах подписи прячем — остаются кружки с номерами.
|
||||||
|
@include below($bp-sm) {
|
||||||
|
.label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Stepper, IStepperStep } from './Stepper';
|
||||||
|
|
||||||
|
const STEPS: IStepperStep[] = [
|
||||||
|
{ id: 1, label: 'Выбор позиций' },
|
||||||
|
{ id: 2, label: 'Загрузка КП' },
|
||||||
|
{ id: 3, label: 'Формирование' },
|
||||||
|
{ id: 4, label: 'Согласование' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const meta: Meta<typeof Stepper> = {
|
||||||
|
title: 'Навигация/Stepper',
|
||||||
|
component: Stepper,
|
||||||
|
tags: ['autodocs']
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Stepper>;
|
||||||
|
|
||||||
|
// Дошли до шага 3, шаг 1 завершён, сейчас на шаге 2. Клик работает только по
|
||||||
|
// шагам 1–3 (шаг 4 недоступен — до него ещё не дошли).
|
||||||
|
export const Interactive: Story = {
|
||||||
|
render: () => {
|
||||||
|
const [current, setCurrent] = useState<string | number>(2);
|
||||||
|
return (
|
||||||
|
<Stepper
|
||||||
|
steps={STEPS}
|
||||||
|
currentId={current}
|
||||||
|
completedIds={[1]}
|
||||||
|
highestId={3}
|
||||||
|
onStepClick={setCurrent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FirstStep: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Stepper steps={STEPS} currentId={1} completedIds={[]} highestId={1} onStepClick={() => undefined} />
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllDone: Story = {
|
||||||
|
render: () => (
|
||||||
|
<Stepper steps={STEPS} currentId={4} completedIds={[1, 2, 3, 4]} highestId={4} onStepClick={() => undefined} />
|
||||||
|
)
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { highestReachableIndex, IStepperStep } from './stepperLogic';
|
||||||
|
|
||||||
|
const STEPS: IStepperStep[] = [
|
||||||
|
{ id: 1, label: 'Выбор позиций' },
|
||||||
|
{ id: 2, label: 'Загрузка КП' },
|
||||||
|
{ id: 3, label: 'Формирование' },
|
||||||
|
{ id: 4, label: 'Согласование' }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('highestReachableIndex — навигация степпера', () => {
|
||||||
|
it('возвращает индекс достигнутого шага', () => {
|
||||||
|
expect(highestReachableIndex(STEPS, 3)).toBe(2);
|
||||||
|
expect(highestReachableIndex(STEPS, 4)).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('первый шаг → индекс 0', () => {
|
||||||
|
expect(highestReachableIndex(STEPS, 1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('неизвестный id → 0 (безопасный фолбэк, не даём улететь вперёд)', () => {
|
||||||
|
expect(highestReachableIndex(STEPS, 99)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('работает и со строковыми id', () => {
|
||||||
|
const steps: IStepperStep[] = [
|
||||||
|
{ id: 'select', label: 'Выбор' },
|
||||||
|
{ id: 'load', label: 'Загрузка' },
|
||||||
|
{ id: 'build', label: 'Сборка' }
|
||||||
|
];
|
||||||
|
expect(highestReachableIndex(steps, 'load')).toBe(1);
|
||||||
|
expect(highestReachableIndex(steps, 'build')).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Чистая логика степпера — без React и стилей, чтобы покрывать юнит-тестами
|
||||||
|
// в node-окружении (быстро, без браузера).
|
||||||
|
|
||||||
|
export interface IStepperStep {
|
||||||
|
/** Идентификатор шага (число или строка). */
|
||||||
|
id: string | number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индекс максимально доступного шага. Навигация разрешена по шагам с индексом
|
||||||
|
* <= этого. Порядок берём по ПОЗИЦИИ в массиве (а не по значению id) — работает
|
||||||
|
* и для строковых id. Неизвестный id → 0 (безопасный фолбэк на первый шаг).
|
||||||
|
*/
|
||||||
|
export function highestReachableIndex(steps: IStepperStep[], highestId: string | number): number {
|
||||||
|
const idx = steps.findIndex((s) => s.id === highestId);
|
||||||
|
return idx === -1 ? 0 : idx;
|
||||||
|
}
|
||||||
@@ -48,6 +48,8 @@ export { confirmModal } from './ConfirmDialog/confirmModal';
|
|||||||
export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
|
export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
|
||||||
|
|
||||||
// --- Навигация ---
|
// --- Навигация ---
|
||||||
|
export { Stepper, highestReachableIndex } from './Stepper/Stepper';
|
||||||
|
export type { IStepperStep, IStepperProps } from './Stepper/Stepper';
|
||||||
export { Sidebar } from './Sidebar/Sidebar';
|
export { Sidebar } from './Sidebar/Sidebar';
|
||||||
export type { ISidebarItem, ISidebarProps } from './Sidebar/Sidebar';
|
export type { ISidebarItem, ISidebarProps } from './Sidebar/Sidebar';
|
||||||
export { SidebarToggle } from './Sidebar/SidebarToggle';
|
export { SidebarToggle } from './Sidebar/SidebarToggle';
|
||||||
|
|||||||
+1
-1
@@ -13,5 +13,5 @@
|
|||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": ["src"],
|
||||||
"exclude": ["src/**/*.stories.tsx"]
|
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ export default defineConfig({
|
|||||||
plugins: [react(), dts({
|
plugins: [react(), dts({
|
||||||
include: ["src"],
|
include: ["src"],
|
||||||
// истории — только для витрины Storybook, в пакет их типы не кладём
|
// истории — только для витрины Storybook, в пакет их типы не кладём
|
||||||
exclude: ["src/**/*.stories.tsx"],
|
exclude: ["src/**/*.stories.tsx", "src/**/*.test.ts"],
|
||||||
insertTypesEntry: true
|
insertTypesEntry: true
|
||||||
})],
|
})],
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
// Отдельный конфиг для быстрых ЮНИТ-тестов чистой логики (node, без браузера).
|
||||||
|
// Storybook добавил свой browser-проект в vite.config.ts — его не трогаем;
|
||||||
|
// логику гоняем этим конфигом: npm test.
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
css: false
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user