Repository: zakodium-oss/react-science
Pull request #887: feat: rework Accordion API
Author: login: stropitek
Created: 2025-03-10T16:18:33Z | Merged: 2025-03-13T10:15:59Z

Squash commit message:

```txt
feat: rework Accordion API and add new features to it

New controlled / uncontrolled API with `open` and `defaultOpen` props.
New `closeAllExcept` and `toggle` methods on `useAccordionControls` hook.

Remove padding where accordion item's toolbar is placed. The toolbar should decide of it's own padding or margin instead.

BREAKING-CHANGE: The `useAccordionContext` hook has been removed from the API. It should only be used internally.
The `defaultOpened` prop of `Accordion.Item` has been renamed to `defaultOpen`.
The `title` prop no longer acts as an identifier, and an additional `id` props has been added to that effect.
The `useToggleAccordion` hook has been renamed to `useAccordionControls`.
Using the same identifier twice now throws a global JS error (not a render error).
The `toolbar` prop has been changed to `renderToolbar` to allow customization of render base on open state.

Closes: https://github.com/zakodium-oss/react-science/issues/876
```



Diff:
diff --git a/src/app/panels/measurement-config/MeasurementConfigPanelAccordion.tsx b/src/app/panels/measurement-config/MeasurementConfigPanelAccordion.tsx
index 1d4503c8..729d4af3 100644
--- a/src/app/panels/measurement-config/MeasurementConfigPanelAccordion.tsx
+++ b/src/app/panels/measurement-config/MeasurementConfigPanelAccordion.tsx
@@ -17,8 +17,9 @@ export function MeasurementConfigPanelAccordion() {
   const size = (selectedMeasurements[selectedKind] || []).length;
   return (
     <Accordion.Item
+      id="measurement-config"
       title={`Measurement${size > 1 ? 's' : ''} config`}
-      defaultOpened
+      defaultOpen
     >
       <MeasurementConfigPanel />
     </Accordion.Item>
diff --git a/src/app/panels/measurement-info/MeasurementInfoPanelAccordion.tsx b/src/app/panels/measurement-info/MeasurementInfoPanelAccordion.tsx
index d9720a86..cd306bd8 100644
--- a/src/app/panels/measurement-info/MeasurementInfoPanelAccordion.tsx
+++ b/src/app/panels/measurement-info/MeasurementInfoPanelAccordion.tsx
@@ -4,7 +4,7 @@ import { MeasurementInfoPanel } from './MeasurementInfoPanel.js';
 
 export function MeasurementInfoPanelAccordion() {
   return (
-    <Accordion.Item title="Measurement info" defaultOpened>
+    <Accordion.Item id="measurement-info" title="Measurement info" defaultOpen>
       <MeasurementInfoPanel />
     </Accordion.Item>
   );
diff --git a/src/app/panels/measurements/MeasurementsPanelAccordion.tsx b/src/app/panels/measurements/MeasurementsPanelAccordion.tsx
index 8df492fd..8156e2f2 100644
--- a/src/app/panels/measurements/MeasurementsPanelAccordion.tsx
+++ b/src/app/panels/measurements/MeasurementsPanelAccordion.tsx
@@ -23,7 +23,7 @@ export function MeasurementsPanelAccordion() {
       : 'Measurements';
 
   return (
-    <Accordion.Item title={title} defaultOpened>
+    <Accordion.Item id="measurement-list" title={title} defaultOpen>
       <MeasurementsPanelStack
         initialPanel={{
           renderPanel: MeasurementsPanel,
diff --git a/src/components/accordion/accordion.tsx b/src/components/accordion/accordion.tsx
index f66ba38f..49886a32 100644
--- a/src/components/accordion/accordion.tsx
+++ b/src/components/accordion/accordion.tsx
@@ -1,4 +1,5 @@
 import styled from '@emotion/styled';
+import { useControllableState } from '@radix-ui/react-use-controllable-state';
 import type {
   MouseEvent as ReactMouseEvent,
   ReactElement,
@@ -8,24 +9,46 @@ import { useCallback } from 'react';
 
 import { useAccordionContext } from './accordion_context.js';
 
-export interface AccordionProps {
+export interface AccordionProps<T extends string = string> {
   children?:
-    | Array<ReactElement<AccordionItemProps>>
-    | ReactElement<AccordionItemProps>
+    | Array<ReactElement<AccordionItemProps<T>>>
+    | ReactElement<AccordionItemProps<T>>
     | boolean
     | null;
 }
 
-export interface AccordionItemProps {
+export interface AccordionItemProps<T extends string = string> {
+  /**
+   * A value uniquely identifying the accordion item.
+   */
+  id: T;
+  /**
+   * The title of the accordion item.
+   */
   title: string;
   children: ReactNode;
-  defaultOpened?: boolean;
+  /**
+   * Defines whether the accordion item is initially open.
+   * This prop has no effect if the `open` prop is used.
+   * A value of `true` means the accordion item is initially open.
+   * A value of `false` means the accordion item is initially closed.
+   * @default false
+   */
+  defaultOpen?: boolean;
+  /**
+   * Controls the open state of the accordion item.
+   */
+  open?: boolean;
+  /**
+   * Called whenever the open state of the pane changes.
+   */
+  onOpenChange?: (isOpen: boolean) => void;
   /**
    * When set to true, the item's children will not be rendered if the item is closed.
    * When not set or set to false, the item's children will be rendered but hidden when the item is closed.
    */
   unmountChildren?: boolean;
-  toolbar?: ReactNode;
+  renderToolbar?: (isOpen: boolean) => ReactNode;
 }
 
 const AccordionItemHeader = styled.div`
@@ -42,7 +65,7 @@ const AccordionItemHeader = styled.div`
   width: 100%;
   user-select: none;
   justify-content: space-between;
-  padding: 0 12px;
+  padding-left: 12px;
 `;
 
 const AccordionContainer = styled.div`
@@ -52,16 +75,29 @@ const AccordionContainer = styled.div`
   width: 100%;
 `;
 
-export function Accordion(props: AccordionProps) {
+export function Accordion<T extends string = string>(props: AccordionProps<T>) {
   return <AccordionContainer>{props.children}</AccordionContainer>;
 }
 
-Accordion.Item = function AccordionItem(props: AccordionItemProps) {
-  const { title, children, defaultOpened, toolbar } = props;
-  const { item, utils, unmountChildren } = useAccordionContext(
+Accordion.Item = function AccordionItem<T extends string = string>(
+  props: AccordionItemProps<T>,
+) {
+  const {
+    id,
     title,
-    defaultOpened,
-  );
+    children,
+    defaultOpen = false,
+    open,
+    renderToolbar,
+    onOpenChange,
+  } = props;
+
+  const [isOpen, setIsOpen] = useControllableState({
+    prop: open,
+    defaultProp: defaultOpen,
+    onChange: onOpenChange,
+  });
+  const { utils, unmountChildren } = useAccordionContext(id, setIsOpen);
 
   const shouldUnmountChildren =
     props.unmountChildren === undefined
@@ -71,7 +107,7 @@ Accordion.Item = function AccordionItem(props: AccordionItemProps) {
   const onClickHandle = useCallback(
     (event: ReactMouseEvent<HTMLDivElement>) => {
       if (event.shiftKey) {
-        utils.clear();
+        utils.closeOthers();
       } else {
         utils.toggle();
       }
@@ -82,7 +118,7 @@ Accordion.Item = function AccordionItem(props: AccordionItemProps) {
   return (
     <div
       style={{
-        flex: item?.isOpen ? '1 1 1px' : 'none',
+        flex: isOpen ? '1 1 1px' : 'none',
         display: 'flex',
         flexDirection: 'column',
         isolation: 'isolate',
@@ -96,13 +132,13 @@ Accordion.Item = function AccordionItem(props: AccordionItemProps) {
         >
           {title}
         </div>
-        {toolbar}
+        {renderToolbar?.(isOpen ?? false)}
       </AccordionItemHeader>
-      {!item?.isOpen && shouldUnmountChildren ? null : (
+      {!isOpen && shouldUnmountChildren ? null : (
         <div
           style={{
-            display: item?.isOpen ? 'flex' : 'none',
-            flex: item?.isOpen ? '1 1 1px' : 'none',
+            display: isOpen ? 'flex' : 'none',
+            flex: isOpen ? '1 1 1px' : 'none',
             backgroundColor: 'white',
             maxHeight: '100%',
             overflow: 'hidden',
diff --git a/src/components/accordion/accordion_context.provider.tsx b/src/components/accordion/accordion_context.provider.tsx
deleted file mode 100644
index fbaf89a7..00000000
--- a/src/components/accordion/accordion_context.provider.tsx
+++ /dev/null
@@ -1,91 +0,0 @@
-import type { ReactNode } from 'react';
-import { useMemo, useReducer } from 'react';
-import { match } from 'ts-pattern';
-
-import type { ContextType } from './accordion_context.js';
-import { accordionContext } from './accordion_context.js';
-import type {
-  AccordionItemState,
-  ActionType,
-} from './accordion_context.utils.js';
-import {
-  changeItem,
-  clearItem,
-  removeItem,
-  toggleItem,
-} from './accordion_context.utils.js';
-
-interface AccordionProviderProps {
-  children: ReactNode;
-  unmountChildren?: boolean;
-}
-
-export type AccordionActions =
-  | ActionType<'CHANGE', { title: string; isOpen: boolean }>
-  | ActionType<'CLEAR', string>
-  | ActionType<'TOGGLE', string>
-  | ActionType<'REMOVE', string>
-  | ActionType<'CREATE', AccordionItemState>;
-
-function reducer(
-  previous: AccordionItemState[],
-  action: AccordionActions,
-): AccordionItemState[] {
-  return match(action)
-    .with({ type: 'CHANGE' }, ({ payload }) => {
-      return changeItem(previous, payload);
-    })
-    .with({ type: 'CLEAR' }, ({ payload }) => {
-      return clearItem(previous, payload);
-    })
-    .with({ type: 'TOGGLE' }, ({ payload }) => {
-      return toggleItem(previous, payload);
-    })
-    .with({ type: 'REMOVE' }, ({ payload }) => {
-      return removeItem(previous, payload);
-    })
-    .with({ type: 'CREATE' }, ({ payload }) => {
-      return [...previous, payload];
-    })
-    .exhaustive();
-}
-
-export function AccordionProvider(props: AccordionProviderProps) {
-  const { unmountChildren = false, children } = props;
-  const [items, dispatch] = useReducer(reducer, []);
-
-  const utils = useMemo<ContextType[1]>(() => {
-    return {
-      change: (title: string, isOpen: boolean) =>
-        dispatch({ type: 'CHANGE', payload: { title, isOpen } }),
-      clear: (except: string) => dispatch({ type: 'CLEAR', payload: except }),
-      toggle: (title: string) => dispatch({ type: 'TOGGLE', payload: title }),
-      remove: (title: string) => dispatch({ type: 'REMOVE', payload: title }),
-      create: (title: string, defaultOpened?: boolean) => {
-        const item: AccordionItemState = {
-          title,
-          isOpen: defaultOpened || false,
-        };
-
-        dispatch({ type: 'CREATE', payload: item });
-        return item;
-      },
-    };
-  }, []);
-
-  const contextValue = useMemo<ContextType>(() => {
-    return [
-      {
-        items,
-        unmountChildren,
-      },
-      utils,
-    ];
-  }, [items, unmountChildren, utils]);
-
-  return (
-    <accordionContext.Provider value={contextValue}>
-      {children}
-    </accordionContext.Provider>
-  );
-}
diff --git a/src/components/accordion/accordion_context.ts b/src/components/accordion/accordion_context.ts
index c2fe753e..a1e9f82c 100644
--- a/src/components/accordion/accordion_context.ts
+++ b/src/components/accordion/accordion_context.ts
@@ -1,19 +1,18 @@
 import { createContext, useContext, useEffect, useMemo } from 'react';
 
 import type {
-  AccordionItemState,
+  AccordionItemSetIsOpen,
   AccordionState,
-} from './accordion_context.utils.js';
-import { getItem } from './accordion_context.utils.js';
+} from './accordion_context_utils.js';
 
 export type ContextType = [
   AccordionContext,
   {
-    clear: (except: string) => void;
-    toggle: (title: string) => void;
-    remove: (title: string) => void;
-    create: (title: string, defaultOpened?: boolean) => AccordionItemState;
-    change: (title: string, isOpen: boolean) => void;
+    closeAllExcept: (except: string) => void;
+    toggle: (id: string) => void;
+    unregister: (id: string) => void;
+    register: (id: string, setIsOpen: AccordionItemSetIsOpen) => void;
+    change: (id: string, isOpen: boolean) => void;
   },
 ];
 
@@ -21,24 +20,10 @@ type AccordionContext = AccordionState;
 
 export const accordionContext = createContext<ContextType | null>(null);
 
-export function useToggleAccordion() {
-  const context = useContext(accordionContext);
-
-  if (!context) {
-    throw new Error('AccordionContext was not found');
-  }
-
-  const [, utils] = context;
-
-  return useMemo(() => {
-    return {
-      open: (title: string) => utils.change(title, true),
-      close: (title: string) => utils.change(title, false),
-    };
-  }, [utils]);
-}
-
-export function useAccordionContext(title: string, defaultOpened?: boolean) {
+export function useAccordionContext(
+  id: string,
+  setIsOpen: AccordionItemSetIsOpen,
+) {
   const context = useContext(accordionContext);
 
   if (!context) {
@@ -48,23 +33,18 @@ export function useAccordionContext(title: string, defaultOpened?: boolean) {
   const [state, utils] = context;
 
   useEffect(() => {
-    utils.create(title, defaultOpened);
-    return () => utils.remove(title);
-  }, [defaultOpened, title, utils]);
-
-  const item = getItem(title, state.items);
+    utils.register(id, setIsOpen);
+    return () => utils.unregister(id);
+  }, [id, utils, setIsOpen]);
 
   return useMemo(
     () => ({
-      item,
       unmountChildren: state.unmountChildren,
       utils: {
-        clear: () => utils.clear(title),
-        toggle: () => utils.toggle(title),
-        remove: () => utils.remove(title),
-        create: (defaultOpened?: boolean) => utils.create(title, defaultOpened),
+        closeOthers: () => utils.closeAllExcept(id),
+        toggle: () => utils.toggle(id),
       },
     }),
-    [item, title, utils, state.unmountChildren],
+    [id, utils, state.unmountChildren],
   );
 }
diff --git a/src/components/accordion/accordion_context.utils.ts b/src/components/accordion/accordion_context.utils.ts
deleted file mode 100644
index d55cda69..00000000
--- a/src/components/accordion/accordion_context.utils.ts
+++ /dev/null
@@ -1,66 +0,0 @@
-export interface AccordionItemState {
-  title: string;
-  isOpen: boolean;
-}
-
-export interface AccordionState {
-  items: AccordionItemState[];
-  unmountChildren: boolean;
-}
-
-export function getItem(title: string, items: AccordionItemState[]) {
-  return items.find((element) => element.title === title);
-}
-
-export type ActionType<Action, Payload = void> = Payload extends void
-  ? { type: Action }
-  : { type: Action; payload: Payload };
-
-export function removeItem(array: AccordionItemState[], title: string) {
-  return array.filter((element) => element.title !== title);
-}
-
-export function changeItem(
-  array: AccordionItemState[],
-  payload: { title: string; isOpen: boolean },
-) {
-  const { title, isOpen } = payload;
-  const item = getItem(title, array);
-
-  if (item) {
-    return [...removeItem(array, title), { ...item, isOpen }];
-  }
-
-  return array;
-}
-
-export function clearItem(array: AccordionItemState[], except: string) {
-  const item = getItem(except, array);
-
-  if (!item) {
-    return [...array, { isOpen: true, title: except }];
-  }
-
-  return array.map((element) => {
-    return {
-      ...element,
-      isOpen: element.title === item.title,
-    };
-  });
-}
-
-export function toggleItem(array: AccordionItemState[], title: string) {
-  const item = getItem(title, array);
-
-  if (!item) {
-    return [...array, { title, isOpen: true }];
-  }
-
-  return [
-    ...removeItem(array, title),
-    {
-      ...item,
-      isOpen: !item.isOpen,
-    },
-  ];
-}
diff --git a/src/components/accordion/accordion_context_provider.tsx b/src/components/accordion/accordion_context_provider.tsx
new file mode 100644
index 00000000..70927b53
--- /dev/null
+++ b/src/components/accordion/accordion_context_provider.tsx
@@ -0,0 +1,79 @@
+import type { ReactNode } from 'react';
+import { useMemo, useRef } from 'react';
+
+import { assert } from '../utils/index.js';
+
+import type { ContextType } from './accordion_context.js';
+import { accordionContext } from './accordion_context.js';
+import type { AccordionItemSetIsOpen } from './accordion_context_utils.js';
+import { getAccordionRegister } from './accordion_context_utils.js';
+
+export interface AccordionProviderProps {
+  children: ReactNode;
+  unmountChildren?: boolean;
+}
+
+export function AccordionProvider(props: AccordionProviderProps) {
+  const { unmountChildren = false, children } = props;
+  const registerRef = useRef(getAccordionRegister());
+
+  const utils = useMemo<ContextType[1]>(() => {
+    return {
+      change: (id: string, isOpen: boolean) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(id);
+        if (item) {
+          item.setIsOpen(isOpen);
+        }
+      },
+      closeAllExcept: (id: string) => {
+        assert(registerRef.current);
+        for (const [key, item] of registerRef.current.entries()) {
+          if (key === id) {
+            item.setIsOpen(true);
+          } else {
+            item.setIsOpen(false);
+          }
+        }
+      },
+      toggle: (id: string) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(id);
+        if (item) {
+          item.setIsOpen((prevState) => !prevState);
+        }
+      },
+      unregister: (id: string) => {
+        assert(registerRef.current);
+        registerRef.current.delete(id);
+      },
+      register: (id: string, setIsOpen: AccordionItemSetIsOpen) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(id);
+        if (item !== undefined) {
+          throw new Error(
+            `Accordion item with title "${id}" already exists. Using non-unique titles can lead to unexpected behavior.`,
+          );
+        }
+        registerRef.current.set(id, {
+          setIsOpen,
+        });
+      },
+    };
+  }, [registerRef]);
+
+  const contextValue = useMemo<ContextType>(() => {
+    return [
+      {
+        unmountChildren,
+      },
+      utils,
+    ];
+  }, [unmountChildren, utils]);
+
+  return (
+    <accordionContext.Provider value={contextValue}>
+      {children}
+    </accordionContext.Provider>
+  );
+}
diff --git a/src/components/accordion/accordion_context_utils.ts b/src/components/accordion/accordion_context_utils.ts
new file mode 100644
index 00000000..cda2d921
--- /dev/null
+++ b/src/components/accordion/accordion_context_utils.ts
@@ -0,0 +1,22 @@
+import type { Dispatch, SetStateAction } from 'react';
+
+export interface AccordionItemState {
+  title: string;
+  onChange: AccordionItemSetIsOpen;
+}
+
+export interface AccordionState {
+  unmountChildren: boolean;
+}
+
+export type AccordionItemSetIsOpen = Dispatch<
+  SetStateAction<boolean | undefined>
+>;
+
+interface AccordionRegisteredItem {
+  setIsOpen: AccordionItemSetIsOpen;
+}
+
+export function getAccordionRegister() {
+  return new Map<string, AccordionRegisteredItem>();
+}
diff --git a/src/components/accordion/accordion_hooks.ts b/src/components/accordion/accordion_hooks.ts
new file mode 100644
index 00000000..77f13012
--- /dev/null
+++ b/src/components/accordion/accordion_hooks.ts
@@ -0,0 +1,32 @@
+import { useContext, useMemo } from 'react';
+
+import { accordionContext } from './accordion_context.js';
+
+export interface AccordionControls<T extends string = string> {
+  open: (id: T) => void;
+  close: (id: T) => void;
+  toggle: (id: T) => void;
+  closeAllExcept: (except: T) => void;
+}
+
+export function useAccordionControls<
+  T extends string = string,
+>(): AccordionControls<T> {
+  const context = useContext(accordionContext);
+
+  if (!context) {
+    throw new Error('AccordionContext was not found');
+  }
+
+  const [, utils] = context;
+
+  return useMemo(
+    () => ({
+      open: (id: T) => utils.change(id, true),
+      close: (id: T) => utils.change(id, false),
+      toggle: (id: T) => utils.toggle(id),
+      closeAllExcept: (except: T) => utils.closeAllExcept(except),
+    }),
+    [utils],
+  );
+}
diff --git a/src/components/accordion/index.ts b/src/components/accordion/index.ts
index 733e5da8..0af8b1c9 100644
--- a/src/components/accordion/index.ts
+++ b/src/components/accordion/index.ts
@@ -1,7 +1,3 @@
 export * from './accordion.js';
-export { type AccordionState } from './accordion_context.utils.js';
-export {
-  useToggleAccordion,
-  useAccordionContext,
-} from './accordion_context.js';
-export { AccordionProvider } from './accordion_context.provider.js';
+export * from './accordion_context_provider.js';
+export * from './accordion_hooks.js';
diff --git a/stories/components/accordion.stories.tsx b/stories/components/accordion.stories.tsx
index 304702c9..1632c861 100644
--- a/stories/components/accordion.stories.tsx
+++ b/stories/components/accordion.stories.tsx
@@ -1,20 +1,33 @@
 import { Button } from '@blueprintjs/core';
+import { action } from '@storybook/addon-actions';
 import type { Meta } from '@storybook/react';
 import { useState } from 'react';
 
-import type { ToolbarItemProps } from '../../src/components/index.js';
+import type {
+  AccordionItemProps,
+  ToolbarItemProps,
+} from '../../src/components/index.js';
 import {
   Accordion,
   AccordionProvider,
   SplitPane,
   Toolbar,
-  useToggleAccordion,
+  useAccordionControls,
 } from '../../src/components/index.js';
 import { AccordionDecorator } from '../utils.js';
 
+import {
+  AccordionStoryItem,
+  AccordionStoryProvider,
+} from './accordion_story_helpers.js';
+
 export default {
   title: 'Components / Accordion',
+  component: Accordion.Item,
   decorators: [AccordionDecorator],
+  args: {
+    onOpenChange: action('onOpenChange'),
+  },
 };
 const items: Array<
   Pick<ToolbarItemProps, 'tooltip' | 'icon' | 'disabled'> & {
@@ -41,18 +54,20 @@ const items: Array<
   },
 ];
 
-export function Fixed() {
+export function Control(props: AccordionItemProps) {
   return (
     <div
       style={{
-        height: 300,
+        height: '100%',
       }}
     >
       <Accordion>
         <Accordion.Item
+          {...props}
+          id="first"
           title="First Item"
-          defaultOpened
-          toolbar={
+          defaultOpen
+          renderToolbar={() => (
             <Toolbar>
               {items.map((item) => (
                 <Toolbar.Item
@@ -66,11 +81,11 @@ export function Fixed() {
                 />
               ))}
             </Toolbar>
-          }
+          )}
         >
           This is the first content
         </Accordion.Item>
-        <Accordion.Item title="Second Item">
+        <Accordion.Item {...props} id="second" title="Second Item">
           This is the content of the second item Lorem ipsum, dolor sit amet
           consectetur adipisicing elit. Error provident illum quidem quod dicta
           sed aperiam ex iusto nesciunt culpa esse, quibusdam quam ea
@@ -146,7 +161,7 @@ export function Fixed() {
           dolor sit amet consectetur adipisicing elit. Ad ipsam enim quis iusto
           sit veniam esse id accusantium quos minima facilis commodi quibus
         </Accordion.Item>
-        <Accordion.Item title="With Toolbar">
+        <Accordion.Item {...props} title="With Toolbar">
           <Toolbar>
             <Toolbar.Item tooltip="Test A" icon="lab-test" />
             <Toolbar.Item tooltip="Test B" icon="airplane" />
@@ -161,12 +176,12 @@ export function WithDynamicItems() {
   const [state, setState] = useState([
     {
       title: 'first',
-      defaultOpened: true,
+      defaultOpen: true,
       content: 'This is the content of the first item',
     },
     {
       title: 'Second',
-      defaultOpened: false,
+      defaultOpen: false,
       content: 'This is the content of the second item',
     },
   ]);
@@ -177,7 +192,7 @@ export function WithDynamicItems() {
       {
         title: `${state.length + 1}`,
         content: 'Element added',
-        defaultOpened: false,
+        defaultOpen: false,
       },
     ]);
   }
@@ -224,7 +239,7 @@ export function WithDynamicItems() {
       >
         <Accordion>
           {state.map(({ content, ...element }) => (
-            <Accordion.Item key={element.title} {...element}>
+            <Accordion.Item id={element.title} key={element.title} {...element}>
               {content}
             </Accordion.Item>
           ))}
@@ -234,17 +249,11 @@ export function WithDynamicItems() {
   );
 }
 
-export function WithToggle() {
-  const utils = useToggleAccordion();
-
-  function onCallback(title: string, action: 'open' | 'close') {
-    if (action === 'open') {
-      utils.open(title);
-    } else {
-      utils.close(title);
-    }
-  }
+type WithToggleTitle = 'spectra' | 'integral';
 
+export function WithToggle() {
+  const utils = useAccordionControls<WithToggleTitle>();
+  const TypedAccordionItem = Accordion.Item<WithToggleTitle>;
   return (
     <>
       <div
@@ -266,29 +275,33 @@ export function WithToggle() {
           }}
         >
           <SplitPane defaultSize="35%">
-            <div style={{ padding: 5, display: 'flex', gap: 5, height: 40 }}>
-              <button
-                type="button"
-                onClick={() => onCallback('Spectra', 'open')}
-                style={{
-                  backgroundColor: 'rgba(252, 165, 165)',
-                  paddingLeft: 5,
-                  paddingRight: 5,
-                }}
-              >
+            <div
+              style={{
+                padding: 5,
+                display: 'grid',
+                gridTemplateColumns: '1fr 1fr 1fr',
+                gridTemplateRows: '30px 30px',
+                gap: 5,
+              }}
+            >
+              <Button onClick={() => utils.open('spectra')}>
                 Open Spectra
-              </button>
-              <button
-                type="button"
-                onClick={() => onCallback('Spectra', 'close')}
-                style={{
-                  backgroundColor: 'rgba(110, 231, 183)',
-                  paddingLeft: 5,
-                  paddingRight: 5,
-                }}
-              >
+              </Button>
+              <Button onClick={() => utils.close('spectra')}>
                 Close Spectra
-              </button>
+              </Button>
+              <Button onClick={() => utils.toggle('spectra')}>
+                Toggle Spectra
+              </Button>
+              <Button onClick={() => utils.open('integral')}>
+                Open Integral
+              </Button>
+              <Button onClick={() => utils.close('integral')}>
+                Close Integral
+              </Button>
+              <Button onClick={() => utils.toggle('integral')}>
+                Toggle Integral
+              </Button>
             </div>
             <div
               style={{
@@ -298,12 +311,12 @@ export function WithToggle() {
               }}
             >
               <Accordion>
-                <Accordion.Item title="Spectra" defaultOpened>
+                <TypedAccordionItem id="spectra" title="Spectra" defaultOpen>
                   <p style={{ padding: 5 }}>Spectra lorem</p>
-                </Accordion.Item>
-                <Accordion.Item title="Integral">
+                </TypedAccordionItem>
+                <TypedAccordionItem id="integral" title="Integral">
                   <p style={{ padding: 5 }}>Integral lorem</p>
-                </Accordion.Item>
+                </TypedAccordionItem>
               </Accordion>
             </div>
           </SplitPane>
@@ -313,6 +326,8 @@ export function WithToggle() {
   );
 }
 
+WithToggle.storyName = 'With the useToggleAccordion hook';
+
 export const UnmountChildren = {
   decorators: [],
   args: {
@@ -322,10 +337,10 @@ export const UnmountChildren = {
     return (
       <AccordionProvider {...props}>
         <Accordion>
-          <Accordion.Item title="First">
+          <Accordion.Item id="first" title="First">
             <Count />
           </Accordion.Item>
-          <Accordion.Item title="Second" defaultOpened>
+          <Accordion.Item id="second" title="Second" defaultOpen>
             <Count />
           </Accordion.Item>
         </Accordion>
@@ -337,16 +352,79 @@ export const UnmountChildren = {
 export function UnmountSomeChildren() {
   return (
     <Accordion>
-      <Accordion.Item title="Always render children" defaultOpened>
+      <Accordion.Item id="1" title="Always render children" defaultOpen>
         <Count />
       </Accordion.Item>
-      <Accordion.Item title="Unmount children" defaultOpened unmountChildren>
+      <Accordion.Item
+        id="2"
+        title="Unmount children"
+        defaultOpen
+        unmountChildren
+      >
         <Count />
       </Accordion.Item>
     </Accordion>
   );
 }
 
+export function WithToolbar() {
+  function renderToolbar(isOpen: boolean) {
+    return <Button icon={isOpen ? 'minus' : 'plus'} variant="minimal" />;
+  }
+  return (
+    <Accordion>
+      <Accordion.Item id="first" title="First" renderToolbar={renderToolbar}>
+        First content
+      </Accordion.Item>
+      <Accordion.Item
+        id="second"
+        title="Second"
+        defaultOpen
+        renderToolbar={renderToolbar}
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
+}
+
+export function ControlledState() {
+  return (
+    <AccordionStoryProvider>
+      <Accordion>
+        <AccordionStoryItem id="first" title="First">
+          First content
+        </AccordionStoryItem>
+        <AccordionStoryItem id="second" title="Second">
+          Second content
+        </AccordionStoryItem>
+        <AccordionStoryItem id="third" title="Third">
+          Third content
+        </AccordionStoryItem>
+      </Accordion>
+    </AccordionStoryProvider>
+  );
+}
+
+export function BadWithIdenticalTitle() {
+  return (
+    <>
+      <div>
+        This story should throw a global exception and thus not be displayed
+        properly
+      </div>
+      <Accordion>
+        <Accordion.Item id="item" title="Accordion item">
+          Item 1
+        </Accordion.Item>
+        <Accordion.Item id="item" title="Accordion item">
+          Item 2
+        </Accordion.Item>
+      </Accordion>
+    </>
+  );
+}
+
 function Count() {
   const [count, setCount] = useState(0);
 
diff --git a/stories/components/accordion_story_helpers.tsx b/stories/components/accordion_story_helpers.tsx
new file mode 100644
index 00000000..a28a4c7c
--- /dev/null
+++ b/stories/components/accordion_story_helpers.tsx
@@ -0,0 +1,81 @@
+import type { Dispatch, ReactNode } from 'react';
+import { createContext, useContext, useMemo, useReducer } from 'react';
+import { match } from 'ts-pattern';
+
+import { Accordion, assert } from '../../src/components/index.js';
+
+type AccordionStoryItemId = 'first' | 'second' | 'third';
+
+interface AccordionStoryState {
+  openItems: AccordionStoryItemId[];
+}
+
+type AccordionStoryAction =
+  | { type: 'add'; id: AccordionStoryItemId }
+  | { type: 'remove'; id: AccordionStoryItemId };
+
+function accordionStoryReducer(
+  state: AccordionStoryState,
+  action: AccordionStoryAction,
+) {
+  return match(action)
+    .with({ type: 'add' }, ({ id }) => {
+      return {
+        openItems: Array.from(new Set([...state.openItems, id])),
+      };
+    })
+    .with({ type: 'remove' }, ({ id }) => {
+      return {
+        openItems: state.openItems.filter((item) => item !== id),
+      };
+    })
+    .exhaustive();
+}
+
+const accordionStoryContext = createContext<{
+  state: AccordionStoryState;
+  dispatch: Dispatch<AccordionStoryAction>;
+} | null>(null);
+
+export function AccordionStoryProvider(props: { children: ReactNode }) {
+  const [state, dispatch] = useReducer(accordionStoryReducer, {
+    openItems: ['first'],
+  });
+  const value = useMemo(() => ({ state, dispatch }), [state, dispatch]);
+  return (
+    <accordionStoryContext.Provider value={value}>
+      {props.children}
+    </accordionStoryContext.Provider>
+  );
+}
+
+interface AccordionStoryItemProps {
+  id: AccordionStoryItemId;
+  title: string;
+  children: ReactNode;
+}
+
+export function AccordionStoryItem(props: AccordionStoryItemProps) {
+  const { id, title, children } = props;
+  const contextValue = useContext(accordionStoryContext);
+  assert(contextValue, 'AccordionStoryItem must be used within AccordionStory');
+  const { state, dispatch } = contextValue;
+  if (!state) {
+    throw new Error('AccordionStoryItem must be used within AccordionStory');
+  }
+
+  return (
+    <Accordion.Item<AccordionStoryItemId>
+      id={id}
+      title={title}
+      open={state.openItems.includes(id)}
+      onOpenChange={(isOpen) =>
+        isOpen
+          ? dispatch({ type: 'add', id })
+          : dispatch({ type: 'remove', id })
+      }
+    >
+      {children}
+    </Accordion.Item>
+  );
+}
diff --git a/stories/components/dialog.stories.tsx b/stories/components/dialog.stories.tsx
index b15ab24d..3bdedf22 100644
--- a/stories/components/dialog.stories.tsx
+++ b/stories/components/dialog.stories.tsx
@@ -323,10 +323,10 @@ function DemoPage(props: { openDialog: () => void }) {
               }}
             >
               <Accordion>
-                <Accordion.Item title="Spectra" defaultOpened>
+                <Accordion.Item id="spectra" title="Spectra" defaultOpen>
                   <p style={{ padding: 5 }}>Spectra lorem</p>
                 </Accordion.Item>
-                <Accordion.Item title="Integral">
+                <Accordion.Item id="integral" title="Integral">
                   <p style={{ padding: 5 }}>Integral lorem</p>
                 </Accordion.Item>
               </Accordion>
diff --git a/stories/components/panel-header.stories.tsx b/stories/components/panel-header.stories.tsx
index e418d382..7efaf82a 100644
--- a/stories/components/panel-header.stories.tsx
+++ b/stories/components/panel-header.stories.tsx
@@ -83,7 +83,7 @@ export function WithAccordion() {
       }}
     >
       <Accordion>
-        <Accordion.Item title="First Item" defaultOpened>
+        <Accordion.Item id="first" title="First Item" defaultOpen>
           <PanelHeader total={20} onClickSettings={() => void 0}>
             <Toolbar>
               <Toolbar.Item intent="danger" icon="trash" />
@@ -93,7 +93,7 @@ export function WithAccordion() {
           </PanelHeader>
           This is the first content
         </Accordion.Item>
-        <Accordion.Item title="Second Item">
+        <Accordion.Item id="second" title="Second Item">
           <PanelHeader current={2} total={3} onClickSettings={() => void 0}>
             <Button variant="minimal" icon="filter" />
           </PanelHeader>
@@ -202,7 +202,7 @@ export function WithAccordionPanelToolbarStackedPanel() {
       }}
     >
       <Accordion>
-        <Accordion.Item title="First Item" defaultOpened>
+        <Accordion.Item id="first" title="First Item" defaultOpen>
           <Panel
             initialPanel={{
               renderPanel: InitialPanel,
@@ -211,7 +211,7 @@ export function WithAccordionPanelToolbarStackedPanel() {
             renderActivePanelOnly={false}
           />
         </Accordion.Item>
-        <Accordion.Item title="Second Item">
+        <Accordion.Item id="second" title="Second Item">
           <PanelHeader current={2} total={3} onClickSettings={() => void 0}>
             <Button variant="minimal" icon="filter" />
           </PanelHeader>
diff --git a/stories/components/panel-preferences-toolbar.stories.tsx b/stories/components/panel-preferences-toolbar.stories.tsx
index ce2fe8c0..d1cf693b 100644
--- a/stories/components/panel-preferences-toolbar.stories.tsx
+++ b/stories/components/panel-preferences-toolbar.stories.tsx
@@ -41,7 +41,7 @@ export function WithAccordion() {
       }}
     >
       <Accordion>
-        <Accordion.Item title="First Item" defaultOpened>
+        <Accordion.Item id="first" title="First Item" defaultOpen>
           {isOpen ? (
             <div>
               <PanelPreferencesToolbar onSave={close} onClose={close} />
diff --git a/stories/components/split_pane.advanced.stories.tsx b/stories/components/split_pane.advanced.stories.tsx
index e17b4fa9..15d2577c 100644
--- a/stories/components/split_pane.advanced.stories.tsx
+++ b/stories/components/split_pane.advanced.stories.tsx
@@ -24,7 +24,7 @@ export function Inception() {
           <p>A</p>
           <AccordionProvider>
             <Accordion>
-              <Accordion.Item title="A">
+              <Accordion.Item id="A" title="A">
                 <p>
                   Lorem ipsum dolor sit amet consectetur adipisicing elit.
                   Perferendis distinctio ducimus beatae iure! Vel, laudantium.
@@ -32,7 +32,7 @@ export function Inception() {
                   quibusdam incidunt enim officia tenetur corrupti qui quia.
                 </p>
               </Accordion.Item>
-              <Accordion.Item title="B">
+              <Accordion.Item id="B" title="B">
                 <p>
                   Lorem ipsum dolor sit amet consectetur adipisicing elit.
                   Perferendis distinctio ducimus beatae iure! Vel, laudantium.
@@ -40,7 +40,7 @@ export function Inception() {
                   quibusdam incidunt enim officia tenetur corrupti qui quia.
                 </p>
               </Accordion.Item>
-              <Accordion.Item title="C">
+              <Accordion.Item id="C" title="C">
                 <p>
                   Lorem ipsum dolor sit amet consectetur adipisicing elit.
                   Perferendis distinctio ducimus beatae iure! Vel, laudantium.


Reviews:
user: login: tpoisseau
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T08:08:22Z
user: login: stropitek
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T16:34:35Z
user: login: stropitek
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T16:36:11Z
user: login: stropitek
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-11T16:37:32Z
user: login: tpoisseau
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T08:23:59Z
user: login: tpoisseau
body: SGTM, a better story for control state would be great.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T08:34:07Z
user: login: stropitek
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T10:14:54Z

Review comments:
diff_hunk: @@ -0,0 +1,80 @@
+import type { ReactNode } from 'react';
+import { useMemo, useRef } from 'react';
+
+import { assert } from '../utils/index.js';
+
+import type { ContextType } from './accordion_context.js';
+import { accordionContext } from './accordion_context.js';
+import type { AccordionItemSetIsOpen } from './accordion_context_utils.js';
+import { getAccordionRegister } from './accordion_context_utils.js';
+
+export interface AccordionProviderProps {
+  children: ReactNode;
+  unmountChildren?: boolean;
+}
+
+export function AccordionProvider(props: AccordionProviderProps) {
+  const { unmountChildren = false, children } = props;
+  const registerRef = useRef(getAccordionRegister());
+
+  const utils = useMemo<ContextType[1]>(() => {
+    return {
+      change: (title: string, isOpen: boolean) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(title);
+        if (item) {
+          item.setIsOpen(isOpen);
+        }
+      },
+      closeAllExcept: (title: string) => {
+        assert(registerRef.current);
+        for (const [key, item] of registerRef.current.entries()) {
+          if (key === title) {
+            item.setIsOpen(true);
+          } else {
+            item.setIsOpen(false);
+          }
+        }
+      },
+      toggle: (title: string) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(title);
+        if (item) {
+          item.setIsOpen((prevState) => !prevState);
+        }
+      },
+      unregister: (title: string) => {
+        assert(registerRef.current);
+        registerRef.current.delete(title);
+      },
+      register: (title: string, setIsOpen: AccordionItemSetIsOpen) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(title);
+        if (item) {
+          // eslint-disable-next-line no-console
+          console.error(
path: src/components/accordion/accordion_context_provider.tsx
user: login: tpoisseau
body: `console.warn`, or if you consider it's an error, use assert.

NB: title should be renamed to id
created_at: 2025-03-11T07:54:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -8,18 +9,37 @@ import { useCallback } from 'react';
 
 import { useAccordionContext } from './accordion_context.js';
 
-export interface AccordionProps {
+export interface AccordionProps<T extends string = string> {
   children?:
-    | Array<ReactElement<AccordionItemProps>>
-    | ReactElement<AccordionItemProps>
+    | Array<ReactElement<AccordionItemProps<T>>>
+    | ReactElement<AccordionItemProps<T>>
     | boolean
     | null;
 }
 
-export interface AccordionItemProps {
-  title: string;
+export interface AccordionItemProps<T extends string = string> {
+  /**
+   * The tile of the accordion item.
+   * The title also acts as an identifier and should be unique within an accordion provider.
+   */
+  title: T;
path: src/components/accordion/accordion.tsx
user: login: tpoisseau
body: `id` props for the identifier, and `name` or `title` props to display. Would be more appropriate than a title which do both.
created_at: 2025-03-11T07:58:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -337,16 +340,91 @@ export const UnmountChildren = {
 export function UnmountSomeChildren() {
   return (
     <Accordion>
-      <Accordion.Item title="Always render children" defaultOpened>
+      <Accordion.Item title="Always render children" defaultOpen>
         <Count />
       </Accordion.Item>
-      <Accordion.Item title="Unmount children" defaultOpened unmountChildren>
+      <Accordion.Item title="Unmount children" defaultOpen unmountChildren>
         <Count />
       </Accordion.Item>
     </Accordion>
   );
 }
 
+export function WithToolbar() {
+  return (
+    <Accordion>
+      <Accordion.Item
+        title="First"
+        toolbar={<Button icon="minus" variant="minimal" />}
+      >
+        First content
+      </Accordion.Item>
+      <Accordion.Item
+        title="Second"
+        defaultOpen
+        toolbar={<Button icon="minus" variant="minimal" />}
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
+}
+
+type ControlledTitle = 'First' | 'Second';
+
+export function ControlledState() {
+  const [openItems, setOpenItems] = useState<ControlledTitle[]>(['First']);
+
+  const addItem = useCallback(
+    (title: ControlledTitle) => {
+      setOpenItems((openItems) => Array.from(new Set([...openItems, title])));
+    },
+    [setOpenItems],
+  );
+
+  const removeItem = useCallback(
+    (title: ControlledTitle) => {
+      setOpenItems((openItems) => openItems.filter((item) => item !== title));
+    },
+    [setOpenItems],
+  );
+
+  return (
+    <Accordion>
+      <Accordion.Item<ControlledTitle>
+        title="First"
+        open={openItems.includes('First')}
+        onOpenChange={(isOpen) =>
+          isOpen ? addItem('First') : removeItem('First')
+        }
+      >
+        First content
+      </Accordion.Item>
+      <Accordion.Item<ControlledTitle>
+        title="Second"
+        open={openItems.includes('Second')}
+        onOpenChange={(isOpen) =>
+          isOpen ? addItem('Second') : removeItem('Second')
+        }
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
path: stories/components/accordion.stories.tsx
user: login: tpoisseau
body: I'm not convinced by the controlled state api. It is verbose and repetitive.

I think the state should be managed at `Accordion` level, with `opens` / `defaultOpens` / `onChange` props.
created_at: 2025-03-11T08:08:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -337,16 +340,91 @@ export const UnmountChildren = {
 export function UnmountSomeChildren() {
   return (
     <Accordion>
-      <Accordion.Item title="Always render children" defaultOpened>
+      <Accordion.Item title="Always render children" defaultOpen>
         <Count />
       </Accordion.Item>
-      <Accordion.Item title="Unmount children" defaultOpened unmountChildren>
+      <Accordion.Item title="Unmount children" defaultOpen unmountChildren>
         <Count />
       </Accordion.Item>
     </Accordion>
   );
 }
 
+export function WithToolbar() {
+  return (
+    <Accordion>
+      <Accordion.Item
+        title="First"
+        toolbar={<Button icon="minus" variant="minimal" />}
+      >
+        First content
+      </Accordion.Item>
+      <Accordion.Item
+        title="Second"
+        defaultOpen
+        toolbar={<Button icon="minus" variant="minimal" />}
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
+}
+
+type ControlledTitle = 'First' | 'Second';
+
+export function ControlledState() {
+  const [openItems, setOpenItems] = useState<ControlledTitle[]>(['First']);
+
+  const addItem = useCallback(
+    (title: ControlledTitle) => {
+      setOpenItems((openItems) => Array.from(new Set([...openItems, title])));
+    },
+    [setOpenItems],
+  );
+
+  const removeItem = useCallback(
+    (title: ControlledTitle) => {
+      setOpenItems((openItems) => openItems.filter((item) => item !== title));
+    },
+    [setOpenItems],
+  );
+
+  return (
+    <Accordion>
+      <Accordion.Item<ControlledTitle>
+        title="First"
+        open={openItems.includes('First')}
+        onOpenChange={(isOpen) =>
+          isOpen ? addItem('First') : removeItem('First')
+        }
+      >
+        First content
+      </Accordion.Item>
+      <Accordion.Item<ControlledTitle>
+        title="Second"
+        open={openItems.includes('Second')}
+        onOpenChange={(isOpen) =>
+          isOpen ? addItem('Second') : removeItem('Second')
+        }
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
path: stories/components/accordion.stories.tsx
user: login: stropitek
body: The main use case is to use the "uncontrolled" version and use the `useAccordionToggle` hook if we need to control the opened / closed items from outside.

The `defaultOpens` prop on `Accordion` would make it more difficult to use it for this use case (need to repeat the `id` in 2 different components).

I agree that the story is verbose, but in a real case scenario of controlled state we would have a simple `dispatch` on change and a wrapper component around `Accordion.Item` to make things much less verbose.

Do you want me to construct a story which shows how that could look like with a less naive example?
created_at: 2025-03-11T16:34:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -8,18 +9,37 @@ import { useCallback } from 'react';
 
 import { useAccordionContext } from './accordion_context.js';
 
-export interface AccordionProps {
+export interface AccordionProps<T extends string = string> {
   children?:
-    | Array<ReactElement<AccordionItemProps>>
-    | ReactElement<AccordionItemProps>
+    | Array<ReactElement<AccordionItemProps<T>>>
+    | ReactElement<AccordionItemProps<T>>
     | boolean
     | null;
 }
 
-export interface AccordionItemProps {
-  title: string;
+export interface AccordionItemProps<T extends string = string> {
+  /**
+   * The tile of the accordion item.
+   * The title also acts as an identifier and should be unique within an accordion provider.
+   */
+  title: T;
path: src/components/accordion/accordion.tsx
user: login: stropitek
body: ok I will do that (I though about it but in the end tried not to make too many breaking changes)
created_at: 2025-03-11T16:36:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,80 @@
+import type { ReactNode } from 'react';
+import { useMemo, useRef } from 'react';
+
+import { assert } from '../utils/index.js';
+
+import type { ContextType } from './accordion_context.js';
+import { accordionContext } from './accordion_context.js';
+import type { AccordionItemSetIsOpen } from './accordion_context_utils.js';
+import { getAccordionRegister } from './accordion_context_utils.js';
+
+export interface AccordionProviderProps {
+  children: ReactNode;
+  unmountChildren?: boolean;
+}
+
+export function AccordionProvider(props: AccordionProviderProps) {
+  const { unmountChildren = false, children } = props;
+  const registerRef = useRef(getAccordionRegister());
+
+  const utils = useMemo<ContextType[1]>(() => {
+    return {
+      change: (title: string, isOpen: boolean) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(title);
+        if (item) {
+          item.setIsOpen(isOpen);
+        }
+      },
+      closeAllExcept: (title: string) => {
+        assert(registerRef.current);
+        for (const [key, item] of registerRef.current.entries()) {
+          if (key === title) {
+            item.setIsOpen(true);
+          } else {
+            item.setIsOpen(false);
+          }
+        }
+      },
+      toggle: (title: string) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(title);
+        if (item) {
+          item.setIsOpen((prevState) => !prevState);
+        }
+      },
+      unregister: (title: string) => {
+        assert(registerRef.current);
+        registerRef.current.delete(title);
+      },
+      register: (title: string, setIsOpen: AccordionItemSetIsOpen) => {
+        assert(registerRef.current);
+        const item = registerRef.current.get(title);
+        if (item) {
+          // eslint-disable-next-line no-console
+          console.error(
path: src/components/accordion/accordion_context_provider.tsx
user: login: stropitek
body: Ok I will do an assertion then.

The error can only happen in a `useEffect`, so it will result in an uncaught exception and not catched by error boundaries.
created_at: 2025-03-11T16:37:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -337,16 +340,91 @@ export const UnmountChildren = {
 export function UnmountSomeChildren() {
   return (
     <Accordion>
-      <Accordion.Item title="Always render children" defaultOpened>
+      <Accordion.Item title="Always render children" defaultOpen>
         <Count />
       </Accordion.Item>
-      <Accordion.Item title="Unmount children" defaultOpened unmountChildren>
+      <Accordion.Item title="Unmount children" defaultOpen unmountChildren>
         <Count />
       </Accordion.Item>
     </Accordion>
   );
 }
 
+export function WithToolbar() {
+  return (
+    <Accordion>
+      <Accordion.Item
+        title="First"
+        toolbar={<Button icon="minus" variant="minimal" />}
+      >
+        First content
+      </Accordion.Item>
+      <Accordion.Item
+        title="Second"
+        defaultOpen
+        toolbar={<Button icon="minus" variant="minimal" />}
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
+}
+
+type ControlledTitle = 'First' | 'Second';
+
+export function ControlledState() {
+  const [openItems, setOpenItems] = useState<ControlledTitle[]>(['First']);
+
+  const addItem = useCallback(
+    (title: ControlledTitle) => {
+      setOpenItems((openItems) => Array.from(new Set([...openItems, title])));
+    },
+    [setOpenItems],
+  );
+
+  const removeItem = useCallback(
+    (title: ControlledTitle) => {
+      setOpenItems((openItems) => openItems.filter((item) => item !== title));
+    },
+    [setOpenItems],
+  );
+
+  return (
+    <Accordion>
+      <Accordion.Item<ControlledTitle>
+        title="First"
+        open={openItems.includes('First')}
+        onOpenChange={(isOpen) =>
+          isOpen ? addItem('First') : removeItem('First')
+        }
+      >
+        First content
+      </Accordion.Item>
+      <Accordion.Item<ControlledTitle>
+        title="Second"
+        open={openItems.includes('Second')}
+        onOpenChange={(isOpen) =>
+          isOpen ? addItem('Second') : removeItem('Second')
+        }
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
path: stories/components/accordion.stories.tsx
user: login: tpoisseau
body: Yes, please provide a story which control the state with a real case scenario
created_at: 2025-03-13T08:23:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -337,16 +340,91 @@ export const UnmountChildren = {
 export function UnmountSomeChildren() {
   return (
     <Accordion>
-      <Accordion.Item title="Always render children" defaultOpened>
+      <Accordion.Item title="Always render children" defaultOpen>
         <Count />
       </Accordion.Item>
-      <Accordion.Item title="Unmount children" defaultOpened unmountChildren>
+      <Accordion.Item title="Unmount children" defaultOpen unmountChildren>
         <Count />
       </Accordion.Item>
     </Accordion>
   );
 }
 
+export function WithToolbar() {
+  return (
+    <Accordion>
+      <Accordion.Item
+        title="First"
+        toolbar={<Button icon="minus" variant="minimal" />}
+      >
+        First content
+      </Accordion.Item>
+      <Accordion.Item
+        title="Second"
+        defaultOpen
+        toolbar={<Button icon="minus" variant="minimal" />}
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
+}
+
+type ControlledTitle = 'First' | 'Second';
+
+export function ControlledState() {
+  const [openItems, setOpenItems] = useState<ControlledTitle[]>(['First']);
+
+  const addItem = useCallback(
+    (title: ControlledTitle) => {
+      setOpenItems((openItems) => Array.from(new Set([...openItems, title])));
+    },
+    [setOpenItems],
+  );
+
+  const removeItem = useCallback(
+    (title: ControlledTitle) => {
+      setOpenItems((openItems) => openItems.filter((item) => item !== title));
+    },
+    [setOpenItems],
+  );
+
+  return (
+    <Accordion>
+      <Accordion.Item<ControlledTitle>
+        title="First"
+        open={openItems.includes('First')}
+        onOpenChange={(isOpen) =>
+          isOpen ? addItem('First') : removeItem('First')
+        }
+      >
+        First content
+      </Accordion.Item>
+      <Accordion.Item<ControlledTitle>
+        title="Second"
+        open={openItems.includes('Second')}
+        onOpenChange={(isOpen) =>
+          isOpen ? addItem('Second') : removeItem('Second')
+        }
+      >
+        Second content
+      </Accordion.Item>
+    </Accordion>
+  );
path: stories/components/accordion.stories.tsx
user: login: stropitek
body: New story using a reducer + context to manage the external state provided.
created_at: 2025-03-13T10:14:54Z
author_association: CONTRIBUTOR

PR comments:
user: login: cloudflare-workers-and-pages[bot]
created_at: 2025-03-10T16:18:36Z
body: ## Deploying react-science with &nbsp;<a href="https://pages.dev"><img alt="Cloudflare Pages" src="https://user-images.githubusercontent.com/23264/106598434-9e719e00-654f-11eb-9e59-6167043cfa01.png" width="16"></a> &nbsp;Cloudflare Pages

<table><tr><td><strong>Latest commit:</strong> </td><td>
<code>5cd7d67</code>
</td></tr>
<tr><td><strong>Status:</strong></td><td>&nbsp;✅&nbsp; Deploy successful!</td></tr>
<tr><td><strong>Preview URL:</strong></td><td>
<a href='https://38068db9.react-science.pages.dev'>https://38068db9.react-science.pages.dev</a>
</td></tr>
<tr><td><strong>Branch Preview URL:</strong></td><td>
<a href='https://876-allow-accordion-open-sta.react-science.pages.dev'>https://876-allow-accordion-open-sta.react-science.pages.dev</a>
</td></tr>
</table>

[View logs](https://dash.cloudflare.com/450ed5d75f0920351f3d411747f466a8/pages/view/react-science/38068db9-4e8a-440c-b5c1-b8b06a320f96)

author_association: NONE
user: login: stropitek
created_at: 2025-03-12T16:14:47Z
body: @tpoisseau I made some additional API changes unrelated to your comments compared to when you last reviewed.
author_association: CONTRIBUTOR
user: login: stropitek
created_at: 2025-03-12T16:16:29Z
body: See https://github.com/zakodium-oss/react-science/pull/887#issue-2907794350 for an overview of the changes.
author_association: CONTRIBUTOR