From 7bfc7a927d07567b7f9f36e0984537cf3a75dfb5 Mon Sep 17 00:00:00 2001 From: Marta Kozina Date: Mon, 2 Oct 2023 21:56:26 +0200 Subject: [PATCH 1/2] feat: TET-363 tag --- src/components/Tag/Tag.props.ts | 13 ++ src/components/Tag/Tag.stories.tsx | 53 +++++++++ src/components/Tag/Tag.styles.ts | 60 ++++++++++ src/components/Tag/Tag.test.tsx | 112 ++++++++++++++++++ src/components/Tag/Tag.tsx | 98 +++++++++++++++ src/components/Tag/index.ts | 2 + src/components/Tag/stylesBuilder/index.ts | 1 + .../Tag/stylesBuilder/stylesBuilder.ts | 33 ++++++ src/docs-components/TagDocs.tsx | 63 ++++++++++ 9 files changed, 435 insertions(+) create mode 100644 src/components/Tag/Tag.props.ts create mode 100644 src/components/Tag/Tag.stories.tsx create mode 100644 src/components/Tag/Tag.styles.ts create mode 100644 src/components/Tag/Tag.test.tsx create mode 100644 src/components/Tag/Tag.tsx create mode 100644 src/components/Tag/index.ts create mode 100644 src/components/Tag/stylesBuilder/index.ts create mode 100644 src/components/Tag/stylesBuilder/stylesBuilder.ts create mode 100644 src/docs-components/TagDocs.tsx 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..190e6855 --- /dev/null +++ b/src/components/Tag/Tag.styles.ts @@ -0,0 +1,60 @@ +import { BaseProps } from '@/types'; + +export type TagConfig = { + hasOnClick?: BaseProps; + innerElements?: { + label: BaseProps; + closeButton?: BaseProps; + }; + beforeComponent: { + avatar?: BaseProps; + }; +} & BaseProps; + +export const defaultConfig = { + display: 'inline-flex', + h: 'xSmall', + alignItems: 'center', + borderRadius: 'medium', + backgroundColor: 'interaction-neutral-subtle-normal', + opacity: { + disabled: 50, + }, + cursor: 'default', + outlineColor: { + focus: 'interaction-focus-default', + }, + transitionDuration: 50, + hasOnClick: { + backgroundColor: { + _: 'interaction-neutral-subtle-normal', + hover: 'interaction-neutral-subtle-hover', + active: 'interaction-neutral-subtle-active', + focus: 'interaction-neutral-subtle-normal', + selected: 'interaction-neutral-subtle-selected', + disabled: 'interaction-neutral-subtle-normal', + }, + cursor: { + _: 'pointer', + disabled: 'default', + }, + }, + innerElements: { + label: { + mx: 'component-padding-small', + }, + closeButton: { + mr: 'component-padding-xSmall', + h: '2xSmall', + w: '2xSmall', + opacity: { + disabled: 100, + }, + }, + }, + beforeComponent: { + avatar: { + ml: 'component-padding-2xSmall', + }, + }, +}; diff --git a/src/components/Tag/Tag.test.tsx b/src/components/Tag/Tag.test.tsx new file mode 100644 index 00000000..a406e11e --- /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-before-component'), + closeButton: queryByTestId('tag-icon-button'), + }; +}; + +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: hsla(204,20%,95%,1);'); + }); + + 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..e703b8eb --- /dev/null +++ b/src/components/Tag/Tag.tsx @@ -0,0 +1,98 @@ +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'; + +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 handleOnCloseClick: MouseEventHandler = useCallback( + (e) => { + onCloseClick?.(e); + e.stopPropagation(); + }, + [onCloseClick], + ); + + const containerRef = useRef(null); + const handleOnKeyDown: KeyboardEventHandler = useCallback( + (e) => { + if ( + e.target === containerRef.current && + (e.key === 'Enter' || e.key === ' ') + ) { + onClick?.(e); + } + }, + [containerRef, onClick], + ); + + return ( + + {!!beforeComponent && ( + + )} + + {label} + + {!!onCloseClick && ( + + )} + + ); +}; 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..6309b5db --- /dev/null +++ b/src/components/Tag/stylesBuilder/stylesBuilder.ts @@ -0,0 +1,33 @@ +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..51fbea1d --- /dev/null +++ b/src/docs-components/TagDocs.tsx @@ -0,0 +1,63 @@ +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} + 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) => ( + + ))} + + + ))} + + ))} + +); From dee339e24952992ba6b141592eb39baf06dea219 Mon Sep 17 00:00:00 2001 From: Marta Kozina Date: Tue, 3 Oct 2023 18:21:03 +0200 Subject: [PATCH 2/2] feat: TET-363 tag v2 --- src/components/Tag/Tag.styles.ts | 33 +++++++++++-------- src/components/Tag/Tag.test.tsx | 4 +-- src/components/Tag/Tag.tsx | 29 +++++++++------- .../Tag/stylesBuilder/stylesBuilder.ts | 9 +++-- src/docs-components/TagDocs.tsx | 1 + 5 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/components/Tag/Tag.styles.ts b/src/components/Tag/Tag.styles.ts index 190e6855..b6528750 100644 --- a/src/components/Tag/Tag.styles.ts +++ b/src/components/Tag/Tag.styles.ts @@ -1,16 +1,21 @@ -import { BaseProps } from '@/types'; +import type { BaseProps } from '@/types/BaseProps'; export type TagConfig = { hasOnClick?: BaseProps; innerElements?: { label: BaseProps; closeButton?: BaseProps; - }; - beforeComponent: { - avatar?: BaseProps; + beforeComponent?: { + avatar?: BaseProps; + }; }; } & BaseProps; +const backgroundColor = { + hover: 'interaction-neutral-subtle-hover', + active: 'interaction-neutral-subtle-active', + focus: 'interaction-neutral-subtle-normal', +}; export const defaultConfig = { display: 'inline-flex', h: 'xSmall', @@ -25,14 +30,16 @@ export const defaultConfig = { focus: 'interaction-focus-default', }, transitionDuration: 50, + color: 'content-primary', hasOnClick: { backgroundColor: { _: 'interaction-neutral-subtle-normal', - hover: 'interaction-neutral-subtle-hover', - active: 'interaction-neutral-subtle-active', - focus: 'interaction-neutral-subtle-normal', - selected: 'interaction-neutral-subtle-selected', disabled: 'interaction-neutral-subtle-normal', + selected: { + _: 'interaction-neutral-subtle-selected', + ...backgroundColor, + }, + ...backgroundColor, }, cursor: { _: 'pointer', @@ -51,10 +58,10 @@ export const defaultConfig = { disabled: 100, }, }, - }, - beforeComponent: { - avatar: { - ml: 'component-padding-2xSmall', + beforeComponent: { + avatar: { + ml: 'component-padding-2xSmall', + }, }, }, -}; +} satisfies TagConfig; diff --git a/src/components/Tag/Tag.test.tsx b/src/components/Tag/Tag.test.tsx index a406e11e..5d097c44 100644 --- a/src/components/Tag/Tag.test.tsx +++ b/src/components/Tag/Tag.test.tsx @@ -11,8 +11,8 @@ const getTag = (jsx: JSX.Element) => { return { tag: getByTestId('tag'), label: getByTestId('tag-label'), - avatar: queryByTestId('tag-before-component'), - closeButton: queryByTestId('tag-icon-button'), + avatar: queryByTestId('tag-avatar'), + closeButton: queryByTestId('tag-iconButton'), }; }; diff --git a/src/components/Tag/Tag.tsx b/src/components/Tag/Tag.tsx index e703b8eb..b68414f4 100644 --- a/src/components/Tag/Tag.tsx +++ b/src/components/Tag/Tag.tsx @@ -15,6 +15,11 @@ import { IconButton } from '../IconButton'; import { tet } from '@/tetrisly'; import { MarginProps } from '@/types'; +const KEYBOARD_KEYS = { + Enter: 'Enter', + Space: ' ', +}; + export const Tag: FC = ({ label, state, @@ -31,20 +36,12 @@ export const Tag: FC = ({ [custom, hasOnClick], ); - const handleOnCloseClick: MouseEventHandler = useCallback( - (e) => { - onCloseClick?.(e); - e.stopPropagation(); - }, - [onCloseClick], - ); - const containerRef = useRef(null); const handleOnKeyDown: KeyboardEventHandler = useCallback( (e) => { if ( e.target === containerRef.current && - (e.key === 'Enter' || e.key === ' ') + (e.key === KEYBOARD_KEYS.Enter || e.key === KEYBOARD_KEYS.Space) ) { onClick?.(e); } @@ -52,6 +49,14 @@ export const Tag: FC = ({ [containerRef, onClick], ); + const handleOnCloseClick: MouseEventHandler = useCallback( + (e) => { + onCloseClick?.(e); + e.stopPropagation(); + }, + [onCloseClick], + ); + return ( = ({ size="2xSmall" {...beforeComponent.props} {...styles.avatar} - data-testid="tag-before-component" + data-testid="tag-avatar" /> )} = ({ > {label} - {!!onCloseClick && ( + {hasCloseButton && ( )} diff --git a/src/components/Tag/stylesBuilder/stylesBuilder.ts b/src/components/Tag/stylesBuilder/stylesBuilder.ts index 6309b5db..117b5c6c 100644 --- a/src/components/Tag/stylesBuilder/stylesBuilder.ts +++ b/src/components/Tag/stylesBuilder/stylesBuilder.ts @@ -16,15 +16,18 @@ export const stylesBuilder = ( ): TagStylesBuilder => { const { hasOnClick: hasOnClickStyles, - innerElements: { label, closeButton }, - beforeComponent: { avatar }, + innerElements: { + label, + closeButton, + beforeComponent: { avatar }, + }, ...container } = mergeConfigWithCustom({ defaultConfig, custom }); return { container: { ...container, - ...(hasOnClick ? hasOnClickStyles : {}), + ...(hasOnClick && hasOnClickStyles), }, label, avatar, diff --git a/src/docs-components/TagDocs.tsx b/src/docs-components/TagDocs.tsx index 51fbea1d..9de01e30 100644 --- a/src/docs-components/TagDocs.tsx +++ b/src/docs-components/TagDocs.tsx @@ -23,6 +23,7 @@ const RenderTag = ({ label="Tag" state={state === ':normal' ? undefined : (state as TagProps['state'])} onCloseClick={header === 'Remove button: Yes' ? () => null : undefined} + onClick={() => null} beforeComponent={ label === 'Before component: Avatar' ? {