import { ChangeDetectionStrategy, Component, DestroyRef, ElementRef, Injector, afterNextRender, booleanAttribute, effect, inject, input, model, output, untracked, viewChild, } from '@angular/core'; import { DOCUMENT } from '@angular/common'; import { tabbables, trapTab, uniqueId } from '../core/a11y'; export type AuDialogCloseReason = 'escape' | 'backdrop' | 'close-button' | 'programmatic'; /** * Modal dialog (WAI-ARIA dialog pattern). On open, focus moves to the first focusable element * (or one marked `autofocus`) and Tab is trapped inside; Escape closes; on close, focus returns * to the element that opened it. Page scrolling is locked while open. */ @Component({ selector: 'au-dialog', changeDetection: ChangeDetectionStrategy.OnPush, template: ` @if (open()) {
} `, styles: ` .au-dialog__backdrop { position: fixed; inset: 0; z-index: 1000; background: var(--au-overlay); animation: au-fade var(--au-duration) ease; } .au-dialog__panel { position: fixed; z-index: 1001; left: 50%; top: 50%; transform: translate(-50%, -50%); width: min(30rem, calc(100vw - 2rem)); max-height: calc(100dvh - 2rem); overflow: auto; box-sizing: border-box; padding: 1.25rem; border-radius: calc(var(--au-radius) * 1.4); border: 1px solid var(--au-border); background: var(--au-surface); color: var(--au-text); box-shadow: var(--au-shadow-lg); font-family: var(--au-font); animation: au-pop var(--au-duration) ease; } .au-dialog__panel[data-size='lg'] { width: min(44rem, calc(100vw - 2rem)); } .au-dialog__panel:focus { outline: none; } .au-dialog__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; } .au-dialog__title { margin: 0; font-size: 1.15rem; line-height: 1.3; } .au-dialog__close { flex: none; display: grid; place-items: center; width: 2rem; height: 2rem; margin: -0.25rem -0.25rem 0 0; border: 0; border-radius: calc(var(--au-radius) * 0.6); background: none; color: var(--au-muted); cursor: pointer; } .au-dialog__close:hover { background: var(--au-surface-2); color: var(--au-text); } .au-dialog__close:focus-visible { outline: 2px solid var(--au-focus); outline-offset: 1px; } .au-dialog__close svg { width: 1rem; height: 1rem; stroke: currentColor; stroke-width: 2; stroke-linecap: round; } .au-dialog__description { margin: 0.35rem 0 0; color: var(--au-muted); font-size: 0.9rem; } .au-dialog__body { margin-top: 1rem; } .au-dialog__footer { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 0.5rem; margin-top: 1.25rem; } .au-dialog__footer:empty { display: none; } @keyframes au-fade { from { opacity: 0; } } @keyframes au-pop { from { opacity: 0; transform: translate(-50%, -46%); } } `, }) export class AuDialog { readonly open = model(false); readonly title = input.required(); readonly description = input(''); readonly size = input<'md' | 'lg'>('md'); readonly closeOnEscape = input(true, { transform: booleanAttribute }); readonly closeOnBackdrop = input(true, { transform: booleanAttribute }); readonly closeLabel = input('Close dialog'); readonly closed = output(); readonly titleId = uniqueId('au-dialog'); protected readonly descriptionId = `${this.titleId}-desc`; private readonly panel = viewChild>('panel'); private readonly document = inject(DOCUMENT); private readonly injector = inject(Injector); private returnFocus: HTMLElement | null = null; private previousOverflow = ''; private shown = false; constructor() { effect(() => { const isOpen = this.open(); untracked(() => (isOpen ? this.onOpen() : this.onClose())); }); inject(DestroyRef).onDestroy(() => this.open() && this.onClose()); } close(reason: AuDialogCloseReason = 'programmatic'): void { if (!this.open()) return; this.open.set(false); this.closed.emit(reason); } protected onBackdrop(): void { if (this.closeOnBackdrop()) this.close('backdrop'); } protected onKeydown(event: KeyboardEvent): void { if (event.key === 'Escape' && this.closeOnEscape()) { event.stopPropagation(); this.close('escape'); return; } const panel = this.panel()?.nativeElement; if (panel) trapTab(event, panel); } private onOpen(): void { this.shown = true; const active = this.document.activeElement as HTMLElement | null; this.returnFocus = active && active !== this.document.body ? active : null; this.previousOverflow = this.document.body.style.overflow; this.document.body.style.overflow = 'hidden'; afterNextRender( () => { const panel = this.panel()?.nativeElement; if (!panel) return; const preferred = panel.querySelector('[autofocus]'); const body = panel.querySelector('.au-dialog__body'); const first = preferred ?? (body && tabbables(body)[0]) ?? tabbables(panel)[0] ?? panel; first.focus(); }, { injector: this.injector }, ); } private onClose(): void { if (!this.shown) return; this.shown = false; this.document.body.style.overflow = this.previousOverflow; const target = this.returnFocus; this.returnFocus = null; if (target?.isConnected) target.focus(); } }