From c72f93e359b20bf09320ad03257abd8ba38d8b8d 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: Fri, 14 Aug 2026 10:35:42 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20Sidebar=20+=20SidebarToggle,=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D0=BF=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20(0.2.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/preview.tsx | 30 ++- PROJECT.md | 17 ++ package.json | 2 +- src/Alert/Alert.stories.tsx | 17 ++ src/AttachmentList/AttachmentList.stories.tsx | 22 ++ src/Button/Button.stories.tsx | 2 +- .../CollapseSection.stories.tsx | 21 ++ src/ConfirmDialog/ConfirmDialog.stories.tsx | 36 ++++ src/FileDropzone/FileDropzone.stories.tsx | 18 ++ src/FormActions/FormActions.module.scss | 12 ++ src/FormActions/FormActions.stories.tsx | 20 ++ src/FormCard/FormCard.module.scss | 4 + src/FormCard/FormCard.stories.tsx | 24 +++ src/FormField/FormField.stories.tsx | 25 +++ src/FormGrid/FormGrid.module.scss | 2 +- src/FormGrid/FormGrid.stories.tsx | 34 +++ src/FormMode.stories.tsx | 30 +++ src/Icon/Icon.stories.tsx | 32 +++ src/Icon/IconSprite.tsx | 3 + src/Icon/iconNames.ts | 1 + src/MoneyInput/MoneyInput.stories.tsx | 18 ++ src/PhoneInput/PhoneInput.stories.tsx | 18 ++ src/PrintButton/PrintButton.stories.tsx | 13 ++ src/RequestNumber/RequestNumber.stories.tsx | 13 ++ src/Select/Select.module.scss | 2 + src/Select/Select.stories.tsx | 24 +++ src/Sidebar/Sidebar.module.scss | 204 ++++++++++++++++++ src/Sidebar/Sidebar.stories.tsx | 92 ++++++++ src/Sidebar/Sidebar.tsx | 113 ++++++++++ src/Sidebar/SidebarToggle.module.scss | 29 +++ src/Sidebar/SidebarToggle.tsx | 23 ++ src/TextArea/TextArea.module.scss | 2 + src/TextArea/TextArea.stories.tsx | 19 ++ src/TextInput/TextInput.module.scss | 2 + src/TextInput/TextInput.stories.tsx | 30 +++ src/Toggle/Toggle.stories.tsx | 18 ++ src/index.ts | 6 + src/tokens/_tokens.scss | 21 ++ 38 files changed, 985 insertions(+), 14 deletions(-) create mode 100644 src/Alert/Alert.stories.tsx create mode 100644 src/AttachmentList/AttachmentList.stories.tsx create mode 100644 src/CollapseSection/CollapseSection.stories.tsx create mode 100644 src/ConfirmDialog/ConfirmDialog.stories.tsx create mode 100644 src/FileDropzone/FileDropzone.stories.tsx create mode 100644 src/FormActions/FormActions.stories.tsx create mode 100644 src/FormCard/FormCard.stories.tsx create mode 100644 src/FormField/FormField.stories.tsx create mode 100644 src/FormGrid/FormGrid.stories.tsx create mode 100644 src/FormMode.stories.tsx create mode 100644 src/Icon/Icon.stories.tsx create mode 100644 src/MoneyInput/MoneyInput.stories.tsx create mode 100644 src/PhoneInput/PhoneInput.stories.tsx create mode 100644 src/PrintButton/PrintButton.stories.tsx create mode 100644 src/RequestNumber/RequestNumber.stories.tsx create mode 100644 src/Select/Select.stories.tsx create mode 100644 src/Sidebar/Sidebar.module.scss create mode 100644 src/Sidebar/Sidebar.stories.tsx create mode 100644 src/Sidebar/Sidebar.tsx create mode 100644 src/Sidebar/SidebarToggle.module.scss create mode 100644 src/Sidebar/SidebarToggle.tsx create mode 100644 src/TextArea/TextArea.stories.tsx create mode 100644 src/TextInput/TextInput.stories.tsx create mode 100644 src/Toggle/Toggle.stories.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 073582e..bd99d60 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,21 +1,29 @@ -import type { Preview } from '@storybook/react-vite' +import type { Preview } from '@storybook/react-vite'; +import * as React from 'react'; +import { IconSprite } from '../src/Icon/IconSprite'; const preview: Preview = { parameters: { controls: { matchers: { - color: /(background|color)$/i, - date: /Date$/i, + color: /(background|color)$/i, + date: /Date$/i, }, }, - - a11y: { - // 'todo' - show a11y violations in the test UI only - // 'error' - fail CI on a11y violations - // 'off' - skip a11y checks entirely - test: 'todo' - } + a11y: { test: 'todo' }, }, + // Спрайт иконок монтируется один раз для ВСЕХ историй — иначе пустой. + // Плюс общий отступ и шрифт Inter, как в боевом портале. + decorators: [ + (Story) => ( + <> + +
+ +
+ + ), + ], }; -export default preview; \ No newline at end of file +export default preview; diff --git a/PROJECT.md b/PROJECT.md index 6267a57..2527bdd 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -96,7 +96,24 @@ DecisionPanel, ApprovalMatrix, DepartmentPicker, ProjectPicker. - Пикеры — возможно позже РАЗДЕЛИТЬ: презентационный UI (ui-kit) + data-обёртка (проект). - Логику согласования — возможно в отдельный пакет @tnsu/approval (или @tnsu/sp-core). На будущее. +### Адаптивность (жёсткое требование) — базовый проход СДЕЛАН +Часть DoD каждого компонента. Основа: в tokens.scss брейкпоинты +`$bp-xs:350 / $bp-sm:640 / $bp-md:768 / $bp-lg:1024 / $bp-xl:1280` + миксины `below()`/`above()`. +Проход по компонентам (в 0.2.0): +- FormGrid / CollapseSection — 2 колонки → 1 на < sm (через below($bp-sm)). +- TextInput/TextArea/Select — сделаны резиновыми (width:100% + box-sizing) — раньше зависели + от глобального стиля портала. +- FormCard — padding 32→18 на < sm. +- FormActions — кнопки в столбец на всю ширину на < sm (поля согласованы с FormCard 18px). +- ConfirmDialog — уже адаптивна (overlay padding + max-width). +- Sidebar — рейл ↔ выезжающий drawer + SidebarToggle (бургер, виден только < md). +- Мелкие (Alert, Icon, Toggle, Button, AttachmentList, FileDropzone, PrintButton) — уже резиновые. +ItemsTable→скролл сделаем вместе с обобщением в DataTable. + ### Группа 4 — навигация / app shell (ФАЗА 2) +- [СДЕЛАНО, не опубликовано] Sidebar — обобщён (items/brand пропсами, id=string) + + адаптивный: десктоп-рейл со сворачиванием, < 768px — выезжающий drawer + затемнение + (mobileOpen/onMobileClose, бургер рисует родитель). Собирается, в index экспортирован. Tabs (из CategoryTabs), Breadcrumbs (новый), Sidebar (из SideNav), AppLayout (из RequestPortal), Drawer(?). Сначала «расцепить» от роутинга портала → сделать controlled (items + activeId + onSelect), потом переносить. Даёт новым приложениям готовую оболочку. diff --git a/package.json b/package.json index 7487a0a..16dd266 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tnsu/ui-kit", - "version": "0.1.0", + "version": "0.2.0", "description": "TNSU shared UI components for SPFx web parts", "author": "Timur Gorodetskiy", "license": "ISC", diff --git a/src/Alert/Alert.stories.tsx b/src/Alert/Alert.stories.tsx new file mode 100644 index 0000000..5b8246d --- /dev/null +++ b/src/Alert/Alert.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { Alert } from './Alert'; + +const meta: Meta = { + title: 'Сообщения/Alert', + component: Alert, + tags: ['autodocs'], + argTypes: { variant: { control: 'radio', options: ['warn', 'ok', 'err'] } }, + args: { variant: 'ok', children: 'Заявка успешно отправлена.' }, +}; + +export default meta; +type Story = StoryObj; + +export const Success: Story = { args: { variant: 'ok', children: 'Заявка успешно отправлена.' } }; +export const Warning: Story = { args: { variant: 'warn', children: 'Проверьте заполнение обязательных полей.' } }; +export const Error: Story = { args: { variant: 'err', children: 'Не удалось загрузить заявку №42.' } }; diff --git a/src/AttachmentList/AttachmentList.stories.tsx b/src/AttachmentList/AttachmentList.stories.tsx new file mode 100644 index 0000000..ec342f8 --- /dev/null +++ b/src/AttachmentList/AttachmentList.stories.tsx @@ -0,0 +1,22 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { AttachmentList } from './AttachmentList'; + +const ITEMS = [ + { url: '#', fileName: 'Договор.pdf' }, + { url: '#', fileName: 'Смета.xlsx' }, + { url: '#', fileName: 'Скан приказа.jpg' }, +]; + +const meta: Meta = { + title: 'Файлы/AttachmentList', + component: AttachmentList, + tags: ['autodocs'], + args: { items: ITEMS }, +}; + +export default meta; +type Story = StoryObj; + +export const ReadOnly: Story = {}; +export const Removable: Story = { args: { onRemove: () => undefined } }; +export const Empty: Story = { args: { items: [] } }; diff --git a/src/Button/Button.stories.tsx b/src/Button/Button.stories.tsx index fe32e93..d7c3beb 100644 --- a/src/Button/Button.stories.tsx +++ b/src/Button/Button.stories.tsx @@ -4,7 +4,7 @@ import { Button } from './Button'; // meta — описание компонента для Storybook: где показать в дереве слева, // какой компонент и какими контролами крутить пропсы в панели справа. const meta: Meta = { - title: 'Components/Button', + title: 'Кнопки/Button', component: Button, tags: ['autodocs'], // автоматически сгенерит страницу-документацию argTypes: { diff --git a/src/CollapseSection/CollapseSection.stories.tsx b/src/CollapseSection/CollapseSection.stories.tsx new file mode 100644 index 0000000..d0f23fb --- /dev/null +++ b/src/CollapseSection/CollapseSection.stories.tsx @@ -0,0 +1,21 @@ +import * as React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { CollapseSection } from './CollapseSection'; + +const meta: Meta = { + title: 'Формы/CollapseSection', + component: CollapseSection, + tags: ['autodocs'], + args: { + title: 'Заявка на отпуск', + icon: 'people', + defaultOpen: true, + children:

Поля секции формы.

, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Open: Story = {}; +export const Closed: Story = { args: { defaultOpen: false } }; diff --git a/src/ConfirmDialog/ConfirmDialog.stories.tsx b/src/ConfirmDialog/ConfirmDialog.stories.tsx new file mode 100644 index 0000000..230a062 --- /dev/null +++ b/src/ConfirmDialog/ConfirmDialog.stories.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { ConfirmHost } from './ConfirmDialog'; +import { confirmModal } from './confirmModal'; +import { Button } from '../Button/Button'; + +// ConfirmHost монтируется один раз, а confirmModal(...) вызывается императивно +// из любого места и возвращает промис (true/false). +const meta: Meta = { + title: 'Оверлеи/ConfirmDialog', + component: ConfirmHost, + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + render: () => ( + <> + + + + ), +}; diff --git a/src/FileDropzone/FileDropzone.stories.tsx b/src/FileDropzone/FileDropzone.stories.tsx new file mode 100644 index 0000000..fdcce2f --- /dev/null +++ b/src/FileDropzone/FileDropzone.stories.tsx @@ -0,0 +1,18 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { FileDropzone } from './FileDropzone'; + +const meta: Meta = { + title: 'Файлы/FileDropzone', + component: FileDropzone, + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + render: () => { + const [files, setFiles] = useState([]); + return ; + }, +}; diff --git a/src/FormActions/FormActions.module.scss b/src/FormActions/FormActions.module.scss index 421b269..ac04766 100644 --- a/src/FormActions/FormActions.module.scss +++ b/src/FormActions/FormActions.module.scss @@ -12,4 +12,16 @@ border-top: 1px solid $gray-200; border-radius: 0 0 $r-lg $r-lg; box-shadow: 0 -4px 16px rgba(15, 36, 64, .04); + + // Мобилка: кнопки в столбец на всю ширину; отрицательные поля под уменьшенный + // padding карточки (18px, см. FormCard). Основная кнопка (accent, последняя) — сверху. + @include below($bp-sm) { + flex-direction: column-reverse; + margin: 20px -18px -18px; + padding: 14px 18px; + + > * { + width: 100%; + } + } } diff --git a/src/FormActions/FormActions.stories.tsx b/src/FormActions/FormActions.stories.tsx new file mode 100644 index 0000000..4c0ee36 --- /dev/null +++ b/src/FormActions/FormActions.stories.tsx @@ -0,0 +1,20 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { FormActions } from './FormActions'; +import { Button } from '../Button/Button'; + +const meta: Meta = { + title: 'Формы/FormActions', + component: FormActions, + tags: ['autodocs'], +}; + +export default meta; + +export const Default: StoryObj = { + render: () => ( + + + + + ), +}; diff --git a/src/FormCard/FormCard.module.scss b/src/FormCard/FormCard.module.scss index a76e618..dd3f6d3 100644 --- a/src/FormCard/FormCard.module.scss +++ b/src/FormCard/FormCard.module.scss @@ -6,6 +6,10 @@ border-radius: $r-lg; box-shadow: $sh-1; padding: 32px; + + @include below($bp-sm) { + padding: 18px; + } } .header { diff --git a/src/FormCard/FormCard.stories.tsx b/src/FormCard/FormCard.stories.tsx new file mode 100644 index 0000000..c6231ba --- /dev/null +++ b/src/FormCard/FormCard.stories.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { FormCard } from './FormCard'; +import { RequestNumber } from '../RequestNumber/RequestNumber'; + +const meta: Meta = { + title: 'Формы/FormCard', + component: FormCard, + tags: ['autodocs'], + args: { + title: 'Заявка на отпуск', + subtitle: 'Категория: Отдел кадров', + children:

Здесь располагаются поля формы.

, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = {}; + +export const WithHeaderRight: Story = { + args: { headerRight: }, +}; diff --git a/src/FormField/FormField.stories.tsx b/src/FormField/FormField.stories.tsx new file mode 100644 index 0000000..dfd281f --- /dev/null +++ b/src/FormField/FormField.stories.tsx @@ -0,0 +1,25 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { FormField } from './FormField'; +import { TextInput } from '../TextInput/TextInput'; + +const meta: Meta = { + title: 'Формы/FormField', + component: FormField, + tags: ['autodocs'], + args: { label: 'Должность', required: true }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: (args) => { + const [v, setV] = useState(''); + return ( + + + + ); + }, +}; diff --git a/src/FormGrid/FormGrid.module.scss b/src/FormGrid/FormGrid.module.scss index 4cf95b6..8d2971d 100644 --- a/src/FormGrid/FormGrid.module.scss +++ b/src/FormGrid/FormGrid.module.scss @@ -5,7 +5,7 @@ grid-template-columns: 1fr 1fr; gap: 18px 22px; - @media (max-width: 640px) { + @include below($bp-sm) { grid-template-columns: 1fr; } } diff --git a/src/FormGrid/FormGrid.stories.tsx b/src/FormGrid/FormGrid.stories.tsx new file mode 100644 index 0000000..0a36a05 --- /dev/null +++ b/src/FormGrid/FormGrid.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { FormGrid } from './FormGrid'; +import { FormField } from '../FormField/FormField'; +import { TextInput } from '../TextInput/TextInput'; + +const meta: Meta = { + title: 'Формы/FormGrid', + component: FormGrid, + tags: ['autodocs'], +}; + +export default meta; + +export const TwoColumns: StoryObj = { + render: () => { + const [a, setA] = useState(''); + const [b, setB] = useState(''); + const [c, setC] = useState(''); + return ( + + + + + + + + + + + + ); + }, +}; diff --git a/src/FormMode.stories.tsx b/src/FormMode.stories.tsx new file mode 100644 index 0000000..a09bb06 --- /dev/null +++ b/src/FormMode.stories.tsx @@ -0,0 +1,30 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { FormModeProvider } from './FormMode'; +import { TextInput } from './TextInput/TextInput'; +import { Select } from './Select/Select'; + +// FormModeProvider проставляет режим «только чтение» для всех полей внутри — +// поля-примитивы сами его подхватывают через useFormReadOnly(), не нужно тащить +// readOnly в каждое поле руками. +const meta: Meta = { + title: 'Основа/FormMode', + component: FormModeProvider, + tags: ['autodocs'], +}; + +export default meta; + +export const ReadOnly: StoryObj = { + render: () => ( + +
+ undefined} /> + ; + }, +}; diff --git a/src/Sidebar/Sidebar.module.scss b/src/Sidebar/Sidebar.module.scss new file mode 100644 index 0000000..0f572b5 --- /dev/null +++ b/src/Sidebar/Sidebar.module.scss @@ -0,0 +1,204 @@ +@import '../tokens/tokens'; + +// Тёмный боковой рейл на всю высоту (фирменный navy-градиент, золотой +// маркер активного пункта). Не «плавает»: примыкает к контенту, тянется по высоте. +.nav { + display: flex; + flex-direction: column; + width: 248px; + padding: $sp-4 0 $sp-3; + background: $grad-header; + box-shadow: inset -1px 0 0 rgba(255, 255, 255, .06); + transition: width .2s; +} + +.navCollapsed { + width: 64px; +} + +// ---- Шапка рейла: бренд + сворачивание ---- +.head { + display: flex; + align-items: center; + justify-content: space-between; + gap: $sp-2; + padding: 0 $sp-4 $sp-4; + border-bottom: 1px solid rgba(255, 255, 255, .08); +} + +.navCollapsed .head { + flex-direction: column; + gap: $sp-3; + padding: 0 0 $sp-3; +} + +.brand { + display: flex; + align-items: center; + gap: $sp-2; + min-width: 0; +} + +.brandText { + font-family: $font-serif; + font-size: 15px; + font-weight: 600; + color: $white; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.toggle { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + padding: 0; + border: 1px solid rgba(255, 255, 255, .18); + border-radius: $r-sm; + background: transparent; + color: rgba(255, 255, 255, .6); + cursor: pointer; + transition: .16s; + + &:hover { + border-color: $gold-400; + color: $gold-300; + } +} + +// ---- Пункты разделов ---- +.items { + display: flex; + flex-direction: column; + gap: 2px; + margin-top: $sp-4; +} + +.navCollapsed .items { + margin-top: $sp-3; +} + +.item { + position: relative; + display: flex; + align-items: center; + gap: $sp-3; + width: 100%; + padding: 12px 18px; + border: 0; + border-left: 3px solid transparent; + background: transparent; + color: rgba(255, 255, 255, .62); + font-family: $font-sans; + font-size: 13.5px; + font-weight: 500; + text-align: left; + white-space: nowrap; + cursor: pointer; + transition: color .18s, background .18s, border-color .18s; + + &:hover { + color: rgba(255, 255, 255, .92); + background: rgba(255, 255, 255, .04); + } +} + +.navCollapsed .item { + justify-content: center; + padding: 12px 0; +} + +.itemActive { + color: $white; + background: rgba(255, 255, 255, .07); + border-left-color: $gold-500; + + .itemIcon { + color: $gold-400; + } +} + +.itemIcon { + flex-shrink: 0; + display: inline-flex; + color: inherit; +} + +.itemTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.badge { + flex-shrink: 0; + min-width: 18px; + padding: 1px 6px; + border-radius: $r-full; + background: $gold-500; + color: $navy-950; + font-size: $fs-overline; + font-weight: 700; + text-align: center; +} + +.navCollapsed .badge { + position: absolute; + top: 6px; + right: 8px; +} + +// ---- Затемнение фона под мобильным drawer ---- +.backdrop { + display: none; +} + +// ---- Адаптив: < 768px рейл превращается в выезжающий drawer ---- +// На мобилке сайдбар спрятан за левым краем и выезжает поверх контента, когда +// родитель ставит mobileOpen (бургер в шапке). Свёрнутый режим (иконки) на +// мобилке не применяем — drawer всегда полный; переключатель ширины прячем. +@include below($bp-md) { + .nav, + .navCollapsed { + position: fixed; + top: 0; + left: 0; + bottom: 0; + z-index: 1000; + width: 248px; + transform: translateX(-100%); + transition: transform .25s ease; + } + + .navMobileOpen { + transform: translateX(0); + box-shadow: $sh-modal; + } + + .toggle { + display: none; + } + + .backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 999; + background: rgba(10, 27, 48, .45); + } +} + +// На совсем узких экранах drawer занимает почти всю ширину, оставляя щель +// для затемнения справа — так видно, что за ним есть контент. +@include below($bp-xs) { + .nav, + .navCollapsed { + width: 86vw; + max-width: 300px; + } +} diff --git a/src/Sidebar/Sidebar.stories.tsx b/src/Sidebar/Sidebar.stories.tsx new file mode 100644 index 0000000..2f8dd2b --- /dev/null +++ b/src/Sidebar/Sidebar.stories.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from 'react'; +import { Sidebar, ISidebarItem } from './Sidebar'; +import { SidebarToggle } from './SidebarToggle'; + +const ITEMS: ISidebarItem[] = [ + { id: 'new', title: 'Новая заявка', icon: 'plus' }, + { id: 'approvals', title: 'На согласовании', icon: 'user-check', count: 3 }, + { id: 'execution', title: 'На исполнении', icon: 'helmet' }, + { id: 'rework', title: 'Доработка', icon: 'edit', count: 1 }, + { id: 'own', title: 'Мои заявки', icon: 'clipboard' }, + { id: 'search', title: 'Поиск', icon: 'search' }, +]; + +const meta: Meta = { + title: 'Навигация/Sidebar', + component: Sidebar, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const [active, setActive] = useState('approvals'); + const [collapsed, setCollapsed] = useState(false); + return ( + setCollapsed((c) => !c)} + /> + ); + }, +}; + +export const Collapsed: Story = { + render: () => { + const [active, setActive] = useState('approvals'); + const [collapsed, setCollapsed] = useState(true); + return ( + setCollapsed((c) => !c)} + /> + ); + }, +}; + +// Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook) +// ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента. +export const MobileDrawer: Story = { + parameters: { viewport: { defaultViewport: 'mobile1' } }, + render: () => { + const [active, setActive] = useState('approvals'); + const [open, setOpen] = useState(false); + return ( +
+ setOpen(true)} /> +

