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()) {
@if (description()) {
{{ description() }}
}
}
`,
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();
}
}