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
placeholderto define the placeholder for the trigger. - Use
contentto set default or saved content. - Provide
searchTermandoptionsto control the menu list visibility based on user search - Use
onUpdateto 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.
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
| Part | Description |
|---|---|
| center | The main area of the 'track' |
| railEnd | The right fixed element. For icons, buttons, or trailing content. |
| railStart | The left fixed element. For icons, buttons, or any content to appear before the main area . |
ComboboxEditorTrigger parts
| Part | Description |
|---|---|
| center | The main content area containing the editor input field. |
| focusIndicator | The visual indicator that appears when the trigger is focused. |
ComboboxEditorListbox parts
No parts available. Only root.
ComboboxEditorPopover parts
No parts available. Only root.