From 27ff3169b98620e4d156fbad515ac2a8d3d6a77c Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Wed, 15 Jul 2026 03:34:52 +0400 Subject: [PATCH] feat(design-system): add reusable Dialog primitive Standalone, OnPush. Backdrop click and Escape close, focus trap with return-focus on close, role=dialog/aria-modal. Sizes sm/md/lg. Colors/ radius/shadow/spacing via existing tenant CSS vars with fallbacks. --- .../shared/ui/dialog/dialog.component.html | 33 ++++++ .../shared/ui/dialog/dialog.component.scss | 104 +++++++++++++++++ src/app/shared/ui/dialog/dialog.component.ts | 107 ++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 src/app/shared/ui/dialog/dialog.component.html create mode 100644 src/app/shared/ui/dialog/dialog.component.scss create mode 100644 src/app/shared/ui/dialog/dialog.component.ts diff --git a/src/app/shared/ui/dialog/dialog.component.html b/src/app/shared/ui/dialog/dialog.component.html new file mode 100644 index 0000000..d9e49ea --- /dev/null +++ b/src/app/shared/ui/dialog/dialog.component.html @@ -0,0 +1,33 @@ +@if (open()) { +
+ +
+} diff --git a/src/app/shared/ui/dialog/dialog.component.scss b/src/app/shared/ui/dialog/dialog.component.scss new file mode 100644 index 0000000..2cbe889 --- /dev/null +++ b/src/app/shared/ui/dialog/dialog.component.scss @@ -0,0 +1,104 @@ +.app-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: var(--space-md, 1rem); + background: rgba(0, 0, 0, 0.5); + animation: app-dialog-fade-in var(--transition-normal, 200ms ease); +} + +.app-dialog-panel { + width: 100%; + max-height: 90vh; + overflow-y: auto; + background: var(--bg-primary, #fff); + border-radius: var(--radius-lg, 10px); + box-shadow: var(--shadow-md, 0 8px 24px rgba(0, 0, 0, 0.16)); + animation: app-dialog-scale-in var(--transition-normal, 200ms ease); + + &:focus-visible { + outline: none; + } +} + +.app-dialog-panel--sm { + max-width: 400px; +} + +.app-dialog-panel--md { + max-width: 560px; +} + +.app-dialog-panel--lg { + max-width: 800px; +} + +.app-dialog-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md, 1rem); + padding: var(--space-md, 1rem) var(--space-lg, 1.5rem); + border-bottom: 1px solid var(--border-color, #e4e4e7); +} + +.app-dialog-panel__title { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary, #1f322d); +} + +.app-dialog-panel__close { + border: none; + background: transparent; + color: var(--text-secondary, #6b7280); + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + padding: var(--space-xs, 0.25rem); + border-radius: var(--radius-sm, 4px); + + &:hover { + background: var(--bg-secondary, #f4f4f5); + } + + &:focus-visible { + outline: 2px solid var(--primary-color, #2f6e5d); + outline-offset: 2px; + } +} + +.app-dialog-panel__body { + padding: var(--space-lg, 1.5rem); +} + +@keyframes app-dialog-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes app-dialog-scale-in { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} + +@media (prefers-reduced-motion: reduce) { + .app-dialog-backdrop, + .app-dialog-panel { + animation: none; + } +} diff --git a/src/app/shared/ui/dialog/dialog.component.ts b/src/app/shared/ui/dialog/dialog.component.ts new file mode 100644 index 0000000..b74bdbe --- /dev/null +++ b/src/app/shared/ui/dialog/dialog.component.ts @@ -0,0 +1,107 @@ +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + HostListener, + input, + OnChanges, + output, + SimpleChanges, + ViewChild +} from '@angular/core'; + +export type DialogSize = 'sm' | 'md' | 'lg'; + +const FOCUSABLE_SELECTOR = + 'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +@Component({ + selector: 'app-dialog', + standalone: true, + templateUrl: './dialog.component.html', + styleUrl: './dialog.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DialogComponent implements OnChanges, AfterViewInit { + readonly open = input(false); + readonly titleText = input(null); + readonly size = input('md'); + + readonly closed = output(); + + @ViewChild('panel') private panelRef?: ElementRef; + + private previouslyFocused: HTMLElement | null = null; + + ngOnChanges(changes: SimpleChanges): void { + if (changes['open']) { + if (this.open()) { + this.previouslyFocused = document.activeElement as HTMLElement | null; + queueMicrotask(() => this.focusPanel()); + } else { + this.previouslyFocused?.focus(); + this.previouslyFocused = null; + } + } + } + + ngAfterViewInit(): void { + if (this.open()) { + this.focusPanel(); + } + } + + @HostListener('document:keydown', ['$event']) + protected handleKeydown(event: KeyboardEvent): void { + if (!this.open()) { + return; + } + if (event.key === 'Escape') { + this.requestClose(); + return; + } + if (event.key === 'Tab') { + this.trapFocus(event); + } + } + + protected requestClose(): void { + this.closed.emit(); + } + + protected handleBackdropClick(): void { + this.requestClose(); + } + + private focusPanel(): void { + const panel = this.panelRef?.nativeElement; + if (!panel) { + return; + } + const focusable = panel.querySelectorAll(FOCUSABLE_SELECTOR); + (focusable[0] ?? panel).focus(); + } + + private trapFocus(event: KeyboardEvent): void { + const panel = this.panelRef?.nativeElement; + if (!panel) { + return; + } + const focusable = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)); + if (focusable.length === 0) { + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + const active = document.activeElement; + + if (event.shiftKey && active === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && active === last) { + event.preventDefault(); + first.focus(); + } + } +}