diff --git a/package-lock.json b/package-lock.json index 492e652..697a6e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 024ed66..6224e00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tnsu/ui-kit", - "version": "0.12.0", + "version": "0.13.0", "description": "TNSU shared UI components for SPFx web parts", "author": "Timur Gorodetskiy", "license": "ISC", diff --git a/src/Sidebar/Sidebar.module.scss b/src/Sidebar/Sidebar.module.scss index 0f572b5..a057c7d 100644 --- a/src/Sidebar/Sidebar.module.scss +++ b/src/Sidebar/Sidebar.module.scss @@ -97,6 +97,8 @@ font-size: 13.5px; font-weight: 500; text-align: left; + // Пункт-ссылка () выглядит так же, как пункт-кнопка. + 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; diff --git a/src/Sidebar/Sidebar.stories.tsx b/src/Sidebar/Sidebar.stories.tsx index 2f8dd2b..cc2ec6e 100644 --- a/src/Sidebar/Sidebar.stories.tsx +++ b/src/Sidebar/Sidebar.stories.tsx @@ -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 ( + setCollapsed((c) => !c)} + /> + ); + }, +}; + // Мобильный drawer: сузь окно браузера (или переключи Viewport в панели Storybook) // ниже 768px — сайдбар спрячется, а «☰ Меню» его выдвинет поверх контента. export const MobileDrawer: Story = { diff --git a/src/Sidebar/Sidebar.tsx b/src/Sidebar/Sidebar.tsx index d38975a..0a4cd50 100644 --- a/src/Sidebar/Sidebar.tsx +++ b/src/Sidebar/Sidebar.tsx @@ -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 = ({ // На мобилке после выбора раздела 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 = ({
- {items.map((item) => { - const isActive = item.id === activeId; - const itemClasses = [styles.item, isActive ? styles.itemActive : ''].filter(Boolean).join(' '); - const showCount = !!item.count; - - return ( - - ); - })} + item={item} + activeId={activeId} + collapsed={collapsed} + onSelect={handleSelect} + onLinkOpen={closeMobile} + onExpandRail={onToggleCollapsed} + /> + ) : ( + + ) + )}
diff --git a/src/Sidebar/SidebarEntry.tsx b/src/Sidebar/SidebarEntry.tsx new file mode 100644 index 0000000..6ff6c34 --- /dev/null +++ b/src/Sidebar/SidebarEntry.tsx @@ -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 = ({ 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 ( +
+ + + ); + } + + return ( + + ); +}; diff --git a/src/Sidebar/SidebarGroup.tsx b/src/Sidebar/SidebarGroup.tsx new file mode 100644 index 0000000..1917787 --- /dev/null +++ b/src/Sidebar/SidebarGroup.tsx @@ -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 = ({ + item, + activeId, + collapsed, + onSelect, + onLinkOpen, + onExpandRail, +}) => { + const [isOpen, setIsOpen] = React.useState(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 = ( + + + + ); + + return ( +
+ + {!collapsed && isExpanded && ( +
+ {(item.children || []).map((child) => ( + + ))} +
+ )} +
+ ); +}; diff --git a/src/Sidebar/SidebarItemContent.tsx b/src/Sidebar/SidebarItemContent.tsx new file mode 100644 index 0000000..4d61de1 --- /dev/null +++ b/src/Sidebar/SidebarItemContent.tsx @@ -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 = ({ item, collapsed, trailing }) => ( + <> + + + + {!collapsed && {item.title}} + {!!item.count && {item.count}} + {!collapsed && trailing} + +); diff --git a/src/Sidebar/SidebarSubItem.tsx b/src/Sidebar/SidebarSubItem.tsx new file mode 100644 index 0000000..ff0543d --- /dev/null +++ b/src/Sidebar/SidebarSubItem.tsx @@ -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 = ({ item, isActive, onSelect, onLinkOpen }) => { + const classes = [styles.subItem, isActive ? styles.subItemActive : ''].filter(Boolean).join(' '); + const content = ( + <> + {item.title} + {!!item.count && {item.count}} + + ); + + if (item.href) { + return ( + + {content} + + ); + } + return ( + + ); +}; diff --git a/src/Sidebar/sidebarLogic.test.ts b/src/Sidebar/sidebarLogic.test.ts new file mode 100644 index 0000000..eb55ab3 --- /dev/null +++ b/src/Sidebar/sidebarLogic.test.ts @@ -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); + }); +}); diff --git a/src/Sidebar/sidebarLogic.ts b/src/Sidebar/sidebarLogic.ts new file mode 100644 index 0000000..e3ecc30 --- /dev/null +++ b/src/Sidebar/sidebarLogic.ts @@ -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); +} diff --git a/src/index.ts b/src/index.ts index b5591bb..0301223 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,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';