Compare commits

...

4 Commits

Author SHA1 Message Date
Тимур Городецкий 4bdaca7b8f feat: AppLayout (оболочка приложения) — 0.8.0 2026-08-17 10:30:00 +05:00
Тимур Городецкий 6bf198f8f2 feat: Tabs (0.7.0) 2026-08-17 10:17:50 +05:00
Тимур Городецкий 49a7665418 feat: FormHeader (0.6.0) 2026-08-17 09:18:18 +05:00
Тимур Городецкий d7bebe0a2f feat: Stepper (навигация по шагам, логика в stepperLogic) — 0.5.0 2026-08-17 09:08:00 +05:00
21 changed files with 650 additions and 10 deletions
+10
View File
@@ -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/
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@tnsu/ui-kit",
"version": "0.4.0",
"version": "0.8.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"
},
+44
View File
@@ -0,0 +1,44 @@
@import '../tokens/tokens';
.layout {
display: flex;
align-items: stretch; // боковая панель тянется на высоту контента
width: 100%;
min-height: 78vh; // «присутствие» рейла даже когда контента мало
font-family: $font-sans;
}
.side {
flex: 0 0 auto;
display: flex;
}
.main {
flex: 1 1 auto;
min-width: 0; // чтобы контент не распирал колонку
display: flex;
flex-direction: column;
padding: $sp-5 $sp-6 $sp-8;
}
// Бургер: виден только на мобилке (над контентом).
.mobileBar {
display: none;
margin-bottom: $sp-4;
}
// < 768px: боковая панель превращается в drawer (position:fixed внутри Sidebar) —
// колонка .side схлопывается сама, контент на всю ширину. Показываем бургер.
@include below($bp-md) {
.layout {
min-height: 0;
}
.mobileBar {
display: block;
}
.main {
padding: $sp-4;
}
}
+50
View File
@@ -0,0 +1,50 @@
import * as React from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { AppLayout } from './AppLayout';
import { Sidebar, ISidebarItem } from '../Sidebar/Sidebar';
import { SidebarToggle } from '../Sidebar/SidebarToggle';
const ITEMS: ISidebarItem[] = [
{ id: 'ct', title: 'СТ ОМТС', icon: 'clipboard', count: 1 },
{ id: 'oid', title: 'СТ ОИД', icon: 'chart' },
{ id: 'kps', title: 'СТ КПС', icon: 'building' }
];
const meta: Meta<typeof AppLayout> = {
title: 'Раскладка/AppLayout',
component: AppLayout,
tags: ['autodocs']
};
export default meta;
// Оболочка: сайдбар слева + контент справа. Сузь окно < 768px — сайдбар уйдёт в
// drawer, появится бургер, контент займёт всю ширину.
export const SidebarWithContent: StoryObj<typeof AppLayout> = {
render: () => {
const [active, setActive] = useState<string | number>('ct');
const [collapsed, setCollapsed] = useState(false);
const [mobileOpen, setMobileOpen] = useState(false);
return (
<AppLayout
sidebar={
<Sidebar
items={ITEMS}
activeId={active}
onChange={setActive}
brand="Договора"
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((c) => !c)}
mobileOpen={mobileOpen}
onMobileClose={() => setMobileOpen(false)}
/>
}
mobileToggle={<SidebarToggle onClick={() => setMobileOpen(true)} />}
>
<h2 style={{ margin: 0, fontFamily: "'Lora', Georgia, serif", color: '#0F2440' }}>Контентная область</h2>
<p style={{ color: '#6B7280' }}>Здесь — содержимое активного раздела (реестр, форма и т.д.).</p>
</AppLayout>
);
}
};
+29
View File
@@ -0,0 +1,29 @@
import * as React from 'react';
import styles from './AppLayout.module.scss';
export interface IAppLayoutProps {
/** Боковая панель — обычно <Sidebar/>. */
sidebar: React.ReactNode;
/**
* Кнопка-бургер для мобильного drawer — обычно <SidebarToggle/>. Видна только
* на мобилке (< 768px), над контентом. Необязательна.
*/
mobileToggle?: React.ReactNode;
children: React.ReactNode;
}
/**
* Оболочка приложения — один из видов раскладки: боковая панель слева + контентная
* область справа. Переиспользуемый app-shell.
* Адаптивно: на < 768px боковая панель уходит в выезжающий drawer (за это отвечает
* сам Sidebar), контент занимает всю ширину, сверху показывается бургер (mobileToggle).
*/
export const AppLayout: React.FC<IAppLayoutProps> = ({ sidebar, mobileToggle, children }) => (
<div className={styles.layout}>
<div className={styles.side}>{sidebar}</div>
<main className={styles.main}>
{mobileToggle && <div className={styles.mobileBar}>{mobileToggle}</div>}
{children}
</main>
</div>
);
+4 -7
View File
@@ -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) => (
<input value={r.name} onChange={(e) => setCell(i, 'name', e.target.value)} style={{ width: '100%' }} />
)
render: (r, i) => <TextInput value={r.name} onChange={(v) => setCell(i, 'name', v)} />
},
{
key: 'qty',
title: 'Кол-во',
align: 'right',
render: (r, i) => (
<input value={r.qty} onChange={(e) => setCell(i, 'qty', e.target.value)} style={{ width: '80px' }} />
)
width: '120px',
render: (r, i) => <TextInput value={r.qty} onChange={(v) => setCell(i, 'qty', v)} />
}
];
return <DataTable columns={columns} rows={items} />;
+48
View File
@@ -0,0 +1,48 @@
@import '../tokens/tokens';
.header {
display: flex;
align-items: center;
gap: $sp-3;
padding: 14px 20px;
background: $navy-50;
border: 1px solid $navy-100;
border-left: 3px solid $gold-500; // фирменный золотой акцент
border-radius: $r-md;
}
.icon {
flex-shrink: 0;
color: $navy-700;
}
.title {
flex: 1 1 auto;
min-width: 0;
font-family: $font-serif;
font-size: $fs-section-title;
font-weight: 600;
color: $navy-900;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.right {
flex-shrink: 0;
display: flex;
align-items: center;
gap: $sp-2;
}
// Мобилка: разрешаем перенос и заголовку, и правому слоту — ничего не обрезается.
@include below($bp-sm) {
.header {
flex-wrap: wrap;
padding: 12px 14px;
}
.title {
white-space: normal;
}
}
+26
View File
@@ -0,0 +1,26 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { FormHeader } from './FormHeader';
import { Badge } from '../Badge/Badge';
const meta: Meta<typeof FormHeader> = {
title: 'Формы/FormHeader',
component: FormHeader,
tags: ['autodocs'],
args: { title: 'Сравнительная таблица №1' }
};
export default meta;
type Story = StoryObj<typeof FormHeader>;
export const Basic: Story = {};
export const WithStatus: Story = {
args: { right: <Badge variant="info">На согласовании</Badge> }
};
export const LongTitle: Story = {
args: {
title: 'Сравнительная таблица по закупке материалов для строительства жилого дома в п. Топар',
right: <Badge variant="success">Согласована</Badge>
}
};
+24
View File
@@ -0,0 +1,24 @@
import * as React from 'react';
import styles from './FormHeader.module.scss';
import { Icon } from '../Icon/Icon';
import { IconName } from '../Icon/iconNames';
export interface IFormHeaderProps {
title: React.ReactNode;
/** Иконка слева. По умолчанию 'document'. */
icon?: IconName;
/**
* Правый слот — обычно статус-бейдж (Badge), но может быть что угодно
* (номер, кнопки). Смысл статуса задаёт проект, FormHeader про него не знает.
*/
right?: React.ReactNode;
}
/** Шапка формы: золотой акцент слева, иконка, заголовок и правый слот (статус). */
export const FormHeader: React.FC<IFormHeaderProps> = ({ title, icon = 'document', right }) => (
<div className={styles.header}>
<Icon name={icon} className={styles.icon} />
<span className={styles.title}>{title}</span>
{right && <span className={styles.right}>{right}</span>}
</div>
);
+85
View File
@@ -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;
}
}
+48
View File
@@ -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} />
)
};
+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>
);
}
+34
View File
@@ -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);
});
});
+18
View File
@@ -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;
}
+44
View File
@@ -0,0 +1,44 @@
@import '../tokens/tokens';
// Светлая полоса вкладок; активная — navy с золотым подчёркиванием.
// На узких экранах — горизонтальная прокрутка (не ломается, не переносится «лесенкой»).
.tabs {
display: flex;
align-items: stretch;
gap: $sp-2;
border-bottom: 1px solid $gray-200;
overflow-x: auto;
}
.tab {
display: inline-flex;
align-items: center;
gap: $sp-2;
padding: 12px 16px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: $gray-500;
font-family: $font-sans;
font-size: 13px;
font-weight: 500;
letter-spacing: .2px;
white-space: nowrap;
cursor: pointer;
transition: color .18s, border-color .18s;
&:hover {
color: $navy-800;
}
}
.active {
color: $navy-900;
font-weight: 600;
border-bottom-color: $gold-500;
}
.count {
color: $gray-400;
font-weight: 500;
}
+44
View File
@@ -0,0 +1,44 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from 'react';
import { Tabs, ITab } from './Tabs';
const QUEUE: ITab[] = [
{ id: 'pending', label: 'На согласовании у меня', count: 1 },
{ id: 'mine', label: 'Инициализированные мной', count: 71 },
{ id: 'approved', label: 'Согласованные мной', count: 4 }
];
const meta: Meta<typeof Tabs> = {
title: 'Навигация/Tabs',
component: Tabs,
tags: ['autodocs']
};
export default meta;
type Story = StoryObj<typeof Tabs>;
// Вкладки-очереди реестра (как в дашборде myDocuments).
export const Queues: Story = {
render: () => {
const [active, setActive] = useState<string | number>('pending');
return <Tabs tabs={QUEUE} activeId={active} onChange={setActive} />;
}
};
// Вкладки категорий с иконками.
export const WithIcons: Story = {
render: () => {
const [active, setActive] = useState<string | number>('rec');
return (
<Tabs
activeId={active}
onChange={setActive}
tabs={[
{ id: 'rec', label: 'Отдел рекрутинга', icon: 'people' },
{ id: 'it', label: 'IT', icon: 'monitor' },
{ id: 'buh', label: 'Бухгалтерия', icon: 'calculator' }
]}
/>
);
}
};
+45
View File
@@ -0,0 +1,45 @@
import * as React from 'react';
import styles from './Tabs.module.scss';
import { Icon } from '../Icon/Icon';
import { IconName } from '../Icon/iconNames';
export interface ITab {
id: string | number;
label: React.ReactNode;
/** Счётчик после подписи, напр. «(71)». undefined/0 — не показывать. */
count?: number;
/** Иконка слева (необязательно). */
icon?: IconName;
}
export interface ITabsProps {
tabs: ITab[];
activeId: string | number;
onChange: (id: string | number) => void;
}
/**
* Горизонтальные вкладки: активная — navy с золотым подчёркиванием. Управляемый.
* На узких экранах прокручивается вбок (не ломается). Подходит и для очередей со
* счётчиками (реестр), и для категорий с иконками.
*/
export const Tabs: React.FC<ITabsProps> = ({ tabs, activeId, onChange }) => (
<nav className={styles.tabs} aria-label="Вкладки">
{tabs.map((t) => {
const isActive = t.id === activeId;
return (
<button
key={t.id}
type="button"
className={[styles.tab, isActive ? styles.active : ''].filter(Boolean).join(' ')}
aria-current={isActive ? 'page' : undefined}
onClick={() => onChange(t.id)}
>
{t.icon && <Icon name={t.icon} size="sm" />}
<span>{t.label}</span>
{!!t.count && <span className={styles.count}>({t.count})</span>}
</button>
);
})}
</nav>
);
+10
View File
@@ -14,6 +14,8 @@ export { Badge } from './Badge/Badge';
export type { IBadgeProps, BadgeVariant } from './Badge/Badge';
// --- Оболочка и раскладка формы ---
export { FormHeader } from './FormHeader/FormHeader';
export type { IFormHeaderProps } from './FormHeader/FormHeader';
export { FormCard } from './FormCard/FormCard';
export { FormGrid } from './FormGrid/FormGrid';
export { FormField } from './FormField/FormField';
@@ -47,7 +49,15 @@ export { ConfirmHost } from './ConfirmDialog/ConfirmDialog';
export { confirmModal } from './ConfirmDialog/confirmModal';
export type { IConfirmOptions } from './ConfirmDialog/confirmModal';
// --- Раскладка приложения ---
export { AppLayout } from './AppLayout/AppLayout';
export type { IAppLayoutProps } from './AppLayout/AppLayout';
// --- Навигация ---
export { Tabs } from './Tabs/Tabs';
export type { ITab, ITabsProps } from './Tabs/Tabs';
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';
+1 -1
View File
@@ -13,5 +13,5 @@
"skipLibCheck": true
},
"include": ["src"],
"exclude": ["src/**/*.stories.tsx"]
"exclude": ["src/**/*.stories.tsx", "src/**/*.test.ts"]
}
+1 -1
View File
@@ -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: {
+12
View File
@@ -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
}
});