[Glitch] Tags component

Port 3f46034039 to glitch-soc

Signed-off-by: Claire <claire.github-309c@sitedethib.com>
This commit is contained in:
Echo
2026-01-28 11:17:11 +01:00
committed by Claire
parent 36be9bbebf
commit da2cb50aaa
5 changed files with 273 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
.tag {
border-radius: 9999px;
border: 1px solid var(--color-border-primary);
appearance: none;
background: none;
padding: 8px;
transition: all 0.2s ease-in-out;
color: var(--color-text-primary);
display: inline-flex;
align-items: center;
gap: 2px;
}
button.tag:hover,
button.tag:focus {
border-color: var(--color-bg-brand-base-hover);
}
button.tag:focus-visible {
outline: var(--outline-focus-default);
outline-offset: 2px;
}
.active {
border-color: var(--color-text-brand);
background: var(--color-bg-brand-softer);
color: var(--color-text-brand);
}
.icon {
height: 1.2em;
width: 1.2em;
}
.closeButton {
svg {
height: 1.2em;
width: 1.2em;
}
}
.active .closeButton {
color: inherit;
}
.tagsWrapper {
display: flex;
flex-wrap: wrap;
gap: 8px;
}

View File

@@ -0,0 +1,46 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import MusicNoteIcon from '@/material-icons/400-24px/music_note.svg?react';
import { EditableTag, Tag } from './tag';
const meta = {
component: Tag,
title: 'Components/Tags/Single Tag',
args: {
name: 'example-tag',
active: false,
onClick: action('Click'),
},
} satisfies Meta<typeof Tag>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const WithIcon: Story = {
args: {
icon: MusicNoteIcon,
},
};
export const Editable: Story = {
render(args) {
return <EditableTag {...args} onRemove={action('Remove')} />;
},
};
export const EditableWithIcon: Story = {
render(args) {
return (
<EditableTag
{...args}
removeIcon={MusicNoteIcon}
onRemove={action('Remove')}
/>
);
},
};

View File

@@ -0,0 +1,94 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import { forwardRef } from 'react';
import { useIntl } from 'react-intl';
import classNames from 'classnames';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import type { IconProp } from '../icon';
import { Icon } from '../icon';
import { IconButton } from '../icon_button';
import classes from './style.module.css';
export interface TagProps {
name: ReactNode;
active?: boolean;
icon?: IconProp;
className?: string;
children?: ReactNode;
}
export const Tag = forwardRef<
HTMLButtonElement,
TagProps & ComponentPropsWithoutRef<'button'>
>(({ name, active, icon, className, children, ...props }, ref) => {
if (!name) {
return null;
}
return (
<button
{...props}
type='button'
ref={ref}
className={classNames(className, classes.tag, active && classes.active)}
>
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
{typeof name === 'string' ? `#${name}` : name}
{children}
</button>
);
});
Tag.displayName = 'Tag';
export const EditableTag = forwardRef<
HTMLSpanElement,
TagProps & {
onRemove: () => void;
removeIcon?: IconProp;
} & ComponentPropsWithoutRef<'span'>
>(
(
{
name,
active,
icon,
className,
children,
removeIcon = CloseIcon,
onRemove,
...props
},
ref,
) => {
const intl = useIntl();
if (!name) {
return null;
}
return (
<span
{...props}
ref={ref}
className={classNames(className, classes.tag, active && classes.active)}
>
{icon && <Icon icon={icon} id='tag-icon' className={classes.icon} />}
{typeof name === 'string' ? `#${name}` : name}
{children}
<IconButton
className={classes.closeButton}
iconComponent={removeIcon}
onClick={onRemove}
icon='remove'
title={intl.formatMessage({
id: 'tag.remove',
defaultMessage: 'Remove',
})}
/>
</span>
);
},
);
EditableTag.displayName = 'EditableTag';

View File

@@ -0,0 +1,29 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { action } from 'storybook/actions';
import { Tags } from './tags';
const meta = {
component: Tags,
title: 'Components/Tags/List',
args: {
tags: [{ name: 'tag-one' }, { name: 'tag-two' }],
active: 'tag-one',
},
} satisfies Meta<typeof Tags>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
render(args) {
return <Tags {...args} />;
},
};
export const Editable: Story = {
args: {
onRemove: action('Remove'),
},
};

View File

@@ -0,0 +1,54 @@
import { useCallback } from 'react';
import type { ComponentPropsWithoutRef, FC } from 'react';
import classes from './style.module.css';
import { EditableTag, Tag } from './tag';
import type { TagProps } from './tag';
type Tag = TagProps & { name: string };
export type TagsProps = {
tags: Tag[];
active?: string;
} & (
| ({
onRemove?: never;
} & ComponentPropsWithoutRef<'button'>)
| ({ onRemove?: (tag: string) => void } & ComponentPropsWithoutRef<'span'>)
);
export const Tags: FC<TagsProps> = ({ tags, active, onRemove, ...props }) => {
if (onRemove) {
return (
<div className={classes.tagsWrapper}>
{tags.map((tag) => (
<MappedTag
key={tag.name}
active={tag.name === active}
onRemove={onRemove}
{...tag}
{...props}
/>
))}
</div>
);
}
return (
<div className={classes.tagsWrapper}>
{tags.map((tag) => (
<Tag key={tag.name} active={tag.name === active} {...tag} {...props} />
))}
</div>
);
};
const MappedTag: FC<Tag & { onRemove?: (tag: string) => void }> = ({
onRemove,
...props
}) => {
const handleRemove = useCallback(() => {
onRemove?.(props.name);
}, [onRemove, props.name]);
return <EditableTag {...props} onRemove={handleRemove} />;
};