feat(design-system): add reusable Dialog primitive
Some checks failed
Architecture Governance / architecture (push) Has been cancelled

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.
This commit is contained in:
sdarbinyan
2026-07-15 03:34:52 +04:00
parent 5cbc9e4873
commit 27ff3169b9
3 changed files with 244 additions and 0 deletions

View File

@@ -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<string | null>(null);
readonly size = input<DialogSize>('md');
readonly closed = output<void>();
@ViewChild('panel') private panelRef?: ElementRef<HTMLElement>;
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<HTMLElement>(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<HTMLElement>(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();
}
}
}