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/packages/ui/src/components/ListBox/ListBox.module.css b/packages/ui/src/components/ListBox/ListBox.module.css index 99fea1adc3..ea323b9471 100644 --- a/packages/ui/src/components/ListBox/ListBox.module.css +++ b/packages/ui/src/components/ListBox/ListBox.module.css @@ -21,6 +21,14 @@ box-sizing: border-box; overflow-y: auto; outline: none; + display: flex; + flex-direction: column; + + gap: var(--bui-space-3); + + &:has([data-selection-mode]) { + gap: 0; + } &:focus-visible { outline: none; @@ -31,29 +39,94 @@ box-sizing: border-box; display: flex; align-items: center; - gap: var(--bui-space-2); - padding-block: var(--bui-space-2); - padding-inline: var(--bui-space-3); + 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); - cursor: pointer; - user-select: none; outline: none; - &[data-hovered] { - background-color: var(--bui-bg-neutral-2); - } - - &[data-focus-visible] { - background-color: var(--bui-bg-neutral-2); - } - &[data-disabled] { cursor: not-allowed; color: var(--bui-fg-disabled); } + + &[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-ListBoxItemCheck { @@ -92,11 +165,21 @@ flex-direction: column; gap: var(--bui-space-1); min-width: 0; + overflow: hidden; + + & > * { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } } .bui-ListBoxItemDescription { font-size: var(--bui-font-size-2); color: var(--bui-fg-secondary); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .bui-ListBoxItemActions { diff --git a/packages/ui/src/components/ListBox/ListBox.stories.tsx b/packages/ui/src/components/ListBox/ListBox.stories.tsx index 26c8465494..2ad1429272 100644 --- a/packages/ui/src/components/ListBox/ListBox.stories.tsx +++ b/packages/ui/src/components/ListBox/ListBox.stories.tsx @@ -17,13 +17,11 @@ import preview from '../../../../../.storybook/preview'; import { useState } from 'react'; import { ListBox, ListBoxItem } from './ListBox'; -import { ButtonIcon } from '../ButtonIcon'; -import { MenuTrigger, Menu, MenuItem } from '../Menu'; +import { MenuItem } from '../Menu'; import { TagGroup, Tag } from '../TagGroup'; import type { Selection } from 'react-aria-components'; import { RiJavascriptLine, - RiMoreLine, RiReactjsLine, RiShipLine, RiTerminalLine, @@ -159,6 +157,27 @@ export const SelectionModeSingle = meta.story({ }, }); +export const SelectionModeSingleWithIcons = meta.story({ + render: args => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {items.map(item => ( + + {item.label} + + ))} + + ); + }, +}); + export const SelectionModeMultiple = meta.story({ render: args => { const [selected, setSelected] = useState( @@ -182,6 +201,29 @@ export const SelectionModeMultiple = meta.story({ }, }); +export const SelectionModeMultipleWithIcons = meta.story({ + render: args => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {items.map(item => ( + + {item.label} + + ))} + + ); + }, +}); + export const Disabled = meta.story({ render: args => ( @@ -195,6 +237,9 @@ export const Disabled = meta.story({ }); export const WithActionsMenu = meta.story({ + args: { + style: { width: 420 }, + }, render: args => ( {items.map(item => ( @@ -202,21 +247,14 @@ export const WithActionsMenu = meta.story({ key={item.id} id={item.id} icon={itemIcons[item.id]} - customActions={ - - } - size="small" - aria-label="More actions" - /> - - }>Edit - }>Share - } color="danger"> - Delete - - - + menuItems={ + <> + }>Edit + }>Share + } color="danger"> + Delete + + } > {item.label} @@ -228,7 +266,7 @@ export const WithActionsMenu = meta.story({ export const WithActionsTags = meta.story({ args: { - style: { width: 380 }, + style: { width: 420 }, }, render: args => { const tagMap: Record = { @@ -261,3 +299,48 @@ export const WithActionsTags = meta.story({ ); }, }); + +export const WithActionsMenuAndTags = meta.story({ + args: { + style: { width: 420 }, + }, + render: args => { + const tagMap: Record = { + react: ['frontend', 'ui'], + typescript: ['typed', 'js'], + javascript: ['web'], + rust: ['systems', 'fast'], + go: ['backend'], + }; + + return ( + + {items.map(item => ( + + }>Edit + }>Share + } color="danger"> + Delete + + + } + customActions={ + + {tagMap[item.id].map(tag => ( + {tag} + ))} + + } + > + {item.label} + + ))} + + ); + }, +}); diff --git a/packages/ui/src/components/ListBox/ListBox.tsx b/packages/ui/src/components/ListBox/ListBox.tsx index 221dd5fdb9..8bb24ac714 100644 --- a/packages/ui/src/components/ListBox/ListBox.tsx +++ b/packages/ui/src/components/ListBox/ListBox.tsx @@ -19,11 +19,13 @@ import { ListBoxItem as RAListBoxItem, Text, } from 'react-aria-components'; -import { RiCheckLine } from '@remixicon/react'; +import { RiCheckLine, RiMoreLine } from '@remixicon/react'; import { useDefinition } from '../../hooks/useDefinition'; import { ListBoxDefinition, ListBoxItemDefinition } from './definition'; import type { ListBoxProps, ListBoxItemProps } from './types'; import { Box } from '../Box/Box'; +import { ButtonIcon } from '../ButtonIcon'; +import { MenuTrigger, Menu } from '../Menu'; /** * A listbox displays a list of options and allows a user to select one or more of them. @@ -31,7 +33,10 @@ import { Box } from '../Box/Box'; * @public */ export const ListBox = (props: ListBoxProps) => { - const { ownProps, restProps } = useDefinition(ListBoxDefinition, props); + const { ownProps, restProps, dataAttributes } = useDefinition( + ListBoxDefinition, + props, + ); const { classes, items, children, renderEmptyState } = ownProps; return ( @@ -39,6 +44,7 @@ export const ListBox = (props: ListBoxProps) => { className={classes.root} items={items} renderEmptyState={renderEmptyState} + {...dataAttributes} {...restProps} > {children} @@ -52,8 +58,12 @@ export const ListBox = (props: ListBoxProps) => { * @public */ export const ListBoxItem = (props: ListBoxItemProps) => { - const { ownProps, restProps } = useDefinition(ListBoxItemDefinition, props); - const { classes, children, description, icon, customActions } = ownProps; + const { ownProps, restProps, dataAttributes } = useDefinition( + ListBoxItemDefinition, + props, + ); + const { classes, children, description, icon, menuItems, customActions } = + ownProps; const textValue = typeof children === 'string' ? children : undefined; @@ -61,6 +71,7 @@ export const ListBoxItem = (props: ListBoxItemProps) => { {({ isSelected }) => ( @@ -92,6 +103,23 @@ export const ListBoxItem = (props: ListBoxItemProps) => { {customActions} )} + {menuItems && ( +
e.stopPropagation()} + onKeyDown={e => e.stopPropagation()} + > + + } + size="small" + aria-label="More actions" + variant="tertiary" + /> + {menuItems} + +
+ )} )}
diff --git a/packages/ui/src/components/ListBox/definition.ts b/packages/ui/src/components/ListBox/definition.ts index ef8d6e4b61..95e97d582c 100644 --- a/packages/ui/src/components/ListBox/definition.ts +++ b/packages/ui/src/components/ListBox/definition.ts @@ -41,6 +41,7 @@ export const ListBoxDefinition = defineComponent()({ */ export const ListBoxItemDefinition = defineComponent()({ styles, + bg: 'consumer', classNames: { root: 'bui-ListBoxItem', check: 'bui-ListBoxItemCheck', @@ -53,6 +54,7 @@ export const ListBoxItemDefinition = defineComponent()({ children: {}, description: {}, icon: {}, + menuItems: {}, customActions: {}, className: {}, }, diff --git a/packages/ui/src/components/ListBox/types.ts b/packages/ui/src/components/ListBox/types.ts index 6299518467..fd2cc4146c 100644 --- a/packages/ui/src/components/ListBox/types.ts +++ b/packages/ui/src/components/ListBox/types.ts @@ -58,9 +58,15 @@ export type ListBoxItemOwnProps = { * Optional icon displayed before the label, rendered in a 32×32px box. */ icon?: React.ReactNode; + /** + * 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 item, - * e.g. a ButtonIcon with a dropdown menu, or a set of tags. + * e.g. a set of tags. For a dropdown menu, prefer `menuItems`. */ customActions?: React.ReactNode; className?: string; diff --git a/packages/ui/src/recipes/CardsWithListBox.stories.tsx b/packages/ui/src/recipes/CardsWithListBox.stories.tsx new file mode 100644 index 0000000000..d979743525 --- /dev/null +++ b/packages/ui/src/recipes/CardsWithListBox.stories.tsx @@ -0,0 +1,240 @@ +/* + * 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, + ListBox, + ListBoxItem, +} 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.ReactNode; + 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; +} + +const ServiceListCard = ({ + title, + items, + description = false, +}: 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 ListBox', + parameters: { + layout: 'fullscreen', + }, +}); + +export const Default = meta.story({ + decorators: [withRouter], + render: () => ( + + + + + + + ), +}); + +export const WithDescription = meta.story({ + args: { + description: true, + }, + render: () => ( + + + + + + + ), +});