diff --git a/src/lib/components/menu/menu.component.ts b/src/lib/components/menu/menu.component.ts new file mode 100644 index 0000000..a692f69 --- /dev/null +++ b/src/lib/components/menu/menu.component.ts @@ -0,0 +1,59 @@ +import { Component, Input, TemplateRef, ViewChild } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { Menu } from 'primeng/menu'; +import { MenuItem, PrimeTemplate } from 'primeng/api'; + +export interface MenuModel extends MenuItem { + caption?: string; +} + +@Component({ + selector: 'menu', + host: { style: 'display: contents' }, + standalone: true, + imports: [Menu, PrimeTemplate, NgTemplateOutlet], + template: ` + + + @if (itemTemplate) { + + + } @else { + + @if (item.icon) { + + } + @if ($any(item).caption) { + + } @else { + {{ item.label }} + } + + } + + + `, +}) +export class MenuComponent { + @ViewChild('menuRef') menuRef!: Menu; + + @Input() model: MenuModel[] = []; + @Input() popup = false; + @Input() itemTemplate: TemplateRef | null = null; + + toggle(event: Event): void { + this.menuRef.toggle(event); + } +} diff --git a/src/prime-preset/tokens/components/menu.ts b/src/prime-preset/tokens/components/menu.ts new file mode 100644 index 0000000..2ef5fcf --- /dev/null +++ b/src/prime-preset/tokens/components/menu.ts @@ -0,0 +1,67 @@ +export const menuCss = ({ dt }: { dt: (token: string) => string }): string => ` + .p-menu.p-component { + padding: ${dt('menu.extend.paddingY')} ${dt('menu.extend.paddingX')}; + } + + .p-menu .p-menu-item-content .p-menu-item-link .p-menu-item-label { + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.300')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: ${dt('fonts.lineHeight.400')}; + } + + .p-menu .p-menu-item-content .menu-item-label { + display: flex; + flex-direction: column; + gap: ${dt('menu.extend.extItem.caption.gap')}; + } + + .p-menu .p-menu-item-content .menu-item-caption { + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.200')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + color: ${dt('menu.colorScheme.light.extend.extItem.caption.color')}; + } + + .p-menu .p-menu-item:not(.p-disabled) .p-menu-item-content:hover, + .p-menu .p-menu-item:not(.p-disabled) .p-menu-item-content:hover .p-menu-item-link, + .p-menu .p-menu-item:not(.p-disabled) .p-menu-item-content:hover .p-menu-item-label, + .p-menu .p-menu-item:not(.p-disabled) .p-menu-item-content:hover .p-menu-item-icon { + background: ${dt('menu.colorScheme.light.item.focusBackground')}; + color: ${dt('menu.colorScheme.light.item.focusColor')}; + } + + .p-menu .p-menu-item.p-menuitem-checked > .p-menu-item-content, + .p-menu .p-menu-item.p-focus > .p-menu-item-content { + background: ${dt('menu.extend.extItem.activeBackground')}; + color: ${dt('menu.extend.extItem.activeColor')}; + } + + .p-menu .p-menu-item.p-menuitem-checked > .p-menu-item-content .p-menu-item-link, + .p-menu .p-menu-item.p-menuitem-checked > .p-menu-item-content .p-menu-item-label, + .p-menu .p-menu-item.p-focus > .p-menu-item-content .p-menu-item-link, + .p-menu .p-menu-item.p-focus > .p-menu-item-content .p-menu-item-label { + color: ${dt('menu.extend.extItem.activeColor')}; + } + + .p-menu .p-menu-item.p-menuitem-checked > .p-menu-item-content .p-menu-item-icon, + .p-menu .p-menu-item.p-focus > .p-menu-item-content .p-menu-item-icon { + color: ${dt('menu.colorScheme.light.extend.extItem.icon.activeColor')}; + } + + .p-menu .p-menu-item.p-menuitem-checked:not(.p-disabled) > .p-menu-item-content:hover { + background: ${dt('menu.colorScheme.light.item.focusBackground')}; + color: ${dt('menu.colorScheme.light.item.focusColor')}; + } + + .p-menu .p-menu-item.p-menuitem-checked:not(.p-disabled) > .p-menu-item-content:hover .p-menu-item-icon { + color: ${dt('menu.colorScheme.light.item.focusColor')}; + } + + .p-menu .p-menu-submenu-label { + text-transform: uppercase; + font-size: ${dt('fonts.fontSize.200')}; + font-family: ${dt('fonts.fontFamily.heading')}; + line-height: ${dt('fonts.lineHeight.400')}; + } +`; diff --git a/src/stories/components/menu/examples/menu-basic.component.ts b/src/stories/components/menu/examples/menu-basic.component.ts new file mode 100644 index 0000000..e18fe08 --- /dev/null +++ b/src/stories/components/menu/examples/menu-basic.component.ts @@ -0,0 +1,23 @@ +import { Component } from '@angular/core'; +import { MenuComponent, MenuModel } from '../../../../lib/components/menu/menu.component'; + +const template = ` +
+ +
+`; + +@Component({ + selector: 'app-menu-basic', + standalone: true, + imports: [MenuComponent], + template, +}) +export class MenuBasicComponent { + items: MenuModel[] = [ + { label: 'Новый заказ' }, + { label: 'Поиск отправления' }, + { separator: true }, + { label: 'Экспорт' }, + ]; +} diff --git a/src/stories/components/menu/examples/menu-custom.component.ts b/src/stories/components/menu/examples/menu-custom.component.ts new file mode 100644 index 0000000..3d04f2b --- /dev/null +++ b/src/stories/components/menu/examples/menu-custom.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { MenuComponent, MenuModel } from '../../../../lib/components/menu/menu.component'; + +const template = ` +
+ +
+`; + +@Component({ + selector: 'app-menu-custom', + standalone: true, + imports: [MenuComponent], + template, +}) +export class MenuCustomComponent { + items: MenuModel[] = [ + { + label: 'Создать отправление', + caption: 'Оформление нового заказа', + icon: 'ti ti-file-plus', + }, + { + label: 'Найти посылку', + caption: 'Поиск по трек-номеру', + icon: 'ti ti-map-pin', + }, + { separator: true }, + { + label: 'Экспорт данных', + caption: 'Выгрузка в CSV или Excel', + icon: 'ti ti-download', + }, + ]; +} diff --git a/src/stories/components/menu/examples/menu-grouped.component.ts b/src/stories/components/menu/examples/menu-grouped.component.ts new file mode 100644 index 0000000..d1050f3 --- /dev/null +++ b/src/stories/components/menu/examples/menu-grouped.component.ts @@ -0,0 +1,35 @@ +import { Component } from '@angular/core'; +import { MenuComponent, MenuModel } from '../../../../lib/components/menu/menu.component'; + +const template = ` +
+ +
+`; + +@Component({ + selector: 'app-menu-grouped', + standalone: true, + imports: [MenuComponent], + template, +}) +export class MenuGroupedComponent { + items: MenuModel[] = [ + { + label: 'Заказы', + items: [ + { label: 'Новый заказ', icon: 'ti ti-plus' }, + { label: 'Список заказов', icon: 'ti ti-list' }, + { label: 'Архив', icon: 'ti ti-archive' }, + ], + }, + { + label: 'Отправления', + items: [ + { label: 'Создать накладную', icon: 'ti ti-file-invoice' }, + { label: 'Отследить посылку', icon: 'ti ti-map-pin' }, + { label: 'Отменить отправление', icon: 'ti ti-ban' }, + ], + }, + ]; +} diff --git a/src/stories/components/menu/examples/menu-popup.component.ts b/src/stories/components/menu/examples/menu-popup.component.ts new file mode 100644 index 0000000..2b18c79 --- /dev/null +++ b/src/stories/components/menu/examples/menu-popup.component.ts @@ -0,0 +1,31 @@ +import { Component, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { MenuComponent, MenuModel } from '../../../../lib/components/menu/menu.component'; + +const template = ` +
+ + +
+`; + +@Component({ + selector: 'app-menu-popup', + standalone: true, + imports: [MenuComponent, Button], + template, +}) +export class MenuPopupComponent { + @ViewChild('menuRef') menuRef!: MenuComponent; + + items: MenuModel[] = [ + { label: 'Создать отправление', icon: 'ti ti-file-plus' }, + { label: 'Найти по трек-номеру', icon: 'ti ti-search' }, + { separator: true }, + { label: 'Экспорт данных', icon: 'ti ti-download' }, + ]; + + toggle(event: Event): void { + this.menuRef.toggle(event); + } +} diff --git a/src/stories/components/menu/examples/menu-with-icons.component.ts b/src/stories/components/menu/examples/menu-with-icons.component.ts new file mode 100644 index 0000000..dbffd8d --- /dev/null +++ b/src/stories/components/menu/examples/menu-with-icons.component.ts @@ -0,0 +1,25 @@ +import { Component } from '@angular/core'; +import { MenuComponent, MenuModel } from '../../../../lib/components/menu/menu.component'; + +const template = ` +
+ +
+`; + +@Component({ + selector: 'app-menu-with-icons', + standalone: true, + imports: [MenuComponent], + template, +}) +export class MenuWithIconsComponent { + items: MenuModel[] = [ + { label: 'Создать отправление', icon: 'ti ti-file-plus' }, + { label: 'Открыть список заказов', icon: 'ti ti-folder-open' }, + { label: 'Сохранить черновик', icon: 'ti ti-device-floppy' }, + { separator: true }, + { label: 'Распечатать накладную', icon: 'ti ti-printer' }, + { label: 'Экспорт данных', icon: 'ti ti-download' }, + ]; +} diff --git a/src/stories/components/menu/menu.stories.ts b/src/stories/components/menu/menu.stories.ts new file mode 100644 index 0000000..b226d21 --- /dev/null +++ b/src/stories/components/menu/menu.stories.ts @@ -0,0 +1,276 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { MenuComponent } from '../../../lib/components/menu/menu.component'; +import { MenuPopupComponent } from './examples/menu-popup.component'; +import { MenuBasicComponent } from './examples/menu-basic.component'; +import { MenuWithIconsComponent } from './examples/menu-with-icons.component'; +import { MenuGroupedComponent } from './examples/menu-grouped.component'; +import { MenuCustomComponent } from './examples/menu-custom.component'; + +const meta: Meta = { + title: 'Components/Menu/Menu', + component: MenuComponent, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: `Компонент навигационного меню. Поддерживает режим popup (по нажатию на триггер) и inline-отображение, группировку пунктов и пункты с описанием (caption). + +\`\`\`typescript +import { MenuComponent } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + }, + designTokens: { prefix: '--p-menu' }, + }, + argTypes: { + model: { + control: false, + description: 'Массив пунктов меню.', + table: { + category: 'Props', + type: { summary: 'MenuModel[]' }, + }, + }, + popup: { + control: 'boolean', + description: 'Режим popup — меню отображается при вызове метода toggle().', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ── Popup ───────────────────────────────────────────────────────────────────── + +export const Default: Story = { + name: 'Popup', + decorators: [moduleMetadata({ imports: [MenuPopupComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { + story: 'Меню вызывается по нажатию на кнопку. Используйте метод toggle() для показа/скрытия.', + }, + source: { + language: 'ts', + code: ` +import { Component, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { MenuComponent, MenuModel } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-menu-popup', + standalone: true, + imports: [MenuComponent, Button], + template: \` + + + \`, +}) +export class MenuPopupComponent { + @ViewChild('menuRef') menuRef!: MenuComponent; + + items: MenuModel[] = [ + { label: 'Создать отправление', icon: 'ti ti-file-plus' }, + { label: 'Найти по трек-номеру', icon: 'ti ti-search' }, + { separator: true }, + { label: 'Экспорт данных', icon: 'ti ti-download' }, + ]; + + toggle(event: Event): void { + this.menuRef.toggle(event); + } +} + `, + }, + }, + }, +}; + +// ── Basic ───────────────────────────────────────────────────────────────────── + +export const Basic: Story = { + name: 'Basic', + decorators: [moduleMetadata({ imports: [MenuBasicComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { + story: 'Базовый вариант inline-меню без иконок.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { MenuComponent, MenuModel } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-menu-basic', + standalone: true, + imports: [MenuComponent], + template: \` + + \`, +}) +export class MenuBasicComponent { + items: MenuModel[] = [ + { label: 'Новый заказ' }, + { label: 'Поиск отправления' }, + { separator: true }, + { label: 'Экспорт' }, + ]; +} + `, + }, + }, + }, +}; + +// ── WithIcons ───────────────────────────────────────────────────────────────── + +export const WithIcons: Story = { + name: 'WithIcons', + decorators: [moduleMetadata({ imports: [MenuWithIconsComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { + story: 'Пункты меню с иконками.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { MenuComponent, MenuModel } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-menu-with-icons', + standalone: true, + imports: [MenuComponent], + template: \` + + \`, +}) +export class MenuWithIconsComponent { + items: MenuModel[] = [ + { label: 'Создать отправление', icon: 'ti ti-file-plus' }, + { label: 'Открыть список заказов', icon: 'ti ti-folder-open' }, + { label: 'Сохранить черновик', icon: 'ti ti-device-floppy' }, + { separator: true }, + { label: 'Распечатать накладную', icon: 'ti ti-printer' }, + { label: 'Экспорт данных', icon: 'ti ti-download' }, + ]; +} + `, + }, + }, + }, +}; + +// ── Grouped ─────────────────────────────────────────────────────────────────── + +export const Grouped: Story = { + name: 'Grouped', + decorators: [moduleMetadata({ imports: [MenuGroupedComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { + story: 'Группировка пунктов меню через label у родительского элемента.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { MenuComponent, MenuModel } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-menu-grouped', + standalone: true, + imports: [MenuComponent], + template: \` + + \`, +}) +export class MenuGroupedComponent { + items: MenuModel[] = [ + { + label: 'Заказы', + items: [ + { label: 'Новый заказ', icon: 'ti ti-plus' }, + { label: 'Список заказов', icon: 'ti ti-list' }, + { label: 'Архив', icon: 'ti ti-archive' }, + ], + }, + { + label: 'Отправления', + items: [ + { label: 'Создать накладную', icon: 'ti ti-file-invoice' }, + { label: 'Отследить посылку', icon: 'ti ti-map-pin' }, + { label: 'Отменить отправление', icon: 'ti ti-ban' }, + ], + }, + ]; +} + `, + }, + }, + }, +}; + +// ── Custom ──────────────────────────────────────────────────────────────────── + +export const Custom: Story = { + name: 'Custom', + decorators: [moduleMetadata({ imports: [MenuCustomComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { + story: 'Пункты меню с иконкой и описанием (caption). Поле caption передаётся через MenuModel.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { MenuComponent, MenuModel } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-menu-custom', + standalone: true, + imports: [MenuComponent], + template: \` + + \`, +}) +export class MenuCustomComponent { + items: MenuModel[] = [ + { + label: 'Создать отправление', + caption: 'Оформление нового заказа', + icon: 'ti ti-file-plus', + }, + { + label: 'Найти посылку', + caption: 'Поиск по трек-номеру', + icon: 'ti ti-map-pin', + }, + { separator: true }, + { + label: 'Экспорт данных', + caption: 'Выгрузка в CSV или Excel', + icon: 'ti ti-download', + }, + ]; +} + `, + }, + }, + }, +};