From b64cf24d518d5e12c5d31a0b39ac109850dda0fe Mon Sep 17 00:00:00 2001 From: Andrey Goder Date: Thu, 9 Jul 2026 19:04:52 -0700 Subject: [PATCH 1/3] Add SwatchPicker color field --- README.md | 1 + site/src/component-categories.ts | 1 + src/components/Badge/Badge.recipe.ts | 1 + src/components/Badge/Badge.test.tsx | 8 + src/components/Badge/Badge.tsx | 20 +- src/components/Badge/index.ts | 1 + src/components/Schedule/CalendarEvent.ts | 4 +- .../SwatchPicker/SwatchPicker.recipe.ts | 150 +++++++++ .../SwatchPicker/SwatchPicker.stories.tsx | 106 ++++++ .../SwatchPicker/SwatchPicker.test.tsx | 309 ++++++++++++++++++ src/components/SwatchPicker/SwatchPicker.tsx | 245 ++++++++++++++ src/components/SwatchPicker/index.ts | 5 + src/components/Tag/Tag.tsx | 13 +- src/index.ts | 7 + src/internal/colorNames.ts | 27 ++ src/tokens/contrast.test.ts | 22 +- 16 files changed, 878 insertions(+), 42 deletions(-) create mode 100644 src/components/SwatchPicker/SwatchPicker.recipe.ts create mode 100644 src/components/SwatchPicker/SwatchPicker.stories.tsx create mode 100644 src/components/SwatchPicker/SwatchPicker.test.tsx create mode 100644 src/components/SwatchPicker/SwatchPicker.tsx create mode 100644 src/components/SwatchPicker/index.ts create mode 100644 src/internal/colorNames.ts diff --git a/README.md b/README.md index cbf621e2..cbda0060 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,7 @@ mode details, scoped theming examples, and per-instance overrides. - **SearchFilterInput** — search input with structured filter tags - **Select** — single-value dropdown selector - **Slider** — range slider with marks and labels +- **SwatchPicker** — swatch picker for the named theme palette - **Switch** — toggle switch - **TagsInput** — free-form tag entry field - **TextArea** — multi-line text input diff --git a/site/src/component-categories.ts b/site/src/component-categories.ts index df2e5bb8..7c75d773 100644 --- a/site/src/component-categories.ts +++ b/site/src/component-categories.ts @@ -53,6 +53,7 @@ export const componentCategories: Record = { 'SearchFilterInput', 'Select', 'Slider', + 'SwatchPicker', 'Switch', 'TagsInput', 'TextArea', diff --git a/src/components/Badge/Badge.recipe.ts b/src/components/Badge/Badge.recipe.ts index 5563efde..47c217ea 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 948aa85a..46368d22 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 b827b1c9..d04076f4 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 2b6e7bb5..5923fdcc 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/Schedule/CalendarEvent.ts b/src/components/Schedule/CalendarEvent.ts index ca9aac46..c284d969 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/SwatchPicker/SwatchPicker.recipe.ts b/src/components/SwatchPicker/SwatchPicker.recipe.ts new file mode 100644 index 00000000..43333f01 --- /dev/null +++ b/src/components/SwatchPicker/SwatchPicker.recipe.ts @@ -0,0 +1,150 @@ +import {cva, sva, type RecipeVariantProps} from 'styled-system/css'; + +export const swatchPickerRecipe = cva({ + base: { + display: 'flex', + alignItems: 'center', + flexWrap: 'wrap', + gap: '2', + }, +}); + +export const swatchRecipe = 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: { + outlineWidth: 'focus', + outlineStyle: 'solid', + outlineColor: 'primary', + outlineOffset: 'focusOffset', + }, + }, + fill: { + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + borderWidth: 'thin', + borderStyle: 'solid', + borderRadius: 'full', + transitionProperty: 'border-color, border-width, box-shadow, transform', + transitionDuration: 'fast', + transitionTimingFunction: 'default', + }, + }, + variants: { + color: { + red: { + fill: { + bg: 'surface.red', + borderColor: 'surface.red.accent', + color: 'surface.red.fg', + }, + }, + orange: { + fill: { + bg: 'surface.orange', + borderColor: 'surface.orange.accent', + color: 'surface.orange.fg', + }, + }, + yellow: { + fill: { + bg: 'surface.yellow', + borderColor: 'surface.yellow.accent', + color: 'surface.yellow.fg', + }, + }, + green: { + fill: { + bg: 'surface.green', + borderColor: 'surface.green.accent', + color: 'surface.green.fg', + }, + }, + teal: { + fill: { + bg: 'surface.teal', + borderColor: 'surface.teal.accent', + color: 'surface.teal.fg', + }, + }, + cyan: { + fill: { + bg: 'surface.cyan', + borderColor: 'surface.cyan.accent', + color: 'surface.cyan.fg', + }, + }, + blue: { + fill: { + bg: 'surface.blue', + borderColor: 'surface.blue.accent', + color: 'surface.blue.fg', + }, + }, + purple: { + fill: { + bg: 'surface.purple', + borderColor: 'surface.purple.accent', + color: 'surface.purple.fg', + }, + }, + pink: { + fill: { + bg: 'surface.pink', + borderColor: 'surface.pink.accent', + color: 'surface.pink.fg', + }, + }, + gray: { + fill: { + 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: { + borderWidth: 'thick', + boxShadow: 'sm', + }, + }, + false: {}, + }, + isDisabled: { + true: { + button: { + cursor: 'not-allowed', + opacity: 0.55, + }, + }, + false: {}, + }, + }, + defaultVariants: { + size: 'md', + isSelected: false, + isDisabled: false, + }, +}); + +export type SwatchVariants = RecipeVariantProps; diff --git a/src/components/SwatchPicker/SwatchPicker.stories.tsx b/src/components/SwatchPicker/SwatchPicker.stories.tsx new file mode 100644 index 00000000..a06164c5 --- /dev/null +++ b/src/components/SwatchPicker/SwatchPicker.stories.tsx @@ -0,0 +1,106 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {useState} from 'react'; +import {Badge} from 'components/Badge'; +import { + SwatchPicker, + type ColorName, + type SwatchPickerProps, +} from 'components/SwatchPicker'; +import {Tag} from 'components/Tag'; + +function SwatchPickerStory(args: SwatchPickerProps): React.JSX.Element { + const [value, setValue] = useState(args.value); + + return ; +} + +function SizesStory(args: SwatchPickerProps): React.JSX.Element { + const [value, setValue] = useState(args.value); + + return ( +
+ + + +
+ ); +} + +function DrivesOtherComponentsStory( + args: SwatchPickerProps, +): React.JSX.Element { + const [value, setValue] = useState(args.value); + + return ( +
+ +
+ + +
+
+ ); +} + +const meta = { + title: 'Components/SwatchPicker', + component: SwatchPicker, + 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: SwatchPickerProps): React.JSX.Element => ( + + ), +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Sizes: Story = { + render: (args: SwatchPickerProps): React.JSX.Element => ( + + ), +}; + +export const Subset: Story = { + args: { + colors: ['red', 'orange', 'green', 'blue', 'purple'], + value: 'green', + }, +}; + +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: SwatchPickerProps): React.JSX.Element => ( + + ), +}; diff --git a/src/components/SwatchPicker/SwatchPicker.test.tsx b/src/components/SwatchPicker/SwatchPicker.test.tsx new file mode 100644 index 00000000..da4a59cd --- /dev/null +++ b/src/components/SwatchPicker/SwatchPicker.test.tsx @@ -0,0 +1,309 @@ +import {fireEvent, render, screen, 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 {SwatchPicker} from 'components/SwatchPicker/SwatchPicker'; +import {COLOR_LABELS, COLOR_NAMES, type ColorName} from 'internal/colorNames'; +import {createPopoverFocusShim} from 'internal/testHelpers'; + +const shim = createPopoverFocusShim(); + +beforeAll(shim.install); +afterAll(shim.uninstall); +beforeEach(() => { + shim.reset(); + shim.setFocusVisible(false); +}); + +describe('SwatchPicker', () => { + 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(); + }); +}); diff --git a/src/components/SwatchPicker/SwatchPicker.tsx b/src/components/SwatchPicker/SwatchPicker.tsx new file mode 100644 index 00000000..552c0772 --- /dev/null +++ b/src/components/SwatchPicker/SwatchPicker.tsx @@ -0,0 +1,245 @@ +'use client'; + +import {Check} from 'lucide-react'; +import { + useCallback, + useId, + useRef, + type CSSProperties, + type KeyboardEvent, + type ReactNode, + type Ref, +} from 'react'; +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 { + swatchPickerRecipe, + swatchRecipe, +} from 'components/SwatchPicker/SwatchPicker.recipe'; +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 SwatchPickerProps = { + /** + * 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; + +/** + * A controlled swatch picker for choosing one named theme color. + */ +export function SwatchPicker({ + className, + colors = COLOR_NAMES, + 'data-testid': dataTestId, + description, + isDisabled = false, + isLabelHidden = false, + isOptional, + isRequired, + label, + labelTooltip, + onChange, + ref, + size = 'md', + status, + style, + value, +}: SwatchPickerProps): 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) => { + const isSelected = color === value; + const classes = swatchRecipe({ + color, + isDisabled, + isSelected, + size, + }); + + return ( + + ); + })} + {hint.hintElement} +
+
+ ); +} + +SwatchPicker.displayName = 'SwatchPicker'; diff --git a/src/components/SwatchPicker/index.ts b/src/components/SwatchPicker/index.ts new file mode 100644 index 00000000..ace2b963 --- /dev/null +++ b/src/components/SwatchPicker/index.ts @@ -0,0 +1,5 @@ +export { + SwatchPicker, + type SwatchPickerProps, +} from 'components/SwatchPicker/SwatchPicker'; +export {COLOR_NAMES, type ColorName} from 'internal/colorNames'; diff --git a/src/components/Tag/Tag.tsx b/src/components/Tag/Tag.tsx index 214d2679..c988286b 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 3c112749..94424d2c 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, + SwatchPicker, + type ColorName, + type SwatchPickerProps, +} from 'components/SwatchPicker'; export { TextInput, type TextInputProps, diff --git a/src/internal/colorNames.ts b/src/internal/colorNames.ts new file mode 100644 index 00000000..9ea3b78c --- /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 16f7565e..628161b3 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}) => { From 60ee0b86a1e1d00e62416e14bae550c0fb4e67f5 Mon Sep 17 00:00:00 2001 From: Andrey Goder Date: Thu, 9 Jul 2026 19:43:43 -0700 Subject: [PATCH 2/3] Rename SwatchPicker to ColorSwatchPicker Pure rename, no behavior change. Renames the component, its props type, its recipes (swatchPickerRecipe -> colorSwatchPickerRecipe, swatchRecipe -> colorSwatchRecipe), the variants type, and the Storybook title. Updates the public barrel export, the README component list, and the docs site category list; both lists stay alphabetically ordered, so the entry moves up under CheckboxInput. --- README.md | 2 +- site/src/component-categories.ts | 2 +- .../ColorSwatchPicker.recipe.ts} | 6 +- .../ColorSwatchPicker.stories.tsx} | 55 ++++++++++++------- .../ColorSwatchPicker.test.tsx} | 54 ++++++++++++------ .../ColorSwatchPicker.tsx} | 20 +++---- src/components/ColorSwatchPicker/index.ts | 5 ++ src/components/SwatchPicker/index.ts | 5 -- src/index.ts | 6 +- 9 files changed, 96 insertions(+), 59 deletions(-) rename src/components/{SwatchPicker/SwatchPicker.recipe.ts => ColorSwatchPicker/ColorSwatchPicker.recipe.ts} (95%) rename src/components/{SwatchPicker/SwatchPicker.stories.tsx => ColorSwatchPicker/ColorSwatchPicker.stories.tsx} (58%) rename src/components/{SwatchPicker/SwatchPicker.test.tsx => ColorSwatchPicker/ColorSwatchPicker.test.tsx} (89%) rename src/components/{SwatchPicker/SwatchPicker.tsx => ColorSwatchPicker/ColorSwatchPicker.tsx} (93%) create mode 100644 src/components/ColorSwatchPicker/index.ts delete mode 100644 src/components/SwatchPicker/index.ts diff --git a/README.md b/README.md index cbda0060..30f0499d 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 @@ -167,7 +168,6 @@ mode details, scoped theming examples, and per-instance overrides. - **SearchFilterInput** — search input with structured filter tags - **Select** — single-value dropdown selector - **Slider** — range slider with marks and labels -- **SwatchPicker** — swatch picker for the named theme palette - **Switch** — toggle switch - **TagsInput** — free-form tag entry field - **TextArea** — multi-line text input diff --git a/site/src/component-categories.ts b/site/src/component-categories.ts index 7c75d773..e8f96bed 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', @@ -53,7 +54,6 @@ export const componentCategories: Record = { 'SearchFilterInput', 'Select', 'Slider', - 'SwatchPicker', 'Switch', 'TagsInput', 'TextArea', diff --git a/src/components/SwatchPicker/SwatchPicker.recipe.ts b/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts similarity index 95% rename from src/components/SwatchPicker/SwatchPicker.recipe.ts rename to src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts index 43333f01..6ac4814e 100644 --- a/src/components/SwatchPicker/SwatchPicker.recipe.ts +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts @@ -1,6 +1,6 @@ import {cva, sva, type RecipeVariantProps} from 'styled-system/css'; -export const swatchPickerRecipe = cva({ +export const colorSwatchPickerRecipe = cva({ base: { display: 'flex', alignItems: 'center', @@ -9,7 +9,7 @@ export const swatchPickerRecipe = cva({ }, }); -export const swatchRecipe = sva({ +export const colorSwatchRecipe = sva({ slots: ['button', 'fill'], base: { button: { @@ -147,4 +147,4 @@ export const swatchRecipe = sva({ }, }); -export type SwatchVariants = RecipeVariantProps; +export type ColorSwatchVariants = RecipeVariantProps; diff --git a/src/components/SwatchPicker/SwatchPicker.stories.tsx b/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx similarity index 58% rename from src/components/SwatchPicker/SwatchPicker.stories.tsx rename to src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx index a06164c5..f3e54822 100644 --- a/src/components/SwatchPicker/SwatchPicker.stories.tsx +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx @@ -2,38 +2,55 @@ import type {Meta, StoryObj} from '@storybook/react-vite'; import {useState} from 'react'; import {Badge} from 'components/Badge'; import { - SwatchPicker, + ColorSwatchPicker, type ColorName, - type SwatchPickerProps, -} from 'components/SwatchPicker'; + type ColorSwatchPickerProps, +} from 'components/ColorSwatchPicker'; import {Tag} from 'components/Tag'; -function SwatchPickerStory(args: SwatchPickerProps): React.JSX.Element { +function ColorSwatchPickerStory( + args: ColorSwatchPickerProps, +): React.JSX.Element { const [value, setValue] = useState(args.value); - return ; + return ; } -function SizesStory(args: SwatchPickerProps): React.JSX.Element { +function SizesStory(args: ColorSwatchPickerProps): React.JSX.Element { const [value, setValue] = useState(args.value); return (
- - - + + +
); } function DrivesOtherComponentsStory( - args: SwatchPickerProps, + args: ColorSwatchPickerProps, ): React.JSX.Element { const [value, setValue] = useState(args.value); return (
- +
@@ -43,8 +60,8 @@ function DrivesOtherComponentsStory( } const meta = { - title: 'Components/SwatchPicker', - component: SwatchPicker, + title: 'Components/ColorSwatchPicker', + component: ColorSwatchPicker, args: { label: 'Office color', description: 'Choose the named theme color used for this office.', @@ -58,18 +75,18 @@ const meta = { options: ['sm', 'md', 'lg'], }, }, - render: (args: SwatchPickerProps): React.JSX.Element => ( - + render: (args: ColorSwatchPickerProps): React.JSX.Element => ( + ), -} satisfies Meta; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = {}; export const Sizes: Story = { - render: (args: SwatchPickerProps): React.JSX.Element => ( + render: (args: ColorSwatchPickerProps): React.JSX.Element => ( ), }; @@ -100,7 +117,7 @@ export const Required: Story = { }; export const DrivesOtherComponents: Story = { - render: (args: SwatchPickerProps): React.JSX.Element => ( + render: (args: ColorSwatchPickerProps): React.JSX.Element => ( ), }; diff --git a/src/components/SwatchPicker/SwatchPicker.test.tsx b/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx similarity index 89% rename from src/components/SwatchPicker/SwatchPicker.test.tsx rename to src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx index da4a59cd..e1c23ab4 100644 --- a/src/components/SwatchPicker/SwatchPicker.test.tsx +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx @@ -10,7 +10,7 @@ import { it, vi, } from 'vitest'; -import {SwatchPicker} from 'components/SwatchPicker/SwatchPicker'; +import {ColorSwatchPicker} from 'components/ColorSwatchPicker/ColorSwatchPicker'; import {COLOR_LABELS, COLOR_NAMES, type ColorName} from 'internal/colorNames'; import {createPopoverFocusShim} from 'internal/testHelpers'; @@ -23,10 +23,14 @@ beforeEach(() => { shim.setFocusVisible(false); }); -describe('SwatchPicker', () => { +describe('ColorSwatchPicker', () => { it('renders a labelled radiogroup with one radio per color', () => { render( - {}} value="blue" />, + {}} + value="blue" + />, ); const group = screen.getByRole('radiogroup', {name: 'Office color'}); @@ -43,7 +47,11 @@ describe('SwatchPicker', () => { it('renders controlled checked state and roving tabindex', () => { render( - {}} value="blue" />, + {}} + value="blue" + />, ); const selected = screen.getByRole('radio', {name: 'Blue'}); @@ -63,7 +71,11 @@ describe('SwatchPicker', () => { function Example(): React.JSX.Element { const [value, setValue] = useState('red'); return ( - + ); } @@ -80,7 +92,11 @@ describe('SwatchPicker', () => { const onChange = vi.fn(); render( - , + , ); await user.click(screen.getByRole('radio', {name: 'Blue'})); @@ -92,7 +108,11 @@ describe('SwatchPicker', () => { const onChange = vi.fn(); render( - , + , ); await user.click(screen.getByRole('radio', {name: 'Red'})); @@ -104,7 +124,7 @@ describe('SwatchPicker', () => { const onChange = vi.fn(); render( - { const onChange = vi.fn(); render( - { it('renders a custom color subset in order', () => { render( - {}} @@ -180,7 +200,7 @@ describe('SwatchPicker', () => { it('keeps one swatch tabbable when value is not in colors', () => { render( - {}} @@ -200,7 +220,7 @@ describe('SwatchPicker', () => { const onChange = vi.fn(); render( - { it('sets aria-invalid and describes status messages', () => { render( - {}} status={{type: 'error', message: 'Choose a color'}} @@ -244,7 +264,7 @@ describe('SwatchPicker', () => { it('wires description and label ids', () => { render( - {}} @@ -262,7 +282,7 @@ describe('SwatchPicker', () => { it('renders required and optional field indicators', () => { const {rerender} = render( - {}} @@ -274,7 +294,7 @@ describe('SwatchPicker', () => { expect(screen.getByText('Required')).toBeInTheDocument(); rerender( - {}} @@ -289,7 +309,7 @@ describe('SwatchPicker', () => { const ref = createRef(); render( - {colors.map((color, index) => { const isSelected = color === value; - const classes = swatchRecipe({ + const classes = colorSwatchRecipe({ color, isDisabled, isSelected, @@ -242,4 +242,4 @@ export function SwatchPicker({ ); } -SwatchPicker.displayName = 'SwatchPicker'; +ColorSwatchPicker.displayName = 'ColorSwatchPicker'; diff --git a/src/components/ColorSwatchPicker/index.ts b/src/components/ColorSwatchPicker/index.ts new file mode 100644 index 00000000..d96e0ebd --- /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/SwatchPicker/index.ts b/src/components/SwatchPicker/index.ts deleted file mode 100644 index ace2b963..00000000 --- a/src/components/SwatchPicker/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { - SwatchPicker, - type SwatchPickerProps, -} from 'components/SwatchPicker/SwatchPicker'; -export {COLOR_NAMES, type ColorName} from 'internal/colorNames'; diff --git a/src/index.ts b/src/index.ts index 94424d2c..3174222c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -527,10 +527,10 @@ export { } from 'components/Field'; export { COLOR_NAMES, - SwatchPicker, + ColorSwatchPicker, type ColorName, - type SwatchPickerProps, -} from 'components/SwatchPicker'; + type ColorSwatchPickerProps, +} from 'components/ColorSwatchPicker'; export { TextInput, type TextInputProps, From f5e5da40ba8860c8355480111ca0d56c8ebc0683 Mon Sep 17 00:00:00 2001 From: Andrey Goder Date: Thu, 9 Jul 2026 19:45:53 -0700 Subject: [PATCH 3/3] Tighten ColorSwatchPicker spacing, rings, hover, and tooltips Addresses review feedback on the swatch visuals and affordances: - Spacing between swatches drops from spacing.2 to spacing.1. - Each swatch gets a hover tooltip naming its color. Tooltips are hover-only (focusTrigger: 'never'): the color is already the swatch's accessible name, and a focus tooltip both collided with the group's keyboard hint and stayed open on the focused swatch while the pointer hovered a different one. For the same reason the tooltip is left out of aria-describedby, which would otherwise announce the color twice. - The focus ring moves off the padded button and onto the circle itself, so it sits 2px out instead of 6px. - Selection now draws an outer ring in the swatch's own accent color, separated from the circle by a background-colored gap, replacing the thick inner border. The color is threaded through a --swatch-ring custom property so one box-shadow serves all ten palette entries. - Swatches grow to scale(1.12) on hover, suppressed when disabled and under prefers-reduced-motion. The button keeps its spacing.1 padding, which is what reserves room for both rings inside its own box, so neither can bleed onto a neighboring swatch. Verified geometry in headless Chromium against Storybook (light and dark): gap 4px, focus ring at 2px offset, selected ring spanning 2-4px, hover 32px -> 35.84px. Recipe tests assert on whole Panda class names rather than substrings, since focusOffset is a prefix of focusOffsetLoose. --- .../ColorSwatchPicker.recipe.ts | 47 +++- .../ColorSwatchPicker.stories.tsx | 11 + .../ColorSwatchPicker.test.tsx | 209 +++++++++++++++++- .../ColorSwatchPicker/ColorSwatchPicker.tsx | 100 ++++++--- 4 files changed, 326 insertions(+), 41 deletions(-) diff --git a/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts b/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts index 6ac4814e..853a0362 100644 --- a/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.recipe.ts @@ -5,10 +5,17 @@ export const colorSwatchPickerRecipe = cva({ display: 'flex', alignItems: 'center', flexWrap: 'wrap', - gap: '2', + 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: { @@ -25,10 +32,7 @@ export const colorSwatchRecipe = sva({ color: 'inherit', cursor: 'pointer', _focusVisible: { - outlineWidth: 'focus', - outlineStyle: 'solid', - outlineColor: 'primary', - outlineOffset: 'focusOffset', + outline: 'none', }, }, fill: { @@ -38,15 +42,28 @@ export const colorSwatchRecipe = sva({ borderWidth: 'thin', borderStyle: 'solid', borderRadius: 'full', - transitionProperty: 'border-color, border-width, box-shadow, transform', + 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', @@ -54,6 +71,7 @@ export const colorSwatchRecipe = sva({ }, orange: { fill: { + '--swatch-ring': 'token(colors.surface.orange.accent)', bg: 'surface.orange', borderColor: 'surface.orange.accent', color: 'surface.orange.fg', @@ -61,6 +79,7 @@ export const colorSwatchRecipe = sva({ }, yellow: { fill: { + '--swatch-ring': 'token(colors.surface.yellow.accent)', bg: 'surface.yellow', borderColor: 'surface.yellow.accent', color: 'surface.yellow.fg', @@ -68,6 +87,7 @@ export const colorSwatchRecipe = sva({ }, green: { fill: { + '--swatch-ring': 'token(colors.surface.green.accent)', bg: 'surface.green', borderColor: 'surface.green.accent', color: 'surface.green.fg', @@ -75,6 +95,7 @@ export const colorSwatchRecipe = sva({ }, teal: { fill: { + '--swatch-ring': 'token(colors.surface.teal.accent)', bg: 'surface.teal', borderColor: 'surface.teal.accent', color: 'surface.teal.fg', @@ -82,6 +103,7 @@ export const colorSwatchRecipe = sva({ }, cyan: { fill: { + '--swatch-ring': 'token(colors.surface.cyan.accent)', bg: 'surface.cyan', borderColor: 'surface.cyan.accent', color: 'surface.cyan.fg', @@ -89,6 +111,7 @@ export const colorSwatchRecipe = sva({ }, blue: { fill: { + '--swatch-ring': 'token(colors.surface.blue.accent)', bg: 'surface.blue', borderColor: 'surface.blue.accent', color: 'surface.blue.fg', @@ -96,6 +119,7 @@ export const colorSwatchRecipe = sva({ }, purple: { fill: { + '--swatch-ring': 'token(colors.surface.purple.accent)', bg: 'surface.purple', borderColor: 'surface.purple.accent', color: 'surface.purple.fg', @@ -103,6 +127,7 @@ export const colorSwatchRecipe = sva({ }, pink: { fill: { + '--swatch-ring': 'token(colors.surface.pink.accent)', bg: 'surface.pink', borderColor: 'surface.pink.accent', color: 'surface.pink.fg', @@ -110,6 +135,7 @@ export const colorSwatchRecipe = sva({ }, gray: { fill: { + '--swatch-ring': 'token(colors.surface.gray.accent)', bg: 'surface.gray', borderColor: 'surface.gray.accent', color: 'surface.gray.fg', @@ -124,8 +150,8 @@ export const colorSwatchRecipe = sva({ isSelected: { true: { fill: { - borderWidth: 'thick', - boxShadow: 'sm', + boxShadow: + '0 0 0 2px token(colors.bg), 0 0 0 4px var(--swatch-ring, token(colors.border.emphasized))', }, }, false: {}, @@ -136,6 +162,11 @@ export const colorSwatchRecipe = sva({ cursor: 'not-allowed', opacity: 0.55, }, + fill: { + '[role="radio"]:hover &': { + transform: 'none', + }, + }, }, false: {}, }, diff --git a/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx b/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx index f3e54822..738d5c7d 100644 --- a/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.stories.tsx @@ -98,6 +98,17 @@ export const Subset: Story = { }, }; +/** + * 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, diff --git a/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx b/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx index e1c23ab4..ced110b1 100644 --- a/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.test.tsx @@ -1,4 +1,11 @@ -import {fireEvent, render, screen, within} from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {createRef, useState} from 'react'; import { @@ -11,8 +18,12 @@ import { 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 {createPopoverFocusShim} from 'internal/testHelpers'; +import {assertNonNull, createPopoverFocusShim} from 'internal/testHelpers'; const shim = createPopoverFocusShim(); @@ -23,6 +34,16 @@ beforeEach(() => { 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( @@ -326,4 +347,188 @@ describe('ColorSwatchPicker', () => { 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 index 8ca77394..eee75d19 100644 --- a/src/components/ColorSwatchPicker/ColorSwatchPicker.tsx +++ b/src/components/ColorSwatchPicker/ColorSwatchPicker.tsx @@ -23,6 +23,7 @@ import { } 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'; @@ -92,6 +93,63 @@ export type ColorSwatchPickerProps = { 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. */ @@ -205,37 +263,17 @@ export function ColorSwatchPicker({ ref={mergeRefs(containerRef)} role="radiogroup" tabIndex={-1}> - {colors.map((color, index) => { - const isSelected = color === value; - const classes = colorSwatchRecipe({ - color, - isDisabled, - isSelected, - size, - }); - - return ( - - ); - })} + {colors.map((color, index) => ( + + ))} {hint.hintElement}