'use client'; import React, { Children, forwardRef, isValidElement, useCallback, useMemo, useRef, } from 'react'; import { Surface, cn, surfaceClasses, useEffectiveSurface, } from '../common'; import { useControllableState } from '../hooks/useControllableState'; /* ────────────────────────────────────────────────────────────────────────── PixelChipGroup — controlled filter chip row with single/multi selection. Each child must declare a `value` prop. The group wraps each child in a role=radio (single) or role=checkbox (multi) button so the chip surface stays purely presentational while the wrapper owns the toggle semantics. Keyboard: Tab moves between chips; Space/Enter activates. ────────────────────────────────────────────────────────────────────────── */ interface ChipChildProps { value: string; } export interface PixelChipGroupProps extends Omit, 'onChange' | 'defaultValue'> { /** Controlled selection. */ value?: string[]; /** Uncontrolled initial selection. */ defaultValue?: string[]; onChange?: (next: string[]) => void; multiple?: boolean; surface?: Surface; /** Accessible name (required when single-select so SR users hear the group). */ 'aria-label'?: string; 'aria-labelledby'?: string; children: React.ReactNode; } export const PixelChipGroup = forwardRef( function PixelChipGroup( { value: valueProp, defaultValue, onChange, multiple = false, surface: surfaceProp, className, children, ...rest }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const [value, setValue] = useControllableState({ value: valueProp, defaultValue: defaultValue ?? [], onChange, }); const toggle = (chipValue: string) => { const current = value ?? []; const isSelected = current.includes(chipValue); let next: string[]; if (multiple) { next = isSelected ? current.filter((v) => v !== chipValue) : [...current, chipValue]; } else { next = isSelected ? [] : [chipValue]; } setValue(next); }; const items = Children.toArray(children).filter(isValidElement); // Collect ordered radio values for roving tabindex + arrow nav (single mode). const radioValues = useMemo(() => { const out: string[] = []; for (const child of items) { const el = child as React.ReactElement; const v = el.props?.value; if (typeof v === 'string') out.push(v); } return out; }, [items]); const btnRefs = useRef>(new Map()); const setBtnRef = useCallback((val: string, el: HTMLButtonElement | null) => { if (el) btnRefs.current.set(val, el); else btnRefs.current.delete(val); }, []); const selectedRadio = !multiple ? ((value ?? [])[0] ?? null) : null; const focusableRadio = selectedRadio ?? radioValues[0] ?? null; const moveRadio = useCallback( (current: string, direction: 1 | -1 | 'first' | 'last') => { if (radioValues.length === 0) return; let nextIdx: number; if (direction === 'first') nextIdx = 0; else if (direction === 'last') nextIdx = radioValues.length - 1; else { const idx = radioValues.indexOf(current); if (idx === -1) return; nextIdx = Math.max(0, Math.min(radioValues.length - 1, idx + direction)); } const nextVal = radioValues[nextIdx]; btnRefs.current.get(nextVal)?.focus(); // In radiogroup pattern, arrow key both focuses AND selects. toggle(nextVal); }, // toggle is stable across renders of the same value/onChange combo; we // re-create the callback per radio order/value change anyway. // eslint-disable-next-line react-hooks/exhaustive-deps [radioValues], ); const ariaLabel = (rest as { 'aria-label'?: string })['aria-label']; const ariaLabelledBy = (rest as { 'aria-labelledby'?: string })['aria-labelledby']; const hasName = !!(ariaLabel || ariaLabelledBy); return (
{items.map((child, idx) => { const el = child as React.ReactElement; const chipValue = el.props?.value; if (typeof chipValue !== 'string') { return ( {el} ); } const selected = (value ?? []).includes(chipValue); const handleClick = () => toggle(chipValue); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(chipValue); return; } if (!multiple) { switch (e.key) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); moveRadio(chipValue, 1); return; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); moveRadio(chipValue, -1); return; case 'Home': e.preventDefault(); moveRadio(chipValue, 'first'); return; case 'End': e.preventDefault(); moveRadio(chipValue, 'last'); return; default: return; } } }; // Single-mode roving tabindex: only the selected (or first) radio is // Tab-reachable; others -1. const rovingTabIndex = !multiple ? (chipValue === focusableRadio ? 0 : -1) : undefined; return ( ); })}
); }, ); PixelChipGroup.displayName = 'PixelChipGroup';