'use client'; import React, { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { CheckIcon, ChevronDownIcon, CloseIcon, FieldShell, Surface, cn, focusRing, inputBase, sizeHeight, surfaceClasses, toneMap, useEffectiveSurface, } from '../common'; import { useControllableState } from '../hooks/useControllableState'; import { PixelPopover } from '../overlay-foundation/PixelPopover'; export interface PixelMultiSelectOption { value: string; label: string; icon?: React.ReactNode; disabled?: boolean; } export interface PixelMultiSelectProps { value?: string[]; defaultValue?: string[]; onChange?: (next: string[]) => void; options: PixelMultiSelectOption[]; searchable?: boolean; max?: number; placeholder?: string; clearable?: boolean; surface?: Surface; size?: 'sm' | 'md' | 'lg'; label?: string; hint?: string; error?: string; /** * Hidden-input `name`. Multiple values are serialized as repeated * `` entries; read with * `FormData.getAll(name)`. */ name?: string; id?: string; } export const PixelMultiSelect = forwardRef( function PixelMultiSelect( { value: controlledValue, defaultValue, onChange, options, searchable = false, max, placeholder = 'Select…', clearable = false, surface: surfaceProp, size = 'md', label, hint, error, name, id, }, ref, ) { const surface = useEffectiveSurface(surfaceProp); const s = surfaceClasses(surface); const reactId = useId(); const triggerId = id ?? `${reactId}-trigger`; const listboxId = `${reactId}-listbox`; const [value, setValue] = useControllableState({ value: controlledValue, defaultValue: defaultValue ?? [], onChange, }); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [highlighted, setHighlighted] = useState(0); const searchRef = useRef(null); const isSelected = (v: string) => value.includes(v); const capReached = typeof max === 'number' && value.length >= max; const toggle = (v: string) => { if (isSelected(v)) { setValue(value.filter((x) => x !== v)); return; } if (capReached) return; setValue([...value, v]); }; const clear = useCallback(() => { setValue([]); }, [setValue]); const selectedOptions = value .map((v) => options.find((o) => o.value === v)) .filter((o): o is PixelMultiSelectOption => !!o); const filtered = useMemo( () => searchable && query ? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase())) : options, [options, query, searchable], ); // Reset query + highlight when closing. useEffect(() => { if (!open) { setQuery(''); setHighlighted(0); } }, [open]); // Clamp highlight when list shrinks. useEffect(() => { if (filtered.length === 0) { if (highlighted !== 0) setHighlighted(0); return; } if (highlighted > filtered.length - 1) setHighlighted(filtered.length - 1); }, [filtered.length, highlighted]); const optionIdFor = (v: string) => `${listboxId}-opt-${v}`; const activeId = filtered[highlighted] ? optionIdFor(filtered[highlighted].value) : undefined; const navigate = (e: ReactKeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); if (!open) { setOpen(true); return; } if (filtered.length === 0) return; setHighlighted((h) => (h + 1) % filtered.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); if (!open) { setOpen(true); return; } if (filtered.length === 0) return; setHighlighted((h) => (h - 1 + filtered.length) % filtered.length); } else if (e.key === 'Home') { e.preventDefault(); setHighlighted(0); } else if (e.key === 'End') { e.preventDefault(); setHighlighted(Math.max(0, filtered.length - 1)); } else if (e.key === 'Enter' || e.key === ' ') { if (!open) { e.preventDefault(); setOpen(true); return; } const it = filtered[highlighted]; if (it && !it.disabled && !(capReached && !isSelected(it.value))) { e.preventDefault(); toggle(it.value); } } else if (e.key === 'Backspace' && !query && value.length > 0) { // Convenience: backspace on the trigger removes the last chip. e.preventDefault(); const last = value[value.length - 1]; toggle(last); } }; const showClear = clearable && value.length > 0; return ( {name && value.map((v) => ( ))} {searchable && (
{ setQuery(e.target.value); setHighlighted(0); }} onKeyDown={navigate} className={cn( 'w-full bg-transparent text-xs text-retro-text outline-none placeholder:text-retro-muted', s.font, )} />
)}
    {filtered.length === 0 ? (
  • No results.
  • ) : ( filtered.map((opt, idx) => { const selected = isSelected(opt.value); const disabled = opt.disabled || (!selected && capReached); const isActive = idx === highlighted; return (
  • setHighlighted(idx)} onMouseDown={(e) => e.preventDefault()} onClick={() => { if (disabled) return; toggle(opt.value); }} className={cn( 'flex cursor-pointer items-center gap-2 px-2 py-1.5 text-xs text-retro-text', s.font, s.radius, selected ? cn(toneMap.neutral.soft, 'text-retro-text') : 'text-retro-muted', isActive && !disabled && 'bg-retro-surface/60', disabled && 'opacity-50 cursor-not-allowed', !disabled && !isActive && 'hover:bg-retro-surface hover:text-retro-text', )} > {selected && ( )} {opt.icon && ( {opt.icon} )} {opt.label}
  • ); }) )}
{typeof max === 'number' && (
{value.length}/{max} selected
)}
); }, ); PixelMultiSelect.displayName = 'PixelMultiSelect';