diff --git a/services/platform/app/components/ui/forms/multi-select.stories.tsx b/services/platform/app/components/ui/forms/multi-select.stories.tsx new file mode 100644 index 000000000..3574170f4 --- /dev/null +++ b/services/platform/app/components/ui/forms/multi-select.stories.tsx @@ -0,0 +1,156 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { fn } from 'storybook/test'; + +import { MultiSelect } from './multi-select'; + +const sampleOptions = [ + { value: 'apple', label: 'Apple' }, + { value: 'banana', label: 'Banana' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'date', label: 'Date' }, + { value: 'elderberry', label: 'Elderberry' }, +]; + +const optionsWithDescriptions = [ + { + value: 'sales', + label: 'Sales', + description: 'Customer-facing revenue team', + }, + { + value: 'support', + label: 'Support', + description: 'Handles inbound tickets', + }, + { + value: 'ops', + label: 'Operations', + description: 'Internal logistics and tooling', + }, +]; + +const manyOptions = Array.from({ length: 1000 }, (_, i) => ({ + value: `member-${i + 1}`, + label: `Member ${i + 1}`, +})); + +const meta: Meta = { + title: 'Forms/MultiSelect', + component: MultiSelect, + tags: ['autodocs'], + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +A searchable, scrollable multi-select built on Radix UI Popover. Selected +values render as removable chips in the trigger; the popover lists checkbox +rows that toggle without closing, and large lists (~1000) stay usable via +search + scroll. + +## Usage +\`\`\`tsx +import { MultiSelect } from './multi-select'; + + +\`\`\` + +## Keyboard navigation +- **Arrow Down / Up**: Move the highlight +- **Enter**: Toggle the highlighted option (popover stays open) +- **Home / End**: Jump to first / last option +- **Escape**: Close the popover + +## Accessibility +- Search input has \`role="combobox"\` with \`aria-activedescendant\` +- Options container has \`role="listbox"\` with \`aria-multiselectable\` +- Each option has \`role="option"\` with \`aria-selected\` + `, + }, + }, + }, + argTypes: { + searchPlaceholder: { control: 'text' }, + emptyText: { control: 'text' }, + searchable: { control: 'boolean' }, + align: { + control: 'select', + options: ['start', 'center', 'end'], + }, + }, + args: { + onValueChange: fn(), + searchPlaceholder: 'Search...', + emptyText: 'No results found', + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + options: sampleOptions, + placeholder: 'Select fruit...', + }, + render: function Render(args) { + const [value, setValue] = useState([]); + return ; + }, +}; + +export const WithDescriptions: Story = { + args: { + options: optionsWithDescriptions, + placeholder: 'Select teams...', + searchPlaceholder: 'Search teams...', + label: 'Teams', + }, + render: function Render(args) { + const [value, setValue] = useState(['sales']); + return ; + }, +}; + +export const LargeList: Story = { + args: { + options: manyOptions, + placeholder: 'Add members...', + searchPlaceholder: 'Search 1000 members...', + label: 'Members', + }, + render: function Render(args) { + const [value, setValue] = useState(['member-1', 'member-2', 'member-3']); + return ; + }, +}; + +export const WithoutSearch: Story = { + args: { + options: sampleOptions, + placeholder: 'Select fruit...', + searchable: false, + }, + render: function Render(args) { + const [value, setValue] = useState([]); + return ; + }, +}; diff --git a/services/platform/app/components/ui/forms/multi-select.test.tsx b/services/platform/app/components/ui/forms/multi-select.test.tsx new file mode 100644 index 000000000..e0042dd00 --- /dev/null +++ b/services/platform/app/components/ui/forms/multi-select.test.tsx @@ -0,0 +1,266 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { render, screen } from '@/tests/utils/render'; + +import { MultiSelect, type MultiSelectOption } from './multi-select'; + +const options: MultiSelectOption[] = [ + { value: 'apple', label: 'Apple', description: 'A red fruit' }, + { value: 'banana', label: 'Banana', description: 'A yellow fruit' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'disabled-opt', label: 'Disabled', disabled: true }, +]; + +function renderSelect( + overrides: Partial> = {}, +) { + const onValueChange = vi.fn(); + const result = render( + Open select} + searchPlaceholder="Search..." + emptyText="No results" + aria-label="Test listbox" + {...overrides} + />, + ); + return { ...result, onValueChange }; +} + +describe('MultiSelect', () => { + describe('rendering', () => { + it('renders the trigger', () => { + renderSelect(); + expect(screen.getByText('Open select')).toBeInTheDocument(); + }); + + it('does not render the listbox when closed', () => { + renderSelect(); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('renders all options when open', async () => { + const { user } = renderSelect(); + await user.click(screen.getByText('Open select')); + expect(screen.getAllByRole('option')).toHaveLength(4); + }); + + it('marks the listbox as multi-selectable', async () => { + const { user } = renderSelect(); + await user.click(screen.getByText('Open select')); + expect(screen.getByRole('listbox')).toHaveAttribute( + 'aria-multiselectable', + 'true', + ); + }); + + it('renders option descriptions', async () => { + const { user } = renderSelect(); + await user.click(screen.getByText('Open select')); + expect(screen.getByText('A red fruit')).toBeInTheDocument(); + }); + + it('marks selected options with aria-selected', async () => { + const { user } = renderSelect({ value: ['apple'] }); + await user.click(screen.getByText('Open select')); + const appleOption = screen.getByRole('option', { name: /Apple/i }); + expect(appleOption.getAttribute('aria-selected')).toBe('true'); + const cherryOption = screen.getByRole('option', { name: /Cherry/i }); + expect(cherryOption.getAttribute('aria-selected')).toBe('false'); + }); + + it('renders empty state when no matches', async () => { + const { user } = renderSelect(); + await user.click(screen.getByText('Open select')); + await user.type(screen.getByRole('combobox'), 'zzzzz'); + expect(screen.getByText('No results')).toBeInTheDocument(); + }); + + it('renders footer when provided', async () => { + const { user } = renderSelect({ + footer: , + }); + await user.click(screen.getByText('Open select')); + expect(screen.getByText('Add item')).toBeInTheDocument(); + }); + + it('hides the search input when searchable is false', async () => { + const { user } = renderSelect({ searchable: false }); + await user.click(screen.getByText('Open select')); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + }); + }); + + describe('default trigger', () => { + function renderDefault( + overrides: Partial> = {}, + ) { + const onValueChange = vi.fn(); + const result = render( + , + ); + return { ...result, onValueChange }; + } + + it('shows the placeholder when nothing selected', () => { + renderDefault(); + expect(screen.getByText('Pick fruit')).toBeInTheDocument(); + }); + + it('renders selected values as chips', () => { + renderDefault({ value: ['apple', 'banana'] }); + expect(screen.getByText('Apple')).toBeInTheDocument(); + expect(screen.getByText('Banana')).toBeInTheDocument(); + expect(screen.queryByText('Pick fruit')).not.toBeInTheDocument(); + }); + + it('removes a value when its chip remove button is clicked', async () => { + const { user, onValueChange } = renderDefault({ + value: ['apple', 'banana'], + }); + await user.click(screen.getByRole('button', { name: /remove apple/i })); + expect(onValueChange).toHaveBeenCalledWith(['banana']); + }); + + it('uses removeChipLabel for the chip remove button', () => { + renderDefault({ + value: ['apple'], + removeChipLabel: (o) => `Unpick ${o.label}`, + }); + expect( + screen.getByRole('button', { name: 'Unpick Apple' }), + ).toBeInTheDocument(); + }); + + it('marks the default trigger disabled', () => { + renderDefault({ disabled: true }); + expect(screen.getByRole('combobox')).toHaveAttribute( + 'aria-disabled', + 'true', + ); + }); + + it('does not open on click when disabled', async () => { + const { user } = renderDefault({ disabled: true }); + await user.click(screen.getByRole('combobox')); + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + it('does not emit onValueChange when disabled and toggled', async () => { + const { user, onValueChange } = renderDefault({ + disabled: true, + open: true, + }); + // Even if the popover is forced open, a disabled select must not mutate + // its value when an option is clicked. + await user.click(screen.getByRole('option', { name: /Apple/i })); + expect(onValueChange).not.toHaveBeenCalled(); + }); + }); + + describe('interactions', () => { + it('opens on trigger click', async () => { + const { user } = renderSelect(); + await user.click(screen.getByText('Open select')); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('filters options by search query', async () => { + const { user } = renderSelect(); + await user.click(screen.getByText('Open select')); + await user.type(screen.getByRole('combobox'), 'ban'); + const opts = screen.getAllByRole('option'); + expect(opts).toHaveLength(1); + expect(opts[0]).toHaveTextContent('Banana'); + }); + + it('adds a value when toggling an unselected option', async () => { + const { user, onValueChange } = renderSelect({ value: ['apple'] }); + await user.click(screen.getByText('Open select')); + await user.click(screen.getByRole('option', { name: /Banana/i })); + expect(onValueChange).toHaveBeenCalledWith(['apple', 'banana']); + }); + + it('removes a value when toggling a selected option', async () => { + const { user, onValueChange } = renderSelect({ + value: ['apple', 'banana'], + }); + await user.click(screen.getByText('Open select')); + await user.click(screen.getByRole('option', { name: /Apple/i })); + expect(onValueChange).toHaveBeenCalledWith(['banana']); + }); + + it('keeps the popover open after toggling (multi-select)', async () => { + const { user } = renderSelect(); + await user.click(screen.getByText('Open select')); + await user.click(screen.getByRole('option', { name: /Apple/i })); + expect(screen.getByRole('listbox')).toBeInTheDocument(); + }); + + it('does not toggle disabled options', async () => { + const { user, onValueChange } = renderSelect(); + await user.click(screen.getByText('Open select')); + await user.click(screen.getByRole('option', { name: /Disabled/i })); + expect(onValueChange).not.toHaveBeenCalled(); + }); + + it('toggles the highlighted option with Enter', async () => { + const { user, onValueChange } = renderSelect(); + await user.click(screen.getByText('Open select')); + const input = screen.getByRole('combobox'); + await user.type(input, '{ArrowDown}{Enter}'); + expect(onValueChange).toHaveBeenCalledWith(['banana']); + }); + }); + + describe('keyboard navigation without a search input', () => { + it('focuses the listbox on open so it is keyboard-operable', async () => { + const { user } = renderSelect({ searchable: false }); + await user.click(screen.getByText('Open select')); + const listbox = screen.getByRole('listbox'); + expect(listbox).toHaveAttribute('tabindex', '0'); + expect(listbox).toHaveFocus(); + }); + + it('toggles the highlighted option with ArrowDown + Enter', async () => { + const { user, onValueChange } = renderSelect({ searchable: false }); + await user.click(screen.getByText('Open select')); + await user.keyboard('{ArrowDown}{Enter}'); + expect(onValueChange).toHaveBeenCalledWith(['banana']); + }); + + it('toggles the first/last option with Home/End', async () => { + const { user, onValueChange } = renderSelect({ searchable: false }); + await user.click(screen.getByText('Open select')); + await user.keyboard('{End}{Enter}'); + // Last option (index 3) is disabled, so End lands on the last *enabled* + // option, Cherry. + expect(onValueChange).toHaveBeenCalledWith(['cherry']); + onValueChange.mockClear(); + await user.keyboard('{Home}{Enter}'); + expect(onValueChange).toHaveBeenCalledWith(['apple']); + }); + + it('exposes the highlighted option via aria-activedescendant', async () => { + const { user } = renderSelect({ searchable: false }); + await user.click(screen.getByText('Open select')); + const listbox = screen.getByRole('listbox'); + const activeId = listbox.getAttribute('aria-activedescendant'); + expect(activeId).toBeTruthy(); + expect(screen.getByRole('option', { name: /Apple/i })).toHaveAttribute( + 'id', + activeId, + ); + }); + }); +}); diff --git a/services/platform/app/components/ui/forms/multi-select.tsx b/services/platform/app/components/ui/forms/multi-select.tsx new file mode 100644 index 000000000..bd2cbc1b2 --- /dev/null +++ b/services/platform/app/components/ui/forms/multi-select.tsx @@ -0,0 +1,566 @@ +'use client'; + +import * as PopoverPrimitive from '@radix-ui/react-popover'; +import { Description } from '@tale/ui/description'; +import { SkeletonBox } from '@tale/ui/skeleton'; +import { useSkeleton } from '@tale/ui/skeleton-context'; +import { Text } from '@tale/ui/text'; +import { ChevronDown, Search, X } from 'lucide-react'; +import { + type KeyboardEvent, + type ReactNode, + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react'; + +import { cn } from '@/lib/utils/cn'; + +import { Checkbox } from './checkbox'; +import { Label } from './label'; +import { selectTriggerClasses } from './select'; + +export interface MultiSelectOption { + value: string; + label: string; + /** + * Optional inline badge rendered right after the label (e.g. a category tag + * on a tool row). Wraps onto a second line when the label is long. + */ + labelBadge?: ReactNode; + description?: string; + disabled?: boolean; +} + +export interface MultiSelectProps { + /** The currently selected values. */ + value: ReadonlyArray; + /** Called with the COMPLETE next selection whenever an option is toggled. */ + onValueChange: (value: string[]) => void; + /** Array of options to display. */ + options: ReadonlyArray; + /** + * Custom trigger element. If omitted, a default chip-rendering combobox + * trigger is rendered using `label`, `placeholder`, `error`, etc. + */ + trigger?: ReactNode; + /** Label rendered above the default trigger. Ignored when `trigger` is provided. */ + label?: ReactNode; + /** + * Content shown on the default trigger when nothing is selected. A plain + * string renders as muted placeholder text; pass a node (e.g. a chip) to + * represent an implicit default such as "Organization-wide". + */ + placeholder?: ReactNode; + /** Marks the default trigger as invalid (adds error ring + aria-invalid). */ + error?: boolean; + /** Description text rendered below the default trigger. */ + description?: ReactNode; + /** Adds a required asterisk next to the default trigger's label. */ + required?: boolean; + /** Disables the default trigger. Ignored when `trigger` is provided. */ + disabled?: boolean; + /** Id for the default trigger (used for label association). */ + id?: string; + /** Additional className merged onto the default trigger. */ + triggerClassName?: string; + /** + * Show the search input. Defaults to `true`; the popover is always + * scrollable so large lists (~1000) stay usable via search + scroll. + */ + searchable?: boolean; + /** Placeholder text for the search input. */ + searchPlaceholder?: string; + /** Text to display when no options match the search. */ + emptyText?: string; + /** Optional footer content (e.g., an action button) rendered below the list. */ + footer?: ReactNode; + /** Controlled open state. */ + open?: boolean; + /** Called when open state changes. */ + onOpenChange?: (open: boolean) => void; + /** Popover alignment relative to trigger. @default 'start' */ + align?: 'start' | 'center' | 'end'; + /** Popover side. */ + side?: 'top' | 'right' | 'bottom' | 'left'; + /** Popover side offset in pixels. @default 4 */ + sideOffset?: number; + /** Additional className for the popover content. */ + contentClassName?: string; + /** Accessible label for the listbox. */ + 'aria-label'?: string; + /** Custom filter function; defaults to case-insensitive match on label + description. */ + filterFn?: (option: MultiSelectOption, query: string) => boolean; + /** + * Builds the accessible label for a selected chip's remove button, e.g. + * `(option) => t('removeX', { name: option.label })`. Falls back to the + * option label alone when omitted. + */ + removeChipLabel?: (option: MultiSelectOption) => string; + /** + * Render the popover as a Radix modal layer. Required when the select sits + * inside a modal Dialog: the dialog's scroll lock swallows wheel events over + * the (portaled) popover, so a long option list won't wheel-scroll. A modal + * popover registers its own scroll-lock shard, restoring scrolling. + * @default false + */ + modal?: boolean; +} + +const CONTENT_CLASSES = + 'z-50 min-w-[14.5rem] rounded-lg ring-1 ring-border bg-popover text-popover-foreground dark:bg-muted shadow-md outline-none p-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2'; + +function defaultFilterFn(option: MultiSelectOption, query: string) { + const lower = query.toLowerCase(); + return ( + option.label.toLowerCase().includes(lower) || + (option.description?.toLowerCase().includes(lower) ?? false) + ); +} + +function findNextEnabledIndex( + options: ReadonlyArray, + current: number, + direction: 1 | -1, +) { + const len = options.length; + if (len === 0) return -1; + let index = (current + direction + len) % len; + let iterations = 0; + while (options[index]?.disabled && iterations < len) { + index = (index + direction + len) % len; + iterations++; + } + return options[index]?.disabled ? -1 : index; +} + +// Plain control — the real default trigger (or caller-supplied `trigger`) + +// searchable popover (+ optional label/description). No skeleton logic. +function MultiSelectBase({ + value, + onValueChange, + options, + trigger, + label, + placeholder, + error, + description, + required, + disabled, + id: providedId, + triggerClassName, + searchable = true, + searchPlaceholder, + emptyText, + footer, + open: controlledOpen, + onOpenChange, + align = 'start', + side, + sideOffset = 4, + contentClassName, + 'aria-label': ariaLabel, + filterFn, + removeChipLabel, + modal = false, +}: MultiSelectProps) { + const instanceId = useId(); + const listboxId = `${instanceId}-listbox`; + const optionId = (index: number) => `${instanceId}-option-${index}`; + const triggerId = providedId ?? `${instanceId}-trigger`; + const descriptionId = `${instanceId}-description`; + + const valueSet = useMemo(() => new Set(value), [value]); + + // Preserve selection order so chips read in the order the user picked them. + const selectedOptions = useMemo( + () => + value + .map((v) => options.find((o) => o.value === v)) + .filter((o): o is MultiSelectOption => o !== undefined), + [value, options], + ); + + const isControlled = controlledOpen !== undefined; + const [internalOpen, setInternalOpen] = useState(false); + const isOpen = isControlled ? controlledOpen : internalOpen; + + const [search, setSearch] = useState(''); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const searchRef = useRef(null); + const listRef = useRef(null); + + const setIsOpen = useCallback( + (next: boolean) => { + if (!isControlled) setInternalOpen(next); + onOpenChange?.(next); + }, + [isControlled, onOpenChange], + ); + + const filter = filterFn ?? defaultFilterFn; + + const filteredOptions = useMemo(() => { + if (!search) return options; + return options.filter((o) => filter(o, search)); + }, [options, search, filter]); + + useEffect(() => { + setHighlightedIndex(0); + }, [search]); + + useEffect(() => { + if (!isOpen) return; + const el = listRef.current?.querySelector( + `[data-index="${highlightedIndex}"]`, + ); + el?.scrollIntoView({ block: 'nearest' }); + }, [highlightedIndex, isOpen]); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + // A disabled trigger must not open by any path (Radix's Trigger fires + // onOpenToggle on mouse click regardless of our div's aria-disabled). + if (disabled && nextOpen) return; + setIsOpen(nextOpen); + if (nextOpen) setHighlightedIndex(0); + if (!nextOpen) setSearch(''); + }, + [disabled, setIsOpen], + ); + + // Toggle keeps the popover open — multi-select picks several values per visit. + const handleToggle = useCallback( + (optionValue: string) => { + if (disabled) return; + if (valueSet.has(optionValue)) { + onValueChange(value.filter((v) => v !== optionValue)); + } else { + onValueChange([...value, optionValue]); + } + }, + [disabled, value, valueSet, onValueChange], + ); + + const handleListKeyDown = useCallback( + (e: KeyboardEvent) => { + const len = filteredOptions.length; + if (len === 0) return; + + switch (e.key) { + case 'ArrowDown': { + e.preventDefault(); + const next = findNextEnabledIndex( + filteredOptions, + highlightedIndex, + 1, + ); + if (next >= 0) setHighlightedIndex(next); + break; + } + case 'ArrowUp': { + e.preventDefault(); + const prev = findNextEnabledIndex( + filteredOptions, + highlightedIndex, + -1, + ); + if (prev >= 0) setHighlightedIndex(prev); + break; + } + case 'Enter': { + e.preventDefault(); + const option = filteredOptions[highlightedIndex]; + if (option && !option.disabled) handleToggle(option.value); + break; + } + case 'Home': { + e.preventDefault(); + const first = findNextEnabledIndex(filteredOptions, -1, 1); + if (first >= 0) setHighlightedIndex(first); + break; + } + case 'End': { + e.preventDefault(); + const last = findNextEnabledIndex(filteredOptions, len, -1); + if (last >= 0) setHighlightedIndex(last); + break; + } + } + }, + [filteredOptions, highlightedIndex, handleToggle], + ); + + // The chip remove buttons are nested inside the trigger, so the trigger must + // be a
(not a + )} + + )) + )} +
+