From d7bebe0a2f384280be8f06dff8e31f3443c302be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A2=D0=B8=D0=BC=D1=83=D1=80=20=D0=93=D0=BE=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D1=86=D0=BA=D0=B8=D0=B9?= Date: Mon, 17 Aug 2026 09:08:00 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20Stepper=20(=D0=BD=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=20=D1=88=D0=B0?= =?UTF-8?q?=D0=B3=D0=B0=D0=BC,=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B2=20stepperLogic)=20=E2=80=94=200.5.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT.md | 10 ++++ package.json | 3 +- src/DataTable/DataTable.stories.tsx | 11 ++-- src/Stepper/Stepper.module.scss | 85 +++++++++++++++++++++++++++++ src/Stepper/Stepper.stories.tsx | 48 ++++++++++++++++ src/Stepper/Stepper.tsx | 71 ++++++++++++++++++++++++ src/Stepper/stepperLogic.test.ts | 34 ++++++++++++ src/Stepper/stepperLogic.ts | 18 ++++++ src/index.ts | 2 + tsconfig.json | 2 +- vite.config.ts | 2 +- vitest.config.ts | 12 ++++ 12 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 src/Stepper/Stepper.module.scss create mode 100644 src/Stepper/Stepper.stories.tsx create mode 100644 src/Stepper/Stepper.tsx create mode 100644 src/Stepper/stepperLogic.test.ts create mode 100644 src/Stepper/stepperLogic.ts create mode 100644 vitest.config.ts diff --git a/PROJECT.md b/PROJECT.md index 1602eaa..2da9941 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -140,6 +140,16 @@ Generic Badge в kit (variant: neutral/muted/info/success/warning/danger/primary StatusBadge переписан в тонкий маппер REQUEST_STATUS→variant поверх Badge; StatusBadge.module.scss удалён. Это шаблон для будущего разделения пикеров (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/ StatusBadge — оказались связаны с diagnostics/statuses, отложены) + ConfirmDialog/ diff --git a/package.json b/package.json index a78f76f..47ce760 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tnsu/ui-kit", - "version": "0.4.0", + "version": "0.5.0", "description": "TNSU shared UI components for SPFx web parts", "author": "Timur Gorodetskiy", "license": "ISC", @@ -29,6 +29,7 @@ "scripts": { "build": "vite build", "dev": "vite build --watch", + "test": "vitest run --config vitest.config.ts", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build" }, diff --git a/src/DataTable/DataTable.stories.tsx b/src/DataTable/DataTable.stories.tsx index 61b4871..edaf2b6 100644 --- a/src/DataTable/DataTable.stories.tsx +++ b/src/DataTable/DataTable.stories.tsx @@ -3,6 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; import { DataTable, IDataTableColumn } from './DataTable'; import { Badge } from '../Badge/Badge'; +import { TextInput } from '../TextInput/TextInput'; const meta: Meta = { title: 'Таблицы/DataTable', @@ -72,17 +73,13 @@ export const Editable: StoryObj = { { key: 'name', title: 'Наименование', - render: (r, i) => ( - setCell(i, 'name', e.target.value)} style={{ width: '100%' }} /> - ) + render: (r, i) => setCell(i, 'name', v)} /> }, { key: 'qty', title: 'Кол-во', - align: 'right', - render: (r, i) => ( - setCell(i, 'qty', e.target.value)} style={{ width: '80px' }} /> - ) + width: '120px', + render: (r, i) => setCell(i, 'qty', v)} /> } ]; return ; diff --git a/src/Stepper/Stepper.module.scss b/src/Stepper/Stepper.module.scss new file mode 100644 index 0000000..95896bc --- /dev/null +++ b/src/Stepper/Stepper.module.scss @@ -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; + } +} diff --git a/src/Stepper/Stepper.stories.tsx b/src/Stepper/Stepper.stories.tsx new file mode 100644 index 0000000..738165c --- /dev/null +++ b/src/Stepper/Stepper.stories.tsx @@ -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 = { + title: 'Навигация/Stepper', + component: Stepper, + tags: ['autodocs'] +}; + +export default meta; +type Story = StoryObj; + +// Дошли до шага 3, шаг 1 завершён, сейчас на шаге 2. Клик работает только по +// шагам 1–3 (шаг 4 недоступен — до него ещё не дошли). +export const Interactive: Story = { + render: () => { + const [current, setCurrent] = useState(2); + return ( + + ); + } +}; + +export const FirstStep: Story = { + render: () => ( + undefined} /> + ) +}; + +export const AllDone: Story = { + render: () => ( + undefined} /> + ) +}; diff --git a/src/Stepper/Stepper.tsx b/src/Stepper/Stepper.tsx new file mode 100644 index 0000000..98f9723 --- /dev/null +++ b/src/Stepper/Stepper.tsx @@ -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; + /** + * Максимально достигнутый шаг. Кликабельны шаги ДО него включительно — + * защита от «прыжка вперёд» на шаг, до которого ещё не дошли. + */ + 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 ( + + ); +} diff --git a/src/Stepper/stepperLogic.test.ts b/src/Stepper/stepperLogic.test.ts new file mode 100644 index 0000000..52241d5 --- /dev/null +++ b/src/Stepper/stepperLogic.test.ts @@ -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); + }); +}); diff --git a/src/Stepper/stepperLogic.ts b/src/Stepper/stepperLogic.ts new file mode 100644 index 0000000..af14d2a --- /dev/null +++ b/src/Stepper/stepperLogic.ts @@ -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; +} diff --git a/src/index.ts b/src/index.ts index 56ac7d0..51f7a36 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,6 +48,8 @@ export { confirmModal } 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 type { ISidebarItem, ISidebarProps } from './Sidebar/Sidebar'; export { SidebarToggle } from './Sidebar/SidebarToggle'; diff --git a/tsconfig.json b/tsconfig.json index 5f9544b..5b3d44c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["src/**/*.stories.tsx"] + "exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts"] } diff --git a/vite.config.ts b/vite.config.ts index 81cb3b3..ec115fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ plugins: [react(), dts({ include: ["src"], // истории — только для витрины Storybook, в пакет их типы не кладём - exclude: ["src/**/*.stories.tsx"], + exclude: ["src/**/*.stories.tsx", "src/**/*.test.ts"], insertTypesEntry: true })], build: { diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..3417075 --- /dev/null +++ b/vitest.config.ts @@ -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 + } +});