feat(design-system): add reusable Dialog primitive
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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:
107
src/app/shared/ui/dialog/dialog.component.ts
Normal file
107
src/app/shared/ui/dialog/dialog.component.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user