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
+ }
+});