diff --git a/README.md b/README.md index cbf621e..30f0499 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,7 @@ mode details, scoped theming examples, and per-instance overrides. - **AutocompleteInput** — searchable autocomplete input (combobox) - **CheckboxGroup** — controlled checkbox group for multi-value selection - **CheckboxInput** — checkbox with label and description +- **ColorSwatchPicker** — swatch picker for the named theme palette - **Field** — form field wrapper with label, description, and validation - **FileInput** — file upload with drag-and-drop support - **InputGroup** — groups related form inputs with shared label diff --git a/site/src/component-categories.ts b/site/src/component-categories.ts index df2e5bb..e8f96be 100644 --- a/site/src/component-categories.ts +++ b/site/src/component-categories.ts @@ -42,6 +42,7 @@ export const componentCategories: Record = { 'AutocompleteInput', 'CheckboxGroup', 'CheckboxInput', + 'ColorSwatchPicker', 'Field', 'FileInput', 'InputGroup', diff --git a/src/components/Badge/Badge.recipe.ts b/src/components/Badge/Badge.recipe.ts index 5563efd..47c217e 100644 --- a/src/components/Badge/Badge.recipe.ts +++ b/src/components/Badge/Badge.recipe.ts @@ -40,6 +40,7 @@ export const badgeRecipe = sva({ error: {root: {bg: 'status.error.solid', color: 'status.error.solidFg'}}, blue: {root: {bg: 'surface.blue', color: 'surface.blue.fg'}}, cyan: {root: {bg: 'surface.cyan', color: 'surface.cyan.fg'}}, + gray: {root: {bg: 'surface.gray', color: 'surface.gray.fg'}}, green: {root: {bg: 'surface.green', color: 'surface.green.fg'}}, orange: {root: {bg: 'surface.orange', color: 'surface.orange.fg'}}, pink: {root: {bg: 'surface.pink', color: 'surface.pink.fg'}}, diff --git a/src/components/Badge/Badge.test.tsx b/src/components/Badge/Badge.test.tsx index 948aa85..46368d2 100644 --- a/src/components/Badge/Badge.test.tsx +++ b/src/components/Badge/Badge.test.tsx @@ -33,6 +33,14 @@ describe('Badge', () => { ); }); + it('applies gray as a named palette color', () => { + render(); + + expect(screen.getByTestId('badge')).toHaveClass( + badgeRecipe({color: 'gray'}).root!, + ); + }); + it('forwards ref to the root span', () => { const ref = vi.fn<(element: HTMLSpanElement | null) => void>(); diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index b827b1c..d04076f 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -1,25 +1,15 @@ import type {CSSProperties, Ref} from 'react'; import {badgeRecipe} from 'components/Badge/Badge.recipe'; import {Icon, type IconComponent} from 'components/Icon'; +import type {ColorName} from 'internal/colorNames'; import {cx} from 'utils/cx'; export type BadgeSize = 'sm' | 'md' | 'lg'; -export type BadgeColor = - | 'neutral' - | 'info' - | 'success' - | 'warning' - | 'error' - | 'blue' - | 'cyan' - | 'green' - | 'orange' - | 'pink' - | 'purple' - | 'red' - | 'teal' - | 'yellow'; +export type BadgeStatusColor = + 'neutral' | 'info' | 'success' | 'warning' | 'error'; + +export type BadgeColor = ColorName | BadgeStatusColor; /** * A compact status label, category marker, or count. diff --git a/src/components/Badge/index.ts b/src/components/Badge/index.ts index 2b6e7bb..5923fdc 100644 --- a/src/components/Badge/index.ts +++ b/src/components/Badge/index.ts @@ -3,4 +3,5 @@ export { type BadgeColor, type BadgeProps, type BadgeSize, + type BadgeStatusColor, } from 'components/Badge/Badge'; diff --git a/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts b/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts new file mode 100644 index 0000000..853a036 --- /dev/null +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts @@ -0,0 +1,181 @@ +import {cva, sva, type RecipeVariantProps} from 'styled-system/css'; + +export const colorSwatchPickerRecipe = cva({ + base: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: '1', + }, +}); + +/** + * The button reserves `spacing.1` of padding around the circle so the selected + * ring and the focus ring both draw inside the button's own box, never over a + * neighboring swatch. Both rings occupy the same 2px–4px band outside the + * circle, so a focused swatch shows the primary outline in place of its + * selected ring; the check icon still marks which swatch is selected. + */ +export const colorSwatchRecipe = sva({ + slots: ['button', 'fill'], + base: { + button: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + p: '1', + borderWidth: 0, + borderStyle: 'none', + borderRadius: 'full', + bg: 'transparent', + color: 'inherit', + cursor: 'pointer', + _focusVisible: { + outline: 'none', + }, + }, + fill: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 'thin', + borderStyle: 'solid', + borderRadius: 'full', + transitionProperty: 'box-shadow, transform', + transitionDuration: 'fast', + transitionTimingFunction: 'default', + '@media (prefers-reduced-motion: reduce)': { + transitionDuration: '0s', + }, + '[role="radio"]:hover &': { + transform: 'scale(1.12)', + }, + '[role="radio"]:focus-visible &': { + outlineWidth: 'focus', + outlineStyle: 'solid', + outlineColor: 'primary', + outlineOffset: 'focusOffset', + }, + }, + }, + variants: { + color: { + red: { + fill: { + '--swatch-ring': 'token(colors.surface.red.accent)', + bg: 'surface.red', + borderColor: 'surface.red.accent', + color: 'surface.red.fg', + }, + }, + orange: { + fill: { + '--swatch-ring': 'token(colors.surface.orange.accent)', + bg: 'surface.orange', + borderColor: 'surface.orange.accent', + color: 'surface.orange.fg', + }, + }, + yellow: { + fill: { + '--swatch-ring': 'token(colors.surface.yellow.accent)', + bg: 'surface.yellow', + borderColor: 'surface.yellow.accent', + color: 'surface.yellow.fg', + }, + }, + green: { + fill: { + '--swatch-ring': 'token(colors.surface.green.accent)', + bg: 'surface.green', + borderColor: 'surface.green.accent', + color: 'surface.green.fg', + }, + }, + teal: { + fill: { + '--swatch-ring': 'token(colors.surface.teal.accent)', + bg: 'surface.teal', + borderColor: 'surface.teal.accent', + color: 'surface.teal.fg', + }, + }, + cyan: { + fill: { + '--swatch-ring': 'token(colors.surface.cyan.accent)', + bg: 'surface.cyan', + borderColor: 'surface.cyan.accent', + color: 'surface.cyan.fg', + }, + }, + blue: { + fill: { + '--swatch-ring': 'token(colors.surface.blue.accent)', + bg: 'surface.blue', + borderColor: 'surface.blue.accent', + color: 'surface.blue.fg', + }, + }, + purple: { + fill: { + '--swatch-ring': 'token(colors.surface.purple.accent)', + bg: 'surface.purple', + borderColor: 'surface.purple.accent', + color: 'surface.purple.fg', + }, + }, + pink: { + fill: { + '--swatch-ring': 'token(colors.surface.pink.accent)', + bg: 'surface.pink', + borderColor: 'surface.pink.accent', + color: 'surface.pink.fg', + }, + }, + gray: { + fill: { + '--swatch-ring': 'token(colors.surface.gray.accent)', + bg: 'surface.gray', + borderColor: 'surface.gray.accent', + color: 'surface.gray.fg', + }, + }, + }, + size: { + sm: {fill: {h: '6', w: '6'}}, + md: {fill: {h: '8', w: '8'}}, + lg: {fill: {h: '10', w: '10'}}, + }, + isSelected: { + true: { + fill: { + boxShadow: + '0 0 0 2px token(colors.bg), 0 0 0 4px var(--swatch-ring, token(colors.border.emphasized))', + }, + }, + false: {}, + }, + isDisabled: { + true: { + button: { + cursor: 'not-allowed', + opacity: 0.55, + }, + fill: { + '[role="radio"]:hover &': { + transform: 'none', + }, + }, + }, + false: {}, + }, + }, + defaultVariants: { + size: 'md', + isSelected: false, + isDisabled: false, + }, +}); + +export type ColorSwatchVariants = RecipeVariantProps; diff --git a/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx b/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx new file mode 100644 index 0000000..738d5c7 --- /dev/null +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx @@ -0,0 +1,134 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {useState} from 'react'; +import {Badge} from 'components/Badge'; +import { + ColorSwatchPicker, + type ColorName, + type ColorSwatchPickerProps, +} from 'components/ColorSwatchPicker'; +import {Tag} from 'components/Tag'; + +function ColorSwatchPickerStory( + args: ColorSwatchPickerProps, +): React.JSX.Element { + const [value, setValue] = useState(args.value); + + return ; +} + +function SizesStory(args: ColorSwatchPickerProps): React.JSX.Element { + const [value, setValue] = useState(args.value); + + return ( +
+ + + +
+ ); +} + +function DrivesOtherComponentsStory( + args: ColorSwatchPickerProps, +): React.JSX.Element { + const [value, setValue] = useState(args.value); + + return ( +
+ +
+ + +
+
+ ); +} + +const meta = { + title: 'Components/ColorSwatchPicker', + component: ColorSwatchPicker, + args: { + label: 'Office color', + description: 'Choose the named theme color used for this office.', + size: 'md', + value: 'blue', + }, + argTypes: { + isDisabled: {control: 'boolean'}, + size: { + control: {type: 'select'}, + options: ['sm', 'md', 'lg'], + }, + }, + render: (args: ColorSwatchPickerProps): React.JSX.Element => ( + + ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Sizes: Story = { + render: (args: ColorSwatchPickerProps): React.JSX.Element => ( + + ), +}; + +export const Subset: Story = { + args: { + colors: ['red', 'orange', 'green', 'blue', 'purple'], + value: 'green', + }, +}; + +/** + * Hovering a swatch grows it slightly and reveals a tooltip naming the color. + * The selected swatch carries an outer ring in its own color. + */ +export const HoverAndTooltips: Story = { + args: { + description: 'Hover a swatch to see its name and the grow effect.', + value: 'purple', + }, +}; + +export const Disabled: Story = { + args: { + isDisabled: true, + }, +}; + +export const Error: Story = { + args: { + status: {message: 'Choose an office color.', type: 'error'}, + }, +}; + +export const Required: Story = { + args: { + isRequired: true, + }, +}; + +export const DrivesOtherComponents: Story = { + render: (args: ColorSwatchPickerProps): React.JSX.Element => ( + + ), +}; diff --git a/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx b/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx new file mode 100644 index 0000000..ced110b --- /dev/null +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx @@ -0,0 +1,534 @@ +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {createRef, useState} from 'react'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import {ColorSwatchPicker} from 'components/ColorSwatchPicker/ColorSwatchPicker'; +import { + colorSwatchPickerRecipe, + colorSwatchRecipe, +} from 'components/ColorSwatchPicker/ColorSwatchPicker.recipe'; +import {COLOR_LABELS, COLOR_NAMES, type ColorName} from 'internal/colorNames'; +import {assertNonNull, createPopoverFocusShim} from 'internal/testHelpers'; + +const shim = createPopoverFocusShim(); + +beforeAll(shim.install); +afterAll(shim.uninstall); +beforeEach(() => { + shim.reset(); + shim.setFocusVisible(false); +}); + +/** + * The tooltip anchored to `radio`, found by the color name it renders. Every + * swatch renders its own tooltip popover, so the text is what tells them apart. + */ +function getTooltipFor(radio: HTMLElement): HTMLElement { + const label = assertNonNull(radio.getAttribute('aria-label')); + const content = screen.getByText(label); + return assertNonNull(content.closest('[role="tooltip"]')); +} + +describe('ColorSwatchPicker', () => { + it('renders a labelled radiogroup with one radio per color', () => { + render( + {}} + value="blue" + />, + ); + + const group = screen.getByRole('radiogroup', {name: 'Office color'}); + expect(group).toHaveAttribute('aria-orientation', 'horizontal'); + + const radios = within(group).getAllByRole('radio'); + expect(radios).toHaveLength(COLOR_NAMES.length); + for (const color of COLOR_NAMES) { + expect( + within(group).getByRole('radio', {name: COLOR_LABELS[color]}), + ).toBeInTheDocument(); + } + }); + + it('renders controlled checked state and roving tabindex', () => { + render( + {}} + value="blue" + />, + ); + + const selected = screen.getByRole('radio', {name: 'Blue'}); + expect(selected).toHaveAttribute('tabindex', '0'); + expect(selected).toBeChecked(); + + for (const color of COLOR_NAMES.filter(color => color !== 'blue')) { + const radio = screen.getByRole('radio', {name: COLOR_LABELS[color]}); + expect(radio).toHaveAttribute('tabindex', '-1'); + expect(radio).not.toBeChecked(); + } + }); + + it('updates controlled state when a swatch is clicked', async () => { + const user = userEvent.setup(); + + function Example(): React.JSX.Element { + const [value, setValue] = useState('red'); + return ( + + ); + } + + render(); + + await user.click(screen.getByRole('radio', {name: 'Blue'})); + + expect(screen.getByRole('radio', {name: 'Red'})).not.toBeChecked(); + expect(screen.getByRole('radio', {name: 'Blue'})).toBeChecked(); + }); + + it('calls onChange when clicking a different swatch', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('radio', {name: 'Blue'})); + expect(onChange).toHaveBeenCalledWith('blue'); + }); + + it('does not call onChange when clicking the selected swatch', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + await user.click(screen.getByRole('radio', {name: 'Red'})); + expect(onChange).not.toHaveBeenCalled(); + }); + + it('supports arrow key navigation with selection following focus', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + screen.getByRole('radio', {name: 'Red'}).focus(); + await user.keyboard('{ArrowRight}'); + + expect(onChange).toHaveBeenLastCalledWith('blue'); + expect(screen.getByRole('radio', {name: 'Blue'})).toHaveFocus(); + + await user.keyboard('{ArrowDown}'); + + expect(onChange).toHaveBeenLastCalledWith('green'); + expect(screen.getByRole('radio', {name: 'Green'})).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + + expect(screen.getByRole('radio', {name: 'Red'})).toHaveFocus(); + }); + + it('supports reverse navigation plus Home and End', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + screen.getByRole('radio', {name: 'Blue'}).focus(); + await user.keyboard('{ArrowLeft}'); + + expect(onChange).toHaveBeenLastCalledWith('red'); + expect(screen.getByRole('radio', {name: 'Red'})).toHaveFocus(); + + await user.keyboard('{ArrowUp}'); + + expect(onChange).toHaveBeenLastCalledWith('green'); + expect(screen.getByRole('radio', {name: 'Green'})).toHaveFocus(); + + await user.keyboard('{Home}'); + + expect(onChange).toHaveBeenLastCalledWith('red'); + expect(screen.getByRole('radio', {name: 'Red'})).toHaveFocus(); + + await user.keyboard('{End}'); + + expect(onChange).toHaveBeenLastCalledWith('green'); + expect(screen.getByRole('radio', {name: 'Green'})).toHaveFocus(); + }); + + it('renders a custom color subset in order', () => { + render( + {}} + value="red" + />, + ); + + expect( + screen.getAllByRole('radio').map(radio => radio.dataset.value), + ).toEqual(['red', 'blue']); + }); + + it('keeps one swatch tabbable when value is not in colors', () => { + render( + {}} + value="green" + />, + ); + + const tabbable = screen + .getAllByRole('radio') + .filter(radio => radio.getAttribute('tabindex') === '0'); + expect(tabbable).toHaveLength(1); + expect(tabbable[0]).toHaveAccessibleName('Red'); + }); + + it('does not change when disabled', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + , + ); + + const blue = screen.getByRole('radio', {name: 'Blue'}); + expect(screen.getByRole('radiogroup')).toHaveAttribute( + 'aria-disabled', + 'true', + ); + for (const radio of screen.getAllByRole('radio')) { + expect(radio).toHaveAttribute('aria-disabled', 'true'); + } + + fireEvent.click(blue); + screen.getByRole('radio', {name: 'Red'}).focus(); + await user.keyboard('{ArrowRight}'); + + expect(onChange).not.toHaveBeenCalled(); + }); + + it('sets aria-invalid and describes status messages', () => { + render( + {}} + status={{type: 'error', message: 'Choose a color'}} + value="red" + />, + ); + + const group = screen.getByRole('radiogroup'); + const message = screen.getByText('Choose a color'); + expect(group).toHaveAttribute('aria-invalid', 'true'); + expect(message).toHaveAttribute('id'); + expect(group).toHaveAttribute('aria-describedby', message.id); + }); + + it('wires description and label ids', () => { + render( + {}} + value="red" + />, + ); + + const group = screen.getByRole('radiogroup'); + const description = screen.getByText('Used for schedule events.'); + + expect(group).toHaveAccessibleName('Office color'); + expect(description).toHaveAttribute('id'); + expect(group).toHaveAttribute('aria-describedby', description.id); + }); + + it('renders required and optional field indicators', () => { + const {rerender} = render( + {}} + value="red" + />, + ); + + expect(screen.getByRole('radiogroup')).toBeRequired(); + expect(screen.getByText('Required')).toBeInTheDocument(); + + rerender( + {}} + value="red" + />, + ); + + expect(screen.getByText('Optional')).toBeInTheDocument(); + }); + + it('forwards className, style, data-testid, and ref to the field root', () => { + const ref = createRef(); + + render( + {}} + ref={ref} + style={{maxWidth: 320}} + value="red" + />, + ); + + const root = screen.getByTestId('picker'); + expect(root).toHaveClass('custom-picker'); + expect(root).toHaveStyle({maxWidth: '320px'}); + expect(ref.current).toBe(root); + expect(within(root).getByRole('radiogroup')).toBeInTheDocument(); + }); + + it('renders one tooltip per swatch naming its color', () => { + render( + {}} + value="red" + />, + ); + + expect(screen.getAllByRole('tooltip', {hidden: true})).toHaveLength(2); + expect( + getTooltipFor(screen.getByRole('radio', {name: 'Red'})), + ).toBeInTheDocument(); + expect( + getTooltipFor(screen.getByRole('radio', {name: 'Blue'})), + ).toBeInTheDocument(); + }); + + it('opens only the hovered swatch tooltip and closes it on mouse leave', async () => { + render( + {}} + value="red" + />, + ); + + const blue = screen.getByRole('radio', {name: 'Blue'}); + const blueTooltip = getTooltipFor(blue); + const redTooltip = getTooltipFor(screen.getByRole('radio', {name: 'Red'})); + + fireEvent.mouseEnter(blue); + + await waitFor(() => { + expect(shim.isPopoverOpen(blueTooltip)).toBe(true); + }); + expect(shim.isPopoverOpen(redTooltip)).toBe(false); + + fireEvent.mouseLeave(blue); + + await waitFor(() => { + expect(shim.isPopoverOpen(blueTooltip)).toBe(false); + }); + }); + + it('opens tooltips on hover only, never on keyboard focus', () => { + shim.setFocusVisible(true); + + render( + {}} + value="red" + />, + ); + + // The selected swatch is the group's tab stop, so it is the only one a + // focus-triggered tooltip could ever attach to. + const red = screen.getByRole('radio', {name: 'Red'}); + expect(red).toHaveAttribute('tabindex', '0'); + + red.focus(); + fireEvent.focusIn(red); + + // The group's keyboard hint does open on focus, so assert on this swatch's + // own tooltip rather than on `showPopover` as a whole. + expect(shim.isPopoverOpen(getTooltipFor(red))).toBe(false); + }); + + it('does not describe swatches by their tooltip, which repeats the label', () => { + render( + {}} + value="red" + />, + ); + + const red = screen.getByRole('radio', {name: 'Red'}); + expect(red).not.toHaveAttribute('aria-describedby'); + expect(red).toHaveAccessibleName('Red'); + }); + + it('does not open tooltips while disabled', () => { + vi.useFakeTimers(); + + try { + render( + {}} + value="red" + />, + ); + + fireEvent.mouseEnter(screen.getByRole('radio', {name: 'Blue'})); + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(shim.showPopover).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe('colorSwatchRecipe', () => { + const swatch = ( + overrides: Parameters[0] = {}, + ): ReturnType => + colorSwatchRecipe({color: 'blue', size: 'md', ...overrides}); + + /** + * Panda emits atomic class names whose suffix is the token, so `focusOffset` + * is a prefix of `focusOffsetLoose`. Compare whole classes, never substrings. + */ + const classesOf = (className: string | undefined): ReadonlyArray => + assertNonNull(className).split(' '); + + /** + * Whether any class applies `utility`, under any condition prefix — a + * `_focusVisible` style lands on `focus-visible:silver-ring-o_…`, so matching + * only the start of the class would miss it. + */ + const hasUtility = ( + className: string | undefined, + utility: string, + ): boolean => classesOf(className).some(name => name.includes(utility)); + + it('spaces swatches by spacing.1', () => { + expect(classesOf(colorSwatchPickerRecipe())).toContain('silver-gap_1'); + }); + + it('marks the selected swatch with an outer ring in its own color', () => { + const selected = swatch({isSelected: true}); + const unselected = swatch({isSelected: false}); + + expect(classesOf(selected.fill)).toContain( + 'silver---swatch-ring_token(colors.surface.blue.accent)', + ); + expect(classesOf(selected.fill)).toContain( + 'silver-bx-sh_0_0_0_2px_token(colors.bg),_0_0_0_4px_var(--swatch-ring,_token(colors.border.emphasized))', + ); + expect(hasUtility(unselected.fill, 'silver-bx-sh_')).toBe(false); + }); + + it('keeps the selected border width identical to the unselected one', () => { + // The old design thickened an inner border when selected; the ring replaced it. + expect(classesOf(swatch({isSelected: true}).fill)).toContain( + 'silver-bd-w_thin', + ); + expect(classesOf(swatch({isSelected: false}).fill)).toContain( + 'silver-bd-w_thin', + ); + }); + + it('draws the focus ring around the circle rather than the padded button', () => { + const {button, fill} = swatch(); + + expect(classesOf(fill)).toContain( + '[[role="radio"]:focus-visible_&]:silver-ring-o_focusOffset', + ); + expect(classesOf(fill)).toContain( + '[[role="radio"]:focus-visible_&]:silver-ring-c_primary', + ); + expect(hasUtility(button, 'silver-ring-o_')).toBe(false); + }); + + it('grows the circle on hover, except when disabled', () => { + expect(classesOf(swatch({isDisabled: false}).fill)).toContain( + '[[role="radio"]:hover_&]:silver-trf_scale(1.12)', + ); + expect(classesOf(swatch({isDisabled: true}).fill)).toContain( + '[[role="radio"]:hover_&]:silver-trf_none', + ); + }); +}); diff --git a/src/components/ColorSwatchPicker/ColorSwatchPicker.tsx b/src/components/ColorSwatchPicker/ColorSwatchPicker.tsx new file mode 100644 index 0000000..eee75d1 --- /dev/null +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.tsx @@ -0,0 +1,283 @@ +'use client'; + +import {Check} from 'lucide-react'; +import { + useCallback, + useId, + useRef, + type CSSProperties, + type KeyboardEvent, + type ReactNode, + type Ref, +} from 'react'; +import { + colorSwatchPickerRecipe, + colorSwatchRecipe, +} from 'components/ColorSwatchPicker/ColorSwatchPicker.recipe'; +import { + Field, + getNecessity, + type FieldNecessity, + type InputSize, + type InputStatus, +} from 'components/Field'; +import {getDescribedBy, getStatusMessageID} from 'components/Field/inputUtils'; +import {Icon} from 'components/Icon'; +import {useTooltip} from 'components/Tooltip'; +import useKeyboardHint from 'hooks/useKeyboardHint'; +import useListFocus from 'hooks/useListFocus'; +import {COLOR_LABELS, COLOR_NAMES, type ColorName} from 'internal/colorNames'; +import isReactNode from 'internal/isReactNode'; +import {mergeRefs} from 'internal/mergeRefs'; + +export type ColorSwatchPickerProps = { + /** + * Additional CSS class names applied to the field root. + */ + className?: string; + /** + * Palette colors to render, in display order. + * @default COLOR_NAMES + */ + colors?: ReadonlyArray; + /** + * Test ID applied to the field root. + */ + 'data-testid'?: string; + /** + * Supporting text displayed below the label. + */ + description?: ReactNode; + /** + * Whether all swatches are disabled. + * @default false + */ + isDisabled?: boolean; + /** + * Whether to visually hide the label. + * @default false + */ + isLabelHidden?: boolean; + /** + * Label text for the swatch picker. + */ + label: string; + /** + * Tooltip content shown next to the label. + */ + labelTooltip?: ReactNode; + /** + * Callback fired when the selected color changes. + */ + onChange: (value: ColorName) => void; + /** + * Ref forwarded to the field root. + */ + ref?: Ref; + /** + * Size of the swatches. + * @default 'md' + */ + size?: InputSize; + /** + * Validation status displayed below the picker. + */ + status?: InputStatus; + /** + * Inline styles applied to the field root. + */ + style?: CSSProperties; + /** + * The currently selected color. + */ + value: ColorName; +} & FieldNecessity; + +interface ColorSwatchProps { + color: ColorName; + isDisabled: boolean; + isSelected: boolean; + isTabbable: boolean; + onSelect: (color: ColorName) => void; + size: InputSize; +} + +function ColorSwatch({ + color, + isDisabled, + isSelected, + isTabbable, + onSelect, + size, +}: ColorSwatchProps): React.JSX.Element { + const label = COLOR_LABELS[color]; + const classes = colorSwatchRecipe({color, isDisabled, isSelected, size}); + // Hover-only: focus already surfaces the color through the accessible name, + // and a focus tooltip would collide with the group's keyboard hint and stay + // open on the focused swatch while the pointer hovers another one. + const {ref: tooltipRef, renderTooltip} = useTooltip({ + focusTrigger: 'never', + isEnabled: !isDisabled, + }); + + return ( + <> + + {/* + The tooltip repeats the swatch's accessible name, so it is deliberately + left out of `aria-describedby` — that would announce the color twice. + */} + {renderTooltip(label)} + + ); +} + +/** + * A controlled swatch picker for choosing one named theme color. + */ +export function ColorSwatchPicker({ + className, + colors = COLOR_NAMES, + 'data-testid': dataTestId, + description, + isDisabled = false, + isLabelHidden = false, + isOptional, + isRequired, + label, + labelTooltip, + onChange, + ref, + size = 'md', + status, + style, + value, +}: ColorSwatchPickerProps): React.JSX.Element { + const inputId = useId(); + const labelId = `${inputId}-label`; + const descriptionID = isReactNode(description) + ? `${inputId}-description` + : undefined; + const statusMessageID = getStatusMessageID(inputId, status); + const describedBy = getDescribedBy(descriptionID, statusMessageID); + const containerRef = useRef(null); + const selectedIndex = colors.indexOf(value); + const tabStopIndex = selectedIndex === -1 ? 0 : selectedIndex; + + const handleChange = useCallback( + (nextValue: ColorName) => { + if (nextValue !== value) { + onChange(nextValue); + } + }, + [onChange, value], + ); + + const getItems = useCallback( + () => + Array.from( + containerRef.current?.querySelectorAll( + '[role="radio"]:not([aria-disabled="true"])', + ) ?? [], + ), + [], + ); + const {handleKeyDown: handleListKeyDown} = useListFocus({ + getItems, + onFocusItem: item => { + const nextValue = item.dataset.value as ColorName | undefined; + if (nextValue != null) { + handleChange(nextValue); + } + }, + orientation: 'both', + }); + const hint = useKeyboardHint({ + isEnabled: !isDisabled, + orientation: 'horizontal', + }); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + hint.onKeyDown(event); + if (isDisabled) { + return; + } + handleListKeyDown(event); + }, + [handleListKeyDown, hint, isDisabled], + ); + + const necessity = getNecessity(isOptional, isRequired); + + return ( + +
+ {colors.map((color, index) => ( + + ))} + {hint.hintElement} +
+
+ ); +} + +ColorSwatchPicker.displayName = 'ColorSwatchPicker'; diff --git a/src/components/ColorSwatchPicker/index.ts b/src/components/ColorSwatchPicker/index.ts new file mode 100644 index 0000000..d96e0eb --- /dev/null +++ b/src/components/ColorSwatchPicker/index.ts @@ -0,0 +1,5 @@ +export { + ColorSwatchPicker, + type ColorSwatchPickerProps, +} from 'components/ColorSwatchPicker/ColorSwatchPicker'; +export {COLOR_NAMES, type ColorName} from 'internal/colorNames'; diff --git a/src/components/Schedule/CalendarEvent.ts b/src/components/Schedule/CalendarEvent.ts index ca9aac4..c284d96 100644 --- a/src/components/Schedule/CalendarEvent.ts +++ b/src/components/Schedule/CalendarEvent.ts @@ -1,11 +1,11 @@ import {Temporal} from '@js-temporal/polyfill'; import type {Instant} from 'components/Schedule/types'; -import type {TagColor} from 'components/Tag'; +import type {ColorName} from 'internal/colorNames'; import type {PlainDate} from 'internal/dateTypes'; const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/; -export type ScheduleEventColor = Exclude; +export type ScheduleEventColor = ColorName; export interface ScheduleCategory { color: ScheduleEventColor; diff --git a/src/components/Tag/Tag.tsx b/src/components/Tag/Tag.tsx index 214d267..c988286 100644 --- a/src/components/Tag/Tag.tsx +++ b/src/components/Tag/Tag.tsx @@ -14,19 +14,10 @@ import {tagRecipe} from 'components/Tag/Tag.recipe'; import {Tooltip} from 'components/Tooltip'; import {VisuallyHidden} from 'components/VisuallyHidden'; import {ActionElement} from 'internal/ActionElement'; +import type {ColorName} from 'internal/colorNames'; import {cx} from 'utils/cx'; -export type TagColor = - | 'red' - | 'orange' - | 'yellow' - | 'green' - | 'teal' - | 'cyan' - | 'blue' - | 'purple' - | 'pink' - | 'gray'; +export type TagColor = ColorName; export type TagSize = 'sm' | 'md' | 'lg'; diff --git a/src/index.ts b/src/index.ts index 3c11274..3174222 100644 --- a/src/index.ts +++ b/src/index.ts @@ -307,6 +307,7 @@ export { type BadgeColor, type BadgeProps, type BadgeSize, + type BadgeStatusColor, } from 'components/Badge'; export { Card, @@ -524,6 +525,12 @@ export { type InputStatus, type InputStatusType, } from 'components/Field'; +export { + COLOR_NAMES, + ColorSwatchPicker, + type ColorName, + type ColorSwatchPickerProps, +} from 'components/ColorSwatchPicker'; export { TextInput, type TextInputProps, diff --git a/src/internal/colorNames.ts b/src/internal/colorNames.ts new file mode 100644 index 0000000..9ea3b78 --- /dev/null +++ b/src/internal/colorNames.ts @@ -0,0 +1,27 @@ +export const COLOR_NAMES = [ + 'red', + 'orange', + 'yellow', + 'green', + 'teal', + 'cyan', + 'blue', + 'purple', + 'pink', + 'gray', +] as const; + +export type ColorName = (typeof COLOR_NAMES)[number]; + +export const COLOR_LABELS: Record = { + red: 'Red', + orange: 'Orange', + yellow: 'Yellow', + green: 'Green', + teal: 'Teal', + cyan: 'Cyan', + blue: 'Blue', + purple: 'Purple', + pink: 'Pink', + gray: 'Gray', +}; diff --git a/src/tokens/contrast.test.ts b/src/tokens/contrast.test.ts index 16f7565..628161b 100644 --- a/src/tokens/contrast.test.ts +++ b/src/tokens/contrast.test.ts @@ -1,5 +1,6 @@ import {describe, expect, it} from 'vitest'; +import {COLOR_NAMES} from 'internal/colorNames'; import {themePresets} from 'themes/presets'; // panda.config.ts lives at the repo root (outside src) and has no path alias; @@ -107,19 +108,6 @@ function contrastRatio(a: string, b: string): number { return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); } -const SURFACE_COLORS = [ - 'blue', - 'cyan', - 'gray', - 'green', - 'orange', - 'pink', - 'purple', - 'red', - 'teal', - 'yellow', -] as const; - // Every text-bearing solid fill paired with its intended foreground. // status.disabled.solid is intentionally omitted: WCAG 2.1 SC 1.4.3 exempts // inactive/disabled UI components from the contrast requirement. @@ -135,7 +123,7 @@ const PAIRINGS: {name: string; bg: string; fg: string}[] = [ bg: `{colors.status.${s}.solid}`, fg: `{colors.status.${s}.solidFg}`, })), - ...SURFACE_COLORS.map(c => ({ + ...COLOR_NAMES.map(c => ({ name: `surface.${c} tint`, bg: `{colors.surface.${c}.DEFAULT}`, fg: `{colors.surface.${c}.fg}`, @@ -145,6 +133,12 @@ const PAIRINGS: {name: string; bg: string; fg: string}[] = [ const MODES: Mode[] = ['base', '_dark']; describe('token color contrast (WCAG AA, normal text)', () => { + it('keeps ColorName in sync with surface semantic colors', () => { + const surface = (semantic.surface ?? {}) as Record; + + expect(Object.keys(surface).sort()).toEqual([...COLOR_NAMES].sort()); + }); + it.each(PAIRINGS.flatMap(p => MODES.map(mode => ({...p, mode}))))( '$name meets 4.5:1 in $mode mode', ({bg, fg, mode}) => {