Add Popup Focus Trap Feature

This commit is contained in:
copecone committed 2026-05-02 17:01:54 +09:00
1 parent 3bece368b3
commit 713f0ef75b
7 files changed
+127 -13

No files matched your search

+4 -1
View File
@@ -1,5 +1,8 @@
<header-menu></header-menu> <header-menu></header-menu>
<router-outlet />
<main id="main-content">
<router-outlet />
</main>
<div id="global-popup-root" class="popup-outlet"> <div id="global-popup-root" class="popup-outlet">
@for (popup of popupService.activePopups(); track popup.component) { @for (popup of popupService.activePopups(); track popup.component) {
+2 -2
View File
@@ -1,5 +1,5 @@
<div class="popup-backdrop" [class.center]="isCenter" (click)="$event.target === $event.currentTarget && onClose()"> <div class="popup-backdrop" [class.center]="isCenter" (click)="$event.target === $event.currentTarget && onClose()">
<div class="popup-container" <div #popupContainer class="popup-container"
[class]="'variant-' + variant()" [class]="'variant-' + variant()"
[class.custom-pos]="!isCenter" [class.custom-pos]="!isCenter"
[style.top]="customStyle.top" [style.top]="customStyle.top"
@@ -12,7 +12,7 @@
</div> </div>
<div class="header-right"> <div class="header-right">
<button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()">close</button> <button class="material-symbols-outlined icon-24 close-btn" (click)="onClose()" [aria-label]="closeButtonLabel()">close</button>
</div> </div>
</header> </header>
+5
View File
@@ -52,6 +52,11 @@ $default_animation_duration: 0.2s;
&.custom-pos { &.custom-pos {
position: absolute; position: absolute;
} }
&:focus-visible {
outline-offset: 0;
outline: 2px solid var(--selection-outline-color);
}
} }
@keyframes popup-enter { @keyframes popup-enter {
+111 -8
View File
@@ -1,4 +1,15 @@
import { Component, input, output, HostListener } from '@angular/core'; import {
Component,
input,
output,
HostListener,
ElementRef,
inject,
viewChild,
OnInit,
OnDestroy,
} from '@angular/core';
import { TranslationService } from '../../services/translation.service';
export type PopupPosition = { export type PopupPosition = {
top?: string; top?: string;
@@ -15,12 +26,16 @@ export type PopupVariant = 'glass' | 'solid';
templateUrl: './popup.html', templateUrl: './popup.html',
styleUrl: './popup.scss', styleUrl: './popup.scss',
host: { host: {
'role': 'dialog', role: 'dialog',
'aria-modal': 'true', 'aria-modal': 'true',
'tabindex': '-1' tabindex: '-1',
} },
}) })
export class PopupComponent { export class PopupComponent implements OnInit, OnDestroy {
private translation = inject(TranslationService).translation;
popupContainer = viewChild<ElementRef<HTMLElement>>('popupContainer');
private previouslyFocusedElement: HTMLElement | null = null;
/** /**
* 팝업의 위치 (기본값은 'center') * 팝업의 위치 (기본값은 'center')
* 'center' 또는 { top, left, right, bottom } 객체를 받습니다. * 'center' 또는 { top, left, right, bottom } 객체를 받습니다.
@@ -32,14 +47,102 @@ export class PopupComponent {
*/ */
variant = input<PopupVariant>('glass'); variant = input<PopupVariant>('glass');
/**
* 팝업 닫기 버튼의 aria-label (기본값은 번역의 'close')
*/
closeButtonLabel = input<string>(this.translation.common.close);
/** /**
* 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼) * 팝업 닫기 요청 (배경 클릭 또는 닫기 버튼)
*/ */
closeRequest = output<void>(); closeRequest = output<void>();
@HostListener('document:keydown.escape') constructor(private element: ElementRef) {}
onKeydownHandler() {
this.onClose(); 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() { onClose() {
+1
View File
@@ -21,6 +21,7 @@ export const EN = {
selectOption: 'Select Option...', selectOption: 'Select Option...',
close: 'Close', close: 'Close',
save: 'Save', save: 'Save',
skipToContent: 'Skip to main content',
}, },
}; };
+2 -1
View File
@@ -20,8 +20,9 @@ export const JP: Translation = {
signup: '新規登録', signup: '新規登録',
}, },
common: { common: {
selectOption: '選択...', selectOption: 'オプションを選択...',
close: '閉じる', close: '閉じる',
save: '保存', save: '保存',
skipToContent: '本文へスキップ',
}, },
}; };
+2 -1
View File
@@ -20,8 +20,9 @@ export const KR: Translation = {
signup: '회원가입', signup: '회원가입',
}, },
common: { common: {
selectOption: '선택...', selectOption: '옵션 선택...',
close: '닫기', close: '닫기',
save: '저장', save: '저장',
skipToContent: '본문으로 건너뛰기',
}, },
}; };