diff --git a/src/components/Tag/Tag.props.ts b/src/components/Tag/Tag.props.ts new file mode 100644 index 00000000..5100aed1 --- /dev/null +++ b/src/components/Tag/Tag.props.ts @@ -0,0 +1,13 @@ +import { SyntheticEvent } from 'react'; + +import { TagConfig } from '@/components/Tag/Tag.styles.ts'; +import { TextInputProps } from '@/components/TextInput'; + +export type TagProps = { + label: string; + state?: 'selected' | 'disabled'; + beforeComponent?: TextInputProps.InnerComponents.Avatar; + onClick?: (e: SyntheticEvent) => void; + onCloseClick?: (e: SyntheticEvent) => void; + custom?: TagConfig; +}; diff --git a/src/components/Tag/Tag.stories.tsx b/src/components/Tag/Tag.stories.tsx new file mode 100644 index 00000000..4e505186 --- /dev/null +++ b/src/components/Tag/Tag.stories.tsx @@ -0,0 +1,53 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { Tag } from './Tag'; + +import { TagDocs } from '@/docs-components/TagDocs.tsx'; +import { TetDocs } from '@/docs-components/TetDocs'; + +const meta = { + title: 'Tag', + component: Tag, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'A compact, visually distinct element used to label, categorize, or organize content. Tags can help users quickly identify and filter items by attributes such as keywords, topics, or statuses.', + }, + page: () => ( + + + + ), + }, + }, + args: { + label: 'Tag', + onClick: () => null, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const BeforeAvatarComponent: Story = { + args: { + beforeComponent: { + type: 'Avatar', + props: { + initials: 'A', + emphasis: 'high', + }, + }, + }, +}; + +export const WithRemoveButton: Story = { + args: { + state: undefined, + onCloseClick: () => null, + }, +}; diff --git a/src/components/Tag/Tag.styles.ts b/src/components/Tag/Tag.styles.ts new file mode 100644 index 00000000..de04d9ea --- /dev/null +++ b/src/components/Tag/Tag.styles.ts @@ -0,0 +1,69 @@ +import type { BaseProps } from '@/types/BaseProps'; + +export type TagConfig = { + hasOnClick?: BaseProps; + innerElements?: { + label: BaseProps; + closeButton?: BaseProps; + beforeComponent?: { + avatar?: BaseProps; + }; + }; +} & BaseProps; + +const backgroundColor = { + hover: '$color-interaction-neutral-subtle-hover', + active: '$color-interaction-neutral-subtle-active', + focus: '$color-interaction-neutral-subtle-normal', +}; + +export const defaultConfig = { + display: 'inline-flex', + h: '$size-xSmall', + alignItems: 'center', + borderRadius: '$border-radius-medium', + backgroundColor: '$color-interaction-neutral-subtle-normal', + opacity: { + disabled: '$opacity-disabled', + }, + cursor: 'default', + outlineColor: { + focus: '$color-interaction-focus-default', + }, + transitionDuration: 50, + color: '$color-content-primary', + hasOnClick: { + backgroundColor: { + _: '$color-interaction-neutral-subtle-normal', + disabled: '$color-interaction-neutral-subtle-normal', + selected: { + _: '$color-interaction-neutral-subtle-selected', + ...backgroundColor, + }, + ...backgroundColor, + }, + cursor: { + _: 'pointer', + disabled: 'default', + }, + }, + innerElements: { + label: { + mx: '$space-component-padding-small', + text: '$typo-body-medium', + }, + closeButton: { + mr: '$space-component-padding-xSmall', + h: '$size-2xSmall', + w: '$size-2xSmall', + opacity: { + disabled: '$opacity-100', + }, + }, + beforeComponent: { + avatar: { + ml: '$space-component-padding-2xSmall', + }, + }, + }, +} satisfies TagConfig; diff --git a/src/components/Tag/Tag.test.tsx b/src/components/Tag/Tag.test.tsx new file mode 100644 index 00000000..5736746c --- /dev/null +++ b/src/components/Tag/Tag.test.tsx @@ -0,0 +1,112 @@ +import { vi } from 'vitest'; + +import { render, fireEvent } from '../../tests/render'; + +import { Tag } from '@/components/Tag/Tag.tsx'; +import { customPropTester } from '@/tests/customPropTester'; + +const getTag = (jsx: JSX.Element) => { + const { getByTestId, queryByTestId } = render(jsx); + + return { + tag: getByTestId('tag'), + label: getByTestId('tag-label'), + avatar: queryByTestId('tag-avatar'), + closeButton: queryByTestId('tag-iconButton'), + }; +}; + +describe('Tag', () => { + const handleEventMock = vi.fn(); + + customPropTester(, { + containerId: 'tag', + }); + + beforeEach(() => { + handleEventMock.mockReset(); + }); + + it('should render the tag', () => { + const { tag } = getTag(); + expect(tag).toBeInTheDocument(); + }); + + it('should render the correct label', () => { + const { tag } = getTag(); + expect(tag).toHaveTextContent('label'); + }); + + it('should render beforeComponent', () => { + const { avatar } = getTag( + , + ); + expect(avatar).toBeInTheDocument(); + }); + + it('should render closeButton', () => { + const { closeButton } = getTag( + , + ); + expect(closeButton).toBeInTheDocument(); + }); + + it('should emit onClick', () => { + const { tag } = getTag(); + fireEvent.click(tag); + expect(handleEventMock).toHaveBeenCalled(); + }); + + it('should not emit onCloseClick', () => { + const { closeButton } = getTag( + , + ); + fireEvent.click(closeButton as Element); + expect(handleEventMock).not.toHaveBeenCalled(); + }); + + it('should render disabled closeButton', () => { + const { closeButton } = getTag( + , + ); + expect(closeButton).toBeDisabled(); + }); + + it('should render the correct color (disabled)', () => { + const { tag } = getTag(); + expect(tag).toHaveStyle('background-color: rgb(240, 243, 245);'); + }); + + it('should render the right cursor (with onClick)', () => { + const { tag } = getTag(); + expect(tag).toHaveStyle('cursor: pointer'); + }); + + it('should render the right cursor (without onClick)', () => { + const { tag } = getTag(); + expect(tag).toHaveStyle('cursor: default'); + }); + + it('should render the right cursor (with state disabled)', () => { + const { tag } = getTag(); + expect(tag).toHaveStyle('cursor: default'); + }); + + it('should not emit onClick', () => { + const onCloseCLick = vi.fn(); + const onClick = vi.fn(); + + const { closeButton } = getTag( + , + ); + + if (closeButton) { + fireEvent.click(closeButton); + } + expect(onCloseCLick).toBeCalledTimes(1); + expect(onClick).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Tag/Tag.tsx b/src/components/Tag/Tag.tsx new file mode 100644 index 00000000..301dba37 --- /dev/null +++ b/src/components/Tag/Tag.tsx @@ -0,0 +1,103 @@ +import { + FC, + KeyboardEventHandler, + MouseEventHandler, + useCallback, + useMemo, + useRef, +} from 'react'; + +import { stylesBuilder } from './stylesBuilder'; +import { TagProps } from './Tag.props'; +import { Avatar } from '../Avatar'; +import { IconButton } from '../IconButton'; + +import { tet } from '@/tetrisly'; +import { MarginProps } from '@/types'; + +const KEYBOARD_KEYS = { + Enter: 'Enter', + Space: ' ', +}; + +export const Tag: FC = ({ + label, + state, + beforeComponent, + onClick, + onCloseClick, + custom, + ...restProps +}) => { + const hasCloseButton = !!onCloseClick; + const hasOnClick = !!onClick; + const styles = useMemo( + () => stylesBuilder(custom, hasOnClick), + [custom, hasOnClick], + ); + + const containerRef = useRef(null); + const handleOnKeyDown: KeyboardEventHandler = useCallback( + (e) => { + if ( + e.target === containerRef.current && + (e.key === KEYBOARD_KEYS.Enter || e.key === KEYBOARD_KEYS.Space) + ) { + onClick?.(e); + } + }, + [containerRef, onClick], + ); + + const handleOnCloseClick: MouseEventHandler = useCallback( + (e) => { + onCloseClick?.(e); + e.stopPropagation(); + }, + [onCloseClick], + ); + + return ( + + {!!beforeComponent && ( + + )} + + {label} + + {hasCloseButton && ( + + )} + + ); +}; diff --git a/src/components/Tag/index.ts b/src/components/Tag/index.ts new file mode 100644 index 00000000..cd8beb86 --- /dev/null +++ b/src/components/Tag/index.ts @@ -0,0 +1,2 @@ +export { Tag } from './Tag'; +export type { TagProps } from './Tag.props'; diff --git a/src/components/Tag/stylesBuilder/index.ts b/src/components/Tag/stylesBuilder/index.ts new file mode 100644 index 00000000..fe248ee5 --- /dev/null +++ b/src/components/Tag/stylesBuilder/index.ts @@ -0,0 +1 @@ +export { stylesBuilder } from './stylesBuilder'; diff --git a/src/components/Tag/stylesBuilder/stylesBuilder.ts b/src/components/Tag/stylesBuilder/stylesBuilder.ts new file mode 100644 index 00000000..117b5c6c --- /dev/null +++ b/src/components/Tag/stylesBuilder/stylesBuilder.ts @@ -0,0 +1,36 @@ +import type { TagProps } from '../Tag.props'; +import { defaultConfig } from '../Tag.styles'; + +import { mergeConfigWithCustom } from '@/services'; +import type { BaseProps } from '@/types/BaseProps'; + +type TagStylesBuilder = { + container: BaseProps; + label: BaseProps; + avatar: BaseProps; + closeButton: BaseProps; +}; +export const stylesBuilder = ( + custom: TagProps['custom'], + hasOnClick?: boolean, +): TagStylesBuilder => { + const { + hasOnClick: hasOnClickStyles, + innerElements: { + label, + closeButton, + beforeComponent: { avatar }, + }, + ...container + } = mergeConfigWithCustom({ defaultConfig, custom }); + + return { + container: { + ...container, + ...(hasOnClick && hasOnClickStyles), + }, + label, + avatar, + closeButton, + }; +}; diff --git a/src/docs-components/TagDocs.tsx b/src/docs-components/TagDocs.tsx new file mode 100644 index 00000000..b9c8a074 --- /dev/null +++ b/src/docs-components/TagDocs.tsx @@ -0,0 +1,70 @@ +import { FC } from 'react'; + +import { Tag, TagProps } from '@/components/Tag'; +import { SectionHeader } from '@/docs-components/common/SectionHeader.tsx'; +import { States } from '@/docs-components/common/States.tsx'; +import { tet } from '@/tetrisly'; + +const headers = ['Remove button: No', 'Remove button: Yes'] as const; +const labels = ['Before component: None', 'Before component: Avatar']; +const states = [':normal', ':selected', ':disabled']; + +const RenderTag = ({ + header, + label, + state, +}: { + header: (typeof headers)[number]; + label: (typeof labels)[number]; + state: (typeof states)[number]; +}) => ( + + null : undefined} + onClick={() => null} + beforeComponent={ + label === 'Before component: Avatar' + ? { + type: 'Avatar', + props: { initials: 'A', appearance: 'blue', emphasis: 'high' }, + } + : undefined + } + /> + +); + +export const TagDocs: FC = () => ( + + {headers.map((header) => ( + + + {header} + + {labels.map((label) => ( + + + + + {states.map((state) => ( + + ))} + + + ))} + + ))} + +);