diff --git a/app/javascript/flavours/glitch/components/tags/style.module.css b/app/javascript/flavours/glitch/components/tags/style.module.css new file mode 100644 index 0000000000..1492b67c88 --- /dev/null +++ b/app/javascript/flavours/glitch/components/tags/style.module.css @@ -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; +} diff --git a/app/javascript/flavours/glitch/components/tags/tag.stories.tsx b/app/javascript/flavours/glitch/components/tags/tag.stories.tsx new file mode 100644 index 0000000000..3db9f5fe99 --- /dev/null +++ b/app/javascript/flavours/glitch/components/tags/tag.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithIcon: Story = { + args: { + icon: MusicNoteIcon, + }, +}; + +export const Editable: Story = { + render(args) { + return ; + }, +}; + +export const EditableWithIcon: Story = { + render(args) { + return ( + + ); + }, +}; diff --git a/app/javascript/flavours/glitch/components/tags/tag.tsx b/app/javascript/flavours/glitch/components/tags/tag.tsx new file mode 100644 index 0000000000..4dd4b89b55 --- /dev/null +++ b/app/javascript/flavours/glitch/components/tags/tag.tsx @@ -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 ( + + ); +}); +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 ( + + {icon && } + {typeof name === 'string' ? `#${name}` : name} + {children} + + + ); + }, +); +EditableTag.displayName = 'EditableTag'; diff --git a/app/javascript/flavours/glitch/components/tags/tags.stories.tsx b/app/javascript/flavours/glitch/components/tags/tags.stories.tsx new file mode 100644 index 0000000000..cf4e361b20 --- /dev/null +++ b/app/javascript/flavours/glitch/components/tags/tags.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + render(args) { + return ; + }, +}; + +export const Editable: Story = { + args: { + onRemove: action('Remove'), + }, +}; diff --git a/app/javascript/flavours/glitch/components/tags/tags.tsx b/app/javascript/flavours/glitch/components/tags/tags.tsx new file mode 100644 index 0000000000..c1c120def7 --- /dev/null +++ b/app/javascript/flavours/glitch/components/tags/tags.tsx @@ -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 = ({ tags, active, onRemove, ...props }) => { + if (onRemove) { + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); + } + + return ( +
+ {tags.map((tag) => ( + + ))} +
+ ); +}; + +const MappedTag: FC void }> = ({ + onRemove, + ...props +}) => { + const handleRemove = useCallback(() => { + onRemove?.(props.name); + }, [onRemove, props.name]); + return ; +};