@supunlakmal/hooks
Version:
A collection of reusable React hooks
140 lines • 6.13 kB
JavaScript
import { useEffect, useState, useRef, useCallback } from 'react';
const defaultFocusableSelector = '[role="gridcell"], [role="option"], [role="menuitem"], button:not([disabled]), [href], input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
/**
* Implements the roving tabindex accessibility pattern for keyboard navigation within a group.
* Manages focus among a set of descendants of a container element.
*
* @param containerRef Ref object pointing to the container element.
* @param options Configuration options for the roving tabindex behavior.
*/
export const useRovingTabIndex = (containerRef, options = {}) => {
const { focusableSelector = defaultFocusableSelector, initialIndex = 0, wrapAround = true, orientation = 'horizontal', onIndexChange, } = options;
const [activeIndex, setActiveIndex] = useState(initialIndex);
const focusableElementsRef = useRef([]);
const onIndexChangeRef = useRef(onIndexChange);
useEffect(() => {
onIndexChangeRef.current = onIndexChange;
}, [onIndexChange]);
const updateFocusableElements = useCallback(() => {
if (containerRef.current) {
focusableElementsRef.current = Array.from(containerRef.current.querySelectorAll(focusableSelector));
// Ensure initial tabindex setup
focusableElementsRef.current.forEach((el, index) => {
el.setAttribute('tabindex', index === activeIndex ? '0' : '-1');
});
// Validate initialIndex
if (initialIndex >= focusableElementsRef.current.length ||
initialIndex < 0) {
setActiveIndex(0);
}
}
}, [containerRef, focusableSelector, activeIndex, initialIndex]);
useEffect(() => {
updateFocusableElements();
}, [updateFocusableElements]);
// Effect to handle dynamic children changes (basic)
useEffect(() => {
const container = containerRef.current;
if (!container)
return;
const observer = new MutationObserver(() => {
updateFocusableElements();
// Adjust activeIndex if the focused element disappears
if (activeIndex >= focusableElementsRef.current.length &&
focusableElementsRef.current.length > 0) {
setActiveIndex(focusableElementsRef.current.length - 1);
}
});
observer.observe(container, { childList: true, subtree: true });
return () => observer.disconnect();
}, [containerRef, updateFocusableElements, activeIndex]);
const handleKeyDown = useCallback((event) => {
var _a;
const elements = focusableElementsRef.current;
if (!elements.length)
return;
let newIndex = activeIndex;
let shouldPreventDefault = false;
const isHorizontal = orientation === 'horizontal' || orientation === 'both';
const isVertical = orientation === 'vertical' || orientation === 'both';
switch (event.key) {
case 'ArrowRight':
if (isHorizontal) {
newIndex = activeIndex + 1;
shouldPreventDefault = true;
}
break;
case 'ArrowLeft':
if (isHorizontal) {
newIndex = activeIndex - 1;
shouldPreventDefault = true;
}
break;
case 'ArrowDown':
if (isVertical) {
newIndex = activeIndex + 1;
shouldPreventDefault = true;
}
break;
case 'ArrowUp':
if (isVertical) {
newIndex = activeIndex - 1;
shouldPreventDefault = true;
}
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
break;
case 'End':
newIndex = elements.length - 1;
shouldPreventDefault = true;
break;
default:
return; // Exit if key is not handled
}
if (shouldPreventDefault) {
event.preventDefault();
}
if (wrapAround) {
if (newIndex < 0)
newIndex = elements.length - 1;
if (newIndex >= elements.length)
newIndex = 0;
}
else {
newIndex = Math.max(0, Math.min(newIndex, elements.length - 1));
}
if (newIndex !== activeIndex) {
elements[activeIndex].setAttribute('tabindex', '-1');
const newElement = elements[newIndex];
newElement.setAttribute('tabindex', '0');
newElement.focus();
setActiveIndex(newIndex);
(_a = onIndexChangeRef.current) === null || _a === void 0 ? void 0 : _a.call(onIndexChangeRef, newIndex, newElement);
}
}, [activeIndex, wrapAround, orientation, onIndexChangeRef]);
useEffect(() => {
const container = containerRef.current;
if (!container)
return;
// Attach keydown listener to the container
container.addEventListener('keydown', handleKeyDown);
// Initial focus setup if needed (e.g., on mount)
// This might conflict with autoFocus or other focus management, use carefully.
// if (activeIndex >= 0 && activeIndex < focusableElementsRef.current.length) {
// focusableElementsRef.current[activeIndex]?.focus();
// }
return () => {
container.removeEventListener('keydown', handleKeyDown);
};
}, [containerRef, handleKeyDown]);
// Return focus management utilities or state if needed
// For now, the hook primarily manages focus internally
return {
activeIndex,
setActiveIndex, // Allow external control if necessary
focusableElements: focusableElementsRef.current, // Expose the elements
};
};
//# sourceMappingURL=useRovingTabIndex.js.map