feat(ui): migrate Checkbox component to React Aria

Migrates the Checkbox component from Base UI Components to React Aria Components.

Breaking changes:
- Props renamed to React Aria conventions (checked → isSelected, disabled → isDisabled, etc.)
- Label prop removed - use children instead
- CSS class bui-CheckboxLabel removed
- Data attribute changed from data-checked to data-selected
- Use without label is no longer supported

Migration example:
Before: <Checkbox label="Accept terms" checked={agreed} onChange={setAgreed} />
After: <Checkbox isSelected={agreed} onChange={setAgreed}>Accept terms</Checkbox>

Changes include:
- Updated TypeScript types and component implementation
- Migrated CSS to use React Aria data attributes ([data-selected], [data-disabled], etc.)
- Updated Storybook stories and documentation
- Fixed CSS structure to properly separate label wrapper and checkbox indicator styles
- Updated component definitions and API reports
- Created changeset with migration guide

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-10-23 16:34:51 +02:00
parent 7cbfbe9b33
commit 5c614fff85
10 changed files with 160 additions and 142 deletions
+58
View File
@@ -0,0 +1,58 @@
---
'@backstage/ui': minor
---
**BREAKING**: Migrated Checkbox component from Base UI to React Aria Components.
API changes required:
- `checked``isSelected`
- `defaultChecked``defaultSelected`
- `disabled``isDisabled`
- `required``isRequired`
- `label` prop removed - use `children` instead
- CSS: `bui-CheckboxLabel` class removed
- Data attribute: `data-checked``data-selected`
- Use without label is no longer supported
Migration examples:
Before:
```tsx
<Checkbox label="Accept terms" checked={agreed} onChange={setAgreed} />
```
After:
```tsx
<Checkbox isSelected={agreed} onChange={setAgreed}>
Accept terms
</Checkbox>
```
Before:
```tsx
<Checkbox label="Option" disabled />
```
After:
```tsx
<Checkbox isDisabled>Option</Checkbox>
```
Before:
```tsx
<Checkbox />
```
After:
```tsx
<Checkbox>
<VisuallyHidden>Accessible label</VisuallyHidden>
</Checkbox>
```
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-mui-to-bui': patch
---
Updated BUI checkbox preview example to align with new component API.
@@ -5,31 +5,32 @@ import {
} from '@/utils/propDefs';
export const checkboxPropDefs: Record<string, PropDef> = {
label: {
type: 'string',
responsive: false,
},
defaultChecked: {
children: {
type: 'enum',
values: ['boolean', "'indeterminate'"],
values: ['React.ReactNode'],
responsive: false,
},
checked: {
type: 'enum',
values: ['boolean', "'indeterminate'"],
responsive: false,
},
onChange: {
type: 'enum',
values: ["(checked: boolean | 'indeterminate') => void"],
responsive: false,
},
disabled: {
isSelected: {
type: 'enum',
values: ['boolean'],
responsive: false,
},
required: {
defaultSelected: {
type: 'enum',
values: ['boolean'],
responsive: false,
},
onChange: {
type: 'enum',
values: ['(isSelected: boolean) => void'],
responsive: false,
},
isDisabled: {
type: 'enum',
values: ['boolean'],
responsive: false,
},
isRequired: {
type: 'enum',
values: ['boolean'],
responsive: false,
@@ -48,13 +49,13 @@ export const checkboxPropDefs: Record<string, PropDef> = {
export const checkboxUsageSnippet = `import { Checkbox } from '@backstage/ui';
<Checkbox />`;
<Checkbox>Accept terms</Checkbox>`;
export const checkboxDefaultSnippet = `<Checkbox label="Accept terms and conditions" />`;
export const checkboxDefaultSnippet = `<Checkbox>Accept terms and conditions</Checkbox>`;
export const checkboxVariantsSnippet = `<Inline alignY="center">
<Checkbox />
<Checkbox checked />
<Checkbox label="Checkbox" />
<Checkbox label="Checkbox" checked />
</Inline>`;
export const checkboxVariantsSnippet = `<Flex direction="column" gap="2">
<Checkbox>Unchecked</Checkbox>
<Checkbox isSelected>Checked</Checkbox>
<Checkbox isDisabled>Disabled</Checkbox>
<Checkbox isSelected isDisabled>Checked & Disabled</Checkbox>
</Flex>`;
+6 -24
View File
@@ -6,6 +6,7 @@
import { Avatar as Avatar_2 } from '@base-ui-components/react/avatar';
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';
@@ -270,31 +271,13 @@ export interface CellProps extends CellProps_2 {
// @public (undocumented)
export const Checkbox: ForwardRefExoticComponent<
CheckboxProps & RefAttributes<HTMLButtonElement>
CheckboxProps & RefAttributes<HTMLLabelElement>
>;
// @public (undocumented)
export interface CheckboxProps {
export interface CheckboxProps extends CheckboxProps_2 {
// (undocumented)
checked?: boolean;
// (undocumented)
className?: string;
// (undocumented)
defaultChecked?: boolean;
// (undocumented)
disabled?: boolean;
// (undocumented)
label?: string;
// (undocumented)
name?: string;
// (undocumented)
onChange?: (checked: boolean) => void;
// (undocumented)
required?: boolean;
// (undocumented)
style?: React.CSSProperties;
// (undocumented)
value?: string;
children: React.ReactNode;
}
// @public
@@ -431,12 +414,11 @@ export const componentDefinitions: {
};
readonly Checkbox: {
readonly classNames: {
readonly root: 'bui-CheckboxRoot';
readonly label: 'bui-CheckboxLabel';
readonly root: 'bui-Checkbox';
readonly indicator: 'bui-CheckboxIndicator';
};
readonly dataAttributes: {
readonly checked: readonly [true, false];
readonly selected: readonly [true, false];
};
};
readonly Collapsible: {
@@ -17,35 +17,7 @@
@layer tokens, base, components, utilities;
@layer components {
.bui-CheckboxRoot {
border: none;
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
box-shadow: inset 0 0 0 1px var(--bui-border);
cursor: pointer;
border-radius: 2px;
transition: background-color 0.2s ease-in-out;
background-color: var(--bui-bg-surface-1);
padding: 0;
flex-shrink: 0;
}
.bui-CheckboxRoot:focus-visible {
transition: none;
outline: 2px solid var(--bui-ring);
outline-offset: 2px;
}
.bui-CheckboxRoot[data-checked] {
background-color: var(--bui-bg-solid);
box-shadow: none;
color: var(--bui-fg-solid);
}
.bui-CheckboxLabel {
.bui-Checkbox {
display: flex;
flex-direction: row;
align-items: center;
@@ -55,18 +27,48 @@
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-primary);
user-select: none;
cursor: pointer;
}
&:hover {
& .bui-CheckboxRoot:not([data-checked]) {
box-shadow: inset 0 0 0 1px var(--bui-border-hover);
.bui-Checkbox[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.bui-CheckboxIndicator {
border: none;
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
box-shadow: inset 0 0 0 1px var(--bui-border);
border-radius: 2px;
transition: background-color 0.2s ease-in-out;
background-color: var(--bui-bg-surface-1);
padding: 0;
flex-shrink: 0;
color: var(--bui-fg-solid);
@media (prefers-reduced-motion: reduce) {
& {
transition: none;
}
}
}
.bui-CheckboxIndicator {
display: flex;
align-items: center;
justify-content: center;
color: var(--bui-fg-solid);
.bui-Checkbox[data-focus-visible] .bui-CheckboxIndicator {
transition: none;
outline: 2px solid var(--bui-ring);
outline-offset: 2px;
}
.bui-Checkbox[data-selected] .bui-CheckboxIndicator {
background-color: var(--bui-bg-solid);
box-shadow: none;
}
.bui-Checkbox[data-hovered]:not([data-selected]) .bui-CheckboxIndicator {
box-shadow: inset 0 0 0 1px var(--bui-border-hover);
}
}
@@ -17,7 +17,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Checkbox } from './Checkbox';
import { Flex } from '../Flex';
import { Text } from '../Text';
const meta = {
title: 'Backstage UI/Checkbox',
@@ -29,31 +28,20 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'Accept terms and conditions',
children: 'Accept terms and conditions',
},
};
export const AllVariants: Story = {
...Default,
render: () => (
<Flex align="center">
<Checkbox />
<Checkbox checked />
<Checkbox label="Checkbox" />
<Checkbox label="Checkbox" checked />
</Flex>
),
};
export const Playground: Story = {
render: () => (
<Flex>
<Text>All variants</Text>
<Flex align="center">
<Checkbox />
<Checkbox checked />
<Checkbox label="Checkbox" />
<Checkbox label="Checkbox" checked />
</Flex>
<Flex direction="column" gap="2">
<Checkbox>Unchecked</Checkbox>
<Checkbox isSelected>Checked</Checkbox>
<Checkbox isDisabled>Disabled</Checkbox>
<Checkbox isSelected isDisabled>
Checked & Disabled
</Checkbox>
</Flex>
),
};
@@ -15,7 +15,7 @@
*/
import { forwardRef } from 'react';
import { Checkbox as CheckboxPrimitive } from '@base-ui-components/react/checkbox';
import { Checkbox as RACheckbox } from 'react-aria-components';
import type { CheckboxProps } from './types';
import { useStyles } from '../../hooks/useStyles';
import clsx from 'clsx';
@@ -23,33 +23,24 @@ import styles from './Checkbox.module.css';
import { RiCheckLine } from '@remixicon/react';
/** @public */
export const Checkbox = forwardRef<HTMLButtonElement, CheckboxProps>(
export const Checkbox = forwardRef<HTMLLabelElement, CheckboxProps>(
(props, ref) => {
const { classNames, cleanedProps } = useStyles('Checkbox', props);
const { label, onChange, className, ...rest } = cleanedProps;
const { classNames } = useStyles('Checkbox');
const { className, children, ...rest } = props;
const checkboxElement = (
<CheckboxPrimitive.Root
return (
<RACheckbox
ref={ref}
className={clsx(classNames.root, styles[classNames.root], className)}
onCheckedChange={onChange}
{...rest}
>
<CheckboxPrimitive.Indicator
<div
className={clsx(classNames.indicator, styles[classNames.indicator])}
>
<RiCheckLine size={12} />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
return label ? (
<label className={clsx(classNames.label, styles[classNames.label])}>
{checkboxElement}
{label}
</label>
) : (
checkboxElement
</div>
{children}
</RACheckbox>
);
},
);
+3 -11
View File
@@ -13,17 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CheckboxProps as RACheckboxProps } from 'react-aria-components';
/** @public */
export interface CheckboxProps {
label?: string;
defaultChecked?: boolean;
checked?: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
required?: boolean;
className?: string;
name?: string;
value?: string;
style?: React.CSSProperties;
export interface CheckboxProps extends RACheckboxProps {
children: React.ReactNode;
}
@@ -89,12 +89,11 @@ export const componentDefinitions = {
},
Checkbox: {
classNames: {
root: 'bui-CheckboxRoot',
label: 'bui-CheckboxLabel',
root: 'bui-Checkbox',
indicator: 'bui-CheckboxIndicator',
},
dataAttributes: {
checked: [true, false] as const,
selected: [true, false] as const,
},
},
Collapsible: {
@@ -92,7 +92,7 @@ export function BuiThemePreview({ mode, styleObject }: IsolatedPreviewProps) {
{ value: 'option3', label: 'Option 3' },
]}
/>
<Checkbox label="Checkbox Option" />
<Checkbox>Checkbox Option</Checkbox>
<RadioGroup label="Radio Group" orientation="horizontal">
<Radio value="option-1">Option 1</Radio>
<Radio value="option-2">Option 2</Radio>