From 4e43a301c322b8b2d7c6de5a6282700753353fa4 Mon Sep 17 00:00:00 2001 From: HariBhandari07 Date: Tue, 16 Jan 2024 15:52:35 +0545 Subject: [PATCH] Feat: Handle Accordion uncontrolled component state --- src/App.tsx | 8 +-- src/components/Accordion.tsx | 127 +++++++++++++++++++++++++++++++++-- 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index e9567fa..377dce6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,16 +3,16 @@ import { Accordion } from "./components/Accordion.tsx"; function App() { return ( <> - - + + Button 1 Panel 1 - + Button 2 Panel 2 - + Button 3 Panel 3 diff --git a/src/components/Accordion.tsx b/src/components/Accordion.tsx index 8290adf..1b77af0 100644 --- a/src/components/Accordion.tsx +++ b/src/components/Accordion.tsx @@ -4,7 +4,9 @@ import { ElementType, forwardRef, ReactNode, + useCallback, useContext, + useState, } from "react"; const ACCORDION_NAME = "Accordion"; @@ -14,6 +16,13 @@ const PANEL_NAME = "AccordionPanel"; const ACCORDION_CONTEXT = "AccordionContext"; const ACCORDION_ITEM_CONTEXT = "AccordionItemContext"; +export function noop() {} + +enum AccordionStates { + Open = "OPEN", + Collapsed = "COLLAPSED", +} + const Accordion = forwardRef(function ( { children, @@ -28,8 +37,57 @@ const Accordion = forwardRef(function ( }: AccordionProps, forwardedRef ) { + const [openPanels, setOpenPanels] = useState(() => { + if (defaultIndex != null) { + if (multiple) { + // If multiple is set to true, we need to make sure the `defaultIndex` + // is an array (and vice versa). + return Array.isArray(defaultIndex) ? defaultIndex : [defaultIndex]; + } else { + return Array.isArray(defaultIndex) + ? defaultIndex[0] ?? 0 + : defaultIndex; + } + } + if (collapsible) { + // collapsible with no defaultIndex will start with all panels collapsed + return multiple ? [] : -1; + } + return multiple ? [0] : 0; + }); + + const onAccordionItemClick = useCallback( + (index: number) => { + setOpenPanels((prevOpenPanels) => { + if (multiple) { + prevOpenPanels = prevOpenPanels as number[]; + // close open panels + if (prevOpenPanels.includes(index)) { + // other panels are open OR accordion is allowed to collapse + if (prevOpenPanels.length > 1 || collapsible) { + return prevOpenPanels.filter((i) => i !== index); + } + } else { + // open panel + return [...prevOpenPanels, index].sort(); + } + } else { + return prevOpenPanels === index && collapsible ? -1 : index; + } + return prevOpenPanels; + }); + }, + [multiple, collapsible] + ); + + const context = { + openPanels, + onAccordionItemClick: readOnly ? noop : onAccordionItemClick, + readOnly, + }; + return ( - + {children} @@ -42,12 +100,27 @@ const AccordionItem = forwardRef(function ( children, as: Comp = "div", disabled = false, + index, ...props }: AccordionItemProps, forwardedRef ) { + const { openPanels } = useAccordionContext(); + + const state = + (Array.isArray(openPanels) + ? openPanels.includes(index) && AccordionStates.Open + : openPanels === index && AccordionStates.Open) || + AccordionStates.Collapsed; + + const context = { + index, + state, + disabled, + }; + return ( - + {children} @@ -59,8 +132,24 @@ const AccordionButton = forwardRef(function ( { children, as: Comp = "button", ...props }: AccordionButtonProps, forwardedRef ) { + const { onAccordionItemClick } = useAccordionContext(); + const { disabled, index } = useAccordionItemContext(); + + const handleTriggerClick = (e: React.MouseEvent) => { + e.preventDefault(); + if (disabled) { + return; + } + onAccordionItemClick(index); + }; + return ( - + {children} ); @@ -70,15 +159,26 @@ const AccordionPanel = forwardRef(function ( { children, as: Comp = "div", ...props }: AccordionPanelProps, forwardedRef ) { + const { state } = useAccordionItemContext(); + return ( - + ); }); -const AccordionContext = createContext({}); -const AccordionItemContext = createContext({}); +const AccordionContext = createContext< + InternalAccordionContextValue | undefined +>(undefined); +const AccordionItemContext = createContext< + InternalAccordionItemContextValue | undefined +>(undefined); const useAccordionContext = () => { const context = useContext(AccordionContext); @@ -130,6 +230,7 @@ interface AccordionItemProps { as?: ElementType | ComponentType; children: ReactNode; disabled?: boolean; + index: number; } interface AccordionButtonProps { @@ -141,3 +242,17 @@ interface AccordionPanelProps { as?: ElementType | ComponentType; children: ReactNode; } + +interface InternalAccordionContextValue { + openPanels: AccordionIndex; + + onAccordionItemClick(index: AccordionIndex): void; + + readOnly: boolean; +} + +interface InternalAccordionItemContextValue { + index: number; + disabled: boolean; + state: AccordionStates; +}