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/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/css/styles.css b/packages/canon/css/styles.css
index 9f32da5d66..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);
@@ -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);
diff --git a/packages/canon/report.api.md b/packages/canon/report.api.md
index bdab503f61..1b1e465a61 100644
--- a/packages/canon/report.api.md
+++ b/packages/canon/report.api.md
@@ -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;
// @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/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.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..d1d7451a92
--- /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,
+} 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(
+ (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/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 edb3b1dc29..8c8471b0ef 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';
@@ -88,7 +85,7 @@ export const TextField = forwardRef(
placeholder={placeholder}
/>
-
+
);
},
diff --git a/packages/canon/src/css/components.css b/packages/canon/src/css/components.css
index aa453f7314..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';
@@ -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';
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';