Skip to content

Commit a9ba9ab

Browse files
adrian-potepaMarta Kozina
andauthored
feat: tag component (#91)
* feat: TET-363 tag * feat: TET-363 tag v2 * feat: tokens update TET-363 --------- Co-authored-by: Marta Kozina <[email protected]>
1 parent 2b08cb4 commit a9ba9ab

File tree

9 files changed

+459
-0
lines changed

9 files changed

+459
-0
lines changed

src/components/Tag/Tag.props.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { SyntheticEvent } from 'react';
2+
3+
import { TagConfig } from '@/components/Tag/Tag.styles.ts';
4+
import { TextInputProps } from '@/components/TextInput';
5+
6+
export type TagProps = {
7+
label: string;
8+
state?: 'selected' | 'disabled';
9+
beforeComponent?: TextInputProps.InnerComponents.Avatar;
10+
onClick?: (e: SyntheticEvent<HTMLSpanElement>) => void;
11+
onCloseClick?: (e: SyntheticEvent<HTMLButtonElement>) => void;
12+
custom?: TagConfig;
13+
};

src/components/Tag/Tag.stories.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { Tag } from './Tag';
4+
5+
import { TagDocs } from '@/docs-components/TagDocs.tsx';
6+
import { TetDocs } from '@/docs-components/TetDocs';
7+
8+
const meta = {
9+
title: 'Tag',
10+
component: Tag,
11+
tags: ['autodocs'],
12+
parameters: {
13+
docs: {
14+
description: {
15+
component:
16+
'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.',
17+
},
18+
page: () => (
19+
<TetDocs docs="https://docs.tetrisly.com/components/in-progress/tag">
20+
<TagDocs />
21+
</TetDocs>
22+
),
23+
},
24+
},
25+
args: {
26+
label: 'Tag',
27+
onClick: () => null,
28+
},
29+
} satisfies Meta<typeof Tag>;
30+
31+
export default meta;
32+
type Story = StoryObj<typeof meta>;
33+
34+
export const Default: Story = {};
35+
36+
export const BeforeAvatarComponent: Story = {
37+
args: {
38+
beforeComponent: {
39+
type: 'Avatar',
40+
props: {
41+
initials: 'A',
42+
emphasis: 'high',
43+
},
44+
},
45+
},
46+
};
47+
48+
export const WithRemoveButton: Story = {
49+
args: {
50+
state: undefined,
51+
onCloseClick: () => null,
52+
},
53+
};

src/components/Tag/Tag.styles.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import type { BaseProps } from '@/types/BaseProps';
2+
3+
export type TagConfig = {
4+
hasOnClick?: BaseProps;
5+
innerElements?: {
6+
label: BaseProps;
7+
closeButton?: BaseProps;
8+
beforeComponent?: {
9+
avatar?: BaseProps;
10+
};
11+
};
12+
} & BaseProps;
13+
14+
const backgroundColor = {
15+
hover: '$color-interaction-neutral-subtle-hover',
16+
active: '$color-interaction-neutral-subtle-active',
17+
focus: '$color-interaction-neutral-subtle-normal',
18+
};
19+
20+
export const defaultConfig = {
21+
display: 'inline-flex',
22+
h: '$size-xSmall',
23+
alignItems: 'center',
24+
borderRadius: '$border-radius-medium',
25+
backgroundColor: '$color-interaction-neutral-subtle-normal',
26+
opacity: {
27+
disabled: '$opacity-disabled',
28+
},
29+
cursor: 'default',
30+
outlineColor: {
31+
focus: '$color-interaction-focus-default',
32+
},
33+
transitionDuration: 50,
34+
color: '$color-content-primary',
35+
hasOnClick: {
36+
backgroundColor: {
37+
_: '$color-interaction-neutral-subtle-normal',
38+
disabled: '$color-interaction-neutral-subtle-normal',
39+
selected: {
40+
_: '$color-interaction-neutral-subtle-selected',
41+
...backgroundColor,
42+
},
43+
...backgroundColor,
44+
},
45+
cursor: {
46+
_: 'pointer',
47+
disabled: 'default',
48+
},
49+
},
50+
innerElements: {
51+
label: {
52+
mx: '$space-component-padding-small',
53+
text: '$typo-body-medium',
54+
},
55+
closeButton: {
56+
mr: '$space-component-padding-xSmall',
57+
h: '$size-2xSmall',
58+
w: '$size-2xSmall',
59+
opacity: {
60+
disabled: '$opacity-100',
61+
},
62+
},
63+
beforeComponent: {
64+
avatar: {
65+
ml: '$space-component-padding-2xSmall',
66+
},
67+
},
68+
},
69+
} satisfies TagConfig;

src/components/Tag/Tag.test.tsx

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { vi } from 'vitest';
2+
3+
import { render, fireEvent } from '../../tests/render';
4+
5+
import { Tag } from '@/components/Tag/Tag.tsx';
6+
import { customPropTester } from '@/tests/customPropTester';
7+
8+
const getTag = (jsx: JSX.Element) => {
9+
const { getByTestId, queryByTestId } = render(jsx);
10+
11+
return {
12+
tag: getByTestId('tag'),
13+
label: getByTestId('tag-label'),
14+
avatar: queryByTestId('tag-avatar'),
15+
closeButton: queryByTestId('tag-iconButton'),
16+
};
17+
};
18+
19+
describe('Tag', () => {
20+
const handleEventMock = vi.fn();
21+
22+
customPropTester(<Tag label="Label" />, {
23+
containerId: 'tag',
24+
});
25+
26+
beforeEach(() => {
27+
handleEventMock.mockReset();
28+
});
29+
30+
it('should render the tag', () => {
31+
const { tag } = getTag(<Tag label="label" />);
32+
expect(tag).toBeInTheDocument();
33+
});
34+
35+
it('should render the correct label', () => {
36+
const { tag } = getTag(<Tag label="label" />);
37+
expect(tag).toHaveTextContent('label');
38+
});
39+
40+
it('should render beforeComponent', () => {
41+
const { avatar } = getTag(
42+
<Tag
43+
label="label"
44+
beforeComponent={{ type: 'Avatar', props: { initials: 'A' } }}
45+
/>,
46+
);
47+
expect(avatar).toBeInTheDocument();
48+
});
49+
50+
it('should render closeButton', () => {
51+
const { closeButton } = getTag(
52+
<Tag label="label" onCloseClick={handleEventMock} />,
53+
);
54+
expect(closeButton).toBeInTheDocument();
55+
});
56+
57+
it('should emit onClick', () => {
58+
const { tag } = getTag(<Tag label="label" onClick={handleEventMock} />);
59+
fireEvent.click(tag);
60+
expect(handleEventMock).toHaveBeenCalled();
61+
});
62+
63+
it('should not emit onCloseClick', () => {
64+
const { closeButton } = getTag(
65+
<Tag label="label" onCloseClick={handleEventMock} state="disabled" />,
66+
);
67+
fireEvent.click(closeButton as Element);
68+
expect(handleEventMock).not.toHaveBeenCalled();
69+
});
70+
71+
it('should render disabled closeButton', () => {
72+
const { closeButton } = getTag(
73+
<Tag label="label" state="disabled" onCloseClick={handleEventMock} />,
74+
);
75+
expect(closeButton).toBeDisabled();
76+
});
77+
78+
it('should render the correct color (disabled)', () => {
79+
const { tag } = getTag(<Tag label="label" state="disabled" />);
80+
expect(tag).toHaveStyle('background-color: rgb(240, 243, 245);');
81+
});
82+
83+
it('should render the right cursor (with onClick)', () => {
84+
const { tag } = getTag(<Tag label="label" onClick={handleEventMock} />);
85+
expect(tag).toHaveStyle('cursor: pointer');
86+
});
87+
88+
it('should render the right cursor (without onClick)', () => {
89+
const { tag } = getTag(<Tag label="label" />);
90+
expect(tag).toHaveStyle('cursor: default');
91+
});
92+
93+
it('should render the right cursor (with state disabled)', () => {
94+
const { tag } = getTag(<Tag label="label" state="disabled" />);
95+
expect(tag).toHaveStyle('cursor: default');
96+
});
97+
98+
it('should not emit onClick', () => {
99+
const onCloseCLick = vi.fn();
100+
const onClick = vi.fn();
101+
102+
const { closeButton } = getTag(
103+
<Tag label="label" onCloseClick={onCloseCLick} onClick={onClick} />,
104+
);
105+
106+
if (closeButton) {
107+
fireEvent.click(closeButton);
108+
}
109+
expect(onCloseCLick).toBeCalledTimes(1);
110+
expect(onClick).not.toHaveBeenCalled();
111+
});
112+
});

src/components/Tag/Tag.tsx

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import {
2+
FC,
3+
KeyboardEventHandler,
4+
MouseEventHandler,
5+
useCallback,
6+
useMemo,
7+
useRef,
8+
} from 'react';
9+
10+
import { stylesBuilder } from './stylesBuilder';
11+
import { TagProps } from './Tag.props';
12+
import { Avatar } from '../Avatar';
13+
import { IconButton } from '../IconButton';
14+
15+
import { tet } from '@/tetrisly';
16+
import { MarginProps } from '@/types';
17+
18+
const KEYBOARD_KEYS = {
19+
Enter: 'Enter',
20+
Space: ' ',
21+
};
22+
23+
export const Tag: FC<TagProps & MarginProps> = ({
24+
label,
25+
state,
26+
beforeComponent,
27+
onClick,
28+
onCloseClick,
29+
custom,
30+
...restProps
31+
}) => {
32+
const hasCloseButton = !!onCloseClick;
33+
const hasOnClick = !!onClick;
34+
const styles = useMemo(
35+
() => stylesBuilder(custom, hasOnClick),
36+
[custom, hasOnClick],
37+
);
38+
39+
const containerRef = useRef<HTMLSpanElement | null>(null);
40+
const handleOnKeyDown: KeyboardEventHandler<HTMLSpanElement> = useCallback(
41+
(e) => {
42+
if (
43+
e.target === containerRef.current &&
44+
(e.key === KEYBOARD_KEYS.Enter || e.key === KEYBOARD_KEYS.Space)
45+
) {
46+
onClick?.(e);
47+
}
48+
},
49+
[containerRef, onClick],
50+
);
51+
52+
const handleOnCloseClick: MouseEventHandler<HTMLButtonElement> = useCallback(
53+
(e) => {
54+
onCloseClick?.(e);
55+
e.stopPropagation();
56+
},
57+
[onCloseClick],
58+
);
59+
60+
return (
61+
<tet.span
62+
tabIndex={0}
63+
ref={containerRef}
64+
onClick={onClick}
65+
onKeyDown={handleOnKeyDown}
66+
{...styles.container}
67+
data-state={state}
68+
data-testid="tag"
69+
{...restProps}
70+
>
71+
{!!beforeComponent && (
72+
<Avatar
73+
shape="square"
74+
size="2xSmall"
75+
{...beforeComponent.props}
76+
{...styles.avatar}
77+
data-testid="tag-avatar"
78+
/>
79+
)}
80+
<tet.p
81+
{...styles.label}
82+
mr={
83+
hasCloseButton
84+
? '$space-component-padding-xSmall'
85+
: '$space-component-padding-small'
86+
}
87+
data-testid="tag-label"
88+
>
89+
{label}
90+
</tet.p>
91+
{hasCloseButton && (
92+
<IconButton
93+
icon="20-close"
94+
variant="bare"
95+
onClick={handleOnCloseClick}
96+
state={state}
97+
{...styles.closeButton}
98+
data-testid="tag-iconButton"
99+
/>
100+
)}
101+
</tet.span>
102+
);
103+
};

src/components/Tag/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { Tag } from './Tag';
2+
export type { TagProps } from './Tag.props';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { stylesBuilder } from './stylesBuilder';
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { TagProps } from '../Tag.props';
2+
import { defaultConfig } from '../Tag.styles';
3+
4+
import { mergeConfigWithCustom } from '@/services';
5+
import type { BaseProps } from '@/types/BaseProps';
6+
7+
type TagStylesBuilder = {
8+
container: BaseProps;
9+
label: BaseProps;
10+
avatar: BaseProps;
11+
closeButton: BaseProps;
12+
};
13+
export const stylesBuilder = (
14+
custom: TagProps['custom'],
15+
hasOnClick?: boolean,
16+
): TagStylesBuilder => {
17+
const {
18+
hasOnClick: hasOnClickStyles,
19+
innerElements: {
20+
label,
21+
closeButton,
22+
beforeComponent: { avatar },
23+
},
24+
...container
25+
} = mergeConfigWithCustom({ defaultConfig, custom });
26+
27+
return {
28+
container: {
29+
...container,
30+
...(hasOnClick && hasOnClickStyles),
31+
},
32+
label,
33+
avatar,
34+
closeButton,
35+
};
36+
};

0 commit comments

Comments
 (0)