[카테고리:] 자바스크립트

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

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