ComboboxMultiSelect

ComboboxMultiSelect allows users to choose multiple options from a predefined list. When the input is focused, a popover appears below the input, which displays a list of all available options. Users can filter the options based on string match, by typing into the input element. Each selected option is presented as a Tag, which includes a dismiss button to remove that selection. selected.

The options must be known as the components are rendered, which means this component is not suitable for server-side searches.

Available from eds-core/1.20.0

Quick Start

Installation
npm install @adaptavant/eds-core
Import
import { ComboboxMultiSelect } from '@adaptavant/eds-core';

Open Menu

By default, the menu opens when the user types in the trigger input. Also, using menuTrigger="focus" prop allows you to open the menu when the input is focused.

const initialOptions = [
	{
		id: "opiasri",
		name: "Oscar Piastri",
	},
	{
		id: "dricciardo",
		name: "Daniel Ricciardo",
	},
	{
		id: "kchandhok",
		name: "Karun Chandhok",
	},
	{
		id: "jdaruvala",
		name: "Jehan Daruvala",
	},
	{
		id: "rkubia",
		name: "Robert Kubica",
	},
];

const selectionReducer = (state, action) => {
	switch (action.type) {
		case "ADD":
			return [...state, action.payload];
		case "REMOVE":
			return state.filter((item) => item.id !== action.payload.id);
		case 'DESELECT_ALL':
			return [];
		case 'DROP_LAST':
			if(state.length === 0) return state;
			return state.slice(0, -1);
		default:
			return state;
		}
};

const [selectedOptions, dispatch] = React.useReducer(selectionReducer, []);
const [searchTerm, setSearchTerm] = React.useState("");

