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..fa979d65fe --- /dev/null +++ b/docs-ui/src/content/toggle-button-group.mdx @@ -0,0 +1,115 @@ +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, +} 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 + +### 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..94299f71a3 --- /dev/null +++ b/docs-ui/src/content/toggle-button-group.props.ts @@ -0,0 +1,75 @@ +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 +`; + +export const toggleButtonGroupSingleSnippet = ` + Apples + Oranges + Bananas +`; + +export const toggleButtonGroupMultipleSnippet = ` + Frontend + Backend + Platform +`; + +export const toggleButtonGroupVerticalSnippet = ` + Low + Medium + High +`; + +export const toggleButtonGroupDisabledSnippet = ` + Cat + Dog + Bird +`; + +export const toggleButtonGroupDisallowEmptySnippet = ` + One + Two + Three +`; + +export const toggleButtonGroupIconsSnippet = ` + Cloud + Starred + Next +`; + +export const toggleButtonGroupIconsOnlySnippet = ` + + + +`; diff --git a/docs-ui/src/content/toggle-button.mdx b/docs-ui/src/content/toggle-button.mdx new file mode 100644 index 0000000000..2ec644cdd5 --- /dev/null +++ b/docs-ui/src/content/toggle-button.mdx @@ -0,0 +1,94 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { ToggleButtonSnippet } from '@/snippets/stories-snippets'; +import { + toggleButtonPropDefs, + toggleButtonUsageSnippet, + toggleButtonVariantsSnippet, + toggleButtonSizesSnippet, + toggleButtonIconsSnippet, + toggleButtonDisabledSnippet, + toggleButtonControlledSnippet, +} 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={toggleButtonVariantsSnippet} +/> + +## Usage + + + +## API reference + + + +## Examples + +### Variants + +} + code={toggleButtonVariantsSnippet} +/> + +### Sizes + +} + code={toggleButtonSizesSnippet} +/> + +### With Icons + +} + code={toggleButtonIconsSnippet} +/> + +### Disabled + +} + code={toggleButtonDisabledSnippet} +/> + +### Controlled + +} + code={toggleButtonControlledSnippet} +/> + + + + + 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..71625e7e6f --- /dev/null +++ b/docs-ui/src/content/toggle-button.props.ts @@ -0,0 +1,59 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; + +export const toggleButtonPropDefs: Record = { + variant: { + type: 'enum', + values: ['primary', 'secondary'], + default: 'primary', + responsive: true, + }, + size: { + type: 'enum', + values: ['small', 'medium'], + default: 'small', + 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'] }, + ...classNamePropDefs, + ...stylePropDefs, +}; + +export const toggleButtonUsageSnippet = `import { ToggleButton } from '@backstage/ui'; + +Toggle`; + +export const toggleButtonVariantsSnippet = ` + Primary + Secondary +`; + +export const toggleButtonSizesSnippet = ` + Small + Medium +`; + +export const toggleButtonIconsSnippet = ` + Favorite + Confirm +`; + +export const toggleButtonDisabledSnippet = ` + Disabled + Selected +`; + +export const toggleButtonControlledSnippet = `const [selected, setSelected] = useState(false); + + + {selected ? 'On' : 'Off'} +`; 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/src/components/ToggleButton/ToggleButton.module.css b/packages/ui/src/components/ToggleButton/ToggleButton.module.css new file mode 100644 index 0000000000..ad3b173f04 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/ToggleButton.module.css @@ -0,0 +1,106 @@ +/* + * 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; + } + + .bui-ToggleButton[data-variant='secondary'] { + background: transparent; + color: var(--bui-fg-primary); + box-shadow: inset 0 0 0 1px var(--bui-border); + } + + .bui-ToggleButton[data-variant='primary'][data-selected], + .bui-ToggleButton[data-variant='primary'][data-pressed] { + background: var(--bui-bg-solid); + color: var(--bui-fg-solid); + } + + .bui-ToggleButton[data-variant='primary']:not([data-selected])[data-hovered] { + background: var(--bui-bg-surface-2); + } + + .bui-ToggleButton[data-variant='secondary'][data-selected], + .bui-ToggleButton[data-variant='secondary'][data-pressed] { + background: var(--bui-bg-surface-2); + box-shadow: inset 0 0 0 1px var(--bui-border-hover); + } + + .bui-ToggleButton[data-focus-visible] { + outline: 2px solid var(--bui-ring); + outline-offset: 2px; + } + + .bui-ToggleButton[data-disabled] { + cursor: not-allowed; + color: var(--bui-fg-disabled); + background: var(--bui-bg-disabled); + box-shadow: inset 0 0 0 1px var(--bui-border-disabled); + } + + .bui-ToggleButton[data-pressed]:not([data-disabled]) { + transform: scale(0.98); + } + + .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..55395542e3 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/ToggleButton.stories.tsx @@ -0,0 +1,226 @@ +/* + * 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 { ToggleButtonGroup } from '../ToggleButtonGroup/ToggleButtonGroup'; +import { Flex } from '../Flex'; +import { Text } from '../Text'; +import { useState } from 'react'; +import { + RiCheckLine, + RiHeartLine, + RiStarFill, + RiStarLine, + RiCloudLine, + RiArrowRightSLine, +} from '@remixicon/react'; + +const meta = preview.meta({ + title: 'Backstage UI/ToggleButton', + component: ToggleButton, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium'], + }, + variant: { + control: 'select', + options: ['primary', 'secondary'], + }, + }, +}); + +export const Default = meta.story({ + args: { + children: 'Toggle', + }, +}); + +export const Variants = meta.story({ + args: { + children: 'Toggle', + }, + parameters: { + argTypes: { + variant: { + control: false, + }, + }, + }, + render: () => ( + + Primary + Secondary + + ), +}); + +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 MixedIcons = meta.story({ + render: () => ( + + }> + Cloud + + } /> + }> + Next + + + ), +}); + +export const Disabled = meta.story({ + render: () => ( + + + Primary + + + Secondary + + + Selected + + + ), +}); + +export const Controlled = meta.story({ + render: () => { + const [selected, setSelected] = useState(false); + return ( + + : } + > + {selected ? 'Starred' : 'Not starred'} + + State: {selected ? 'selected' : 'unselected'} + + ); + }, +}); + +export const GroupSingle = meta.story({ + render: () => ( + + Apples + Oranges + Bananas + + ), +}); + +export const GroupMultiple = meta.story({ + render: () => ( + + Dogs + Cats + Rabbits + + ), +}); + +export const GroupWithIcons = meta.story({ + render: () => ( + + }> + Chill + + }> + Focus + + }> + Party + + + ), +}); + +export const VerticalGroup = meta.story({ + render: () => ( + + Low + Medium + High + + ), +}); diff --git a/packages/ui/src/components/ToggleButton/ToggleButton.tsx b/packages/ui/src/components/ToggleButton/ToggleButton.tsx new file mode 100644 index 0000000000..e688b29c96 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/ToggleButton.tsx @@ -0,0 +1,59 @@ +/* + * 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'; + +/** @public */ +export const ToggleButton = forwardRef( + (props: ToggleButtonProps, ref: Ref) => { + const { classNames, dataAttributes, cleanedProps } = useStyles( + ToggleButtonDefinition, + { + size: 'small', + variant: 'primary', + ...props, + }, + ); + + const { children, className, iconStart, iconEnd, ...rest } = cleanedProps; + + return ( + + + {iconStart} + {children} + {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..78e026eed8 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/definition.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 { ComponentDefinition } from '../../types'; + +/** + * Component definition for ToggleButton + * @public + */ +export const ToggleButtonDefinition = { + classNames: { + root: 'bui-ToggleButton', + content: 'bui-ToggleButtonContent', + }, + dataAttributes: { + size: ['small', 'medium'] as const, + variant: ['primary', 'secondary'] 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..9bffb72c83 --- /dev/null +++ b/packages/ui/src/components/ToggleButton/types.ts @@ -0,0 +1,35 @@ +/* + * 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, ReactNode } from 'react'; +import type { ToggleButtonProps as AriaToggleButtonProps } from 'react-aria-components'; + +/** + * Properties for {@link ToggleButton} + * + * @public + */ +export interface ToggleButtonProps extends AriaToggleButtonProps { + size?: 'small' | 'medium' | Partial>; + variant?: + | 'primary' + | 'secondary' + | Partial>; + iconStart?: ReactElement; + iconEnd?: ReactElement; + children?: ReactNode; +} 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..173679f64b --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css @@ -0,0 +1,112 @@ +/* + * 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); + } + + .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; + } + + .bui-ToggleButtonGroup :global(.bui-ToggleButton)[data-variant='secondary'] { + box-shadow: none; + } + + /* 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..2294069f71 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.stories.tsx @@ -0,0 +1,179 @@ +/* + * 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 { + 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 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 ( + + setSelectedKeys(new Set(keys))} + > + 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..26b4eb51c8 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -0,0 +1,51 @@ +/* + * 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, + { + orientation: 'horizontal', + ...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..d0de983f66 --- /dev/null +++ b/packages/ui/src/components/ToggleButtonGroup/types.ts @@ -0,0 +1,27 @@ +/* + * 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 { ToggleButtonGroupProps as AriaToggleButtonGroupProps } from 'react-aria-components'; + +/** @public */ +export interface ToggleButtonGroupProps + extends AriaToggleButtonGroupProps { + orientation?: + | 'horizontal' + | 'vertical' + | Partial>; +} 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