diff --git a/.changeset/rude-trains-marry.md b/.changeset/rude-trains-marry.md new file mode 100644 index 0000000000..0246ff6c9e --- /dev/null +++ b/.changeset/rude-trains-marry.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': minor +--- + +Introduce new `ToggleButton` & `ToggleButtonGroup` components in Backstage UI diff --git a/docs-ui/src/content/toggle-button-group.mdx b/docs-ui/src/content/toggle-button-group.mdx new file mode 100644 index 0000000000..19034f0ac5 --- /dev/null +++ b/docs-ui/src/content/toggle-button-group.mdx @@ -0,0 +1,126 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { + toggleButtonGroupPropDefs, + toggleButtonGroupUsageSnippet, + toggleButtonGroupSingleSnippet, + toggleButtonGroupMultipleSnippet, + toggleButtonGroupVerticalSnippet, + toggleButtonGroupDisabledSnippet, + toggleButtonGroupDisallowEmptySnippet, + toggleButtonGroupIconsSnippet, + toggleButtonGroupIconsOnlySnippet, + toggleButtonGroupSurfacesSnippet, +} from './toggle-button-group.props'; +import { ToggleButtonGroupSnippet } from '@/snippets/stories-snippets'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; +import { ToggleButtonGroupDefinition } from '../utils/definitions'; + + + +} + code={toggleButtonGroupSingleSnippet} +/> + +## Usage + + + +## API reference + + + +## Examples + +### Surfaces + +} + code={toggleButtonGroupSurfacesSnippet} +/> + +### Single Selection + +} + code={toggleButtonGroupSingleSnippet} +/> + +### Multiple Selection + +} + code={toggleButtonGroupMultipleSnippet} +/> + +### Icons and Text + +} + code={toggleButtonGroupIconsSnippet} +/> + +### Icons Only + +} + code={toggleButtonGroupIconsOnlySnippet} +/> + +### Disallow Empty Selection + +} + code={toggleButtonGroupDisallowEmptySnippet} +/> + +### Vertical Orientation + +} + code={toggleButtonGroupVerticalSnippet} +/> + +### Disabled + +} + code={toggleButtonGroupDisabledSnippet} +/> + + + + diff --git a/docs-ui/src/content/toggle-button-group.props.ts b/docs-ui/src/content/toggle-button-group.props.ts new file mode 100644 index 0000000000..b8f12941de --- /dev/null +++ b/docs-ui/src/content/toggle-button-group.props.ts @@ -0,0 +1,142 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; + +export const toggleButtonGroupPropDefs: Record = { + selectionMode: { + type: 'enum', + values: ['single', 'multiple'], + default: 'single', + }, + orientation: { + type: 'enum', + values: ['horizontal', 'vertical'], + default: 'horizontal', + responsive: true, + }, + selectedKeys: { type: 'enum', values: ['Iterable'] }, + defaultSelectedKeys: { type: 'enum', values: ['Iterable'] }, + onSelectionChange: { type: 'enum', values: ['(keys) => void'] }, + isDisabled: { type: 'boolean', default: 'false' }, + disallowEmptySelection: { type: 'boolean', default: 'false' }, + ...classNamePropDefs, + ...stylePropDefs, +}; + +export const toggleButtonGroupUsageSnippet = `import { ToggleButtonGroup, ToggleButton } from '@backstage/ui'; + + + Dogs + Cats + Birds +`; + +export const toggleButtonGroupSingleSnippet = ` + Dogs + Cats + Birds +`; + +export const toggleButtonGroupMultipleSnippet = ` + Frontend + Backend + Platform +`; + +export const toggleButtonGroupVerticalSnippet = ` + Morning + Afternoon + Evening +`; + +export const toggleButtonGroupDisabledSnippet = ` + Cat + Dog + Bird +`; + +export const toggleButtonGroupDisallowEmptySnippet = ` + One + Two + Three +`; + +export const toggleButtonGroupIconsSnippet = `import { RiCloudLine, RiStarFill, RiStarLine, RiArrowRightSLine } from '@remixicon/react'; + + + } /> + } + /> + }> + Star + + }> + Next + +`; + +export const toggleButtonGroupIconsOnlySnippet = `import { RiCloudLine, RiStarLine, RiArrowRightSLine } from '@remixicon/react'; + + + } /> + } /> + } /> +`; + +export const toggleButtonGroupSurfacesSnippet = ` + + Default + + + Option 1 + Option 2 + Option 3 + + + + + On Surface 0 + + + Option 1 + Option 2 + Option 3 + + + + + On Surface 1 + + + Option 1 + Option 2 + Option 3 + + + + + On Surface 2 + + + Option 1 + Option 2 + Option 3 + + + + + On Surface 3 + + + Option 1 + Option 2 + Option 3 + + + +`; diff --git a/docs-ui/src/content/toggle-button.mdx b/docs-ui/src/content/toggle-button.mdx new file mode 100644 index 0000000000..78750ec365 --- /dev/null +++ b/docs-ui/src/content/toggle-button.mdx @@ -0,0 +1,106 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { ToggleButtonSnippet } from '@/snippets/stories-snippets'; +import { + toggleButtonPropDefs, + toggleButtonUsageSnippet, + toggleButtonSurfacesSnippet, + toggleButtonSizesSnippet, + toggleButtonIconsSnippet, + toggleButtonDisabledSnippet, + toggleButtonControlledSnippet, + toggleButtonFunctionChildrenSnippet, +} from './toggle-button.props'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; +import { ToggleButtonDefinition } from '../utils/definitions'; + + + +} + code={toggleButtonUsageSnippet} +/> + +## Usage + + + +## API reference + + + +## Examples + +### Surfaces + +} + code={toggleButtonSurfacesSnippet} +/> + +### Sizes + +} + code={toggleButtonSizesSnippet} +/> + +### With Icons + +} + code={toggleButtonIconsSnippet} +/> + +### Disabled + +} + code={toggleButtonDisabledSnippet} +/> + +### Controlled + +} + code={toggleButtonControlledSnippet} +/> + +### Dynamic Content with Function Children + +The `children` prop can be a function that receives render props, allowing you to dynamically customize the button content based on component state (such as `isSelected`, `isDisabled`, `isHovered`, etc.). + +} + code={toggleButtonFunctionChildrenSnippet} +/> + + + + diff --git a/docs-ui/src/content/toggle-button.props.ts b/docs-ui/src/content/toggle-button.props.ts new file mode 100644 index 0000000000..9115b29603 --- /dev/null +++ b/docs-ui/src/content/toggle-button.props.ts @@ -0,0 +1,195 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; + +export const toggleButtonPropDefs: Record = { + size: { + type: 'enum', + values: ['small', 'medium'], + default: 'small', + responsive: true, + }, + onSurface: { + type: 'enum', + values: ['0', '1', '2', '3', 'danger', 'warning', 'success', 'auto'], + description: 'Surface level this toggle is placed on', + responsive: true, + }, + iconStart: { type: 'enum', values: ['ReactNode'] }, + iconEnd: { type: 'enum', values: ['ReactNode'] }, + isSelected: { type: 'boolean' }, + defaultSelected: { type: 'boolean' }, + onChange: { type: 'enum', values: ['(isSelected: boolean) => void'] }, + isDisabled: { type: 'boolean', default: 'false' }, + children: { + type: 'enum', + values: ['ReactNode', '(values: ToggleButtonRenderProps) => ReactNode'], + description: + 'The children of the component. A function may be provided to alter the children based on component state (such as `isSelected`, `isDisabled`, `isHovered`, etc.).', + }, + ...classNamePropDefs, + ...stylePropDefs, +}; + +export const toggleButtonUsageSnippet = `import { ToggleButton } from '@backstage/ui'; + +Toggle`; + +export const toggleButtonSurfacesSnippet = ` + + Default + + Toggle + + + + On Surface 0 + + Toggle + + + + On Surface 1 + + Toggle + + + + On Surface 2 + + Toggle + + + + On Surface 3 + + Toggle + + +`; + +export const toggleButtonSizesSnippet = ` + Small + Medium +`; + +export const toggleButtonIconsSnippet = `import { RiStarLine, RiStarFill, RiCheckLine } from '@remixicon/react'; + + + }>Favorite + } defaultSelected>Starred + }>Confirm +`; + +export const toggleButtonDisabledSnippet = ` + Disabled + Selected +`; + +export const toggleButtonControlledSnippet = `import { useState } from 'react'; +import { RiStarFill, RiStarLine } from '@remixicon/react'; + +const [selected, setSelected] = useState(false); + + : } +> + {selected ? 'Starred' : 'Not starred'} +`; + +export const toggleButtonFunctionChildrenSnippet = `import { RiStarFill, RiStarLine } from '@remixicon/react'; + + + Example 1: Selection State + + + {({ isSelected }) => (isSelected ? '✓ Selected' : 'Not Selected')} + + + {({ isSelected }) => (isSelected ? '✓ Selected' : 'Not Selected')} + + + + + + Example 2: Multiple States + + + {({ isSelected, isHovered }) => { + const states = []; + if (isSelected) states.push('on'); + else states.push('off'); + if (isHovered) states.push('hovered'); + return \`Email (\${states.join(', ')})\`; + }} + + + {({ isSelected, isHovered }) => { + const states = []; + if (isSelected) states.push('on'); + else states.push('off'); + if (isHovered) states.push('hovered'); + return \`Push (\${states.join(', ')})\`; + }} + + + + + + Example 3: Conditional Icons + + + {({ isSelected }) => ( + <> + {isSelected ? : } + {isSelected ? 'Starred' : 'Star'} + + )} + + + + + + Example 4: Status Indicators + + + {({ isSelected }) => ( + + + Active + + )} + + + {({ isSelected }) => ( + + + Inactive + + )} + + + +`; diff --git a/docs-ui/src/snippets/stories-snippets.tsx b/docs-ui/src/snippets/stories-snippets.tsx index 0507c85c9f..ba722a5edb 100644 --- a/docs-ui/src/snippets/stories-snippets.tsx +++ b/docs-ui/src/snippets/stories-snippets.tsx @@ -30,6 +30,8 @@ import * as TableStories from '../../../packages/ui/src/components/Table/stories import * as TagGroupStories from '../../../packages/ui/src/components/TagGroup/TagGroup.stories'; import * as PasswordFieldStories from '../../../packages/ui/src/components/PasswordField/PasswordField.stories'; import * as VisuallyHiddenStories from '../../../packages/ui/src/components/VisuallyHidden/VisuallyHidden.stories'; +import * as ToggleButtonStories from '../../../packages/ui/src/components/ToggleButton/ToggleButton.stories'; +import * as ToggleButtonGroupStories from '../../../packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories'; // Helper function to create snippet components // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -75,6 +77,10 @@ export const HeaderSnippet = createSnippetComponent(HeaderStories); export const HeaderPageSnippet = createSnippetComponent(HeaderPageStories); export const TableSnippet = createSnippetComponent(TableStories); export const TagGroupSnippet = createSnippetComponent(TagGroupStories); +export const ToggleButtonSnippet = createSnippetComponent(ToggleButtonStories); +export const ToggleButtonGroupSnippet = createSnippetComponent( + ToggleButtonGroupStories, +); export const VisuallyHiddenSnippet = createSnippetComponent( VisuallyHiddenStories, ); diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index de369043d1..b20725142c 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -116,6 +116,14 @@ export const components: Page[] = [ title: 'TextField', slug: 'text-field', }, + { + title: 'ToggleButton', + slug: 'toggle-button', + }, + { + title: 'ToggleButtonGroup', + slug: 'toggle-button-group', + }, { title: 'Tooltip', slug: 'tooltip', diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 35edc52d09..b7b6604421 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -51,6 +51,8 @@ import type { TagGroupProps as TagGroupProps_2 } from 'react-aria-components'; import type { TagListProps } from 'react-aria-components'; import type { TagProps as TagProps_2 } from 'react-aria-components'; import type { TextFieldProps as TextFieldProps_2 } from 'react-aria-components'; +import type { ToggleButtonGroupProps as ToggleButtonGroupProps_2 } from 'react-aria-components'; +import type { ToggleButtonProps as ToggleButtonProps_2 } from 'react-aria-components'; import { TooltipProps as TooltipProps_2 } from 'react-aria-components'; import { TooltipTriggerComponentProps } from 'react-aria-components'; @@ -1814,6 +1816,55 @@ export type TextVariants = // @public (undocumented) export type TextWeights = 'regular' | 'bold'; +// @public (undocumented) +export const ToggleButton: ForwardRefExoticComponent< + ToggleButtonProps & RefAttributes +>; + +// @public +export const ToggleButtonDefinition: { + readonly classNames: { + readonly root: 'bui-ToggleButton'; + readonly content: 'bui-ToggleButtonContent'; + }; + readonly dataAttributes: { + readonly size: readonly ['small', 'medium']; + }; +}; + +// @public (undocumented) +export const ToggleButtonGroup: ForwardRefExoticComponent< + ToggleButtonGroupProps & RefAttributes +>; + +// @public +export const ToggleButtonGroupDefinition: { + readonly classNames: { + readonly root: 'bui-ToggleButtonGroup'; + }; + readonly dataAttributes: { + readonly orientation: readonly ['horizontal', 'vertical']; + }; +}; + +// @public (undocumented) +export interface ToggleButtonGroupProps + extends Omit { + // (undocumented) + orientation?: NonNullable; +} + +// @public +export interface ToggleButtonProps extends ToggleButtonProps_2 { + // (undocumented) + iconEnd?: ReactElement; + // (undocumented) + iconStart?: ReactElement; + onSurface?: Responsive; + // (undocumented) + size?: 'small' | 'medium' | Partial>; +} + // @public (undocumented) export const Tooltip: ForwardRefExoticComponent< TooltipProps & RefAttributes diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.module.css b/packages/ui/src/components/ToggleButton/ToggleButton.module.css new file mode 100644 index 0000000000..e23c4d53eb --- /dev/null +++ b/packages/ui/src/components/ToggleButton/ToggleButton.module.css @@ -0,0 +1,109 @@ +/* + * 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. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .bui-ToggleButton { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--bui-space-1_5); + border: none; + border-radius: var(--bui-radius-2); + background: var(--bui-bg-surface-1); + color: var(--bui-fg-primary); + font-family: var(--bui-font-regular); + font-weight: var(--bui-font-weight-bold); + padding: 0 var(--bui-space-2); + cursor: pointer; + transition: background-color 150ms ease, box-shadow 150ms ease, + color 150ms ease, transform 100ms ease; + + &[data-selected], + &[data-pressed] { + background: var(--bui-bg-solid); + color: var(--bui-fg-solid); + } + + &:not([data-selected])[data-hovered] { + background: var(--bui-bg-surface-2); + } + + &[data-disabled] { + background: var(--bui-bg-neutral-on-surface-0-disabled); + color: var(--bui-fg-disabled); + } + + &[data-disabled][data-hovered] { + background: var(--bui-bg-neutral-on-surface-0-disabled); + } + + &[data-disabled][data-selected] { + background: var(--bui-bg-solid-disabled); + color: var(--bui-fg-disabled); + } + } + + .bui-ToggleButton[data-focus-visible] { + outline: none; + box-shadow: inset 0 0 0 2px var(--bui-ring); + } + + .bui-ToggleButton[data-disabled] { + cursor: not-allowed; + } + + .bui-ToggleButton[data-pressed] { + transform: scale(0.98); + } + + .bui-ToggleButton[data-disabled][data-pressed] { + transform: none; + } + + .bui-ToggleButton[data-size='small'] { + height: 2rem; + font-size: var(--bui-font-size-3); + padding: 0 var(--bui-space-2); + + svg { + width: 1rem; + height: 1rem; + } + } + + .bui-ToggleButton[data-size='medium'] { + height: 2.5rem; + font-size: var(--bui-font-size-4); + padding: 0 var(--bui-space-3); + + svg { + width: 1.25rem; + height: 1.25rem; + } + } + + .bui-ToggleButtonContent { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--bui-space-1_5); + height: 100%; + width: 100%; + } +} diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx new file mode 100644 index 0000000000..14183e6ede --- /dev/null +++ b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx @@ -0,0 +1,305 @@ +/* + * 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 preview from '../../../../../.storybook/preview'; +import { ToggleButton } from './ToggleButton'; +import { Flex } from '../Flex'; +import { Text } from '../Text'; +import { useState } from 'react'; +import { + RiCheckLine, + RiStarFill, + RiStarLine, + RiCloudLine, + RiArrowRightSLine, +} from '@remixicon/react'; + +const meta = preview.meta({ + title: 'Backstage UI/ToggleButton', + component: ToggleButton, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium'], + }, + }, +}); + +export const Default = meta.story({ + args: { + children: 'Toggle', + }, +}); + +export const Surfaces = meta.story({ + args: { + children: 'Toggle', + }, + parameters: { + argTypes: { + size: { + control: false, + }, + }, + }, + render: () => ( + + + Default + + Toggle + + + + On Surface 0 + + Toggle + + + + On Surface 1 + + Toggle + + + + On Surface 2 + + Toggle + + + + On Surface 3 + + Toggle + + + + ), +}); + +export const Sizes = meta.story({ + args: { + children: 'Toggle', + }, + parameters: { + argTypes: { + size: { + control: false, + }, + }, + }, + render: () => ( + + Small + Medium + + ), +}); + +export const WithIcons = meta.story({ + args: { + children: 'Favorite', + }, + render: args => ( + + } /> + } defaultSelected /> + } /> + } /> + + ), +}); + +export const IconsOnly = meta.story({ + render: () => ( + + } /> + } + /> + } /> + + ), +}); + +export const IconsAndText = meta.story({ + render: () => ( + + }>Cloud + }>Star + }>Next + + ), +}); + +export const Disabled = meta.story({ + render: () => ( + + Disabled + + Selected + + + ), +}); + +export const Controlled = meta.story({ + render: () => { + const [selected, setSelected] = useState(false); + return ( + + : } + > + {selected ? 'Starred' : 'Not starred'} + + State: {selected ? 'selected' : 'unselected'} + + ); + }, +}); + +export const FunctionChildren = meta.story({ + render: () => { + const [disabled, setDisabled] = useState(false); + return ( + + + + {({ isDisabled, isSelected }) => + isDisabled + ? `Disabled ${isSelected ? '(Selected)' : '(Unselected)'}` + : `Enabled ${isSelected ? '(Selected)' : '(Unselected)'}` + } + + setDisabled(!disabled)} + > + {disabled ? 'Enable' : 'Disable'} + + + + Toggle the button to change the disabled state and see text update + + + ); + }, +}); + +export const DynamicContent = meta.story({ + render: () => { + return ( + + + Example 1: Selection State + + + {({ isSelected }) => (isSelected ? '✓ Selected' : 'Not Selected')} + + + {({ isSelected }) => (isSelected ? '✓ Selected' : 'Not Selected')} + + + + + + Example 2: Multiple States + + + {({ isSelected, isHovered }) => { + const states = []; + if (isSelected) states.push('on'); + else states.push('off'); + if (isHovered) states.push('hovered'); + return `Email (${states.join(', ')})`; + }} + + + {({ isSelected, isHovered }) => { + const states = []; + if (isSelected) states.push('on'); + else states.push('off'); + if (isHovered) states.push('hovered'); + return `Push (${states.join(', ')})`; + }} + + + + + + Example 3: Conditional Icons + + + {({ isSelected }) => ( + <> + {isSelected ? : } + {isSelected ? 'Starred' : 'Star'} + + )} + + + + + + Example 4: Status Indicators + + + {({ isSelected }) => ( + + + Active + + )} + + + {({ isSelected }) => ( + + + Inactive + + )} + + + + + ); + }, +}); diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.tsx new file mode 100644 index 0000000000..9c96304a80 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/ToggleButton.tsx @@ -0,0 +1,71 @@ +/* + * 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 clsx from 'clsx'; +import { forwardRef, Ref } from 'react'; +import { ToggleButton as AriaToggleButton } from 'react-aria-components'; +import type { ToggleButtonProps } from './types'; +import { useStyles } from '../../hooks/useStyles'; +import { ToggleButtonDefinition } from './definition'; +import styles from './ToggleButton.module.css'; +import { useSurface } from '../../hooks/useSurface'; + +/** @public */ +export const ToggleButton = forwardRef( + (props: ToggleButtonProps, ref: Ref) => { + const { classNames, dataAttributes, cleanedProps } = useStyles( + ToggleButtonDefinition, + { + size: 'small', + ...props, + }, + ); + + const { children, className, iconStart, iconEnd, onSurface, ...rest } = + cleanedProps; + + const { surface } = useSurface({ onSurface }); + + return ( + + {renderProps => { + // If children is a function, call it with render props; otherwise use children as-is + const renderedChildren = + typeof children === 'function' ? children(renderProps) : children; + + return ( + + {iconStart} + {renderedChildren} + {iconEnd} + + ); + }} + + ); + }, +); + +ToggleButton.displayName = 'ToggleButton'; diff --git a/packages/ui/src/components/ToggleButton/definition.ts b/packages/ui/src/components/ToggleButton/definition.ts new file mode 100644 index 0000000000..98c22fef35 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/definition.ts @@ -0,0 +1,31 @@ +/* + * 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 { ComponentDefinition } from '../../types'; + +/** + * Component definition for ToggleButton + * @public + */ +export const ToggleButtonDefinition = { + classNames: { + root: 'bui-ToggleButton', + content: 'bui-ToggleButtonContent', + }, + dataAttributes: { + size: ['small', 'medium'] as const, + }, +} as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/ToggleButton/index.ts b/packages/ui/src/components/ToggleButton/index.ts new file mode 100644 index 0000000000..2d50f072e4 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/index.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export * from './ToggleButton'; +export * from './types'; +export { ToggleButtonDefinition } from './definition'; diff --git a/packages/ui/src/components/ToggleButton/types.ts b/packages/ui/src/components/ToggleButton/types.ts new file mode 100644 index 0000000000..de70bd3c29 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/types.ts @@ -0,0 +1,33 @@ +/* + * 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 { Breakpoint } from '../..'; +import type { ReactElement } from 'react'; +import type { ToggleButtonProps as AriaToggleButtonProps } from 'react-aria-components'; +import type { Responsive, Surface } from '../../types'; + +/** + * Properties for {@link ToggleButton} + * + * @public + */ +export interface ToggleButtonProps extends AriaToggleButtonProps { + size?: 'small' | 'medium' | Partial>; + iconStart?: ReactElement; + iconEnd?: ReactElement; + /** Surface the toggle button is placed on. Defaults to context surface if available */ + onSurface?: Responsive; +} diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css new file mode 100644 index 0000000000..90bedd1756 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css @@ -0,0 +1,109 @@ +/* + * 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. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .bui-ToggleButtonGroup { + display: inline-flex; + align-items: center; + flex-wrap: nowrap; + border-radius: var(--bui-radius-2); + overflow: hidden; + box-shadow: inset 0 0 0 1px var(--bui-border); + width: fit-content; + } + + .bui-ToggleButtonGroup[data-orientation='vertical'] { + flex-direction: column; + align-items: stretch; + width: fit-content; + } + + .bui-ToggleButtonGroup > :global(.bui-ToggleButton) { + border-radius: 0; + margin: 0; + box-shadow: none; + border: 0; + } + + /* Horizontal radius rules (default orientation) */ + .bui-ToggleButtonGroup:not([data-orientation='vertical']) + > :global(.bui-ToggleButton) { + border-radius: 0; + } + + .bui-ToggleButtonGroup:not([data-orientation='vertical']) + > :global(.bui-ToggleButton):first-child { + border-radius: var(--bui-radius-2) 0 0 var(--bui-radius-2); + } + + .bui-ToggleButtonGroup:not([data-orientation='vertical']) + > :global(.bui-ToggleButton):last-child { + border-radius: 0 var(--bui-radius-2) var(--bui-radius-2) 0; + } + + .bui-ToggleButtonGroup:not([data-orientation='vertical']) + > :global(.bui-ToggleButton):only-child { + border-radius: var(--bui-radius-2); + } + + /* Horizontal dividers */ + .bui-ToggleButtonGroup:not([data-orientation='vertical']) + :global(.bui-ToggleButton) + + :global(.bui-ToggleButton) { + border-left: 1px solid var(--bui-border); + } + + /* Vertical dividers */ + .bui-ToggleButtonGroup[data-orientation='vertical'] + :global(.bui-ToggleButton) { + width: 100%; + } + + .bui-ToggleButtonGroup[data-orientation='vertical'] + :global(.bui-ToggleButton) + + :global(.bui-ToggleButton) { + border-top: 1px solid var(--bui-border); + } + + /* Vertical radius rules */ + .bui-ToggleButtonGroup[data-orientation='vertical'] + > :global(.bui-ToggleButton) { + border-radius: 0; + } + + .bui-ToggleButtonGroup[data-orientation='vertical'] + > :global(.bui-ToggleButton):first-child { + border-radius: var(--bui-radius-2) var(--bui-radius-2) 0 0; + } + + .bui-ToggleButtonGroup[data-orientation='vertical'] + > :global(.bui-ToggleButton):last-child { + border-radius: 0 0 var(--bui-radius-2) var(--bui-radius-2); + } + + .bui-ToggleButtonGroup[data-orientation='vertical'] + > :global(.bui-ToggleButton):only-child { + border-radius: var(--bui-radius-2); + } + + /* Focus ring on group surface */ + .bui-ToggleButtonGroup:focus-visible { + outline: 2px solid var(--bui-ring); + outline-offset: 2px; + } +} diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx new file mode 100644 index 0000000000..30e53f0669 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx @@ -0,0 +1,265 @@ +/* + * 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 preview from '../../../../../.storybook/preview'; +import { ToggleButtonGroup } from './ToggleButtonGroup'; +import { ToggleButton } from '../ToggleButton/ToggleButton'; +import { Flex } from '../Flex'; +import { Text } from '../Text'; +import { useState } from 'react'; +import type { Selection } from 'react-aria-components'; +import { + RiCloudLine, + RiStarLine, + RiStarFill, + RiArrowRightSLine, +} from '@remixicon/react'; + +const meta = preview.meta({ + title: 'Backstage UI/ToggleButtonGroup', + component: ToggleButtonGroup, + argTypes: { + selectionMode: { + control: 'select', + options: ['single', 'multiple'], + }, + orientation: { + control: 'select', + options: ['horizontal', 'vertical'], + }, + }, +}); + +export const SingleSelection = meta.story({ + args: { + selectionMode: 'single', + defaultSelectedKeys: ['dogs'], + }, + render: args => ( + + Dogs + Cats + Birds + + ), +}); + +export const MultipleSelection = meta.story({ + args: { + selectionMode: 'multiple', + defaultSelectedKeys: ['frontend'], + }, + render: args => ( + + Frontend + Backend + Platform + + ), +}); + +export const Surfaces = meta.story({ + args: { + selectionMode: 'single', + defaultSelectedKeys: ['option1'], + }, + parameters: { + argTypes: { + selectionMode: { + control: false, + }, + }, + }, + render: () => ( + + + Default + + + Option 1 + Option 2 + Option 3 + + + + + On Surface 0 + + + Option 1 + Option 2 + Option 3 + + + + + On Surface 1 + + + Option 1 + Option 2 + Option 3 + + + + + On Surface 2 + + + Option 1 + Option 2 + Option 3 + + + + + On Surface 3 + + + Option 1 + Option 2 + Option 3 + + + + + ), +}); + +export const DisabledGroup = meta.story({ + args: { + selectionMode: 'single', + isDisabled: true, + }, + render: args => ( + + Cat + Dog + Bird + + ), +}); + +export const DisallowEmptySelection = meta.story({ + args: { + selectionMode: 'single', + disallowEmptySelection: true, + defaultSelectedKeys: ['one'], + }, + render: args => ( + + One + Two + Three + + ), +}); + +export const MixedDisabled = meta.story({ + render: () => ( + + One + + Two + + Three + + ), +}); + +export const Orientation = meta.story({ + args: { + orientation: 'vertical', + }, + render: args => ( + + Morning + Afternoon + Evening + + ), +}); + +export const ControlledGroup = meta.story({ + render: () => { + const [selectedKeys, setSelectedKeys] = useState( + new Set(['beta']), + ); + + return ( + + + Alpha + Beta + Gamma + + Selected: {[...selectedKeys].join(', ') || 'none'} + + ); + }, +}); + +export const WithIcons = meta.story({ + args: { + selectionMode: 'single', + }, + + render: () => ( + + } /> + } + /> + }> + Star + + }> + Next + + + ), +}); + +export const IconsOnly = meta.story({ + render: () => ( + + } /> + } /> + } /> + + ), +}); diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx new file mode 100644 index 0000000000..43449f8472 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -0,0 +1,50 @@ +/* + * 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 clsx from 'clsx'; +import { forwardRef, Ref } from 'react'; +import { ToggleButtonGroup as AriaToggleButtonGroup } from 'react-aria-components'; +import type { ToggleButtonGroupProps } from './types'; +import { useStyles } from '../../hooks/useStyles'; +import { ToggleButtonGroupDefinition } from './definition'; +import styles from './ToggleButtonGroup.module.css'; + +/** @public */ +export const ToggleButtonGroup = forwardRef( + (props: ToggleButtonGroupProps, ref: Ref) => { + const { classNames, dataAttributes, cleanedProps } = useStyles( + ToggleButtonGroupDefinition, + { + ...props, + }, + ); + + const { className, children, ...rest } = cleanedProps; + + return ( + + {children} + + ); + }, +); + +ToggleButtonGroup.displayName = 'ToggleButtonGroup'; diff --git a/packages/ui/src/components/ToggleButtonGroup/definition.ts b/packages/ui/src/components/ToggleButtonGroup/definition.ts new file mode 100644 index 0000000000..7acf6828f0 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/definition.ts @@ -0,0 +1,30 @@ +/* + * 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 { ComponentDefinition } from '../../types'; + +/** + * Component definition for ToggleButtonGroup + * @public + */ +export const ToggleButtonGroupDefinition = { + classNames: { + root: 'bui-ToggleButtonGroup', + }, + dataAttributes: { + orientation: ['horizontal', 'vertical'] as const, + }, +} as const satisfies ComponentDefinition; diff --git a/packages/ui/src/components/ToggleButtonGroup/index.ts b/packages/ui/src/components/ToggleButtonGroup/index.ts new file mode 100644 index 0000000000..63893ec3f4 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/index.ts @@ -0,0 +1,19 @@ +/* + * 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. + */ + +export * from './ToggleButtonGroup'; +export * from './types'; +export { ToggleButtonGroupDefinition } from './definition'; diff --git a/packages/ui/src/components/ToggleButtonGroup/types.ts b/packages/ui/src/components/ToggleButtonGroup/types.ts new file mode 100644 index 0000000000..7a57708750 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/types.ts @@ -0,0 +1,23 @@ +/* + * 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 { ToggleButtonGroupProps as AriaToggleButtonGroupProps } from 'react-aria-components'; + +/** @public */ +export interface ToggleButtonGroupProps + extends Omit { + orientation?: NonNullable; +} diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index a0dc412697..6c470e4d37 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -47,6 +47,8 @@ export { SearchFieldDefinition } from './components/SearchField/definition'; export { SelectDefinition } from './components/Select/definition'; export { SkeletonDefinition } from './components/Skeleton/definition'; export { SwitchDefinition } from './components/Switch/definition'; +export { ToggleButtonDefinition } from './components/ToggleButton/definition'; +export { ToggleButtonGroupDefinition } from './components/ToggleButtonGroup/definition'; export { TableDefinition } from './components/Table/definition'; export { TablePaginationDefinition } from './components/TablePagination/definition'; export { TabsDefinition } from './components/Tabs/definition'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 03ae3245e3..1c8529f1e6 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -52,6 +52,8 @@ export * from './components/Link'; export * from './components/Select'; export * from './components/Skeleton'; export * from './components/Switch'; +export * from './components/ToggleButton'; +export * from './components/ToggleButtonGroup'; export * from './components/VisuallyHidden'; // Types