From 6d4a3f42b0473306dd69a27a160df47c6a57baf1 Mon Sep 17 00:00:00 2001 From: Andrew Holloway Date: Wed, 4 Dec 2024 17:52:59 -0600 Subject: [PATCH] feat(SelectionChip): introduce 1.0 component (#2112) - support event and state handling - support transition states for when selected - implement design API - add tests and snapshots --- .../SelectionChip/SelectionChip.module.css | 92 +++++++++++++ .../SelectionChip/SelectionChip.stories.ts | 49 +++++++ .../SelectionChip/SelectionChip.test.ts | 7 + .../SelectionChip/SelectionChip.tsx | 97 +++++++++++++ .../__snapshots__/SelectionChip.test.ts.snap | 127 ++++++++++++++++++ src/components/SelectionChip/index.ts | 1 + src/index.ts | 5 +- 7 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 src/components/SelectionChip/SelectionChip.module.css create mode 100644 src/components/SelectionChip/SelectionChip.stories.ts create mode 100644 src/components/SelectionChip/SelectionChip.test.ts create mode 100644 src/components/SelectionChip/SelectionChip.tsx create mode 100644 src/components/SelectionChip/__snapshots__/SelectionChip.test.ts.snap create mode 100644 src/components/SelectionChip/index.ts diff --git a/src/components/SelectionChip/SelectionChip.module.css b/src/components/SelectionChip/SelectionChip.module.css new file mode 100644 index 000000000..f6ffb25f8 --- /dev/null +++ b/src/components/SelectionChip/SelectionChip.module.css @@ -0,0 +1,92 @@ +/*------------------------------------*\ + # SELECTION CHIP +\*------------------------------------*/ + +/** + * SelectionChip + */ +.selection-chip { + position: relative; + display: inline-flex; + align-items: center; + gap: calc(var(--eds-size-1) / 16 * 1rem); + overflow: hidden; + + padding: calc(var(--eds-size-1) / 16 * 1rem) calc(var(--eds-size-2) / 16 * 1rem); + border-radius: calc(var(--eds-border-radius-full) * 1px); + + color: var(--eds-theme-color-text-utility-interactive-primary); + border: calc(var(--eds-border-width-sm) * 1px) solid var(--eds-theme-color-border-utility-default-low-emphasis); + background-color: var(--eds-theme-color-background-utility-interactive-no-emphasis); +} + +.selection-chip__label { + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; +} + +.selection-chip__input { + position: absolute; + top: 0; + left: 0; + width: 1px; + height: 1px; + outline: none; +} + +.selection-chip--has-icon { + padding-right: calc(var(--eds-size-2-and-half) / 16 * 1rem); +} + +.selection-chip:has(.selection-chip__input:focus-visible) { + outline: none; + box-shadow: 0 0 0 calc(var(--eds-border-width-md) * 1px) white, 0 0 0 calc(var(--eds-border-width-lg) * 1px) var(--eds-theme-color-border-utility-focus); +} + +@supports not selector(:focus-visible) { + .selection-chip:has(.selection-chip__input:focus) { + outline: none; + box-shadow: 0 0 0 calc(var(--eds-border-width-md) * 1px) white, 0 0 0 calc(var(--eds-border-width-lg) * 1px) var(--eds-theme-color-border-utility-focus); + } +} + +/** + * Color theme tokens + */ + +.selection-chip:hover { + background-color: var(--eds-theme-color-background-utility-default-no-emphasis-hover); +} + +.selection-chip:active { + background-color: var(--eds-theme-color-background-utility-default-no-emphasis-active); +} + +.selection-chip:has(.selection-chip__input:checked) { + border: calc(var(--eds-border-width-sm) * 1px) solid var(--eds-theme-color-border-utility-interactive); + box-shadow: inset 0 0 0 calc(var(--eds-border-width-sm) * 1px) var(--eds-theme-color-border-utility-interactive); + background-color: var(--eds-theme-color-background-utility-interactive-low-emphasis); +} + +.selection-chip:has(.selection-chip__input:checked):hover { + border-color: var(--eds-theme-color-border-utility-interactive-hover); + background-color: var(--eds-theme-color-background-utility-interactive-low-emphasis-hover); +} + +.selection-chip:has(.selection-chip__input:checked):active { + border-color: var(--eds-theme-color-border-utility-interactive-active); + background-color: var(--eds-theme-color-background-utility-interactive-low-emphasis-active); +} + +.selection-chip:has(.selection-chip__input:focus-visible:checked) { + outline: none; + box-shadow: inset 0 0 0 calc(var(--eds-border-width-sm) * 1px) var(--eds-theme-color-border-utility-interactive), 0 0 0 calc(var(--eds-border-width-md) * 1px) white, 0 0 0 calc(var(--eds-border-width-lg) * 1px) var(--eds-theme-color-border-utility-focus); +} + +@supports not selector(:focus-visible) { + .selection-chip:has(.selection-chip__input:focus:checked) { + outline: none; + box-shadow: inset 0 0 0 calc(var(--eds-border-width-sm) * 1px) var(--eds-theme-color-border-utility-interactive), 0 0 0 calc(var(--eds-border-width-md) * 1px) white, 0 0 0 calc(var(--eds-border-width-lg) * 1px) var(--eds-theme-color-border-utility-focus); + } +} \ No newline at end of file diff --git a/src/components/SelectionChip/SelectionChip.stories.ts b/src/components/SelectionChip/SelectionChip.stories.ts new file mode 100644 index 000000000..3b3d1f7c8 --- /dev/null +++ b/src/components/SelectionChip/SelectionChip.stories.ts @@ -0,0 +1,49 @@ +import type { StoryObj, Meta } from '@storybook/react'; +import type React from 'react'; + +import { SelectionChip } from './SelectionChip'; + +export default { + title: 'Components/SelectionChip', + component: SelectionChip, + parameters: { + badges: ['intro-1.0', 'current-1.0'], + }, +} as Meta; + +type Args = React.ComponentProps; + +export const Default: StoryObj = { + args: { + label: 'Label', + }, +}; + +export const Disabled: StoryObj = { + args: { + ...Default.args, + isDisabled: true, + }, +}; + +export const WithIcon: StoryObj = { + args: { + ...Default.args, + leadingIcon: 'alarm-add', + }, +}; + +export const ControlledChecked: StoryObj = { + args: { + ...WithIcon.args, + checked: true, + onChange: () => {}, + }, +}; + +export const UncontrolledChecked: StoryObj = { + args: { + ...WithIcon.args, + defaultChecked: true, + }, +}; diff --git a/src/components/SelectionChip/SelectionChip.test.ts b/src/components/SelectionChip/SelectionChip.test.ts new file mode 100644 index 000000000..ce24aa304 --- /dev/null +++ b/src/components/SelectionChip/SelectionChip.test.ts @@ -0,0 +1,7 @@ +import { generateSnapshots } from '@chanzuckerberg/story-utils'; +import * as stories from './SelectionChip.stories'; +import type { StoryFile } from '../../util/utility-types'; + +describe('', () => { + generateSnapshots(stories as StoryFile); +}); diff --git a/src/components/SelectionChip/SelectionChip.tsx b/src/components/SelectionChip/SelectionChip.tsx new file mode 100644 index 000000000..c50d3692d --- /dev/null +++ b/src/components/SelectionChip/SelectionChip.tsx @@ -0,0 +1,97 @@ +import clsx from 'clsx'; +import React, { forwardRef } from 'react'; + +import { useId } from '../../util/useId'; +import type { ForwardedRefComponent } from '../../util/utility-types'; + +import Icon, { type IconName } from '../Icon'; +import Text from '../Text'; + +import styles from './SelectionChip.module.css'; + +export type SelectionChipProps = { + // Component API + // Design API + /** + * Whether the chip is disabled or not + */ + isDisabled?: boolean; + /** + * Text used in the chip to give it a description + */ + label: string; + /** + * Leading icon for the chip + */ + leadingIcon: IconName; + /** + * Chip types (correspond to the equivalent input types) + */ + type?: 'checkbox' | 'radio'; +} & Pick< + React.InputHTMLAttributes, + 'id' | 'name' | 'className' | 'checked' | 'defaultChecked' | 'onChange' +>; + +type SelectionChipRefProps = ForwardedRefComponent< + HTMLInputElement, + SelectionChipProps +>; + +/** + * `import {SelectionChip} from "@chanzuckerberg/eds";` + * + * Compact, interactive UI elements used to make selections. + */ +export const SelectionChip: SelectionChipRefProps = forwardRef( + ( + { + checked, + className, + defaultChecked, + id, + isDisabled, + label, + leadingIcon, + name, + onChange, + type = 'checkbox', + ...other + }, + ref, + ) => { + const componentClassName = clsx( + styles['selection-chip'], + leadingIcon && styles['selection-chip--has-icon'], + isDisabled && styles['selection-chip--disabled'], + className, + ); + + const generatedIdVar = useId(); + const idVar = id || generatedIdVar; + + return ( + + ); + }, +); diff --git a/src/components/SelectionChip/__snapshots__/SelectionChip.test.ts.snap b/src/components/SelectionChip/__snapshots__/SelectionChip.test.ts.snap new file mode 100644 index 000000000..1ec584121 --- /dev/null +++ b/src/components/SelectionChip/__snapshots__/SelectionChip.test.ts.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` ControlledChecked story renders snapshot 1`] = ` + +`; + +exports[` Default story renders snapshot 1`] = ` + +`; + +exports[` Disabled story renders snapshot 1`] = ` + +`; + +exports[` UncontrolledChecked story renders snapshot 1`] = ` + +`; + +exports[` WithIcon story renders snapshot 1`] = ` + +`; diff --git a/src/components/SelectionChip/index.ts b/src/components/SelectionChip/index.ts new file mode 100644 index 000000000..0d8d1ada6 --- /dev/null +++ b/src/components/SelectionChip/index.ts @@ -0,0 +1 @@ +export { SelectionChip as default } from './SelectionChip'; diff --git a/src/index.ts b/src/index.ts index d4f9d0391..f0bb233e6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import './tokens-dist/css/variables.css'; /** - * 1.x component exports + * 1.x component theme exports */ export { default as Avatar } from './components/Avatar'; export { default as Badge } from './components/Badge'; @@ -21,7 +21,7 @@ export { default as Text } from './components/Text'; export { default as Toggle } from './components/Toggle'; /** - * 2.x component exports + * 2.x component theme exports */ export { default as Accordion } from './components/Accordion'; export { default as AppNotification } from './components/AppNotification'; @@ -49,6 +49,7 @@ export { default as PopoverContainer } from './components/PopoverContainer'; export { default as PopoverListItem } from './components/PopoverListItem'; export { default as Radio } from './components/Radio'; export { default as Select } from './components/Select'; +export { default as SelectionChip } from './components/SelectionChip'; export { default as TabGroup } from './components/TabGroup'; export { default as TextareaField } from './components/TextareaField'; export { default as ToastNotification } from './components/ToastNotification';