Blog

  • 라디오버튼 접근성 적용 훅

    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 } 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
        }
    }