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: TET-858 boolean pill #141

Merged
merged 9 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from 3 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
17 changes: 17 additions & 0 deletions src/components/BooleanPill/BooleanPill.props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { HTMLAttributes } from 'react';

import { BooleanPillConfig } from './BooleanPill.styles';
import { AvatarAppearance } from '../Avatar/types';

export type BooleanPillProps = {
text: string;
state?: 'default' | 'disabled';
isSelected?: boolean;
isInverted?: boolean;
tabIndex?: number;
custom?: BooleanPillConfig;
avatar?:
| { appearance?: 'image'; image: string }
| { appearance: Exclude<AvatarAppearance, 'image'>; initials: string };
onChange?: (state: boolean) => void;
golas-m marked this conversation as resolved.
Show resolved Hide resolved
} & Omit<HTMLAttributes<HTMLSpanElement>, 'color'>;
72 changes: 72 additions & 0 deletions src/components/BooleanPill/BooleanPill.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import type { Meta, StoryObj } from '@storybook/react';

import { BooleanPill } from './BooleanPill';

import { BooleanPillDocs } from '@/docs-components/BooleanPillDocs';
import { TetDocs } from '@/docs-components/TetDocs';

const meta = {
title: 'BooleanPill',
component: BooleanPill,
tags: ['autodocs'],
argTypes: {},
args: {
state: 'default',
text: 'Value',
},
parameters: {
docs: {
description: {
component:
'A compact, rounded indicator used to represent tags, categories, or statuses. Pills often include text and/or icons and can be interactive, such as allowing users to remove a filter or tag.',
},
page: () => (
<TetDocs docs="https://docs.tetrisly.com/components/in-progress/pill">
<BooleanPillDocs />
</TetDocs>
),
},
},
} satisfies Meta<typeof BooleanPill>;

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

export const Default: Story = {
args: {
state: 'default',
},
};

export const DefaultWithAvatar: Story = {
args: {
state: 'default',
avatar: { image: 'https://thispersondoesnotexist.com/' },
},
};

export const Disabled: Story = {
args: {
state: 'disabled',
},
};

export const Selected: Story = {
args: {
isSelected: true,
},
};

export const DisabledAndSelected: Story = {
args: {
isSelected: true,
state: 'disabled',
},
};

export const SelectedWithAvatar: Story = {
args: {
isSelected: true,
avatar: { appearance: 'magenta', initials: 'M' },
},
};
87 changes: 87 additions & 0 deletions src/components/BooleanPill/BooleanPill.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { BooleanPillState } from './BooleanPillState.type';

import { BaseProps } from '@/types';

export type BooleanPillConfig = {
isSelected: BaseProps;
hasAvatar: BaseProps;
state?: Partial<
Record<BooleanPillState, Record<'primary' | 'inverted', BaseProps>>
>;
} & BaseProps;

export const defaultConfig = {
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
textAlign: 'center',
whiteSpace: 'nowrap',
h: `$size-small`,
golas-m marked this conversation as resolved.
Show resolved Hide resolved
padding: `$space-component-padding-xSmall $space-component-padding-medium`,
gap: `$space-component-gap-small`,
borderRadius: `$border-radius-large`,
color: '$color-content-primary',
borderWidth: '$border-width-small',
borderColor: '$color-transparent',
transition: true,
transitionDuration: 200,
outline: {
focus: 'solid',
},
outlineColor: {
_: '$color-interaction-focus-default',
focus: '$color-interaction-focus-default',
},
outlineWidth: {
focus: '$border-width-focus',
},
outlineOffset: 1,
hasAvatar: {
pl: '$space-component-padding-xSmall',
},
isSelected: {
pl: '$space-component-padding-small',
backgroundColor: '$color-interaction-background-formField',
borderColor: {
_: '$color-interaction-border-neutral-normal',
hover: '$color-interaction-border-neutral-hover',
active: '$color-interaction-border-neutral-active',
},
},
state: {
default: {
primary: {
backgroundColor: {
_: '$color-interaction-neutral-subtle-normal',
hover: '$color-interaction-neutral-subtle-hover',
active: '$color-interaction-neutral-subtle-active',
},
},
inverted: {
backgroundColor: '$color-interaction-background-formField',
borderColor: {
_: '$color-interaction-border-neutral-normal',
hover: '$color-interaction-border-neutral-hover',
active: '$color-interaction-border-neutral-active',
},
},
},
disabled: {
primary: {
backgroundColor: '$color-interaction-neutral-subtle-normal',
opacity: '$opacity-disabled',
pointerEvents: 'none',
},
inverted: {
backgroundColor: '$color-interaction-background-formField',
borderColor: '$color-interaction-border-neutral-normal',
opacity: '$opacity-disabled',
pointerEvents: 'none',
},
},
},
} as const satisfies BooleanPillConfig;

export const booleanPillStyles = {
defaultConfig,
};
107 changes: 107 additions & 0 deletions src/components/BooleanPill/BooleanPill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { vi } from 'vitest';

import { BooleanPill } from './BooleanPill';
import { BooleanPillState } from './BooleanPillState.type';
import { render, screen, fireEvent } from '../../tests/render';

