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,33 @@
@if (open()) {
<div class="app-dialog-backdrop" (click)="handleBackdropClick()">
<div
#panel
class="app-dialog-panel"
[class.app-dialog-panel--sm]="size() === 'sm'"
[class.app-dialog-panel--md]="size() === 'md'"
[class.app-dialog-panel--lg]="size() === 'lg'"
role="dialog"
aria-modal="true"
[attr.aria-label]="titleText()"
tabindex="-1"
(click)="$event.stopPropagation()"
>
@if (titleText()) {
<div class="app-dialog-panel__header">
<h2 class="app-dialog-panel__title">{{ titleText() }}</h2>
<button
type="button"
class="app-dialog-panel__close"
(click)="requestClose()"
aria-label="Close dialog"
>
&times;
</button>
</div>
}
<div class="app-dialog-panel__body">
<ng-content />
</div>
</div>
</div>
}

View File

@@ -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;
}
}

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();
}
}
}