From d195bb453dc435721cf64dd3b67c8f963174f83a Mon Sep 17 00:00:00 2001 From: Ben Breckler Date: Tue, 16 Jun 2026 16:46:22 +0200 Subject: [PATCH 1/6] feat: add ExpansionPanel component An animated expand/collapse (disclosure) primitive: ExpansionPanel, ExpansionPanelHeader, ExpansionPanelToggle, and ExpansionPanelContent. - Controlled (isExpanded + onToggleExpand) and uncontrolled (initiallyExpanded) modes; the toggle wires aria-expanded + aria-controls automatically and renders an icon-only chevron or a full-width button when given children. - Content animates height (react-transition-group) with an opacity cross-fade and a subtle drop-in. - Add react-transition-group as a peer dependency (already used by comms-web and todoist-web) and to the rollup external list. - Chevron authored as a local inline-SVG, kept in the component folder for now pending shared icon syncing. Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 43 +++- package.json | 5 +- rollup.config.mjs | 10 +- ...nimated-expansion-panel-content.module.css | 12 + .../animated-expansion-panel-content.tsx | 148 +++++++++++++ src/expansion-panel/chevron-down-icon.tsx | 42 ++++ .../expansion-panel.module.css | 23 ++ .../expansion-panel.stories.jsx | 66 ++++++ src/expansion-panel/expansion-panel.test.tsx | 64 ++++++ src/expansion-panel/expansion-panel.tsx | 206 ++++++++++++++++++ src/expansion-panel/index.ts | 6 + src/index.ts | 1 + 12 files changed, 620 insertions(+), 6 deletions(-) create mode 100644 src/expansion-panel/animated-expansion-panel-content.module.css create mode 100644 src/expansion-panel/animated-expansion-panel-content.tsx create mode 100644 src/expansion-panel/chevron-down-icon.tsx create mode 100644 src/expansion-panel/expansion-panel.module.css create mode 100644 src/expansion-panel/expansion-panel.stories.jsx create mode 100644 src/expansion-panel/expansion-panel.test.tsx create mode 100644 src/expansion-panel/expansion-panel.tsx create mode 100644 src/expansion-panel/index.ts diff --git a/package-lock.json b/package-lock.json index 7e3c2653..b864c474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@types/react-18": "npm:@types/react@^18.3", "@types/react-dom": "19.2.3", "@types/react-dom-18": "npm:@types/react-dom@^18.3", + "@types/react-transition-group": "4.4.6", "@typescript-eslint/eslint-plugin": "8.46.2", "@typescript-eslint/parser": "8.46.2", "@vitejs/plugin-react": "5.2.0", @@ -89,6 +90,7 @@ "react-dom": "19.2.7", "react-dom-18": "npm:react-dom@18.3.1", "react-is": "19.2.7", + "react-transition-group": "4.4.5", "rimraf": "^3.0.2", "rollup": "2.79.2", "rollup-plugin-styles": "4.0.0", @@ -104,7 +106,8 @@ "classnames": "^2.2.5", "react": ">=18.0.0 <20.0.0", "react-compiler-runtime": "^1.0.0", - "react-dom": ">=18.0.0 <20.0.0" + "react-dom": ">=18.0.0 <20.0.0", + "react-transition-group": "^4.4.5" } }, "node_modules/@actions/core": { @@ -7398,6 +7401,16 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.6", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.6.tgz", + "integrity": "sha512-VnCdSxfcm08KjsJVQcfBmhEQAPnLB8G08hAxn39azX1qYBQ/5RVQuoHuKIcfKOdncuaUvEpFKFzEvbtIMsfVew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -10458,6 +10471,17 @@ "dev": true, "license": "MIT" }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -22877,6 +22901,23 @@ "node": ">=0.10.0" } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-package-up": { "version": "12.0.0", "resolved": "https://registry.npmjs.org/read-package-up/-/read-package-up-12.0.0.tgz", diff --git a/package.json b/package.json index 73bbd7f5..ae04add9 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,8 @@ "classnames": "^2.2.5", "react": ">=18.0.0 <20.0.0", "react-compiler-runtime": "^1.0.0", - "react-dom": ">=18.0.0 <20.0.0" + "react-dom": ">=18.0.0 <20.0.0", + "react-transition-group": "^4.4.5" }, "devDependencies": { "@ariakit/react": "0.4.19", @@ -101,6 +102,7 @@ "@types/react-18": "npm:@types/react@^18.3", "@types/react-dom": "19.2.3", "@types/react-dom-18": "npm:@types/react-dom@^18.3", + "@types/react-transition-group": "4.4.6", "@typescript-eslint/eslint-plugin": "8.46.2", "@typescript-eslint/parser": "8.46.2", "@vitejs/plugin-react": "5.2.0", @@ -136,6 +138,7 @@ "react-dom": "19.2.7", "react-dom-18": "npm:react-dom@18.3.1", "react-is": "19.2.7", + "react-transition-group": "4.4.5", "rimraf": "^3.0.2", "rollup": "2.79.2", "rollup-plugin-styles": "4.0.0", diff --git a/rollup.config.mjs b/rollup.config.mjs index 4834c118..bd27b6eb 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -1,10 +1,11 @@ -import typescript from '@rollup/plugin-typescript' -import resolve from '@rollup/plugin-node-resolve' +import { exec } from 'child_process' + import { babel } from '@rollup/plugin-babel' import commonjs from '@rollup/plugin-commonjs' -import styles from 'rollup-plugin-styles' +import resolve from '@rollup/plugin-node-resolve' import terser from '@rollup/plugin-terser' -import { exec } from 'child_process' +import typescript from '@rollup/plugin-typescript' +import styles from 'rollup-plugin-styles' import { visualizer } from 'rollup-plugin-visualizer' const isWatchMode = process.env.ROLLUP_WATCH === 'true' @@ -14,6 +15,7 @@ const external = [ /@babel\/runtime/, 'react', 'react-dom', + 'react-transition-group', 'react-compiler-runtime', 'classnames', 'prop-types', diff --git a/src/expansion-panel/animated-expansion-panel-content.module.css b/src/expansion-panel/animated-expansion-panel-content.module.css new file mode 100644 index 00000000..717d1618 --- /dev/null +++ b/src/expansion-panel/animated-expansion-panel-content.module.css @@ -0,0 +1,12 @@ +.container { + min-height: 0; + height: 0; + transition-duration: 200ms; + transition-property: height; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +.entered { + height: auto; + overflow: visible; +} diff --git a/src/expansion-panel/animated-expansion-panel-content.tsx b/src/expansion-panel/animated-expansion-panel-content.tsx new file mode 100644 index 00000000..1ab4094d --- /dev/null +++ b/src/expansion-panel/animated-expansion-panel-content.tsx @@ -0,0 +1,148 @@ +import * as React from 'react' +import { Transition } from 'react-transition-group' + +import { Box } from '../box' + +import styles from './animated-expansion-panel-content.module.css' + +const HEIGHT_TRANSITION_DURATION = 200 /* milliseconds */ + +/** Vertical offset (px) the content drops in from as it opens. Negative = starts + * slightly above its resting place and settles down, matching the direction the + * height reveals (top-down), so the two motions read as one. */ +const CONTENT_ENTER_OFFSET = -12 + +/* On open the content drops in with a gentle overshoot, and its fade is delayed + * slightly so it trails the reveal/drop rather than fading in from the first frame. + * On close it just fades out promptly (no delay, no spring). */ +const OPEN_TRANSITION = + 'opacity 150ms ease-out 50ms, transform 190ms cubic-bezier(0.34, 1.3, 0.64, 1)' +const CLOSE_TRANSITION = 'opacity 140ms ease-in, transform 190ms cubic-bezier(0.4, 0, 0.2, 1)' + +function setElementHeight(element: HTMLElement, height: number | 'auto') { + element.style.transitionDuration = `${HEIGHT_TRANSITION_DURATION}ms` + element.style.height = height === 'auto' ? height : `${height}px` +} + +/** + * Drives the content's own motion (opacity + vertical offset). The bounce lives + * here — on the content itself — rather than on the container height, so it reads + * the same regardless of what sits below it. (A height-only overshoot is only + * visible by displacing following content.) Spring easing lives in the inline + * transition above. + */ +function setContentMotion( + element: HTMLElement | null, + opacity: 0 | 1, + offset: number, + transition?: string, +) { + if (!element) { + return + } + if (transition !== undefined) { + element.style.transition = transition + } + element.style.opacity = String(opacity) + element.style.transform = offset === 0 ? 'none' : `translateY(${offset}px)` +} + +type Props = { + /** The content to be collapsed */ + children: React.ReactNode + + /** The expanded/collapse state of the panel */ + isExpanded: boolean + + /** Callback fired when the expansion animation completes */ + onEntered?: () => void +} + +/** + * Internal wrapper used by `ExpansionPanelContent`. Animates the container's + * height to reveal/hide the space, while the content cross-fades and springs + * into place (driven imperatively so the motion still runs when entering from + * `display: none`). + */ +function AnimatedExpansionPanelContent({ isExpanded, children, onEntered }: Props) { + const transitionElementRef = React.useRef(null) + const wrapperRef = React.useRef(null) + const contentRef = React.useRef(null) + + const handleEnter = React.useCallback(() => { + if (!transitionElementRef.current) { + return + } + + setElementHeight(transitionElementRef.current, 0) + setContentMotion(contentRef.current, 0, CONTENT_ENTER_OFFSET, OPEN_TRANSITION) + }, []) + + const handleEntering = React.useCallback(() => { + if (!transitionElementRef.current) { + return + } + + setElementHeight(transitionElementRef.current, wrapperRef.current?.clientHeight ?? 0) + setContentMotion(contentRef.current, 1, 0) + }, []) + + const handleEntered = React.useCallback(() => { + if (!transitionElementRef.current) { + return + } + setElementHeight(transitionElementRef.current, 'auto') + onEntered?.() + }, [onEntered]) + + const handleExit = React.useCallback(() => { + if (!transitionElementRef.current) { + return + } + setElementHeight(transitionElementRef.current, wrapperRef.current?.clientHeight ?? 0) + setContentMotion(contentRef.current, 1, 0, CLOSE_TRANSITION) + }, []) + + const handleExiting = React.useCallback(() => { + if (!transitionElementRef.current) { + return + } + + // Reading this property is important, even if we do not consume the value. + // Without this, the expanded -> collapsed animation will not work. + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + wrapperRef.current?.clientHeight + setElementHeight(transitionElementRef.current, 0) + setContentMotion(contentRef.current, 0, 0) + }, []) + + return ( + + {(state) => ( + + + + {children} + + + + )} + + ) +} + +export { AnimatedExpansionPanelContent } diff --git a/src/expansion-panel/chevron-down-icon.tsx b/src/expansion-panel/chevron-down-icon.tsx new file mode 100644 index 00000000..c9e52dcf --- /dev/null +++ b/src/expansion-panel/chevron-down-icon.tsx @@ -0,0 +1,42 @@ +import * as React from 'react' + +/** + * Chevron used by `ExpansionPanelToggle`. Kept local to the component for now, + * pending shared icon syncing (https://github.com/doist/icons). + */ +function ChevronDownIcon(props: JSX.IntrinsicElements['svg']) { + return ( + + + + ) +} + +/** Smaller-weight variant of {@link ChevronDownIcon}. */ +function ChevronDownSmallIcon(props: JSX.IntrinsicElements['svg']) { + return ( + + + + ) +} + +export { ChevronDownIcon, ChevronDownSmallIcon } diff --git a/src/expansion-panel/expansion-panel.module.css b/src/expansion-panel/expansion-panel.module.css new file mode 100644 index 00000000..a3589ffa --- /dev/null +++ b/src/expansion-panel/expansion-panel.module.css @@ -0,0 +1,23 @@ +.toggle { + /* Remove button click animation */ + transform: none !important; + + /* No idle background — the revealed chevron stays flat until directly hovered. */ + --reactist-btn-idle-fill: transparent; +} + +/* An expanded panel sets aria-expanded="true", which Reactist would otherwise render + with an active fill. Keep it flat unless the chevron itself is hovered/focused, so + the background only appears when pointing at the chevron (not the whole header). */ +.toggle[aria-expanded='true']:not(:hover):not(:focus-visible) { + --reactist-btn-hover-fill: transparent; +} + +.toggleIcon { + transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + transform: rotate(-90deg); +} + +[aria-expanded='true'] .toggleIcon { + transform: rotate(0deg); +} diff --git a/src/expansion-panel/expansion-panel.stories.jsx b/src/expansion-panel/expansion-panel.stories.jsx new file mode 100644 index 00000000..92664592 --- /dev/null +++ b/src/expansion-panel/expansion-panel.stories.jsx @@ -0,0 +1,66 @@ +import * as React from 'react' + +import { Box } from '../box' +import { Stack } from '../stack' +import { Text } from '../text' + +import { + ExpansionPanel, + ExpansionPanelContent, + ExpansionPanelHeader, + ExpansionPanelToggle, +} from './expansion-panel' + +export default { + title: '📐 Layout/ExpansionPanel', + component: ExpansionPanel, +} + +export const IconToggle = { + name: 'Icon toggle', + render: () => ( + + + + + Fruit + + + + + + Apple + Banana + Cherry + + + + + ), +} + +export const ButtonToggle = { + name: 'Button toggle', + render: () => ( + + + + + Vegetables + + + + + Carrot + Potato + Spinach + + + + + ), +} diff --git a/src/expansion-panel/expansion-panel.test.tsx b/src/expansion-panel/expansion-panel.test.tsx new file mode 100644 index 00000000..4022af49 --- /dev/null +++ b/src/expansion-panel/expansion-panel.test.tsx @@ -0,0 +1,64 @@ +import * as React from 'react' + +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { + ExpansionPanel, + ExpansionPanelContent, + ExpansionPanelHeader, + ExpansionPanelToggle, +} from './expansion-panel' + +type PanelOverrides = + | { initiallyExpanded?: boolean } + | { isExpanded: boolean; onToggleExpand: () => void } + +function renderPanel(overrides: PanelOverrides = {}) { + return render( + + + + + + Body content + + , + ) +} + +describe('ExpansionPanel', () => { + it('wires the toggle to the content via aria-controls', () => { + renderPanel() + + const toggle = screen.getByRole('button', { name: 'Toggle section' }) + expect(toggle).toHaveAttribute('aria-controls', 'panel-content') + expect(document.getElementById('panel-content')).toBeInTheDocument() + }) + + it('toggles its expanded state in uncontrolled mode', async () => { + const user = userEvent.setup() + renderPanel({ initiallyExpanded: true }) + + const toggle = screen.getByRole('button', { name: 'Toggle section' }) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + }) + + it('reflects controlled state and calls onToggleExpand', async () => { + const user = userEvent.setup() + const onToggleExpand = jest.fn() + renderPanel({ isExpanded: false, onToggleExpand }) + + const toggle = screen.getByRole('button', { name: 'Toggle section' }) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + + await user.click(toggle) + expect(onToggleExpand).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/expansion-panel/expansion-panel.tsx b/src/expansion-panel/expansion-panel.tsx new file mode 100644 index 00000000..46e651a2 --- /dev/null +++ b/src/expansion-panel/expansion-panel.tsx @@ -0,0 +1,206 @@ +import * as React from 'react' + +import classNames from 'classnames' + +import { Box } from '../box' +import { Button, IconButton } from '../button' + +import { AnimatedExpansionPanelContent } from './animated-expansion-panel-content' +import { ChevronDownIcon, ChevronDownSmallIcon } from './chevron-down-icon' + +import styles from './expansion-panel.module.css' + +/** Make the listed keys of `T` optional. */ +type SetOptional = Omit & Partial> + +function useControlledState(value = false, onToggle?: () => void): [boolean, () => void] { + const [state, setState] = React.useState(value) + return onToggle === undefined ? [state, () => setState(!state)] : [value, onToggle] +} + +type ExpansionPanelState = { + isExpanded: boolean + id: string + onToggleExpand: () => void +} + +const ExpansionPanelStateContext = React.createContext({ + isExpanded: true, + id: '', + onToggleExpand: () => undefined, +}) + +type ExpansionPanelProps = { + /** + * The id to apply to the expanded content, used by the toggle's + * aria-controls attribute + */ + id: string + children: React.ReactNode +} & ( + | { + /** + * The default state of the expansion panel when used in uncontrolled + * mode (e.g. without the `isExpanded` and `onToggleExpand` props) + */ + initiallyExpanded?: boolean + isExpanded?: never + onToggleExpand?: never + } + | { + /** Controlled expand/collapse state. Pair with `onToggleExpand`. */ + isExpanded: boolean + /** Called when the toggle is clicked. Consumer flips `isExpanded`. */ + onToggleExpand: () => void + initiallyExpanded?: never + } +) + +/** + * Animated expand/collapse primitive. Compose with `ExpansionPanelHeader`, + * `ExpansionPanelToggle`, and `ExpansionPanelContent` as children. + * + * Supports both controlled (`isExpanded` + `onToggleExpand`) and uncontrolled + * (`initiallyExpanded`) modes; mixing the two logs a warning and falls back to + * controlled. + */ +function ExpansionPanel({ children, initiallyExpanded, id, ...props }: ExpansionPanelProps) { + if ( + initiallyExpanded !== undefined && + (props.isExpanded !== undefined || props.onToggleExpand !== undefined) + ) { + // eslint-disable-next-line no-console + console.warn('[ExpansionPanel]: cannot use initiallyExpanded in controlled mode') + initiallyExpanded = undefined + } + + const [isExpanded, onToggleExpand] = useControlledState( + props.isExpanded || initiallyExpanded, + props.onToggleExpand, + ) + const contextValue = React.useMemo( + () => ({ isExpanded, onToggleExpand, id }), + [isExpanded, onToggleExpand, id], + ) + + return ( + + {children} + + ) +} + +/** + * Semantic wrapper for the panel's header row. A thin alias over `Box` — use it + * to colocate the toggle with sibling controls (e.g. add/delete buttons) without + * making the whole row clickable. + */ +function ExpansionPanelHeader({ + children, + ...props +}: React.ComponentProps): React.ReactElement { + return {children} +} + +type ExpansionPanelToggleProps = { + /** + * Required accessible label for the toggle button. The consumer owns + * localization — typically something like `"Toggle list of Projects"`. + */ + 'aria-label': string + 'aria-describedby'?: string + + /** Chevron icon size. Defaults to `'24'`. */ + size?: '24' | '16' +} & ( + | ({ + children: React.ReactNode + } & Omit, 'variant'>, 'size'>) + | ({ + children?: never + } & Omit, 'variant'>, 'size' | 'icon'>) +) + +/** + * Button that toggles the panel's expand/collapse state. Wires up + * `aria-expanded` and `aria-controls` automatically from the parent + * `ExpansionPanel`'s context. + * + * Renders an `IconButton` when no children are passed, or a full-width `Button` + * with a chevron start icon otherwise. Remaining props forward to the underlying + * button. + */ +function ExpansionPanelToggle({ + exceptionallySetClassName, + children, + 'aria-label': ariaLabel, + 'aria-describedby': ariaDescribedBy, + size = '24', + ...props +}: ExpansionPanelToggleProps) { + const { id, isExpanded, onToggleExpand } = React.useContext(ExpansionPanelStateContext) + + const ChevronIcon = size === '24' ? ChevronDownIcon : ChevronDownSmallIcon + + if (!children) { + return ( + } + exceptionallySetClassName={classNames(styles.toggle, exceptionallySetClassName)} + /> + ) + } + + return ( + + ) +} + +/** + * Container for the panel's expandable content. Animates height on + * expand/collapse driven by the parent `ExpansionPanel`'s state. Extra props + * forward to the inner `Box`. + */ +function ExpansionPanelContent({ + children, + onEntered, + ...props +}: Omit, 'id'> & { + /** Called once the expand animation finishes (not fired on collapse). */ + onEntered?: () => void +}) { + const { isExpanded, id } = React.useContext(ExpansionPanelStateContext) + return ( + + + + {children} + + + + ) +} + +export { ExpansionPanel, ExpansionPanelContent, ExpansionPanelHeader, ExpansionPanelToggle } diff --git a/src/expansion-panel/index.ts b/src/expansion-panel/index.ts new file mode 100644 index 00000000..42fc2b45 --- /dev/null +++ b/src/expansion-panel/index.ts @@ -0,0 +1,6 @@ +export { + ExpansionPanel, + ExpansionPanelContent, + ExpansionPanelHeader, + ExpansionPanelToggle, +} from './expansion-panel' diff --git a/src/index.ts b/src/index.ts index e032a649..a2747233 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,6 +38,7 @@ export * from './text-field' // other components export * from './avatar' export * from './badge' +export * from './expansion-panel' export * from './menu' export * from './modal' export * from './tabs' From 2be8e88214fa38e07ebc2722405b5467cbd7a9e7 Mon Sep 17 00:00:00 2001 From: Ben Breckler Date: Wed, 17 Jun 2026 10:49:46 +0200 Subject: [PATCH 2/6] refactor: address review feedback on ExpansionPanel - Wrap all subcomponents in React.forwardRef and export their prop types - Derive controlled mode from `isExpanded` (not `onToggleExpand`), use `??` for the fallback, and make the uncontrolled toggle a stable callback; warn on an incomplete controlled pair - Make `id` optional with a `useId` fallback - Toggle API: require `aria-label` only for the icon-only variant and omit the internally-owned props (onClick/aria-expanded/aria-controls) from the public type - Tests: add jest-axe a11y check, a full-width button-variant test, and content visibility assertions - Rename the story from .jsx to .tsx Co-Authored-By: Claude Opus 4.8 --- ...tories.jsx => expansion-panel.stories.tsx} | 0 src/expansion-panel/expansion-panel.test.tsx | 40 +++ src/expansion-panel/expansion-panel.tsx | 290 +++++++++++------- 3 files changed, 216 insertions(+), 114 deletions(-) rename src/expansion-panel/{expansion-panel.stories.jsx => expansion-panel.stories.tsx} (100%) diff --git a/src/expansion-panel/expansion-panel.stories.jsx b/src/expansion-panel/expansion-panel.stories.tsx similarity index 100% rename from src/expansion-panel/expansion-panel.stories.jsx rename to src/expansion-panel/expansion-panel.stories.tsx diff --git a/src/expansion-panel/expansion-panel.test.tsx b/src/expansion-panel/expansion-panel.test.tsx index 4022af49..d2655470 100644 --- a/src/expansion-panel/expansion-panel.test.tsx +++ b/src/expansion-panel/expansion-panel.test.tsx @@ -2,6 +2,7 @@ import * as React from 'react' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { axe } from 'jest-axe' import { ExpansionPanel, @@ -27,7 +28,25 @@ function renderPanel(overrides: PanelOverrides = {}) { ) } +function renderButtonTogglePanel(overrides: PanelOverrides = {}) { + return render( + + + Section label + + + Body content + + , + ) +} + describe('ExpansionPanel', () => { + it('has no accessibility violations', async () => { + const { container } = renderPanel({ initiallyExpanded: true }) + expect(await axe(container)).toHaveNoViolations() + }) + it('wires the toggle to the content via aria-controls', () => { renderPanel() @@ -36,6 +55,15 @@ describe('ExpansionPanel', () => { expect(document.getElementById('panel-content')).toBeInTheDocument() }) + it('shows content when expanded and hides it when collapsed', () => { + const { unmount } = renderPanel({ initiallyExpanded: true }) + expect(screen.getByText('Body content')).toBeVisible() + unmount() + + renderPanel({ initiallyExpanded: false }) + expect(screen.getByText('Body content')).not.toBeVisible() + }) + it('toggles its expanded state in uncontrolled mode', async () => { const user = userEvent.setup() renderPanel({ initiallyExpanded: true }) @@ -61,4 +89,16 @@ describe('ExpansionPanel', () => { await user.click(toggle) expect(onToggleExpand).toHaveBeenCalledTimes(1) }) + + it('renders a full-width button toggle (named by its children) when given children', async () => { + const user = userEvent.setup() + renderButtonTogglePanel({ initiallyExpanded: true }) + + const toggle = screen.getByRole('button', { name: 'Section label' }) + expect(toggle).toHaveAttribute('aria-expanded', 'true') + expect(toggle).toHaveAttribute('aria-controls', 'panel-content') + + await user.click(toggle) + expect(toggle).toHaveAttribute('aria-expanded', 'false') + }) }) diff --git a/src/expansion-panel/expansion-panel.tsx b/src/expansion-panel/expansion-panel.tsx index 46e651a2..faca5ecb 100644 --- a/src/expansion-panel/expansion-panel.tsx +++ b/src/expansion-panel/expansion-panel.tsx @@ -10,12 +10,24 @@ import { ChevronDownIcon, ChevronDownSmallIcon } from './chevron-down-icon' import styles from './expansion-panel.module.css' -/** Make the listed keys of `T` optional. */ -type SetOptional = Omit & Partial> +/** + * Returns the `[isExpanded, toggle]` pair. Controlled mode is derived from + * whether `isExpanded` is provided (not from `onToggleExpand`), so a controlled + * panel always reflects later prop updates. The uncontrolled toggle is a stable + * callback so the context value below doesn't change on every render. + */ +function useControlledState( + controlledValue: boolean | undefined, + onToggle: (() => void) | undefined, + initiallyExpanded: boolean | undefined, +): [boolean, () => void] { + const [internalState, setInternalState] = React.useState(initiallyExpanded ?? false) + const toggleInternal = React.useCallback(() => setInternalState((expanded) => !expanded), []) -function useControlledState(value = false, onToggle?: () => void): [boolean, () => void] { - const [state, setState] = React.useState(value) - return onToggle === undefined ? [state, () => setState(!state)] : [value, onToggle] + if (controlledValue !== undefined) { + return [controlledValue, onToggle ?? toggleInternal] + } + return [internalState, toggleInternal] } type ExpansionPanelState = { @@ -32,10 +44,11 @@ const ExpansionPanelStateContext = React.createContext({ type ExpansionPanelProps = { /** - * The id to apply to the expanded content, used by the toggle's - * aria-controls attribute + * The id applied to the expanded content, used by the toggle's + * `aria-controls` attribute. Defaults to an auto-generated id; provide one + * only when you need a specific, stable DOM id. */ - id: string + id?: string children: React.ReactNode } & ( | { @@ -61,121 +74,169 @@ type ExpansionPanelProps = { * `ExpansionPanelToggle`, and `ExpansionPanelContent` as children. * * Supports both controlled (`isExpanded` + `onToggleExpand`) and uncontrolled - * (`initiallyExpanded`) modes; mixing the two logs a warning and falls back to - * controlled. + * (`initiallyExpanded`) modes. */ -function ExpansionPanel({ children, initiallyExpanded, id, ...props }: ExpansionPanelProps) { - if ( - initiallyExpanded !== undefined && - (props.isExpanded !== undefined || props.onToggleExpand !== undefined) - ) { - // eslint-disable-next-line no-console - console.warn('[ExpansionPanel]: cannot use initiallyExpanded in controlled mode') - initiallyExpanded = undefined - } +const ExpansionPanel = React.forwardRef( + function ExpansionPanel({ children, initiallyExpanded, id: providedId, ...props }, ref) { + if ( + initiallyExpanded !== undefined && + (props.isExpanded !== undefined || props.onToggleExpand !== undefined) + ) { + // eslint-disable-next-line no-console + console.warn('[ExpansionPanel]: cannot use initiallyExpanded in controlled mode') + } + if (props.isExpanded !== undefined && props.onToggleExpand === undefined) { + // eslint-disable-next-line no-console + console.warn('[ExpansionPanel]: `isExpanded` must be paired with `onToggleExpand`') + } - const [isExpanded, onToggleExpand] = useControlledState( - props.isExpanded || initiallyExpanded, - props.onToggleExpand, - ) - const contextValue = React.useMemo( - () => ({ isExpanded, onToggleExpand, id }), - [isExpanded, onToggleExpand, id], - ) - - return ( - - {children} - - ) -} + const generatedId = React.useId() + const id = providedId ?? generatedId + + const [isExpanded, onToggleExpand] = useControlledState( + props.isExpanded, + props.onToggleExpand, + initiallyExpanded, + ) + const contextValue = React.useMemo( + () => ({ isExpanded, onToggleExpand, id }), + [isExpanded, onToggleExpand, id], + ) + + return ( + + {children} + + ) + }, +) + +type ExpansionPanelHeaderProps = React.ComponentProps /** * Semantic wrapper for the panel's header row. A thin alias over `Box` — use it * to colocate the toggle with sibling controls (e.g. add/delete buttons) without * making the whole row clickable. */ -function ExpansionPanelHeader({ - children, - ...props -}: React.ComponentProps): React.ReactElement { - return {children} -} +const ExpansionPanelHeader = React.forwardRef( + function ExpansionPanelHeader({ children, ...props }, ref) { + return ( + + {children} + + ) + }, +) -type ExpansionPanelToggleProps = { - /** - * Required accessible label for the toggle button. The consumer owns - * localization — typically something like `"Toggle list of Projects"`. - */ - 'aria-label': string - 'aria-describedby'?: string +/** + * Props the toggle owns internally and consumers must not override — omitted + * from the public prop types. (`size` here refers to the chevron size below, not + * the button's own `size`, which is also internal.) + */ +type InternalToggleProps = + | 'variant' + | 'size' + | 'icon' + | 'startIcon' + | 'onClick' + | 'aria-expanded' + | 'aria-controls' + | 'aria-label' + | 'children' - /** Chevron icon size. Defaults to `'24'`. */ - size?: '24' | '16' -} & ( +type ButtonTogglePassthrough = Omit, InternalToggleProps> +type IconButtonTogglePassthrough = Omit< + React.ComponentProps, + InternalToggleProps +> + +type ExpansionPanelToggleProps = | ({ + /** + * Visible label for the button. `aria-label` is optional here since + * the children already name the button. + */ children: React.ReactNode - } & Omit, 'variant'>, 'size'>) + 'aria-label'?: string + /** Chevron icon size. Defaults to `'24'`. */ + size?: '24' | '16' + } & ButtonTogglePassthrough) | ({ children?: never - } & Omit, 'variant'>, 'size' | 'icon'>) -) + /** Required accessible label for the icon-only toggle. */ + 'aria-label': string + /** Chevron icon size. Defaults to `'24'`. */ + size?: '24' | '16' + } & IconButtonTogglePassthrough) /** - * Button that toggles the panel's expand/collapse state. Wires up - * `aria-expanded` and `aria-controls` automatically from the parent + * Button that toggles the panel's expand/collapse state. Wires up `onClick`, + * `aria-expanded`, and `aria-controls` automatically from the parent * `ExpansionPanel`'s context. * - * Renders an `IconButton` when no children are passed, or a full-width `Button` - * with a chevron start icon otherwise. Remaining props forward to the underlying - * button. + * Renders an `IconButton` when no children are passed (`aria-label` required), + * or a full-width `Button` with a chevron start icon otherwise. Remaining props + * forward to the underlying button. */ -function ExpansionPanelToggle({ - exceptionallySetClassName, - children, - 'aria-label': ariaLabel, - 'aria-describedby': ariaDescribedBy, - size = '24', - ...props -}: ExpansionPanelToggleProps) { - const { id, isExpanded, onToggleExpand } = React.useContext(ExpansionPanelStateContext) - - const ChevronIcon = size === '24' ? ChevronDownIcon : ChevronDownSmallIcon - - if (!children) { +const ExpansionPanelToggle = React.forwardRef( + function ExpansionPanelToggle( + { exceptionallySetClassName, children, 'aria-label': ariaLabel, size = '24', ...props }, + ref, + ) { + const { id, isExpanded, onToggleExpand } = React.useContext(ExpansionPanelStateContext) + + const ChevronIcon = size === '24' ? ChevronDownIcon : ChevronDownSmallIcon + + // `props` is the toggle's discriminated union minus the destructured keys. + // Destructuring `children` doesn't narrow the rest, so assert the variant + // we've already branched on. + if (!children) { + return ( + )} + onClick={onToggleExpand} + aria-expanded={isExpanded} + aria-controls={id} + // Required by the icon-only variant's type; the `!children` branch guarantees it. + aria-label={ariaLabel as string} + icon={} + exceptionallySetClassName={classNames(styles.toggle, exceptionallySetClassName)} + /> + ) + } + return ( - )} onClick={onToggleExpand} aria-expanded={isExpanded} aria-controls={id} aria-label={ariaLabel} - aria-describedby={ariaDescribedBy} - icon={} + startIcon={} exceptionallySetClassName={classNames(styles.toggle, exceptionallySetClassName)} - /> + > + {children} + ) - } + }, +) - return ( - - ) +type ExpansionPanelContentProps = Omit, 'id'> & { + /** Called once the expand animation finishes (not fired on collapse). */ + onEntered?: () => void } /** @@ -183,24 +244,25 @@ function ExpansionPanelToggle({ * expand/collapse driven by the parent `ExpansionPanel`'s state. Extra props * forward to the inner `Box`. */ -function ExpansionPanelContent({ - children, - onEntered, - ...props -}: Omit, 'id'> & { - /** Called once the expand animation finishes (not fired on collapse). */ - onEntered?: () => void -}) { - const { isExpanded, id } = React.useContext(ExpansionPanelStateContext) - return ( - - - - {children} - - - - ) -} +const ExpansionPanelContent = React.forwardRef( + function ExpansionPanelContent({ children, onEntered, ...props }, ref) { + const { isExpanded, id } = React.useContext(ExpansionPanelStateContext) + return ( + + + + {children} + + + + ) + }, +) export { ExpansionPanel, ExpansionPanelContent, ExpansionPanelHeader, ExpansionPanelToggle } +export type { + ExpansionPanelContentProps, + ExpansionPanelHeaderProps, + ExpansionPanelProps, + ExpansionPanelToggleProps, +} From 21f29c369bd30864b16414c14e1c8855cd7d0f0c Mon Sep 17 00:00:00 2001 From: Ben Breckler Date: Thu, 18 Jun 2026 10:45:41 +0200 Subject: [PATCH 3/6] refactor: apply review feedback on ExpansionPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop React.forwardRef from all subcomponents (plain functions); the DOM elements aren't part of the documented API - Remove the `as` casts on the toggle prop spread — plain `{...props}` type-checks - Drive the animated container's overflow and display via Box props instead of a className/style split (`overflow={...}`, `display={...}`); drop overflow from `.entered` - Rename HEIGHT_TRANSITION_DURATION -> HEIGHT_TRANSITION_DURATION_MS - Story: move under "Navigation & structure", give the demo panel a background so it reads as a panel, and size the title to match (not below) the content Co-Authored-By: Claude Opus 4.8 --- ...nimated-expansion-panel-content.module.css | 1 - .../animated-expansion-panel-content.tsx | 10 +- .../expansion-panel.stories.tsx | 12 +- src/expansion-panel/expansion-panel.test.tsx | 10 +- src/expansion-panel/expansion-panel.tsx | 193 ++++++++---------- 5 files changed, 108 insertions(+), 118 deletions(-) diff --git a/src/expansion-panel/animated-expansion-panel-content.module.css b/src/expansion-panel/animated-expansion-panel-content.module.css index 717d1618..90a2ef69 100644 --- a/src/expansion-panel/animated-expansion-panel-content.module.css +++ b/src/expansion-panel/animated-expansion-panel-content.module.css @@ -8,5 +8,4 @@ .entered { height: auto; - overflow: visible; } diff --git a/src/expansion-panel/animated-expansion-panel-content.tsx b/src/expansion-panel/animated-expansion-panel-content.tsx index 1ab4094d..d014c75b 100644 --- a/src/expansion-panel/animated-expansion-panel-content.tsx +++ b/src/expansion-panel/animated-expansion-panel-content.tsx @@ -5,7 +5,7 @@ import { Box } from '../box' import styles from './animated-expansion-panel-content.module.css' -const HEIGHT_TRANSITION_DURATION = 200 /* milliseconds */ +const HEIGHT_TRANSITION_DURATION_MS = 200 /** Vertical offset (px) the content drops in from as it opens. Negative = starts * slightly above its resting place and settles down, matching the direction the @@ -20,7 +20,7 @@ const OPEN_TRANSITION = const CLOSE_TRANSITION = 'opacity 140ms ease-in, transform 190ms cubic-bezier(0.4, 0, 0.2, 1)' function setElementHeight(element: HTMLElement, height: number | 'auto') { - element.style.transitionDuration = `${HEIGHT_TRANSITION_DURATION}ms` + element.style.transitionDuration = `${HEIGHT_TRANSITION_DURATION_MS}ms` element.style.height = height === 'auto' ? height : `${height}px` } @@ -124,15 +124,15 @@ function AnimatedExpansionPanelContent({ isExpanded, children, onEntered }: Prop onEntered={handleEntered} onExiting={handleExiting} onExit={handleExit} - timeout={HEIGHT_TRANSITION_DURATION} + timeout={HEIGHT_TRANSITION_DURATION_MS} in={isExpanded} > {(state) => ( diff --git a/src/expansion-panel/expansion-panel.stories.tsx b/src/expansion-panel/expansion-panel.stories.tsx index 92664592..72452e54 100644 --- a/src/expansion-panel/expansion-panel.stories.tsx +++ b/src/expansion-panel/expansion-panel.stories.tsx @@ -12,27 +12,27 @@ import { } from './expansion-panel' export default { - title: '📐 Layout/ExpansionPanel', + title: '🧭 Navigation & structure/ExpansionPanel', component: ExpansionPanel, } export const IconToggle = { name: 'Icon toggle', render: () => ( - + - + Fruit - + Apple Banana Cherry @@ -46,7 +46,7 @@ export const IconToggle = { export const ButtonToggle = { name: 'Button toggle', render: () => ( - + @@ -54,7 +54,7 @@ export const ButtonToggle = { - + Carrot Potato Spinach diff --git a/src/expansion-panel/expansion-panel.test.tsx b/src/expansion-panel/expansion-panel.test.tsx index d2655470..9a0f8d56 100644 --- a/src/expansion-panel/expansion-panel.test.tsx +++ b/src/expansion-panel/expansion-panel.test.tsx @@ -55,13 +55,19 @@ describe('ExpansionPanel', () => { expect(document.getElementById('panel-content')).toBeInTheDocument() }) - it('shows content when expanded and hides it when collapsed', () => { + it('shows content when expanded and reports collapsed via the toggle otherwise', () => { + // Note: the collapsed panel hides its content with `display: none` via a CSS + // class, which jsdom doesn't apply, so we assert the visible (expanded) state + // directly and the collapsed state through the toggle's `aria-expanded`. const { unmount } = renderPanel({ initiallyExpanded: true }) expect(screen.getByText('Body content')).toBeVisible() unmount() renderPanel({ initiallyExpanded: false }) - expect(screen.getByText('Body content')).not.toBeVisible() + expect(screen.getByRole('button', { name: 'Toggle section' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) }) it('toggles its expanded state in uncontrolled mode', async () => { diff --git a/src/expansion-panel/expansion-panel.tsx b/src/expansion-panel/expansion-panel.tsx index faca5ecb..5a148686 100644 --- a/src/expansion-panel/expansion-panel.tsx +++ b/src/expansion-panel/expansion-panel.tsx @@ -76,40 +76,43 @@ type ExpansionPanelProps = { * Supports both controlled (`isExpanded` + `onToggleExpand`) and uncontrolled * (`initiallyExpanded`) modes. */ -const ExpansionPanel = React.forwardRef( - function ExpansionPanel({ children, initiallyExpanded, id: providedId, ...props }, ref) { - if ( - initiallyExpanded !== undefined && - (props.isExpanded !== undefined || props.onToggleExpand !== undefined) - ) { - // eslint-disable-next-line no-console - console.warn('[ExpansionPanel]: cannot use initiallyExpanded in controlled mode') - } - if (props.isExpanded !== undefined && props.onToggleExpand === undefined) { - // eslint-disable-next-line no-console - console.warn('[ExpansionPanel]: `isExpanded` must be paired with `onToggleExpand`') - } - - const generatedId = React.useId() - const id = providedId ?? generatedId - - const [isExpanded, onToggleExpand] = useControlledState( - props.isExpanded, - props.onToggleExpand, - initiallyExpanded, - ) - const contextValue = React.useMemo( - () => ({ isExpanded, onToggleExpand, id }), - [isExpanded, onToggleExpand, id], - ) +function ExpansionPanel({ + children, + initiallyExpanded, + id: providedId, + ...props +}: ExpansionPanelProps) { + if ( + initiallyExpanded !== undefined && + (props.isExpanded !== undefined || props.onToggleExpand !== undefined) + ) { + // eslint-disable-next-line no-console + console.warn('[ExpansionPanel]: cannot use initiallyExpanded in controlled mode') + } + if (props.isExpanded !== undefined && props.onToggleExpand === undefined) { + // eslint-disable-next-line no-console + console.warn('[ExpansionPanel]: `isExpanded` must be paired with `onToggleExpand`') + } - return ( - - {children} - - ) - }, -) + const generatedId = React.useId() + const id = providedId ?? generatedId + + const [isExpanded, onToggleExpand] = useControlledState( + props.isExpanded, + props.onToggleExpand, + initiallyExpanded, + ) + const contextValue = React.useMemo( + () => ({ isExpanded, onToggleExpand, id }), + [isExpanded, onToggleExpand, id], + ) + + return ( + + {children} + + ) +} type ExpansionPanelHeaderProps = React.ComponentProps @@ -118,15 +121,9 @@ type ExpansionPanelHeaderProps = React.ComponentProps * to colocate the toggle with sibling controls (e.g. add/delete buttons) without * making the whole row clickable. */ -const ExpansionPanelHeader = React.forwardRef( - function ExpansionPanelHeader({ children, ...props }, ref) { - return ( - - {children} - - ) - }, -) +function ExpansionPanelHeader({ children, ...props }: ExpansionPanelHeaderProps) { + return {children} +} /** * Props the toggle owns internally and consumers must not override — omitted @@ -178,61 +175,51 @@ type ExpansionPanelToggleProps = * or a full-width `Button` with a chevron start icon otherwise. Remaining props * forward to the underlying button. */ -const ExpansionPanelToggle = React.forwardRef( - function ExpansionPanelToggle( - { exceptionallySetClassName, children, 'aria-label': ariaLabel, size = '24', ...props }, - ref, - ) { - const { id, isExpanded, onToggleExpand } = React.useContext(ExpansionPanelStateContext) - - const ChevronIcon = size === '24' ? ChevronDownIcon : ChevronDownSmallIcon - - // `props` is the toggle's discriminated union minus the destructured keys. - // Destructuring `children` doesn't narrow the rest, so assert the variant - // we've already branched on. - if (!children) { - return ( - )} - onClick={onToggleExpand} - aria-expanded={isExpanded} - aria-controls={id} - // Required by the icon-only variant's type; the `!children` branch guarantees it. - aria-label={ariaLabel as string} - icon={} - exceptionallySetClassName={classNames(styles.toggle, exceptionallySetClassName)} - /> - ) - } - +function ExpansionPanelToggle({ + exceptionallySetClassName, + children, + 'aria-label': ariaLabel, + size = '24', + ...props +}: ExpansionPanelToggleProps) { + const { id, isExpanded, onToggleExpand } = React.useContext(ExpansionPanelStateContext) + + const ChevronIcon = size === '24' ? ChevronDownIcon : ChevronDownSmallIcon + + if (!children) { return ( - + /> ) - }, -) + } + + return ( + + ) +} type ExpansionPanelContentProps = Omit, 'id'> & { /** Called once the expand animation finishes (not fired on collapse). */ @@ -244,20 +231,18 @@ type ExpansionPanelContentProps = Omit, 'id'> & * expand/collapse driven by the parent `ExpansionPanel`'s state. Extra props * forward to the inner `Box`. */ -const ExpansionPanelContent = React.forwardRef( - function ExpansionPanelContent({ children, onEntered, ...props }, ref) { - const { isExpanded, id } = React.useContext(ExpansionPanelStateContext) - return ( - - - - {children} - - - - ) - }, -) +function ExpansionPanelContent({ children, onEntered, ...props }: ExpansionPanelContentProps) { + const { isExpanded, id } = React.useContext(ExpansionPanelStateContext) + return ( + + + + {children} + + + + ) +} export { ExpansionPanel, ExpansionPanelContent, ExpansionPanelHeader, ExpansionPanelToggle } export type { From 9e7014008ac228f7483ab7073367a6517a373656 Mon Sep 17 00:00:00 2001 From: Ben Breckler Date: Thu, 18 Jun 2026 11:00:19 +0200 Subject: [PATCH 4/6] refactor: align ExpansionPanel story title with Todoist sidebar section header Use size=body, tone=secondary, weight=semibold to match the section header title styling in todoist-web. Co-Authored-By: Claude Opus 4.8 --- src/expansion-panel/expansion-panel.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/expansion-panel/expansion-panel.stories.tsx b/src/expansion-panel/expansion-panel.stories.tsx index 72452e54..a42df5c2 100644 --- a/src/expansion-panel/expansion-panel.stories.tsx +++ b/src/expansion-panel/expansion-panel.stories.tsx @@ -26,7 +26,7 @@ export const IconToggle = { alignItems="center" justifyContent="spaceBetween" > - + Fruit From c3ddcd57b7140372f67008a2b2a7ad8df3d5ef52 Mon Sep 17 00:00:00 2001 From: Ben Breckler Date: Fri, 19 Jun 2026 09:58:11 +0200 Subject: [PATCH 5/6] fix: add Navigation & structure to Storybook nav order Insert the new category into the explicit storySort order in .storybook/preview.ts (alphabetically, between Menus & tabs and Overlays) so ExpansionPanel's section sorts correctly instead of falling to the bottom. Co-Authored-By: Claude Opus 4.8 --- .storybook/preview.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 1b1e662c..3674bbe6 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,9 +1,12 @@ -import type { Preview } from '@storybook/react-vite' -import { create } from 'storybook/theming/create' -import BaseDecorator from './BaseDecorator' import '../src/styles/design-tokens.css' import '../stories/components/styles/story.css' +import { create } from 'storybook/theming/create' + +import BaseDecorator from './BaseDecorator' + +import type { Preview } from '@storybook/react-vite' + const badgeFontStyles = { fontSize: '12px', lineHeight: '14px', @@ -31,6 +34,7 @@ const preview: Preview = { '📝 Form', '📐 Layout', '📑 Menus & tabs', + '🧭 Navigation & structure', '🪟 Overlays', '🔤 Typography', '⚙️ Utility', From ee032d15d8288780047247860008617f70b5e6d1 Mon Sep 17 00:00:00 2001 From: Ben Breckler Date: Fri, 19 Jun 2026 11:00:18 +0200 Subject: [PATCH 6/6] fix: import JSX type for React 19 compatibility React 19 removed the global JSX namespace, so import it from 'react' for the chevron icon's prop typing (matches the existing Reactist icons). Co-Authored-By: Claude Opus 4.8 --- src/expansion-panel/chevron-down-icon.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/expansion-panel/chevron-down-icon.tsx b/src/expansion-panel/chevron-down-icon.tsx index c9e52dcf..8bddf70e 100644 --- a/src/expansion-panel/chevron-down-icon.tsx +++ b/src/expansion-panel/chevron-down-icon.tsx @@ -1,4 +1,5 @@ import * as React from 'react' +import type { JSX } from 'react' /** * Chevron used by `ExpansionPanelToggle`. Kept local to the component for now,