Skip to content

Commit a3c89de

Browse files
authored
feat: TET-858 boolean pill (#141)
* feat: TET-858 boolean pill * feat: TET-858 review changes * feat: TET-858 remove onChange * feat: TET-858 cleanup
1 parent 973d79d commit a3c89de

File tree

10 files changed

+474
-0
lines changed

10 files changed

+474
-0
lines changed
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { HTMLAttributes } from 'react';
2+
3+
import { BooleanPillConfig } from './BooleanPill.styles';
4+
import { AvatarAppearance } from '../Avatar/types';
5+
6+
export type BooleanPillProps = {
7+
text: string;
8+
state?: 'default' | 'disabled';
9+
isSelected?: boolean;
10+
isInverted?: boolean;
11+
tabIndex?: number;
12+
custom?: BooleanPillConfig;
13+
avatar?:
14+
| { appearance?: 'image'; image: string }
15+
| { appearance: Exclude<AvatarAppearance, 'image'>; initials: string };
16+
} & Omit<HTMLAttributes<HTMLSpanElement>, 'color'>;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { BooleanPill } from './BooleanPill';
4+
5+
import { BooleanPillDocs } from '@/docs-components/BooleanPillDocs';
6+
import { TetDocs } from '@/docs-components/TetDocs';
7+
8+
const meta = {
9+
title: 'BooleanPill',
10+
component: BooleanPill,
11+
tags: ['autodocs'],
12+
argTypes: {},
13+
args: {
14+
state: 'default',
15+
text: 'Value',
16+
},
17+
parameters: {
18+
docs: {
19+
description: {
20+
component:
21+
'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.',
22+
},
23+
page: () => (
24+
<TetDocs docs="https://docs.tetrisly.com/components/in-progress/pill">
25+
<BooleanPillDocs />
26+
</TetDocs>
27+
),
28+
},
29+
},
30+
} satisfies Meta<typeof BooleanPill>;
31+
32+
export default meta;
33+
type Story = StoryObj<typeof meta>;
34+
35+
export const Default: Story = {
36+
args: {
37+
state: 'default',
38+
},
39+
};
40+
41+
export const DefaultWithAvatar: Story = {
42+
args: {
43+
state: 'default',
44+
avatar: { image: 'https://thispersondoesnotexist.com/' },
45+
},
46+
};
47+
48+
export const Disabled: Story = {
49+
args: {
50+
state: 'disabled',
51+
},
52+
};
53+
54+
export const Selected: Story = {
55+
args: {
56+
isSelected: true,
57+
},
58+
};
59+
60+
export const DisabledAndSelected: Story = {
61+
args: {
62+
isSelected: true,
63+
state: 'disabled',
64+
},
65+
};
66+
67+
export const SelectedWithAvatar: Story = {
68+
args: {
69+
isSelected: true,
70+
avatar: { appearance: 'magenta', initials: 'M' },
71+
},
72+
};
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { BooleanPillState } from './BooleanPillState.type';
2+
3+
import { BaseProps } from '@/types';
4+
5+
export type BooleanPillConfig = {
6+
isSelected: BaseProps;
7+
hasAvatar: BaseProps;
8+
state?: Partial<
9+
Record<BooleanPillState, Record<'primary' | 'inverted', BaseProps>>
10+
>;
11+
} & BaseProps;
12+
13+
export const defaultConfig = {
14+
display: 'inline-flex',
15+
justifyContent: 'center',
16+
alignItems: 'center',
17+
textAlign: 'center',
18+
whiteSpace: 'nowrap',
19+
h: '$size-small',
20+
padding: '$space-component-padding-xSmall $space-component-padding-medium',
21+
gap: '$space-component-gap-small',
22+
borderRadius: '$border-radius-large',
23+
color: '$color-content-primary',
24+
borderWidth: '$border-width-small',
25+
borderColor: '$color-transparent',
26+
transition: true,
27+
transitionDuration: 200,
28+
outline: {
29+
focus: 'solid',
30+
},
31+
outlineColor: {
32+
_: '$color-interaction-focus-default',
33+
focus: '$color-interaction-focus-default',
34+
},
35+
outlineWidth: {
36+
focus: '$border-width-focus',
37+
},
38+
outlineOffset: 1,
39+
hasAvatar: {
40+
pl: '$space-component-padding-xSmall',
41+
},
42+
isSelected: {
43+
pl: '$space-component-padding-small',
44+
backgroundColor: '$color-interaction-background-formField',
45+
borderColor: {
46+
_: '$color-interaction-border-neutral-normal',
47+
hover: '$color-interaction-border-neutral-hover',
48+
active: '$color-interaction-border-neutral-active',
49+
},
50+
},
51+
state: {
52+
default: {
53+
primary: {
54+
backgroundColor: {
55+
_: '$color-interaction-neutral-subtle-normal',
56+
hover: '$color-interaction-neutral-subtle-hover',
57+
active: '$color-interaction-neutral-subtle-active',
58+
},
59+
},
60+
inverted: {
61+
backgroundColor: '$color-interaction-background-formField',
62+
borderColor: {
63+
_: '$color-interaction-border-neutral-normal',
64+
hover: '$color-interaction-border-neutral-hover',
65+
active: '$color-interaction-border-neutral-active',
66+
},
67+
},
68+
},
69+
disabled: {
70+
primary: {
71+
backgroundColor: '$color-interaction-neutral-subtle-normal',
72+
opacity: '$opacity-disabled',
73+
pointerEvents: 'none',
74+
},
75+
inverted: {
76+
backgroundColor: '$color-interaction-background-formField',
77+
borderColor: '$color-interaction-border-neutral-normal',
78+
opacity: '$opacity-disabled',
79+
pointerEvents: 'none',
80+
},
81+
},
82+
},
83+
} as const satisfies BooleanPillConfig;
84+
85+
export const booleanPillStyles = {
86+
defaultConfig,
87+
};
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { BooleanPill } from './BooleanPill';
2+
import { BooleanPillState } from './BooleanPillState.type';
3+
import { render, screen } from '../../tests/render';
4+
5+
describe('BooleanPill', () => {
6+
const states: BooleanPillState[] = ['default', 'disabled'];
7+
const selected = [false, true];
8+
const pillPointer = 'boolean-pill';
9+
10+
it('should render the BooleanPill ', () => {
11+
render(<BooleanPill text="Value" />);
12+
const pill = screen.getByTestId(pillPointer);
13+
expect(pill).toBeInTheDocument();
14+
});
15+
16+
it('should be disabled if disabled state is passed', () => {
17+
render(<BooleanPill text="Value" state="disabled" />);
18+
const pill = screen.getByTestId(pillPointer);
19+
expect(pill).toHaveStyle('pointer-events: none');
20+
expect(pill).toHaveStyle('opacity: 0.5');
21+
});
22+
23+
states.forEach((state) => {
24+
describe(`State: ${state}`, () => {
25+
it('should render the BooleanPill', () => {
26+
render(<BooleanPill text="Value" state={state} />);
27+
const pill = screen.getByTestId(pillPointer);
28+
expect(pill).toBeInTheDocument();
29+
});
30+
31+
it('should render correct text', () => {
32+
render(<BooleanPill state={state} text="Hello there!" />);
33+
const pill = screen.getByTestId(pillPointer);
34+
expect(pill).toHaveTextContent('Hello there!');
35+
});
36+
37+
it('should not render avatar if avatar prop is not passed', () => {
38+
render(<BooleanPill text="Value" state={state} />);
39+
const pill = screen.getByTestId(pillPointer);
40+
const avatar = screen.queryByTestId('boolean-pill-avatar');
41+
expect(pill).toBeInTheDocument();
42+
expect(avatar).not.toBeInTheDocument();
43+
});
44+
45+
it('should render avatar if avatar prop is passed', () => {
46+
render(
47+
<BooleanPill
48+
text="Value"
49+
state={state}
50+
avatar={{ appearance: 'magenta', initials: 'M' }}
51+
/>,
52+
);
53+
const pill = screen.getByTestId(pillPointer);
54+
const avatar = screen.getByTestId('boolean-pill-avatar');
55+
expect(pill).toBeInTheDocument();
56+
expect(avatar).toBeInTheDocument();
57+
});
58+
59+
selected.forEach((isSelected) => {
60+
describe(`isSelected ${isSelected}`, () => {
61+
it('should correctly render the checkmark', () => {
62+
render(
63+
<BooleanPill
64+
text="Value"
65+
state={state}
66+
isSelected={isSelected}
67+
/>,
68+
);
69+
const pill = screen.getByTestId(pillPointer);
70+
const checkmark = screen.queryByTestId('boolean-pill-checkmark');
71+
expect(pill).toBeInTheDocument();
72+
73+
if (isSelected) {
74+
expect(checkmark).toBeInTheDocument();
75+
} else {
76+
expect(checkmark).not.toBeInTheDocument();
77+
}
78+
});
79+
});
80+
});
81+
});
82+
});
83+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { Icon } from '@virtuslab/tetrisly-icons';
2+
import { useMemo, type FC } from 'react';
3+
4+
import { BooleanPillProps } from './BooleanPill.props';
5+
import { stylesBuilder } from './stylesBuilder';
6+
import { Avatar } from '../Avatar';
7+
8+
import { tet } from '@/tetrisly';
9+
10+
export const BooleanPill: FC<BooleanPillProps> = ({
11+
state = 'default',
12+
isSelected = false,
13+
isInverted = false,
14+
tabIndex = 0,
15+
avatar,
16+
text,
17+
custom,
18+
...rest
19+
}) => {
20+
const styles = useMemo(
21+
() =>
22+
stylesBuilder({
23+
state,
24+
custom,
25+
isSelected,
26+
isInverted,
27+
hasAvatar: !!avatar,
28+
}),
29+
[custom, isInverted, state, avatar, isSelected],
30+
);
31+
32+
const avatarProps = useMemo(
33+
() =>
34+
avatar &&
35+
('image' in avatar
36+
? {
37+
img: { src: avatar.image, alt: 'avatar' },
38+
appearance: 'image' as const,
39+
}
40+
: {
41+
initials: avatar.initials,
42+
appearance: avatar.appearance,
43+
}),
44+
45+
[avatar],
46+
);
47+
48+
return (
49+
<tet.span
50+
tabIndex={tabIndex}
51+
data-state={state}
52+
data-testid="boolean-pill"
53+
{...styles.container}
54+
{...rest}
55+
>
56+
{isSelected && (
57+
<Icon data-testid="boolean-pill-checkmark" name="20-check-large" />
58+
)}
59+
{!!avatarProps && (
60+
<Avatar
61+
emphasis="low"
62+
shape="rounded"
63+
size="xSmall"
64+
data-testid="boolean-pill-avatar"
65+
{...avatarProps}
66+
/>
67+
)}
68+
{text}
69+
</tet.span>
70+
);
71+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export type BooleanPillState = 'default' | 'disabled';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { BooleanPill } from './BooleanPill';
2+
export type { BooleanPillProps } from './BooleanPill.props';
3+
export { booleanPillStyles } from './BooleanPill.styles';
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { BooleanPillConfig, defaultConfig } from './BooleanPill.styles';
2+
import { BooleanPillState } from './BooleanPillState.type';
3+
4+
import { mergeConfigWithCustom } from '@/services';
5+
import { BaseProps } from '@/types/BaseProps';
6+
7+
type BooleanPillStyleBuilder = {
8+
container: BaseProps;
9+
};
10+
11+
type BooleanPillStyleBuilderInput = {
12+
state: BooleanPillState;
13+
isInverted: boolean;
14+
isSelected: boolean;
15+
hasAvatar: boolean;
16+
custom?: BooleanPillConfig;
17+
};
18+
19+
export const stylesBuilder = ({
20+
state,
21+
isInverted,
22+
isSelected,
23+
hasAvatar,
24+
custom,
25+
}: BooleanPillStyleBuilderInput): BooleanPillStyleBuilder => {
26+
const { state: containerState, ...container } = mergeConfigWithCustom({
27+
defaultConfig,
28+
custom,
29+
});
30+
const containerStyles = isInverted
31+
? containerState[state].inverted
32+
: containerState[state].primary;
33+
34+
const withAvatarStyles = hasAvatar ? container.hasAvatar : {};
35+
const withSelectedStyles = isSelected ? container.isSelected : {};
36+
37+
return {
38+
container: {
39+
...container,
40+
...containerStyles,
41+
...withAvatarStyles,
42+
...withSelectedStyles,
43+
},
44+
};
45+
};

0 commit comments

Comments
 (0)