Add Popup Focus Trap Feature
This commit is contained in:
1 parent
3bece368b3
commit
713f0ef75b
7 files changed
+127
-13
No files matched your search
+4
-1
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -20,8 +20,9 @@ export const JP: Translation = {
|
|||||||
signup: '新規登録',
|
signup: '新規登録',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
selectOption: '選択...',
|
selectOption: 'オプションを選択...',
|
||||||
close: '閉じる',
|
close: '閉じる',
|
||||||
save: '保存',
|
save: '保存',
|
||||||
|
skipToContent: '本文へスキップ',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
+2
-1
@@ -20,8 +20,9 @@ export const KR: Translation = {
|
|||||||
signup: '회원가입',
|
signup: '회원가입',
|
||||||
},
|
},
|
||||||
common: {
|
common: {
|
||||||
selectOption: '선택...',
|
selectOption: '옵션 선택...',
|
||||||
close: '닫기',
|
close: '닫기',
|
||||||
save: '저장',
|
save: '저장',
|
||||||
|
skipToContent: '본문으로 건너뛰기',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
Reference in new issue
Block a user