diff --git a/src/components/BooleanPill/BooleanPill.props.ts b/src/components/BooleanPill/BooleanPill.props.ts new file mode 100644 index 00000000..6238951d --- /dev/null +++ b/src/components/BooleanPill/BooleanPill.props.ts @@ -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; initials: string }; +} & Omit, 'color'>; diff --git a/src/components/BooleanPill/BooleanPill.stories.tsx b/src/components/BooleanPill/BooleanPill.stories.tsx new file mode 100644 index 00000000..811c6e9e --- /dev/null +++ b/src/components/BooleanPill/BooleanPill.stories.tsx @@ -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: () => ( + + + + ), + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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' }, + }, +}; diff --git a/src/components/BooleanPill/BooleanPill.styles.ts b/src/components/BooleanPill/BooleanPill.styles.ts new file mode 100644 index 00000000..75aede38 --- /dev/null +++ b/src/components/BooleanPill/BooleanPill.styles.ts @@ -0,0 +1,87 @@ +import { BooleanPillState } from './BooleanPillState.type'; + +import { BaseProps } from '@/types'; + +export type BooleanPillConfig = { + isSelected: BaseProps; + hasAvatar: BaseProps; + state?: Partial< + Record> + >; +} & 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, +}; diff --git a/src/components/BooleanPill/BooleanPill.test.tsx b/src/components/BooleanPill/BooleanPill.test.tsx new file mode 100644 index 00000000..efd3d1f5 --- /dev/null +++ b/src/components/BooleanPill/BooleanPill.test.tsx @@ -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(); + const pill = screen.getByTestId(pillPointer); + expect(pill).toBeInTheDocument(); + }); + + it('should be disabled if disabled state is passed', () => { + render(); + 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(); + const pill = screen.getByTestId(pillPointer); + expect(pill).toBeInTheDocument(); + }); + + it('should render correct text', () => { + render(); + const pill = screen.getByTestId(pillPointer); + expect(pill).toHaveTextContent('Hello there!'); + }); + + it('should not render avatar if avatar prop is not passed', () => { + render(); + 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( + , + ); + 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( + , + ); + 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(); + } + }); + }); + }); + }); + }); +}); diff --git a/src/components/BooleanPill/BooleanPill.tsx b/src/components/BooleanPill/BooleanPill.tsx new file mode 100644 index 00000000..c70faa7c --- /dev/null +++ b/src/components/BooleanPill/BooleanPill.tsx @@ -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 = ({ + 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 ( + + {isSelected && ( + + )} + {!!avatarProps && ( + + )} + {text} + + ); +}; diff --git a/src/components/BooleanPill/BooleanPillState.type.ts b/src/components/BooleanPill/BooleanPillState.type.ts new file mode 100644 index 00000000..37d87777 --- /dev/null +++ b/src/components/BooleanPill/BooleanPillState.type.ts @@ -0,0 +1 @@ +export type BooleanPillState = 'default' | 'disabled'; diff --git a/src/components/BooleanPill/index.ts b/src/components/BooleanPill/index.ts new file mode 100644 index 00000000..ba3fc154 --- /dev/null +++ b/src/components/BooleanPill/index.ts @@ -0,0 +1,3 @@ +export { BooleanPill } from './BooleanPill'; +export type { BooleanPillProps } from './BooleanPill.props'; +export { booleanPillStyles } from './BooleanPill.styles'; diff --git a/src/components/BooleanPill/stylesBuilder.ts b/src/components/BooleanPill/stylesBuilder.ts new file mode 100644 index 00000000..20f04322 --- /dev/null +++ b/src/components/BooleanPill/stylesBuilder.ts @@ -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, + }, + }; +}; diff --git a/src/docs-components/BooleanPillDocs.tsx b/src/docs-components/BooleanPillDocs.tsx new file mode 100644 index 00000000..2746af19 --- /dev/null +++ b/src/docs-components/BooleanPillDocs.tsx @@ -0,0 +1,95 @@ +import { startCase } from 'lodash'; +import { FC } from 'react'; + +import { SectionHeader } from './common/SectionHeader'; + +import { BooleanPill, BooleanPillProps } from '@/components/BooleanPill'; +import { tet } from '@/tetrisly'; + +const states = ['default', 'disabled'] as const; +const appearances = [false, true] as const; +const selected = [false, true] as const; + +const props = [ + { id: '0', text: 'Value', onChange: () => {} } as const, + { + id: '1', + text: 'Value', + avatar: { initials: 'M' }, + } as const, + { + id: '2', + text: 'Value', + avatar: { image: 'https://thispersondoesnotexist.com/' }, + } as const, +] as BooleanPillProps[]; + +export const BooleanPillDocs: FC = () => ( + <> + {states.map((state) => ( + + + {startCase(state)} + + {appearances.map((appearance) => ( + + + {appearance ? 'Inverted' : 'Primary'} + + + {selected.map((select) => ( + + + Selected: {String(select)} + + + + {props.map(({ id, ...prop }) => ( + + ))} + + + ))} + + ))} + + ))} + +); diff --git a/src/index.ts b/src/index.ts index 492b3ff3..b1adcac2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './components/AlertBanner'; export * from './components/Avatar'; export * from './components/Badge'; +export * from './components/BooleanPill'; export * from './components/Button'; export * from './components/Checkbox'; export * from './components/CheckboxGroup';