const filteredOptions = React.useMemo(() => {
	if (searchTerm === "") return initialOptions;
	return initialOptions.filter((item) =>
		item.name.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [initialOptions, searchTerm]);

const handleSelectionChange = (item) => {
	if(!item) return;
	const isSelected = selectedOptions.some(
		(selected) => selected.id === item.id
	);
	dispatch({
		type: isSelected ? "REMOVE" : "ADD",
		payload: item,
	});
	setSearchTerm("");
};

return (
	<Field label="Select Items">
		<ComboboxMultiSelect
			inputValue={searchTerm}
			menuTrigger="focus"
			onClose={() => setSearchTerm("")}
			onClearAll={() => dispatch({ type: 'DESELECT_ALL' })}
			onClearInput={() => setSearchTerm("")}
			onRemoveLast={() => dispatch({ type: 'DROP_LAST' })}
			onInputChange={setSearchTerm}
			onSelectionChange={handleSelectionChange}
			options={filteredOptions}
			selectedKey="id"
			selectedOptions={selectedOptions}
		>
			<ComboboxMultiSelectTextInput
				placeholder="Search..."
				renderItem={({ name }) => name}
			/>
			<ComboboxMultiSelectPopover inputPlaceholder="Search...">
				<ComboboxMultiSelectListbox
					noResultsFallback={
						<Text className="text-secondary text-center text-body-12 py-4">
							No matching results
						</Text>
					}
					options={filteredOptions}
				>
					{(item) => (
						<ComboboxMultiSelectItem option={item}>
							{item.name}
						</ComboboxMultiSelectItem>
					)}
				</ComboboxMultiSelectListbox>
			</ComboboxMultiSelectPopover>
		</ComboboxMultiSelect>
	</Field>
);

Select All Option

Use the ComboboxMultiSelectAll component to render a “Select all” option inside the listbox. This lets users select or deselect all options at once and also supports the indeterminate state when only some items are selected

// Data
const allOptions = [
	{
		id: 'opiasri',
		name: 'Oscar Piastri',
	},
	{
		id: 'dricciardo',
		name: 'Daniel Ricciardo',
	},
	{
		id: 'kchandhok',
		name: 'Karun Chandhok',
	},
	{
		id: 'jdaruvala',
		name: 'Jehan Daruvala',
	},
	{
		id: 'rkubia',
		name: 'Robert Kubica',
	},
];
const selectionKey = 'id';

// Actions
const createSelectionReducer = (selectionKey) => {
	return (state, action) => {
		switch (action.type) {
			case 'ADD':
				return [...state, action.payload];
			case 'REMOVE':
				return state.filter(
					(item) => item[selectionKey] !== action.payload[selectionKey]
				);
			case 'DESELECT_ALL':
				return [];
			case 'DROP_LAST':
				if (state.length === 0) return state;
				return state.slice(0, -1);
			case 'REPLACE':
				return action.payload;
			default:
				return state;
		}
	};
};

// States
const [searchTerm, setSearchTerm] = React.useState('');
const deferredSearchTerm = React.useDeferredValue(searchTerm);
const selectionReducer = createSelectionReducer(selectionKey);
const [selectedOptions, dispatch] = React.useReducer(selectionReducer, []);

const isAllSelected = selectedOptions.length === allOptions.length;

const isPartiallySelected = selectedOptions.length > 0 && !isAllSelected;

// Handlers
function handleSelectAll() {
	if (isAllSelected) {
		dispatch({ type: 'DESELECT_ALL' });
	} else {
		dispatch({
			type: 'REPLACE',
			payload: allOptions,
		});
	}
}

const handleSelectionChange = (tappedOption) => {
	if (!tappedOption) return;

	const isSelected = selectedOptions.some(
		(selected) => selected[selectionKey] === tappedOption[selectionKey]
	);
	dispatch({
		type: isSelected ? 'REMOVE' : 'ADD',
		payload: tappedOption,
	});
	setSearchTerm('');
};

// Filter Options with selected options
const filteredOptions = React.useMemo(() => {
	const filteredOptions =
		deferredSearchTerm === ''
			? allOptions
			: allOptions.filter((user) =>
					user.name
						.toLowerCase()
						.includes(deferredSearchTerm.toLowerCase())
				);

	const selectedFilteredOptions = filteredOptions.map((option) => {
		const isSelected = selectedOptions.some(
			(selected) => selected[selectionKey] === option[selectionKey]
		);
		return {
			...option,
			isSelected,
		};
	});

	if (filteredOptions.length === allOptions.length) {
		return [
			{
				[selectionKey]: 'select-all',
				value: 'Select All',
				isSelected: isAllSelected,
			},
			...selectedFilteredOptions,
		];
	}

	return selectedFilteredOptions;
}, [
	deferredSearchTerm,
	selectedOptions,
	selectionKey,
	isAllSelected,
	allOptions,
]);

return (
	<Field label="Select Fruit">
		<ComboboxMultiSelect
			inputValue={searchTerm}
			menuTrigger="focus"
			onClearAll={() => dispatch({ type: 'DESELECT_ALL' })}
			onClearInput={() => setSearchTerm('')}
			onClose={() => setSearchTerm('')}
			onInputChange={setSearchTerm}
			onRemoveLast={() => dispatch({ type: 'DROP_LAST' })}
			onSelectionChange={handleSelectionChange}
			selectedKey="name"
			selectedOptions={selectedOptions}
			options={allOptions}
		>
			<ComboboxMultiSelectTextInput
				placeholder="Search..."
				renderItem={({ name }) => name}
			/>
			<ComboboxMultiSelectPopover>
				<ComboboxMultiSelectListbox
					noResultsFallback={<Text className="text-secondary text-center text-body-12 py-4">No matching results</Text>}
					options={filteredOptions}
				>
					{(user) => {
						const isSelectAll = user.id === 'select-all';

						if (isSelectAll) {
							return (
								<ComboboxMultiSelectAll
									onClick={handleSelectAll}
								>
									Select all
								</ComboboxMultiSelectAll>
							);
						}

						return (
							<ComboboxMultiSelectItem id={user.name} option={user}>
								{user.name}
							</ComboboxMultiSelectItem>
						);
					}}
				</ComboboxMultiSelectListbox>
			</ComboboxMultiSelectPopover>
		</ComboboxMultiSelect>
	</Field>
);

Max Visible Items

Use maxVisibleItems prop to control how many selected tags can be shown in the input field. If the selection exceeds this, the rest will be collapsed into a "+X more" indicator to save space.

const initialOptions = [
	{
		id: "opiasri",
		name: "Oscar Piastri",
	},
	{
		id: "dricciardo",
		name: "Daniel Ricciardo",
	},
	{
		id: "kchandhok",
		name: "Karun Chandhok",
	},
	{
		id: "jdaruvala",
		name: "Jehan Daruvala",
	},
	{
		id: "rkubia",
		name: "Robert Kubica",
	},
];

const selectionReducer = (state, action) => {
	switch (action.type) {
		case "ADD":
			return [...state, action.payload];
		case "REMOVE":
			return state.filter((item) => item.id !== action.payload.id);
		case 'DESELECT_ALL':
			return [];
		case 'DROP_LAST':
			if(state.length === 0) return state;
			return state.slice(0, -1);
		default:
			return state;
		}
};

const [selectedOptions, dispatch] = React.useReducer(selectionReducer, []);
const [searchTerm, setSearchTerm] = React.useState("");

const filteredOptions = React.useMemo(() => {
	if (searchTerm === "") return initialOptions;
	return initialOptions.filter((item) =>
		item.name.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [initialOptions, searchTerm]);

const handleSelectionChange = (item) => {
	if(!item) return;
	const isSelected = selectedOptions.some(
		(selected) => selected.id === item.id
	);
	dispatch({
		type: isSelected ? "REMOVE" : "ADD",
		payload: item,
	});
	setSearchTerm("");
};

return (
	<Field label="Select Items">
		<ComboboxMultiSelect
			inputValue={searchTerm}
			menuTrigger="focus"
			onClose={() => setSearchTerm("")}
			onClearAll={() => dispatch({ type: 'DESELECT_ALL' })}
			onClearInput={() => setSearchTerm("")}
			onRemoveLast={() => dispatch({ type: 'DROP_LAST' })}
			onInputChange={setSearchTerm}
			onSelectionChange={handleSelectionChange}
			options={filteredOptions}
			selectedKey="id"
			selectedOptions={selectedOptions}
		>
		 	<ComboboxMultiSelectTextInput
				maxVisibleItems={2} // Display maximum of 3 items, rest truncates
				placeholder="Search..."
				renderItem={({ name }) => name}
			/>
			<ComboboxMultiSelectPopover>
				<ComboboxMultiSelectListbox
					noResultsFallback={
						<Text className="text-secondary text-center text-body-12 py-4">
							No matching results
						</Text>
					}
					options={filteredOptions}
				>
					{(item) => (
						<ComboboxMultiSelectItem option={item}>
							{item.name}
						</ComboboxMultiSelectItem>
					)}
				</ComboboxMultiSelectListbox>
			</ComboboxMultiSelectPopover>
		</ComboboxMultiSelect>
	</Field>
);

With Apply Button

For large lists and to improve the mobile experience, you can enable draft-to-save mode. Ideally, this introduces a two-step selection process where users selects their choices, reviews them and then confirm by tapping "Apply" button.

  • Use shouldUseDraftMode={true} on ComboboxMultiSelect to enable this draft-to-save mode on mobile.
  • Use draftSaveButton on ComboboxMultiSelectPopover to add an apply button in mobile view.
  • Use onDraftSave callback on ComboboxMultiSelect to save the draft options into the selected options. This is called when the user taps the apply button.

Note: This is a mobile-only feature. The apply button will not appear on desktop views - it's specifically designed for mobile interfaces where this component opens as a sheet.

const initialOptions = [
	{
		id: "apple",
		name: "Apple",
	},
	{
		id: "banana",
		name: "Banana",
	},
	{
		id: "cherry",
		name: "Cherry",
	},
	{
		id: "grape",
		name: "Grape",
	},
	{
		id: "orange",
		name: "Orange",
	},
];

const selectionReducer = (state, action) => {
	switch (action.type) {
		case "ADD":
			return [...state, action.payload];
		case "REMOVE":
			return state.filter((item) => item.id !== action.payload.id);
		case 'DESELECT_ALL':
			return [];
		case 'DROP_LAST':
			if(state.length === 0) return state;
			return state.slice(0, -1);
			case 'REPLACE':
				return action.payload;
		default:
			return state;
		}
};

const [selectedOptions, dispatch] = React.useReducer(selectionReducer, []);
const [searchTerm, setSearchTerm] = React.useState("");

const filteredOptions = React.useMemo(() => {
	if (searchTerm === "") return initialOptions;
	return initialOptions.filter((item) =>
		item.name.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [initialOptions, searchTerm]);

const handleSelectionChange = (item) => {
	if(!item) return;
	const isSelected = selectedOptions.some(
		(selected) => selected.id === item.id
	);
	dispatch({
		type: isSelected ? "REMOVE" : "ADD",
		payload: item,
	});
	setSearchTerm("");
};

const handleDraftSave = (draftSelectedOptions) => {
	dispatch({
		type: 'REPLACE',
		payload: draftSelectedOptions,
	});
};

return (
	<Field label="Select Fruits">
		<ComboboxMultiSelect
			inputValue={searchTerm}
			menuTrigger="focus"
			onClose={() => setSearchTerm("")}
			onClearAll={() => dispatch({ type: 'DESELECT_ALL' })}
			onClearInput={() => setSearchTerm("")}
			onRemoveLast={() => dispatch({ type: 'DROP_LAST' })}
			onInputChange={setSearchTerm}
			onSelectionChange={handleSelectionChange}
			options={initialOptions}
			selectedKey="id"
			selectedOptions={selectedOptions}
			shouldUseDraftMode={true} // Enable draft-to-save mode on mobile
			onDraftSave={handleDraftSave}
		>
		 	<ComboboxMultiSelectTextInput
				placeholder="Search fruits..."
				renderItem={({ name }) => name}
			/>
			<ComboboxMultiSelectPopover
						draftSaveButton={({ triggerProps }) => (
							<Button
								className="w-full"
								size="large"
								variant="accentSecondary"
								{...triggerProps}
							>
								Apply
							</Button>
						)}
					>
				<ComboboxMultiSelectListbox
					noResultsFallback={
						<Text className="text-secondary text-center text-body-12 py-4">
							No matching results
						</Text>
					}
					options={filteredOptions}
				>
					{(item) => (
						<ComboboxMultiSelectItem option={item}>
							{item.name}
						</ComboboxMultiSelectItem>
					)}
				</ComboboxMultiSelectListbox>
			</ComboboxMultiSelectPopover>
		</ComboboxMultiSelect>
	</Field>
);

Style API

Our design system components include style props that allow you to easily customize different parts of each component to match your design needs.

Please refer to the Style API documentation for more insights.

ComboboxMultiSelect parts

const initialOptions = [
	{
		id: "opiasri",
		name: "Oscar Piastri",
	},
	{
		id: "dricciardo",
		name: "Daniel Ricciardo",
	},
	{
		id: "kchandhok",
		name: "Karun Chandhok",
	},
	{
		id: "jdaruvala",
		name: "Jehan Daruvala",
	},
	{
		id: "rkubia",
		name: "Robert Kubica",
	},
];

const selectionReducer = (state, action) => {
	switch (action.type) {
		case "ADD":
			return [...state, action.payload];
		case "REMOVE":
			return state.filter((item) => item.id !== action.payload.id);
		case 'DROP_LAST':
			if(state.length === 0) return state;
			return state.slice(0, -1);
		default:
			return state;
		}
};

const [selectedOptions, dispatch] = React.useReducer(selectionReducer, []);
const [searchTerm, setSearchTerm] = React.useState("");

const filteredOptions = React.useMemo(() => {
	if (searchTerm === "") return initialOptions;
	return initialOptions.filter((item) =>
		item.name.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [initialOptions, searchTerm]);

const handleSelectionChange = (item) => {
	if(!item) return;
	const isSelected = selectedOptions.some(
		(selected) => selected.id === item.id
	);
	dispatch({
		type: isSelected ? "REMOVE" : "ADD",
		payload: item,
	});
	setSearchTerm("");
};

return (
	<Field label="Select Items">
		<ComboboxMultiSelect
			inputValue={searchTerm}
			menuTrigger="focus"
			onClearInput={() => setSearchTerm("")}
			onInputChange={setSearchTerm}
			onRemoveLast={() => dispatch({ type: 'DROP_LAST' })}
			onSelectionChange={handleSelectionChange}
			options={filteredOptions}
			selectedKey="id"
			selectedOptions={selectedOptions}
		>
			<ComboboxMultiSelectTextInput
				className="bg-palette-violet-background"
				classNames={{
					focusIndicator: 'border-palette-violet-border',
					tag: 'bg-palette-blue-background',
					tagLabel: 'text-palette-blue-text',
					tagCloseButtonIcon: 'fill-palette-blue-border',
				}}
				placeholder="Search..."
				renderItem={({ name }) => name}
			/>
			<ComboboxMultiSelectPopover className="bg-palette-violet-background"
 classNames={{
              sheet: "bg-palette-violet-background",
              sheetHeader: "text-secondary",
              sheetHeading: "text-heading-24",
              sheetWrapper: "text-positive",
              sheetContent: "bg-accent",
            }}>
				<ComboboxMultiSelectListbox
					noResultsFallback={
						<Text className="text-secondary text-center text-body-12 py-4">
							No matching results
						</Text>
					}
					options={filteredOptions}
				>
					{(item) => (
						<ComboboxMultiSelectItem
							option={item}
							className={`
								bg-palette-violet-background
								active:bg-palette-violet-background-active
								active:data-[highlighted]:bg-palette-violet-background-active
								data-[highlighted]:bg-palette-violet-background-active
							`}
							classNames={{
								checkboxControl:
									'peer-checked:bg-palette-violet-background-active',
							}}
						>
							{item.name}
						</ComboboxMultiSelectItem>
					)}
				</ComboboxMultiSelectListbox>
			</ComboboxMultiSelectPopover>
		</ComboboxMultiSelect>
	</Field>
);

No parts available. Only root.

ComboboxMultiSelectItem parts

PartDescription
centerThe main area of the 'track'
checkboxThe checkbox root
checkboxControlThe interactive area of the checkbox
checkboxIconThe icon within the checkbox
railCommon style applied to both railStart and railEnd via styles.rail. Used for shared customization
railEndThe right fixed element. For icons, buttons, or trailing content.
railStartThe left fixed element. For icons, buttons, or any content to appear before the main area .

ComboboxMultiSelectTextInput parts

PartDescription
adornmentEndContainer for the clear (remove all) icon that appears at the end.
focusIndicatorA hidden span or visual indicator to show when the input is focused.
innerThe flex element which contains the input and the tags
inputStyles applied to the input field.
rootWrapper around the whole input, tags, and adornments.
tagThe element which represents a selected item
tagCloseButtonThe button within a Tag which dismisses the selection
tagCloseButtonIconThe icon element within the TagCloseButton
tagLabelThe text element within the tag

ComboboxMultiSelectPopover parts

PartDescription
rootParent Container on desktop view.
sheetParent Container of the popover in mobile view.
sheetWrapperThe inner wrapper of the sheet, typically used for controlling the layout or the sheet content's alignment and size.
sheetHeaderHeader Container holding the heading elements.
sheetHeadingStyles to the heading text.
sheetContentBox holding the options in the sheet
closeButtonThe button element used to close the sheet, typically placed at the top-right corner of the dialog.

Note: Styles applied to the popover root will not be automatically applied to sheet , recommend to add sheet styles as shown in the example.