diff --git a/src/components/SelectablePill/SelectablePill.props.ts b/src/components/SelectablePill/SelectablePill.props.ts new file mode 100644 index 00000000..402ca1d5 --- /dev/null +++ b/src/components/SelectablePill/SelectablePill.props.ts @@ -0,0 +1,28 @@ +import { HTMLAttributes } from 'react'; + +import { SelectablePillConfig } from './SelectablePill.styles'; +import { AvatarAppearance } from '../Avatar/types'; + +import { IconName } from '@/utility-types/IconName'; + +export type SelectablePillProps = { + text: string; + prefix?: string; + state?: 'default' | 'disabled'; + isSelected?: boolean; + isInverted?: boolean; + tabIndex?: number; + custom?: SelectablePillConfig; + beforeComponent?: BeforeComponentProps; + onChange?: (state: boolean) => void; +} & Omit, 'color'>; + +type BeforeComponentProps = + | { + icon: IconName<20>; + } + | { + avatar: + | { appearance?: 'image'; image: string } + | { appearance: Exclude; initials: string }; + }; diff --git a/src/components/SelectablePill/SelectablePill.stories.tsx b/src/components/SelectablePill/SelectablePill.stories.tsx new file mode 100644 index 00000000..deafcaf4 --- /dev/null +++ b/src/components/SelectablePill/SelectablePill.stories.tsx @@ -0,0 +1,115 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { SelectablePill } from './SelectablePill'; + +import { SelectablePillDocs } from '@/docs-components/SelectablePillDocs'; +import { TetDocs } from '@/docs-components/TetDocs'; + +const meta = { + title: 'SelectablePill', + component: SelectablePill, + 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 Disabled: Story = { + args: { + state: 'disabled', + }, +}; + +export const Selected: Story = { + args: { + isSelected: true, + }, +}; + +export const Inverted: Story = { + args: { + isInverted: true, + }, +}; + +export const WithIcon: Story = { + args: { + beforeComponent: { icon: '20-tree' }, + }, +}; + +export const DisabledWithIcon: Story = { + args: { + state: 'disabled', + beforeComponent: { icon: '20-tree' }, + }, +}; + +export const WithIconAndPrefix: Story = { + args: { + prefix: 'Prefix', + beforeComponent: { icon: '20-tree' }, + }, +}; + +export const SelectedWithIcon: Story = { + args: { + isSelected: true, + beforeComponent: { icon: '20-tree' }, + }, +}; + +export const InvertedWithPrefix: Story = { + args: { + isInverted: true, + prefix: 'Prefix', + }, +}; + +export const WithAvatar: Story = { + args: { + beforeComponent: { + avatar: { image: 'https://thispersondoesnotexist.com/' }, + }, + }, +}; + +export const WithAvatarInitialsAndPrefix: Story = { + args: { + prefix: 'Prefix', + beforeComponent: { + avatar: { appearance: 'blue', initials: 'M' }, + }, + }, +}; + +export const SelectedWithPrefix: Story = { + args: { + prefix: 'Prefix', + isSelected: true, + }, +}; diff --git a/src/components/SelectablePill/SelectablePill.styles.ts b/src/components/SelectablePill/SelectablePill.styles.ts new file mode 100644 index 00000000..c6589757 --- /dev/null +++ b/src/components/SelectablePill/SelectablePill.styles.ts @@ -0,0 +1,131 @@ +import { SelectablePillState } from './SelectablePillState.type'; + +import { BaseProps } from '@/types'; + +export type SelectablePillConfig = { + isSelected: BaseProps; + state?: Partial< + Record> + >; + hasIcon?: BaseProps; + hasAvatar?: BaseProps; + hasPrefix?: BaseProps; + innerElements?: { + icon?: BaseProps; + actionIcon?: BaseProps; + prefix?: BaseProps; + contentContainer?: Record<'small' | 'xSmall', BaseProps>; + textContainer?: 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-small', + pl: '$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, + hasIcon: { + pl: '$space-component-padding-small', + }, + hasPrefix: { + pl: '$space-component-padding-medium', + }, + hasAvatar: { + pl: '$space-component-padding-xSmall', + }, + isSelected: { + backgroundColor: '$color-interaction-background-formField', + borderColor: { + _: '$color-interaction-border-neutral-normal', + hover: '$color-interaction-border-neutral-hover', + active: '$color-interaction-border-neutral-active', + }, + }, + innerElements: { + icon: { + color: '$color-content-secondary', + }, + actionIcon: { + color: '$color-action-neutral-normal', + }, + prefix: { + text: '$typo-body-medium', + color: '$color-content-secondary', + }, + textContainer: { + display: 'inline-flex', + alignItems: 'center', + gap: '$space-component-gap-xSmall', + }, + contentContainer: { + xSmall: { + display: 'inline-flex', + alignItems: 'center', + gap: '$space-component-gap-xSmall', + }, + small: { + display: 'inline-flex', + alignItems: 'center', + gap: '$space-component-gap-small', + }, + }, + }, + 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 SelectablePillConfig; + +export const selectablePillStyles = { + defaultConfig, +}; diff --git a/src/components/SelectablePill/SelectablePill.test.tsx b/src/components/SelectablePill/SelectablePill.test.tsx new file mode 100644 index 00000000..1b743844 --- /dev/null +++ b/src/components/SelectablePill/SelectablePill.test.tsx @@ -0,0 +1,150 @@ +import { vi } from 'vitest'; + +import { SelectablePill } from './SelectablePill'; +import { SelectablePillState } from './SelectablePillState.type'; +import { render, screen, fireEvent } from '../../tests/render'; + +describe('SelectablePill', () => { + const states: SelectablePillState[] = ['default', 'disabled']; + const selected = [false, true]; + const pillPointer = 'selectable-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 prefix if prefix prop is not passed', () => { + render(); + const pill = screen.getByTestId(pillPointer); + expect(pill).not.toHaveTextContent('Prefix'); + }); + + it('should render prefix if prefix prop is passed', () => { + render(); + const pill = screen.getByTestId(pillPointer); + expect(pill).toHaveTextContent('Prefix'); + }); + + it('should not render avatar if avatar prop is not passed', () => { + render(); + const pill = screen.getByTestId(pillPointer); + const avatar = screen.queryByTestId('selectable-pill-avatar'); + expect(pill).toBeInTheDocument(); + expect(avatar).not.toBeInTheDocument(); + }); + + it('should not render icon if icon prop is not passed', () => { + render(); + const pill = screen.getByTestId(pillPointer); + const icon = screen.queryByTestId('selectable-pill-icon'); + expect(pill).toBeInTheDocument(); + expect(icon).not.toBeInTheDocument(); + }); + + it('should render icon if icon prop is passed', () => { + render( + , + ); + const pill = screen.getByTestId(pillPointer); + const icon = screen.getByTestId('selectable-pill-icon'); + expect(pill).toBeInTheDocument(); + expect(icon).toBeInTheDocument(); + }); + + it('should render avatar if avatar prop is passed', () => { + render( + , + ); + const pill = screen.getByTestId(pillPointer); + const avatar = screen.getByTestId('selectable-pill-avatar'); + expect(pill).toBeInTheDocument(); + expect(avatar).toBeInTheDocument(); + }); + + selected.forEach((isSelected) => { + describe(`isSelected ${isSelected}`, () => { + it('should handle onChange properly when clicked', () => { + const onChangeMock = vi.fn(); + render( + , + ); + + 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( + , + ); + const pill = screen.getByTestId(pillPointer); + expect(pill).toBeInTheDocument(); + + if (isSelected) { + const checkmark = screen.queryByTestId( + 'selectable-pill-selected-icon', + ); + expect(checkmark).toBeInTheDocument(); + } else { + const checkmark = screen.queryByTestId( + 'selectable-pill-unselected-icon', + ); + expect(checkmark).toBeInTheDocument(); + } + }); + }); + }); + }); + }); +}); diff --git a/src/components/SelectablePill/SelectablePill.tsx b/src/components/SelectablePill/SelectablePill.tsx new file mode 100644 index 00000000..7aa67d50 --- /dev/null +++ b/src/components/SelectablePill/SelectablePill.tsx @@ -0,0 +1,107 @@ +import { Icon } from '@virtuslab/tetrisly-icons'; +import { MouseEventHandler, useCallback, useMemo, type FC } from 'react'; + +import { SelectablePillProps } from './SelectablePill.props'; +import { stylesBuilder } from './stylesBuilder'; +import { Avatar } from '../Avatar'; + +import { tet } from '@/tetrisly'; + +export const SelectablePill: FC = ({ + state = 'default', + isSelected = false, + isInverted = false, + tabIndex = 0, + beforeComponent, + text, + prefix, + custom, + onChange, + ...rest +}) => { + const styles = useMemo( + () => + stylesBuilder({ + state, + custom, + prefix, + isSelected, + isInverted, + beforeComponent, + }), + [custom, isInverted, state, beforeComponent, isSelected, prefix], + ); + + const avatarProps = useMemo( + () => + beforeComponent && + 'avatar' in beforeComponent && + ('image' in beforeComponent.avatar + ? { + img: { src: beforeComponent.avatar.image, alt: 'avatar' }, + appearance: 'image' as const, + } + : { + initials: beforeComponent.avatar.initials, + appearance: beforeComponent.avatar.appearance, + }), + + [beforeComponent], + ); + + const iconProps = useMemo( + () => beforeComponent && 'icon' in beforeComponent && beforeComponent.icon, + [beforeComponent], + ); + + const handleOnClick: MouseEventHandler = useCallback(() => { + if (state !== 'disabled') { + onChange?.(!isSelected); + } + }, [onChange, state, isSelected]); + + return ( + + {!!iconProps && ( + + + + )} + + {!!prefix && {prefix}:} + {!!avatarProps && ( + + )} + + {text} + + {isSelected ? ( + + ) : ( + + )} + + + + + ); +}; diff --git a/src/components/SelectablePill/SelectablePillState.type.ts b/src/components/SelectablePill/SelectablePillState.type.ts new file mode 100644 index 00000000..d1aaeb2b --- /dev/null +++ b/src/components/SelectablePill/SelectablePillState.type.ts @@ -0,0 +1 @@ +export type SelectablePillState = 'default' | 'disabled'; diff --git a/src/components/SelectablePill/index.ts b/src/components/SelectablePill/index.ts new file mode 100644 index 00000000..51c84588 --- /dev/null +++ b/src/components/SelectablePill/index.ts @@ -0,0 +1,3 @@ +export { SelectablePill } from './SelectablePill'; +export type { SelectablePillProps } from './SelectablePill.props'; +export { selectablePillStyles } from './SelectablePill.styles'; diff --git a/src/components/SelectablePill/stylesBuilder.ts b/src/components/SelectablePill/stylesBuilder.ts new file mode 100644 index 00000000..0e68ec76 --- /dev/null +++ b/src/components/SelectablePill/stylesBuilder.ts @@ -0,0 +1,81 @@ +import { SelectablePillProps } from './SelectablePill.props'; +import { SelectablePillConfig, defaultConfig } from './SelectablePill.styles'; +import { SelectablePillState } from './SelectablePillState.type'; + +import { mergeConfigWithCustom } from '@/services'; +import { BaseProps } from '@/types/BaseProps'; + +type SelectablePillStyleBuilder = { + container: BaseProps; + icon: BaseProps; + prefix: BaseProps; + actionIcon: BaseProps; + textContainer: BaseProps; + contentContainer: BaseProps; +}; + +type SelectablePillStyleBuilderInput = { + state: SelectablePillState; + isInverted: boolean; + isSelected: boolean; + beforeComponent?: SelectablePillProps['beforeComponent']; + prefix?: string; + custom?: SelectablePillConfig; +}; + +export const stylesBuilder = ({ + state, + isInverted, + isSelected, + beforeComponent, + prefix, + custom, +}: SelectablePillStyleBuilderInput): SelectablePillStyleBuilder => { + const { + state: containerState, + innerElements: { + icon, + actionIcon, + textContainer, + contentContainer, + prefix: prefixElement, + }, + ...container + } = mergeConfigWithCustom({ + defaultConfig, + custom, + }); + const containerStyles = isInverted + ? containerState[state].inverted + : containerState[state].primary; + + const hasPrefix = !!prefix; + const hasBeforeComponent = !!beforeComponent; + const hasAvatar = hasBeforeComponent && 'avatar' in beforeComponent; + const hasIcon = hasBeforeComponent && 'icon' in beforeComponent; + + const withAvatarStyles = hasAvatar ? container.hasAvatar : {}; + const withPrefixStyles = hasPrefix ? container.hasPrefix : {}; + const withIconStyles = hasIcon ? container.hasIcon : {}; + const withSelectedStyles = isSelected ? container.isSelected : {}; + + const contentContainerStyles = hasAvatar + ? contentContainer.small + : contentContainer.xSmall; + + return { + container: { + ...container, + ...containerStyles, + ...withAvatarStyles, + ...withPrefixStyles, + ...withIconStyles, + ...withSelectedStyles, + }, + icon, + actionIcon, + textContainer, + prefix: prefixElement, + contentContainer: contentContainerStyles, + }; +}; diff --git a/src/docs-components/SelectablePillDocs.tsx b/src/docs-components/SelectablePillDocs.tsx new file mode 100644 index 00000000..8133de3b --- /dev/null +++ b/src/docs-components/SelectablePillDocs.tsx @@ -0,0 +1,107 @@ +import { startCase } from 'lodash'; +import { FC } from 'react'; + +import { SectionHeader } from './common/SectionHeader'; + +import { + SelectablePill, + SelectablePillProps, +} from '@/components/SelectablePill'; +import { tet } from '@/tetrisly'; + +const states = ['default', 'disabled'] as const; +const appearances = [false, true] as const; +const selected = [false, true] as const; + +const props = [ + { text: 'Value' } as const, + { text: 'Value', prefix: 'Prefix' } as const, + { text: 'Value', beforeComponent: { icon: '20-tree' } } as const, + { + text: 'Value', + prefix: 'Prefix', + beforeComponent: { icon: '20-tree' }, + } as const, + { + text: 'Value', + beforeComponent: { avatar: { initials: 'M' } }, + } as const, + { + text: 'Value', + beforeComponent: { + avatar: { image: 'https://thispersondoesnotexist.com/' }, + }, + } as const, + { + text: 'Value', + prefix: 'Prefix', + beforeComponent: { + avatar: { image: 'https://thispersondoesnotexist.com/' }, + }, + } as const, +] as SelectablePillProps[]; + +export const SelectablePillDocs: FC = () => ( + <> + {states.map((state) => ( + + + {startCase(state)} + + {appearances.map((appearance) => ( + + + {appearance ? 'Inverted' : 'Primary'} + + + {selected.map((select) => ( + + + Selected: {String(select)} + + + + {props.map((prop) => ( + + ))} + + + ))} + + ))} + + ))} + +); diff --git a/src/index.ts b/src/index.ts index 492b3ff3..18091ddd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,6 +21,7 @@ export * from './components/RadioButton'; export * from './components/RadioButtonGroup'; export * from './components/SearchInput'; export * from './components/Select'; +export * from './components/SelectablePill'; export * from './components/SocialButton'; export * from './components/Status'; export * from './components/StatusDot';