-
Notifications
You must be signed in to change notification settings - Fork 23
feat: add ExpansionPanel component #1075
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
d195bb4
2be8e88
21f29c3
9e70140
c3ddcd5
ee032d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| .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; | ||
| } | ||
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💭 Nothing to action on here, but hopefully,
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 — 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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 P2 |
||
| 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 } | ||
| 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 } |
| 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); | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?