diff --git a/src/lib/components/dialog/dialog-open.service.ts b/src/lib/components/dialog/dialog-open.service.ts new file mode 100644 index 0000000..f82d0c3 --- /dev/null +++ b/src/lib/components/dialog/dialog-open.service.ts @@ -0,0 +1,34 @@ +import { Injectable, Type } from '@angular/core'; +import { DialogService, DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { DialogSize } from './dialog.component'; + +export type UiDynamicDialogConfig = Omit, 'styleClass'> & { + size?: DialogSize; + styleClass?: string; +}; + +export { DynamicDialogRef, DynamicDialogConfig }; + +@Injectable() +export class UiDialogService { + constructor(private readonly primengDialogService: DialogService) {} + + open(componentType: Type, config: UiDynamicDialogConfig = {}): DynamicDialogRef | null { + const { size, styleClass, ...rest } = config; + const sizeClass = this.toSizeClass(size); + const mergedStyleClass = [sizeClass, styleClass].filter(Boolean).join(' '); + + return this.primengDialogService.open(componentType, { + ...rest, + ...(mergedStyleClass && { styleClass: mergedStyleClass }), + appendTo: rest.appendTo ?? 'body', + }); + } + + private toSizeClass(size?: DialogSize): string { + if (size === 'sm') return 'p-dialog-sm'; + if (size === 'lg') return 'p-dialog-lg'; + if (size === 'xlg') return 'p-dialog-xlg'; + return ''; + } +} diff --git a/src/lib/components/dialog/dialog.component.ts b/src/lib/components/dialog/dialog.component.ts new file mode 100644 index 0000000..aa5722a --- /dev/null +++ b/src/lib/components/dialog/dialog.component.ts @@ -0,0 +1,58 @@ +import { Component, EventEmitter, Input, Output, TemplateRef } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { Dialog } from 'primeng/dialog'; +import { PrimeTemplate } from 'primeng/api'; + +export type DialogSize = 'sm' | 'default' | 'lg' | 'xlg'; + +@Component({ + selector: 'dialog', + host: { style: 'display: contents' }, + standalone: true, + imports: [Dialog, NgTemplateOutlet, PrimeTemplate], + template: ` + + @if (headerTemplate) { + + + + } + + + + + + `, +}) +export class DialogComponent { + @Input() header = ''; + @Input() visible = false; + @Input() modal = true; + @Input() size: DialogSize = 'default'; + @Input() dismissableMask = false; + @Input() closeOnEscape = true; + @Input() showHeader = true; + @Input() focusOnShow = false; + @Input() appendTo: string = 'body'; + @Input() headerTemplate: TemplateRef | null = null; + @Input() footerTemplate: TemplateRef | null = null; + @Output() visibleChange = new EventEmitter(); + + get sizeClass(): string { + if (this.size === 'sm') return 'p-dialog-sm'; + if (this.size === 'lg') return 'p-dialog-lg'; + if (this.size === 'xlg') return 'p-dialog-xlg'; + return ''; + } +} diff --git a/src/prime-preset/tokens/components/dialog.ts b/src/prime-preset/tokens/components/dialog.ts new file mode 100644 index 0000000..c157e61 --- /dev/null +++ b/src/prime-preset/tokens/components/dialog.ts @@ -0,0 +1,53 @@ +export const dialogCss = ({ dt }: { dt: (token: string) => string }): string => ` + .p-dialog .p-dialog-title { + font-family: ${dt('fonts.fontFamily.heading')}; + font-size: ${dt('dialog.title.fontSize')}; + font-weight: ${dt('dialog.title.fontWeight')}; + line-height: ${dt('fonts.lineHeight.550')}; + } + + .p-dialog .p-dialog-content { + font-family: ${dt('fonts.fontFamily.base')}; + font-size: ${dt('fonts.fontSize.300')}; + font-weight: ${dt('fonts.fontWeight.regular')}; + line-height: ${dt('fonts.lineHeight.500')}; + } + + .p-dialog .p-dialog-header { + border-bottom: ${dt('borderWidth.100')} solid ${dt('dialog.root.borderColor')}; + display: flex; + align-items: center; + justify-content: space-between; + } + + .p-dialog .p-dialog-header-actions { + display: flex; + align-items: center; + margin-left: auto; + } + + .p-dialog .p-dialog-header-actions .p-dialog-close-button.p-button-text:focus-visible, + .p-dialog .p-dialog-header-actions .p-dialog-close-button.p-button:focus-visible, + .p-dialog .p-button-text:focus-visible, + .p-dialog .p-button:focus-visible { + outline: 0 none; + outline-color: transparent; + box-shadow: none; + } + + .p-dialog { + width: ${dt('sizing.80x')}; + } + + .p-dialog.p-component.p-dialog-sm { + width: ${dt('overlay.sm.width')}; + } + + .p-dialog.p-component.p-dialog-lg { + width: ${dt('overlay.lg.width')}; + } + + .p-dialog.p-component.p-dialog-xlg { + width: ${dt('overlay.xlg.width')}; + } +`; diff --git a/src/prime-preset/tokens/tokens.json b/src/prime-preset/tokens/tokens.json index 19d1ea5..78e7c3d 100644 --- a/src/prime-preset/tokens/tokens.json +++ b/src/prime-preset/tokens/tokens.json @@ -2753,7 +2753,7 @@ "padding": "{content.padding.400}" }, "footer": { - "padding": "0 {overlay.modal.padding.md} {overlay.modal.padding.md} {overlay.modal.padding.md}", + "padding": "0 {overlay.modal.padding.300} {overlay.modal.padding.300} {overlay.modal.padding.300}", "gap": "{content.gap.200}" } }, diff --git a/src/stories/components/dialog/dialog.stories.ts b/src/stories/components/dialog/dialog.stories.ts new file mode 100644 index 0000000..208fc2c --- /dev/null +++ b/src/stories/components/dialog/dialog.stories.ts @@ -0,0 +1,377 @@ +import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; +import { DialogComponent } from '../../../lib/components/dialog/dialog.component'; +import { DialogDefaultComponent, template as dialogDefaultTemplate } from './examples/dialog-default.component'; +import { DialogSmallComponent, template as dialogSmallTemplate } from './examples/dialog-small.component'; +import { DialogLargeComponent, template as dialogLargeTemplate } from './examples/dialog-large.component'; +import { DialogExtraLargeComponent, template as dialogExtraLargeTemplate } from './examples/dialog-extra-large.component'; +import { DialogNoModalComponent, template as dialogNoModalTemplate } from './examples/dialog-no-modal.component'; +import { DialogNoHeaderComponent, template as dialogNoHeaderTemplate } from './examples/dialog-no-header.component'; +import { DialogDynamicComponent } from './examples/dialog-dynamic.component'; + +const meta: Meta = { + title: 'Components/Overlay/Dialog', + component: DialogComponent, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: `Dialog (модальное окно) — контейнер, отображающийся поверх основного содержимого страницы. + +\`\`\`typescript +import { DialogComponent } from '@cdek-it/angular-ui-kit'; +\`\`\``, + }, + }, + designTokens: { prefix: '--p-dialog' }, + }, + argTypes: { + header: { + control: 'text', + description: 'Заголовок окна', + table: { + category: 'Props', + defaultValue: { summary: '' }, + type: { summary: 'string' }, + }, + }, + headerTemplate: { + control: false, + description: 'Кастомный шаблон заголовка. При наличии заменяет строковый header', + table: { + category: 'Props', + defaultValue: { summary: 'null' }, + type: { summary: 'TemplateRef | null' }, + }, + }, + size: { + control: 'select', + options: ['sm', 'default', 'lg', 'xlg'], + description: 'Размер диалога', + table: { + category: 'Props', + defaultValue: { summary: 'default' }, + type: { summary: "'sm' | 'default' | 'lg' | 'xlg'" }, + }, + }, + modal: { + control: 'boolean', + description: 'Должно ли окно быть модальным (блокировать фон)', + table: { + category: 'Props', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, + dismissableMask: { + control: 'boolean', + description: 'Закрывать ли окно при клике на маску', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + closeOnEscape: { + control: 'boolean', + description: 'Закрывать ли окно по нажатию Escape', + table: { + category: 'Props', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, + showHeader: { + control: 'boolean', + description: 'Отображать ли заголовок', + table: { + category: 'Props', + defaultValue: { summary: 'true' }, + type: { summary: 'boolean' }, + }, + }, + focusOnShow: { + control: 'boolean', + description: 'Фокус на первый элемент при открытии', + table: { + category: 'Props', + defaultValue: { summary: 'false' }, + type: { summary: 'boolean' }, + }, + }, + appendTo: { + control: 'text', + description: 'Элемент, к которому прикрепляется диалог (например body или CSS-селектор)', + table: { + category: 'Props', + defaultValue: { summary: "'body'" }, + type: { summary: 'string' }, + }, + }, + visibleChange: { + control: false, + description: 'Изменение видимости диалога', + table: { + category: 'Events', + type: { summary: 'EventEmitter' }, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// ── Basic ───────────────────────────────────────────────────────────────────── + +export const Basic: Story = { + name: 'Basic', + decorators: [moduleMetadata({ imports: [DialogDefaultComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { + story: 'Базовый пример диалогового окна с заголовком, контентом и кнопками действий.', + }, + source: { + language: 'ts', + code: ` +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-dialog-basic', + standalone: true, + imports: [DialogComponent, Button], + template: \`${dialogDefaultTemplate}\`, +}) +export class DialogBasicComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} + `, + }, + }, + }, +}; + +// ── Small ───────────────────────────────────────────────────────────────────── + +export const Small: Story = { + name: 'Small', + decorators: [moduleMetadata({ imports: [DialogSmallComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { story: 'Уменьшенный размер диалога (SM).' }, + source: { + language: 'ts', + code: ` +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-dialog-small', + standalone: true, + imports: [DialogComponent, Button], + template: \`${dialogSmallTemplate}\`, +}) +export class DialogSmallComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} + `, + }, + }, + }, +}; + +// ── Large ───────────────────────────────────────────────────────────────────── + +export const Large: Story = { + name: 'Large', + decorators: [moduleMetadata({ imports: [DialogLargeComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { story: 'Увеличенный размер диалога (LG).' }, + source: { + language: 'ts', + code: ` +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-dialog-large', + standalone: true, + imports: [DialogComponent, Button], + template: \`${dialogLargeTemplate}\`, +}) +export class DialogLargeComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} + `, + }, + }, + }, +}; + +// ── Extra Large ─────────────────────────────────────────────────────────────── + +export const ExtraLarge: Story = { + name: 'Extra Large', + decorators: [moduleMetadata({ imports: [DialogExtraLargeComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { story: 'Максимальный размер диалога (XLG).' }, + source: { + language: 'ts', + code: ` +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-dialog-extra-large', + standalone: true, + imports: [DialogComponent, Button], + template: \`${dialogExtraLargeTemplate}\`, +}) +export class DialogExtraLargeComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} + `, + }, + }, + }, +}; + +// ── No Modal ────────────────────────────────────────────────────────────────── + +export const NoModal: Story = { + name: 'No Modal', + decorators: [moduleMetadata({ imports: [DialogNoModalComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { story: 'Окно не блокирует фон страницы.' }, + source: { + language: 'ts', + code: ` +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-dialog-no-modal', + standalone: true, + imports: [DialogComponent, Button], + template: \`${dialogNoModalTemplate}\`, +}) +export class DialogNoModalComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} + `, + }, + }, + }, +}; + +// ── Show Header ─────────────────────────────────────────────────────────────── + +export const NoHeader: Story = { + name: 'Show Header', + decorators: [moduleMetadata({ imports: [DialogNoHeaderComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { story: 'Заголовок можно скрыть с помощью пропса showHeader: false.' }, + source: { + language: 'ts', + code: ` +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '@cdek-it/angular-ui-kit'; + +@Component({ + selector: 'app-dialog-no-header', + standalone: true, + imports: [DialogComponent, Button], + template: \`${dialogNoHeaderTemplate}\`, +}) +export class DialogNoHeaderComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} + `, + }, + }, + }, +}; + +// ── Dynamic ─────────────────────────────────────────────────────────────────── + +export const Dynamic: Story = { + name: 'Dynamic', + decorators: [moduleMetadata({ imports: [DialogDynamicComponent] })], + render: () => ({ template: `` }), + parameters: { + docs: { + description: { + story: 'Программное открытие диалога через `UiDialogService`. Содержимое — любой Angular-компонент, получающий `DynamicDialogRef` для закрытия.', + }, + source: { + language: 'ts', + code: ` +import { Component } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { UiDialogService } from '@cdek-it/angular-ui-kit'; + +// Содержимое диалога +@Component({ + selector: 'app-dialog-dynamic-content', + standalone: true, + imports: [Button], + template: \` +

Заявка на доставку груза №CDEK-2025-00478312 готова к оформлению.

+
+ + +
+ \`, +}) +export class DialogDynamicContentComponent { + constructor(readonly ref: DynamicDialogRef) {} +} + +// Компонент-триггер +@Component({ + selector: 'app-dialog-dynamic', + standalone: true, + imports: [Button], + providers: [DialogService, UiDialogService], + template: \` + + \`, +}) +export class DialogDynamicComponent { + constructor(private readonly dialogService: UiDialogService) {} + + open(): void { + this.dialogService.open(DialogDynamicContentComponent, { + header: 'Подтверждение заявки', + modal: true, + }); + } +}`, + }, + }, + }, +}; diff --git a/src/stories/components/dialog/examples/dialog-default.component.ts b/src/stories/components/dialog/examples/dialog-default.component.ts new file mode 100644 index 0000000..faa6326 --- /dev/null +++ b/src/stories/components/dialog/examples/dialog-default.component.ts @@ -0,0 +1,34 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '../../../../lib/components/dialog/dialog.component'; + +export const template = ` +
+ + + + + + + + +

Заявка на доставку груза №CDEK-2025-00478312 готова к оформлению. Вес отправления: 3,5 кг, габариты: 40×30×20 см. Ориентировочный срок доставки — 3 рабочих дня.

+
+
+`; + +@Component({ + selector: 'app-dialog-basic', + standalone: true, + imports: [DialogComponent, Button], + template, +}) +export class DialogDefaultComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} diff --git a/src/stories/components/dialog/examples/dialog-dynamic.component.ts b/src/stories/components/dialog/examples/dialog-dynamic.component.ts new file mode 100644 index 0000000..32cb4bd --- /dev/null +++ b/src/stories/components/dialog/examples/dialog-dynamic.component.ts @@ -0,0 +1,49 @@ +import { Component } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogService, DynamicDialogRef } from 'primeng/dynamicdialog'; +import { UiDialogService } from '../../../../lib/components/dialog/dialog-open.service'; + +// ── Содержимое диалога ──────────────────────────────────────────────────────── + +@Component({ + selector: 'app-dialog-dynamic-content', + standalone: true, + imports: [Button], + template: ` +

Заявка на доставку груза №CDEK-2025-00478312 готова к оформлению.

+

Вес отправления: 3,5 кг, габариты: 40×30×20 см. Ориентировочный срок — 3 рабочих дня.

+
+ + +
+ `, +}) +export class DialogDynamicContentComponent { + constructor(readonly ref: DynamicDialogRef) {} +} + +// ── Компонент-триггер ───────────────────────────────────────────────────────── + +export const template = ` +
+ +
+`; + +@Component({ + selector: 'app-dialog-dynamic', + standalone: true, + imports: [Button], + providers: [DialogService, UiDialogService], + template, +}) +export class DialogDynamicComponent { + constructor(private readonly dialogService: UiDialogService) {} + + open(): void { + this.dialogService.open(DialogDynamicContentComponent, { + header: 'Подтверждение заявки', + modal: true, + }); + } +} diff --git a/src/stories/components/dialog/examples/dialog-extra-large.component.ts b/src/stories/components/dialog/examples/dialog-extra-large.component.ts new file mode 100644 index 0000000..88c43fa --- /dev/null +++ b/src/stories/components/dialog/examples/dialog-extra-large.component.ts @@ -0,0 +1,35 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '../../../../lib/components/dialog/dialog.component'; + +export const template = ` +
+ + + + + + + + +

За апрель 2025 года обработано 4 872 отправления. Успешно доставлено — 4 641 (95,3%). Возвраты — 112 (2,3%). В пути — 119 (2,4%). Средний срок доставки по России составил 2,7 рабочего дня. Наиболее загруженные направления: Москва — Санкт-Петербург, Москва — Новосибирск, Москва — Екатеринбург.

+
+
+`; + +@Component({ + selector: 'app-dialog-extra-large', + standalone: true, + imports: [DialogComponent, Button], + template, +}) +export class DialogExtraLargeComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} diff --git a/src/stories/components/dialog/examples/dialog-large.component.ts b/src/stories/components/dialog/examples/dialog-large.component.ts new file mode 100644 index 0000000..3cd061a --- /dev/null +++ b/src/stories/components/dialog/examples/dialog-large.component.ts @@ -0,0 +1,35 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '../../../../lib/components/dialog/dialog.component'; + +export const template = ` +
+ + + + + + + + +

Отправление CDEK-2025-00478312 передано курьеру для доставки до двери получателя. Последнее обновление: 09.04.2025, 14:35. Адрес доставки: г. Новосибирск, ул. Ленина, 42, кв. 8. Получатель: Иванов И.И., +7 913 000-00-00.

+
+
+`; + +@Component({ + selector: 'app-dialog-large', + standalone: true, + imports: [DialogComponent, Button], + template, +}) +export class DialogLargeComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} diff --git a/src/stories/components/dialog/examples/dialog-no-header.component.ts b/src/stories/components/dialog/examples/dialog-no-header.component.ts new file mode 100644 index 0000000..6cdd7c4 --- /dev/null +++ b/src/stories/components/dialog/examples/dialog-no-header.component.ts @@ -0,0 +1,36 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '../../../../lib/components/dialog/dialog.component'; + +export const template = ` +
+ + + +
+ +
+
+ + +

Заявка на доставку принята в обработку. Трек-номер будет присвоен в течение 15 минут и отправлен на указанный email.

+
+
+`; + +@Component({ + selector: 'app-dialog-no-header', + standalone: true, + imports: [DialogComponent, Button], + template, +}) +export class DialogNoHeaderComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} diff --git a/src/stories/components/dialog/examples/dialog-no-modal.component.ts b/src/stories/components/dialog/examples/dialog-no-modal.component.ts new file mode 100644 index 0000000..6c9c523 --- /dev/null +++ b/src/stories/components/dialog/examples/dialog-no-modal.component.ts @@ -0,0 +1,35 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '../../../../lib/components/dialog/dialog.component'; + +export const template = ` +
+ + + + + + + + +

Маршрут отправления CDEK-2025-00478312: Москва (склад) → Новосибирск (сортировочный центр) → Новосибирск (пункт выдачи). Это окно не блокирует основной контент страницы.

+
+
+`; + +@Component({ + selector: 'app-dialog-no-modal', + standalone: true, + imports: [DialogComponent, Button], + template, +}) +export class DialogNoModalComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +} diff --git a/src/stories/components/dialog/examples/dialog-small.component.ts b/src/stories/components/dialog/examples/dialog-small.component.ts new file mode 100644 index 0000000..06fed64 --- /dev/null +++ b/src/stories/components/dialog/examples/dialog-small.component.ts @@ -0,0 +1,35 @@ +import { Component, TemplateRef, ViewChild } from '@angular/core'; +import { Button } from 'primeng/button'; +import { DialogComponent } from '../../../../lib/components/dialog/dialog.component'; + +export const template = ` +
+ + + + + + + + +

Отправление CDEK-2025-00478312 прибыло на сортировочный центр г. Новосибирск и готово к передаче курьеру.

+
+
+`; + +@Component({ + selector: 'app-dialog-small', + standalone: true, + imports: [DialogComponent, Button], + template, +}) +export class DialogSmallComponent { + @ViewChild('footer') footer!: TemplateRef; + visible = false; +}