chore: wip ToggleButton/ToggleButtonGroup

Signed-off-by: Gabriel Dugny <gabriel.dugny@believe.com>
This commit is contained in:
Gabriel Dugny
2025-12-24 16:18:40 +01:00
parent 6fc00e6804
commit 1fc2c6c591
20 changed files with 1256 additions and 0 deletions
+115
View File
@@ -0,0 +1,115 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import {
toggleButtonGroupPropDefs,
toggleButtonGroupUsageSnippet,
toggleButtonGroupSingleSnippet,
toggleButtonGroupMultipleSnippet,
toggleButtonGroupVerticalSnippet,
toggleButtonGroupDisabledSnippet,
toggleButtonGroupDisallowEmptySnippet,
toggleButtonGroupIconsSnippet,
toggleButtonGroupIconsOnlySnippet,
} from './toggle-button-group.props';
import { ToggleButtonGroupSnippet } from '@/snippets/stories-snippets';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { ToggleButtonGroupDefinition } from '../utils/definitions';
<PageTitle
title="ToggleButtonGroup"
description="A grouping container for related ToggleButtons supporting single or multiple selection."
/>
<Snippet
align="center"
py={4}
preview={<ToggleButtonGroupSnippet story="SingleSelection" />}
code={toggleButtonGroupSingleSnippet}
/>
## Usage
<CodeBlock code={toggleButtonGroupUsageSnippet} />
## API reference
<PropsTable data={toggleButtonGroupPropDefs} />
## Examples
### Single Selection
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonGroupSnippet story="SingleSelection" />}
code={toggleButtonGroupSingleSnippet}
/>
### Multiple Selection
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonGroupSnippet story="MultipleSelection" />}
code={toggleButtonGroupMultipleSnippet}
/>
### Icons and Text
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonGroupSnippet story="WithIcons" />}
code={toggleButtonGroupIconsSnippet}
/>
### Icons Only
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonGroupSnippet story="IconsOnly" />}
code={toggleButtonGroupIconsOnlySnippet}
/>
### Disallow Empty Selection
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonGroupSnippet story="DisallowEmptySelection" />}
code={toggleButtonGroupDisallowEmptySnippet}
/>
### Vertical Orientation
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonGroupSnippet story="Orientation" />}
code={toggleButtonGroupVerticalSnippet}
/>
### Disabled
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonGroupSnippet story="DisabledGroup" />}
code={toggleButtonGroupDisabledSnippet}
/>
<Theming definition={ToggleButtonGroupDefinition} />
<ChangelogComponent component="toggle-button-group" />
@@ -0,0 +1,75 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
export const toggleButtonGroupPropDefs: Record<string, PropDef> = {
selectionMode: {
type: 'enum',
values: ['single', 'multiple'],
default: 'single',
},
orientation: {
type: 'enum',
values: ['horizontal', 'vertical'],
default: 'horizontal',
responsive: true,
},
selectedKeys: { type: 'enum', values: ['Iterable<Key>'] },
defaultSelectedKeys: { type: 'enum', values: ['Iterable<Key>'] },
onSelectionChange: { type: 'enum', values: ['(keys) => void'] },
isDisabled: { type: 'boolean', default: 'false' },
disallowEmptySelection: { type: 'boolean', default: 'false' },
...classNamePropDefs,
...stylePropDefs,
};
export const toggleButtonGroupUsageSnippet = `import { ToggleButtonGroup, ToggleButton } from '@backstage/ui';
<ToggleButtonGroup selectionMode="single">
<ToggleButton id="dogs">Dogs</ToggleButton>
<ToggleButton id="cats">Cats</ToggleButton>
</ToggleButtonGroup>`;
export const toggleButtonGroupSingleSnippet = `<ToggleButtonGroup selectionMode="single" defaultSelectedKeys={['apples']}>
<ToggleButton id="apples">Apples</ToggleButton>
<ToggleButton id="oranges">Oranges</ToggleButton>
<ToggleButton id="bananas">Bananas</ToggleButton>
</ToggleButtonGroup>`;
export const toggleButtonGroupMultipleSnippet = `<ToggleButtonGroup selectionMode="multiple" defaultSelectedKeys={['frontend']}>
<ToggleButton id="frontend">Frontend</ToggleButton>
<ToggleButton id="backend">Backend</ToggleButton>
<ToggleButton id="platform">Platform</ToggleButton>
</ToggleButtonGroup>`;
export const toggleButtonGroupVerticalSnippet = `<ToggleButtonGroup selectionMode="single" orientation="vertical">
<ToggleButton id="low">Low</ToggleButton>
<ToggleButton id="medium">Medium</ToggleButton>
<ToggleButton id="high">High</ToggleButton>
</ToggleButtonGroup>`;
export const toggleButtonGroupDisabledSnippet = `<ToggleButtonGroup selectionMode="single" isDisabled>
<ToggleButton id="cat">Cat</ToggleButton>
<ToggleButton id="dog">Dog</ToggleButton>
<ToggleButton id="bird">Bird</ToggleButton>
</ToggleButtonGroup>`;
export const toggleButtonGroupDisallowEmptySnippet = `<ToggleButtonGroup selectionMode="single" disallowEmptySelection defaultSelectedKeys={['one']}>
<ToggleButton id="one">One</ToggleButton>
<ToggleButton id="two">Two</ToggleButton>
<ToggleButton id="three">Three</ToggleButton>
</ToggleButtonGroup>`;
export const toggleButtonGroupIconsSnippet = `<ToggleButtonGroup selectionMode="single" defaultSelectedKeys={['cloud']}>
<ToggleButton id="cloud" iconStart="cloud">Cloud</ToggleButton>
<ToggleButton id="starred" iconStart="starFill">Starred</ToggleButton>
<ToggleButton id="next" iconEnd="arrowRight">Next</ToggleButton>
</ToggleButtonGroup>`;
export const toggleButtonGroupIconsOnlySnippet = `<ToggleButtonGroup selectionMode="multiple" defaultSelectedKeys={['cloud']}>
<ToggleButton id="cloud" aria-label="Cloud" iconStart="cloud" />
<ToggleButton id="star" aria-label="Star" iconStart="starLine" />
<ToggleButton id="next" aria-label="Next" iconEnd="arrowRight" />
</ToggleButtonGroup>`;
+94
View File
@@ -0,0 +1,94 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import { ToggleButtonSnippet } from '@/snippets/stories-snippets';
import {
toggleButtonPropDefs,
toggleButtonUsageSnippet,
toggleButtonVariantsSnippet,
toggleButtonSizesSnippet,
toggleButtonIconsSnippet,
toggleButtonDisabledSnippet,
toggleButtonControlledSnippet,
} from './toggle-button.props';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { ToggleButtonDefinition } from '../utils/definitions';
<PageTitle
title="ToggleButton"
description="A button that toggles between selected and unselected states."
/>
<Snippet
align="center"
py={4}
preview={<ToggleButtonSnippet story="Variants" />}
code={toggleButtonVariantsSnippet}
/>
## Usage
<CodeBlock code={toggleButtonUsageSnippet} />
## API reference
<PropsTable data={toggleButtonPropDefs} />
## Examples
### Variants
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonSnippet story="Variants" />}
code={toggleButtonVariantsSnippet}
/>
### Sizes
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonSnippet story="Sizes" />}
code={toggleButtonSizesSnippet}
/>
### With Icons
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonSnippet story="WithIcons" />}
code={toggleButtonIconsSnippet}
/>
### Disabled
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonSnippet story="Disabled" />}
code={toggleButtonDisabledSnippet}
/>
### Controlled
<Snippet
align="center"
py={4}
open
preview={<ToggleButtonSnippet story="Controlled" />}
code={toggleButtonControlledSnippet}
/>
<Theming definition={ToggleButtonDefinition} />
<ChangelogComponent component="toggle-button" />
@@ -0,0 +1,59 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
export const toggleButtonPropDefs: Record<string, PropDef> = {
variant: {
type: 'enum',
values: ['primary', 'secondary'],
default: 'primary',
responsive: true,
},
size: {
type: 'enum',
values: ['small', 'medium'],
default: 'small',
responsive: true,
},
iconStart: { type: 'enum', values: ['ReactNode'] },
iconEnd: { type: 'enum', values: ['ReactNode'] },
isSelected: { type: 'boolean' },
defaultSelected: { type: 'boolean' },
onChange: { type: 'enum', values: ['(isSelected: boolean) => void'] },
isDisabled: { type: 'boolean', default: 'false' },
children: { type: 'enum', values: ['ReactNode'] },
...classNamePropDefs,
...stylePropDefs,
};
export const toggleButtonUsageSnippet = `import { ToggleButton } from '@backstage/ui';
<ToggleButton>Toggle</ToggleButton>`;
export const toggleButtonVariantsSnippet = `<Flex align="center">
<ToggleButton variant="primary">Primary</ToggleButton>
<ToggleButton variant="secondary">Secondary</ToggleButton>
</Flex>`;
export const toggleButtonSizesSnippet = `<Flex align="center">
<ToggleButton size="small">Small</ToggleButton>
<ToggleButton size="medium">Medium</ToggleButton>
</Flex>`;
export const toggleButtonIconsSnippet = `<Flex align="center">
<ToggleButton iconStart="star">Favorite</ToggleButton>
<ToggleButton iconEnd="check">Confirm</ToggleButton>
</Flex>`;
export const toggleButtonDisabledSnippet = `<Flex align="center">
<ToggleButton isDisabled>Disabled</ToggleButton>
<ToggleButton defaultSelected isDisabled>Selected</ToggleButton>
</Flex>`;
export const toggleButtonControlledSnippet = `const [selected, setSelected] = useState(false);
<ToggleButton isSelected={selected} onChange={setSelected}>
{selected ? 'On' : 'Off'}
</ToggleButton>`;
@@ -30,6 +30,8 @@ import * as TableStories from '../../../packages/ui/src/components/Table/stories
import * as TagGroupStories from '../../../packages/ui/src/components/TagGroup/TagGroup.stories';
import * as PasswordFieldStories from '../../../packages/ui/src/components/PasswordField/PasswordField.stories';
import * as VisuallyHiddenStories from '../../../packages/ui/src/components/VisuallyHidden/VisuallyHidden.stories';
import * as ToggleButtonStories from '../../../packages/ui/src/components/ToggleButton/ToggleButton.stories';
import * as ToggleButtonGroupStories from '../../../packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories';
// Helper function to create snippet components
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -75,6 +77,10 @@ export const HeaderSnippet = createSnippetComponent(HeaderStories);
export const HeaderPageSnippet = createSnippetComponent(HeaderPageStories);
export const TableSnippet = createSnippetComponent(TableStories);
export const TagGroupSnippet = createSnippetComponent(TagGroupStories);
export const ToggleButtonSnippet = createSnippetComponent(ToggleButtonStories);
export const ToggleButtonGroupSnippet = createSnippetComponent(
ToggleButtonGroupStories,
);
export const VisuallyHiddenSnippet = createSnippetComponent(
VisuallyHiddenStories,
);
+8
View File
@@ -116,6 +116,14 @@ export const components: Page[] = [
title: 'TextField',
slug: 'text-field',
},
{
title: 'ToggleButton',
slug: 'toggle-button',
},
{
title: 'ToggleButtonGroup',
slug: 'toggle-button-group',
},
{
title: 'Tooltip',
slug: 'tooltip',
@@ -0,0 +1,106 @@
/*
* 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-ToggleButton {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--bui-space-1_5);
border: none;
border-radius: var(--bui-radius-2);
background: var(--bui-bg-surface-1);
color: var(--bui-fg-primary);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-bold);
padding: 0 var(--bui-space-2);
cursor: pointer;
transition: background-color 150ms ease, box-shadow 150ms ease,
color 150ms ease, transform 100ms ease;
}
.bui-ToggleButton[data-variant='secondary'] {
background: transparent;
color: var(--bui-fg-primary);
box-shadow: inset 0 0 0 1px var(--bui-border);
}
.bui-ToggleButton[data-variant='primary'][data-selected],
.bui-ToggleButton[data-variant='primary'][data-pressed] {
background: var(--bui-bg-solid);
color: var(--bui-fg-solid);
}
.bui-ToggleButton[data-variant='primary']:not([data-selected])[data-hovered] {
background: var(--bui-bg-surface-2);
}
.bui-ToggleButton[data-variant='secondary'][data-selected],
.bui-ToggleButton[data-variant='secondary'][data-pressed] {
background: var(--bui-bg-surface-2);
box-shadow: inset 0 0 0 1px var(--bui-border-hover);
}
.bui-ToggleButton[data-focus-visible] {
outline: 2px solid var(--bui-ring);
outline-offset: 2px;
}
.bui-ToggleButton[data-disabled] {
cursor: not-allowed;
color: var(--bui-fg-disabled);
background: var(--bui-bg-disabled);
box-shadow: inset 0 0 0 1px var(--bui-border-disabled);
}
.bui-ToggleButton[data-pressed]:not([data-disabled]) {
transform: scale(0.98);
}
.bui-ToggleButton[data-size='small'] {
height: 2rem;
font-size: var(--bui-font-size-3);
padding: 0 var(--bui-space-2);
svg {
width: 1rem;
height: 1rem;
}
}
.bui-ToggleButton[data-size='medium'] {
height: 2.5rem;
font-size: var(--bui-font-size-4);
padding: 0 var(--bui-space-3);
svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bui-ToggleButtonContent {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--bui-space-1_5);
height: 100%;
width: 100%;
}
}
@@ -0,0 +1,226 @@
/*
* 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 preview from '../../../../../.storybook/preview';
import { ToggleButton } from './ToggleButton';
import { ToggleButtonGroup } from '../ToggleButtonGroup/ToggleButtonGroup';
import { Flex } from '../Flex';
import { Text } from '../Text';
import { useState } from 'react';
import {
RiCheckLine,
RiHeartLine,
RiStarFill,
RiStarLine,
RiCloudLine,
RiArrowRightSLine,
} from '@remixicon/react';
const meta = preview.meta({
title: 'Backstage UI/ToggleButton',
component: ToggleButton,
argTypes: {
size: {
control: 'select',
options: ['small', 'medium'],
},
variant: {
control: 'select',
options: ['primary', 'secondary'],
},
},
});
export const Default = meta.story({
args: {
children: 'Toggle',
},
});
export const Variants = meta.story({
args: {
children: 'Toggle',
},
parameters: {
argTypes: {
variant: {
control: false,
},
},
},
render: () => (
<Flex align="center">
<ToggleButton variant="primary">Primary</ToggleButton>
<ToggleButton variant="secondary">Secondary</ToggleButton>
</Flex>
),
});
export const Sizes = meta.story({
args: {
children: 'Toggle',
},
parameters: {
argTypes: {
size: {
control: false,
},
},
},
render: () => (
<Flex align="center">
<ToggleButton size="small">Small</ToggleButton>
<ToggleButton size="medium">Medium</ToggleButton>
</Flex>
),
});
export const WithIcons = meta.story({
args: {
children: 'Favorite',
},
render: args => (
<Flex align="center">
<ToggleButton {...args} iconStart={<RiStarLine />} />
<ToggleButton {...args} iconStart={<RiStarFill />} defaultSelected />
<ToggleButton {...args} iconEnd={<RiCheckLine />} />
<ToggleButton iconEnd={<RiCheckLine />} />
</Flex>
),
});
export const IconsOnly = meta.story({
render: () => (
<Flex align="center">
<ToggleButton aria-label="Cloud" iconStart={<RiCloudLine />} />
<ToggleButton
aria-label="Starred"
defaultSelected
iconStart={<RiStarFill />}
/>
<ToggleButton aria-label="Next" iconStart={<RiArrowRightSLine />} />
</Flex>
),
});
export const IconsAndText = meta.story({
render: () => (
<Flex align="center">
<ToggleButton iconStart={<RiCloudLine />}>Cloud</ToggleButton>
<ToggleButton iconStart={<RiStarLine />}>Star</ToggleButton>
<ToggleButton iconEnd={<RiArrowRightSLine />}>Next</ToggleButton>
</Flex>
),
});
export const MixedIcons = meta.story({
render: () => (
<ToggleButtonGroup selectionMode="multiple" defaultSelectedKeys={['cloud']}>
<ToggleButton id="cloud" iconStart={<RiCloudLine />}>
Cloud
</ToggleButton>
<ToggleButton id="star" iconStart={<RiStarLine />} />
<ToggleButton id="arrow" iconEnd={<RiArrowRightSLine />}>
Next
</ToggleButton>
</ToggleButtonGroup>
),
});
export const Disabled = meta.story({
render: () => (
<Flex align="center">
<ToggleButton variant="primary" isDisabled>
Primary
</ToggleButton>
<ToggleButton variant="secondary" isDisabled>
Secondary
</ToggleButton>
<ToggleButton variant="primary" defaultSelected isDisabled>
Selected
</ToggleButton>
</Flex>
),
});
export const Controlled = meta.story({
render: () => {
const [selected, setSelected] = useState(false);
return (
<Flex direction="column" gap="3">
<ToggleButton
aria-label="Star"
isSelected={selected}
onChange={setSelected}
iconStart={selected ? <RiStarFill /> : <RiStarLine />}
>
{selected ? 'Starred' : 'Not starred'}
</ToggleButton>
<Text>State: {selected ? 'selected' : 'unselected'}</Text>
</Flex>
);
},
});
export const GroupSingle = meta.story({
render: () => (
<ToggleButtonGroup selectionMode="single" defaultSelectedKeys={['apples']}>
<ToggleButton id="apples">Apples</ToggleButton>
<ToggleButton id="oranges">Oranges</ToggleButton>
<ToggleButton id="bananas">Bananas</ToggleButton>
</ToggleButtonGroup>
),
});
export const GroupMultiple = meta.story({
render: () => (
<ToggleButtonGroup selectionMode="multiple" defaultSelectedKeys={['dogs']}>
<ToggleButton id="dogs">Dogs</ToggleButton>
<ToggleButton id="cats">Cats</ToggleButton>
<ToggleButton id="rabbits">Rabbits</ToggleButton>
</ToggleButtonGroup>
),
});
export const GroupWithIcons = meta.story({
render: () => (
<ToggleButtonGroup
selectionMode="multiple"
defaultSelectedKeys={['chill']}
orientation="horizontal"
>
<ToggleButton id="chill" iconStart={<RiHeartLine />}>
Chill
</ToggleButton>
<ToggleButton id="focus" iconStart={<RiCheckLine />}>
Focus
</ToggleButton>
<ToggleButton id="party" iconStart={<RiStarLine />}>
Party
</ToggleButton>
</ToggleButtonGroup>
),
});
export const VerticalGroup = meta.story({
render: () => (
<ToggleButtonGroup selectionMode="single" orientation="vertical">
<ToggleButton id="low">Low</ToggleButton>
<ToggleButton id="medium">Medium</ToggleButton>
<ToggleButton id="high">High</ToggleButton>
</ToggleButtonGroup>
),
});
@@ -0,0 +1,59 @@
/*
* 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 clsx from 'clsx';
import { forwardRef, Ref } from 'react';
import { ToggleButton as AriaToggleButton } from 'react-aria-components';
import type { ToggleButtonProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { ToggleButtonDefinition } from './definition';
import styles from './ToggleButton.module.css';
/** @public */
export const ToggleButton = forwardRef(
(props: ToggleButtonProps, ref: Ref<HTMLButtonElement>) => {
const { classNames, dataAttributes, cleanedProps } = useStyles(
ToggleButtonDefinition,
{
size: 'small',
variant: 'primary',
...props,
},
);
const { children, className, iconStart, iconEnd, ...rest } = cleanedProps;
return (
<AriaToggleButton
className={clsx(classNames.root, styles[classNames.root], className)}
ref={ref}
{...dataAttributes}
{...rest}
>
<span
className={clsx(classNames.content, styles[classNames.content])}
data-slot="content"
>
{iconStart}
{children}
{iconEnd}
</span>
</AriaToggleButton>
);
},
);
ToggleButton.displayName = 'ToggleButton';
@@ -0,0 +1,32 @@
/*
* 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 { ComponentDefinition } from '../../types';
/**
* Component definition for ToggleButton
* @public
*/
export const ToggleButtonDefinition = {
classNames: {
root: 'bui-ToggleButton',
content: 'bui-ToggleButtonContent',
},
dataAttributes: {
size: ['small', 'medium'] as const,
variant: ['primary', 'secondary'] as const,
},
} as const satisfies ComponentDefinition;
@@ -0,0 +1,19 @@
/*
* 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.
*/
export * from './ToggleButton';
export * from './types';
export { ToggleButtonDefinition } from './definition';
@@ -0,0 +1,35 @@
/*
* 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 { Breakpoint } from '../..';
import type { ReactElement, ReactNode } from 'react';
import type { ToggleButtonProps as AriaToggleButtonProps } from 'react-aria-components';
/**
* Properties for {@link ToggleButton}
*
* @public
*/
export interface ToggleButtonProps extends AriaToggleButtonProps {
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint, 'primary' | 'secondary'>>;
iconStart?: ReactElement;
iconEnd?: ReactElement;
children?: ReactNode;
}
@@ -0,0 +1,112 @@
/*
* 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-ToggleButtonGroup {
display: inline-flex;
align-items: center;
flex-wrap: nowrap;
border-radius: var(--bui-radius-2);
overflow: hidden;
box-shadow: inset 0 0 0 1px var(--bui-border);
}
.bui-ToggleButtonGroup[data-orientation='vertical'] {
flex-direction: column;
align-items: stretch;
width: fit-content;
}
.bui-ToggleButtonGroup > :global(.bui-ToggleButton) {
border-radius: 0;
margin: 0;
box-shadow: none;
border: 0;
}
.bui-ToggleButtonGroup :global(.bui-ToggleButton)[data-variant='secondary'] {
box-shadow: none;
}
/* Horizontal radius rules (default orientation) */
.bui-ToggleButtonGroup:not([data-orientation='vertical'])
> :global(.bui-ToggleButton) {
border-radius: 0;
}
.bui-ToggleButtonGroup:not([data-orientation='vertical'])
> :global(.bui-ToggleButton):first-child {
border-radius: var(--bui-radius-2) 0 0 var(--bui-radius-2);
}
.bui-ToggleButtonGroup:not([data-orientation='vertical'])
> :global(.bui-ToggleButton):last-child {
border-radius: 0 var(--bui-radius-2) var(--bui-radius-2) 0;
}
.bui-ToggleButtonGroup:not([data-orientation='vertical'])
> :global(.bui-ToggleButton):only-child {
border-radius: var(--bui-radius-2);
}
/* Horizontal dividers */
.bui-ToggleButtonGroup:not([data-orientation='vertical'])
:global(.bui-ToggleButton)
+ :global(.bui-ToggleButton) {
border-left: 1px solid var(--bui-border);
}
/* Vertical dividers */
.bui-ToggleButtonGroup[data-orientation='vertical']
:global(.bui-ToggleButton) {
width: 100%;
}
.bui-ToggleButtonGroup[data-orientation='vertical']
:global(.bui-ToggleButton)
+ :global(.bui-ToggleButton) {
border-top: 1px solid var(--bui-border);
}
/* Vertical radius rules */
.bui-ToggleButtonGroup[data-orientation='vertical']
> :global(.bui-ToggleButton) {
border-radius: 0;
}
.bui-ToggleButtonGroup[data-orientation='vertical']
> :global(.bui-ToggleButton):first-child {
border-radius: var(--bui-radius-2) var(--bui-radius-2) 0 0;
}
.bui-ToggleButtonGroup[data-orientation='vertical']
> :global(.bui-ToggleButton):last-child {
border-radius: 0 0 var(--bui-radius-2) var(--bui-radius-2);
}
.bui-ToggleButtonGroup[data-orientation='vertical']
> :global(.bui-ToggleButton):only-child {
border-radius: var(--bui-radius-2);
}
/* Focus ring on group surface */
.bui-ToggleButtonGroup:focus-visible {
outline: 2px solid var(--bui-ring);
outline-offset: 2px;
}
}
@@ -0,0 +1,179 @@
/*
* 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 preview from '../../../../../.storybook/preview';
import { ToggleButtonGroup } from './ToggleButtonGroup';
import { ToggleButton } from '../ToggleButton/ToggleButton';
import { Flex } from '../Flex';
import { Text } from '../Text';
import { useState } from 'react';
import {
RiCloudLine,
RiStarLine,
RiStarFill,
RiArrowRightSLine,
} from '@remixicon/react';
const meta = preview.meta({
title: 'Backstage UI/ToggleButtonGroup',
component: ToggleButtonGroup,
argTypes: {
selectionMode: {
control: 'select',
options: ['single', 'multiple'],
},
orientation: {
control: 'select',
options: ['horizontal', 'vertical'],
},
},
});
export const SingleSelection = meta.story({
args: {
selectionMode: 'single',
defaultSelectedKeys: ['dogs'],
},
render: args => (
<ToggleButtonGroup {...args}>
<ToggleButton id="dogs">Dogs</ToggleButton>
<ToggleButton id="cats">Cats</ToggleButton>
<ToggleButton id="birds">Birds</ToggleButton>
</ToggleButtonGroup>
),
});
export const MultipleSelection = meta.story({
args: {
selectionMode: 'multiple',
defaultSelectedKeys: ['frontend'],
},
render: args => (
<ToggleButtonGroup {...args}>
<ToggleButton id="frontend">Frontend</ToggleButton>
<ToggleButton id="backend">Backend</ToggleButton>
<ToggleButton id="platform">Platform</ToggleButton>
</ToggleButtonGroup>
),
});
export const DisabledGroup = meta.story({
args: {
selectionMode: 'single',
isDisabled: true,
},
render: args => (
<ToggleButtonGroup {...args}>
<ToggleButton id="cat">Cat</ToggleButton>
<ToggleButton id="dog">Dog</ToggleButton>
<ToggleButton id="bird">Bird</ToggleButton>
</ToggleButtonGroup>
),
});
export const DisallowEmptySelection = meta.story({
args: {
selectionMode: 'single',
disallowEmptySelection: true,
defaultSelectedKeys: ['one'],
},
render: args => (
<ToggleButtonGroup {...args}>
<ToggleButton id="one">One</ToggleButton>
<ToggleButton id="two">Two</ToggleButton>
<ToggleButton id="three">Three</ToggleButton>
</ToggleButtonGroup>
),
});
export const MixedDisabled = meta.story({
render: () => (
<ToggleButtonGroup selectionMode="single">
<ToggleButton id="one">One</ToggleButton>
<ToggleButton id="two" isDisabled>
Two
</ToggleButton>
<ToggleButton id="three">Three</ToggleButton>
</ToggleButtonGroup>
),
});
export const Orientation = meta.story({
args: {
orientation: 'vertical',
},
render: args => (
<ToggleButtonGroup {...args} selectionMode="single">
<ToggleButton id="morning">Morning</ToggleButton>
<ToggleButton id="afternoon">Afternoon</ToggleButton>
<ToggleButton id="evening">Evening</ToggleButton>
</ToggleButtonGroup>
),
});
export const ControlledGroup = meta.story({
render: () => {
const [selectedKeys, setSelectedKeys] = useState(new Set(['beta']));
return (
<Flex direction="column" gap="3">
<ToggleButtonGroup
selectionMode="single"
selectedKeys={selectedKeys}
onSelectionChange={keys => setSelectedKeys(new Set(keys))}
>
<ToggleButton id="alpha">Alpha</ToggleButton>
<ToggleButton id="beta">Beta</ToggleButton>
<ToggleButton id="gamma">Gamma</ToggleButton>
</ToggleButtonGroup>
<Text>Selected: {[...selectedKeys].join(', ') || 'none'}</Text>
</Flex>
);
},
});
export const WithIcons = meta.story({
args: {
selectionMode: 'single',
},
render: () => (
<ToggleButtonGroup selectionMode="multiple" defaultSelectedKeys={['cloud']}>
<ToggleButton id="cloud" aria-label="Cloud" iconStart={<RiCloudLine />} />
<ToggleButton
id="starred"
aria-label="Starred"
iconStart={<RiStarFill />}
/>
<ToggleButton id="star" iconStart={<RiStarLine />}>
Star
</ToggleButton>
<ToggleButton id="next" iconEnd={<RiArrowRightSLine />}>
Next
</ToggleButton>
</ToggleButtonGroup>
),
});
export const IconsOnly = meta.story({
render: () => (
<ToggleButtonGroup selectionMode="multiple" defaultSelectedKeys={['cloud']}>
<ToggleButton id="cloud" iconStart={<RiCloudLine />} />
<ToggleButton id="star" iconStart={<RiStarLine />} />
<ToggleButton id="next" iconEnd={<RiArrowRightSLine />} />
</ToggleButtonGroup>
),
});
@@ -0,0 +1,51 @@
/*
* 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 clsx from 'clsx';
import { forwardRef, Ref } from 'react';
import { ToggleButtonGroup as AriaToggleButtonGroup } from 'react-aria-components';
import type { ToggleButtonGroupProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import { ToggleButtonGroupDefinition } from './definition';
import styles from './ToggleButtonGroup.module.css';
/** @public */
export const ToggleButtonGroup = forwardRef(
(props: ToggleButtonGroupProps, ref: Ref<HTMLDivElement>) => {
const { classNames, dataAttributes, cleanedProps } = useStyles(
ToggleButtonGroupDefinition,
{
orientation: 'horizontal',
...props,
},
);
const { className, children, ...rest } = cleanedProps;
return (
<AriaToggleButtonGroup
className={clsx(classNames.root, styles[classNames.root], className)}
ref={ref}
{...dataAttributes}
{...rest}
>
{children}
</AriaToggleButtonGroup>
);
},
);
ToggleButtonGroup.displayName = 'ToggleButtonGroup';
@@ -0,0 +1,30 @@
/*
* 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 { ComponentDefinition } from '../../types';
/**
* Component definition for ToggleButtonGroup
* @public
*/
export const ToggleButtonGroupDefinition = {
classNames: {
root: 'bui-ToggleButtonGroup',
},
dataAttributes: {
orientation: ['horizontal', 'vertical'] as const,
},
} as const satisfies ComponentDefinition;
@@ -0,0 +1,19 @@
/*
* 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.
*/
export * from './ToggleButtonGroup';
export * from './types';
export { ToggleButtonGroupDefinition } from './definition';
@@ -0,0 +1,27 @@
/*
* 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 { Breakpoint } from '../..';
import type { ToggleButtonGroupProps as AriaToggleButtonGroupProps } from 'react-aria-components';
/** @public */
export interface ToggleButtonGroupProps
extends AriaToggleButtonGroupProps<object> {
orientation?:
| 'horizontal'
| 'vertical'
| Partial<Record<Breakpoint, 'horizontal' | 'vertical'>>;
}
+2
View File
@@ -47,6 +47,8 @@ export { SearchFieldDefinition } from './components/SearchField/definition';
export { SelectDefinition } from './components/Select/definition';
export { SkeletonDefinition } from './components/Skeleton/definition';
export { SwitchDefinition } from './components/Switch/definition';
export { ToggleButtonDefinition } from './components/ToggleButton/definition';
export { ToggleButtonGroupDefinition } from './components/ToggleButtonGroup/definition';
export { TableDefinition } from './components/Table/definition';
export { TablePaginationDefinition } from './components/TablePagination/definition';
export { TabsDefinition } from './components/Tabs/definition';
+2
View File
@@ -52,6 +52,8 @@ export * from './components/Link';
export * from './components/Select';
export * from './components/Skeleton';
export * from './components/Switch';
export * from './components/ToggleButton';
export * from './components/ToggleButtonGroup';
export * from './components/VisuallyHidden';
// Types