'use client' import * as React from 'react' import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '../../internal/utils' import { useDirection } from '../../hooks/use-direction' import { COLOR_SWATCH_SHAPES, COLOR_SWATCH_SIZES, describeColor, normalizeColor } from '../../internal/color-control' import { useColorPickerContext } from '../../internal/color-picker-context' import { ColorSwatch } from '../color-swatch/color-swatch' import { type Color, formatColor } from '../../lib/color' const ITEM = '[data-slot=color-swatch-picker-item]' type ColorSwatchPickerLayout = 'grid' | 'stack' const pickerVariants = cva('relative isolate flex gap-1', { variants: { size: COLOR_SWATCH_SIZES, layout: { grid: 'flex-row flex-wrap', stack: 'flex-col', }, }, }) // The button is the outer ring's box, so the swatch has to sit inside it. At 0.8em // the gap either side is 0.1em, which holds at every size because both are ems. const itemVariants = cva( 'outline-ring relative flex size-[1em] shrink-0 transform-gpu cursor-pointer items-center justify-center focus-visible:outline-3 motion-safe:transition motion-safe:duration-250 motion-safe:ease-[cubic-bezier(0.175,0.885,0.32,1.5)] motion-safe:active:translate-y-px motion-safe:active:scale-90 motion-safe:active:duration-100 motion-safe:active:ease-in-out data-disabled:pointer-events-none data-disabled:cursor-default', { variants: { shape: COLOR_SWATCH_SHAPES } }, ) type ColorSwatchPickerSize = NonNullable['size']> type ColorSwatchPickerShape = NonNullable['shape']> interface ColorSwatchPickerContextValue { colors: Map selectedKey: string | null select: (color: Color) => void shape: ColorSwatchPickerShape disabled: boolean } const ColorSwatchPickerContext = React.createContext(null) function usePickerContext() { const context = React.useContext(ColorSwatchPickerContext) if (!context) throw new Error('Appica UI: must be used within .') return context } /** Where the indicator sits, in the picker's own coordinates. */ interface Indicator { x: number y: number width: number height: number } function sameBox(a: Indicator | null, b: Indicator) { return a != null && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height } /** * The item one row up or down in a wrapped grid, nearest the column the focus is * already in. Rows are whatever shares an `offsetTop`, since the wrapping is the * browser's to decide. */ function rowNeighbor(items: HTMLElement[], from: number, step: 1 | -1) { const origin = items[from]! const beyond = items.filter((item) => step > 0 ? item.offsetTop > origin.offsetTop : item.offsetTop < origin.offsetTop, ) if (beyond.length === 0) return from + step const tops = beyond.map((item) => item.offsetTop) const row = step > 0 ? Math.min(...tops) : Math.max(...tops) const nearest = beyond .filter((item) => item.offsetTop === row) .reduce((best, item) => Math.abs(item.offsetLeft - origin.offsetLeft) < Math.abs(best.offsetLeft - origin.offsetLeft) ? item : best, ) return items.indexOf(nearest) } interface ColorSwatchPickerProps extends Omit< React.ComponentPropsWithoutRef<'div'>, 'color' | 'defaultValue' | 'onChange' > { /** * Selected color. Pass a `Color` or any CSS color string to control the component. * Inside a `ColorPicker` it can be left off: the control then reads and writes the * picker's color. */ value?: Color | string /** Color selected before any interaction, when the component is uncontrolled. */ defaultValue?: Color | string /** Fires with the color that was picked. */ onValueChange?: (value: Color) => void /** * Wrapping rows of swatches, or a single column. * @default 'grid' */ layout?: ColorSwatchPickerLayout /** * A preset scale, or a pixel number for an exact size. Sizes the button; the swatch * inside is smaller, so the selected ring has somewhere to sit. * @default 'md' */ size?: ColorSwatchPickerSize | number /** * Rounded square or full circle, applied to every swatch and to the ring. * @default 'rounded' */ shape?: ColorSwatchPickerShape /** * Prevent interaction and swap every color for a flat muted fill. * @default false */ disabled?: boolean } function ColorSwatchPicker({ value, defaultValue, onValueChange, layout = 'grid', size = 'md', shape = 'rounded', disabled: disabledProp, className, style, children, ...props }: ColorSwatchPickerProps) { const mirrored = useDirection() === 'rtl' const rootRef = React.useRef(null) const colors = React.useMemo(() => new Map(), []) const picker = useColorPickerContext() const [uncontrolled, setUncontrolled] = React.useState(() => defaultValue === undefined ? null : normalizeColor(defaultValue), ) const [indicator, setIndicator] = React.useState(null) const disabled = disabledProp || (picker?.disabled ?? false) const resolved = value !== undefined ? normalizeColor(value) : (picker?.value ?? uncontrolled) // Keyed on hexa like React Aria, so the same color written two ways is one entry. const selectedKey = resolved === null ? null : formatColor(resolved, 'hexa') const select = (color: Color) => { if (value === undefined && !picker) setUncontrolled(color) picker?.setValue(color) picker?.commitValue(color) onValueChange?.(color) } // No dependency list: this mirrors the rendered DOM back into the roving tab stop // and the indicator's box, both of which any render can invalidate. React.useEffect(() => { const root = rootRef.current if (!root) return const sync = () => { const items = Array.from(root.querySelectorAll(ITEM)) const selected = items.find((item) => item.getAttribute('aria-selected') === 'true') const reachable = selected ?? items.find((item) => !item.hasAttribute('data-disabled')) for (const item of items) item.tabIndex = item === reachable ? 0 : -1 if (!selected) return setIndicator(null) const box = { x: selected.offsetLeft, y: selected.offsetTop, width: selected.offsetWidth, height: selected.offsetHeight, } setIndicator((previous) => (sameBox(previous, box) ? previous : box)) } sync() if (typeof ResizeObserver === 'undefined') return const observer = new ResizeObserver(sync) observer.observe(root) return () => observer.disconnect() }) const handleKeyDown = (event: React.KeyboardEvent) => { const root = rootRef.current if (!root) return const items = Array.from(root.querySelectorAll(`${ITEM}:not([data-disabled])`)) const current = items.indexOf(document.activeElement as HTMLElement) if (current < 0) return const stack = layout === 'stack' const forward = mirrored ? 'ArrowLeft' : 'ArrowRight' const backward = mirrored ? 'ArrowRight' : 'ArrowLeft' const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : event.key === forward ? current + 1 : event.key === backward ? current - 1 : event.key === 'ArrowDown' ? stack ? current + 1 : rowNeighbor(items, current, 1) : event.key === 'ArrowUp' ? stack ? current - 1 : rowNeighbor(items, current, -1) : NaN if (Number.isNaN(next)) return event.preventDefault() const target = items[(next + items.length) % items.length] if (!target) return target.focus() // Selection follows focus, which is what a single-select listbox is expected to // do and what makes arrowing through a palette worth doing at all. const color = colors.get(target.dataset.value ?? '') if (color) select(color) } const isNumeric = typeof size === 'number' const context: ColorSwatchPickerContextValue = { colors, selectedKey, select, shape, disabled } return (
{children} {indicator && (
) } interface ColorSwatchPickerItemProps extends Omit, 'color' | 'value'> { /** Color this swatch offers. Pass a `Color` or any CSS color string. */ color: Color | string /** Name announced for the color, in place of the description built from the color itself. */ colorName?: string /** * Prevent this swatch from being picked, and swap its color for a flat muted fill. * @default false */ disabled?: boolean } function ColorSwatchPickerItem({ color, colorName, disabled = false, className, children, ...props }: ColorSwatchPickerItemProps) { const { colors, selectedKey, select, shape, disabled: pickerDisabled } = usePickerContext() const resolved = React.useMemo(() => normalizeColor(color), [color]) const key = formatColor(resolved, 'hexa') const selected = selectedKey === key const off = disabled || pickerDisabled React.useEffect(() => { colors.set(key, resolved) return () => { colors.delete(key) } }, [colors, key, resolved]) return ( ) } export { ColorSwatchPicker, ColorSwatchPickerItem } export type { ColorSwatchPickerProps, ColorSwatchPickerItemProps }