diff --git a/.changeset/eighty-rivers-decide.md b/.changeset/eighty-rivers-decide.md new file mode 100644 index 0000000000..6d201d9896 --- /dev/null +++ b/.changeset/eighty-rivers-decide.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Add new TagGroup component to Backstage UI. diff --git a/packages/ui/css/styles.css b/packages/ui/css/styles.css index a9635723ee..35f472eaff 100644 --- a/packages/ui/css/styles.css +++ b/packages/ui/css/styles.css @@ -10260,6 +10260,76 @@ padding-top: var(--bui-space-4); } +.bui-TagList { + gap: var(--bui-space-2); + flex-wrap: wrap; + display: flex; +} + +.bui-Tag { + color: var(--bui-fg-primary); + background-color: var(--bui-gray-2); + border-radius: var(--bui-radius-full); + font-weight: var(--bui-font-weight-regular); + justify-content: center; + align-items: center; + gap: var(--bui-space-1); + box-sizing: border-box; + transition-property: background-color, box-shadow, color; + transition-duration: .2s; + transition-timing-function: ease-in-out; + display: flex; +} + +.bui-Tag[data-size="small"] { + height: 26px; + padding: 0 var(--bui-space-2); + font-size: var(--bui-font-size-1); +} + +.bui-Tag[data-size="medium"] { + height: 32px; + padding: 0 var(--bui-space-2); + font-size: var(--bui-font-size-2); +} + +.bui-Tag[data-hovered] { + background-color: var(--bui-gray-3); + cursor: pointer; +} + +.bui-Tag[data-focus-visible] { + outline: 2px solid var(--bui-ring); + outline-offset: 1px; +} + +.bui-Tag[data-selected] { + box-shadow: inset 0 0 0 1px var(--bui-gray-8); +} + +.bui-TagRemoveButton { + cursor: pointer; + color: var(--bui-fg-primary); + background-color: #0000; + border: none; + width: 1rem; + height: 1rem; + margin: 0; + padding: 0; +} + +.bui-TagIcon { + justify-content: center; + align-items: center; + transition: color .2s ease-in-out; + display: flex; + + & svg { + width: 1rem; + height: 1rem; + } +} + .bui-Text { font-family: var(--bui-font-regular); margin: 0; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index b5c61edc9c..5d04bf8626 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -42,6 +42,9 @@ import type { TabListProps as TabListProps_2 } from 'react-aria-components'; import type { TabPanelProps as TabPanelProps_2 } from 'react-aria-components'; import type { TabProps as TabProps_2 } from 'react-aria-components'; import { TabsProps as TabsProps_2 } from 'react-aria-components'; +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 { TooltipProps as TooltipProps_2 } from 'react-aria-components'; import { TooltipTriggerComponentProps } from 'react-aria-components'; @@ -608,6 +611,15 @@ export const componentDefinitions: { readonly panel: 'bui-TabPanel'; }; }; + readonly TagGroup: { + readonly classNames: { + readonly group: 'bui-TagGroup'; + readonly list: 'bui-TagList'; + readonly tag: 'bui-Tag'; + readonly tagIcon: 'bui-TagIcon'; + readonly tagRemoveButton: 'bui-TagRemoveButton'; + }; + }; readonly Text: { readonly classNames: { readonly root: 'bui-Text'; @@ -1697,6 +1709,28 @@ export const Tabs: (props: TabsProps) => JSX_2.Element | null; // @public export interface TabsProps extends TabsProps_2 {} +// @public +export const Tag: (props: TagProps) => JSX_2.Element; + +// @public +export const TagGroup: ({ + items, + children, + renderEmptyState, + ...props +}: TagGroupProps) => JSX_2.Element; + +// @public +export interface TagGroupProps + extends Omit, + Pick, 'items' | 'children' | 'renderEmptyState'> {} + +// @public +export interface TagProps extends TagProps_2 { + icon?: React.ReactNode; + size?: 'small' | 'medium'; +} + // @public (undocumented) const Text_2: { ( diff --git a/packages/ui/src/components/TagGroup/TagGroup.stories.tsx b/packages/ui/src/components/TagGroup/TagGroup.stories.tsx new file mode 100644 index 0000000000..b257442e50 --- /dev/null +++ b/packages/ui/src/components/TagGroup/TagGroup.stories.tsx @@ -0,0 +1,184 @@ +/* + * 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 { useState } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import { TagGroup, Tag } from '.'; +import type { Selection } from 'react-aria-components'; +import { Icon, IconNames } from '@backstage/ui'; +import { useListData } from 'react-stately'; + +export interface ListItem { + id: string; + name: string; + icon: IconNames; +} + +const meta = { + title: 'Backstage UI/TagGroup', + component: TagGroup, + argTypes: { + selectionMode: { + control: { type: 'inline-radio' }, + options: ['single', 'multiple'], + }, + 'aria-label': { + control: { type: 'text' }, + }, + }, +} satisfies Meta>; + +export default meta; +type Story = StoryObj; + +const initialList: ListItem[] = [ + { id: 'banana', name: 'Banana', icon: 'bug' }, + { id: 'apple', name: 'Apple', icon: 'account-circle' }, + { id: 'orange', name: 'Orange', icon: 'eye' }, + { id: 'pear', name: 'Pear', icon: 'heart' }, + { id: 'grape', name: 'Grape', icon: 'bug' }, + { id: 'pineapple', name: 'Pineapple', icon: 'eye' }, + { id: 'strawberry', name: 'Strawberry', icon: 'heart' }, +]; + +export const Default: Story = { + args: { + 'aria-label': 'Tag Group', + }, + render: args => ( + + {initialList.map(item => ( + {item.name} + ))} + + ), +}; + +export const SelectionModeSingle: Story = { + args: { + selectionMode: 'single', + 'aria-label': 'Tag Group', + }, + render: args => { + const [selected, setSelected] = useState(new Set(['travel'])); + + return ( + + {item => {item.name}} + + ); + }, +}; + +export const SelectionModeMultiple: Story = { + args: { + selectionMode: 'multiple', + 'aria-label': 'Tag Group', + }, + render: args => { + const [selected, setSelected] = useState( + new Set(['travel', 'shopping']), + ); + + return ( + + {item => {item.name}} + + ); + }, +}; + +export const WithIcon: Story = { + args: { + ...Default.args, + }, + render: args => ( + + {initialList.map(item => ( + : undefined} + > + {item.name} + + ))} + + ), +}; + +export const WithRemoveButton: Story = { + args: { + ...Default.args, + }, + render: args => { + const [selected, setSelected] = useState(new Set(['travel'])); + + const list = useListData({ + initialItems: initialList, + }); + + return ( + list.remove(...keys)} + selectedKeys={selected} + onSelectionChange={setSelected} + {...args} + > + {item => {item.name}} + + ); + }, +}; + +export const WithIconAndRemoveButton: Story = { + args: { + ...Default.args, + }, + render: args => { + const [selected, setSelected] = useState(new Set(['travel'])); + + const list = useListData({ + initialItems: initialList, + }); + + return ( + list.remove(...keys)} + selectedKeys={selected} + onSelectionChange={setSelected} + {...args} + > + {item => ( + : undefined}> + {item.name} + + )} + + ); + }, +}; diff --git a/packages/ui/src/components/TagGroup/TagGroup.styles.css b/packages/ui/src/components/TagGroup/TagGroup.styles.css new file mode 100644 index 0000000000..11f78daf74 --- /dev/null +++ b/packages/ui/src/components/TagGroup/TagGroup.styles.css @@ -0,0 +1,85 @@ +/* + * 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. + */ + +.bui-TagList { + display: flex; + flex-wrap: wrap; + gap: var(--bui-space-2); +} + +.bui-Tag { + color: var(--bui-fg-primary); + background-color: var(--bui-gray-2); + border-radius: var(--bui-radius-full); + display: flex; + align-items: center; + justify-content: center; + font-weight: var(--bui-font-weight-regular); + gap: var(--bui-space-1); + transition-property: background-color, box-shadow, color; + transition-duration: 0.2s; + transition-timing-function: ease-in-out; + box-sizing: border-box; +} + +.bui-Tag[data-size='small'] { + height: 26px; + padding: 0 var(--bui-space-2); + font-size: var(--bui-font-size-1); +} + +.bui-Tag[data-size='medium'] { + height: 32px; + padding: 0 var(--bui-space-2); + font-size: var(--bui-font-size-2); +} + +.bui-Tag[data-hovered] { + background-color: var(--bui-gray-3); + cursor: pointer; +} + +.bui-Tag[data-focus-visible] { + outline: 2px solid var(--bui-ring); + outline-offset: 1px; +} + +.bui-Tag[data-selected] { + box-shadow: inset 0 0 0 1px var(--bui-gray-8); +} + +.bui-TagRemoveButton { + background-color: transparent; + border: none; + cursor: pointer; + color: var(--bui-fg-primary); + padding: 0; + margin: 0; + width: 1rem; + height: 1rem; +} + +.bui-TagIcon { + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s ease-in-out; + + svg { + width: 1rem; + height: 1rem; + } +} diff --git a/packages/ui/src/components/TagGroup/TagGroup.tsx b/packages/ui/src/components/TagGroup/TagGroup.tsx new file mode 100644 index 0000000000..de07415880 --- /dev/null +++ b/packages/ui/src/components/TagGroup/TagGroup.tsx @@ -0,0 +1,87 @@ +/* + * 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 { TagProps, TagGroupProps } from './types'; +import { + TagGroup as ReactAriaTagGroup, + TagList as ReactAriaTagList, + Tag as ReactAriaTag, + Button as ReactAriaButton, +} from 'react-aria-components'; +import type { ReactNode } from 'react'; +import { RiCloseCircleLine } from '@remixicon/react'; +import clsx from 'clsx'; +import { useStyles } from '../../hooks/useStyles'; + +/** + * A component that renders a list of tags. + * + * @public + */ +export const TagGroup = ({ + items, + children, + renderEmptyState, + ...props +}: TagGroupProps) => { + const { classNames } = useStyles('TagGroup'); + return ( + + + {children} + + + ); +}; + +/** + * A component that renders a tag. + * + * @public + */ +export const Tag = (props: TagProps) => { + const { children, className, icon, size = 'small', ...rest } = props; + const textValue = typeof children === 'string' ? children : undefined; + const { classNames } = useStyles('TagGroup'); + + return ( + + {({ allowsRemoving }) => ( + <> + {icon && {icon}} + {children as ReactNode} + {allowsRemoving && ( + + + + )} + + )} + + ); +}; diff --git a/packages/ui/src/components/TagGroup/index.ts b/packages/ui/src/components/TagGroup/index.ts new file mode 100644 index 0000000000..b7434ce574 --- /dev/null +++ b/packages/ui/src/components/TagGroup/index.ts @@ -0,0 +1,18 @@ +/* + * 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 { TagGroup, Tag } from './TagGroup'; +export type { TagGroupProps, TagProps } from './types'; diff --git a/packages/ui/src/components/TagGroup/types.ts b/packages/ui/src/components/TagGroup/types.ts new file mode 100644 index 0000000000..e828e6e524 --- /dev/null +++ b/packages/ui/src/components/TagGroup/types.ts @@ -0,0 +1,47 @@ +/* + * 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 { + TagGroupProps as ReactAriaTagGroupProps, + TagListProps as ReactAriaTagListProps, + TagProps as ReactAriaTagProps, +} from 'react-aria-components'; + +/** + * Props for the TagGroup component. + * + * @public + */ +export interface TagGroupProps + extends Omit, + Pick, 'items' | 'children' | 'renderEmptyState'> {} + +/** + * Props for the Tag component. + * + * @public + */ +export interface TagProps extends ReactAriaTagProps { + /** + * The icon to display in the chip. + */ + icon?: React.ReactNode; + + /** + * The size of the chip. + */ + size?: 'small' | 'medium'; +} diff --git a/packages/ui/src/css/components.css b/packages/ui/src/css/components.css index f83cc50ad2..8aa81a8217 100644 --- a/packages/ui/src/css/components.css +++ b/packages/ui/src/css/components.css @@ -36,6 +36,7 @@ @import '../components/Table/Table.styles.css'; @import '../components/TablePagination/TablePagination.styles.css'; @import '../components/Tabs/Tabs.styles.css'; +@import '../components/TagGroup/TagGroup.styles.css'; @import '../components/Text/styles.css'; @import '../components/TextField/TextField.styles.css'; @import '../components/SearchField/SearchField.styles.css'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 583ea365c8..f5d9230d4d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -45,6 +45,7 @@ export * from './components/RadioGroup'; export * from './components/Table'; export * from './components/TablePagination'; export * from './components/Tabs'; +export * from './components/TagGroup'; export * from './components/Text'; export * from './components/TextField'; export * from './components/Tooltip'; diff --git a/packages/ui/src/utils/componentDefinitions.ts b/packages/ui/src/utils/componentDefinitions.ts index 05fc027d45..5a0e8bdc1a 100644 --- a/packages/ui/src/utils/componentDefinitions.ts +++ b/packages/ui/src/utils/componentDefinitions.ts @@ -260,6 +260,15 @@ export const componentDefinitions = { panel: 'bui-TabPanel', }, }, + TagGroup: { + classNames: { + group: 'bui-TagGroup', + list: 'bui-TagList', + tag: 'bui-Tag', + tagIcon: 'bui-TagIcon', + tagRemoveButton: 'bui-TagRemoveButton', + }, + }, Text: { classNames: { root: 'bui-Text',