Skip to content

Commit e70e130

Browse files
benbrecklerclaude
andauthored
feat: add ExpansionPanel component (#1075)
* 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * refactor: apply review feedback on ExpansionPanel - 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e29e99b commit e70e130

13 files changed

Lines changed: 720 additions & 9 deletions

.storybook/preview.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import type { Preview } from '@storybook/react-vite'
1+
import '../src/styles/design-tokens.css'
2+
import '../stories/components/styles/story.css'
3+
24
import { create } from 'storybook/theming/create'
5+
36
import BaseDecorator from './BaseDecorator'
47
import { reactistBadgeTones } from './components/badge-tones'
5-
import '../src/styles/design-tokens.css'
6-
import '../stories/components/styles/story.css'
8+
9+
import type { Preview } from '@storybook/react-vite'
710

811
const preview: Preview = {
912
decorators: [BaseDecorator],
@@ -27,6 +30,7 @@ const preview: Preview = {
2730
'📝 Form',
2831
'📐 Layout',
2932
'📑 Menus & tabs',
33+
'🧭 Navigation & structure',
3034
'🪟 Overlays',
3135
'🔤 Typography',
3236
'⚙️ Utility',

package-lock.json

Lines changed: 42 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@
6464
"classnames": "^2.2.5",
6565
"react": ">=18.0.0 <20.0.0",
6666
"react-compiler-runtime": "^1.0.0",
67-
"react-dom": ">=18.0.0 <20.0.0"
67+
"react-dom": ">=18.0.0 <20.0.0",
68+
"react-transition-group": "^4.4.5"
6869
},
6970
"devDependencies": {
7071
"@ariakit/react": "0.4.19",
@@ -101,6 +102,7 @@
101102
"@types/react-18": "npm:@types/react@^18.3",
102103
"@types/react-dom": "19.2.3",
103104
"@types/react-dom-18": "npm:@types/react-dom@^18.3",
105+
"@types/react-transition-group": "4.4.6",
104106
"@typescript-eslint/eslint-plugin": "8.46.2",
105107
"@typescript-eslint/parser": "8.46.2",
106108
"@vitejs/plugin-react": "5.2.0",
@@ -136,6 +138,7 @@
136138
"react-dom": "19.2.7",
137139
"react-dom-18": "npm:react-dom@18.3.1",
138140
"react-is": "19.2.7",
141+
"react-transition-group": "4.4.5",
139142
"rimraf": "^3.0.2",
140143
"rollup": "2.79.2",
141144
"rollup-plugin-styles": "4.0.0",

rollup.config.mjs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import typescript from '@rollup/plugin-typescript'
2-
import resolve from '@rollup/plugin-node-resolve'
1+
import { exec } from 'child_process'
2+
33
import { babel } from '@rollup/plugin-babel'
44
import commonjs from '@rollup/plugin-commonjs'
5-
import styles from 'rollup-plugin-styles'
5+
import resolve from '@rollup/plugin-node-resolve'
66
import terser from '@rollup/plugin-terser'
7-
import { exec } from 'child_process'
7+
import typescript from '@rollup/plugin-typescript'
8+
import styles from 'rollup-plugin-styles'
89
import { visualizer } from 'rollup-plugin-visualizer'
910

1011
const isWatchMode = process.env.ROLLUP_WATCH === 'true'
@@ -14,6 +15,7 @@ const external = [
1415
/@babel\/runtime/,
1516
'react',
1617
'react-dom',
18+
'react-transition-group',
1719
'react-compiler-runtime',
1820
'classnames',
1921
'prop-types',
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
.container {
2+
min-height: 0;
3+
height: 0;
4+
transition-duration: 200ms;
5+
transition-property: height;
6+
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
7+
}
8+
9+
.entered {
10+
height: auto;
11+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import * as React from 'react'
2+
import { Transition } from 'react-transition-group'
3+
4+
import { Box } from '../box'
5+
6+
import styles from './animated-expansion-panel-content.module.css'
7+
8+
const HEIGHT_TRANSITION_DURATION_MS = 200
9+
10+
/** Vertical offset (px) the content drops in from as it opens. Negative = starts
11+
* slightly above its resting place and settles down, matching the direction the
12+
* height reveals (top-down), so the two motions read as one. */
13+
const CONTENT_ENTER_OFFSET = -12
14+
15+
/* On open the content drops in with a gentle overshoot, and its fade is delayed
16+
* slightly so it trails the reveal/drop rather than fading in from the first frame.
17+
* On close it just fades out promptly (no delay, no spring). */
18+
const OPEN_TRANSITION =
19+
'opacity 150ms ease-out 50ms, transform 190ms cubic-bezier(0.34, 1.3, 0.64, 1)'
20+
const CLOSE_TRANSITION = 'opacity 140ms ease-in, transform 190ms cubic-bezier(0.4, 0, 0.2, 1)'
21+
22+
function setElementHeight(element: HTMLElement, height: number | 'auto') {
23+
element.style.transitionDuration = `${HEIGHT_TRANSITION_DURATION_MS}ms`
24+
element.style.height = height === 'auto' ? height : `${height}px`
25+
}
26+
27+
/**
28+
* Drives the content's own motion (opacity + vertical offset). The bounce lives
29+
* here — on the content itself — rather than on the container height, so it reads
30+
* the same regardless of what sits below it. (A height-only overshoot is only
31+
* visible by displacing following content.) Spring easing lives in the inline
32+
* transition above.
33+
*/
34+
function setContentMotion(
35+
element: HTMLElement | null,
36+
opacity: 0 | 1,
37+
offset: number,
38+
transition?: string,
39+
) {
40+
if (!element) {
41+
return
42+
}
43+
if (transition !== undefined) {
44+
element.style.transition = transition
45+
}
46+
element.style.opacity = String(opacity)
47+
element.style.transform = offset === 0 ? 'none' : `translateY(${offset}px)`
48+
}
49+
50+
type Props = {
51+
/** The content to be collapsed */
52+
children: React.ReactNode
53+
54+
/** The expanded/collapse state of the panel */
55+
isExpanded: boolean
56+
57+
/** Callback fired when the expansion animation completes */
58+
onEntered?: () => void
59+
}
60+
61+
/**
62+
* Internal wrapper used by `ExpansionPanelContent`. Animates the container's
63+
* height to reveal/hide the space, while the content cross-fades and springs
64+
* into place (driven imperatively so the motion still runs when entering from
65+
* `display: none`).
66+
*/
67+
function AnimatedExpansionPanelContent({ isExpanded, children, onEntered }: Props) {
68+
const transitionElementRef = React.useRef<HTMLDivElement>(null)
69+
const wrapperRef = React.useRef<HTMLDivElement>(null)
70+
const contentRef = React.useRef<HTMLDivElement>(null)
71+
72+
const handleEnter = React.useCallback(() => {
73+
if (!transitionElementRef.current) {
74+
return
75+
}
76+
77+
setElementHeight(transitionElementRef.current, 0)
78+
setContentMotion(contentRef.current, 0, CONTENT_ENTER_OFFSET, OPEN_TRANSITION)
79+
}, [])
80+
81+
const handleEntering = React.useCallback(() => {
82+
if (!transitionElementRef.current) {
83+
return
84+
}
85+
86+
setElementHeight(transitionElementRef.current, wrapperRef.current?.clientHeight ?? 0)
87+
setContentMotion(contentRef.current, 1, 0)
88+
}, [])
89+
90+
const handleEntered = React.useCallback(() => {
91+
if (!transitionElementRef.current) {
92+
return
93+
}
94+
setElementHeight(transitionElementRef.current, 'auto')
95+
onEntered?.()
96+
}, [onEntered])
97+
98+
const handleExit = React.useCallback(() => {
99+
if (!transitionElementRef.current) {
100+
return
101+
}
102+
setElementHeight(transitionElementRef.current, wrapperRef.current?.clientHeight ?? 0)
103+
setContentMotion(contentRef.current, 1, 0, CLOSE_TRANSITION)
104+
}, [])
105+
106+
const handleExiting = React.useCallback(() => {
107+
if (!transitionElementRef.current) {
108+
return
109+
}
110+
111+
// Reading this property is important, even if we do not consume the value.
112+
// Without this, the expanded -> collapsed animation will not work.
113+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
114+
wrapperRef.current?.clientHeight
115+
setElementHeight(transitionElementRef.current, 0)
116+
setContentMotion(contentRef.current, 0, 0)
117+
}, [])
118+
119+
return (
120+
<Transition
121+
nodeRef={transitionElementRef}
122+
onEntering={handleEntering}
123+
onEnter={handleEnter}
124+
onEntered={handleEntered}
125+
onExiting={handleExiting}
126+
onExit={handleExit}
127+
timeout={HEIGHT_TRANSITION_DURATION_MS}
128+
in={isExpanded}
129+
>
130+
{(state) => (
131+
<Box
132+
ref={transitionElementRef}
133+
overflow={state === 'entered' ? 'visible' : 'hidden'}
134+
display={state === 'exited' ? 'none' : 'block'}
135+
className={[styles.container, state === 'entered' ? styles.entered : null]}
136+
>
137+
<Box display="flex" ref={wrapperRef}>
138+
<Box width="full" ref={contentRef}>
139+
{children}
140+
</Box>
141+
</Box>
142+
</Box>
143+
)}
144+
</Transition>
145+
)
146+
}
147+
148+
export { AnimatedExpansionPanelContent }
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import * as React from 'react'
2+
import type { JSX } from 'react'
3+
4+
/**
5+
* Chevron used by `ExpansionPanelToggle`. Kept local to the component for now,
6+
* pending shared icon syncing (https://github.com/doist/icons).
7+
*/
8+
function ChevronDownIcon(props: JSX.IntrinsicElements['svg']) {
9+
return (
10+
<svg
11+
xmlns="http://www.w3.org/2000/svg"
12+
width="24"
13+
height="24"
14+
viewBox="0 0 24 24"
15+
fill="currentColor"
16+
{...props}
17+
>
18+
<path
19+
fillRule="evenodd"
20+
clipRule="evenodd"
21+
d="M17.854 8.896a.5.5 0 0 0-.708 0L12 14.043 6.854 8.896a.5.5 0 1 0-.708.708l5.5 5.5a.5.5 0 0 0 .708 0l5.5-5.5a.5.5 0 0 0 0-.708Z"
22+
/>
23+
</svg>
24+
)
25+
}
26+
27+
/** Smaller-weight variant of {@link ChevronDownIcon}. */
28+
function ChevronDownSmallIcon(props: JSX.IntrinsicElements['svg']) {
29+
return (
30+
<svg
31+
xmlns="http://www.w3.org/2000/svg"
32+
width="24"
33+
height="24"
34+
viewBox="0 0 24 24"
35+
fill="currentColor"
36+
{...props}
37+
>
38+
<path d="M15.646 9.646a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L12 13.293l3.646-3.647Z" />
39+
</svg>
40+
)
41+
}
42+
43+
export { ChevronDownIcon, ChevronDownSmallIcon }
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
.toggle {
2+
/* Remove button click animation */
3+
transform: none !important;
4+
5+
/* No idle background — the revealed chevron stays flat until directly hovered. */
6+
--reactist-btn-idle-fill: transparent;
7+
}
8+
9+
/* An expanded panel sets aria-expanded="true", which Reactist would otherwise render
10+
with an active fill. Keep it flat unless the chevron itself is hovered/focused, so
11+
the background only appears when pointing at the chevron (not the whole header). */
12+
.toggle[aria-expanded='true']:not(:hover):not(:focus-visible) {
13+
--reactist-btn-hover-fill: transparent;
14+
}
15+
16+
.toggleIcon {
17+
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
18+
transform: rotate(-90deg);
19+
}
20+
21+
[aria-expanded='true'] .toggleIcon {
22+
transform: rotate(0deg);
23+
}

0 commit comments

Comments
 (0)