refactor(ui): replace Collapsible with Accordion component

Replaces the Base UI Collapsible component with a new Accordion component built on React Aria's Disclosure primitives.

Key changes:
- Removed Collapsible component and all related files
- Added new Accordion component with AccordionTrigger, AccordionPanel, and AccordionGroup
- Introduced opinionated styling with built-in trigger component featuring animated chevron icon
- Added support for title/subtitle props and custom trigger content via children
- Implemented AccordionGroup with single/multiple expansion modes
- Updated all documentation from collapsible.mdx to accordion.mdx
- Added comprehensive migration guide in changeset
- Updated API reports and exports

This is a breaking change. Users must migrate from Collapsible to either:
1. Accordion (opinionated styled component) - recommended for most use cases
2. React Aria Disclosure directly - for full customization

The changeset provides detailed migration examples for both paths.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-10-22 11:24:18 +02:00
parent e748a1e87f
commit 83c100e6ac
19 changed files with 869 additions and 400 deletions
+39
View File
@@ -0,0 +1,39 @@
---
'@backstage/ui': minor
---
**BREAKING**: Removed `Collapsible` component. Migrate to `Accordion` or use React Aria `Disclosure`.
## Migration Path 1: Accordion (Opinionated Styled Component)
Accordion provides preset styling with a similar component structure.
```diff
- import { Collapsible } from '@backstage/ui';
+ import { Accordion, AccordionTrigger, AccordionPanel } from '@backstage/ui';
- <Collapsible.Root>
- <Collapsible.Trigger render={(props) => <Button {...props}>Toggle</Button>} />
- <Collapsible.Panel>Content</Collapsible.Panel>
- </Collapsible.Root>
+ <Accordion>
+ <AccordionTrigger title="Toggle" />
+ <AccordionPanel>Content</AccordionPanel>
+ </Accordion>
```
CSS classes: `.bui-CollapsibleRoot``.bui-Accordion`, `.bui-CollapsibleTrigger``.bui-AccordionTrigger` (now on heading element), `.bui-CollapsiblePanel``.bui-AccordionPanel`
## Migration Path 2: React Aria Disclosure (Full Customization)
For custom styling without preset styles:
```tsx
import { Disclosure, Button, DisclosurePanel } from 'react-aria-components';
<Disclosure>
<Button slot="trigger">Toggle</Button>
<DisclosurePanel>Content</DisclosurePanel>
</Disclosure>;
```
+128
View File
@@ -0,0 +1,128 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import { AccordionSnippet } from '@/snippets/stories-snippets';
import {
accordionPropDefs,
accordionTriggerPropDefs,
accordionPanelPropDefs,
accordionGroupPropDefs,
accordionUsageSnippet,
accordionWithSubtitleSnippet,
accordionCustomTriggerSnippet,
accordionDefaultExpandedSnippet,
accordionGroupSingleOpenSnippet,
accordionGroupMultipleOpenSnippet,
} from './accordion.props';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
<PageTitle
title="Accordion"
description="A component for showing and hiding content with animation."
/>
<Snippet
align="center"
py={4}
height={240}
preview={<AccordionSnippet story="Default" />}
code={accordionUsageSnippet}
/>
## Usage
<CodeBlock code={accordionUsageSnippet} />
## API reference
### Accordion
Root container for the accordion. Renders a `<div>` element.
<PropsTable data={accordionPropDefs} />
### AccordionTrigger
Trigger component with built-in animated chevron icon. Renders a heading element (defaults to `<h3>`, configurable via `level` prop) wrapping a `<button>`.
<PropsTable data={accordionTriggerPropDefs} />
### AccordionPanel
Panel with the accordion content. Renders a `<div>` element.
<PropsTable data={accordionPanelPropDefs} />
### AccordionGroup
Container for managing multiple accordions. Renders a `<div>` element.
<PropsTable data={accordionGroupPropDefs} />
## Examples
### With Subtitle
Here's a view when using both title and subtitle props.
<Snippet
align="center"
py={4}
height={240}
preview={<AccordionSnippet story="WithSubtitle" />}
code={accordionWithSubtitleSnippet}
/>
### Custom Trigger
Here's a view when providing custom multi-line content as children.
<Snippet
align="center"
py={4}
height={280}
preview={<AccordionSnippet story="CustomTrigger" />}
code={accordionCustomTriggerSnippet}
/>
### Default Expanded
Here's a view when the panel is expanded by default.
<Snippet
align="center"
py={4}
height={280}
preview={<AccordionSnippet story="DefaultExpanded" />}
code={accordionDefaultExpandedSnippet}
/>
### Group Single Open
Here's a view when only one accordion can be open at a time.
<Snippet
align="center"
py={4}
height={280}
preview={<AccordionSnippet story="GroupSingleOpen" />}
code={accordionGroupSingleOpenSnippet}
/>
### Group Multiple Open
Here's a view when multiple accordions can be open simultaneously.
<Snippet
align="center"
py={4}
height={280}
preview={<AccordionSnippet story="GroupMultipleOpen" />}
code={accordionGroupMultipleOpenSnippet}
/>
<Theming component="Accordion" />
<ChangelogComponent component="accordion" />
+119
View File
@@ -0,0 +1,119 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
export const accordionPropDefs: Record<string, PropDef> = {
children: {
type: 'enum',
values: ['ReactNode', '(state: { isExpanded: boolean }) => ReactNode'],
},
defaultExpanded: {
type: 'boolean',
default: 'false',
},
isExpanded: {
type: 'boolean',
},
onExpandedChange: {
type: 'enum',
values: ['(isExpanded: boolean) => void'],
},
...classNamePropDefs,
...stylePropDefs,
};
export const accordionTriggerPropDefs: Record<string, PropDef> = {
level: {
type: 'enum',
values: ['1', '2', '3', '4', '5', '6'],
default: '3',
},
title: {
type: 'string',
},
subtitle: {
type: 'string',
},
children: {
type: 'enum',
values: ['ReactNode'],
},
...classNamePropDefs,
...stylePropDefs,
};
export const accordionPanelPropDefs: Record<string, PropDef> = {
...classNamePropDefs,
...stylePropDefs,
};
export const accordionGroupPropDefs: Record<string, PropDef> = {
allowsMultiple: {
type: 'boolean',
default: 'false',
},
...classNamePropDefs,
...stylePropDefs,
};
export const accordionUsageSnippet = `import { Accordion, AccordionTrigger, AccordionPanel } from '@backstage/ui';
<Accordion>
<AccordionTrigger title="Toggle Panel" />
<AccordionPanel>Your content</AccordionPanel>
</Accordion>`;
export const accordionWithSubtitleSnippet = `<Accordion>
<AccordionTrigger
title="Advanced Settings"
subtitle="Configure additional options"
/>
<AccordionPanel>
<Text>Your content here</Text>
</AccordionPanel>
</Accordion>`;
export const accordionCustomTriggerSnippet = `<Accordion>
<AccordionTrigger>
<Box>
<Text as="div" weight="bold">Custom Multi-line Trigger</Text>
<Text as="div" size="small" color="secondary">
Click to expand additional details
</Text>
</Box>
</AccordionTrigger>
<AccordionPanel>
<Text>Your content here</Text>
</AccordionPanel>
</Accordion>`;
export const accordionDefaultExpandedSnippet = `<Accordion defaultExpanded>
<AccordionTrigger title="Toggle Panel" />
<AccordionPanel>
<Text>Your content here</Text>
</AccordionPanel>
</Accordion>`;
export const accordionGroupSingleOpenSnippet = `<AccordionGroup>
<Accordion>
<AccordionTrigger title="First Panel" />
<AccordionPanel>Content 1</AccordionPanel>
</Accordion>
<Accordion>
<AccordionTrigger title="Second Panel" />
<AccordionPanel>Content 2</AccordionPanel>
</Accordion>
</AccordionGroup>`;
export const accordionGroupMultipleOpenSnippet = `<AccordionGroup allowsMultiple>
<Accordion>
<AccordionTrigger title="First Panel" />
<AccordionPanel>Content 1</AccordionPanel>
</Accordion>
<Accordion>
<AccordionTrigger title="Second Panel" />
<AccordionPanel>Content 2</AccordionPanel>
</Accordion>
</AccordionGroup>`;
-72
View File
@@ -1,72 +0,0 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import { CollapsibleSnippet } from '@/snippets/stories-snippets';
import {
collapsibleRootPropDefs,
collapsibleTriggerPropDefs,
collapsiblePanelPropDefs,
collapsibleUsageSnippet,
collapsibleDefaultSnippet,
collapsibleTriggerSnippet,
collapsibleOpenSnippet,
} from './collapsible.props';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
<PageTitle
title="Collapsible"
description="A collapsible component that can be used to display content in a box."
/>
<Snippet
align="center"
py={4}
height={240}
preview={<CollapsibleSnippet story="Default" />}
code={collapsibleDefaultSnippet}
/>
## Usage
<CodeBlock code={collapsibleUsageSnippet} />
## API reference
### Collapsible.Root
Groups all parts of the collapsible. Renders a `<div>` element.
<PropsTable data={collapsibleRootPropDefs} />
### Collapsible.Trigger
The trigger by default render a simple unstyled button. Because menus can be rendered in different ways, we recommend
using the `render` prop to render a custom trigger.
<CodeBlock code={collapsibleTriggerSnippet} />
<PropsTable data={collapsibleTriggerPropDefs} />
### Collapsible.Panel
A panel with the collapsible contents. Renders a `<div>` element.
<PropsTable data={collapsiblePanelPropDefs} />
## Examples
Open the panel by default by setting the `defaultOpen` prop to `true`.
<Snippet
align="center"
py={4}
open
preview={<CollapsibleSnippet story="Open" />}
code={collapsibleOpenSnippet}
/>
<Theming component="Collapsible" />
<ChangelogComponent component="collapsible" />
-86
View File
@@ -1,86 +0,0 @@
import {
classNamePropDefs,
stylePropDefs,
renderPropDefs,
type PropDef,
} from '@/utils/propDefs';
export const collapsibleRootPropDefs: Record<string, PropDef> = {
defaultOpen: {
type: 'boolean',
default: 'false',
},
open: {
type: 'boolean',
},
onOpenChange: {
type: 'enum',
values: ['(open) => void'],
},
...renderPropDefs,
...classNamePropDefs,
...stylePropDefs,
};
export const collapsibleTriggerPropDefs: Record<string, PropDef> = {
...renderPropDefs,
...classNamePropDefs,
...stylePropDefs,
};
export const collapsiblePanelPropDefs: Record<string, PropDef> = {
hiddenUntilFound: {
type: 'boolean',
default: 'false',
},
keepMounted: {
type: 'boolean',
default: 'false',
},
...renderPropDefs,
...classNamePropDefs,
...stylePropDefs,
};
export const collapsibleUsageSnippet = `import { Collapsible } from '@backstage/ui';
<Collapsible.Root>
<Collapsible.Trigger render={(props, state) => (
<Button {...props}>
{state.open ? 'Close Panel' : 'Open Panel'}
</Button>
)} />
<Collapsible.Panel>Your content</Collapsible.Panel>
</Collapsible.Root>`;
export const collapsibleDefaultSnippet = `<Collapsible.Root>
<Collapsible.Trigger render={(props, state) => (
<Button {...props}>
{state.open ? 'Close Panel' : 'Open Panel'}
</Button>
)} />
<Collapsible.Panel>
<Box>
<Text>It's the edge of the world and all of Western civilization</Text>
<Text>The sun may rise in the East, at least it settled in a final location</Text>
<Text>It's understood that Hollywood sells Californication</Text>
</Box>
</Collapsible.Panel>
</Collapsible.Root>`;
export const collapsibleTriggerSnippet = `<Collapsible.Trigger render={props => <Button {...props} />} />`;
export const collapsibleOpenSnippet = `<Collapsible.Root defaultOpen>
<Collapsible.Trigger render={(props, state) => (
<Button {...props}>
{state.open ? 'Close Panel' : 'Open Panel'}
</Button>
)} />
<Collapsible.Panel>
<Box>
<Text>It's the edge of the world and all of Western civilization</Text>
<Text>The sun may rise in the East, at least it settled in a final location</Text>
<Text>It's understood that Hollywood sells Californication</Text>
</Box>
</Collapsible.Panel>
</Collapsible.Root>`;
+2 -2
View File
@@ -15,7 +15,7 @@ import * as SelectStories from '../../../packages/ui/src/components/Select/Selec
import * as MenuStories from '../../../packages/ui/src/components/Menu/Menu.stories';
import * as LinkStories from '../../../packages/ui/src/components/Link/Link.stories';
import * as AvatarStories from '../../../packages/ui/src/components/Avatar/Avatar.stories';
import * as CollapsibleStories from '../../../packages/ui/src/components/Collapsible/Collapsible.stories';
import * as AccordionStories from '../../../packages/ui/src/components/Accordion/Accordion.stories';
import * as DialogStories from '../../../packages/ui/src/components/Dialog/Dialog.stories';
import * as RadioGroupStories from '../../../packages/ui/src/components/RadioGroup/RadioGroup.stories';
import * as TabsStories from '../../../packages/ui/src/components/Tabs/Tabs.stories';
@@ -62,7 +62,7 @@ export const SelectSnippet = createSnippetComponent(SelectStories);
export const MenuSnippet = createSnippetComponent(MenuStories);
export const LinkSnippet = createSnippetComponent(LinkStories);
export const AvatarSnippet = createSnippetComponent(AvatarStories);
export const CollapsibleSnippet = createSnippetComponent(CollapsibleStories);
export const AccordionSnippet = createSnippetComponent(AccordionStories);
export const DialogSnippet = createSnippetComponent(DialogStories);
export const RadioGroupSnippet = createSnippetComponent(RadioGroupStories);
export const TabsSnippet = createSnippetComponent(TabsStories);
+1
View File
@@ -13,6 +13,7 @@ export type Component =
| 'datatable'
| 'select'
| 'collapsible'
| 'accordion'
| 'checkbox'
| 'container'
| 'link'
+4 -4
View File
@@ -24,6 +24,10 @@ export const layoutComponents: Page[] = [
];
export const components: Page[] = [
{
title: 'Accordion',
slug: 'accordion',
},
{
title: 'Avatar',
slug: 'avatar',
@@ -48,10 +52,6 @@ export const components: Page[] = [
title: 'Checkbox',
slug: 'checkbox',
},
{
title: 'Collapsible',
slug: 'collapsible',
},
{
title: 'Dialog',
slug: 'dialog',
+66 -27
View File
@@ -6,12 +6,14 @@
import { ButtonProps as ButtonProps_2 } from 'react-aria-components';
import { CellProps as CellProps_2 } from 'react-aria-components';
import { CheckboxProps as CheckboxProps_2 } from 'react-aria-components';
import { Collapsible as Collapsible_2 } from '@base-ui-components/react/collapsible';
import { ColumnProps as ColumnProps_2 } from 'react-aria-components';
import { ComponentProps } from 'react';
import type { ComponentPropsWithRef } from 'react';
import { DetailedHTMLProps } from 'react';
import type { DialogTriggerProps as DialogTriggerProps_2 } from 'react-aria-components';
import type { DisclosureGroupProps } from 'react-aria-components';
import type { DisclosurePanelProps } from 'react-aria-components';
import type { DisclosureProps } from 'react-aria-components';
import type { ElementType } from 'react';
import { ForwardRefExoticComponent } from 'react';
import type { HeadingProps } from 'react-aria-components';
@@ -51,6 +53,57 @@ import type { TextFieldProps as TextFieldProps_2 } from 'react-aria-components';
import { TooltipProps as TooltipProps_2 } from 'react-aria-components';
import { TooltipTriggerComponentProps } from 'react-aria-components';
// @public (undocumented)
export const Accordion: ForwardRefExoticComponent<
AccordionProps & RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export const AccordionGroup: ForwardRefExoticComponent<
AccordionGroupProps & RefAttributes<HTMLDivElement>
>;
// @public
export interface AccordionGroupProps extends DisclosureGroupProps {
allowsMultiple?: boolean;
// (undocumented)
className?: string;
}
// @public (undocumented)
export const AccordionPanel: ForwardRefExoticComponent<
AccordionPanelProps & RefAttributes<HTMLDivElement>
>;
// @public
export interface AccordionPanelProps extends DisclosurePanelProps {
// (undocumented)
className?: string;
}
// @public
export interface AccordionProps extends DisclosureProps {
// (undocumented)
className?: string;
}
// @public (undocumented)
export const AccordionTrigger: ForwardRefExoticComponent<
AccordionTriggerProps & RefAttributes<HTMLHeadingElement>
>;
// @public
export interface AccordionTriggerProps extends HeadingProps {
// (undocumented)
children?: React.ReactNode;
// (undocumented)
className?: string;
// (undocumented)
subtitle?: string;
// (undocumented)
title?: string;
}
// @public (undocumented)
export type AlignItems = 'stretch' | 'start' | 'center' | 'end';
@@ -279,25 +332,6 @@ export interface CheckboxProps extends CheckboxProps_2 {
// @public
export type ClassNamesMap = Record<string, string>;
// @public
export const Collapsible: {
Root: ForwardRefExoticComponent<
Omit<Collapsible_2.Root.Props & RefAttributes<HTMLDivElement>, 'ref'> &
RefAttributes<HTMLDivElement>
>;
Trigger: ForwardRefExoticComponent<
Omit<
Collapsible_2.Trigger.Props & RefAttributes<HTMLButtonElement>,
'ref'
> &
RefAttributes<HTMLButtonElement>
>;
Panel: ForwardRefExoticComponent<
Omit<Collapsible_2.Panel.Props & RefAttributes<HTMLButtonElement>, 'ref'> &
RefAttributes<HTMLButtonElement>
>;
};
// @public (undocumented)
export const Column: (props: ColumnProps) => JSX_2.Element;
@@ -417,13 +451,6 @@ export const componentDefinitions: {
readonly selected: readonly [true, false];
};
};
readonly Collapsible: {
readonly classNames: {
readonly root: 'bui-CollapsibleRoot';
readonly trigger: 'bui-CollapsibleTrigger';
readonly panel: 'bui-CollapsiblePanel';
};
};
readonly Container: {
readonly classNames: {
readonly root: 'bui-Container';
@@ -440,6 +467,18 @@ export const componentDefinitions: {
readonly footer: 'bui-DialogFooter';
};
};
readonly Accordion: {
readonly classNames: {
readonly root: 'bui-Accordion';
readonly trigger: 'bui-AccordionTrigger';
readonly triggerButton: 'bui-AccordionTriggerButton';
readonly triggerTitle: 'bui-AccordionTriggerTitle';
readonly triggerSubtitle: 'bui-AccordionTriggerSubtitle';
readonly triggerIcon: 'bui-AccordionTriggerIcon';
readonly panel: 'bui-AccordionPanel';
readonly group: 'bui-AccordionGroup';
};
};
readonly FieldError: {
readonly classNames: {
readonly root: 'bui-FieldError';
@@ -0,0 +1,93 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@layer tokens, base, components, utilities;
@layer components {
.bui-Accordion {
width: 100%;
background-color: var(--bui-bg-surface-1);
border-radius: var(--bui-radius-3);
padding: var(--bui-space-3);
}
.bui-AccordionTrigger {
all: unset;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.bui-AccordionTriggerButton {
all: unset;
width: 100%;
color: var(--bui-fg-primary);
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
text-align: left;
&:focus-visible {
outline: none;
transition: none;
box-shadow: inset 0 0 0 2px var(--bui-ring);
}
&[data-disabled='true'] {
background-color: transparent;
color: var(--bui-fg-disabled);
cursor: not-allowed;
}
}
.bui-AccordionTriggerTitle {
font-size: var(--bui-font-size-4);
font-weight: var(--bui-font-weight-bold);
line-height: 140%;
}
.bui-AccordionTriggerSubtitle {
font-size: var(--bui-font-size-2);
line-height: 140%;
color: var(--bui-fg-secondary);
}
.bui-AccordionTriggerIcon {
transition: transform 150ms ease-out;
flex-shrink: 0;
width: 1rem;
height: 1rem;
[data-expanded='true'] & {
transform: rotate(180deg);
}
}
.bui-AccordionPanel {
[data-expanded='true'] & {
padding-top: var(--bui-space-1);
}
}
.bui-AccordionGroup {
display: flex;
flex-direction: column;
gap: var(--bui-space-3);
width: 100%;
}
}
@@ -0,0 +1,177 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
Accordion,
AccordionTrigger,
AccordionPanel,
AccordionGroup,
} from './Accordion';
import { Box } from '../Box';
import { Text } from '../Text';
const Content = () => (
<Box>
<Text as="p">
It's the edge of the world and all of Western civilization
</Text>
<Text as="p">
The sun may rise in the East, at least it settled in a final location
</Text>
<Text as="p">It's understood that Hollywood sells Californication</Text>
</Box>
);
const meta = {
title: 'Backstage UI/Accordion',
component: Accordion,
} satisfies Meta<typeof Accordion>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render: () => (
<Accordion>
<AccordionTrigger title="Toggle Panel" />
<AccordionPanel>
<Content />
</AccordionPanel>
</Accordion>
),
};
export const WithSubtitle: Story = {
render: () => (
<Accordion>
<AccordionTrigger
title="Advanced Settings"
subtitle="Configure additional options"
/>
<AccordionPanel>
<Content />
</AccordionPanel>
</Accordion>
),
};
export const CustomTrigger: Story = {
render: () => (
<Accordion>
<AccordionTrigger>
<Box>
<Text as="div" variant="body-large" weight="bold">
Custom Multi-line Trigger
</Text>
<Text as="div" variant="body-medium" color="secondary">
Click to expand additional details and configuration options
</Text>
</Box>
</AccordionTrigger>
<AccordionPanel>
<Content />
</AccordionPanel>
</Accordion>
),
};
export const DefaultExpanded: Story = {
render: () => (
<Accordion defaultExpanded>
<AccordionTrigger title="Toggle Panel" />
<AccordionPanel>
<Content />
</AccordionPanel>
</Accordion>
),
};
export const GroupSingleOpen: Story = {
render: () => (
<AccordionGroup>
<Accordion>
<AccordionTrigger title="First Panel" />
<AccordionPanel>
<Box>
<Text as="p">
It's the edge of the world and all of Western civilization
</Text>
</Box>
</AccordionPanel>
</Accordion>
<Accordion>
<AccordionTrigger title="Second Panel" />
<AccordionPanel>
<Box>
<Text as="p">
The sun may rise in the East, at least it settled in a final
location
</Text>
</Box>
</AccordionPanel>
</Accordion>
<Accordion>
<AccordionTrigger title="Third Panel" />
<AccordionPanel>
<Box>
<Text as="p">
It's understood that Hollywood sells Californication
</Text>
</Box>
</AccordionPanel>
</Accordion>
</AccordionGroup>
),
};
export const GroupMultipleOpen: Story = {
render: () => (
<AccordionGroup allowsMultiple>
<Accordion>
<AccordionTrigger title="First Panel" />
<AccordionPanel>
<Box>
<Text as="p">
It's the edge of the world and all of Western civilization
</Text>
</Box>
</AccordionPanel>
</Accordion>
<Accordion>
<AccordionTrigger title="Second Panel" />
<AccordionPanel>
<Box>
<Text as="p">
The sun may rise in the East, at least it settled in a final
location
</Text>
</Box>
</AccordionPanel>
</Accordion>
<Accordion>
<AccordionTrigger title="Third Panel" />
<AccordionPanel>
<Box>
<Text as="p">
It's understood that Hollywood sells Californication
</Text>
</Box>
</AccordionPanel>
</Accordion>
</AccordionGroup>
),
};
@@ -0,0 +1,153 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef } from 'react';
import {
Disclosure as RADisclosure,
Button as RAButton,
DisclosurePanel as RADisclosurePanel,
DisclosureGroup as RADisclosureGroup,
Heading as RAHeading,
} from 'react-aria-components';
import { RiArrowDownSLine } from '@remixicon/react';
import clsx from 'clsx';
import type {
AccordionProps,
AccordionTriggerProps,
AccordionPanelProps,
AccordionGroupProps,
} from './types';
import { useStyles } from '../../hooks/useStyles';
import styles from './Accordion.module.css';
import { Flex } from '../Flex';
/** @public */
export const Accordion = forwardRef<
React.ElementRef<typeof RADisclosure>,
AccordionProps
>(({ className, ...props }, ref) => {
const { classNames, cleanedProps } = useStyles('Accordion', props);
return (
<RADisclosure
ref={ref}
className={clsx(classNames.root, styles[classNames.root], className)}
{...cleanedProps}
/>
);
});
Accordion.displayName = 'Accordion';
/** @public */
export const AccordionTrigger = forwardRef<
React.ElementRef<typeof RAHeading>,
AccordionTriggerProps
>(({ className, title, subtitle, children, ...props }, ref) => {
const { classNames, cleanedProps } = useStyles('Accordion', props);
return (
<RAHeading
ref={ref}
className={clsx(
classNames.trigger,
styles[classNames.trigger],
className,
)}
{...cleanedProps}
>
<RAButton
slot="trigger"
className={clsx(
classNames.triggerButton,
styles[classNames.triggerButton],
)}
>
{children ? (
children
) : (
<Flex gap="2" align="center">
<span
className={clsx(
classNames.triggerTitle,
styles[classNames.triggerTitle],
)}
>
{title}
</span>
{subtitle && (
<span
className={clsx(
classNames.triggerSubtitle,
styles[classNames.triggerSubtitle],
)}
>
{subtitle}
</span>
)}
</Flex>
)}
<RiArrowDownSLine
className={clsx(
classNames.triggerIcon,
styles[classNames.triggerIcon],
)}
size={16}
/>
</RAButton>
</RAHeading>
);
});
AccordionTrigger.displayName = 'AccordionTrigger';
/** @public */
export const AccordionPanel = forwardRef<
React.ElementRef<typeof RADisclosurePanel>,
AccordionPanelProps
>(({ className, ...props }, ref) => {
const { classNames, cleanedProps } = useStyles('Accordion', props);
return (
<RADisclosurePanel
ref={ref}
className={clsx(classNames.panel, styles[classNames.panel], className)}
{...cleanedProps}
/>
);
});
AccordionPanel.displayName = 'AccordionPanel';
/** @public */
export const AccordionGroup = forwardRef<
React.ElementRef<typeof RADisclosureGroup>,
AccordionGroupProps
>(({ className, allowsMultiple = false, ...props }, ref) => {
const { classNames, cleanedProps } = useStyles('Accordion', props);
return (
<RADisclosureGroup
ref={ref}
allowsMultipleExpanded={allowsMultiple}
className={clsx(classNames.group, styles[classNames.group], className)}
{...cleanedProps}
/>
);
});
AccordionGroup.displayName = 'AccordionGroup';
@@ -14,4 +14,15 @@
* limitations under the License.
*/
export { Collapsible } from './Collapsible';
export {
Accordion,
AccordionTrigger,
AccordionPanel,
AccordionGroup,
} from './Accordion';
export type {
AccordionProps,
AccordionTriggerProps,
AccordionPanelProps,
AccordionGroupProps,
} from './types';
@@ -0,0 +1,62 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type {
DisclosureProps as RADisclosureProps,
HeadingProps as RAHeadingProps,
DisclosurePanelProps as RADisclosurePanelProps,
DisclosureGroupProps as RADisclosureGroupProps,
} from 'react-aria-components';
/**
* Props for the Accordion component.
* @public
*/
export interface AccordionProps extends RADisclosureProps {
className?: string;
}
/**
* Props for the AccordionTrigger component.
* @public
*/
export interface AccordionTriggerProps extends RAHeadingProps {
className?: string;
title?: string;
subtitle?: string;
children?: React.ReactNode;
}
/**
* Props for the AccordionPanel component.
* @public
*/
export interface AccordionPanelProps extends RADisclosurePanelProps {
className?: string;
}
/**
* Props for the AccordionGroup component.
* @public
*/
export interface AccordionGroupProps extends RADisclosureGroupProps {
className?: string;
/**
* Whether multiple accordions can be expanded at the same time.
* @defaultValue false
*/
allowsMultiple?: boolean;
}
@@ -1,31 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@layer tokens, base, components, utilities;
@layer components {
.bui-CollapsiblePanel {
display: flex;
height: var(--collapsible-panel-height);
overflow: hidden;
transition: all 150ms ease-out;
&[data-starting-style],
&[data-ending-style] {
height: 0;
}
}
}
@@ -1,84 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Collapsible } from './Collapsible';
import { Button } from '../Button';
import { Box } from '../Box';
import { Text } from '../Text';
import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react';
const meta = {
title: 'Backstage UI/Collapsible',
component: Collapsible.Root,
} satisfies Meta<typeof Collapsible.Root>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
style: {
display: 'flex',
flexDirection: 'column',
gap: 'var(--bui-space-2)',
alignItems: 'center',
},
children: (
<>
<Collapsible.Trigger
render={(props, state) => (
<Button
variant="secondary"
iconEnd={state.open ? <RiArrowUpSLine /> : <RiArrowDownSLine />}
{...props}
>
{state.open ? 'Close Panel' : 'Open Panel'}
</Button>
)}
/>
<Collapsible.Panel>
<Box
p="4"
style={{
border: '1px solid var(--bui-border)',
backgroundColor: 'var(--bui-bg-surface-1)',
color: 'var(--bui-fg-primary)',
borderRadius: 'var(--bui-radius-2)',
width: '460px',
}}
>
<Text>
It's the edge of the world and all of Western civilization
</Text>
<Text>
The sun may rise in the East, at least it settled in a final
location
</Text>
<Text>It's understood that Hollywood sells Californication</Text>
</Box>
</Collapsible.Panel>
</>
),
},
};
export const Open: Story = {
args: {
...Default.args,
defaultOpen: true,
},
};
@@ -1,85 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { forwardRef } from 'react';
import { Collapsible as CollapsiblePrimitive } from '@base-ui-components/react/collapsible';
import clsx from 'clsx';
import { useStyles } from '../../hooks/useStyles';
import styles from './Collapsible.module.css';
const CollapsibleRoot = forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Root>
>(({ className, ...props }, ref) => {
const { classNames, cleanedProps } = useStyles('Collapsible', props);
return (
<CollapsiblePrimitive.Root
ref={ref}
className={clsx(classNames.root, styles[classNames.root], className)}
{...cleanedProps}
/>
);
});
CollapsibleRoot.displayName = CollapsiblePrimitive.Root.displayName;
const CollapsibleTrigger = forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Trigger>
>(({ className, ...props }, ref) => {
const { classNames, cleanedProps } = useStyles('Collapsible', props);
return (
<CollapsiblePrimitive.Trigger
ref={ref}
className={clsx(
classNames.trigger,
styles[classNames.trigger],
className,
)}
{...cleanedProps}
/>
);
});
CollapsibleTrigger.displayName = CollapsiblePrimitive.Trigger.displayName;
const CollapsiblePanel = forwardRef<
React.ElementRef<typeof CollapsiblePrimitive.Panel>,
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.Panel>
>(({ className, ...props }, ref) => {
const { classNames, cleanedProps } = useStyles('Collapsible', props);
return (
<CollapsiblePrimitive.Panel
ref={ref}
className={clsx(classNames.panel, styles[classNames.panel], className)}
{...cleanedProps}
/>
);
});
CollapsiblePanel.displayName = CollapsiblePrimitive.Panel.displayName;
/**
* Collapsible is a component that allows you to collapse and expand content.
* It is a wrapper around the CollapsiblePrimitive component from base-ui-components.
*
* @public
*/
export const Collapsible = {
Root: CollapsibleRoot,
Trigger: CollapsibleTrigger,
Panel: CollapsiblePanel,
};
+1 -1
View File
@@ -30,7 +30,7 @@ export * from './components/Container';
export * from './components/Avatar';
export * from './components/Button';
export * from './components/Card';
export * from './components/Collapsible';
export * from './components/Accordion';
export * from './components/Dialog';
export * from './components/FieldLabel';
export * from './components/Header';
+12 -7
View File
@@ -96,13 +96,6 @@ export const componentDefinitions = {
selected: [true, false] as const,
},
},
Collapsible: {
classNames: {
root: 'bui-CollapsibleRoot',
trigger: 'bui-CollapsibleTrigger',
panel: 'bui-CollapsiblePanel',
},
},
Container: {
classNames: {
root: 'bui-Container',
@@ -119,6 +112,18 @@ export const componentDefinitions = {
footer: 'bui-DialogFooter',
},
},
Accordion: {
classNames: {
root: 'bui-Accordion',
trigger: 'bui-AccordionTrigger',
triggerButton: 'bui-AccordionTriggerButton',
triggerTitle: 'bui-AccordionTriggerTitle',
triggerSubtitle: 'bui-AccordionTriggerSubtitle',
triggerIcon: 'bui-AccordionTriggerIcon',
panel: 'bui-AccordionPanel',
group: 'bui-AccordionGroup',
},
},
FieldError: {
classNames: {
root: 'bui-FieldError',