From 6910892548c9713fd979638f72a13dfe5d428870 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 19 Jun 2025 20:29:32 +0100 Subject: [PATCH 1/6] Add RadioGroup + Radio Signed-off-by: Charles de Dreuille --- .changeset/empty-poets-give.md | 5 + packages/canon/css/components.css | 90 +++++++++++ packages/canon/css/radiogroup.css | 89 +++++++++++ packages/canon/css/styles.css | 90 +++++++++++ packages/canon/report.api.md | 46 ++++++ .../RadioGroup/RadioGroup.stories.tsx | 147 ++++++++++++++++++ .../RadioGroup/RadioGroup.styles.css | 95 +++++++++++ .../src/components/RadioGroup/RadioGroup.tsx | 86 ++++++++++ .../canon/src/components/RadioGroup/index.ts | 18 +++ .../canon/src/components/RadioGroup/types.ts | 32 ++++ packages/canon/src/css/components.css | 1 + packages/canon/src/index.ts | 22 +-- 12 files changed, 711 insertions(+), 10 deletions(-) create mode 100644 .changeset/empty-poets-give.md create mode 100644 packages/canon/css/radiogroup.css create mode 100644 packages/canon/src/components/RadioGroup/RadioGroup.stories.tsx create mode 100644 packages/canon/src/components/RadioGroup/RadioGroup.styles.css create mode 100644 packages/canon/src/components/RadioGroup/RadioGroup.tsx create mode 100644 packages/canon/src/components/RadioGroup/index.ts create mode 100644 packages/canon/src/components/RadioGroup/types.ts diff --git a/.changeset/empty-poets-give.md b/.changeset/empty-poets-give.md new file mode 100644 index 0000000000..2eb63c2ed5 --- /dev/null +++ b/.changeset/empty-poets-give.md @@ -0,0 +1,5 @@ +--- +'@backstage/canon': patch +--- + +Add new `RadioGroup` + `Radio` component to Canon diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css index 327f168696..74e678f845 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -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; diff --git a/packages/canon/css/radiogroup.css b/packages/canon/css/radiogroup.css new file mode 100644 index 0000000000..61abc2b8ac --- /dev/null +++ b/packages/canon/css/radiogroup.css @@ -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); + } + } +} diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 9f32da5d66..55effe9948 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -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; diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index bd0b9cbee0..97b547a66a 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -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>; } +// @public (undocumented) +export const ButtonLink: ForwardRefExoticComponent< + ButtonLinkProps & RefAttributes +>; + +// @public +export interface ButtonLinkProps extends LinkProps_2 { + // (undocumented) + children?: ReactNode; + // (undocumented) + iconEnd?: ReactElement; + // (undocumented) + iconStart?: ReactElement; + // (undocumented) + size?: 'small' | 'medium' | Partial>; + // (undocumented) + variant?: + | 'primary' + | 'secondary' + | Partial>; +} + // @public export interface ButtonProps extends ButtonProps_2 { // (undocumented) @@ -1009,6 +1034,27 @@ export type PositionProps = GetPropDefTypes; // @public (undocumented) export type PropDef = RegularPropDef | ResponsivePropDef; +// @public (undocumented) +export const Radio: ForwardRefExoticComponent< + RadioProps & RefAttributes +>; + +// @public (undocumented) +export const RadioGroup: ForwardRefExoticComponent< + RadioGroupProps & RefAttributes +>; + +// @public (undocumented) +export interface RadioGroupProps + extends Omit, + Omit { + // (undocumented) + children?: ReactNode; +} + +// @public (undocumented) +export interface RadioProps extends RadioProps_2 {} + // @public (undocumented) export type ReactNodePropDef = { type: 'ReactNode'; diff --git a/packages/canon/src/components/RadioGroup/RadioGroup.stories.tsx b/packages/canon/src/components/RadioGroup/RadioGroup.stories.tsx new file mode 100644 index 0000000000..4f28e04bfc --- /dev/null +++ b/packages/canon/src/components/RadioGroup/RadioGroup.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + label: 'What is your favorite pokemon?', + }, + render: args => ( + + Bulbasaur + Charmander + Squirtle + + ), +}; + +export const Horizontal: Story = { + args: { + ...Default.args, + orientation: 'horizontal', + }, + render: args => ( + + Bulbasaur + Charmander + Squirtle + + ), +}; + +export const Disabled: Story = { + args: { + ...Default.args, + isDisabled: true, + }, + render: args => ( + + Bulbasaur + Charmander + Squirtle + + ), +}; + +export const DisabledSingle: Story = { + args: { + ...Default.args, + }, + render: args => ( + + Bulbasaur + + Charmander + + Squirtle + + ), +}; + +export const DisabledAndSelected: Story = { + args: { + ...Default.args, + value: 'charmander', + }, + render: args => ( + + Bulbasaur + + Charmander + + Squirtle + + ), +}; + +export const Invalid: Story = { + args: { + ...Default.args, + name: 'pokemon', + isInvalid: true, + }, + render: args => ( + + Bulbasaur + + Charmander + + Squirtle + + ), +}; + +export const Validation: Story = { + args: { + ...Default.args, + name: 'pokemon', + defaultValue: 'charmander', + validationBehavior: 'aria', + validate: value => (value === 'charmander' ? 'Nice try!' : null), + }, + render: args => ( + + Bulbasaur + Charmander + Squirtle + + ), +}; + +export const ReadOnly: Story = { + args: { + ...Default.args, + isReadOnly: true, + defaultValue: 'charmander', + }, + render: args => ( + + Bulbasaur + Charmander + Squirtle + + ), +}; diff --git a/packages/canon/src/components/RadioGroup/RadioGroup.styles.css b/packages/canon/src/components/RadioGroup/RadioGroup.styles.css new file mode 100644 index 0000000000..a712f9031f --- /dev/null +++ b/packages/canon/src/components/RadioGroup/RadioGroup.styles.css @@ -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); + } + } +} diff --git a/packages/canon/src/components/RadioGroup/RadioGroup.tsx b/packages/canon/src/components/RadioGroup/RadioGroup.tsx new file mode 100644 index 0000000000..5bdfcb8a20 --- /dev/null +++ b/packages/canon/src/components/RadioGroup/RadioGroup.tsx @@ -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( + (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 ( + + +
{children}
+ +
+ ); + }, +); + +RadioGroup.displayName = 'RadioGroup'; + +/** @public */ +export const Radio = forwardRef((props, ref) => { + const { className, ...rest } = props; + + return ( + + ); +}); + +RadioGroup.displayName = 'RadioGroup'; diff --git a/packages/canon/src/components/RadioGroup/index.ts b/packages/canon/src/components/RadioGroup/index.ts new file mode 100644 index 0000000000..fd9572f667 --- /dev/null +++ b/packages/canon/src/components/RadioGroup/index.ts @@ -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'; diff --git a/packages/canon/src/components/RadioGroup/types.ts b/packages/canon/src/components/RadioGroup/types.ts new file mode 100644 index 0000000000..029ef4baf9 --- /dev/null +++ b/packages/canon/src/components/RadioGroup/types.ts @@ -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, + Omit { + children?: ReactNode; +} + +/** @public */ +export interface RadioProps extends AriaRadioProps {} diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index aa453f7314..4199bc04eb 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -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'; diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index 69c302bdc5..79b295484c 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -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'; From 4f1b4ad6c018de7db200d865dcdd0fd4f3df6d1e Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 19 Jun 2025 21:32:43 +0100 Subject: [PATCH 2/6] Add doscs + FieldError Signed-off-by: Charles de Dreuille --- .../src/content/components/radio-group.mdx | 98 +++++++++++++++++++ .../content/components/radio-group.props.ts | 64 ++++++++++++ canon-docs/src/snippets/stories-snippets.tsx | 12 +++ canon-docs/src/utils/data.ts | 5 + .../FieldError/FieldError.stories.tsx | 87 ++++++++++++++++ .../FieldError/FieldError.styles.css | 6 ++ .../src/components/FieldError/FieldError.tsx | 39 ++++++++ .../canon/src/components/FieldError/index.ts | 17 ++++ .../src/components/RadioGroup/RadioGroup.tsx | 4 +- .../TextField/TextField.stories.tsx | 20 +++- .../components/TextField/TextField.styles.css | 7 -- .../src/components/TextField/TextField.tsx | 9 +- packages/canon/src/css/components.css | 1 + packages/canon/src/index.ts | 1 + 14 files changed, 351 insertions(+), 19 deletions(-) create mode 100644 canon-docs/src/content/components/radio-group.mdx create mode 100644 canon-docs/src/content/components/radio-group.props.ts create mode 100644 packages/canon/src/components/FieldError/FieldError.stories.tsx create mode 100644 packages/canon/src/components/FieldError/FieldError.styles.css create mode 100644 packages/canon/src/components/FieldError/FieldError.tsx create mode 100644 packages/canon/src/components/FieldError/index.ts diff --git a/canon-docs/src/content/components/radio-group.mdx b/canon-docs/src/content/components/radio-group.mdx new file mode 100644 index 0000000000..703ac6b8dd --- /dev/null +++ b/canon-docs/src/content/components/radio-group.mdx @@ -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. + +} + code={radioGroupDefaultSnippet} +/> + + + +## API reference + + + +## Examples + +### Horizontal + +Here's a simple TextField with a description. + +} + code={radioGroupHorizontalSnippet} +/> + +### Disabled + +You can disable the entire radio group by adding the `isDisabled` prop to the `RadioGroup` component. + +} + code={radioGroupDisabledSnippet} +/> + +### Disabled Single radio + +You can disable a single radio by adding the `isDisabled` prop to the `Radio` component. + +} + code={radioGroupDisabledSingleSnippet} +/> + +### Validation + +Here's an example of a radio group with errors. + +} + code={radioGroupValidationSnippet} +/> + +### Read only + +You can make the radio group read only by adding the `isReadOnly` prop to the `RadioGroup` component. + +} + code={radioGroupReadOnlySnippet} +/> diff --git a/canon-docs/src/content/components/radio-group.props.ts b/canon-docs/src/content/components/radio-group.props.ts new file mode 100644 index 0000000000..c016be5ac6 --- /dev/null +++ b/canon-docs/src/content/components/radio-group.props.ts @@ -0,0 +1,64 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; + +export const radioGroupPropDefs: Record = { + 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'; + +`; + +export const radioGroupDefaultSnippet = ``; + +export const radioGroupDescriptionSnippet = ``; + +export const radioGroupHorizontalSnippet = ``; + +export const radioGroupDisabledSnippet = ` + Bulbasaur + Charmander + Squirtle +`; + +export const radioGroupDisabledSingleSnippet = ` + Bulbasaur + Charmander + Squirtle +`; + +export const radioGroupValidationSnippet = ` (value === \'charmander\' ? \'Nice try!\' : null)> + Bulbasaur + Charmander + Squirtle +`; + +export const radioGroupReadOnlySnippet = ` + Bulbasaur + Charmander + Squirtle +`; diff --git a/canon-docs/src/snippets/stories-snippets.tsx b/canon-docs/src/snippets/stories-snippets.tsx index e5a7464b43..ef25a37900 100644 --- a/canon-docs/src/snippets/stories-snippets.tsx +++ b/canon-docs/src/snippets/stories-snippets.tsx @@ -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 ? : null; }; + +export const RadioGroupSnippet = ({ + story, +}: { + story: keyof typeof RadioGroupStories; +}) => { + const stories = composeStories(RadioGroupStories); + const StoryComponent = stories[story as keyof typeof stories]; + + return StoryComponent ? : null; +}; diff --git a/canon-docs/src/utils/data.ts b/canon-docs/src/utils/data.ts index a741ad5160..92f0fdbf87 100644 --- a/canon-docs/src/utils/data.ts +++ b/canon-docs/src/utils/data.ts @@ -116,6 +116,11 @@ export const components: Page[] = [ slug: 'menu', status: 'alpha', }, + { + title: 'RadioGroup', + slug: 'radio-group', + status: 'alpha', + }, { title: 'Select', slug: 'select', diff --git a/packages/canon/src/components/FieldError/FieldError.stories.tsx b/packages/canon/src/components/FieldError/FieldError.stories.tsx new file mode 100644 index 0000000000..39714d16d2 --- /dev/null +++ b/packages/canon/src/components/FieldError/FieldError.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +// Show error with server validation using Form component +export const WithServerValidation: Story = { + render: () => ( +
+ + + + +
+ ), +}; + +// Show error using children +export const WithCustomMessage: Story = { + render: () => ( + + + This is a custom error message. + + ), +}; + +// Show error with render prop function +export const WithRenderProp: Story = { + render: () => ( + 'This field is invalid'} + style={{ + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + }} + > + + + {({ validationErrors }) => + validationErrors.length > 0 ? validationErrors[0] : 'Field is invalid' + } + + + ), +}; diff --git a/packages/canon/src/components/FieldError/FieldError.styles.css b/packages/canon/src/components/FieldError/FieldError.styles.css new file mode 100644 index 0000000000..816eb3c35e --- /dev/null +++ b/packages/canon/src/components/FieldError/FieldError.styles.css @@ -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); +} diff --git a/packages/canon/src/components/FieldError/FieldError.tsx b/packages/canon/src/components/FieldError/FieldError.tsx new file mode 100644 index 0000000000..b90d7b3bd3 --- /dev/null +++ b/packages/canon/src/components/FieldError/FieldError.tsx @@ -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( + (props: FieldErrorProps, ref) => { + const { className, ...rest } = props; + + return ( + + ); + }, +); + +FieldError.displayName = 'FieldError'; diff --git a/packages/canon/src/components/FieldError/index.ts b/packages/canon/src/components/FieldError/index.ts new file mode 100644 index 0000000000..5b61f83ace --- /dev/null +++ b/packages/canon/src/components/FieldError/index.ts @@ -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'; diff --git a/packages/canon/src/components/RadioGroup/RadioGroup.tsx b/packages/canon/src/components/RadioGroup/RadioGroup.tsx index 5bdfcb8a20..d1d7451a92 100644 --- a/packages/canon/src/components/RadioGroup/RadioGroup.tsx +++ b/packages/canon/src/components/RadioGroup/RadioGroup.tsx @@ -18,10 +18,10 @@ 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 { FieldError } from '../FieldError'; import type { RadioGroupProps, RadioProps } from './types'; @@ -66,7 +66,7 @@ export const RadioGroup = forwardRef( description={description} />
{children}
- + ); }, diff --git a/packages/canon/src/components/TextField/TextField.stories.tsx b/packages/canon/src/components/TextField/TextField.stories.tsx index e07aa6ea05..c8fc5b5710 100644 --- a/packages/canon/src/components/TextField/TextField.stories.tsx +++ b/packages/canon/src/components/TextField/TextField.stories.tsx @@ -97,16 +97,28 @@ export const Disabled: Story = { export const WithIcon: Story = { args: { ...Default.args, - defaultValue: 'Search...', - icon: , }, + render: args => ( + } + defaultValue="Search..." + /> + ), }; export const DisabledWithIcon: Story = { args: { - ...WithIcon.args, - isDisabled: true, + ...Default.args, }, + render: args => ( + } + defaultValue="Search..." + isDisabled + /> + ), }; export const ShowError: Story = { diff --git a/packages/canon/src/components/TextField/TextField.styles.css b/packages/canon/src/components/TextField/TextField.styles.css index 9f2039b3af..c0432d88aa 100644 --- a/packages/canon/src/components/TextField/TextField.styles.css +++ b/packages/canon/src/components/TextField/TextField.styles.css @@ -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); -} diff --git a/packages/canon/src/components/TextField/TextField.tsx b/packages/canon/src/components/TextField/TextField.tsx index 444e76e983..d20e68ef88 100644 --- a/packages/canon/src/components/TextField/TextField.tsx +++ b/packages/canon/src/components/TextField/TextField.tsx @@ -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'; @@ -86,7 +83,7 @@ export const TextField = forwardRef( {...(icon && { 'data-icon': true })} /> - + ); }, diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css index 4199bc04eb..407723fcc9 100644 --- a/packages/canon/src/css/components.css +++ b/packages/canon/src/css/components.css @@ -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'; diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index 79b295484c..dc0b3c1cfc 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -38,6 +38,7 @@ export * from './components/Checkbox'; export * from './components/Collapsible'; export * from './components/DataTable'; export * from './components/FieldLabel'; +export * from './components/FieldError'; export * from './components/Heading'; export * from './components/Icon'; export * from './components/Link'; From a042eed1cdac0cc86fc70ca6bd0f02c1c3727190 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 19 Jun 2025 21:35:28 +0100 Subject: [PATCH 3/6] Add report Signed-off-by: Charles de Dreuille --- packages/canon/css/components.css | 14 +++++++------- packages/canon/css/fielderror.css | 6 ++++++ packages/canon/css/styles.css | 14 +++++++------- packages/canon/css/textfield.css | 7 ------- packages/canon/report.api.md | 6 ++++++ 5 files changed, 26 insertions(+), 21 deletions(-) create mode 100644 packages/canon/css/fielderror.css diff --git a/packages/canon/css/components.css b/packages/canon/css/components.css index 74e678f845..b080e11f82 100644 --- a/packages/canon/css/components.css +++ b/packages/canon/css/components.css @@ -272,6 +272,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); @@ -985,13 +992,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); diff --git a/packages/canon/css/fielderror.css b/packages/canon/css/fielderror.css new file mode 100644 index 0000000000..816eb3c35e --- /dev/null +++ b/packages/canon/css/fielderror.css @@ -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); +} diff --git a/packages/canon/css/styles.css b/packages/canon/css/styles.css index 55effe9948..806ee771d2 100644 --- a/packages/canon/css/styles.css +++ b/packages/canon/css/styles.css @@ -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); @@ -10209,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); diff --git a/packages/canon/css/textfield.css b/packages/canon/css/textfield.css index fdb3621016..45759c1c4c 100644 --- a/packages/canon/css/textfield.css +++ b/packages/canon/css/textfield.css @@ -85,10 +85,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); -} diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index 97b547a66a..34e5379156 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -14,6 +14,7 @@ import { Context } from 'react'; import type { CSSProperties } from 'react'; import type { ElementType } from 'react'; import { FC } from 'react'; +import { FieldErrorProps } from 'react-aria-components'; import { FocusEvent as FocusEvent_2 } from 'react'; import { ForwardRefExoticComponent } from 'react'; import { HTMLAttributes } from 'react'; @@ -388,6 +389,11 @@ export type EnumPropDef = { required?: boolean; }; +// @public (undocumented) +export const FieldError: ForwardRefExoticComponent< + FieldErrorProps & RefAttributes +>; + // @public (undocumented) export const FieldLabel: ForwardRefExoticComponent< FieldLabelProps & RefAttributes From 576f8740d3e5361ff9b8aa540a5901e3a2afac58 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 23 Jun 2025 13:40:38 +0100 Subject: [PATCH 4/6] Update index.ts Signed-off-by: Charles de Dreuille --- packages/canon/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/canon/src/index.ts b/packages/canon/src/index.ts index d35883bbe6..cc540d1bb7 100644 --- a/packages/canon/src/index.ts +++ b/packages/canon/src/index.ts @@ -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'; From d8bbba866c830462ff6b05afc4922030a5a93b69 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 23 Jun 2025 14:58:56 +0100 Subject: [PATCH 5/6] Update report.api.md Signed-off-by: Charles de Dreuille --- packages/canon/report.api.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md index fa78728506..1b1e465a61 100644 --- a/packages/canon/report.api.md +++ b/packages/canon/report.api.md @@ -14,7 +14,6 @@ import { Context } from 'react'; import type { CSSProperties } from 'react'; import type { ElementType } from 'react'; import { FC } from 'react'; -import { FieldErrorProps } from 'react-aria-components'; import { FocusEvent as FocusEvent_2 } from 'react'; import { ForwardRefExoticComponent } from 'react'; import { HTMLAttributes } from 'react'; @@ -389,11 +388,6 @@ export type EnumPropDef = { required?: boolean; }; -// @public (undocumented) -export const FieldError: ForwardRefExoticComponent< - FieldErrorProps & RefAttributes ->; - // @public (undocumented) export const FieldLabel: ForwardRefExoticComponent< FieldLabelProps & RefAttributes From 81895ee79de1e27ac04434c06819e5f2b830a7b6 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 23 Jun 2025 15:00:05 +0100 Subject: [PATCH 6/6] Remove styles Signed-off-by: Charles de Dreuille --- packages/canon/css/fielderror.css | 6 --- packages/canon/css/radiogroup.css | 89 ------------------------------- 2 files changed, 95 deletions(-) delete mode 100644 packages/canon/css/fielderror.css delete mode 100644 packages/canon/css/radiogroup.css diff --git a/packages/canon/css/fielderror.css b/packages/canon/css/fielderror.css deleted file mode 100644 index 816eb3c35e..0000000000 --- a/packages/canon/css/fielderror.css +++ /dev/null @@ -1,6 +0,0 @@ -.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); -} diff --git a/packages/canon/css/radiogroup.css b/packages/canon/css/radiogroup.css deleted file mode 100644 index 61abc2b8ac..0000000000 --- a/packages/canon/css/radiogroup.css +++ /dev/null @@ -1,89 +0,0 @@ -.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); - } - } -}