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 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
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"
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';
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
Loading