Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: tag component #91

Merged
merged 3 commits into from
Oct 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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