Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -31,6 +34,7 @@ const preview: Preview = {
'📝 Form',
'📐 Layout',
'📑 Menus & tabs',
'🧭 Navigation & structure',
'🪟 Overlays',
'🔤 Typography',
'⚙️ Utility',
Expand Down
43 changes: 42 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -14,6 +15,7 @@ const external = [
/@babel\/runtime/,
'react',
'react-dom',
'react-transition-group',
'react-compiler-runtime',
'classnames',
'prop-types',
Expand Down
11 changes: 11 additions & 0 deletions src/expansion-panel/animated-expansion-panel-content.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.container {
min-height: 0;
height: 0;
transition-duration: 200ms;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the design team have tokens assigned to transition timings and easing functions? This could be a good opportunity to introduce them here.

While we have variables defined for them in Todoist, we haven't done a good job extracting these into variables here in Reactist.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We discussed an animation framework recently but we don't have a final plan and values yet.

I would suggest we go with this approach for now and then use the variables to replace all current values later on?

transition-property: height;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}

.entered {
height: auto;
}
148 changes: 148 additions & 0 deletions src/expansion-panel/animated-expansion-panel-content.tsx
Original file line number Diff line number Diff line change
@@ -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_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
* 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}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<HTMLDivElement>(null)
const wrapperRef = React.useRef<HTMLDivElement>(null)
const contentRef = React.useRef<HTMLDivElement>(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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💭 Nothing to action on here, but hopefully, interpolate-size comes to Firefox and Safari soon so this can be done in CSS.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, nothing to change here — would be great to move this into CSS once interpolate-size lands across browsers.

— Claude

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 (
<Transition

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 P2 Transition is left at its default mount behavior here, so a collapsed panel still renders and keeps its entire content subtree mounted; the display: 'none' on the container only skips paint. In a list/sidebar with many collapsed panels, that eagerly runs all child renders/effects up front and keeps hidden content alive after collapse. Add mountOnEnter at minimum, and unmountOnExit too if preserving hidden state isn't required.

nodeRef={transitionElementRef}
onEntering={handleEntering}
onEnter={handleEnter}
onEntered={handleEntered}
onExiting={handleExiting}
onExit={handleExit}
timeout={HEIGHT_TRANSITION_DURATION_MS}
in={isExpanded}
>
{(state) => (
<Box
ref={transitionElementRef}
overflow={state === 'entered' ? 'visible' : 'hidden'}
display={state === 'exited' ? 'none' : 'block'}
className={[styles.container, state === 'entered' ? styles.entered : null]}
>
<Box display="flex" ref={wrapperRef}>
<Box width="full" ref={contentRef}>
{children}
</Box>
</Box>
</Box>
)}
</Transition>
)
}

export { AnimatedExpansionPanelContent }
43 changes: 43 additions & 0 deletions src/expansion-panel/chevron-down-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as React from 'react'
import type { JSX } 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 (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
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"
/>
</svg>
)
}

/** Smaller-weight variant of {@link ChevronDownIcon}. */
function ChevronDownSmallIcon(props: JSX.IntrinsicElements['svg']) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
{...props}
>
<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" />
</svg>
)
}

export { ChevronDownIcon, ChevronDownSmallIcon }
23 changes: 23 additions & 0 deletions src/expansion-panel/expansion-panel.module.css
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading