Skip to content

Commit

Permalink
feat: TET-858 boolean pill (#141)
Browse files Browse the repository at this point in the history
* feat: TET-858 boolean pill

* feat: TET-858 review changes

* feat: TET-858 remove onChange

* feat: TET-858 cleanup
  • Loading branch information
golas-m committed May 15, 2024
1 parent 973d79d commit a3c89de
Show file tree
Hide file tree
Showing 10 changed files with 474 additions and 0 deletions.
16 changes: 16 additions & 0 deletions src/components/BooleanPill/BooleanPill.props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
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 };
} & 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',
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,
};
83 changes: 83 additions & 0 deletions src/components/BooleanPill/BooleanPill.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { BooleanPill } from './BooleanPill';
import { BooleanPillState } from './BooleanPillState.type';
import { render, screen } 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 avatar = screen.queryByTestId('boolean-pill-avatar');
expect(pill).toBeInTheDocument();
expect(avatar).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 avatar = screen.getByTestId('boolean-pill-avatar');
expect(pill).toBeInTheDocument();
expect(avatar).toBeInTheDocument();
});

selected.forEach((isSelected) => {
describe(`isSelected ${isSelected}`, () => {
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();
}
});
});
});
});
});
});
71 changes: 71 additions & 0 deletions src/components/BooleanPill/BooleanPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { Icon } from '@virtuslab/tetrisly-icons';
import { 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,
...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],
);

return (
<tet.span
tabIndex={tabIndex}
data-state={state}
data-testid="boolean-pill"
{...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';
45 changes: 45 additions & 0 deletions src/components/BooleanPill/stylesBuilder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { BooleanPillConfig, defaultConfig } from './BooleanPill.styles';
import { BooleanPillState } from './BooleanPillState.type';

import { mergeConfigWithCustom } from '@/services';
import { BaseProps } from '@/types/BaseProps';

type BooleanPillStyleBuilder = {
container: BaseProps;
};

type BooleanPillStyleBuilderInput = {
state: BooleanPillState;
isInverted: boolean;
isSelected: boolean;
hasAvatar: boolean;
custom?: BooleanPillConfig;
};

export const stylesBuilder = ({
state,
isInverted,
isSelected,
hasAvatar,
custom,
}: BooleanPillStyleBuilderInput): BooleanPillStyleBuilder => {
const { state: containerState, ...container } = mergeConfigWithCustom({
defaultConfig,
custom,
});
const containerStyles = isInverted
? containerState[state].inverted
: containerState[state].primary;

const withAvatarStyles = hasAvatar ? container.hasAvatar : {};
const withSelectedStyles = isSelected ? container.isSelected : {};

return {
container: {
...container,
...containerStyles,
...withAvatarStyles,
...withSelectedStyles,
},
};
};
Loading

0 comments on commit a3c89de

Please sign in to comment.