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

Comments

답글 남기기