Skip to content
Merged
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
1 change: 1 addition & 0 deletions packages/ui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- Add `Text` primitive with predefined typographic variants (`heading-2xl` through `heading-sm`, `body-xl` through `body-sm`) built on design tokens ([#75870](https://github.com/WordPress/gutenberg/pull/75870)).
- Add `Card` and `CollapsibleCard` primitives ([#76252](https://github.com/WordPress/gutenberg/pull/76252)).
- Add `Link` primitive ([#76013](https://github.com/WordPress/gutenberg/pull/76013)).
- Add `Collapsible` primitive ([#76280](https://github.com/WordPress/gutenberg/pull/76280)).

### Bug Fixes

Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/collapsible-card/content.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Collapsible } from '@base-ui/react/collapsible';
import { forwardRef } from '@wordpress/element';
import * as Card from '../card';
import * as Collapsible from '../collapsible';
import type { ContentProps } from './types';

/**
Expand Down
16 changes: 13 additions & 3 deletions packages/ui/src/collapsible-card/header.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Collapsible } from '@base-ui/react/collapsible';
import clsx from 'clsx';
import type { MouseEvent } from 'react';
import { forwardRef, useCallback, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { chevronDown, chevronUp } from '@wordpress/icons';
import * as Card from '../card';
import * as Collapsible from '../collapsible';
import { IconButton } from '../icon-button';
import styles from './style.module.css';
import type { HeaderProps } from './types';
Expand Down Expand Up @@ -52,11 +52,21 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >(
<div className={ styles[ 'header-trigger-wrapper' ] }>
<Collapsible.Trigger
ref={ triggerRef }
render={ ( props, state ) => (
render={ ( props ) => (
<IconButton
{ ...props }
label={ __( 'Expand or collapse card' ) }
icon={ state.open ? chevronUp : chevronDown }
// The Collapsible wrapper's `render` prop
// uses a single-argument callback (via the
// ComponentProps utility), so Base UI's
// second `state` argument isn't available
// here. We derive the open state from
// `aria-expanded` instead of `state.open`.
icon={
props[ 'aria-expanded' ] === true
? chevronUp
: chevronDown
}
Comment on lines +59 to +69

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.

At this point I think it's definitely cleaner to use a single svg and rotate it in CSS based on the [aria-expanded]. What do you think?

@ciampo ciampo Mar 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yup, it's the approach that I've taken in #76265 anyway. I may keep the code in this PR as-is and apply that change in #76265

variant="minimal"
tone="neutral"
size="compact"
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/collapsible-card/root.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Collapsible } from '@base-ui/react/collapsible';
import { forwardRef } from '@wordpress/element';
import * as Card from '../card';
import * as Collapsible from '../collapsible';
import type { RootProps } from './types';

/**
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/collapsible/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Panel } from './panel';
import { Root } from './root';
import { Trigger } from './trigger';

export { Root, Trigger, Panel };
16 changes: 16 additions & 0 deletions packages/ui/src/collapsible/panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Collapsible as _Collapsible } from '@base-ui/react/collapsible';
import { forwardRef } from '@wordpress/element';
import type { PanelProps } from './types';

/**
* A panel with the collapsible contents. Hidden when collapsed, visible
* when expanded.
*
* `Collapsible` is a collection of React components that combine to render
* a collapsible panel controlled by a button.
*/
export const Panel = forwardRef< HTMLDivElement, PanelProps >(
function CollapsiblePanel( props, forwardedRef ) {
return <_Collapsible.Panel ref={ forwardedRef } { ...props } />;
}
);
15 changes: 15 additions & 0 deletions packages/ui/src/collapsible/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Collapsible as _Collapsible } from '@base-ui/react/collapsible';
import { forwardRef } from '@wordpress/element';
import type { RootProps } from './types';

/**
* Groups all parts of the collapsible.
*
* `Collapsible` is a collection of React components that combine to render
* a collapsible panel controlled by a button.
*/
export const Root = forwardRef< HTMLDivElement, RootProps >(
function CollapsibleRoot( props, forwardedRef ) {
return <_Collapsible.Root ref={ forwardedRef } { ...props } />;
}
);
108 changes: 108 additions & 0 deletions packages/ui/src/collapsible/stories/index.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { useState } from '@wordpress/element';
import * as Collapsible from '../index';

const meta: Meta< typeof Collapsible.Root > = {
title: 'Design System/Components/Collapsible',
component: Collapsible.Root,
subcomponents: {
'Collapsible.Trigger': Collapsible.Trigger,
'Collapsible.Panel': Collapsible.Panel,
},
};
export default meta;

type Story = StoryObj< typeof Collapsible.Root >;

export const Default: Story = {
args: {
children: (
<>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Panel>
<p>Collapsible content here.</p>
</Collapsible.Panel>
</>
),
},
};

export const DefaultOpen: Story = {
argTypes: { open: { control: false } },
args: {
defaultOpen: true,
children: (
<>
<Collapsible.Trigger>Toggle</Collapsible.Trigger>
<Collapsible.Panel>
<p>This panel is open by default.</p>
</Collapsible.Panel>
</>
),
},
};

export const Disabled: Story = {
args: {
disabled: true,
children: (
<>
<Collapsible.Trigger>Toggle (disabled)</Collapsible.Trigger>
<Collapsible.Panel>
<p>This content cannot be toggled.</p>
</Collapsible.Panel>
</>
),
},
};

/**
* When `hiddenUntilFound` is set on `Collapsible.Panel`, the collapsed content
* remains in the DOM using the `hidden="until-found"` HTML attribute instead of
* being removed entirely. This lets the browser's native page search (Ctrl/Cmd+F)
* find text inside collapsed panels and automatically expand them to reveal the
* match — improving discoverability without sacrificing the collapsed layout.
*/
export const HiddenUntilFound: Story = {
render: function HiddenUntilFound() {
return (
<div>
<p>
Use the browser&apos;s find-in-page (Ctrl/Cmd+F) to search
for &quot;hidden treasure&quot;. The collapsed panel will
automatically expand to reveal the match.
</p>
<Collapsible.Root>
<Collapsible.Trigger>Expand to reveal</Collapsible.Trigger>
<Collapsible.Panel hiddenUntilFound>
<p>
This is the hidden treasure that can be found via
the browser&apos;s built-in page search even while
the panel is collapsed.
</p>
</Collapsible.Panel>
</Collapsible.Root>
</div>
);
},
};

export const Controlled: Story = {
argTypes: {
open: { control: false },
defaultOpen: { control: false },
},
render: function Controlled() {
const [ open, setOpen ] = useState( false );
return (
<Collapsible.Root open={ open } onOpenChange={ setOpen }>
<Collapsible.Trigger>
{ open ? 'Close' : 'Open' }
</Collapsible.Trigger>
<Collapsible.Panel>
<p>Controlled collapsible panel.</p>
</Collapsible.Panel>
</Collapsible.Root>
);
},
};
Loading
Loading