import { KeyboardEvent, useCallback } from 'react';
type ControlRole = 'switch' | 'button' | 'toggleButton' | 'checkbox' | 'link';
type ActionKey = 'Enter' | 'Space';
interface UseKeyboardControlProps {
/** 컴포넌트의 역할을 지정합니다. */
role: ControlRole;
/** 키보드 액션 발생 시 실행될 콜백 함수 */
onKeyAction: (key: ActionKey) => void;
/** 컴포넌트의 활성화 여부 (기본값: true) */
isEnabled?: boolean;
/** 현재 선택 상태 (switch, checkbox, toggleButton에만 적용) */
isSelected?: boolean;
/** 사용자 정의 tabIndex (기본값: 0) */
customTabIndex?: number;
/** ARIA 속성을 자동으로 추가할지 여부 (기본값: true) */
includeARIA?: boolean;
}
interface KeyboardControlProps {
/** 키보드 이벤트 핸들러 */
onKeyDown: (e: KeyboardEvent) => void;
/** 탭 인덱스 */
tabIndex: number;
/** 컴포넌트의 역할 (includeARIA가 true인 경우에만 포함) */
role?: 'button' | 'switch' | 'checkbox' | 'link';
/** ARIA 속성들 (includeARIA가 true인 경우에만 포함) */
'aria-disabled'?: boolean;
'aria-checked'?: boolean;
'aria-pressed'?: boolean;
}
/**
* 키보드 접근성을 지원하는 컨트롤 요소를 위한 커스텀 훅
*
* @example
* // 키보드 접근성만 사용하는 경우
* const keyboardProps = useKeyboardControl({
* role: 'button',
* onKeyAction: (key) => {
* if (key === 'Enter') handleEnterKey();
* if (key === 'Space') handleSpaceKey();
* },
* includeARIA: false
* });
*
* // 링크로 사용 (Enter 키만 처리)
* const linkProps = useKeyboardControl({
* role: 'link',
* onKeyAction: (key) => {
* if (key === 'Enter') navigate('/path');
* }
* });
*
* // 스위치로 사용 (Space 키만 처리)
* const switchProps = useKeyboardControl({
* role: 'switch',
* onKeyAction: (key) => {
* if (key === 'Space') setIsOn(prev => !prev);
* },
* isSelected: isOn
* });
*/
export function useKeyboardControl({
role,
onKeyAction,
isEnabled = true,
isSelected,
customTabIndex,
includeARIA = true
}: UseKeyboardControlProps): KeyboardControlProps {
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (!isEnabled) return;
// 각 역할에 따른 키 처리
switch (role) {
case 'link':
// 링크는 Enter 키만 지원
if (e.key === 'Enter') {
e.preventDefault();
onKeyAction('Enter');
}
break;
case 'button':
case 'toggleButton':
// 버튼은 Enter와 Space 모두 지원
if (e.key === 'Enter') {
e.preventDefault();
onKeyAction('Enter');
} else if (e.key === ' ') {
e.preventDefault();
onKeyAction('Space');
}
break;
default:
// 나머지(switch, checkbox)는 Space만 지원
if (e.key === ' ') {
e.preventDefault();
onKeyAction('Space');
}
}
}, [isEnabled, onKeyAction, role]);
// role에 따른 ARIA 속성 설정
const getAriaAttributes = useCallback(() => {
if (!includeARIA) return {};
const baseAttributes = {
'aria-disabled': !isEnabled
};
switch (role) {
case 'switch':
case 'checkbox':
return {
...baseAttributes,
'aria-checked': isSelected
};
case 'toggleButton':
return {
...baseAttributes,
'aria-pressed': isSelected
};
case 'button':
case 'link':
return baseAttributes;
default:
return baseAttributes;
}
}, [role, isEnabled, isSelected, includeARIA]);
// role 매핑 (toggleButton은 실제 DOM에서는 button role을 사용)
const getDOMRole = useCallback((): 'button' | 'switch' | 'checkbox' | 'link' | undefined => {
if (!includeARIA) return undefined;
return role === 'toggleButton' ? 'button' : role;
}, [role, includeARIA]);
return {
...(includeARIA && { role: getDOMRole() }),
onKeyDown: handleKeyDown,
tabIndex: isEnabled ? (customTabIndex ?? 0) : -1,
...getAriaAttributes()
};
}
Blog
-
useKeyboardControl
-
라디오버튼 접근성 적용 훅
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; }
-
바텀시트 컴포넌트
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, RefObject } from 'react'; /** * 탭 컴포넌트의 키보드 접근성과 포커스 관리를 위한 커스텀 훅 * * @param {UseTabsProps} props - 탭 설정 옵션 * @param {number} props.selectedIndex - 현재 선택된 탭의 인덱스 (1부터 시작) * * @returns {object} 탭 관련 속성과 메서드 * @returns {number} returns.focusedIndex - 현재 포커스된 탭의 인덱스 * @returns {function} returns.getTabListProps - 탭 리스트 컨테이너에 적용할 속성을 반환하는 함수 * @returns {function} returns.getTabProps - 각 탭 버튼에 적용할 속성을 반환하는 함수 * * @example * // 1. 기본적인 ul/li/button 구조 * const { getTabListProps, getTabProps } = useTabs({ selectedIndex: 1 }); * * return ( * <ul {...getTabListProps()} role="tablist"> * {tabs.map((tab, index) => ( * <li key={index} role="none"> * <button * {...getTabProps(index)} * role="tab" * aria-selected={index === selectedIndex - 1} * onClick={() => handleTabChange(index + 1)} * > * {tab.label} * </button> * </li> * ))} * </ul> * ); * * @example * // 2. div 구조를 사용한 탭 * const { getTabListProps, getTabProps } = useTabs({ selectedIndex: 1 }); * * return ( * <div {...getTabListProps()} role="tablist"> * {tabs.map((tab, index) => ( * <div * key={index} * {...getTabProps(index)} * role="tab" * aria-selected={index === selectedIndex - 1} * onClick={() => handleTabChange(index + 1)} * > * {tab.label} * </div> * ))} * </div> * ); * * @example * // 3. 앵커 태그를 사용한 탭 (SPA 라우팅) * const { getTabListProps, getTabProps } = useTabs({ selectedIndex: 1 }); * * return ( * <nav {...getTabListProps()} role="tablist"> * {tabs.map((tab, index) => ( * <a * key={index} * href={tab.href} * {...getTabProps(index)} * role="tab" * aria-selected={index === selectedIndex - 1} * onClick={(e) => { * e.preventDefault(); * handleTabChange(index + 1); * }} * > * {tab.label} * </a> * ))} * </nav> * ); */ interface TabProps { /** 탭의 tabIndex 값 */ tabIndex: number; /** 탭 버튼 요소에 대한 참조 */ ref: RefObject<HTMLButtonElement>; } interface TabListProps { /** 키보드 이벤트 핸들러 */ onKeyDown: (e: React.KeyboardEvent) => void; /** 탭 리스트 요소에 대한 참조 */ ref: RefObject<HTMLUListElement>; } interface UseTabsProps { /** 현재 선택된 탭의 인덱스 (1부터 시작) */ selectedIndex: number; } export function useTabs({ selectedIndex }: UseTabsProps) { const [focusedIndex, setFocusedIndex] = useState(selectedIndex - 1); const tabListRef = useRef<HTMLUListElement | null>(null); const tabRefs = useRef<RefObject<HTMLButtonElement>[]>([]); // selectedIndex가 변경될 때 focusedIndex도 업데이트 useEffect(() => { setFocusedIndex(selectedIndex - 1); }, [selectedIndex]); const getTabCount = useCallback(() => { return ( tabListRef.current?.querySelectorAll('[role="tab"]').length || 0 ); }, []); const isTabDisabled = useCallback((index: number) => { const element = tabRefs.current[index]?.current; if (!element) return false; const htmlDisabled = element.hasAttribute('disabled'); const ariaDisabled = element.getAttribute('aria-disabled') === 'true'; return htmlDisabled || ariaDisabled; }, []); useEffect(() => { if (tabListRef.current) { const count = getTabCount(); tabRefs.current = Array(count) .fill(null) .map((_, i) => tabRefs.current[i] || createRef<HTMLButtonElement>()); } }, [getTabCount]); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { const count = getTabCount(); if (count === 0) return; let newIndex = focusedIndex; if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { e.preventDefault(); const increment = e.key === 'ArrowRight' ? 1 : -1; for (let i = 0; i < count; i++) { newIndex = (newIndex + increment + count) % count; if (!isTabDisabled(newIndex)) { break; } } // 새로운 탭으로 포커스 이동 const nextTab = tabRefs.current[newIndex]?.current; if (nextTab && !isTabDisabled(newIndex)) { nextTab.focus(); setFocusedIndex(newIndex); } } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const element = tabRefs.current[focusedIndex]?.current; if (element && !isTabDisabled(focusedIndex)) { element.click(); } } }, [focusedIndex, getTabCount, isTabDisabled] ); const getTabProps = useCallback( (index: number): TabProps => { if (!tabRefs.current[index]) { tabRefs.current[index] = createRef<HTMLButtonElement>(); } let tabIndexProp = -1; if (index === selectedIndex - 1 && !isTabDisabled(index)) { tabIndexProp = 0; } return { tabIndex: tabIndexProp, ref: tabRefs.current[index], }; }, [selectedIndex, isTabDisabled] ); 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 } }