ComboboxEditor

The ComboboxEditor is a Hybrid Combobox, which allows users to enter text, add tags from a list and quickly filter choices. It also allows multi-line input and lets users select the same option more than once.

📝 This uses TipTap Editor under the hood.

Available from eds-core/1.33.0

Quick Start

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

Feature and Usage Pattern

This section highlights the main capabilities of the ComboboxEditor and how it's typically integrated within Field and InlineField components

Features

  • Hybrid Text Input: Seamlessly combines free-form text entry with structured tag selection
  • Multi-line Support: Supports multi-line text input with hard breaks and paragraph formatting
  • Interactive Tags: Insert and manage interactive tags from a searchable dropdown list
  • Real-time Search: Filter available options as you type with dynamic search functionality
  • Single Select Mode: Optionally restrict selection to a single tag at a time
  • Flexible Output Options: The output can be a string, HTML collection or a JSON object.
  • Icons: Provides the ability to display different set of icons for options or group of options.

Pattern

The ComboboxEditor is typically used within a Field or InlineField component, which provide consistent labeling, descriptions, error handling and disabled states. For a minimal or “ghost” appearance, use InlineField.

Refer to the Field and InlineField documentation for more details.

Basic Example

Here is a basic usage of combobox editor with default content and placeholder, which toggles the menu list based on user search.

  • Use placeholder to define the placeholder for the trigger.
  • Use content to set default or saved content.
  • Provide searchTerm and options to control the menu list visibility based on user search
  • Use onUpdate to capture and handle content changes from the trigger.
const options = [
	{ label: 'First Name', value: 'First Name', id: 'first-name' },
	{ label: 'Last Name', value: 'Last Name', id: 'last-name' },
	{ label: 'Email', value: 'Email', id: 'email' },
	{ label: 'Phone', value: 'Phone', id: 'phone' },
	{ label: 'Address', value: 'Address', id: 'address' },
];

const [searchTerm, setSearchTerm] = React.useState('');
const filteredOptions = React.useMemo(() => {
    return options.filter((option) =>
        option.label.toLowerCase().includes(searchTerm.toLowerCase())
    );
}, [options, searchTerm]);

return (
    <Field label="Title">
        <ComboboxEditor
            placeholder="Search"
            content="Lorem ipsum dolor sit amet, consectetur adipiscing elit."
            options={filteredOptions}
            searchTerm={searchTerm}
            onUpdate={({ currentSearchTerm }) => {
                setSearchTerm(currentSearchTerm);
            }}
        >
            <ComboboxEditorTrigger />
            <ComboboxEditorPopover>
                <ComboboxEditorListbox>
                    {(option) => (
                        <ComboboxEditorItem option={option}>
                            {option.label}
                        </ComboboxEditorItem>
                    )}
                </ComboboxEditorListbox>
            </ComboboxEditorPopover>
            </ComboboxEditor>
    </Field>
)

Using Icons

You can display icons alongside option labels to provide visual context or categorization. Pass icon components through your options data and render them using the iconStart prop on ComboboxEditorItem.

const optionsWithIcons = [
	{
		label: 'First Name',
		value: 'First Name',
		id: 'first-name',
		icon: SetmoreColorIcon,
	},
	{
		label: 'Last Name',
		value: 'Last Name',
		id: 'last-name',
		icon: SetmoreColorIcon,
	},
	{ label: 'Email', value: 'Email', id: 'email', icon: SetmoreColorIcon },
	{ label: 'Phone', value: 'Phone', id: 'phone', icon: SetmoreColorIcon },
	{ label: 'Address', value: 'Address', id: 'address', icon: SetmoreColorIcon },
];

const [searchTerm, setSearchTerm] = React.useState('');

