// components/SortDropdown.tsx
import { useRef, useState } from 'react';
interface SortOption {
value: string;
label: string;
}
interface SortDropdownProps {
options: SortOption[];
defaultValue: string;
onChange: (value: string) => void;
}
const SortDropdown = ({ options, defaultValue, onChange }: SortDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState(defaultValue);
const dropdownRef = useRef<HTMLSpanElement>(null);
const firstOptionRef = useRef<HTMLSpanElement>(null);
const handleDropdownClick = () => {
setIsOpen(!isOpen);
if (!isOpen) {
setTimeout(() => {
firstOptionRef.current?.focus();
}, 500);
}
};
const handleOptionSelect = (value: string) => {
setSelectedValue(value);
onChange(value);
setIsOpen(false);
dropdownRef.current?.focus();
};
return (
<div className="sort-dropdown">
<span
ref={dropdownRef}
role="button"
tabIndex={0}
aria-haspopup="listbox"
aria-expanded={isOpen}
onClick={handleDropdownClick}
>
{options.find(opt => opt.value === selectedValue)?.label}
</span>
{isOpen && (
<div
role="radiogroup"
aria-label="정렬 옵션"
className="dropdown-options"
>
{options.map((option, index) => (
<span
key={option.value}
ref={index === 0 ? firstOptionRef : null}
role="radio"
tabIndex={0}
aria-checked={selectedValue === option.value}
onClick={() => handleOptionSelect(option.value)}
>
{option.label}
</span>
))}
</div>
)}
</div>
);
};
export default SortDropdown;
Blog
-
next js 드롭다운 예제
-
안드로이드 레거시 뷰 커스텀 액션 메서드
data class CustomAction( val actionId: Int, val getActionName: () -> String, // 동적으로 액션 이름을 반환하는 함수 val actionHandler: () -> Unit ) fun setCustomAction(view: View, vararg actions: CustomAction) { ViewCompat.setAccessibilityDelegate(view, object : AccessibilityDelegateCompat() { override fun onInitializeAccessibilityNodeInfo(host: View, info: AccessibilityNodeInfoCompat) { super.onInitializeAccessibilityNodeInfo(host, info) for (action in actions) { // 현재 상태에 따른 액션 이름을 가져옵니다 val currentActionName = action.getActionName() info.addAction(AccessibilityNodeInfoCompat.AccessibilityActionCompat(action.actionId, currentActionName)) } } override fun performAccessibilityAction(host: View, actionId: Int, args: Bundle?): Boolean { for (action in actions) { if (action.actionId == actionId) { action.actionHandler.invoke() // 액션 실행 후 accessibility 정보를 갱신합니다 host.sendAccessibilityEvent(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) return true } } return super.performAccessibilityAction(host, actionId, args) } }) }
-
useKeyboardControl
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() }; }
-
라디오버튼 접근성 적용 훅
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;