Merge pull request #30919 from backstage/bui-tag-group
BUI - Add TagGroup component
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/ui': patch
|
||||
---
|
||||
|
||||
Add new TagGroup component to Backstage UI.
|
||||
@@ -209,6 +209,10 @@
|
||||
.bui-Input {
|
||||
border-radius: var(--bui-radius-3);
|
||||
}
|
||||
|
||||
.bui-Tag {
|
||||
border-radius: var(--bui-radius-full);
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme-mode='light'][data-theme-name='spotify'] {
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,114 @@
|
||||
import { PropsTable } from '@/components/PropsTable';
|
||||
import { Snippet } from '@/components/Snippet';
|
||||
import { CodeBlock } from '@/components/CodeBlock';
|
||||
import { TagGroupSnippet } from '@/snippets/stories-snippets';
|
||||
import {
|
||||
tagGroupPropDefs,
|
||||
tagPropDefs,
|
||||
usage,
|
||||
preview,
|
||||
withLink,
|
||||
disabled,
|
||||
withIcons,
|
||||
sizes,
|
||||
removingTags,
|
||||
} from './tag-group.props';
|
||||
import { PageTitle } from '@/components/PageTitle';
|
||||
import { Theming } from '@/components/Theming';
|
||||
import { ChangelogComponent } from '@/components/ChangelogComponent';
|
||||
|
||||
<PageTitle
|
||||
title="TagGroup"
|
||||
description="A tag group is a list of labels, categories, keywords, filters, or other items, that can be used to group and filter content."
|
||||
/>
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
preview={<TagGroupSnippet story="Default" />}
|
||||
code={preview}
|
||||
/>
|
||||
|
||||
## Usage
|
||||
|
||||
<CodeBlock code={usage} />
|
||||
|
||||
## API reference
|
||||
|
||||
### TagGroup
|
||||
|
||||
A tag group is a list of tags.
|
||||
|
||||
<PropsTable data={tagGroupPropDefs} />
|
||||
|
||||
### Tag
|
||||
|
||||
A tag is a single item in a tag group.
|
||||
|
||||
<PropsTable data={tagPropDefs} />
|
||||
|
||||
## Examples
|
||||
|
||||
### With Links
|
||||
|
||||
A tag can be a link by passing a `href` prop.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<TagGroupSnippet story="WithLink" />}
|
||||
code={withLink}
|
||||
/>
|
||||
|
||||
### With Icons
|
||||
|
||||
A tag can have an icon by passing a `icon` prop.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<TagGroupSnippet story="WithIcon" />}
|
||||
code={withIcons}
|
||||
/>
|
||||
|
||||
### Sizes
|
||||
|
||||
A tag can have a size by passing a `size` prop. It could be `small` or `medium`.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<TagGroupSnippet story="Sizes" />}
|
||||
code={sizes}
|
||||
/>
|
||||
|
||||
### Removing tags
|
||||
|
||||
A tag can be removed by passing a `onRemove` prop.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<TagGroupSnippet story="RemovingTags" />}
|
||||
code={removingTags}
|
||||
/>
|
||||
|
||||
### Disabled
|
||||
|
||||
A switch can be disabled using the `isDisabled` prop.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<TagGroupSnippet story="Disabled" />}
|
||||
code={disabled}
|
||||
/>
|
||||
|
||||
<Theming component="Switch" />
|
||||
|
||||
<ChangelogComponent component="switch" />
|
||||
@@ -0,0 +1,161 @@
|
||||
import { classNamePropDefs, stylePropDefs } from '@/utils/propDefs';
|
||||
import type { PropDef } from '@/utils/propDefs';
|
||||
|
||||
export const tagGroupPropDefs: Record<string, PropDef> = {
|
||||
disabledKeys: {
|
||||
type: 'enum',
|
||||
values: ['Iterable<Key>'],
|
||||
description: 'A list of row keys to disable.',
|
||||
},
|
||||
selectionMode: {
|
||||
type: 'enum',
|
||||
values: ['none', 'single', 'multiple'],
|
||||
description: 'The type of selection that is allowed in the collection.',
|
||||
},
|
||||
selectedKeys: {
|
||||
type: 'enum',
|
||||
values: ['all', 'Iterable<Key>'],
|
||||
description: 'The currently selected keys in the collection (controlled).',
|
||||
},
|
||||
defaultSelectedKeys: {
|
||||
type: 'enum',
|
||||
values: ['all', 'Iterable<Key>'],
|
||||
description: 'The initial selected keys in the collection (uncontrolled).',
|
||||
},
|
||||
onRemove: {
|
||||
type: 'enum',
|
||||
values: ['(keys: Set<Key>) => void'],
|
||||
description: 'Handler that is called when a tag is removed.',
|
||||
},
|
||||
onSelectionChange: {
|
||||
type: 'enum',
|
||||
values: ['(keys: Selection) => void'],
|
||||
description: 'Handler that is called when the selection changes.',
|
||||
},
|
||||
...classNamePropDefs,
|
||||
...stylePropDefs,
|
||||
};
|
||||
|
||||
export const tagPropDefs: Record<string, PropDef> = {
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'The id of the tag.',
|
||||
},
|
||||
textValue: {
|
||||
type: 'string',
|
||||
description: 'The text value of the tag.',
|
||||
},
|
||||
isDisabled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the tag is disabled.',
|
||||
},
|
||||
href: {
|
||||
type: 'string',
|
||||
description: 'The href of the tag.',
|
||||
},
|
||||
icon: {
|
||||
type: 'enum',
|
||||
values: ['ReactNode'],
|
||||
description: 'The icon of the tag.',
|
||||
},
|
||||
...classNamePropDefs,
|
||||
...stylePropDefs,
|
||||
};
|
||||
|
||||
export const preview = `import { TagGroup, Tag } from '@backstage/ui';
|
||||
|
||||
<TagGroup aria-label="Tag Group">
|
||||
<Tag>Banana</Tag>
|
||||
<Tag>Apple</Tag>
|
||||
<Tag>Orange</Tag>
|
||||
<Tag>Pear</Tag>
|
||||
<Tag>Grape</Tag>
|
||||
<Tag>Pineapple</Tag>
|
||||
<Tag>Strawberry</Tag>
|
||||
</TagGroup>
|
||||
`;
|
||||
|
||||
export const usage = `import { TagGroup, Tag } from '@backstage/ui';
|
||||
|
||||
// Basic usage
|
||||
<TagGroup aria-label="Tag Group">
|
||||
<Tag>Tag 1</Tag>
|
||||
<Tag>Tag 2</Tag>
|
||||
<Tag>Tag 3</Tag>
|
||||
</TagGroup>
|
||||
|
||||
// With the items prop
|
||||
const list = [
|
||||
{ id: '1', name: 'Tag 1' },
|
||||
{ id: '2', name: 'Tag 2' },
|
||||
{ id: '3', name: 'Tag 3' },
|
||||
];
|
||||
|
||||
<TagGroup aria-label="Tag Group" items={list}>
|
||||
{item => <Tag>{item.name}</Tag>}
|
||||
</TagGroup>`;
|
||||
|
||||
export const withLink = `import { TagGroup, Tag } from '@backstage/ui';
|
||||
|
||||
<TagGroup aria-label="Tag Group">
|
||||
<Tag href="/items/banana">Banana</Tag>
|
||||
<Tag href="/items/apple">Apple</Tag>
|
||||
<Tag href="/items/orange">Orange</Tag>
|
||||
...
|
||||
</TagGroup>`;
|
||||
|
||||
export const disabled = `import { TagGroup, Tag } from '@backstage/ui';
|
||||
|
||||
<TagGroup aria-label="Tag Group">
|
||||
<Tag>Banana</Tag>
|
||||
<Tag isDisabled>Apple</Tag>
|
||||
<Tag isDisabled>Orange</Tag>
|
||||
<Tag>Pear</Tag>
|
||||
...
|
||||
</TagGroup>`;
|
||||
|
||||
export const withIcons = `import { TagGroup, Tag } from '@backstage/ui';
|
||||
|
||||
<TagGroup aria-label="Tag Group">
|
||||
<Tag icon={<RiBugLine />}>Banana</Tag>
|
||||
<Tag icon={<RiAccountCircleLine />}>Apple</Tag>
|
||||
<Tag icon={<RiEyeLine />}>Orange</Tag>
|
||||
...
|
||||
</TagGroup>`;
|
||||
|
||||
export const sizes = `import { TagGroup, Tag, Flex } from '@backstage/ui';
|
||||
|
||||
<Flex direction="column">
|
||||
<TagGroup aria-label="Tag Group">
|
||||
<Tag size="small">Banana</Tag>
|
||||
<Tag size="small">Apple</Tag>
|
||||
<Tag size="small">Orange</Tag>
|
||||
...
|
||||
</TagGroup>
|
||||
<TagGroup aria-label="Tag Group">
|
||||
<Tag size="medium">Banana</Tag>
|
||||
<Tag size="medium">Apple</Tag>
|
||||
<Tag size="medium">Orange</Tag>
|
||||
...
|
||||
</TagGroup>
|
||||
</Flex>`;
|
||||
|
||||
export const removingTags = `import { TagGroup, Tag } from '@backstage/ui';
|
||||
import type { Selection } from 'react-aria-components';
|
||||
import { useListData } from 'react-stately';
|
||||
|
||||
const [selected, setSelected] = useState<Selection>(new Set(['travel']));
|
||||
|
||||
const list = useListData({
|
||||
initialItems: initialList,
|
||||
});
|
||||
|
||||
<TagGroup
|
||||
items={list.items}
|
||||
onRemove={keys => list.remove(...keys)}
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
{...args}
|
||||
>
|
||||
{item => <Tag>{item.name}</Tag>}
|
||||
</TagGroup>`;
|
||||
@@ -27,6 +27,7 @@ import * as CardStories from '../../../packages/ui/src/components/Card/Card.stor
|
||||
import * as HeaderStories from '../../../packages/ui/src/components/Header/Header.stories';
|
||||
import * as HeaderPageStories from '../../../packages/ui/src/components/HeaderPage/HeaderPage.stories';
|
||||
import * as TableStories from '../../../packages/ui/src/components/Table/Table.stories';
|
||||
import * as TagGroupStories from '../../../packages/ui/src/components/TagGroup/TagGroup.stories';
|
||||
|
||||
// Helper function to create snippet components
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -69,3 +70,4 @@ export const CardSnippet = createSnippetComponent(CardStories);
|
||||
export const HeaderSnippet = createSnippetComponent(HeaderStories);
|
||||
export const HeaderPageSnippet = createSnippetComponent(HeaderPageStories);
|
||||
export const TableSnippet = createSnippetComponent(TableStories);
|
||||
export const TagGroupSnippet = createSnippetComponent(TagGroupStories);
|
||||
|
||||
@@ -154,13 +154,18 @@ export const components: Page[] = [
|
||||
{
|
||||
title: 'Table',
|
||||
slug: 'table',
|
||||
status: 'inProgress',
|
||||
status: 'alpha',
|
||||
},
|
||||
{
|
||||
title: 'Tabs',
|
||||
slug: 'tabs',
|
||||
status: 'alpha',
|
||||
},
|
||||
{
|
||||
title: 'TagGroup',
|
||||
slug: 'tag-group',
|
||||
status: 'alpha',
|
||||
},
|
||||
{
|
||||
title: 'Text',
|
||||
slug: 'text',
|
||||
|
||||
@@ -10260,6 +10260,81 @@
|
||||
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-2);
|
||||
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-Tag[data-disabled] {
|
||||
color: var(--bui-fg-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
@@ -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: <T extends object>({
|
||||
items,
|
||||
children,
|
||||
renderEmptyState,
|
||||
...props
|
||||
}: TagGroupProps<T>) => JSX_2.Element;
|
||||
|
||||
// @public
|
||||
export interface TagGroupProps<T>
|
||||
extends Omit<TagGroupProps_2, 'children'>,
|
||||
Pick<TagListProps<T>, 'items' | 'children' | 'renderEmptyState'> {}
|
||||
|
||||
// @public
|
||||
export interface TagProps extends TagProps_2 {
|
||||
icon?: React.ReactNode;
|
||||
size?: 'small' | 'medium';
|
||||
}
|
||||
|
||||
// @public (undocumented)
|
||||
const Text_2: {
|
||||
<T extends ElementType = 'p'>(
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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 { Flex, Icon, IconNames } from '../../';
|
||||
import { useListData } from 'react-stately';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: IconNames;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Backstage UI/TagGroup',
|
||||
component: TagGroup<ListItem>,
|
||||
argTypes: {
|
||||
selectionMode: {
|
||||
control: { type: 'inline-radio' },
|
||||
options: ['single', 'multiple'],
|
||||
},
|
||||
'aria-label': {
|
||||
control: { type: 'text' },
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
Story => (
|
||||
<MemoryRouter>
|
||||
<Story />
|
||||
</MemoryRouter>
|
||||
),
|
||||
],
|
||||
} satisfies Meta<typeof TagGroup<ListItem>>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const initialList: ListItem[] = [
|
||||
{ id: 'banana', name: 'Banana', icon: 'bug' },
|
||||
{ id: 'apple', name: 'Apple', icon: 'account-circle', isDisabled: true },
|
||||
{ id: 'orange', name: 'Orange', icon: 'eye', isDisabled: true },
|
||||
{ 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 => (
|
||||
<TagGroup {...args}>
|
||||
{initialList.map(item => (
|
||||
<Tag key={item.id}>{item.name}</Tag>
|
||||
))}
|
||||
</TagGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const Sizes: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: args => (
|
||||
<Flex direction="column">
|
||||
<TagGroup {...args}>
|
||||
{initialList.map(item => (
|
||||
<Tag key={item.id} size="small" icon={<Icon name={item.icon} />}>
|
||||
{item.name}
|
||||
</Tag>
|
||||
))}
|
||||
</TagGroup>
|
||||
<TagGroup {...args}>
|
||||
{initialList.map(item => (
|
||||
<Tag key={item.id} size="medium" icon={<Icon name={item.icon} />}>
|
||||
{item.name}
|
||||
</Tag>
|
||||
))}
|
||||
</TagGroup>
|
||||
</Flex>
|
||||
),
|
||||
};
|
||||
|
||||
export const SelectionModeSingle: Story = {
|
||||
args: {
|
||||
selectionMode: 'single',
|
||||
'aria-label': 'Tag Group',
|
||||
},
|
||||
render: args => {
|
||||
const [selected, setSelected] = useState<Selection>(new Set(['travel']));
|
||||
|
||||
return (
|
||||
<TagGroup
|
||||
items={initialList}
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
{...args}
|
||||
>
|
||||
{item => <Tag>{item.name}</Tag>}
|
||||
</TagGroup>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const SelectionModeMultiple: Story = {
|
||||
args: {
|
||||
selectionMode: 'multiple',
|
||||
'aria-label': 'Tag Group',
|
||||
},
|
||||
render: args => {
|
||||
const [selected, setSelected] = useState<Selection>(
|
||||
new Set(['travel', 'shopping']),
|
||||
);
|
||||
|
||||
return (
|
||||
<TagGroup
|
||||
items={initialList}
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
{...args}
|
||||
>
|
||||
{item => <Tag>{item.name}</Tag>}
|
||||
</TagGroup>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: args => (
|
||||
<TagGroup {...args}>
|
||||
{initialList.map(item => (
|
||||
<Tag
|
||||
key={item.id}
|
||||
icon={item.icon ? <Icon name={item.icon} /> : undefined}
|
||||
>
|
||||
{item.name}
|
||||
</Tag>
|
||||
))}
|
||||
</TagGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const WithLink: Story = {
|
||||
render: args => (
|
||||
<TagGroup {...args}>
|
||||
{initialList.map(item => (
|
||||
<Tag key={item.id} href={`/items/${item.id}`}>
|
||||
{item.name}
|
||||
</Tag>
|
||||
))}
|
||||
</TagGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
render: args => (
|
||||
<TagGroup {...args}>
|
||||
{initialList.map(item => (
|
||||
<Tag key={item.id} isDisabled={item.isDisabled}>
|
||||
{item.name}
|
||||
</Tag>
|
||||
))}
|
||||
</TagGroup>
|
||||
),
|
||||
};
|
||||
|
||||
export const RemovingTags: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: args => {
|
||||
const [selected, setSelected] = useState<Selection>(new Set(['travel']));
|
||||
|
||||
const list = useListData({
|
||||
initialItems: initialList,
|
||||
});
|
||||
|
||||
return (
|
||||
<TagGroup
|
||||
items={list.items}
|
||||
onRemove={keys => list.remove(...keys)}
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
{...args}
|
||||
>
|
||||
{item => <Tag>{item.name}</Tag>}
|
||||
</TagGroup>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIconAndRemoveButton: Story = {
|
||||
args: {
|
||||
...Default.args,
|
||||
},
|
||||
render: args => {
|
||||
const [selected, setSelected] = useState<Selection>(new Set(['travel']));
|
||||
|
||||
const list = useListData({
|
||||
initialItems: initialList,
|
||||
});
|
||||
|
||||
return (
|
||||
<TagGroup
|
||||
items={list.items}
|
||||
onRemove={keys => list.remove(...keys)}
|
||||
selectedKeys={selected}
|
||||
onSelectionChange={setSelected}
|
||||
{...args}
|
||||
>
|
||||
{item => (
|
||||
<Tag icon={item.icon ? <Icon name={item.icon} /> : undefined}>
|
||||
{item.name}
|
||||
</Tag>
|
||||
)}
|
||||
</TagGroup>
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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-2);
|
||||
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-Tag[data-disabled] {
|
||||
color: var(--bui-fg-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* 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,
|
||||
RouterProvider,
|
||||
} from 'react-aria-components';
|
||||
import type { ReactNode } from 'react';
|
||||
import { RiCloseCircleLine } from '@remixicon/react';
|
||||
import clsx from 'clsx';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { isExternalLink } from '../../utils/isExternalLink';
|
||||
import { useNavigate, useHref } from 'react-router-dom';
|
||||
|
||||
/**
|
||||
* A component that renders a list of tags.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const TagGroup = <T extends object>({
|
||||
items,
|
||||
children,
|
||||
renderEmptyState,
|
||||
...props
|
||||
}: TagGroupProps<T>) => {
|
||||
const { classNames } = useStyles('TagGroup');
|
||||
return (
|
||||
<ReactAriaTagGroup className={classNames.group} {...props}>
|
||||
<ReactAriaTagList
|
||||
className={classNames.list}
|
||||
items={items}
|
||||
renderEmptyState={renderEmptyState}
|
||||
>
|
||||
{children}
|
||||
</ReactAriaTagList>
|
||||
</ReactAriaTagGroup>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that renders a tag.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const Tag = (props: TagProps) => {
|
||||
const { children, className, icon, size = 'small', href, ...rest } = props;
|
||||
const textValue = typeof children === 'string' ? children : undefined;
|
||||
const { classNames } = useStyles('TagGroup');
|
||||
const navigate = useNavigate();
|
||||
const isLink = href !== undefined;
|
||||
const isExternal = isExternalLink(href);
|
||||
|
||||
const content = (
|
||||
<ReactAriaTag
|
||||
textValue={textValue}
|
||||
className={clsx(classNames.tag, className)}
|
||||
data-size={size}
|
||||
href={href}
|
||||
{...rest}
|
||||
>
|
||||
{({ allowsRemoving }) => (
|
||||
<>
|
||||
{icon && <span className={classNames.tagIcon}>{icon}</span>}
|
||||
{children as ReactNode}
|
||||
{allowsRemoving && (
|
||||
<ReactAriaButton
|
||||
className={classNames.tagRemoveButton}
|
||||
slot="remove"
|
||||
>
|
||||
<RiCloseCircleLine size={16} />
|
||||
</ReactAriaButton>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ReactAriaTag>
|
||||
);
|
||||
|
||||
if (isLink && !isExternal) {
|
||||
return (
|
||||
<RouterProvider navigate={navigate} useHref={useHref}>
|
||||
{content}
|
||||
</RouterProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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<T>
|
||||
extends Omit<ReactAriaTagGroupProps, 'children'>,
|
||||
Pick<ReactAriaTagListProps<T>, '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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user