import { useRef } from 'react';
export function useA11yAnnouncer() {
const timer = useRef<number | null>(null);
const announce = async (message: string) => {
const style = `border: 0; padding: 0; margin: 0; position: absolute !important;
height: 1px; width: 1px; overflow: hidden;
clip: rect(1px 1px 1px 1px); clip: rect(1px, 1px, 1px, 1px); clip-path: inset(50%); white-space: nowrap;`.replaceAll(
/\n/g,
''
);
const appendAndAnnounce = async (element: Element, message: string) => {
return new Promise(resolve => {
if (!element.querySelector("[name='p_announceForAccessibility']")) {
const div = document.createElement('div');
div.setAttribute('name', 'div_announceForAccessibility');
div.setAttribute('style', style);
div.innerHTML = '<p aria-live="polite" name="p_announceForAccessibility"></p>';
element.appendChild(div);
}
const pElement = element.querySelector<HTMLParagraphElement>("[name='p_announceForAccessibility']");
if (!pElement) {
return;
}
pElement.innerText = '';
setTimeout(() => {
pElement.innerText = message;
resolve(void 0);
}, 200);
});
};
const announceMessages = async () => {
if (typeof window !== 'undefined') {
const bodyElement = document.body;
const dialogElements = document.body.querySelectorAll<HTMLDialogElement>(
'[role="dialog"][aria-modal="true"], dialog'
);
await appendAndAnnounce(bodyElement, message);
dialogElements.forEach(element => {
appendAndAnnounce(element, message);
});
}
};
await announceMessages();
timer.current = window.setTimeout(removeAnnounce, 1000);
};
const removeAnnounce = () => {
if (typeof window !== 'undefined') {
const divElements = document.body.querySelectorAll<HTMLDivElement>("[name='div_announceForAccessibility']");
if (!divElements || !timer.current) {
return;
}
divElements.forEach(element => {
element.parentNode?.removeChild(element);
});
clearTimeout(timer.current);
timer.current = null;
}
};
return [announce, removeAnnounce] as const;
}
Blog
-
next js 어나운서
-
next js 포커스 매니저 컴포넌트
interface FocusManagerProps { isOpen: boolean; containerId: string; children: React.ReactNode; } const FocusManager = ({ isOpen, containerId, children }: FocusManagerProps) => { const previousFocusRef = useRef<HTMLElement | null>(null); useEffect(() => { const container = document.getElementById(containerId); if (isOpen && container) { // 현재 포커스 저장 previousFocusRef.current = document.activeElement as HTMLElement; // 포커스 이동 처리 setTimeout(() => { const focusableElement = container.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) as HTMLElement; if (focusableElement) { focusableElement.focus(); } else { container.setAttribute('tabindex', '-1'); container.focus(); } }, 500); } else if (!isOpen && container) { // 이전 포커스로 복귀 setTimeout(() => { if (previousFocusRef.current && previousFocusRef.current !== document.body) { previousFocusRef.current.focus(); } }, 500); } }, [isOpen, containerId]); return <>{children}</>; }; export default FocusManager;
-
next js 바텀 시트 접근성 적용
interface BottomSheetAccessibilityManagerProps { isOpen: boolean; bottomSheetId: string; children: React.ReactNode; initialFocusElementId?: string; } const BottomSheetAccessibilityManager = ({ isOpen, bottomSheetId, children, initialFocusElementId }: BottomSheetAccessibilityManagerProps) => { const previousFocusRef = useRef<HTMLElement | null>(null); useEffect(() => { const bottomSheet = document.getElementById(bottomSheetId); if (isOpen && bottomSheet) { // 현재 포커스 저장 previousFocusRef.current = document.activeElement as HTMLElement; // 형제 요소들 inert 처리 const siblings = Array.from(bottomSheet.parentElement?.children || []) .filter(child => child !== bottomSheet); siblings.forEach(sibling => { sibling.setAttribute('inert', ''); }); // 포커스 이동 처리 setTimeout(() => { // 페이지 로딩 시 자동 오픈이거나 이전 포커스가 없는 경우 if (!previousFocusRef.current || previousFocusRef.current === document.body) { if (initialFocusElementId) { const initialFocusElement = document.getElementById(initialFocusElementId); if (initialFocusElement) { initialFocusElement.focus(); return; } } } // 일반적인 포커스 이동 처리 const focusableElement = bottomSheet.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) as HTMLElement; if (focusableElement) { focusableElement.focus(); } else { bottomSheet.setAttribute('tabindex', '-1'); bottomSheet.focus(); } }, 500); } else if (!isOpen && bottomSheet) { // inert 속성 제거 const siblings = Array.from(bottomSheet.parentElement?.children || []) .filter(child => child !== bottomSheet); siblings.forEach(sibling => { sibling.removeAttribute('inert'); }); // 이전 포커스로 복귀 // 이전 포커스가 유효한 경우에만 포커스 이동 setTimeout(() => { if (previousFocusRef.current && previousFocusRef.current !== document.body) { previousFocusRef.current.focus(); } }, 500); } }, [isOpen, bottomSheetId, initialFocusElementId]); return <>{children}</>; }; export default BottomSheetAccessibilityManager;
-
UIView 하위의 모든 텍스트를 가져와서 삽입하는 코드
class AccessibleButtonContainerView: UIView { override var accessibilityLabel: String? { get { let combinedLabels = subviews.compactMap { view -> String? in if let label = view as? UILabel { return label.text } return view.accessibilityLabel }.joined(separator: " ") return combinedLabels.isEmpty ? nil : combinedLabels } set { super.accessibilityLabel = newValue } } override init(frame: CGRect) { super.init(frame: frame) setupAccessibility() } required init?(coder: NSCoder) { super.init(coder: coder) setupAccessibility() } private func setupAccessibility() { isAccessibilityElement = true accessibilityTraits = .button } }