describe('BooleanPill', () => {
const states: BooleanPillState[] = ['default', 'disabled'];
const selected = [false, true];
const pillPointer = 'boolean-pill';

it('should render the BooleanPill ', () => {
render(<BooleanPill text="Value" />);
const pill = screen.getByTestId(pillPointer);
expect(pill).toBeInTheDocument();
});

it('should be disabled if disabled state is passed', () => {
render(<BooleanPill text="Value" state="disabled" />);
const pill = screen.getByTestId(pillPointer);
expect(pill).toHaveStyle('pointer-events: none');
expect(pill).toHaveStyle('opacity: 0.5');
});

states.forEach((state) => {
describe(`State: ${state}`, () => {
it('should render the BooleanPill', () => {
render(<BooleanPill text="Value" state={state} />);
const pill = screen.getByTestId(pillPointer);
expect(pill).toBeInTheDocument();
});

it('should render correct text', () => {
render(<BooleanPill state={state} text="Hello there!" />);
const pill = screen.getByTestId(pillPointer);
expect(pill).toHaveTextContent('Hello there!');
});

it('should not render avatar if avatar prop is not passed', () => {
render(<BooleanPill text="Value" state={state} />);
const pill = screen.getByTestId(pillPointer);
const checkmark = screen.queryByTestId('boolean-pill-avatar');
expect(pill).toBeInTheDocument();
expect(checkmark).not.toBeInTheDocument();
});

it('should render avatar if avatar prop is passed', () => {
render(
<BooleanPill
text="Value"
state={state}
avatar={{ appearance: 'magenta', initials: 'M' }}
/>,
);
const pill = screen.getByTestId(pillPointer);
const checkmark = screen.getByTestId('boolean-pill-avatar');
expect(pill).toBeInTheDocument();
expect(checkmark).toBeInTheDocument();
});

selected.forEach((isSelected) => {
describe(`isSelected ${isSelected}`, () => {
it('should handle onChange properly when clicked', () => {
const onChangeMock = vi.fn();
render(
<BooleanPill
text="Value"
state={state}
isSelected={isSelected}
onChange={onChangeMock}
/>,
);

const pill = screen.getByTestId(pillPointer);
expect(pill).toBeInTheDocument();
fireEvent.click(pill);
if (state !== 'disabled') {
expect(onChangeMock).toHaveBeenCalled();
expect(onChangeMock).toBeCalledWith(!isSelected);
} else {
expect(onChangeMock).not.toHaveBeenCalled();
}
});

it('should correctly render the checkmark', () => {
render(
<BooleanPill
text="Value"
state={state}
isSelected={isSelected}
/>,
);
const pill = screen.getByTestId(pillPointer);
const checkmark = screen.queryByTestId('boolean-pill-checkmark');
expect(pill).toBeInTheDocument();

if (isSelected) {
expect(checkmark).toBeInTheDocument();
} else {
expect(checkmark).not.toBeInTheDocument();
}
});
});
});
});
});
});
79 changes: 79 additions & 0 deletions src/components/BooleanPill/BooleanPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Icon } from '@virtuslab/tetrisly-icons';
import { MouseEventHandler, useCallback, useMemo, type FC } from 'react';

import { BooleanPillProps } from './BooleanPill.props';
import { stylesBuilder } from './stylesBuilder';
import { Avatar } from '../Avatar';

import { tet } from '@/tetrisly';

export const BooleanPill: FC<BooleanPillProps> = ({
state = 'default',
isSelected = false,
isInverted = false,
tabIndex = 0,
avatar,
text,
custom,
onChange,
...rest
}) => {
const styles = useMemo(
() =>
stylesBuilder({
state,
custom,
isSelected,
isInverted,
hasAvatar: !!avatar,
}),
[custom, isInverted, state, avatar, isSelected],
);

const avatarProps = useMemo(
() =>
avatar &&
('image' in avatar
? {
img: { src: avatar.image, alt: 'avatar' },
appearance: 'image' as const,
}
: {
initials: avatar.initials,
appearance: avatar.appearance,
}),

[avatar],
);

const handleOnClick: MouseEventHandler<HTMLSpanElement> = useCallback(() => {
if (state !== 'disabled') {
onChange?.(!isSelected);
}
}, [onChange, state, isSelected]);

return (
<tet.span
tabIndex={tabIndex}
data-state={state}
onClick={handleOnClick}
data-testid="boolean-pill"
golas-m marked this conversation as resolved.
Show resolved Hide resolved
{...styles.container}
{...rest}
>
{isSelected && (
<Icon data-testid="boolean-pill-checkmark" name="20-check-large" />
)}
{!!avatarProps && (
<Avatar
emphasis="low"
shape="rounded"
size="xSmall"
data-testid="boolean-pill-avatar"
{...avatarProps}
/>
)}
{text}
</tet.span>
);
};
1 change: 1 addition & 0 deletions src/components/BooleanPill/BooleanPillState.type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type BooleanPillState = 'default' | 'disabled';
3 changes: 3 additions & 0 deletions src/components/BooleanPill/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { BooleanPill } from './BooleanPill';
export type { BooleanPillProps } from './BooleanPill.props';
export { booleanPillStyles } from './BooleanPill.styles';
Loading
Loading