Merge pull request #30327 from backstage/canon-radiogroup
Canon - Add RadioGroup + Radio
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/canon': patch
|
||||
---
|
||||
|
||||
Add new `RadioGroup` + `Radio` component to Canon
|
||||
@@ -0,0 +1,98 @@
|
||||
import { PropsTable } from '@/components/PropsTable';
|
||||
import { Snippet } from '@/components/Snippet';
|
||||
import { RadioGroupSnippet } from '@/snippets/stories-snippets';
|
||||
import {
|
||||
radioGroupPropDefs,
|
||||
radioGroupUsageSnippet,
|
||||
radioGroupDefaultSnippet,
|
||||
radioGroupDescriptionSnippet,
|
||||
radioGroupHorizontalSnippet,
|
||||
radioGroupDisabledSnippet,
|
||||
radioGroupDisabledSingleSnippet,
|
||||
radioGroupValidationSnippet,
|
||||
radioGroupReadOnlySnippet,
|
||||
} from './radio-group.props';
|
||||
import { ComponentInfos } from '@/components/ComponentInfos';
|
||||
|
||||
# RadioGroup
|
||||
|
||||
A radio group allows a user to select a single item from a list of mutually exclusive options.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
preview={<RadioGroupSnippet story="Default" />}
|
||||
code={radioGroupDefaultSnippet}
|
||||
/>
|
||||
|
||||
<ComponentInfos
|
||||
component="radio-group"
|
||||
classNames={['canon-RadioGroup', 'canon-RadioGroupContent']}
|
||||
usageCode={radioGroupUsageSnippet}
|
||||
/>
|
||||
|
||||
## API reference
|
||||
|
||||
<PropsTable data={radioGroupPropDefs} />
|
||||
|
||||
## Examples
|
||||
|
||||
### Horizontal
|
||||
|
||||
Here's a simple TextField with a description.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<RadioGroupSnippet story="Horizontal" />}
|
||||
code={radioGroupHorizontalSnippet}
|
||||
/>
|
||||
|
||||
### Disabled
|
||||
|
||||
You can disable the entire radio group by adding the `isDisabled` prop to the `RadioGroup` component.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<RadioGroupSnippet story="Disabled" />}
|
||||
code={radioGroupDisabledSnippet}
|
||||
/>
|
||||
|
||||
### Disabled Single radio
|
||||
|
||||
You can disable a single radio by adding the `isDisabled` prop to the `Radio` component.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<RadioGroupSnippet story="DisabledSingle" />}
|
||||
code={radioGroupDisabledSingleSnippet}
|
||||
/>
|
||||
|
||||
### Validation
|
||||
|
||||
Here's an example of a radio group with errors.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<RadioGroupSnippet story="Validation" />}
|
||||
code={radioGroupValidationSnippet}
|
||||
/>
|
||||
|
||||
### Read only
|
||||
|
||||
You can make the radio group read only by adding the `isReadOnly` prop to the `RadioGroup` component.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<RadioGroupSnippet story="ReadOnly" />}
|
||||
code={radioGroupReadOnlySnippet}
|
||||
/>
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
classNamePropDefs,
|
||||
stylePropDefs,
|
||||
type PropDef,
|
||||
} from '@/utils/propDefs';
|
||||
|
||||
export const radioGroupPropDefs: Record<string, PropDef> = {
|
||||
size: {
|
||||
type: 'enum',
|
||||
values: ['small', 'medium'],
|
||||
default: 'small',
|
||||
responsive: true,
|
||||
},
|
||||
label: {
|
||||
type: 'string',
|
||||
},
|
||||
icon: {
|
||||
type: 'enum',
|
||||
values: ['ReactNode'],
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
},
|
||||
...classNamePropDefs,
|
||||
...stylePropDefs,
|
||||
};
|
||||
|
||||
export const radioGroupUsageSnippet = `import { RadioGroup } from '@backstage/canon';
|
||||
|
||||
<RadioGroup />`;
|
||||
|
||||
export const radioGroupDefaultSnippet = `<RadioGroup label="Label" />`;
|
||||
|
||||
export const radioGroupDescriptionSnippet = `<TextField label="Label" description="Description" placeholder="Enter a URL" />`;
|
||||
|
||||
export const radioGroupHorizontalSnippet = `<RadioGroup label="Label" orientation="horizontal" />`;
|
||||
|
||||
export const radioGroupDisabledSnippet = `<RadioGroup label="Label" isDisabled>
|
||||
<Radio value="bulbasaur">Bulbasaur</Radio>
|
||||
<Radio value="charmander">Charmander</Radio>
|
||||
<Radio value="squirtle">Squirtle</Radio>
|
||||
</RadioGroup>`;
|
||||
|
||||
export const radioGroupDisabledSingleSnippet = `<RadioGroup label="Label">
|
||||
<Radio value="bulbasaur">Bulbasaur</Radio>
|
||||
<Radio value="charmander" isDisabled>Charmander</Radio>
|
||||
<Radio value="squirtle">Squirtle</Radio>
|
||||
</RadioGroup>`;
|
||||
|
||||
export const radioGroupValidationSnippet = `<RadioGroup validate: value => (value === \'charmander\' ? \'Nice try!\' : null)>
|
||||
<Radio value="bulbasaur">Bulbasaur</Radio>
|
||||
<Radio value="charmander">Charmander</Radio>
|
||||
<Radio value="squirtle">Squirtle</Radio>
|
||||
</RadioGroup>`;
|
||||
|
||||
export const radioGroupReadOnlySnippet = `<RadioGroup label="Label" isReadOnly>
|
||||
<Radio value="bulbasaur">Bulbasaur</Radio>
|
||||
<Radio value="charmander">Charmander</Radio>
|
||||
<Radio value="squirtle">Squirtle</Radio>
|
||||
</RadioGroup>`;
|
||||
@@ -18,6 +18,7 @@ import * as MenuStories from '../../../packages/canon/src/components/Menu/Menu.s
|
||||
import * as LinkStories from '../../../packages/canon/src/components/Link/Link.stories';
|
||||
import * as AvatarStories from '../../../packages/canon/src/components/Avatar/Avatar.stories';
|
||||
import * as CollapsibleStories from '../../../packages/canon/src/components/Collapsible/Collapsible.stories';
|
||||
import * as RadioGroupStories from '../../../packages/canon/src/components/RadioGroup/RadioGroup.stories';
|
||||
import * as TabsStories from '../../../packages/canon/src/components/Tabs/Tabs.stories';
|
||||
import * as SwitchStories from '../../../packages/canon/src/components/Switch/Switch.stories';
|
||||
|
||||
@@ -197,3 +198,14 @@ export const SwitchSnippet = ({
|
||||
|
||||
return StoryComponent ? <StoryComponent /> : null;
|
||||
};
|
||||
|
||||
export const RadioGroupSnippet = ({
|
||||
story,
|
||||
}: {
|
||||
story: keyof typeof RadioGroupStories;
|
||||
}) => {
|
||||
const stories = composeStories(RadioGroupStories);
|
||||
const StoryComponent = stories[story as keyof typeof stories];
|
||||
|
||||
return StoryComponent ? <StoryComponent /> : null;
|
||||
};
|
||||
|
||||
@@ -116,6 +116,11 @@ export const components: Page[] = [
|
||||
slug: 'menu',
|
||||
status: 'alpha',
|
||||
},
|
||||
{
|
||||
title: 'RadioGroup',
|
||||
slug: 'radio-group',
|
||||
status: 'alpha',
|
||||
},
|
||||
{
|
||||
title: 'Select',
|
||||
slug: 'select',
|
||||
|
||||
@@ -9496,6 +9496,13 @@
|
||||
min-width: 10.5rem;
|
||||
}
|
||||
|
||||
.canon-FieldError {
|
||||
color: var(--canon-fg-danger);
|
||||
font-size: var(--canon-font-size-2);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
margin-top: var(--canon-space-2);
|
||||
}
|
||||
|
||||
.canon-FieldLabel {
|
||||
margin-bottom: var(--canon-space-3);
|
||||
gap: var(--canon-space-1);
|
||||
@@ -9815,6 +9822,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;
|
||||
@@ -10119,13 +10216,6 @@
|
||||
border: 1px solid var(--canon-border-disabled);
|
||||
}
|
||||
|
||||
.canon-TextFieldError {
|
||||
color: var(--canon-fg-danger);
|
||||
font-size: var(--canon-font-size-2);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
margin-top: var(--canon-space-2);
|
||||
}
|
||||
|
||||
.canon-TooltipPopup {
|
||||
box-sizing: border-box;
|
||||
transform-origin: var(--transform-origin);
|
||||
|
||||
@@ -20,6 +20,8 @@ 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';
|
||||
@@ -1032,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,87 @@
|
||||
/*
|
||||
* 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 { TextField, Input, Form } from 'react-aria-components';
|
||||
import { FieldError } from './FieldError';
|
||||
|
||||
const meta = {
|
||||
title: 'Forms/FieldError',
|
||||
component: FieldError,
|
||||
} satisfies Meta<typeof FieldError>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
// Show error with server validation using Form component
|
||||
export const WithServerValidation: Story = {
|
||||
render: () => (
|
||||
<Form validationErrors={{ demo: 'This is a server validation error.' }}>
|
||||
<TextField
|
||||
name="demo"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
<FieldError />
|
||||
</TextField>
|
||||
</Form>
|
||||
),
|
||||
};
|
||||
|
||||
// Show error using children
|
||||
export const WithCustomMessage: Story = {
|
||||
render: () => (
|
||||
<TextField
|
||||
isInvalid
|
||||
validationBehavior="aria"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
<FieldError>This is a custom error message.</FieldError>
|
||||
</TextField>
|
||||
),
|
||||
};
|
||||
|
||||
// Show error with render prop function
|
||||
export const WithRenderProp: Story = {
|
||||
render: () => (
|
||||
<TextField
|
||||
isInvalid
|
||||
validationBehavior="aria"
|
||||
validate={() => 'This field is invalid'}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
<FieldError>
|
||||
{({ validationErrors }) =>
|
||||
validationErrors.length > 0 ? validationErrors[0] : 'Field is invalid'
|
||||
}
|
||||
</FieldError>
|
||||
</TextField>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
.canon-FieldError {
|
||||
color: var(--canon-fg-danger);
|
||||
font-size: var(--canon-font-size-2);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
margin-top: var(--canon-space-2);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* 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 {
|
||||
FieldError as AriaFieldError,
|
||||
type FieldErrorProps,
|
||||
} from 'react-aria-components';
|
||||
import clsx from 'clsx';
|
||||
|
||||
/** @public */
|
||||
export const FieldError = forwardRef<HTMLDivElement, FieldErrorProps>(
|
||||
(props: FieldErrorProps, ref) => {
|
||||
const { className, ...rest } = props;
|
||||
|
||||
return (
|
||||
<AriaFieldError
|
||||
className={clsx('canon-FieldError', className)}
|
||||
ref={ref}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
FieldError.displayName = 'FieldError';
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 './FieldError';
|
||||
@@ -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,
|
||||
} from 'react-aria-components';
|
||||
import clsx from 'clsx';
|
||||
import { FieldLabel } from '../FieldLabel';
|
||||
import { FieldError } from '../FieldError';
|
||||
|
||||
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 />
|
||||
</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 {}
|
||||
@@ -104,10 +104,3 @@
|
||||
cursor: not-allowed;
|
||||
border: 1px solid var(--canon-border-disabled);
|
||||
}
|
||||
|
||||
.canon-TextFieldError {
|
||||
color: var(--canon-fg-danger);
|
||||
font-size: var(--canon-font-size-2);
|
||||
font-weight: var(--canon-font-weight-regular);
|
||||
margin-top: var(--canon-space-2);
|
||||
}
|
||||
|
||||
@@ -15,14 +15,11 @@
|
||||
*/
|
||||
|
||||
import { forwardRef, useEffect } from 'react';
|
||||
import {
|
||||
Input,
|
||||
TextField as AriaTextField,
|
||||
FieldError,
|
||||
} from 'react-aria-components';
|
||||
import { Input, TextField as AriaTextField } from 'react-aria-components';
|
||||
import { useResponsiveValue } from '../../hooks/useResponsiveValue';
|
||||
import clsx from 'clsx';
|
||||
import { FieldLabel } from '../FieldLabel';
|
||||
import { FieldError } from '../FieldError';
|
||||
|
||||
import type { TextFieldProps } from './types';
|
||||
|
||||
@@ -88,7 +85,7 @@ export const TextField = forwardRef<HTMLDivElement, TextFieldProps>(
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
</div>
|
||||
<FieldError className="canon-TextFieldError" />
|
||||
<FieldError />
|
||||
</AriaTextField>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
@import '../components/Container/styles.css';
|
||||
@import '../components/DataTable/Root/DataTableRoot.styles.css';
|
||||
@import '../components/DataTable/Pagination/DataTablePagination.styles.css';
|
||||
@import '../components/FieldError/FieldError.styles.css';
|
||||
@import '../components/FieldLabel/FieldLabel.styles.css';
|
||||
@import '../components/Flex/styles.css';
|
||||
@import '../components/Grid/styles.css';
|
||||
@@ -30,6 +31,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';
|
||||
|
||||
@@ -41,6 +41,7 @@ export * from './components/Icon';
|
||||
export * from './components/ButtonIcon';
|
||||
export * from './components/ButtonLink';
|
||||
export * from './components/Checkbox';
|
||||
export * from './components/RadioGroup';
|
||||
export * from './components/Table';
|
||||
export * from './components/Tabs';
|
||||
export * from './components/TextField';
|
||||
|
||||
Reference in New Issue
Block a user