Compare commits
2 Commits
f2e8108d1b
...
06e9007e73
| Author | SHA1 | Date | |
|---|---|---|---|
| 06e9007e73 | |||
| 0082b90c2b |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@tnsu/ui-kit",
|
||||
"version": "0.0.1",
|
||||
"version": "0.13.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@tnsu/ui-kit",
|
||||
"version": "0.0.1",
|
||||
"version": "0.13.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "latest",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@tnsu/ui-kit",
|
||||
"version": "0.11.0",
|
||||
"version": "0.13.0",
|
||||
"description": "TNSU shared UI components for SPFx web parts",
|
||||
"author": "Timur Gorodetskiy",
|
||||
"license": "ISC",
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
@import "../tokens/tokens";
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.chevron,
|
||||
.chevronOpen {
|
||||
transition: transform 0.18s;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 20;
|
||||
min-width: 220px;
|
||||
max-width: min(360px, calc(100vw - 24px));
|
||||
background: $white;
|
||||
border: 1px solid $gray-200;
|
||||
border-radius: $r-md;
|
||||
box-shadow: $sh-3;
|
||||
padding: 6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: $r-sm;
|
||||
font-family: $font-sans;
|
||||
font-size: 13px;
|
||||
color: $navy-800;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: $navy-50;
|
||||
}
|
||||
}
|
||||
|
||||
.itemLabel {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.check {
|
||||
flex: none;
|
||||
color: $gold-600;
|
||||
}
|
||||
|
||||
@include below($bp-xs) {
|
||||
.menu {
|
||||
min-width: 180px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 8px 10px;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { useState } from 'react';
|
||||
import { DropdownButton } from './DropdownButton';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: '0', label: 'ТОО Монтажник-Т — Директор' },
|
||||
{ value: '1', label: 'ТОО Стройсервис — Главный инженер' },
|
||||
];
|
||||
|
||||
const meta: Meta<typeof DropdownButton> = {
|
||||
title: 'Кнопки/DropdownButton',
|
||||
component: DropdownButton,
|
||||
tags: ['autodocs'],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
export const Default: StoryObj<typeof DropdownButton> = {
|
||||
render: () => {
|
||||
const [v, setV] = useState('0');
|
||||
return (
|
||||
<DropdownButton options={OPTIONS} value={v} onSelect={setV}>
|
||||
<Icon name="document" size="sm" />
|
||||
Печать
|
||||
</DropdownButton>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
import * as React from 'react';
|
||||
import styles from './DropdownButton.module.scss';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
|
||||
export interface IDropdownButtonOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
type DropdownButtonVariant = 'primary' | 'accent' | 'secondary' | 'ghost' | 'danger';
|
||||
type DropdownButtonSize = 'md' | 'sm';
|
||||
|
||||
export interface IDropdownButtonProps {
|
||||
options: IDropdownButtonOption[];
|
||||
onSelect: (value: string) => void;
|
||||
/** Текущее значение — помечается галочкой в списке. */
|
||||
value?: string;
|
||||
variant?: DropdownButtonVariant;
|
||||
size?: DropdownButtonSize;
|
||||
disabled?: boolean;
|
||||
/** К какому краю кнопки прижимать меню (по умолчанию — правый). */
|
||||
align?: 'left' | 'right';
|
||||
/** Содержимое кнопки-триггера (текст/иконка) — стрелка добавляется сама. */
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка с выпадающим списком вариантов: клик по кнопке открывает меню,
|
||||
* выбор варианта сразу вызывает onSelect и закрывает его. В отличие от
|
||||
* классической split-button здесь нет отдельной зоны действия по умолчанию —
|
||||
* вся кнопка целиком является триггером меню, так как без выбора варианта
|
||||
* действие не определено (см. PrintSplitButton в request-portal).
|
||||
*/
|
||||
export const DropdownButton: React.FC<IDropdownButtonProps> = ({
|
||||
options,
|
||||
onSelect,
|
||||
value,
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
disabled,
|
||||
align = 'right',
|
||||
children
|
||||
}) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const rootRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
return undefined;
|
||||
}
|
||||
const onDocMouseDown = (e: MouseEvent): void => {
|
||||
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: KeyboardEvent): void => {
|
||||
if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDocMouseDown);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocMouseDown);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const pick = (v: string): void => {
|
||||
setOpen(false);
|
||||
onSelect(v);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.root} ref={rootRef}>
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
{children}
|
||||
<Icon name="chevron-down" size="sm" className={open ? styles.chevronOpen : styles.chevron} />
|
||||
</Button>
|
||||
{open && (
|
||||
<div className={`${styles.menu} ${align === 'left' ? styles.left : styles.right}`} role="menu">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.item}
|
||||
onClick={() => pick(opt.value)}
|
||||
>
|
||||
<span className={styles.itemLabel}>{opt.label}</span>
|
||||
{value === opt.value && <Icon name="check" size="sm" className={styles.check} />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -59,57 +59,63 @@ const PRINT_CSS =
|
||||
'.rp-print-value{white-space:pre-wrap;color:#000;padding:1px 0 2px}';
|
||||
|
||||
/**
|
||||
* Кнопка «Печать»: переносит документ (`.rp-print-root`) в отдельное чистое окно,
|
||||
* Переносит документ (по умолчанию `.rp-print-root`) в отдельное чистое окно,
|
||||
* заменяя поля ввода на текст значений, и печатает его. Не зависит от скролл-
|
||||
* контейнеров/высоты SharePoint, корректно разбивается на страницы. Общая для форм.
|
||||
* контейнеров/высоты SharePoint, корректно разбивается на страницы. Вынесена
|
||||
* из PrintButton отдельной функцией, чтобы печать можно было вызвать
|
||||
* программно — например, сразу после выбора варианта в DropdownButton, а не
|
||||
* только по клику на саму кнопку (см. PrintSplitButton в request-portal).
|
||||
*/
|
||||
export function printRoot(selector = '.rp-print-root'): void {
|
||||
const source = document.querySelector(selector);
|
||||
if (!source) {
|
||||
window.print();
|
||||
return;
|
||||
}
|
||||
const win = window.open('', '_blank', 'width=980,height=1200');
|
||||
if (!win) {
|
||||
window.print(); // всплывающее окно заблокировано — печатаем как есть
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = source.cloneNode(true) as Element;
|
||||
// Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»).
|
||||
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
|
||||
renderValuesAsText(source, clone);
|
||||
|
||||
// Переносим стили страницы (CSS-модули инжектятся как <style>, шрифты — <link>).
|
||||
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
||||
.map((n) => n.outerHTML)
|
||||
.join('\n');
|
||||
|
||||
win.document.open();
|
||||
win.document.write(
|
||||
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
||||
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
|
||||
// Даём стилям/шрифтам примениться, затем печатаем и закрываем окно.
|
||||
const done = (): void => {
|
||||
win.print();
|
||||
win.close();
|
||||
};
|
||||
if (win.document.readyState === 'complete') {
|
||||
window.setTimeout(done, 350);
|
||||
} else {
|
||||
win.onload = (): void => {
|
||||
window.setTimeout(done, 350);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Кнопка «Печать» — тонкая обёртка над printRoot(). Общая для форм.
|
||||
*/
|
||||
export const PrintButton: React.FC<{ label?: string }> = ({ label = 'Печать' }) => {
|
||||
const onPrint = (): void => {
|
||||
const source = document.querySelector('.rp-print-root');
|
||||
if (!source) {
|
||||
window.print();
|
||||
return;
|
||||
}
|
||||
const win = window.open('', '_blank', 'width=980,height=1200');
|
||||
if (!win) {
|
||||
window.print(); // всплывающее окно заблокировано — печатаем как есть
|
||||
return;
|
||||
}
|
||||
|
||||
const clone = source.cloneNode(true) as Element;
|
||||
// Убираем из печати всё помеченное rp-no-print (напр. блок «Вложения»).
|
||||
clone.querySelectorAll('.rp-no-print').forEach((el) => el.remove());
|
||||
renderValuesAsText(source, clone);
|
||||
|
||||
// Переносим стили страницы (CSS-модули инжектятся как <style>, шрифты — <link>).
|
||||
const head = Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))
|
||||
.map((n) => n.outerHTML)
|
||||
.join('\n');
|
||||
|
||||
win.document.open();
|
||||
win.document.write(
|
||||
`<!doctype html><html><head><meta charset="utf-8">${head}` +
|
||||
`<style>${PRINT_CSS}</style></head><body>${clone.outerHTML}</body></html>`
|
||||
);
|
||||
win.document.close();
|
||||
win.focus();
|
||||
|
||||
// Даём стилям/шрифтам примениться, затем печатаем и закрываем окно.
|
||||
const done = (): void => {
|
||||
win.print();
|
||||
win.close();
|
||||
};
|
||||
if (win.document.readyState === 'complete') {
|
||||
window.setTimeout(done, 350);
|
||||
} else {
|
||||
win.onload = (): void => {
|
||||
window.setTimeout(done, 350);
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="secondary" onClick={onPrint}>
|
||||
<Button variant="secondary" onClick={() => printRoot()}>
|
||||
<Icon name="document" size="sm" />
|
||||
{label}
|
||||
</Button>
|
||||
|
||||
@@ -97,6 +97,8 @@
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
// Пункт-ссылка (<a>) выглядит так же, как пункт-кнопка.
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color .18s, background .18s, border-color .18s;
|
||||
@@ -153,6 +155,55 @@
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
// ---- Раскрывающаяся группа: подпункты без иконок, с отступом под иконку шапки ----
|
||||
$sub-item-indent: 48px; // 18px отступ пункта + 3px маркер + иконка 16px + зазор
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.groupChevron {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
color: rgba(255, 255, 255, .45);
|
||||
}
|
||||
|
||||
.subItems {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: $sp-1;
|
||||
}
|
||||
|
||||
.subItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $sp-2;
|
||||
width: 100%;
|
||||
padding: 8px 18px 8px $sub-item-indent;
|
||||
border: 0;
|
||||
border-left: 3px solid transparent;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, .55);
|
||||
font-family: $font-sans;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color .18s, background .18s;
|
||||
|
||||
&:hover {
|
||||
color: rgba(255, 255, 255, .92);
|
||||
background: rgba(255, 255, 255, .04);
|
||||
}
|
||||
}
|
||||
|
||||
.subItemActive {
|
||||
color: $white;
|
||||
border-left-color: $gold-500;
|
||||
}
|
||||
|
||||
// ---- Затемнение фона под мобильным drawer ----
|
||||
.backdrop {
|
||||
display: none;
|
||||
|
||||
@@ -63,6 +63,39 @@ export const Collapsed: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
// Пункт с children — раскрывающаяся группа; подпункт с href открывается в новой вкладке.
|
||||
const ITEMS_WITH_GROUP: ISidebarItem[] = [
|
||||
...ITEMS.slice(0, 3),
|
||||
{
|
||||
id: 'catalogs',
|
||||
title: 'Справочники',
|
||||
icon: 'info',
|
||||
children: [
|
||||
{ id: 'companies', title: 'Компании', href: 'https://example.com/companies' },
|
||||
{ id: 'units', title: 'Единицы измерения', href: 'https://example.com/units' },
|
||||
{ id: 'manual', title: 'Инструкция', href: 'https://example.com/manual' },
|
||||
],
|
||||
},
|
||||
{ id: 'help', title: 'Справка', icon: 'chat', href: 'https://example.com/help' },
|
||||
];
|
||||
|
||||
export const WithGroupAndLinks: Story = {
|
||||
render: () => {
|
||||
const [active, setActive] = useState('approvals');
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<Sidebar
|
||||
items={ITEMS_WITH_GROUP}
|
||||
activeId={active}
|
||||
onChange={setActive}
|
||||
brand="Портал заявок"
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((c) => !c)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook)
|
||||
// ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента.
|
||||
export const MobileDrawer: Story = {
|
||||
|
||||
+31
-31
@@ -1,16 +1,11 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Sidebar.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
import { ISidebarItem } from './sidebarLogic';
|
||||
import { SidebarEntry } from './SidebarEntry';
|
||||
import { SidebarGroup } from './SidebarGroup';
|
||||
|
||||
export interface ISidebarItem {
|
||||
/** Уникальный идентификатор раздела (любая строка). */
|
||||
id: string;
|
||||
title: string;
|
||||
icon: IconName;
|
||||
/** Счётчик-бейдж справа (например, число заявок в очереди). 0/undefined — не показывать. */
|
||||
count?: number;
|
||||
}
|
||||
export type { ISidebarItem, ISidebarSubItem } from './sidebarLogic';
|
||||
|
||||
export interface ISidebarProps {
|
||||
items: ISidebarItem[];
|
||||
@@ -33,6 +28,7 @@ export interface ISidebarProps {
|
||||
|
||||
/**
|
||||
* Тёмный боковой рейл на всю высоту: бренд сверху, разделы снизу. Управляемый.
|
||||
* Пункт с href — внешняя ссылка (новая вкладка), с children — раскрывающаяся группа.
|
||||
* Адаптивный:
|
||||
* • десктоп — рейл, сворачивается в иконки (collapsed);
|
||||
* • < 768px — выезжающий поверх контента drawer с затемнением фона
|
||||
@@ -58,9 +54,13 @@ export const Sidebar: React.FC<ISidebarProps> = ({
|
||||
|
||||
// На мобилке после выбора раздела drawer закрываем; на десктопе onMobileClose
|
||||
// обычно не передан — вызов безвреден.
|
||||
const closeMobile = (): void => {
|
||||
if (onMobileClose) onMobileClose();
|
||||
};
|
||||
|
||||
const handleSelect = (id: string): void => {
|
||||
onChange(id);
|
||||
if (onMobileClose) onMobileClose();
|
||||
closeMobile();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -84,28 +84,28 @@ export const Sidebar: React.FC<ISidebarProps> = ({
|
||||
</div>
|
||||
|
||||
<div className={styles.items}>
|
||||
{items.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
|
||||
const showCount = !!item.count;
|
||||
|
||||
return (
|
||||
<button
|
||||
{items.map((item) =>
|
||||
item.children ? (
|
||||
<SidebarGroup
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={itemClasses}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => handleSelect(item.id)}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<span className={styles.itemIcon}>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
</span>
|
||||
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
|
||||
{showCount && <span className={styles.badge}>{item.count}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
item={item}
|
||||
activeId={activeId}
|
||||
collapsed={collapsed}
|
||||
onSelect={handleSelect}
|
||||
onLinkOpen={closeMobile}
|
||||
onExpandRail={onToggleCollapsed}
|
||||
/>
|
||||
) : (
|
||||
<SidebarEntry
|
||||
key={item.id}
|
||||
item={item}
|
||||
isActive={item.id === activeId}
|
||||
collapsed={collapsed}
|
||||
onSelect={handleSelect}
|
||||
onLinkOpen={closeMobile}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Sidebar.module.scss';
|
||||
import { ISidebarItem } from './sidebarLogic';
|
||||
import { SidebarItemContent } from './SidebarItemContent';
|
||||
|
||||
interface ISidebarEntryProps {
|
||||
item: ISidebarItem;
|
||||
isActive: boolean;
|
||||
collapsed: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
/** Переход по внешней ссылке — закрыть мобильный drawer. */
|
||||
onLinkOpen: () => void;
|
||||
}
|
||||
|
||||
/** Обычный пункт: раздел (кнопка, onChange) или внешняя ссылка (новая вкладка). */
|
||||
export const SidebarEntry: React.FC<ISidebarEntryProps> = ({ item, isActive, collapsed, onSelect, onLinkOpen }) => {
|
||||
const classes = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' ');
|
||||
const title = collapsed ? item.title : undefined;
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<a className={classes} href={item.href} target="_blank" rel="noopener noreferrer" title={title} onClick={onLinkOpen}>
|
||||
<SidebarItemContent item={item} collapsed={collapsed} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={classes}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
onClick={() => onSelect(item.id)}
|
||||
title={title}
|
||||
>
|
||||
<SidebarItemContent item={item} collapsed={collapsed} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Sidebar.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { hasActiveChild, ISidebarItem } from './sidebarLogic';
|
||||
import { SidebarItemContent } from './SidebarItemContent';
|
||||
import { SidebarSubItem } from './SidebarSubItem';
|
||||
|
||||
interface ISidebarGroupProps {
|
||||
item: ISidebarItem;
|
||||
activeId: string;
|
||||
collapsed: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onLinkOpen: () => void;
|
||||
/** Свёрнутый рейл: подпунктам некуда раскрыться — сначала разворачиваем рейл. */
|
||||
onExpandRail: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Раскрывающаяся группа. Сама разделом не является: клик раскрывает/сворачивает подпункты.
|
||||
* Группа с активным подпунктом всегда раскрыта — иначе текущий раздел не виден в меню.
|
||||
*/
|
||||
export const SidebarGroup: React.FC<ISidebarGroupProps> = ({
|
||||
item,
|
||||
activeId,
|
||||
collapsed,
|
||||
onSelect,
|
||||
onLinkOpen,
|
||||
onExpandRail,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = React.useState<boolean>(false);
|
||||
const containsActive = hasActiveChild(item, activeId);
|
||||
const isExpanded = isOpen || containsActive;
|
||||
const listId = `sidebar-group-${item.id}`;
|
||||
|
||||
const toggle = (): void => {
|
||||
if (collapsed) {
|
||||
onExpandRail();
|
||||
setIsOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
const headerClasses = [styles.item, containsActive ? styles.itemActive : ''].filter(Boolean).join(' ');
|
||||
const chevron = (
|
||||
<span className={styles.groupChevron}>
|
||||
<Icon name={isExpanded ? 'chevron-down' : 'chevron-right'} size="sm" />
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.group}>
|
||||
<button
|
||||
type="button"
|
||||
className={headerClasses}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={listId}
|
||||
onClick={toggle}
|
||||
title={collapsed ? item.title : undefined}
|
||||
>
|
||||
<SidebarItemContent item={item} collapsed={collapsed} trailing={chevron} />
|
||||
</button>
|
||||
{!collapsed && isExpanded && (
|
||||
<div id={listId} className={styles.subItems}>
|
||||
{(item.children || []).map((child) => (
|
||||
<SidebarSubItem
|
||||
key={child.id}
|
||||
item={child}
|
||||
isActive={child.id === activeId}
|
||||
onSelect={onSelect}
|
||||
onLinkOpen={onLinkOpen}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Sidebar.module.scss';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { ISidebarItem } from './sidebarLogic';
|
||||
|
||||
interface ISidebarItemContentProps {
|
||||
item: ISidebarItem;
|
||||
collapsed: boolean;
|
||||
/** Справа после названия (стрелка группы). */
|
||||
trailing?: React.ReactNode;
|
||||
}
|
||||
|
||||
/** Иконка, название и бейдж пункта — одинаковые у раздела, ссылки и шапки группы. */
|
||||
export const SidebarItemContent: React.FC<ISidebarItemContentProps> = ({ item, collapsed, trailing }) => (
|
||||
<>
|
||||
<span className={styles.itemIcon}>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
</span>
|
||||
{!collapsed && <span className={styles.itemTitle}>{item.title}</span>}
|
||||
{!!item.count && <span className={styles.badge}>{item.count}</span>}
|
||||
{!collapsed && trailing}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import styles from './Sidebar.module.scss';
|
||||
import { ISidebarSubItem } from './sidebarLogic';
|
||||
|
||||
interface ISidebarSubItemProps {
|
||||
item: ISidebarSubItem;
|
||||
isActive: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onLinkOpen: () => void;
|
||||
}
|
||||
|
||||
/** Подпункт группы: внешняя ссылка (новая вкладка) или раздел (onChange). */
|
||||
export const SidebarSubItem: React.FC<ISidebarSubItemProps> = ({ item, isActive, onSelect, onLinkOpen }) => {
|
||||
const classes = [styles.subItem, isActive ? styles.subItemActive : ''].filter(Boolean).join(' ');
|
||||
const content = (
|
||||
<>
|
||||
<span className={styles.itemTitle}>{item.title}</span>
|
||||
{!!item.count && <span className={styles.badge}>{item.count}</span>}
|
||||
</>
|
||||
);
|
||||
|
||||
if (item.href) {
|
||||
return (
|
||||
<a className={classes} href={item.href} target="_blank" rel="noopener noreferrer" onClick={onLinkOpen}>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button type="button" className={classes} aria-current={isActive ? 'page' : undefined} onClick={() => onSelect(item.id)}>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { hasActiveChild, isItemHighlighted, ISidebarItem } from './sidebarLogic';
|
||||
|
||||
const PLAIN: ISidebarItem = { id: 'objects', title: 'Объекты', icon: 'folder' };
|
||||
const GROUP: ISidebarItem = {
|
||||
id: 'catalogs',
|
||||
title: 'Справочники',
|
||||
icon: 'info',
|
||||
children: [
|
||||
{ id: 'companies', title: 'Компании', href: 'https://example.com/companies' },
|
||||
{ id: 'settings', title: 'Настройки' }
|
||||
]
|
||||
};
|
||||
|
||||
describe('hasActiveChild — раскрытие группы', () => {
|
||||
it('активен подпункт — группа раскрыта', () => {
|
||||
expect(hasActiveChild(GROUP, 'settings')).toBe(true);
|
||||
});
|
||||
|
||||
it('активен другой раздел или пункт без вложенности — нет', () => {
|
||||
expect(hasActiveChild(GROUP, 'objects')).toBe(false);
|
||||
expect(hasActiveChild(PLAIN, 'objects')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isItemHighlighted — подсветка пункта', () => {
|
||||
it('обычный пункт — по своему id', () => {
|
||||
expect(isItemHighlighted(PLAIN, 'objects')).toBe(true);
|
||||
expect(isItemHighlighted(PLAIN, 'catalogs')).toBe(false);
|
||||
});
|
||||
|
||||
it('группа — когда активен её подпункт', () => {
|
||||
expect(isItemHighlighted(GROUP, 'settings')).toBe(true);
|
||||
expect(isItemHighlighted(GROUP, 'objects')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { IconName } from '../Icon/iconNames';
|
||||
|
||||
/** Подпункт раскрывающейся группы. Без иконки — отступ показывает вложенность. */
|
||||
export interface ISidebarSubItem {
|
||||
/** Уникальный идентификатор (среди всех пунктов меню). */
|
||||
id: string;
|
||||
title: string;
|
||||
/** Внешняя ссылка: открывается в новой вкладке, onChange не вызывается. */
|
||||
href?: string;
|
||||
/** Счётчик-бейдж справа. 0/undefined — не показывать. */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface ISidebarItem {
|
||||
/** Уникальный идентификатор раздела (любая строка). */
|
||||
id: string;
|
||||
title: string;
|
||||
icon: IconName;
|
||||
/** Счётчик-бейдж справа (например, число заявок в очереди). 0/undefined — не показывать. */
|
||||
count?: number;
|
||||
/** Внешняя ссылка: пункт открывается в новой вкладке, onChange не вызывается. */
|
||||
href?: string;
|
||||
/**
|
||||
* Вложенные пункты: пункт становится раскрывающейся группой (клик — раскрыть/свернуть),
|
||||
* сам он разделом не является и onChange не вызывает.
|
||||
*/
|
||||
children?: ISidebarSubItem[];
|
||||
}
|
||||
|
||||
/** Активный раздел — один из подпунктов группы: группу показываем раскрытой и подсвеченной. */
|
||||
export function hasActiveChild(item: ISidebarItem, activeId: string): boolean {
|
||||
return (item.children || []).some((child) => child.id === activeId);
|
||||
}
|
||||
|
||||
/** Подсветка пункта: сам раздел активен или (для группы) активен подпункт. */
|
||||
export function isItemHighlighted(item: ISidebarItem, activeId: string): boolean {
|
||||
return item.id === activeId || hasActiveChild(item, activeId);
|
||||
}
|
||||
+4
-2
@@ -5,9 +5,11 @@
|
||||
|
||||
// --- Кнопки ---
|
||||
export { Button } from './Button/Button';
|
||||
export { PrintButton } from './PrintButton/PrintButton';
|
||||
export { PrintButton, printRoot } from './PrintButton/PrintButton';
|
||||
export { IconButton } from './IconButton/IconButton';
|
||||
export type { IIconButtonProps } from './IconButton/IconButton';
|
||||
export { DropdownButton } from './DropdownButton/DropdownButton';
|
||||
export type { IDropdownButtonProps, IDropdownButtonOption } from './DropdownButton/DropdownButton';
|
||||
|
||||
// --- Сообщения / плашки ---
|
||||
export { Alert } from './Alert/Alert';
|
||||
@@ -71,7 +73,7 @@ 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 type { ISidebarItem, ISidebarSubItem, ISidebarProps } from './Sidebar/Sidebar';
|
||||
export { SidebarToggle } from './Sidebar/SidebarToggle';
|
||||
export type { ISidebarToggleProps } from './Sidebar/SidebarToggle';
|
||||
export { Link } from './Link/Link';
|
||||
|
||||
Reference in New Issue
Block a user