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;
}
답글 남기기
댓글을 달기 위해서는 로그인해야합니다.