Skip to content

Commit

Permalink
Tag Component with updated tokens (#89)
Browse files Browse the repository at this point in the history
* feat: TET-363 tag

* feat: TET-363 tag v2

* feat: tokens update TET-363

---------

Co-authored-by: Marta Kozina <marta.kozina.external@jetbrains.com>
  • Loading branch information
adrian-potepa and Marta Kozina committed Oct 13, 2023
1 parent 0d80296 commit ed5046b
Show file tree
Hide file tree
Showing 9 changed files with 459 additions and 0 deletions.
13 changes: 13 additions & 0 deletions src/components/Tag/Tag.props.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement>) => void;
onCloseClick?: (e: SyntheticEvent<HTMLButtonElement>) => void;
custom?: TagConfig;
};
53 changes: 53 additions & 0 deletions src/components/Tag/Tag.stories.tsx
Original file line number Diff line number Diff line change
@@ -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: () => (
<TetDocs docs="https://docs.tetrisly.com/components/in-progress/tag">
<TagDocs />
</TetDocs>
),
},
},
args: {
label: 'Tag',
onClick: () => null,
},
} satisfies Meta<typeof Tag>;

export default meta;
type Story = StoryObj<typeof meta>;

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,
},
};
69 changes: 69 additions & 0 deletions src/components/Tag/Tag.styles.ts
Original file line number Diff line number Diff line change
@@ -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;
112 changes: 112 additions & 0 deletions src/components/Tag/Tag.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Tag label="Label" />, {
containerId: 'tag',
});

beforeEach(() => {
handleEventMock.mockReset();
});

it('should render the tag', () => {
const { tag } = getTag(<Tag label="label" />);
expect(tag).toBeInTheDocument();
});

it('should render the correct label', () => {
const { tag } = getTag(<Tag label="label" />);
expect(tag).toHaveTextContent('label');
});

it('should render beforeComponent', () => {
const { avatar } = getTag(
<Tag
label="label"
beforeComponent={{ type: 'Avatar', props: { initials: 'A' } }}
/>,
);
expect(avatar).toBeInTheDocument();
});

it('should render closeButton', () => {
const { closeButton } = getTag(
<Tag label="label" onCloseClick={handleEventMock} />,
);
expect(closeButton).toBeInTheDocument();
});

it('should emit onClick', () => {
const { tag } = getTag(<Tag label="label" onClick={handleEventMock} />);
fireEvent.click(tag);
expect(handleEventMock).toHaveBeenCalled();
});

it('should not emit onCloseClick', () => {
const { closeButton } = getTag(
<Tag label="label" onCloseClick={handleEventMock} state="disabled" />,
);
fireEvent.click(closeButton as Element);
expect(handleEventMock).not.toHaveBeenCalled();
});

it('should render disabled closeButton', () => {
const { closeButton } = getTag(
<Tag label="label" state="disabled" onCloseClick={handleEventMock} />,
);
expect(closeButton).toBeDisabled();
});

it('should render the correct color (disabled)', () => {
const { tag } = getTag(<Tag label="label" state="disabled" />);
expect(tag).toHaveStyle('background-color: rgb(240, 243, 245);');
});

it('should render the right cursor (with onClick)', () => {
const { tag } = getTag(<Tag label="label" onClick={handleEventMock} />);
expect(tag).toHaveStyle('cursor: pointer');
});

it('should render the right cursor (without onClick)', () => {
const { tag } = getTag(<Tag label="label" />);
expect(tag).toHaveStyle('cursor: default');
});

it('should render the right cursor (with state disabled)', () => {
const { tag } = getTag(<Tag label="label" state="disabled" />);
expect(tag).toHaveStyle('cursor: default');
});

it('should not emit onClick', () => {
const onCloseCLick = vi.fn();
const onClick = vi.fn();

const { closeButton } = getTag(
<Tag label="label" onCloseClick={onCloseCLick} onClick={onClick} />,
);

if (closeButton) {
fireEvent.click(closeButton);
}
expect(onCloseCLick).toBeCalledTimes(1);
expect(onClick).not.toHaveBeenCalled();
});
});
103 changes: 103 additions & 0 deletions src/components/Tag/Tag.tsx
Original file line number Diff line number Diff line change
@@ -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<TagProps & MarginProps> = ({
label,
state,
beforeComponent,
onClick,
onCloseClick,
custom,
...restProps
}) => {
const hasCloseButton = !!onCloseClick;
const hasOnClick = !!onClick;
const styles = useMemo(
() => stylesBuilder(custom, hasOnClick),
[custom, hasOnClick],
);

const containerRef = useRef<HTMLSpanElement | null>(null);
const handleOnKeyDown: KeyboardEventHandler<HTMLSpanElement> = useCallback(
(e) => {
if (
e.target === containerRef.current &&
(e.key === KEYBOARD_KEYS.Enter || e.key === KEYBOARD_KEYS.Space)
) {
onClick?.(e);
}
},
[containerRef, onClick],
);

const handleOnCloseClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(e) => {
onCloseClick?.(e);
e.stopPropagation();
},
[onCloseClick],
);

return (
<tet.span
tabIndex={0}
ref={containerRef}
onClick={onClick}
onKeyDown={handleOnKeyDown}
{...styles.container}
data-state={state}
data-testid="tag"
{...restProps}
>
{!!beforeComponent && (
<Avatar
shape="square"
size="2xSmall"
{...beforeComponent.props}
{...styles.avatar}
data-testid="tag-avatar"
/>
)}
<tet.p
{...styles.label}
mr={
hasCloseButton
? '$space-component-padding-xSmall'
: '$space-component-padding-small'
}
data-testid="tag-label"
>
{label}
</tet.p>
{hasCloseButton && (
<IconButton
icon="20-close"
variant="bare"
onClick={handleOnCloseClick}
state={state}
{...styles.closeButton}
data-testid="tag-iconButton"
/>
)}
</tet.span>
);
};
2 changes: 2 additions & 0 deletions src/components/Tag/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { Tag } from './Tag';
export type { TagProps } from './Tag.props';
1 change: 1 addition & 0 deletions src/components/Tag/stylesBuilder/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { stylesBuilder } from './stylesBuilder';
36 changes: 36 additions & 0 deletions src/components/Tag/stylesBuilder/stylesBuilder.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading

0 comments on commit ed5046b

Please sign in to comment.