import { useState, useCallback, useEffect, useRef, createRef } from 'react';
interface RadioProps {
tabIndex: number;
ref: React.RefObject<HTMLLabelElement>;
}
interface RadioGroupProps {
onKeyDown: (e: React.KeyboardEvent) => void;
ref: React.RefObject<HTMLDivElement>;
}
export function useRadioGroup(selectedIndex: number | null = null) {
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
const groupRef = useRef<HTMLDivElement>(null);
const radioRefs = useRef<React.RefObject<HTMLLabelElement>[]>([]);
const getRadioCount = useCallback(() => {
return groupRef.current?.querySelectorAll('[role="radio"]').length || 0;
}, []);
useEffect(() => {
if (groupRef.current) {
const count = getRadioCount();
radioRefs.current = Array(count)
.fill(null)
.map((_, i) => radioRefs.current[i] || createRef<HTMLLabelElement>());
}
}, [getRadioCount]);
const onKeyDown = useCallback(
(e: React.KeyboardEvent) => {
const count = getRadioCount();
if (count === 0) return;
let newIndex = focusedIndex;
const startIndex = selectedIndex !== null ? selectedIndex : 0;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
e.preventDefault();
if (focusedIndex === -1) {
newIndex = (startIndex + 1) % count;
} else {
newIndex = (focusedIndex + 1) % count;
}
break;
case 'ArrowLeft':
case 'ArrowUp':
e.preventDefault();
if (focusedIndex === -1) {
newIndex = (startIndex - 1 + count) % count;
} else {
newIndex = (focusedIndex - 1 + count) % count;
}
break;
case ' ':
e.preventDefault();
if (focusedIndex !== -1) {
const element = radioRefs.current[focusedIndex]?.current;
if (element) {
element.click();
}
}
return;
}
if (newIndex !== focusedIndex) {
setFocusedIndex(newIndex);
const element = radioRefs.current[newIndex]?.current;
if (element) {
element.focus();
}
}
},
[focusedIndex, selectedIndex, getRadioCount]
);
const getRadioProps = useCallback(
(index: number): RadioProps => {
if (!radioRefs.current[index]) {
radioRefs.current[index] = createRef<HTMLLabelElement>();
}
return {
tabIndex: focusedIndex === -1
? (selectedIndex === null ? (index === 0 ? 0 : -1) : (index === selectedIndex ? 0 : -1))
: (index === focusedIndex ? 0 : -1),
ref: radioRefs.current[index],
};
},
[focusedIndex, selectedIndex]
);
const getRadioGroupProps = useCallback((): RadioGroupProps => {
return {
onKeyDown,
ref: groupRef,
};
}, [onKeyDown]);
return {
focusedIndex,
getRadioGroupProps,
getRadioProps,
} as const;
}
Blog
-
라디오버튼 접근성 적용 훅
-
바텀시트 컴포넌트
import { useCallback, useEffect, useRef } from 'react'; interface BottomSheetAccessibilityManagerProps { isOpen: boolean; bottomSheetId: string; children: React.ReactNode; initialFocusElementId?: string; } const BottomSheetAccessibilityManager = ({ isOpen, bottomSheetId, children, initialFocusElementId }: BottomSheetAccessibilityManagerProps) => { const previousFocusRef = useRef<HTMLElement | null>(null); const setHiddenExceptBottomSheet = useCallback((element: HTMLElement | null, turn: 'on' | 'off') => { if (typeof window === 'undefined') return; const allElems = document.body.querySelectorAll<HTMLElement>( '*:not(script):not(style):not([inert="true"])' ); allElems.forEach((el) => { el.removeAttribute('inert'); }); if (turn === 'on' && element) { const elementsToHide = Array.from(allElems).filter((el) => { return !element.contains(el) && !el.contains(element); }); elementsToHide.forEach((el) => { el.setAttribute('inert', 'true'); el.setAttribute('is-sr-hidden', 'true'); }); } if (turn === 'off') { document.body.querySelectorAll<HTMLElement>('[is-sr-hidden]').forEach((el) => { el.removeAttribute('is-sr-hidden'); el.removeAttribute('inert'); }); } }, []); useEffect(() => { const bottomSheet = document.getElementById(bottomSheetId); if (isOpen && bottomSheet) { previousFocusRef.current = document.activeElement as HTMLElement; setHiddenExceptBottomSheet(bottomSheet, 'on'); setTimeout(() => { if (initialFocusElementId) { const initialFocusElement = document.getElementById(initialFocusElementId); if (initialFocusElement) { initialFocusElement.focus(); return; } } const focusableElements = bottomSheet.querySelectorAll<HTMLElement>( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (focusableElements.length > 0) { focusableElements[0].focus(); } else { bottomSheet.setAttribute('tabindex', '-1'); bottomSheet.focus(); } }, 100); } else if (!isOpen) { setHiddenExceptBottomSheet(null, 'off'); setTimeout(() => { if (previousFocusRef.current && previousFocusRef.current !== document.body) { previousFocusRef.current.focus(); } }, 100); } }, [isOpen, bottomSheetId, initialFocusElementId, setHiddenExceptBottomSheet]); return <>{children}</>; }; export default BottomSheetAccessibilityManager;
-
next js 탭 컨트롤 키보드 접근성 적용
import { useState, useCallback, useEffect, useRef, createRef } from 'react'; interface TabProps { tabIndex: number; ref: React.RefObject<HTMLElement>; } interface TabListProps { onKeyDown: (e: React.KeyboardEvent) => void; ref: React.RefObject<HTMLElement>; } export function useTabs() { const [focusedIndex, setFocusedIndex] = useState<number>(0); const tabListRef = useRef<HTMLElement>(null); const tabRefs = useRef<React.RefObject<HTMLElement>[]>([]); const isInitialMount = useRef(true); const getTabCount = useCallback(() => { return tabListRef.current?.querySelectorAll('[role="tab"]').length || 0; }, []); useEffect(() => { if (tabListRef.current) { const count = getTabCount(); tabRefs.current = Array(count) .fill(null) .map((_, i) => tabRefs.current[i] || createRef<HTMLElement>()); } }, [getTabCount]); useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; return; } if (focusedIndex >= 0 && tabRefs.current[focusedIndex]?.current) { tabRefs.current[focusedIndex].current?.focus(); } }, [focusedIndex]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { const count = getTabCount(); if (count === 0) return; let newIndex = focusedIndex; if (e.key === 'ArrowRight') { e.preventDefault(); newIndex = (focusedIndex + 1) % count; } else if (e.key === 'ArrowLeft') { e.preventDefault(); newIndex = (focusedIndex - 1 + count) % count; } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const element = tabRefs.current[focusedIndex]?.current; if (element) { element.click(); } return; } if (newIndex !== focusedIndex) { setFocusedIndex(newIndex); } }, [focusedIndex] ); const getTabProps = useCallback( (index: number): TabProps => { if (!tabRefs.current[index]) { tabRefs.current[index] = createRef<HTMLElement>(); } return { tabIndex: index === focusedIndex ? 0 : -1, ref: tabRefs.current[index], }; }, [focusedIndex] ); const getTabListProps = useCallback((): TabListProps => { return { onKeyDown, ref: tabListRef, }; }, [onKeyDown]); return { focusedIndex, getTabListProps, getTabProps, } as const; }
-
맞춤법 수정 프롬프트
You are an AI language editor. Process my voice-recognized text with these capabilities: Your Persona: You are a specialized AI with expertise in: - Voice recognition error correction - Natural language processing - Text restructuring and refinement Your Tasks: 1. Voice Recognition Error Correction: - Fix common voice input mistakes - Remove repeated words and phrases - Correct homophone errors - Fix words split by voice pauses - Correct misheard or misinterpreted words 2. Text Cleanup: - Remove filler words and verbal tics - Eliminate redundant expressions - Fix run-on sentences - Correct grammar and spelling - Adjust punctuation and spacing 3. Formatting: - Structure in clean Markdown - Create logical paragraph breaks - Organize text flow - Format any lists or tables - Apply proper line spacing 4. Content Enhancement: - Preserve my original meaning - Maintain technical terms - Keep my tone and style - Ensure clear communication - Output in my original language Output Requirements: - Provide only the corrected text in Markdown format - Use my original language - Do not include any explanatory comments - Do not explain your changes - Do not add any additional remarks I will provide my voice-recognized text in my next prompt.
-
next js 어나운서
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; }
-
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 } }