diff --git a/.changeset/add-listbox-component.md b/.changeset/add-listbox-component.md new file mode 100644 index 0000000000..4c510e61f9 --- /dev/null +++ b/.changeset/add-listbox-component.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added `List` and `ListRow` components. These provide a standalone, accessible list of interactive rows built on top of React Aria's `GridList` and `GridListItem` primitives. Rows support icons, descriptions, actions, menus, and single or multiple selection modes. + +**Affected components:** List, ListRow diff --git a/.changeset/fix-focus-visible-data-attr.md b/.changeset/fix-focus-visible-data-attr.md new file mode 100644 index 0000000000..5e4f192d4f --- /dev/null +++ b/.changeset/fix-focus-visible-data-attr.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed focus ring styles to use React Aria's `[data-focus-visible]` data attribute instead of the native CSS `:focus-visible` pseudo-class. This ensures keyboard focus rings render reliably when focus is managed programmatically by React Aria (e.g. inside a GridList, Menu, or Select). + +**Affected components:** Accordion, Button, ButtonIcon, ButtonLink, Card, List, Menu, Select, ToggleButtonGroup diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 9734cee295..022b6123e2 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -88,6 +88,7 @@ export default definePreview({ storySort: { order: [ 'Backstage UI', + 'Recipes', 'Guidelines', 'Plugins', 'Layout', diff --git a/docs-ui/src/app/components/list/components.tsx b/docs-ui/src/app/components/list/components.tsx new file mode 100644 index 0000000000..5630027f94 --- /dev/null +++ b/docs-ui/src/app/components/list/components.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { + List, + ListRow, +} from '../../../../../packages/ui/src/components/List/List'; +import { MenuItem } from '../../../../../packages/ui/src/components/Menu/Menu'; +import { + TagGroup, + Tag, +} from '../../../../../packages/ui/src/components/TagGroup/TagGroup'; +import { useState } from 'react'; +import type { Selection } from 'react-aria-components'; +import { + RiJavascriptLine, + RiReactjsLine, + RiShipLine, + RiTerminalLine, + RiCodeLine, + RiDeleteBinLine, + RiEdit2Line, + RiShareBoxLine, +} from '@remixicon/react'; + +const items = [ + { + id: 'react', + label: 'React', + description: 'A JavaScript library for building user interfaces', + icon: , + tags: ['frontend', 'ui'], + }, + { + id: 'typescript', + label: 'TypeScript', + description: 'Typed superset of JavaScript', + icon: , + tags: ['typed', 'js'], + }, + { + id: 'javascript', + label: 'JavaScript', + description: 'The language of the web', + icon: , + tags: ['web'], + }, + { + id: 'rust', + label: 'Rust', + description: 'Systems programming with memory safety', + icon: , + tags: ['systems', 'fast'], + }, + { + id: 'go', + label: 'Go', + description: 'Simple, fast, and reliable', + icon: , + tags: ['backend'], + }, +]; + +const menuItems = ( + <> + }>Edit + }>Share + } color="danger"> + Delete + + +); + +export const Default = () => ( + + {item => ( + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + )} + +); + +export const WithIcons = () => ( + + {item => ( + + {item.label} + + )} + +); + +export const WithDescription = () => ( + + {item => ( + + {item.label} + + )} + +); + +export const SelectionModeSingle = () => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {item => {item.label}} + + ); +}; + +export const SelectionModeMultiple = () => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {item => {item.label}} + + ); +}; + +export const Disabled = () => ( + + {item => {item.label}} + +); diff --git a/docs-ui/src/app/components/list/page.mdx b/docs-ui/src/app/components/list/page.mdx new file mode 100644 index 0000000000..dadeb9c1ee --- /dev/null +++ b/docs-ui/src/app/components/list/page.mdx @@ -0,0 +1,103 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { ReactAriaLink } from '@/components/ReactAriaLink'; +import { + Default, + WithIcons, + WithDescription, + SelectionModeSingle, + SelectionModeMultiple, + Disabled, +} from './components'; +import { listPropDefs, listRowPropDefs } from './props-definition'; +import { + usage, + preview, + withIcons, + withDescription, + selectionModeSingle, + selectionModeMultiple, + disabled, +} from './snippets'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { ListDefinition, ListRowDefinition } from '../../../utils/definitions'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; + +export const reactAriaUrls = { + gridList: 'https://react-aria.adobe.com/GridList', +}; + + + +} code={preview} /> + +## Usage + + + +## API reference + +### List + +Container for a list of interactive rows. + + + + + +### ListRow + +Individual row within a List. + + + + + +## Examples + +### With icons + +} code={withIcons} /> + +### With description + +} + code={withDescription} +/> + +### Single selection + +} + code={selectionModeSingle} +/> + +### Multiple selection + +} + code={selectionModeMultiple} +/> + +### Disabled items + +} code={disabled} /> + + + + diff --git a/docs-ui/src/app/components/list/props-definition.tsx b/docs-ui/src/app/components/list/props-definition.tsx new file mode 100644 index 0000000000..b021fc6e0a --- /dev/null +++ b/docs-ui/src/app/components/list/props-definition.tsx @@ -0,0 +1,84 @@ +import { + classNamePropDefs, + childrenPropDefs, + type PropDef, +} from '@/utils/propDefs'; + +export const listPropDefs: Record = { + items: { + type: 'enum', + values: ['Iterable'], + description: 'Item objects in the collection.', + }, + renderEmptyState: { + type: 'enum', + values: ['(props: GridListRenderProps) => ReactNode'], + description: 'Content to display when the collection is empty.', + }, + selectionMode: { + type: 'enum', + values: ['none', 'single', 'multiple'], + description: 'The type of selection allowed.', + }, + selectedKeys: { + type: 'enum', + values: ['all', 'Iterable'], + description: 'The currently selected keys (controlled).', + }, + defaultSelectedKeys: { + type: 'enum', + values: ['all', 'Iterable'], + description: 'The initial selected keys (uncontrolled).', + }, + disabledKeys: { + type: 'enum', + values: ['Iterable'], + description: 'Keys of items that should be disabled.', + }, + onSelectionChange: { + type: 'enum', + values: ['(keys: Selection) => void'], + description: 'Handler called when the selection changes.', + }, + ...childrenPropDefs, + ...classNamePropDefs, +}; + +export const listRowPropDefs: Record = { + id: { + type: 'string', + description: 'Unique identifier for the row.', + }, + textValue: { + type: 'string', + description: + 'Text value for accessibility. Derived from children if string.', + }, + icon: { + type: 'enum', + values: ['ReactNode'], + description: 'Icon displayed before the row label.', + }, + description: { + type: 'string', + description: 'Secondary description text displayed below the label.', + }, + isDisabled: { + type: 'boolean', + description: 'Whether the row is disabled.', + }, + menuItems: { + type: 'enum', + values: ['ReactNode'], + description: + 'Menu items rendered inside an automatically managed dropdown. Pass MenuItem nodes.', + }, + customActions: { + type: 'enum', + values: ['ReactNode'], + description: + 'Custom action elements displayed on the right side of the row, e.g. tags.', + }, + ...childrenPropDefs, + ...classNamePropDefs, +}; diff --git a/docs-ui/src/app/components/list/snippets.ts b/docs-ui/src/app/components/list/snippets.ts new file mode 100644 index 0000000000..ccaaf7e28d --- /dev/null +++ b/docs-ui/src/app/components/list/snippets.ts @@ -0,0 +1,72 @@ +export const usage = `import { List, ListRow } from '@backstage/ui'; + + + {item => {item.label}} +`; + +export const preview = ` + {item => ( + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + )} +`; + +export const withIcons = ` + {item => ( + + {item.label} + + )} +`; + +export const withDescription = ` + {item => ( + + {item.label} + + )} +`; + +export const selectionModeSingle = `const [selected, setSelected] = useState(new Set(['react'])); + + + {item => {item.label}} +`; + +export const selectionModeMultiple = `const [selected, setSelected] = useState(new Set(['react', 'typescript'])); + + + {item => {item.label}} +`; + +export const disabled = ` + {item => {item.label}} +`; diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index fc1f841b55..3431563835 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -69,6 +69,10 @@ export const components: Page[] = [ title: 'Link', slug: 'link', }, + { + title: 'List', + slug: 'list', + }, { title: 'Menu', slug: 'menu', diff --git a/docs-ui/src/utils/types.ts b/docs-ui/src/utils/types.ts index 670bee3f05..5f00c5be5d 100644 --- a/docs-ui/src/utils/types.ts +++ b/docs-ui/src/utils/types.ts @@ -19,6 +19,8 @@ export type Component = | 'heading' | 'icon' | 'link' + | 'list' + | 'list-row' | 'menu' | 'password-field' | 'radio-group' diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 406ac5316e..0b11df6055 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -19,6 +19,8 @@ import type { DisclosurePanelProps } from 'react-aria-components'; import type { DisclosureProps } from 'react-aria-components'; import type { ElementType } from 'react'; import { ForwardRefExoticComponent } from 'react'; +import type { GridListItemProps } from 'react-aria-components'; +import type { GridListProps } from 'react-aria-components'; import type { HeadingProps } from 'react-aria-components'; import type { HTMLAttributes } from 'react'; import { JSX as JSX_2 } from 'react/jsx-runtime'; @@ -1554,6 +1556,80 @@ export interface LinkProps extends Omit, LinkOwnProps {} +// @public +export const List: (props: ListProps) => JSX_2.Element; + +// @public +export const ListDefinition: { + readonly styles: { + readonly [key: string]: string; + }; + readonly classNames: { + readonly root: 'bui-List'; + }; + readonly propDefs: { + readonly items: {}; + readonly children: {}; + readonly renderEmptyState: {}; + readonly className: {}; + }; +}; + +// @public +export type ListOwnProps = { + items?: GridListProps['items']; + children?: GridListProps['children']; + renderEmptyState?: GridListProps['renderEmptyState']; + className?: string; +}; + +// @public +export interface ListProps + extends ListOwnProps, + Omit, keyof ListOwnProps> {} + +// @public +export const ListRow: (props: ListRowProps) => JSX_2.Element; + +// @public +export const ListRowDefinition: { + readonly styles: { + readonly [key: string]: string; + }; + readonly bg: 'consumer'; + readonly classNames: { + readonly root: 'bui-ListRow'; + readonly check: 'bui-ListRowCheck'; + readonly icon: 'bui-ListRowIcon'; + readonly label: 'bui-ListRowLabel'; + readonly description: 'bui-ListRowDescription'; + readonly actions: 'bui-ListRowActions'; + }; + readonly propDefs: { + readonly children: {}; + readonly description: {}; + readonly icon: {}; + readonly menuItems: {}; + readonly customActions: {}; + readonly className: {}; + }; +}; + +// @public +export type ListRowOwnProps = { + children?: React.ReactNode; + description?: string; + icon?: React.ReactElement; + menuItems?: React.ReactNode; + customActions?: React.ReactNode; + className?: string; +}; + +// @public +export interface ListRowProps + extends ListRowOwnProps, + Omit {} + // @public (undocumented) export interface MarginProps { // (undocumented) diff --git a/packages/ui/src/components/Accordion/Accordion.module.css b/packages/ui/src/components/Accordion/Accordion.module.css index 23b527c2e7..83ae6c24b8 100644 --- a/packages/ui/src/components/Accordion/Accordion.module.css +++ b/packages/ui/src/components/Accordion/Accordion.module.css @@ -53,7 +53,7 @@ cursor: pointer; text-align: left; - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); diff --git a/packages/ui/src/components/Button/Button.module.css b/packages/ui/src/components/Button/Button.module.css index 322a65279d..8273e77d5a 100644 --- a/packages/ui/src/components/Button/Button.module.css +++ b/packages/ui/src/components/Button/Button.module.css @@ -73,7 +73,7 @@ --fg: var(--bui-fg-solid-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--bui-ring); outline-offset: 2px; } @@ -106,7 +106,7 @@ --bg-active: var(--bg-solid-danger-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--bui-border-danger); outline-offset: 2px; } @@ -143,7 +143,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); @@ -172,7 +172,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { box-shadow: inset 0 0 0 2px var(--bui-border-danger); } } @@ -204,7 +204,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); @@ -232,7 +232,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { box-shadow: inset 0 0 0 2px var(--bui-border-danger); } } diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css b/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css index 128e958594..657b143ed5 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css @@ -73,7 +73,7 @@ --fg: var(--bui-fg-solid-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--bui-ring); outline-offset: 2px; } @@ -110,7 +110,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); @@ -144,7 +144,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.module.css b/packages/ui/src/components/ButtonLink/ButtonLink.module.css index 74a3fe6ba6..3c221ab878 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.module.css +++ b/packages/ui/src/components/ButtonLink/ButtonLink.module.css @@ -67,7 +67,7 @@ --fg: var(--bui-fg-solid-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--bui-ring); outline-offset: 2px; } @@ -103,7 +103,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); @@ -136,7 +136,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index 231c6c55e0..eced185550 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -69,7 +69,7 @@ } } - .bui-Card[data-interactive]:has(.bui-CardTrigger:focus-visible) { + .bui-Card[data-interactive]:has(.bui-CardTrigger[data-focus-visible]) { outline: 2px solid var(--bui-ring); outline-offset: -2px; } diff --git a/packages/ui/src/components/List/List.module.css b/packages/ui/src/components/List/List.module.css new file mode 100644 index 0000000000..9968f0b5bc --- /dev/null +++ b/packages/ui/src/components/List/List.module.css @@ -0,0 +1,195 @@ +/* + * 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-List { + box-sizing: border-box; + outline: none; + display: flex; + flex-direction: column; + + gap: var(--bui-space-3); + + &:has([data-selection-mode]) { + gap: 0; + } + + &[data-focus-visible] { + outline: none; + } + } + + .bui-ListRow { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--bui-space-3); + border-radius: var(--bui-radius-2); + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + color: var(--bui-fg-primary); + outline: none; + + &[data-disabled] { + cursor: not-allowed; + color: var(--bui-fg-disabled); + } + + &[data-focus-visible] { + outline: 2px solid var(--bui-ring); + } + + &[data-selection-mode] { + cursor: pointer; + padding-block: var(--bui-space-2); + padding-inline: var(--bui-space-2); + + &[data-selected] { + &:has(+ [data-selected]) { + border-end-start-radius: 0; + border-end-end-radius: 0; + } + + + [data-selected] { + border-start-start-radius: 0; + border-start-end-radius: 0; + } + } + + &[data-hovered], + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-1-hover); + } + + &[data-pressed], + &[data-selected], + &[data-selected][data-hovered], + &[data-selected][data-focus-visible], + &[data-selected][data-pressed] { + background-color: var(--bui-bg-neutral-1-pressed); + } + + &[data-on-bg='neutral-1'] { + &[data-hovered], + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-2-hover); + } + + &[data-pressed], + &[data-selected], + &[data-selected][data-hovered], + &[data-selected][data-focus-visible], + &[data-selected][data-pressed] { + background-color: var(--bui-bg-neutral-2-pressed); + } + } + + &[data-on-bg='neutral-2'] { + &[data-hovered], + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-3-hover); + } + + &[data-pressed], + &[data-selected], + &[data-selected][data-hovered], + &[data-selected][data-focus-visible], + &[data-selected][data-pressed] { + background-color: var(--bui-bg-neutral-3-pressed); + } + } + + &[data-on-bg='neutral-3'], + &[data-on-bg='neutral-4'] { + &[data-hovered], + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-4-hover); + } + + &[data-pressed], + &[data-selected], + &[data-selected][data-hovered], + &[data-selected][data-focus-visible], + &[data-selected][data-pressed] { + background-color: var(--bui-bg-neutral-4-pressed); + } + } + } + } + + .bui-ListRowCheck { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 1rem; + height: 1rem; + + & svg { + width: 1rem; + height: 1rem; + } + } + + .bui-ListRowIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 2rem; + height: 2rem; + color: var(--bui-fg-secondary); + border-radius: var(--bui-radius-2); + + & svg { + width: 1rem; + height: 1rem; + } + } + + .bui-ListRowLabel { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--bui-space-1); + min-width: 0; + overflow: hidden; + + & > * { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + } + + .bui-ListRowDescription { + font-size: var(--bui-font-size-2); + color: var(--bui-fg-secondary); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + .bui-ListRowActions { + display: flex; + align-items: center; + gap: var(--bui-space-1); + flex-shrink: 0; + margin-left: auto; + } +} diff --git a/packages/ui/src/components/List/List.stories.tsx b/packages/ui/src/components/List/List.stories.tsx new file mode 100644 index 0000000000..224315ddac --- /dev/null +++ b/packages/ui/src/components/List/List.stories.tsx @@ -0,0 +1,290 @@ +/* + * 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 { useState } from 'react'; +import { List, ListRow } from './List'; +import { MenuItem } from '../Menu'; +import { TagGroup, Tag } from '../TagGroup'; +import type { Selection } from 'react-aria-components'; +import { + RiJavascriptLine, + RiReactjsLine, + RiShipLine, + RiTerminalLine, + RiCodeLine, + RiDeleteBinLine, + RiEdit2Line, + RiShareBoxLine, +} from '@remixicon/react'; +import { MemoryRouter } from 'react-router-dom'; + +const meta = preview.meta({ + title: 'Backstage UI/List', + component: List, + args: { + style: { width: 320 }, + 'aria-label': 'List', + }, + decorators: [ + Story => ( + + + + ), + ], +}); + +const items = [ + { + id: 'react', + label: 'React', + description: 'A JavaScript library for building user interfaces', + icon: , + tags: ['frontend', 'ui'], + }, + { + id: 'typescript', + label: 'TypeScript', + description: 'Typed superset of JavaScript', + icon: , + tags: ['typed', 'js'], + }, + { + id: 'javascript', + label: 'JavaScript', + description: 'The language of the web', + icon: , + tags: ['web'], + }, + { + id: 'rust', + label: 'Rust', + description: 'Systems programming with memory safety', + icon: , + tags: ['systems', 'fast'], + }, + { + id: 'go', + label: 'Go', + description: 'Simple, fast, and reliable', + icon: , + tags: ['backend'], + }, +]; + +const menuItems = ( + <> + }>Edit + }>Share + } color="danger"> + Delete + + +); + +export const Default = meta.story({ + render: args => ( + + {item => {item.label}} + + ), +}); + +export const WithIcons = meta.story({ + render: args => ( + + {item => ( + + {item.label} + + )} + + ), +}); + +export const WithDescription = meta.story({ + args: { + style: { width: 340 }, + }, + render: args => ( + + {item => ( + + {item.label} + + )} + + ), +}); + +export const SelectionModeSingle = meta.story({ + render: args => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {item => {item.label}} + + ); + }, +}); + +export const SelectionModeSingleWithIcons = meta.story({ + render: args => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {item => ( + + {item.label} + + )} + + ); + }, +}); + +export const SelectionModeMultiple = meta.story({ + render: args => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {item => {item.label}} + + ); + }, +}); + +export const SelectionModeMultipleWithIcons = meta.story({ + render: args => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {item => ( + + {item.label} + + )} + + ); + }, +}); + +export const Disabled = meta.story({ + render: args => ( + + {item => {item.label}} + + ), +}); + +export const WithActionsMenu = meta.story({ + args: { + style: { width: 420 }, + }, + render: args => ( + + {item => ( + + {item.label} + + )} + + ), +}); + +export const WithActionsTags = meta.story({ + args: { + style: { width: 420 }, + }, + render: args => ( + + {item => ( + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + )} + + ), +}); + +export const WithActionsMenuAndTags = meta.story({ + args: { + style: { width: 420 }, + }, + render: args => ( + + {item => ( + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + )} + + ), +}); diff --git a/packages/ui/src/components/List/List.tsx b/packages/ui/src/components/List/List.tsx new file mode 100644 index 0000000000..a8aeaa6727 --- /dev/null +++ b/packages/ui/src/components/List/List.tsx @@ -0,0 +1,118 @@ +/* + * 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 { + GridList as RAGridList, + GridListItem as RAGridListItem, + Text, +} from 'react-aria-components'; +import { RiCheckLine, RiMoreLine } from '@remixicon/react'; +import { useDefinition } from '../../hooks/useDefinition'; +import { ListDefinition, ListRowDefinition } from './definition'; +import type { ListProps, ListRowProps } from './types'; +import { Box } from '../Box/Box'; +import { ButtonIcon } from '../ButtonIcon'; +import { MenuTrigger, Menu } from '../Menu'; + +/** + * A list displays a list of interactive rows with support for keyboard + * navigation, single or multiple selection, and row actions. + * + * @public + */ +export const List = (props: ListProps) => { + const { ownProps, restProps, dataAttributes } = useDefinition( + ListDefinition, + props, + ); + const { classes, items, children, renderEmptyState } = ownProps; + + return ( + + {children} + + ); +}; + +/** + * A row within a List. + * + * @public + */ +export const ListRow = (props: ListRowProps) => { + const { ownProps, restProps, dataAttributes } = useDefinition( + ListRowDefinition, + props, + ); + const { classes, children, description, icon, menuItems, customActions } = + ownProps; + + const textValue = typeof children === 'string' ? children : undefined; + + return ( + + {({ isSelected }) => ( + <> + {isSelected && ( +
+ +
+ )} + {icon && ( + + {icon} + + )} +
+ {children} + {description && ( + + {description} + + )} +
+ {customActions && ( +
{customActions}
+ )} + {menuItems && ( +
+ + } + size="small" + aria-label="More actions" + variant="tertiary" + /> + {menuItems} + +
+ )} + + )} +
+ ); +}; diff --git a/packages/ui/src/components/List/definition.ts b/packages/ui/src/components/List/definition.ts new file mode 100644 index 0000000000..98b9e24fc6 --- /dev/null +++ b/packages/ui/src/components/List/definition.ts @@ -0,0 +1,61 @@ +/* + * 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 { defineComponent } from '../../hooks/useDefinition'; +import type { ListOwnProps, ListRowOwnProps } from './types'; +import styles from './List.module.css'; + +/** + * Component definition for List + * @public + */ +export const ListDefinition = defineComponent()({ + styles, + classNames: { + root: 'bui-List', + }, + propDefs: { + items: {}, + children: {}, + renderEmptyState: {}, + className: {}, + }, +}); + +/** + * Component definition for ListRow + * @public + */ +export const ListRowDefinition = defineComponent()({ + styles, + bg: 'consumer', + classNames: { + root: 'bui-ListRow', + check: 'bui-ListRowCheck', + icon: 'bui-ListRowIcon', + label: 'bui-ListRowLabel', + description: 'bui-ListRowDescription', + actions: 'bui-ListRowActions', + }, + propDefs: { + children: {}, + description: {}, + icon: {}, + menuItems: {}, + customActions: {}, + className: {}, + }, +}); diff --git a/packages/ui/src/components/List/index.ts b/packages/ui/src/components/List/index.ts new file mode 100644 index 0000000000..e52739c04e --- /dev/null +++ b/packages/ui/src/components/List/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { List, ListRow } from './List'; +export type { + ListProps, + ListOwnProps, + ListRowProps, + ListRowOwnProps, +} from './types'; +export { ListDefinition, ListRowDefinition } from './definition'; diff --git a/packages/ui/src/components/List/types.ts b/packages/ui/src/components/List/types.ts new file mode 100644 index 0000000000..1bd5ad1037 --- /dev/null +++ b/packages/ui/src/components/List/types.ts @@ -0,0 +1,82 @@ +/* + * 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 { + GridListProps as ReactAriaGridListProps, + GridListItemProps as ReactAriaGridListItemProps, +} from 'react-aria-components'; + +/** + * Own props for the List component. + * + * @public + */ +export type ListOwnProps = { + items?: ReactAriaGridListProps['items']; + children?: ReactAriaGridListProps['children']; + renderEmptyState?: ReactAriaGridListProps['renderEmptyState']; + className?: string; +}; + +/** + * Props for the List component. + * + * @public + */ +export interface ListProps + extends ListOwnProps, + Omit, keyof ListOwnProps> {} + +/** + * Own props for the ListRow component. + * + * @public + */ +export type ListRowOwnProps = { + /** + * The main label content of the row. + */ + children?: React.ReactNode; + /** + * Optional secondary description text. + */ + description?: string; + /** + * Optional icon displayed before the label, rendered in a 32×32px box. + */ + icon?: React.ReactElement; + /** + * Optional menu items rendered inside an automatically managed dropdown menu. + * Pass `MenuItem` nodes here and the component will render the trigger button + * and menu wrapper for you. + */ + menuItems?: React.ReactNode; + /** + * Optional actions rendered in a flex row on the right side of the row, + * e.g. a set of tags. For a dropdown menu, prefer `menuItems`. + */ + customActions?: React.ReactNode; + className?: string; +}; + +/** + * Props for the ListRow component. + * + * @public + */ +export interface ListRowProps + extends ListRowOwnProps, + Omit {} diff --git a/packages/ui/src/components/Menu/Menu.module.css b/packages/ui/src/components/Menu/Menu.module.css index e16f87ee5a..9acedd106e 100644 --- a/packages/ui/src/components/Menu/Menu.module.css +++ b/packages/ui/src/components/Menu/Menu.module.css @@ -78,7 +78,7 @@ padding-inline: var(--bui-space-1); display: block; - &:focus-visible { + &[data-focus-visible] { outline: none; } diff --git a/packages/ui/src/components/Select/Select.module.css b/packages/ui/src/components/Select/Select.module.css index 74801de1a9..c5ce30e363 100644 --- a/packages/ui/src/components/Select/Select.module.css +++ b/packages/ui/src/components/Select/Select.module.css @@ -135,7 +135,7 @@ padding-block: var(--bui-space-1); padding-inline: var(--bui-space-1); - &:focus-visible { + &[data-focus-visible] { /* Remove default focus-visible outline because React Aria * triggers it on mouse click open of the list for some reason. * On keyboard use, the top item receives the focus style, diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css index fef9b33a9a..827b7f18d6 100644 --- a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css @@ -102,7 +102,7 @@ } /* Focus ring on group surface */ - .bui-ToggleButtonGroup:focus-visible { + .bui-ToggleButtonGroup[data-focus-visible] { outline: 2px solid var(--bui-ring); outline-offset: 2px; } diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index 79c5a8b884..5aeac8de5a 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -48,6 +48,10 @@ export { HeaderPageDefinition, } from './components/Header/definition'; export { LinkDefinition } from './components/Link/definition'; +export { + ListDefinition, + ListRowDefinition, +} from './components/List/definition'; export { MenuDefinition } from './components/Menu/definition'; export { PasswordFieldDefinition } from './components/PasswordField/definition'; export { PopoverDefinition } from './components/Popover/definition'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 8122fd5fde..4cb1d323c7 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -54,6 +54,7 @@ export * from './components/Popover'; export * from './components/SearchAutocomplete'; export * from './components/SearchField'; export * from './components/Link'; +export * from './components/List'; export * from './components/Select'; export * from './components/Skeleton'; export * from './components/Switch'; diff --git a/packages/ui/src/recipes/CardsWithList.stories.tsx b/packages/ui/src/recipes/CardsWithList.stories.tsx new file mode 100644 index 0000000000..21113d99a4 --- /dev/null +++ b/packages/ui/src/recipes/CardsWithList.stories.tsx @@ -0,0 +1,268 @@ +/* + * 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 type { StoryFn } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { + Card, + CardHeader, + CardBody, + Container, + Grid, + Flex, + Text, + MenuItem, + TagGroup, + Tag, + List, + ListRow, +} from '..'; +import { + RiAccountCircleLine, + RiCloudLine, + RiCodeLine, + RiDeleteBinLine, + RiEdit2Line, + RiGitBranchLine, + RiJavascriptLine, + RiReactjsLine, + RiServerLine, + RiShareBoxLine, + RiShieldLine, + RiTerminalLine, +} from '@remixicon/react'; + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +interface ServiceItem { + id: string; + label: string; + description: string; + icon: React.ReactElement; + tags: string[]; +} + +const frontendServices: ServiceItem[] = [ + { + id: 'portal', + label: 'developer-portal', + description: 'Internal developer portal built on Backstage', + icon: , + tags: ['website', 'production'], + }, + { + id: 'design-system', + label: 'design-system', + description: 'Shared UI components and design tokens', + icon: , + tags: ['library', 'production'], + }, + { + id: 'docs-site', + label: 'docs-site', + description: 'Engineering documentation and runbooks', + icon: , + tags: ['website', 'production'], + }, + { + id: 'admin-ui', + label: 'admin-ui', + description: 'Internal tooling for platform administrators', + icon: , + tags: ['website', 'experimental'], + }, + { + id: 'onboarding-flow', + label: 'onboarding-flow', + description: 'New hire onboarding wizard and checklist', + icon: , + tags: ['website', 'experimental'], + }, +]; + +const backendServices: ServiceItem[] = [ + { + id: 'auth', + label: 'authentication-service', + description: 'Handles user authentication, sessions and token refresh', + icon: , + tags: ['service', 'production'], + }, + { + id: 'api-gateway', + label: 'api-gateway', + description: 'Routes and validates all inbound API requests', + icon: , + tags: ['service', 'production'], + }, + { + id: 'search', + label: 'search-indexer', + description: 'Indexes catalog entities for full-text search', + icon: , + tags: ['service', 'experimental'], + }, + { + id: 'ci-runner', + label: 'ci-runner', + description: 'Orchestrates and executes CI pipeline jobs', + icon: , + tags: ['service', 'production'], + }, + { + id: 'infra-provisioner', + label: 'infra-provisioner', + description: 'Terraform-based cloud resource provisioner', + icon: , + tags: ['service', 'experimental'], + }, +]; + +// --------------------------------------------------------------------------- +// Service list card +// --------------------------------------------------------------------------- + +interface ServiceListCardProps { + title: string; + items: ServiceItem[]; + description?: boolean; + icons?: boolean; +} + +const ServiceListCard = ({ + title, + items, + description = false, + icons = true, +}: ServiceListCardProps) => ( + + + + + + {title} + + + + + + + {items.map(item => ( + + }>Edit + }>Share + } color="danger"> + Delete + + + } + customActions={ + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + ))} + + + +); + +const withRouter = (Story: StoryFn) => ( + + + +); + +const meta = preview.meta({ + title: 'Recipes/Cards with List', + parameters: { + layout: 'fullscreen', + }, +}); + +export const Default = meta.story({ + decorators: [withRouter], + render: () => ( + + + + + + + ), +}); + +export const WithNoIcons = meta.story({ + decorators: [withRouter], + args: { + icons: false, + description: true, + }, + render: args => ( + + + + + + + ), +}); + +export const WithDescription = meta.story({ + args: { + description: true, + }, + render: args => ( + + + + + + + ), +});