diff --git a/src/lib/components/panelmenu/panelmenu.component.ts b/src/lib/components/panelmenu/panelmenu.component.ts
new file mode 100644
index 0000000..efb3516
--- /dev/null
+++ b/src/lib/components/panelmenu/panelmenu.component.ts
@@ -0,0 +1,58 @@
+import { AfterViewChecked, ChangeDetectionStrategy, Component, ElementRef, HostListener, Input } from '@angular/core';
+import { PanelMenu } from 'primeng/panelmenu';
+import { MenuItem } from 'primeng/api';
+
+@Component({
+ selector: 'panelmenu',
+ standalone: true,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ imports: [PanelMenu],
+ template: `
+
+ `,
+})
+export class PanelMenuComponent implements AfterViewChecked {
+ @Input() model: MenuItem[] = [];
+ @Input() multiple = false;
+ @Input() tabindex: number | undefined = undefined;
+
+ private activeItemId: string | null = null;
+
+ constructor(private readonly el: ElementRef) {}
+
+ @HostListener('click', ['$event'])
+ onItemClick(event: MouseEvent): void {
+ const target = event.target as Element;
+
+ if (target.closest('.p-panelmenu-header')) return;
+
+ const item = target.closest('.p-panelmenu-item');
+ if (!item) return;
+
+ this.activeItemId = item.id || null;
+ this.applyActiveClass();
+ }
+
+ ngAfterViewChecked(): void {
+ if (this.activeItemId) {
+ this.applyActiveClass();
+ }
+ }
+
+ private applyActiveClass(): void {
+ const root = this.el.nativeElement;
+ root.querySelectorAll('.p-panelmenu-item-active')
+ .forEach(el => el.classList.remove('p-panelmenu-item-active'));
+
+ if (this.activeItemId) {
+ const active = root.querySelector(`#${CSS.escape(this.activeItemId)}`);
+ if (active) {
+ active.classList.add('p-panelmenu-item-active');
+ }
+ }
+ }
+}
diff --git a/src/prime-preset/map-tokens.ts b/src/prime-preset/map-tokens.ts
index a5dd634..687c020 100644
--- a/src/prime-preset/map-tokens.ts
+++ b/src/prime-preset/map-tokens.ts
@@ -5,6 +5,7 @@ import type { AuraBaseDesignTokens } from '@primeuix/themes/aura/base';
import tokens from './tokens/tokens.json';
import { avatarCss } from './tokens/components/avatar';
import { buttonCss } from './tokens/components/button';
+import { panelmenuCss } from './tokens/components/panelmenu';
import { tooltipCss } from './tokens/components/tooltip';
const presetTokens: Preset = {
@@ -20,6 +21,10 @@ const presetTokens: Preset = {
...(tokens.components.button as unknown as ComponentsDesignTokens['button']),
css: buttonCss,
},
+ panelmenu: {
+ ...(tokens.components.panelmenu as unknown as ComponentsDesignTokens['panelmenu']),
+ css: panelmenuCss,
+ },
tooltip: {
...(tokens.components.tooltip as unknown as ComponentsDesignTokens['tooltip']),
css: tooltipCss,
diff --git a/src/prime-preset/tokens/components/panelmenu.ts b/src/prime-preset/tokens/components/panelmenu.ts
new file mode 100644
index 0000000..0ef7994
--- /dev/null
+++ b/src/prime-preset/tokens/components/panelmenu.ts
@@ -0,0 +1,68 @@
+export const panelmenuCss = ({ dt }: { dt: (token: string) => string }): string => `
+ .p-panelmenu {
+ gap: ${dt('panelmenu.extend.extPanel.gap')};
+ }
+
+ .p-panelmenu-panel {
+ padding: ${dt('panelmenu.extend.extPanel.gap')};
+ }
+
+ .p-panelmenu-header-content,
+ .p-panelmenu-item-content {
+ font-size: ${dt('fonts.fontSize.300')};
+ }
+
+ .p-panelmenu-submenu-icon {
+ font-size: ${dt('panelmenu.extend.iconSize')};
+ }
+
+ /* ─── Active & Focused States ─── */
+
+ .p-panelmenu .p-panelmenu-item.p-panelmenu-item-active > .p-panelmenu-item-content,
+ .p-panelmenu .p-panelmenu-item.p-focus > .p-panelmenu-item-content,
+ .p-panelmenu .p-panelmenu-header.p-focus .p-panelmenu-header-content {
+ background: ${dt('panelmenu.extend.extItem.activeBackground')};
+ color: ${dt('panelmenu.extend.extItem.activeColor')};
+ }
+
+ .p-panelmenu .p-panelmenu-item.p-panelmenu-item-active > .p-panelmenu-item-content :is(.p-panelmenu-item-link, .p-panelmenu-item-label, .p-panelmenu-item-icon, .p-panelmenu-submenu-icon),
+ .p-panelmenu .p-panelmenu-item.p-focus > .p-panelmenu-item-content :is(.p-panelmenu-item-link, .p-panelmenu-item-label, .p-panelmenu-item-icon, .p-panelmenu-header-icon, .p-panelmenu-submenu-icon),
+ .p-panelmenu .p-panelmenu-header.p-focus .p-panelmenu-header-content :is(.p-panelmenu-header-link, .p-panelmenu-header-label, .p-panelmenu-submenu-icon, .p-panelmenu-item-icon, .p-panelmenu-header-icon) {
+ color: ${dt('panelmenu.extend.extItem.activeColor')};
+ }
+
+ /* ─── Hover on Active States ─── */
+
+ .p-panelmenu .p-panelmenu-item.p-panelmenu-item-active:not(.p-disabled) > .p-panelmenu-item-content:hover,
+ .p-panelmenu .p-panelmenu-item.p-focus:not(.p-disabled) > .p-panelmenu-item-content:hover,
+ .p-panelmenu .p-panelmenu-header.p-focus .p-panelmenu-header-content:hover {
+ background: ${dt('panelmenu.item.focusBackground')};
+ color: ${dt('panelmenu.item.focusColor')};
+ }
+
+ .p-panelmenu .p-panelmenu-item.p-panelmenu-item-active:not(.p-disabled) > .p-panelmenu-item-content:hover :is(.p-panelmenu-item-link, .p-panelmenu-item-label),
+ .p-panelmenu .p-panelmenu-item.p-focus:not(.p-disabled) > .p-panelmenu-item-content:hover :is(.p-panelmenu-item-link, .p-panelmenu-item-label),
+ .p-panelmenu .p-panelmenu-header.p-focus .p-panelmenu-header-content:hover :is(.p-panelmenu-header-link, .p-panelmenu-header-label) {
+ color: ${dt('panelmenu.item.focusColor')};
+ }
+
+ .p-panelmenu .p-panelmenu-item.p-panelmenu-item-active:not(.p-disabled) > .p-panelmenu-item-content:hover :is(.p-panelmenu-item-icon, .p-panelmenu-submenu-icon),
+ .p-panelmenu .p-panelmenu-item.p-focus:not(.p-disabled) > .p-panelmenu-item-content:hover :is(.p-panelmenu-item-icon, .p-panelmenu-submenu-icon),
+ .p-panelmenu .p-panelmenu-header.p-focus .p-panelmenu-header-content:hover :is(.p-panelmenu-submenu-icon, .p-panelmenu-item-icon) {
+ color: ${dt('panelmenu.item.icon.focusColor')};
+ }
+
+ /* ─── Captions ─── */
+
+ .p-panelmenu .panelmenu-item-label {
+ display: flex;
+ flex-direction: column;
+ gap: ${dt('panelmenu.extend.extItem.caption.gap')};
+ }
+
+ .p-panelmenu .panelmenu-item-caption {
+ font-size: ${dt('fonts.fontSize.200')};
+ line-height: ${dt('fonts.lineHeight.450')};
+ color: ${dt('panelmenu.extend.extItem.caption.color')};
+ }
+`;
diff --git a/src/stories/components/panelmenu/examples/panelmenu-basic.component.ts b/src/stories/components/panelmenu/examples/panelmenu-basic.component.ts
new file mode 100644
index 0000000..d19cafd
--- /dev/null
+++ b/src/stories/components/panelmenu/examples/panelmenu-basic.component.ts
@@ -0,0 +1,93 @@
+import { Component } from '@angular/core';
+import { StoryObj } from '@storybook/angular';
+import { MenuItem } from 'primeng/api';
+import { PanelMenuComponent } from '../../../../lib/components/panelmenu/panelmenu.component';
+
+const template = `
+
+`;
+const styles = '';
+
+@Component({
+ selector: 'app-panelmenu-basic',
+ standalone: true,
+ imports: [PanelMenuComponent],
+ template,
+ styles,
+})
+export class PanelMenuBasicComponent {
+ items: MenuItem[] = [
+ {
+ label: 'Отправления',
+ items: [
+ { label: 'Новые' },
+ { label: 'В пути' },
+ { label: 'Доставленные' },
+ { label: 'Возвраты', items: [{ label: 'Ожидают' }, { label: 'Завершённые' }] },
+ ],
+ },
+ { label: 'Маршруты' },
+ {
+ label: 'Склады',
+ items: [
+ { label: 'Москва' },
+ { label: 'Новосибирск' },
+ { label: 'Екатеринбург' },
+ ],
+ },
+ { label: 'Настройки', disabled: true },
+ ];
+}
+
+export const Basic: StoryObj = {
+ render: () => ({
+ template: ``,
+ }),
+ parameters: {
+ docs: {
+ description: { story: 'Базовое аккордеон-меню без иконок.' },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { MenuItem } from 'primeng/api';
+import { PanelMenuComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ selector: 'app-panelmenu-basic',
+ standalone: true,
+ imports: [PanelMenuComponent],
+ template: \`
+
+ \`,
+})
+export class PanelMenuBasicComponent {
+ items: MenuItem[] = [
+ {
+ label: 'Отправления',
+ items: [
+ { label: 'Новые' },
+ { label: 'В пути' },
+ { label: 'Доставленные' },
+ { label: 'Возвраты', items: [{ label: 'Ожидают' }, { label: 'Завершённые' }] },
+ ],
+ },
+ { label: 'Маршруты' },
+ {
+ label: 'Склады',
+ items: [
+ { label: 'Москва' },
+ { label: 'Новосибирск' },
+ { label: 'Екатеринбург' },
+ ],
+ },
+ { label: 'Настройки', disabled: true },
+ ];
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/panelmenu/examples/panelmenu-custom.component.ts b/src/stories/components/panelmenu/examples/panelmenu-custom.component.ts
new file mode 100644
index 0000000..3a1ac66
--- /dev/null
+++ b/src/stories/components/panelmenu/examples/panelmenu-custom.component.ts
@@ -0,0 +1,134 @@
+import { Component } from '@angular/core';
+import { StoryObj } from '@storybook/angular';
+import { MenuItem } from 'primeng/api';
+import { PanelMenu } from 'primeng/panelmenu';
+import { Badge } from 'primeng/badge';
+import { NgIf, NgClass } from '@angular/common';
+
+const template = `
+
+`;
+const styles = '';
+
+@Component({
+ selector: 'app-panelmenu-custom',
+ standalone: true,
+ imports: [PanelMenu, Badge, NgIf, NgClass],
+ template,
+ styles,
+})
+export class PanelMenuCustomComponent {
+ items: MenuItem[] = [
+ {
+ label: 'Дашборд',
+ icon: 'ti ti-layout-dashboard',
+ description: 'Главная страница',
+ items: [
+ { label: 'Аналитика', icon: 'ti ti-chart-line', description: 'Аналитика данных' },
+ { label: 'Отчёты', icon: 'ti ti-file-analytics', description: 'Сводные отчёты' },
+ { label: 'Статистика', icon: 'ti ti-chart-bar', description: 'Показатели доставки' },
+ ],
+ },
+ {
+ label: 'Отправления',
+ icon: 'ti ti-package',
+ description: 'Управление заказами',
+ badge: 'New',
+ },
+ {
+ label: 'Склады',
+ icon: 'ti ti-building-warehouse',
+ description: 'Складское хранение',
+ items: [
+ { label: 'Документы', icon: 'ti ti-file-text', description: 'Накладные и акты' },
+ { label: 'Фото', icon: 'ti ti-photo', description: 'Фотофиксация грузов' },
+ ],
+ },
+ {
+ label: 'Настройки',
+ icon: 'ti ti-settings',
+ description: 'Параметры системы',
+ disabled: true,
+ },
+ ];
+}
+
+export const Custom: StoryObj = {
+ render: () => ({
+ template: ``,
+ }),
+ parameters: {
+ docs: {
+ description: { story: 'Кастомный шаблон пункта меню с описанием и бейджем.' },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { MenuItem } from 'primeng/api';
+import { PanelMenu } from 'primeng/panelmenu';
+import { Badge } from 'primeng/badge';
+import { NgIf } from '@angular/common';
+
+@Component({
+ selector: 'app-panelmenu-custom',
+ standalone: true,
+ imports: [PanelMenu, Badge, NgIf],
+ template: \`
+
+
+
+
+
+ \`,
+})
+export class PanelMenuCustomComponent {
+ items: MenuItem[] = [
+ {
+ label: 'Дашборд',
+ icon: 'ti ti-layout-dashboard',
+ description: 'Главная страница',
+ items: [
+ { label: 'Аналитика', icon: 'ti ti-chart-line', description: 'Аналитика данных' },
+ { label: 'Отчёты', icon: 'ti ti-file-analytics', description: 'Сводные отчёты' },
+ ],
+ },
+ { label: 'Отправления', icon: 'ti ti-package', description: 'Управление заказами', badge: 'New' },
+ {
+ label: 'Склады',
+ icon: 'ti ti-building-warehouse',
+ description: 'Складское хранение',
+ items: [
+ { label: 'Документы', icon: 'ti ti-file-text', description: 'Накладные и акты' },
+ { label: 'Фото', icon: 'ti ti-photo', description: 'Фотофиксация грузов' },
+ ],
+ },
+ { label: 'Настройки', icon: 'ti ti-settings', description: 'Параметры системы', disabled: true },
+ ];
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/panelmenu/examples/panelmenu-multiple.component.ts b/src/stories/components/panelmenu/examples/panelmenu-multiple.component.ts
new file mode 100644
index 0000000..606aaa4
--- /dev/null
+++ b/src/stories/components/panelmenu/examples/panelmenu-multiple.component.ts
@@ -0,0 +1,97 @@
+import { Component } from '@angular/core';
+import { StoryObj } from '@storybook/angular';
+import { MenuItem } from 'primeng/api';
+import { PanelMenuComponent } from '../../../../lib/components/panelmenu/panelmenu.component';
+
+const template = `
+
+`;
+const styles = '';
+
+@Component({
+ selector: 'app-panelmenu-multiple',
+ standalone: true,
+ imports: [PanelMenuComponent],
+ template,
+ styles,
+})
+export class PanelMenuMultipleComponent {
+ items: MenuItem[] = [
+ {
+ label: 'Отправления',
+ icon: 'ti ti-package',
+ items: [
+ { label: 'Новые', icon: 'ti ti-circle-plus' },
+ { label: 'В пути', icon: 'ti ti-truck' },
+ { label: 'Доставленные', icon: 'ti ti-circle-check' },
+ {
+ label: 'Возвраты',
+ icon: 'ti ti-arrow-back',
+ items: [{ label: 'Ожидают' }, { label: 'Завершённые' }],
+ },
+ ],
+ },
+ { label: 'Маршруты', icon: 'ti ti-route' },
+ {
+ label: 'Склады',
+ icon: 'ti ti-building-warehouse',
+ items: [
+ { label: 'Москва' },
+ { label: 'Новосибирск' },
+ { label: 'Екатеринбург' },
+ ],
+ },
+ { label: 'Настройки', icon: 'ti ti-settings', disabled: true },
+ ];
+}
+
+export const Multiple: StoryObj = {
+ render: () => ({
+ template: ``,
+ }),
+ parameters: {
+ docs: {
+ description: { story: 'Несколько панелей могут быть раскрыты одновременно.' },
+ source: {
+ language: 'ts',
+ code: `
+import { Component } from '@angular/core';
+import { MenuItem } from 'primeng/api';
+import { PanelMenuComponent } from '@cdek-it/angular-ui-kit';
+
+@Component({
+ selector: 'app-panelmenu-multiple',
+ standalone: true,
+ imports: [PanelMenuComponent],
+ template: \`
+
+ \`,
+})
+export class PanelMenuMultipleComponent {
+ items: MenuItem[] = [
+ {
+ label: 'Отправления',
+ icon: 'ti ti-package',
+ items: [
+ { label: 'Новые', icon: 'ti ti-circle-plus' },
+ { label: 'В пути', icon: 'ti ti-truck' },
+ { label: 'Доставленные', icon: 'ti ti-circle-check' },
+ { label: 'Возвраты', icon: 'ti ti-arrow-back', items: [{ label: 'Ожидают' }, { label: 'Завершённые' }] },
+ ],
+ },
+ { label: 'Маршруты', icon: 'ti ti-route' },
+ {
+ label: 'Склады',
+ icon: 'ti ti-building-warehouse',
+ items: [{ label: 'Москва' }, { label: 'Новосибирск' }, { label: 'Екатеринбург' }],
+ },
+ { label: 'Настройки', icon: 'ti ti-settings', disabled: true },
+ ];
+}
+ `,
+ },
+ },
+ },
+};
diff --git a/src/stories/components/panelmenu/panelmenu.stories.ts b/src/stories/components/panelmenu/panelmenu.stories.ts
new file mode 100644
index 0000000..8a600d6
--- /dev/null
+++ b/src/stories/components/panelmenu/panelmenu.stories.ts
@@ -0,0 +1,108 @@
+import { Meta, StoryObj, moduleMetadata } from '@storybook/angular';
+import { PanelMenuComponent } from '../../../lib/components/panelmenu/panelmenu.component';
+import { PanelMenuBasicComponent, Basic } from './examples/panelmenu-basic.component';
+import { PanelMenuMultipleComponent, Multiple } from './examples/panelmenu-multiple.component';
+import { PanelMenuCustomComponent, Custom } from './examples/panelmenu-custom.component';
+
+const meta: Meta = {
+ title: 'Components/Menu/PanelMenu',
+ component: PanelMenuComponent,
+ tags: ['autodocs'],
+ decorators: [
+ moduleMetadata({
+ imports: [
+ PanelMenuComponent,
+ PanelMenuBasicComponent,
+ PanelMenuMultipleComponent,
+ PanelMenuCustomComponent,
+ ],
+ }),
+ ],
+ parameters: {
+ docs: {
+ description: {
+ component: `Аккордеон-меню с поддержкой вложенных подменю и раскрытием нескольких панелей.
+
+\`\`\`typescript
+import { PanelMenuComponent } from '@cdek-it/angular-ui-kit';
+\`\`\``,
+ },
+ },
+ designTokens: { prefix: '--p-panelmenu' },
+ },
+ argTypes: {
+ model: {
+ table: { disable: true },
+ },
+ multiple: {
+ control: 'boolean',
+ description: 'Разрешает одновременное раскрытие нескольких панелей',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'false' },
+ type: { summary: 'boolean' },
+ },
+ },
+ tabindex: {
+ control: 'number',
+ description: 'Порядок фокуса при навигации клавиатурой',
+ table: {
+ category: 'Props',
+ defaultValue: { summary: 'undefined' },
+ type: { summary: 'number' },
+ },
+ },
+ },
+ args: {
+ multiple: false,
+ },
+};
+
+export default meta;
+type Story = StoryObj;
+
+// ── Default ───────────────────────────────────────────────────────────────────
+
+export const Default: Story = {
+ name: 'Default',
+ render: (args) => {
+ const parts: string[] = [`[model]="model"`];
+ if (args.multiple) parts.push(`[multiple]="true"`);
+ if (args.tabindex !== undefined) parts.push(`[tabindex]="${args.tabindex}"`);
+
+ const template = ``;
+
+ return {
+ props: {
+ ...args,
+ model: [
+ {
+ label: 'Отправления',
+ items: [
+ { label: 'Новые' },
+ { label: 'В пути' },
+ { label: 'Доставленные' },
+ ],
+ },
+ { label: 'Маршруты' },
+ {
+ label: 'Склады',
+ items: [{ label: 'Москва' }, { label: 'Новосибирск' }],
+ },
+ { label: 'Настройки', disabled: true },
+ ],
+ },
+ template,
+ };
+ },
+ parameters: {
+ docs: {
+ description: {
+ story: 'Базовый пример компонента. Используйте Controls для интерактивного изменения пропсов.',
+ },
+ },
+ },
+};
+
+// ── Re-exports from example components ────────────────────────────────────
+export { Basic, Multiple, Custom };