+ Контент страницы. Бургер виден только на мобилке (< 768px) — нажми, drawer выедет слева. +

+ undefined} + mobileOpen={open} + onMobileClose={() => setOpen(false)} + /> +
+ ); + }, +}; diff --git a/src/Sidebar/Sidebar.tsx b/src/Sidebar/Sidebar.tsx new file mode 100644 index 0000000..d38975a --- /dev/null +++ b/src/Sidebar/Sidebar.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import styles from './Sidebar.module.scss'; +import { Icon } from '../Icon/Icon'; +import { IconName } from '../Icon/iconNames'; + +export interface ISidebarItem { + /** Уникальный идентификатор раздела (любая строка). */ + id: string; + title: string; + icon: IconName; + /** Счётчик-бейдж справа (например, число заявок в очереди). 0/undefined — не показывать. */ + count?: number; +} + +export interface ISidebarProps { + items: ISidebarItem[]; + activeId: string; + onChange: (id: string) => void; + /** Бренд в шапке рейла (текст или логотип). Показывается в развёрнутом виде. */ + brand?: React.ReactNode; + /** Десктоп: свёрнут ли рейл в узкую полосу с иконками. */ + collapsed: boolean; + onToggleCollapsed: () => void; + /** + * Мобильный drawer: открыт ли выезжающий сайдбар (< 768px). Управляется + * родителем — кнопкой-бургером в шапке приложения. На десктопе не влияет. + * На мобилке рекомендуется держать collapsed=false (drawer всегда полный). + */ + mobileOpen?: boolean; + /** Закрыть мобильный drawer (клик по затемнению или выбор пункта). */ + onMobileClose?: () => void; +} + +/** + * Тёмный боковой рейл на всю высоту: бренд сверху, разделы снизу. Управляемый. + * Адаптивный: + * • десктоп — рейл, сворачивается в иконки (collapsed); + * • < 768px — выезжающий поверх контента drawer с затемнением фона + * (mobileOpen / onMobileClose), бургер рисует родитель в шапке. + */ +export const Sidebar: React.FC = ({ + items, + activeId, + onChange, + brand, + collapsed, + onToggleCollapsed, + mobileOpen = false, + onMobileClose, +}) => { + const navClasses = [ + styles.nav, + collapsed ? styles.navCollapsed : '', + mobileOpen ? styles.navMobileOpen : '', + ] + .filter(Boolean) + .join(' '); + + // На мобилке после выбора раздела drawer закрываем; на десктопе onMobileClose + // обычно не передан — вызов безвреден. + const handleSelect = (id: string): void => { + onChange(id); + if (onMobileClose) onMobileClose(); + }; + + return ( + <> + {mobileOpen &&