import { Component, input, output, HostListener, ElementRef, inject, viewChild, OnInit, OnDestroy, } from '@angular/core'; import { TranslationService } from '../../services/translation.service'; export type PopupPosition = { top?: string; left?: string; right?: string; bottom?: string; }; export type PopupVariant = 'glass' | 'solid'; @Component({ selector: 'app-popup', standalone: true, templateUrl: './popup.html', styleUrl: './popup.scss', host: { role: 'dialog', 'aria-modal': 'true', tabindex: '-1', }, }) export class PopupComponent implements OnInit, OnDestroy { private translation = inject(TranslationService).translation; popupContainer = viewChild>('popupContainer'); private previouslyFocusedElement: HTMLElement | null = null; /** * 팝업의 위치 (기본값은 'center') * 'center' 또는 { top, left, right, bottom } 객체를 받습니다. */ position = input('center'); /** * 팝업의 스타일 변형 (기본값은 'glass') */ variant = input('glass'); /** * 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close') */ closeButtonLabel = input(this.translation.common.close); /** * 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼) */ closeRequest = output(); constructor(private element: ElementRef) {} ngOnInit() { if (typeof document !== 'undefined') { this.previouslyFocusedElement = document.activeElement as HTMLElement; } setTimeout(() => { this.element.nativeElement.focus(); this.popupContainer()!.nativeElement.tabIndex = 0; this.popupContainer()!.nativeElement.focus(); }, 0); } ngOnDestroy() { if ( this.previouslyFocusedElement && typeof this.previouslyFocusedElement.focus === 'function' ) { this.previouslyFocusedElement.focus(); } } @HostListener('keydown', ['$event']) onKeydownHandler(event: KeyboardEvent) { if (event.key === 'Tab') this.handleTabKey(event); if (event.key === 'Escape') this.onClose(); } private handleTabKey(event: KeyboardEvent) { const focusableElements = this.getOrderedFocusableElements(this.popupContainer()!.nativeElement); if (focusableElements.length === 0) { event.preventDefault(); return; } const firstElement = focusableElements[0] as HTMLElement; const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; const activeElement = document.activeElement; if (event.shiftKey) { // If Shift + Tab and focus is on the first element (or the container), move to the last element if ( activeElement === firstElement || activeElement === this.popupContainer()!.nativeElement ) { lastElement.focus(); event.preventDefault(); } } else { // If Tab and focus is on the last element, move to the first element if (activeElement === lastElement) { firstElement.focus(); event.preventDefault(); } } } getOrderedFocusableElements(container: HTMLElement) { const selector = 'button, [href], input, select, textarea, [tabindex]'; const elements = Array.from(container.querySelectorAll(selector)).filter((el) => { const htmlEl = el as HTMLElement; // 비활성 상태거나 tabindex가 -1이면 제외 if ((htmlEl as HTMLInputElement).disabled || el.getAttribute('tabindex') === '-1') return false; // 화면에 보이지 않는 요소 제외 (필요 시 추가) return !!(htmlEl.offsetWidth || htmlEl.offsetHeight || el.getClientRects().length); }); return elements.sort((a, b) => { const tabA = parseInt(a.getAttribute('tabindex') ?? '0', 10); const tabB = parseInt(b.getAttribute('tabindex') ?? '0', 10); // 1. 양수 tabindex 처리 (낮은 숫자 우선) if (tabA > 0 && tabB > 0) return tabA - tabB; if (tabA > 0) return -1; if (tabB > 0) return 1; // 2. 나머지는(0 또는 기본값) DOM 순서 유지 // querySelectorAll 결과가 이미 DOM 순서이므로 0 반환으로 충분 return 0; }); } onClose() { this.closeRequest.emit(); } get isCenter() { return this.position() === 'center'; } get customStyle() { const pos = this.position(); return pos === 'center' ? {} : pos; } }