Add RadioGroup + Radio

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-06-19 20:29:32 +01:00
parent 302f0c9c4a
commit 6910892548
12 changed files with 711 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/canon': patch
---
Add new `RadioGroup` + `Radio` component to Canon
+90
View File
@@ -591,6 +591,96 @@
overflow: hidden;
}
.canon-RadioGroup {
color: var(--canon-fg-primary);
flex-direction: column;
display: flex;
}
.canon-RadioGroup[data-orientation="horizontal"] .canon-RadioGroupContent {
gap: var(--canon-space-4);
flex-direction: row;
}
.canon-RadioGroupContent {
gap: var(--canon-space-2);
flex-direction: column;
display: flex;
}
.canon-Radio {
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-2);
color: var(--canon-fg-primary);
forced-color-adjust: none;
display: flex;
position: relative;
&:before {
content: "";
box-sizing: border-box;
border: .125rem solid var(--canon-border);
background: var(--canon-gray-1);
border-radius: var(--canon-radius-full);
width: 1rem;
height: 1rem;
transition: all .2s;
display: block;
}
&[data-pressed]:before {
border-color: var(--canon-border);
}
&[data-selected] {
&:before {
border-color: var(--canon-bg-solid);
border-width: .25rem;
}
&[data-pressed]:before {
border-color: var(--canon-bg-solid);
}
}
&[data-focus-visible]:before {
outline: 2px solid var(--canon-ring);
outline-offset: 2px;
}
&[data-disabled] {
cursor: not-allowed;
color: var(--canon-fg-disabled);
&:before {
border-color: var(--canon-border-disabled);
background: var(--canon-bg-disabled);
}
&[data-selected]:before {
border-color: var(--canon-border-disabled);
}
}
&[data-invalid]:before, &[data-invalid][data-selected]:before {
border-color: var(--canon-border-danger);
}
&[data-disabled][data-invalid] {
color: var(--canon-fg-disabled);
&:before {
border-color: var(--canon-border-disabled);
background: var(--canon-bg-disabled);
}
&[data-selected]:before {
border-color: var(--canon-border-disabled);
}
}
}
.canon-TableRoot {
caption-side: bottom;
border-collapse: collapse;
+89
View File
@@ -0,0 +1,89 @@
.canon-RadioGroup {
color: var(--canon-fg-primary);
flex-direction: column;
display: flex;
}
.canon-RadioGroup[data-orientation="horizontal"] .canon-RadioGroupContent {
gap: var(--canon-space-4);
flex-direction: row;
}
.canon-RadioGroupContent {
gap: var(--canon-space-2);
flex-direction: column;
display: flex;
}
.canon-Radio {
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-2);
color: var(--canon-fg-primary);
forced-color-adjust: none;
display: flex;
position: relative;
&:before {
content: "";
box-sizing: border-box;
border: .125rem solid var(--canon-border);
background: var(--canon-gray-1);
border-radius: var(--canon-radius-full);
width: 1rem;
height: 1rem;
transition: all .2s;
display: block;
}
&[data-pressed]:before {
border-color: var(--canon-border);
}
&[data-selected] {
&:before {
border-color: var(--canon-bg-solid);
border-width: .25rem;
}
&[data-pressed]:before {
border-color: var(--canon-bg-solid);
}
}
&[data-focus-visible]:before {
outline: 2px solid var(--canon-ring);
outline-offset: 2px;
}
&[data-disabled] {
cursor: not-allowed;
color: var(--canon-fg-disabled);
&:before {
border-color: var(--canon-border-disabled);
background: var(--canon-bg-disabled);
}
&[data-selected]:before {
border-color: var(--canon-border-disabled);
}
}
&[data-invalid]:before, &[data-invalid][data-selected]:before {
border-color: var(--canon-border-danger);
}
&[data-disabled][data-invalid] {
color: var(--canon-fg-disabled);
&:before {
border-color: var(--canon-border-disabled);
background: var(--canon-bg-disabled);
}
&[data-selected]:before {
border-color: var(--canon-border-disabled);
}
}
}
+90
View File
@@ -9815,6 +9815,96 @@
overflow: hidden;
}
.canon-RadioGroup {
color: var(--canon-fg-primary);
flex-direction: column;
display: flex;
}
.canon-RadioGroup[data-orientation="horizontal"] .canon-RadioGroupContent {
gap: var(--canon-space-4);
flex-direction: row;
}
.canon-RadioGroupContent {
gap: var(--canon-space-2);
flex-direction: column;
display: flex;
}
.canon-Radio {
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-2);
color: var(--canon-fg-primary);
forced-color-adjust: none;
display: flex;
position: relative;
&:before {
content: "";
box-sizing: border-box;
border: .125rem solid var(--canon-border);
background: var(--canon-gray-1);
border-radius: var(--canon-radius-full);
width: 1rem;
height: 1rem;
transition: all .2s;
display: block;
}
&[data-pressed]:before {
border-color: var(--canon-border);
}
&[data-selected] {
&:before {
border-color: var(--canon-bg-solid);
border-width: .25rem;
}
&[data-pressed]:before {
border-color: var(--canon-bg-solid);
}
}
&[data-focus-visible]:before {
outline: 2px solid var(--canon-ring);
outline-offset: 2px;
}
&[data-disabled] {
cursor: not-allowed;
color: var(--canon-fg-disabled);
&:before {
border-color: var(--canon-border-disabled);
background: var(--canon-bg-disabled);
}
&[data-selected]:before {
border-color: var(--canon-border-disabled);
}
}
&[data-invalid]:before, &[data-invalid][data-selected]:before {
border-color: var(--canon-border-danger);
}
&[data-disabled][data-invalid] {
color: var(--canon-fg-disabled);
&:before {
border-color: var(--canon-border-disabled);
background: var(--canon-bg-disabled);
}
&[data-selected]:before {
border-color: var(--canon-border-disabled);
}
}
}
.canon-TableRoot {
caption-side: bottom;
border-collapse: collapse;
+46
View File
@@ -18,7 +18,10 @@ import { FocusEvent as FocusEvent_2 } from 'react';
import { ForwardRefExoticComponent } from 'react';
import { HTMLAttributes } from 'react';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { LinkProps as LinkProps_2 } from 'react-aria-components';
import { Menu as Menu_2 } from '@base-ui-components/react/menu';
import type { RadioGroupProps as RadioGroupProps_2 } from 'react-aria-components';
import type { RadioProps as RadioProps_2 } from 'react-aria-components';
import { ReactElement } from 'react';
import { ReactNode } from 'react';
import { RefAttributes } from 'react';
@@ -174,6 +177,28 @@ export interface ButtonIconProps extends ButtonProps_2 {
| Partial<Record<Breakpoint_2, 'primary' | 'secondary'>>;
}
// @public (undocumented)
export const ButtonLink: ForwardRefExoticComponent<
ButtonLinkProps & RefAttributes<HTMLAnchorElement>
>;
// @public
export interface ButtonLinkProps extends LinkProps_2 {
// (undocumented)
children?: ReactNode;
// (undocumented)
iconEnd?: ReactElement;
// (undocumented)
iconStart?: ReactElement;
// (undocumented)
size?: 'small' | 'medium' | Partial<Record<Breakpoint_2, 'small' | 'medium'>>;
// (undocumented)
variant?:
| 'primary'
| 'secondary'
| Partial<Record<Breakpoint_2, 'primary' | 'secondary'>>;
}
// @public
export interface ButtonProps extends ButtonProps_2 {
// (undocumented)
@@ -1009,6 +1034,27 @@ export type PositionProps = GetPropDefTypes<typeof positionPropDefs>;
// @public (undocumented)
export type PropDef<T = any> = RegularPropDef<T> | ResponsivePropDef<T>;
// @public (undocumented)
export const Radio: ForwardRefExoticComponent<
RadioProps & RefAttributes<HTMLLabelElement>
>;
// @public (undocumented)
export const RadioGroup: ForwardRefExoticComponent<
RadioGroupProps & RefAttributes<HTMLDivElement>
>;
// @public (undocumented)
export interface RadioGroupProps
extends Omit<RadioGroupProps_2, 'children'>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
// (undocumented)
children?: ReactNode;
}
// @public (undocumented)
export interface RadioProps extends RadioProps_2 {}
// @public (undocumented)
export type ReactNodePropDef = {
type: 'ReactNode';
@@ -0,0 +1,147 @@
/*
* Copyright 2024 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';
import { RadioGroup, Radio } from './RadioGroup';
const meta = {
title: 'Forms/RadioGroup',
component: RadioGroup,
} satisfies Meta<typeof RadioGroup>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
label: 'What is your favorite pokemon?',
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
};
export const Horizontal: Story = {
args: {
...Default.args,
orientation: 'horizontal',
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
};
export const Disabled: Story = {
args: {
...Default.args,
isDisabled: true,
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
};
export const DisabledSingle: Story = {
args: {
...Default.args,
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander" isDisabled>
Charmander
</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
};
export const DisabledAndSelected: Story = {
args: {
...Default.args,
value: 'charmander',
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander" isDisabled>
Charmander
</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
};
export const Invalid: Story = {
args: {
...Default.args,
name: 'pokemon',
isInvalid: true,
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander" isDisabled>
Charmander
</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
};
export const Validation: Story = {
args: {
...Default.args,
name: 'pokemon',
defaultValue: 'charmander',
validationBehavior: 'aria',
validate: value => (value === 'charmander' ? 'Nice try!' : null),
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
};
export const ReadOnly: Story = {
args: {
...Default.args,
isReadOnly: true,
defaultValue: 'charmander',
},
render: args => (
<RadioGroup {...args}>
<Radio value="bulbasaur">Bulbasaur</Radio>
<Radio value="charmander">Charmander</Radio>
<Radio value="squirtle">Squirtle</Radio>
</RadioGroup>
),
};
@@ -0,0 +1,95 @@
.canon-RadioGroup {
display: flex;
flex-direction: column;
color: var(--canon-fg-primary);
}
.canon-RadioGroup[data-orientation='horizontal'] .canon-RadioGroupContent {
flex-direction: row;
gap: var(--canon-space-4);
}
.canon-RadioGroupContent {
display: flex;
flex-direction: column;
gap: var(--canon-space-2);
}
.canon-Radio {
display: flex;
/* This is needed so the HiddenInput is positioned correctly */
position: relative;
align-items: center;
gap: var(--canon-space-2);
font-size: var(--canon-font-size-2);
color: var(--canon-fg-primary);
forced-color-adjust: none;
&:before {
content: '';
display: block;
width: 1rem;
height: 1rem;
box-sizing: border-box;
border: 0.125rem solid var(--canon-border);
background: var(--canon-gray-1);
border-radius: var(--canon-radius-full);
transition: all 200ms;
}
&[data-pressed]:before {
border-color: var(--canon-border);
}
&[data-selected] {
&:before {
border-color: var(--canon-bg-solid);
border-width: 0.25rem;
}
&[data-pressed]:before {
border-color: var(--canon-bg-solid);
}
}
&[data-focus-visible]:before {
outline: 2px solid var(--canon-ring);
outline-offset: 2px;
}
&[data-disabled] {
cursor: not-allowed;
color: var(--canon-fg-disabled);
&:before {
border-color: var(--canon-border-disabled);
background: var(--canon-bg-disabled);
}
&[data-selected]:before {
border-color: var(--canon-border-disabled);
}
}
&[data-invalid]:before {
border-color: var(--canon-border-danger);
}
&[data-invalid][data-selected]:before {
border-color: var(--canon-border-danger);
}
/* Ensure disabled state prevails over invalid state */
&[data-disabled][data-invalid] {
color: var(--canon-fg-disabled);
&:before {
border-color: var(--canon-border-disabled);
background: var(--canon-bg-disabled);
}
&[data-selected]:before {
border-color: var(--canon-border-disabled);
}
}
}
@@ -0,0 +1,86 @@
/*
* Copyright 2024 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, useEffect } from 'react';
import {
RadioGroup as AriaRadioGroup,
Radio as AriaRadio,
FieldError,
} from 'react-aria-components';
import clsx from 'clsx';
import { FieldLabel } from '../FieldLabel';
import type { RadioGroupProps, RadioProps } from './types';
/** @public */
export const RadioGroup = forwardRef<HTMLDivElement, RadioGroupProps>(
(props, ref) => {
const {
className,
label,
secondaryLabel,
description,
isRequired,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
children,
...rest
} = props;
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'RadioGroup requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
const secondaryLabelText =
secondaryLabel || (isRequired ? 'Required' : null);
return (
<AriaRadioGroup
className={clsx('canon-RadioGroup', className)}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
{...rest}
ref={ref}
>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
/>
<div className="canon-RadioGroupContent">{children}</div>
<FieldError className="canon-TextFieldError" />
</AriaRadioGroup>
);
},
);
RadioGroup.displayName = 'RadioGroup';
/** @public */
export const Radio = forwardRef<HTMLLabelElement, RadioProps>((props, ref) => {
const { className, ...rest } = props;
return (
<AriaRadio className={clsx('canon-Radio', className)} {...rest} ref={ref} />
);
});
RadioGroup.displayName = 'RadioGroup';
@@ -0,0 +1,18 @@
/*
* Copyright 2024 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 './RadioGroup';
export * from './types';
@@ -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 {
RadioGroupProps as AriaRadioGroupProps,
RadioProps as AriaRadioProps,
} from 'react-aria-components';
import type { FieldLabelProps } from '../FieldLabel/types';
import { ReactNode } from 'react';
/** @public */
export interface RadioGroupProps
extends Omit<AriaRadioGroupProps, 'children'>,
Omit<FieldLabelProps, 'htmlFor' | 'id'> {
children?: ReactNode;
}
/** @public */
export interface RadioProps extends AriaRadioProps {}
+1
View File
@@ -30,6 +30,7 @@
@import '../components/Icon/styles.css';
@import '../components/Link/styles.css';
@import '../components/Menu/Menu.styles.css';
@import '../components/RadioGroup/RadioGroup.styles.css';
@import '../components/Table/styles.css';
@import '../components/Table/TableCell/TableCell.styles.css';
@import '../components/Table/TableCellText/TableCellText.styles.css';
+12 -10
View File
@@ -28,27 +28,29 @@ export * from './components/Box';
export * from './components/Grid';
export * from './components/Flex';
export * from './components/Container';
export * from './components/Text';
export * from './components/Heading';
// UI components
export * from './components/Avatar';
export * from './components/Button';
export * from './components/ButtonIcon';
export * from './components/ButtonLink';
export * from './components/Checkbox';
export * from './components/Collapsible';
export * from './components/DataTable';
export * from './components/FieldLabel';
export * from './components/Heading';
export * from './components/Icon';
export * from './components/ButtonIcon';
export * from './components/Checkbox';
export * from './components/Table';
export * from './components/Tabs';
export * from './components/TextField';
export * from './components/Tooltip';
export * from './components/Menu';
export * from './components/ScrollArea';
export * from './components/Link';
export * from './components/Menu';
export * from './components/RadioGroup';
export * from './components/ScrollArea';
export * from './components/Select';
export * from './components/Switch';
export * from './components/Table';
export * from './components/Tabs';
export * from './components/Text';
export * from './components/TextField';
export * from './components/Tooltip';
// Types
export * from './types';