import React, { useCallback, useEffect, useRef, useState } from 'react'; export type AccordionItemId = string | number; export interface UseAccordionProps { defaultOpen?: AccordionItemId; singleOpen?: boolean; } export interface UseAccordionSectionProps { ref: React.RefObject; open: boolean; onToggle: ( e: React.MouseEvent | React.SyntheticEvent | Event ) => void; onKeyDown: (e: React.KeyboardEvent) => void; role: string; // tabIndex: number; 'aria-expanded': boolean | undefined; } export interface UseAccordionReturn { section: (id: AccordionItemId) => UseAccordionSectionProps; open: (id: AccordionItemId) => void; close: (id: AccordionItemId) => void; openAll: () => void; closeAll: () => void; onOpen: (id: AccordionItemId) => void; hasOpen: boolean; currentOpen?: AccordionItemId | AccordionItemId[] | null; } export const useAccordion = ({ defaultOpen, singleOpen = false, }: UseAccordionProps = {}): UseAccordionReturn => { const sectionsRef = useRef< Map> >(new Map()); const [current, setCurrentOpen] = useState( defaultOpen ? { [defaultOpen]: true } : {} ); const getCurrentOpen = (): AccordionItemId | AccordionItemId[] | null => { const openSections: AccordionItemId[] = []; sectionsRef.current.forEach((ref, id) => { if (ref.current?.open) { openSections.push(id); } }); if (singleOpen) { return openSections[0] || null; } return openSections.length ? openSections : null; }; const open = (id: AccordionItemId) => { sectionsRef?.current?.get(id)?.current?.setAttribute('open', ''); }; const close = (id: AccordionItemId) => { sectionsRef.current.get(id)?.current?.removeAttribute('open'); }; const openAll = () => { if (singleOpen) { throw new Error('Cannot open all sections when singleOpen is true'); } sectionsRef.current.forEach((ref) => ref.current?.setAttribute('open', '')); }; const closeAll = () => { sectionsRef.current.forEach((ref) => ref.current?.removeAttribute('open')); }; const navigateTo = useCallback( (direction: 'next' | 'previous') => { const activeElement = document.activeElement; if (!activeElement) return; const focusableElements = Array.from( document.querySelectorAll('summary') ); const currentIndex = focusableElements.indexOf( activeElement as HTMLElement ); let nextIndex; if (direction === 'next') { nextIndex = currentIndex >= 0 ? currentIndex + 1 : focusableElements.length - 1; } else if (direction === 'previous') { nextIndex = currentIndex <= focusableElements.length - 1 ? currentIndex - 1 : 0; } const nextElement = focusableElements[nextIndex]; if (nextElement) { nextElement.focus(); } }, [current] ); const section = useCallback( (id: AccordionItemId) => { if (!sectionsRef.current.has(id)) { sectionsRef.current.set(id, React.createRef()); } const ref = sectionsRef.current.get( id ) as React.RefObject; const onToggle = (e) => { if (singleOpen && e.target?.open) { sectionsRef.current.forEach((sectionRef, sectionId) => { if (sectionId === id) { setCurrentOpen({ ...current, [id]: e.target?.open }); } if (sectionId !== id && sectionRef.current?.hasAttribute('open')) { sectionRef.current?.removeAttribute('open'); } }); } else { setCurrentOpen({ ...current, [id]: e.target?.open }); } }; const onKeyDown = (e: React.KeyboardEvent) => { switch (e.key) { case 'ArrowDown': e.preventDefault(); // Prevent scrolling if needed navigateTo('next'); break; case 'ArrowUp': e.preventDefault(); // Prevent scrolling if needed navigateTo('previous'); break; case 'Enter': e.preventDefault(); // Prevent form submission if needed if (ref?.current?.hasAttribute('open')) { close(id); } else { open(id); } break; } }; return { ref, open: ref?.current?.hasAttribute('open') || false, onToggle, onKeyDown, role: 'button', 'aria-expanded': ref?.current?.hasAttribute('open'), }; }, [current] ); const hasOpen = useCallback(() => { return getCurrentOpen() !== null; }, []); useEffect(() => { if (defaultOpen) { if (Array.isArray(defaultOpen)) { defaultOpen.forEach((id) => open(id)); } else { open(defaultOpen); } } }, [defaultOpen]); return { section, open, close, hasOpen: hasOpen(), openAll, closeAll, onOpen: open, currentOpen: Object.keys(current).length ? getCurrentOpen() : null, }; }; export default useAccordion;