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', 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..90a2ef69 --- /dev/null +++ b/src/expansion-panel/animated-expansion-panel-content.module.css @@ -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; +} 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..d014c75b --- /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_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(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..8bddf70e --- /dev/null +++ b/src/expansion-panel/chevron-down-icon.tsx @@ -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 ( + + + + ) +} + +/** 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.tsx b/src/expansion-panel/expansion-panel.stories.tsx new file mode 100644 index 00000000..a42df5c2 --- /dev/null +++ b/src/expansion-panel/expansion-panel.stories.tsx @@ -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: '🧭 Navigation & structure/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..9a0f8d56 --- /dev/null +++ b/src/expansion-panel/expansion-panel.test.tsx @@ -0,0 +1,110 @@ +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, + ExpansionPanelContent, + ExpansionPanelHeader, + ExpansionPanelToggle, +} from './expansion-panel' + +type PanelOverrides = + | { initiallyExpanded?: boolean } + | { isExpanded: boolean; onToggleExpand: () => void } + +function renderPanel(overrides: PanelOverrides = {}) { + return render( + + + + + + Body content + + , + ) +} + +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() + + const toggle = screen.getByRole('button', { name: 'Toggle section' }) + expect(toggle).toHaveAttribute('aria-controls', 'panel-content') + expect(document.getElementById('panel-content')).toBeInTheDocument() + }) + + 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.getByRole('button', { name: 'Toggle section' })).toHaveAttribute( + 'aria-expanded', + 'false', + ) + }) + + 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) + }) + + 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 new file mode 100644 index 00000000..5a148686 --- /dev/null +++ b/src/expansion-panel/expansion-panel.tsx @@ -0,0 +1,253 @@ +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' + +/** + * 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), []) + + if (controlledValue !== undefined) { + return [controlledValue, onToggle ?? toggleInternal] + } + return [internalState, toggleInternal] +} + +type ExpansionPanelState = { + isExpanded: boolean + id: string + onToggleExpand: () => void +} + +const ExpansionPanelStateContext = React.createContext({ + isExpanded: true, + id: '', + onToggleExpand: () => undefined, +}) + +type ExpansionPanelProps = { + /** + * 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 + 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. + */ +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`') + } + + 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 }: ExpansionPanelHeaderProps) { + return {children} +} + +/** + * 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' + +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 + 'aria-label'?: string + /** Chevron icon size. Defaults to `'24'`. */ + size?: '24' | '16' + } & ButtonTogglePassthrough) + | ({ + children?: never + /** 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 `onClick`, + * `aria-expanded`, and `aria-controls` automatically from the parent + * `ExpansionPanel`'s context. + * + * 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, + 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 ( + + ) +} + +type ExpansionPanelContentProps = Omit, 'id'> & { + /** Called once the expand animation finishes (not fired on collapse). */ + onEntered?: () => void +} + +/** + * 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 }: ExpansionPanelContentProps) { + const { isExpanded, id } = React.useContext(ExpansionPanelStateContext) + return ( + + + + {children} + + + + ) +} + +export { ExpansionPanel, ExpansionPanelContent, ExpansionPanelHeader, ExpansionPanelToggle } +export type { + ExpansionPanelContentProps, + ExpansionPanelHeaderProps, + ExpansionPanelProps, + ExpansionPanelToggleProps, +} 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'