const filteredOptions = React.useMemo(() => {
	return optionsWithIcons.filter((option) =>
		option.label.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [searchTerm]);

return (
	<Field label="Title">
		<ComboboxEditor
			onUpdate={({ currentSearchTerm }) => {
				setSearchTerm(currentSearchTerm);
			}}
			options={filteredOptions}
			placeholder="Search"
			searchTerm={searchTerm}
		>
			<ComboboxEditorTrigger />
			<ComboboxEditorPopover>
				<ComboboxEditorListbox>
					{(option) => (
						<ComboboxEditorItem iconStart={option.icon} option={option}>
							{option.label}
						</ComboboxEditorItem>
					)}
				</ComboboxEditorListbox>
			</ComboboxEditorPopover>
		</ComboboxEditor>
	</Field>
);

For grouped options with distinct icons per group, see the Grouped Options section.

Grouped Options With Icons

Organize options into logical groups using ComboboxEditorListBoxGroup and ComboboxEditorListGroup components.

Each group should have a label and an array of options. The component will render group headers and properly filter options within each group based on the search term.

For custom icons, you can also pass different icons to the options and iconStart of ComboboxEditorItem, which will be displayed on the tag when selected.

📝 Note: While searching, groups with no matching options will be automatically hidden.

const groupedOptions = [
	{
		label: 'Zoho',
		options: [
		{ id: 'zoho_email', value: 'Email', label: 'Email', icon: ZohoColorIcon },
		{ id: 'zoho_firstname', value: 'First Name', label: 'First Name', icon: ZohoColorIcon },
			{ id: 'zoho_lastname', value: 'Last Name', label: 'Last Name', icon: ZohoColorIcon },
			{ id: 'zoho_company', value: 'Company', label: 'Company', icon: ZohoColorIcon },
		],
	},
	{
		label: 'HubSpot',
		options: [
			{ id: 'hs_email', value: 'Email', label: 'Email', icon: HubspotColorIcon },
			{ id: 'hs_firstname', value: 'First Name', label: 'First Name', icon: HubspotColorIcon },
			{ id: 'hs_lastname', value: 'Last Name', label: 'Last Name', icon: HubspotColorIcon },
			{ id: 'hs_lastname', value: 'Last Name', label: 'Last Name', icon: HubspotColorIcon },
		],
	},
	{
		label: 'Salesforce',
		options: [
			{ id: 'sf_account_id', value: 'Account ID', label: 'Account ID', icon: SalesforceColorIcon },
			{ id: 'sf_contact_email', value: 'Contact Email', label: 'Contact Email', icon: SalesforceColorIcon },
			{ id: 'sf_lead_status', value: 'Lead Status', label: 'Lead Status', icon: SalesforceColorIcon },
			{ id: 'sf_account_owner', value: 'Account Owner', label: 'Account Owner', icon: SalesforceColorIcon },
		],
	},
];

const [searchTerm, setSearchTerm] = React.useState('');

const filteredOptions = React.useMemo(() => {
	return groupedOptions.reduce((filtered, { label, options }) => {
		const matchingOptions = options.filter((item) =>
			item.label.toLowerCase().includes(searchTerm.toLowerCase())
		);

		if (matchingOptions.length > 0) {
			filtered.push({ label, options: matchingOptions });
		}

		return filtered;
	}, []);
}, [searchTerm]);

return (
	<Field label="Title">
		<ComboboxEditor
			onUpdate={({ currentSearchTerm }) => {
				setSearchTerm(currentSearchTerm);
			}}
			options={filteredOptions}
			placeholder="Search"
			searchTerm={searchTerm}
			content={{
				type: 'doc',
				content: [
					{
						type: 'paragraph',
						content: [
							{ type: 'tag', attrs: { id: 'zoho_firstname', value: 'First Name' } },
							{ type: 'text', text: ' '},
							{ type: 'tag', attrs: { id: 'zoho_lastname', value: 'Last Name' } },
							{ type: 'text', text: ' '},
							{ type: 'tag', attrs: { id: 'hs_email', value: 'Email' }},
							{ type: 'text', text: ' '},
							{ type:'hardBreak' },
							{ type: 'tag', attrs: { id: 'sf_lead_status', value: 'Lead Status' }},
							{ type: 'text', text: ' '},
							{ type: 'tag', attrs: { id: 'sf_account_owner', value: 'Account Owner' }},
							{ type: 'text', text: ' '},
						],
					},
				],
			}}
		>
			<ComboboxEditorTrigger />
			<ComboboxEditorPopover>
				<ComboboxEditorListBoxGroup options={filteredOptions}>
					{({ options, label }) => {
						return (
							<ComboboxEditorListGroup label={label} options={options}>
								{(option) => {
									return (
										<ComboboxEditorItem iconStart={option.icon} option={option}>
											{option.label}
										</ComboboxEditorItem>
									);
								}}
							</ComboboxEditorListGroup>
						);
					}}
				</ComboboxEditorListBoxGroup>
			</ComboboxEditorPopover>
		</ComboboxEditor>
	</Field>
);

Content With Tags

const options = [
	{ label: 'First Name', value: 'First Name', id: 'first-name' },
	{ label: 'Last Name', value: 'Last Name', id: 'last-name' },
	{ label: 'Email', value: 'Email', id: 'email' },
	{ label: 'Phone', value: 'Phone', id: 'phone' },
	{ label: 'Address', value: 'Address', id: 'address' },
];

const [searchTerm, setSearchTerm] = React.useState('');

const filteredOptions = React.useMemo(() => {
	return options.filter((option) =>
		option.label.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [options,searchTerm]);

return (
	<Field label="Title">
		<ComboboxEditor
			placeholder="Search"
			content={{
				type: 'doc',
				content: [
					{
						type: 'paragraph',
						content: [
							{ type: 'text', text: 'Good Morning, ' },
							{
								type: 'tag',
								attrs: {
									id: 'first-name',
									value: 'First Name',
								},
							},
							{ type: 'text', text: ' ' },
							{ type: 'hardBreak' },
							{
								type: 'text',
								text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
							},
							{ type: 'hardBreak' },
							{ type: 'text', text: 'Thanks ' },
							{
								type: 'tag',
								attrs: {
									id: 'first-name',
									value: 'First Name',
								},
							},
							{ type: 'text', text: ' ' },
							{
								type: 'tag',
								attrs: {
									id: 'last-name',
									value: 'Last Name',
								},
							},
							{ type: 'text', text: ' ' },
						],
					},
				],
			}}
			options={filteredOptions}
			searchTerm={searchTerm}
			onUpdate={({ currentSearchTerm }) => {
				setSearchTerm(currentSearchTerm);
			}}
		>
			<ComboboxEditorTrigger />
			<ComboboxEditorPopover>
				<ComboboxEditorListbox>
					{(option) => (
						<ComboboxEditorItem option={option}>
							{option.label}
						</ComboboxEditorItem>
					)}
				</ComboboxEditorListbox>
			</ComboboxEditorPopover>
		</ComboboxEditor>
	</Field>
);

Content Formats

Use content prop to pass default values to the editor in two ways:

  • Passing plain text
  • Passing JSON content with proper node schema.

Passing Plain Text

{
	content: 'Lorem ipsum dolor sit amet' 
}

Passing JSON Content

While passing JSON content, you can use the following schema:
Before that, first you need to create a doc node with the following content:

📝 Note: Refer With Default Tags section, for a full working example with JSON content.

{
	content: {
		type: 'doc', // Required top level node
		content: [
			{
				type: 'paragraph',
				content: [
					// ...tag or text nodes
				],
			},
		],
	},
};

Now you can add your tag and text nodes to the content array.

Tag Content JSON Scheme:

{
	type: 'tag',
	attrs: {
		id: 'first-name',
		value: 'First Name',
	},
}

Text Content JSON Schema:

{
	type: 'text',
	text: 'Lorem ipsum dolor',
}

Example with JSON Content Format

const options = [
	{ label: 'First Name', value: 'First Name', id: 'first-name' },
	{ label: 'Last Name', value: 'Last Name', id: 'last-name' },
	{ label: 'Email', value: 'Email', id: 'email' },
];

const [searchTerm, setSearchTerm] = React.useState('');

const filteredOptions = React.useMemo(() => {
	return options.filter((option) =>
		option.label.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [options, searchTerm]);

return (
	<Field label="Message Template">
		<ComboboxEditor
			placeholder="Type your message"
			content={{
				type: 'doc',
				content: [
					{
						type: 'paragraph',
						content: [
							{ type: 'text', text: 'Hello ' },
							{
								type: 'tag',
								attrs: {
									id: 'first-name',
									value: 'First Name',
								},
							},
							{ type: 'text', text: ', your email is ' },
							{
								type: 'tag',
								attrs: {
									id: 'email',
									value: 'Email',
								},
							},
							{ type: 'text', text: '.' },
						],
					},
				],
			}}
			options={filteredOptions}
			searchTerm={searchTerm}
			onUpdate={({ currentSearchTerm }) => {
				setSearchTerm(currentSearchTerm);
			}}
		>
			<ComboboxEditorTrigger />
			<ComboboxEditorPopover>
				<ComboboxEditorListbox>
					{(option) => (
						<ComboboxEditorItem option={option}>
							{option.label}
						</ComboboxEditorItem>
					)}
				</ComboboxEditorListbox>
			</ComboboxEditorPopover>
		</ComboboxEditor>
	</Field>
);

Output Formats

The ComboboxEditor provides three methods to retrieve the editor content in different formats through the onUpdate callback:

  • editor.getJSON() - Returns the content as a structured JSON object with the full document schema. Ideal for saving and restoring the exact editor state.
  • editor.getHTML() - Returns the content as an HTML string. Useful for rendering in other contexts or storing as HTML.
  • editor.getText() - Returns only the plain text content without formatting or tags. Useful for character counting or plain text extraction.

The example below demonstrates how to capture the editor instance and retrieve content in all three formats.

const options = [
	{ label: 'First Name', value: 'First Name', id: 'first-name' },
	{ label: 'Last Name', value: 'Last Name', id: 'last-name' },
	{ label: 'Email', value: 'Email', id: 'email' },
	{ label: 'Phone', value: 'Phone', id: 'phone' },
	{ label: 'Address', value: 'Address', id: 'address' },
];

const [searchTerm, setSearchTerm] = React.useState('');
const [editorInstance, setEditorInstance] = React.useState(null);
const [outputType, setOutputType] = React.useState(null);
const [output, setOutput] = React.useState('');

const filteredOptions = React.useMemo(() => {
	return options.filter((option) =>
		option.label.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [searchTerm]);

return (
	<Stack className="gap-2">
		<Field label="Title">
			<ComboboxEditor
				onUpdate={({ editor, currentSearchTerm }) => {
					setSearchTerm(currentSearchTerm);
					setEditorInstance(editor);
				}}
				options={filteredOptions}
				placeholder="Add more content here"
				searchTerm={searchTerm}
			>
				<ComboboxEditorTrigger />
				<ComboboxEditorPopover>
					<ComboboxEditorListbox>
						{(option) => (
							<ComboboxEditorItem option={option}>
								{option.label}
							</ComboboxEditorItem>
						)}
					</ComboboxEditorListbox>
				</ComboboxEditorPopover>
			</ComboboxEditor>
		</Field>
		<Box className="flex gap-2">
			<Button
				onClick={() => {
					if (editorInstance) {
						const jsonOutput = JSON.stringify(
							editorInstance.getJSON(),
							null,
							2
						);
						setOutput(jsonOutput);
						setOutputType('json');
						navigator.clipboard.writeText(jsonOutput);
					}
				}}
			>
				Copy JSON output
			</Button>
			<Button
				onClick={() => {
					if (editorInstance) {
						const htmlOutput = editorInstance.getHTML();
						setOutput(htmlOutput);
						setOutputType('html');
						navigator.clipboard.writeText(htmlOutput);
					}
				}}
			>
				Copy HTML output
			</Button>
			<Button
				onClick={() => {
					if (editorInstance) {
						const textOutput = editorInstance.getText();
						setOutput(textOutput);
						setOutputType('text');
						navigator.clipboard.writeText(textOutput);
					}
				}}
			>
				Copy Text output
			</Button>
		</Box>
		{output && outputType && (
			<>
				<strong>
					{outputType === 'json' && 'JSON Output:'}
					{outputType === 'html' && 'HTML Output:'}
					{outputType === 'text' && 'Text Output:'}
				</strong>
				<pre className="p-4 overflow-auto text-body-12 rounded-4px text-inverse bg-inverse max-h-96 max-w-screen-sm w-full whitespace-normal">
					{output}
				</pre>
			</>
		)}
	</Stack>
);

Single Selection Mode

The component supports a single select mode by setting the singleSelect prop to true.

⚠️ In this mode, only one tag can be selected at a time. When a new tag is selected, it automatically replaces any existing tag. This can be used in scenarios where you need only one selection.

Only one tag can be selected at a time. Selecting a new tag will replace the existing one.
const options = [
	{ label: 'First Name', value: 'First Name', id: 'first-name' },
	{ label: 'Last Name', value: 'Last Name', id: 'last-name' },
	{ label: 'Email', value: 'Email', id: 'email' },
	{ label: 'Phone', value: 'Phone', id: 'phone' },
	{ label: 'Address', value: 'Address', id: 'address' },
];

const [searchTerm, setSearchTerm] = React.useState('');

const filteredOptions = React.useMemo(() => {
	return options.filter((option) =>
		option.label.toLowerCase().includes(searchTerm.toLowerCase())
	);
}, [options, searchTerm]);

return (
	<Field 
		label="Select a field"
		description="Only one tag can be selected at a time. Selecting a new tag will replace the existing one."
	>
		<ComboboxEditor
			placeholder="Search"
			options={filteredOptions}
			searchTerm={searchTerm}
			singleSelect={true}
			onUpdate={({ currentSearchTerm }) => {
				setSearchTerm(currentSearchTerm);
			}}
		>
			<ComboboxEditorTrigger />
			<ComboboxEditorPopover>
				<ComboboxEditorListbox>
					{(option) => (
						<ComboboxEditorItem option={option}>
							{option.label}
						</ComboboxEditorItem>
					)}
				</ComboboxEditorListbox>
			</ComboboxEditorPopover>
		</ComboboxEditor>
	</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.

ComboboxEditor parts

const options = [
	{ label: 'First Name', value: 'First Name', id: 'first-name' },
	{ label: 'Last Name', value: 'Last Name', id: 'last-name' },
	{ label: 'Email', value: 'Email', id: 'email' },
	{ label: 'Phone', value: 'Phone', id: 'phone' },
	{ label: 'Address', value: 'Address', id: 'address' },
];

const [searchTerm, setSearchTerm] = React.useState('');
const filteredOptions = React.useMemo(() => {
    return options.filter((option) =>
        option.label.toLowerCase().includes(searchTerm.toLowerCase())
    );
}, [options, searchTerm]);

return (
    <Field label="Title">
        <ComboboxEditor
			placeholder="Search"
			onUpdate={({ currentSearchTerm }) => {
				setSearchTerm(currentSearchTerm);
			}}
            options={filteredOptions}
            searchTerm={searchTerm}
			content={{
				type: 'doc',
				content: [
					{
						type: 'paragraph',
						content: [
							{ type: 'text', text: 'Hello, Mr. ' },
							{
								type: 'tag',
								attrs: {
									id: 'first-name',
									value: 'First Name',
								},
							},
							{ type: 'text', text: ' ' },
							{
								type: 'tag',
								attrs: {
									id: 'last-name',
									value: 'Last Name',
								},
							},
							{ type: 'text', text: ' ' },
						],
					},
				],
			}}
        >
			<ComboboxEditorTrigger
				className="bg-palette-violet-background"
				classNames={{
					center: 'bg-palette-violet-background-hover',
					focusIndicator: 'border-palette-violet-border',
				}}
			/>
            <ComboboxEditorPopover className="border-palette-violet-border">
                <ComboboxEditorListbox className="border-palette-violet-border">
                    {(option) => (
                        <ComboboxEditorItem
						className="border border-palette-violet-border bg-palette-violet-background"
						classNames={{
							railStart: 'bg-palette-violet-background-hover',
							center: 'bg-palette-violet-background-hover',
						}}
						option={option}>
                            {option.label}
                        </ComboboxEditorItem>
                    )}
                </ComboboxEditorListbox>
            </ComboboxEditorPopover>
            </ComboboxEditor>
    </Field>
)

No parts available.

ComboboxEditorItem parts

PartDescription
centerThe main area of the 'track'
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 .

ComboboxEditorTrigger parts

PartDescription
centerThe main content area containing the editor input field.
focusIndicatorThe visual indicator that appears when the trigger is focused.

ComboboxEditorListbox parts

No parts available. Only root.

ComboboxEditorPopover parts

No parts available. Only root.