Blog

  • next js 드롭다운 예제

    // 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;
    
  • 안드로이드 레거시 뷰 커스텀 액션 메서드

        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;