From ae5a58edd99c8140fe82787588de20861d0ac0bf Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 20 Jul 2026 11:02:10 -0400 Subject: [PATCH 1/5] feat(components): support multi-select ComboBox values Thread React Aria selection state through the wrapper and render selected collection items as inline removable tags. --- .changeset/bright-combobox-values.md | 5 + packages/components/src/ComboBox.tsx | 411 ++++++++++++++++-- packages/components/src/index.ts | 12 +- .../components/src/styles/ComboBox.module.css | 10 + .../components/src/styles/Group.module.css | 36 ++ 5 files changed, 448 insertions(+), 26 deletions(-) create mode 100644 .changeset/bright-combobox-values.md diff --git a/.changeset/bright-combobox-values.md b/.changeset/bright-combobox-values.md new file mode 100644 index 000000000..bfcbb73c9 --- /dev/null +++ b/.changeset/bright-combobox-values.md @@ -0,0 +1,5 @@ +--- +'@launchpad-ui/components': minor +--- + +Add multiple selection support to `ComboBox`, including `ComboBoxValue` and `ComboBoxTagGroup` for removable selected values. diff --git a/packages/components/src/ComboBox.tsx b/packages/components/src/ComboBox.tsx index 0f2e16f7e..7a614c248 100644 --- a/packages/components/src/ComboBox.tsx +++ b/packages/components/src/ComboBox.tsx @@ -1,38 +1,157 @@ -import type { CSSProperties, Ref } from 'react'; -import type { ComboBoxProps as AriaComboBoxProps } from 'react-aria-components/ComboBox'; +import type { Key } from '@react-types/shared'; +import type { CSSProperties, KeyboardEvent, ReactNode, Ref, RefObject } from 'react'; +import type { + ComboBoxProps as AriaComboBoxProps, + ComboBoxValueProps as AriaComboBoxValueProps, + ComboBoxState, +} from 'react-aria-components/ComboBox'; import type { ContextValue } from 'react-aria-components/slots'; import type { IconButtonProps } from './IconButton'; +import type { TagGroupProps, TagProps } from './TagGroup'; import { useResizeObserver } from '@react-aria/utils'; import { cva } from 'class-variance-authority'; -import { createContext, useCallback, useContext, useRef, useState } from 'react'; -import { ComboBox as AriaComboBox, ComboBoxStateContext } from 'react-aria-components/ComboBox'; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import { + ComboBox as AriaComboBox, + ComboBoxValue as AriaComboBoxValue, + ComboBoxStateContext, +} from 'react-aria-components/ComboBox'; import { composeRenderProps } from 'react-aria-components/composeRenderProps'; import { GroupContext } from 'react-aria-components/Group'; import { Provider } from 'react-aria-components/slots'; import { IconButton } from './IconButton'; +import { InputContext } from './Input'; import { PopoverContext } from './Popover'; import styles from './styles/ComboBox.module.css'; +import { Tag, TagGroup, TagList } from './TagGroup'; import { useLPContextProps } from './utils'; const comboBoxStyles = cva(styles.box); +const comboBoxValueStyles = cva(styles.value); +const comboBoxTagValueStyles = cva(styles.tagValue); +const comboBoxTagGroupStyles = cva(styles.tagGroup); +const comboBoxTagListStyles = cva(styles.tagList); -interface ComboBoxProps extends AriaComboBoxProps { +type ComboBoxSelectionMode = 'single' | 'multiple'; + +interface ComboBoxProps + extends AriaComboBoxProps { ref?: Ref; } interface ComboBoxClearButtonProps extends Partial {} -// biome-ignore lint/suspicious/noExplicitAny: ignore -const ComboBoxContext = createContext, HTMLDivElement>>(null); +interface ComboBoxValueProps extends AriaComboBoxValueProps { + ref?: Ref; +} + +interface ComboBoxTagItem { + id: Key; + textValue: string; + value: T | null; +} + +type AccessibleName = + | { + 'aria-label': string; + 'aria-labelledby'?: string; + } + | { + 'aria-label'?: string; + 'aria-labelledby': string; + }; + +type ComboBoxTagGroupProps = Omit< + TagGroupProps, + 'aria-label' | 'aria-labelledby' | 'children' | 'onRemove' +> & + AccessibleName & { + children?: (item: ComboBoxTagItem) => ReactNode; + size?: NonNullable; + variant?: NonNullable; + }; + +interface ComboBoxBehavior { + isDisabled: boolean; + isReadOnly: boolean; +} + +interface ComboBoxProvidersProps extends ComboBoxBehavior { + children: ReactNode; + groupRef: RefObject; + groupWidth: string | null; + isInvalid: boolean; +} + +const ComboBoxContext = + // biome-ignore lint/suspicious/noExplicitAny: ignore + createContext, HTMLDivElement>>(null); +const ComboBoxValueContext = + // biome-ignore lint/suspicious/noExplicitAny: ignore + createContext, HTMLDivElement>>(null); +const ComboBoxTagGroupContext = + // biome-ignore lint/suspicious/noExplicitAny: ignore + createContext, HTMLDivElement>>(null); +const ComboBoxBehaviorContext = createContext(null); + +const ComboBoxProviders = ({ + children, + groupRef, + groupWidth, + isDisabled, + isInvalid, + isReadOnly, +}: ComboBoxProvidersProps) => { + const state = useContext(ComboBoxStateContext); + const onInputKeyDown = useCallback( + (event: KeyboardEvent) => { + if ( + event.key === 'Backspace' && + event.currentTarget.value === '' && + !isDisabled && + !isReadOnly && + Array.isArray(state?.value) && + state.value.length > 0 + ) { + event.preventDefault(); + state.setValue(state.value.slice(0, -1)); + } + }, + [isDisabled, isReadOnly, state], + ); + + return ( + + + {children} + + + ); +}; /** * A combo box combines a text input with a listbox, allowing users to filter a list of options to items matching a query. * * https://react-spectrum.adobe.com/react-aria/ComboBox.html */ -const ComboBox = ({ ref, ...props }: ComboBoxProps) => { +const ComboBox = ({ + ref, + ...props +}: ComboBoxProps) => { [props, ref] = useLPContextProps(props, ref, ComboBoxContext); const { menuTrigger = 'focus' } = props; const groupRef = useRef(null); @@ -59,21 +178,16 @@ const ComboBox = ({ ref, ...props }: ComboBoxProps) => { comboBoxStyles({ ...renderProps, className }), )} > - {composeRenderProps(props.children, (children, { isInvalid, isDisabled }) => ( - ( + {children} - + ))} ); @@ -81,19 +195,266 @@ const ComboBox = ({ ref, ...props }: ComboBoxProps) => { const ComboBoxClearButton = ({ ref, ...props }: ComboBoxClearButtonProps) => { const state = useContext(ComboBoxStateContext); + const { onPress, ...buttonProps } = props; return ( state?.setSelectedKey(null)} + onPress={(event) => { + state?.setValue(Array.isArray(state.value) ? [] : null); + state?.setInputValue(''); + onPress?.(event); + }} /> ); }; -export { ComboBox, ComboBoxClearButton, ComboBoxContext, comboBoxStyles }; -export type { ComboBoxProps, ComboBoxClearButtonProps }; +/** + * Renders the current value of a ComboBox, or a placeholder when it has no value. + * + * https://react-spectrum.adobe.com/react-aria/ComboBox.html + */ +const ComboBoxValue = ({ ref, ...props }: ComboBoxValueProps) => { + [props, ref] = useLPContextProps(props, ref, ComboBoxValueContext); + return ( + + comboBoxValueStyles({ ...renderProps, className }), + )} + /> + ); +}; + +const toTagItem = (node: { + key: Key; + textValue: string; + value: T | null; +}): ComboBoxTagItem => ({ + id: node.key, + textValue: node.textValue, + value: node.value, +}); + +interface ComboBoxTagGroupContentProps { + behavior: ComboBoxBehavior; + props: ComboBoxTagGroupProps; + state: ComboBoxState; + tagGroupRef?: Ref; +} + +const ComboBoxTagGroupContent = ({ + behavior, + props, + state, + tagGroupRef, +}: ComboBoxTagGroupContentProps) => { + const cacheRef = useRef(new Map>()); + const previousCollectionRef = useRef(new Map>()); + const warnedKeysRef = useRef(new Set()); + const warnedUsageRef = useRef(false); + const selectedKeys: Key[] = Array.isArray(state.value) ? state.value : []; + const selectedItems = new Map>(); + const collectionSnapshot = new Map>(); + + for (const node of state.selectedItems) { + selectedItems.set(node.key, toTagItem(node)); + } + for (const node of state.collection) { + collectionSnapshot.set(node.key, toTagItem(node)); + } + + const descriptors = selectedKeys.flatMap((key) => { + const descriptor = + selectedItems.get(key) ?? cacheRef.current.get(key) ?? previousCollectionRef.current.get(key); + return descriptor ? [descriptor] : []; + }); + const unresolvedKeys = selectedKeys.filter( + (key) => + !selectedItems.has(key) && + !cacheRef.current.has(key) && + !previousCollectionRef.current.has(key), + ); + + useEffect(() => { + const selectedKeySet = new Set(selectedKeys); + for (const key of cacheRef.current.keys()) { + if (!selectedKeySet.has(key)) { + cacheRef.current.delete(key); + } + } + for (const key of selectedKeys) { + const descriptor = selectedItems.get(key) ?? previousCollectionRef.current.get(key); + if (descriptor) { + cacheRef.current.set(key, descriptor); + } + } + previousCollectionRef.current = collectionSnapshot; + }); + + useEffect(() => { + if (process.env.NODE_ENV === 'production') { + return; + } + + if (!Array.isArray(state.value)) { + if (!warnedUsageRef.current) { + console.warn( + 'ComboBoxTagGroup must be rendered inside a LaunchPad ComboBox with selectionMode="multiple".', + ); + warnedUsageRef.current = true; + } + return; + } + + warnedUsageRef.current = false; + const selectedKeySet = new Set(selectedKeys); + for (const key of warnedKeysRef.current) { + if (!selectedKeySet.has(key) || !unresolvedKeys.includes(key)) { + warnedKeysRef.current.delete(key); + } + } + + const timeout = setTimeout(() => { + for (const key of unresolvedKeys) { + if (!warnedKeysRef.current.has(key)) { + console.warn( + `ComboBoxTagGroup cannot render selected key "${String( + key, + )}" until it appears in the ComboBox collection.`, + ); + warnedKeysRef.current.add(key); + } + } + }, 0); + return () => clearTimeout(timeout); + }); + + if (!Array.isArray(state.value)) { + return null; + } + + const { + children, + size = 'small', + variant = 'default', + className, + disabledKeys: consumerDisabledKeys, + ...tagGroupProps + } = props; + const disabledKeys = new Set(consumerDisabledKeys); + if (behavior.isDisabled) { + for (const descriptor of descriptors) { + disabledKeys.add(descriptor.id); + } + } + const currentValue = state.value; + const onRemove = + behavior.isDisabled || behavior.isReadOnly + ? undefined + : (keys: Set) => { + if (Array.isArray(currentValue)) { + state.setValue(currentValue.filter((key) => !keys.has(key))); + } + }; + + return ( +
0 || undefined} + > + + + {descriptors.map((descriptor) => ( + + {children ? children(descriptor) : descriptor.textValue} + + ))} + + +
+ ); +}; + +/** + * Renders a multiple-selection ComboBox value as removable tags. + * + * Place this inside the ComboBox field before its Input. Each child renders tag content from the + * selected collection item; ComboBoxTagGroup owns the Tag itself so keys and accessible text stay + * synchronized with ComboBox state. + * + * https://react-spectrum.adobe.com/react-aria/ComboBox.html#taggroup + */ +const ComboBoxTagGroup = ({ ref, ...props }: ComboBoxTagGroupProps) => { + [props, ref] = useLPContextProps(props, ref, ComboBoxTagGroupContext); + const behavior = useContext(ComboBoxBehaviorContext); + const warnedUsageRef = useRef(false); + + useEffect(() => { + if (process.env.NODE_ENV === 'production' || behavior) { + return; + } + if (!warnedUsageRef.current) { + console.warn( + 'ComboBoxTagGroup must be rendered inside a LaunchPad ComboBox with selectionMode="multiple".', + ); + warnedUsageRef.current = true; + } + }); + + if (!behavior) { + return null; + } + + return ( + > + {({ state }) => ( + + )} + + ); +}; + +export { + ComboBox, + ComboBoxClearButton, + ComboBoxContext, + ComboBoxTagGroup, + ComboBoxTagGroupContext, + ComboBoxValue, + ComboBoxValueContext, + comboBoxStyles, + comboBoxValueStyles, +}; +export type { + ComboBoxProps, + ComboBoxClearButtonProps, + ComboBoxTagGroupProps, + ComboBoxTagItem, + ComboBoxValueProps, +}; diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 3a138314d..76476cbe6 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -19,7 +19,12 @@ export type { export type { CheckboxProps } from './Checkbox'; export type { CheckboxGroupProps } from './CheckboxGroup'; export type { CodeProps } from './Code'; -export type { ComboBoxProps } from './ComboBox'; +export type { + ComboBoxProps, + ComboBoxTagGroupProps, + ComboBoxTagItem, + ComboBoxValueProps, +} from './ComboBox'; export type { DateFieldProps, DateInputProps, @@ -151,7 +156,12 @@ export { ComboBox, ComboBoxClearButton, ComboBoxContext, + ComboBoxTagGroup, + ComboBoxTagGroupContext, + ComboBoxValue, + ComboBoxValueContext, comboBoxStyles, + comboBoxValueStyles, } from './ComboBox'; export { DateField, diff --git a/packages/components/src/styles/ComboBox.module.css b/packages/components/src/styles/ComboBox.module.css index f0cc150a2..c806e9799 100644 --- a/packages/components/src/styles/ComboBox.module.css +++ b/packages/components/src/styles/ComboBox.module.css @@ -10,3 +10,13 @@ width: 100%; } } + +.value, +.tagValue { + display: contents; +} + +.tagGroup.tagGroup, +.tagList.tagList { + display: contents; +} diff --git a/packages/components/src/styles/Group.module.css b/packages/components/src/styles/Group.module.css index e2ced6e42..1b0f7bc7d 100644 --- a/packages/components/src/styles/Group.module.css +++ b/packages/components/src/styles/Group.module.css @@ -13,6 +13,42 @@ padding-block: 3px; } + &:has([data-combobox-tags]) { + position: relative; + flex-wrap: wrap; + row-gap: var(--lp-spacing-200); + + & input[data-rac] { + min-width: var(--lp-size-40); + } + + &:has(> button[data-rac]) { + padding-inline-end: calc(var(--lp-size-24) + var(--lp-spacing-200)); + } + + &:has(> button[data-rac]:nth-of-type(2)) { + padding-inline-end: calc(var(--lp-size-48) + var(--lp-spacing-200)); + } + + & > button[data-rac]:last-of-type { + position: absolute; + inset-inline-end: var(--lp-spacing-200); + top: 50%; + transform: translateY(-50%); + } + + & > button[data-rac]:nth-last-of-type(2) { + position: absolute; + inset-inline-end: calc(var(--lp-size-24) + var(--lp-spacing-200)); + top: 50%; + transform: translateY(-50%); + } + + &:has([data-combobox-tags][data-has-tags]) input::placeholder { + color: transparent; + } + } + &[data-focus-within] { outline: 2px solid var(--lp-color-shadow-interactive-focus); outline-offset: -2px; From 37c3c3d53501993d14881cfdd48b2fdd025f44fd Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 20 Jul 2026 11:02:25 -0400 Subject: [PATCH 2/5] test(components): cover multi-select ComboBox behavior Pin controlled and uncontrolled selection, async item caching, removal, and mode-specific types. --- .../components/__tests__/ComboBox.spec.tsx | 358 +++++++++++++++++- 1 file changed, 356 insertions(+), 2 deletions(-) diff --git a/packages/components/__tests__/ComboBox.spec.tsx b/packages/components/__tests__/ComboBox.spec.tsx index 2b1334f2a..285fe8168 100644 --- a/packages/components/__tests__/ComboBox.spec.tsx +++ b/packages/components/__tests__/ComboBox.spec.tsx @@ -1,9 +1,15 @@ -import { describe, expect, it } from 'vitest'; +import type { Key } from '@react-types/shared'; -import { render, screen, userEvent } from '../../../test/utils'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; + +import { render, screen, userEvent, waitFor, within } from '../../../test/utils'; import { ComboBox, ComboBoxClearButton, + type ComboBoxProps, + ComboBoxTagGroup, + type ComboBoxTagGroupProps, + ComboBoxValue, Group, IconButton, Input, @@ -13,6 +19,59 @@ import { Popover, } from '../src'; +interface Item { + code: string; + name: string; +} + +const items: Item[] = [ + { code: 'CA', name: 'California' }, + { code: 'NY', name: 'New York' }, + { code: 'TX', name: 'Texas' }, +]; + +const MultipleComboBox = ({ + defaultValue = [], + isDisabled, + isReadOnly, +}: { + defaultValue?: Key[]; + isDisabled?: boolean; + isReadOnly?: boolean; +}) => ( + + + + aria-label="Selected states"> + {({ value, textValue }) => value?.name ?? textValue} + + + + + + + + {(item) => ( + + {item.name} + + )} + + + +); + describe('ComboBox', () => { it('renders', async () => { const user = userEvent.setup(); @@ -66,4 +125,299 @@ describe('ComboBox', () => { await user.click(screen.getByRole('button')); expect(await screen.findByRole('combobox')).toHaveValue(''); }); + + it('selects multiple options and keeps the popover open', async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: /^Show suggestions/ })); + const california = await screen.findByRole('option', { name: 'California' }); + await user.click(california); + await user.click(screen.getByRole('option', { name: 'New York' })); + + const tags = document.querySelector('[data-combobox-tags]'); + expect(tags).toHaveTextContent('California'); + expect(tags).toHaveTextContent('New York'); + expect(california).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('option', { name: 'New York' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(screen.getByRole('listbox')).toBeVisible(); + }); + + it('removes a selected tag and clears an uncontrolled filter', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'States' }); + await user.type(input, 'Tex'); + const tags = document.querySelector('[data-combobox-tags]') as HTMLElement; + await user.click(within(tags).getAllByRole('button', { name: /^Remove/, hidden: true })[0]); + + expect(within(tags).queryByText('California')).not.toBeInTheDocument(); + expect(within(tags).getByText('New York')).toBeVisible(); + expect(input).toHaveValue(''); + }); + + it('removes the last selected tag with Backspace from an empty input', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'States' }); + const tags = document.querySelector('[data-combobox-tags]') as HTMLElement; + await user.click(input); + await user.keyboard('{Backspace}'); + + expect(tags).toHaveTextContent('California'); + expect(tags).not.toHaveTextContent('New York'); + expect(input).toHaveFocus(); + + await user.type(input, 'T'); + await user.keyboard('{Backspace}'); + expect(tags).toHaveTextContent('California'); + + await user.keyboard('{Backspace}'); + expect(tags).not.toHaveTextContent('California'); + expect(tags).not.toHaveAttribute('data-has-tags'); + }); + + it('clears every selected tag and the input', async () => { + const user = userEvent.setup(); + render(); + + const input = screen.getByRole('combobox', { name: 'States' }); + await user.type(input, 'Tex'); + const value = document.querySelector('[data-combobox-tags]'); + expect(value).toHaveAttribute('data-has-tags', 'true'); + + await user.click(document.querySelector('button[aria-label="Clear"]') as HTMLButtonElement); + + expect(screen.queryByRole('button', { name: /^Remove/ })).not.toBeInTheDocument(); + expect(input).toHaveValue(''); + expect(value).not.toHaveAttribute('data-has-tags'); + }); + + it('emits multiple controlled values', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + + + + aria-label="Selected states"> + {({ value, textValue }) => value?.name ?? textValue} + + + + + + + {(item) => ( + + {item.name} + + )} + + + , + ); + + await user.click(screen.getByRole('button', { name: /^Show suggestions/ })); + await user.click(await screen.findByRole('option', { name: 'Texas' })); + expect(onChange).toHaveBeenCalledWith(['CA', 'TX']); + }); + + it('clears controlled selection and input and composes onPress', async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const onInputChange = vi.fn(); + const onPress = vi.fn(); + render( + + + + aria-label="Selected states"> + {({ value, textValue }) => value?.name ?? textValue} + + + + + + {(item) => ( + + {item.name} + + )} + + , + ); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + + expect(onChange).toHaveBeenCalledWith([]); + expect(onInputChange).toHaveBeenCalledWith(''); + expect(onPress).toHaveBeenCalledOnce(); + }); + + it('keeps a cached chip when controlled items omit its selected item', () => { + const ControlledItems = ({ availableItems }: { availableItems: Item[] }) => ( + + + + aria-label="Selected states"> + {({ value, textValue }) => value?.name ?? textValue} + + + + + + {(item) => ( + + {item.name} + + )} + + + + ); + const { rerender } = render(); + + rerender(); + + expect(screen.getByText('California')).toBeVisible(); + const value = document.querySelector('[data-combobox-tags]'); + expect(value).toHaveAttribute('data-has-tags', 'true'); + expect(value?.parentElement).toHaveAttribute('data-placeholder', 'true'); + }); + + it('warns for an unknown selected key and renders it when its item arrives', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const ControlledItems = ({ availableItems }: { availableItems: Item[] }) => ( + + + + aria-label="Selected states"> + {({ value, textValue }) => value?.name ?? textValue} + + + + + {(item) => ( + + {item.name} + + )} + + + ); + const { rerender } = render(); + + await waitFor(() => + expect(warning).toHaveBeenCalledWith(expect.stringContaining('selected key "CA"')), + ); + expect(screen.queryByText('California')).not.toBeInTheDocument(); + + rerender(); + expect(screen.getByText('California')).toBeVisible(); + warning.mockRestore(); + }); + + it('uses collection node keys and text values with the default renderer', () => { + render( + + + + + + + + Alpha + Beta + Gamma + + , + ); + + const tags = screen.getByRole('grid', { name: 'Selected letters' }); + expect(within(tags).getByText('Alpha')).toBeVisible(); + expect(within(tags).getByText('Gamma')).toBeVisible(); + }); + + it.each([ + ['disabled', { isDisabled: true }], + ['read only', { isReadOnly: true }], + ] as const)('does not allow removal when %s', (_, stateProps) => { + render(); + + const tags = screen.getByRole('grid', { name: 'Selected states' }); + expect(within(tags).queryByRole('button', { name: /^Remove/ })).not.toBeInTheDocument(); + }); + + it('renders ComboBoxTagGroup only in multiple mode', () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + + aria-label="Selected states" /> + + + + {(item) => ( + + {item.name} + + )} + + , + ); + + expect(screen.queryByRole('grid', { name: 'Selected states' })).not.toBeInTheDocument(); + expect(warning).toHaveBeenCalledWith(expect.stringContaining('selectionMode="multiple"')); + warning.mockRestore(); + }); + + it('renders a custom ComboBoxValue', () => { + render( + + + + + + >{({ selectedText }) => `Selected: ${selectedText}`} + + {(item) => ( + + {item.name} + + )} + + , + ); + + expect(screen.getByText('Selected: California')).toBeVisible(); + }); + + it('preserves mode-specific value types and requires a tag-group accessible name', () => { + type SingleChange = NonNullable['onChange']>; + type MultipleChange = NonNullable['onChange']>; + + expectTypeOf[0]>().toEqualTypeOf(); + expectTypeOf[0]>().toEqualTypeOf(); + expectTypeOf>().not.toMatchTypeOf>(); + expectTypeOf<{ 'aria-label': string }>().toMatchTypeOf>(); + }); }); From 0db7efb5300b5f0cf78b886b1528e37f17886e17 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 20 Jul 2026 11:07:26 -0400 Subject: [PATCH 3/5] docs(components): demonstrate multi-select ComboBox values Add interactive stories for inline tags, controlled item filtering, custom renderers, and field states. --- .../components/stories/ComboBox.stories.tsx | 185 +++++++++++++++++- 1 file changed, 182 insertions(+), 3 deletions(-) diff --git a/packages/components/stories/ComboBox.stories.tsx b/packages/components/stories/ComboBox.stories.tsx index 421741b80..cf0d5bffd 100644 --- a/packages/components/stories/ComboBox.stories.tsx +++ b/packages/components/stories/ComboBox.stories.tsx @@ -1,11 +1,13 @@ +import type { Key } from '@react-types/shared'; import type { Meta, StoryObj } from '@storybook/react-vite'; import type { ComponentType } from 'react'; import { Icon } from '@launchpad-ui/icons'; import { vars } from '@launchpad-ui/vars'; +import { useState } from 'react'; import { expect, userEvent, within } from 'storybook/test'; -import { ComboBox, ComboBoxClearButton } from '../src/ComboBox'; +import { ComboBox, ComboBoxClearButton, ComboBoxTagGroup, ComboBoxValue } from '../src/ComboBox'; import { Group } from '../src/Group'; import { IconButton } from '../src/IconButton'; import { Input } from '../src/Input'; @@ -16,7 +18,11 @@ import { Text } from '../src/Text'; const meta: Meta = { component: ComboBox, - subcomponents: { ComboBoxClearButton } as Record>, + subcomponents: { + ComboBoxClearButton, + ComboBoxTagGroup, + ComboBoxValue, + } as Record>, title: 'Components/Pickers/ComboBox', decorators: [ (Story) => ( @@ -35,7 +41,7 @@ const open = { play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole('button')); + await userEvent.click(canvas.getByRole('button', { name: /^Show suggestions/ })); const body = canvasElement.ownerDocument.body; await expect(await within(body).findByRole('listbox')); }, @@ -181,3 +187,176 @@ export const States: Story = { }, }, }; + +interface StateItem { + id: string; + name: string; +} + +const states: StateItem[] = [ + { id: 'CA', name: 'California' }, + { id: 'CO', name: 'Colorado' }, + { id: 'FL', name: 'Florida' }, + { id: 'NY', name: 'New York' }, + { id: 'TX', name: 'Texas' }, + { id: 'WA', name: 'Washington' }, +]; + +const MultipleField = ({ placeholder = 'Filter states' }: { placeholder?: string }) => ( + + aria-label="Selected states"> + {({ value, textValue }) => value?.name ?? textValue} + + + + + +); + +export const MultipleSelection: Story = { + render: () => ( + + + + + + {(item) => {item.name}} + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox', { name: 'States' }); + await userEvent.type(input, 'Tex'); + + const body = canvasElement.ownerDocument.body; + await userEvent.click(await within(body).findByRole('option', { name: 'Texas' })); + + await expect(input).toHaveValue(''); + await expect(canvas.getByText('Texas')).toBeVisible(); + }, +}; + +export const MultipleSelectionControlledItems: Story = { + render: () => { + const [value, setValue] = useState(['CA', 'NY']); + const [inputValue, setInputValue] = useState(''); + const filteredStates = states.filter((item) => + item.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + { + setValue(nextValue); + setInputValue(''); + }} + inputValue={inputValue} + onInputChange={setInputValue} + items={filteredStates} + > + + + + + {(item) => {item.name}} + + + + ); + }, +}; + +export const MultipleSelectionDefaultRenderer: Story = { + render: () => ( + + + + + + + + + + Alpha + Beta + Gamma + + + + ), +}; + +const MultipleStateExample = ({ + label, + isDisabled, + isInvalid, + isReadOnly, +}: { + label: string; + isDisabled?: boolean; + isInvalid?: boolean; + isReadOnly?: boolean; +}) => ( + + + + + + {(item) => {item.name}} + + + +); + +export const MultipleSelectionStates: Story = { + render: () => ( +
+ + + +
+ ), +}; + +export const CustomValue: Story = { + render: () => ( + + + + + + + + {({ selectedText }) => `Current selection: ${selectedText || 'None'}`} + + + + Item one + Item two + Item three + + + + ), +}; From d63823ef4459afa266e0f918175ab01a28c95c07 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 20 Jul 2026 12:00:03 -0400 Subject: [PATCH 4/5] fix(components): align multi-select ComboBox spacing Use the same spacing between rows and columns, and keep the clear action out of examples. --- packages/components/src/styles/Group.module.css | 1 + packages/components/stories/ComboBox.stories.tsx | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/components/src/styles/Group.module.css b/packages/components/src/styles/Group.module.css index 1b0f7bc7d..add9e7d88 100644 --- a/packages/components/src/styles/Group.module.css +++ b/packages/components/src/styles/Group.module.css @@ -16,6 +16,7 @@ &:has([data-combobox-tags]) { position: relative; flex-wrap: wrap; + column-gap: var(--lp-spacing-200); row-gap: var(--lp-spacing-200); & input[data-rac] { diff --git a/packages/components/stories/ComboBox.stories.tsx b/packages/components/stories/ComboBox.stories.tsx index cf0d5bffd..53775dc09 100644 --- a/packages/components/stories/ComboBox.stories.tsx +++ b/packages/components/stories/ComboBox.stories.tsx @@ -208,7 +208,6 @@ const MultipleField = ({ placeholder = 'Filter states' }: { placeholder?: string {({ value, textValue }) => value?.name ?? textValue} - ); From 03dcbe31098d902baaa033ffd2dc230d137f4e24 Mon Sep 17 00:00:00 2001 From: Alexis Georges Date: Mon, 20 Jul 2026 12:12:22 -0400 Subject: [PATCH 5/5] docs(components): add multi-select custom value example Show how ComboBoxTagGroup can render custom content for multiple inline selected values. Co-authored-by: Cursor --- .../components/stories/ComboBox.stories.tsx | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/components/stories/ComboBox.stories.tsx b/packages/components/stories/ComboBox.stories.tsx index 53775dc09..644305d31 100644 --- a/packages/components/stories/ComboBox.stories.tsx +++ b/packages/components/stories/ComboBox.stories.tsx @@ -333,6 +333,39 @@ export const MultipleSelectionStates: Story = { ), }; +export const MultipleSelectionCustomValue: Story = { + render: () => ( + + + + aria-label="Selected states" /> + + + + + + {(item) => {item.name}} + + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const input = canvas.getByRole('combobox', { name: 'States with custom value' }); + await userEvent.type(input, 'Atlantis'); + await userEvent.tab(); + + await expect(input).toHaveValue('Atlantis'); + await expect(canvas.getByText('California')).toBeVisible(); + await expect(canvas.getByText('New York')).toBeVisible(); + }, +}; + export const CustomValue: Story = { render: () => (