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';