View as Markdown
Combobox
Autocomplete input and command palette with a list of suggestions.
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<twig:Combobox
id="package"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
/>
Installation
php bin/console ux:install combobox --kit shadcn
Install the following Composer dependencies:
composer require symfony/ux-icons tales-from-a-dev/twig-tailwind-extra:^1.0.0
Copy the following file(s) into your app:
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = [
'trigger',
'label',
'popover',
'search',
'option',
'empty',
'hiddenInput',
'group',
'clearButton',
];
static values = {
value: { type: String, default: '' },
placeholder: { type: String, default: 'Select option...' },
};
#activeIndex = -1;
#isOpen = false;
#outsideClickHandler = null;
connect() {
this.#outsideClickHandler = this.#onOutsideClick.bind(this);
}
disconnect() {
this.#close();
}
toggle() {
if (this.#isOpen) {
this.#close();
} else {
this.#open();
}
}
clear(event) {
event.stopPropagation();
this.valueValue = '';
}
onSearch(event) {
const query = event.target.value.toLowerCase();
let firstVisibleIndex = -1;
let visibleCount = 0;
const targets = this.optionTargets;
for (let i = 0; i < targets.length; i++) {
const matches = targets[i].dataset.label.toLowerCase().includes(query);
targets[i].hidden = !matches;
if (matches) {
if (firstVisibleIndex === -1) firstVisibleIndex = i;
visibleCount++;
}
}
for (const group of this.groupTargets) {
group.hidden = !targets.filter((o) => group.contains(o)).some((o) => !o.hidden);
}
this.emptyTarget.hidden = visibleCount > 0;
this.#setActive(firstVisibleIndex);
}
onSelect(event) {
this.#selectOption(event.currentTarget);
}
onOptionHover(event) {
const index = this.optionTargets.indexOf(event.currentTarget);
if (index !== -1 && !event.currentTarget.hidden) {
this.#setActive(index);
}
}
onTriggerKeydown(event) {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
this.#open();
this.#setActive(this.#firstVisibleIndex());
break;
case 'ArrowUp':
event.preventDefault();
this.#open();
this.#setActive(this.#lastVisibleIndex());
break;
case 'Enter':
case ' ':
event.preventDefault();
this.#open();
break;
}
}
onSearchKeydown(event) {
switch (event.key) {
case 'ArrowDown': {
event.preventDefault();
const next = this.#nextVisibleIndex(this.#activeIndex);
if (next !== -1) this.#setActive(next);
break;
}
case 'ArrowUp': {
event.preventDefault();
const prev = this.#prevVisibleIndex(this.#activeIndex);
if (prev !== -1) this.#setActive(prev);
break;
}
case 'Home':
event.preventDefault();
this.#setActive(this.#firstVisibleIndex());
break;
case 'End':
event.preventDefault();
this.#setActive(this.#lastVisibleIndex());
break;
case 'Enter':
event.preventDefault();
if (this.#activeIndex !== -1) {
this.#selectOption(this.optionTargets[this.#activeIndex]);
}
break;
case 'Escape':
event.preventDefault();
this.#close();
this.triggerTarget.focus();
break;
case 'Tab':
this.#close();
break;
}
}
valueValueChanged() {
this.#syncLabel();
this.#syncCheckIcons();
if (this.hasHiddenInputTarget) {
this.hiddenInputTarget.value = this.valueValue;
}
}
#open() {
if (this.#isOpen) return;
this.#isOpen = true;
this.searchTarget.value = '';
for (const option of this.optionTargets) {
option.hidden = false;
}
for (const group of this.groupTargets) {
group.hidden = false;
}
this.emptyTarget.hidden = true;
this.#setActive(-1);
const popover = this.popoverTarget;
popover.hidden = false;
popover.dataset.state = 'open';
this.#positionPopover();
this.triggerTarget.setAttribute('aria-expanded', 'true');
document.addEventListener('pointerdown', this.#outsideClickHandler);
requestAnimationFrame(() => {
this.searchTarget.focus();
});
}
#close() {
if (!this.#isOpen) return;
this.#isOpen = false;
const popover = this.popoverTarget;
popover.hidden = true;
popover.dataset.state = 'closed';
popover.style.cssText = '';
this.triggerTarget.setAttribute('aria-expanded', 'false');
document.removeEventListener('pointerdown', this.#outsideClickHandler);
}
#selectOption(option) {
const { value, label } = option.dataset;
this.valueValue = value;
this.dispatch('change', { detail: { value, label }, bubbles: true });
this.#close();
this.triggerTarget.focus();
}
#syncLabel() {
if (!this.hasLabelTarget) return;
const selected = this.hasOptionTarget
? this.optionTargets.find((o) => o.dataset.value === this.valueValue)
: null;
const label = selected ? selected.dataset.label : '';
this.labelTarget.textContent = label || this.placeholderValue;
this.labelTarget.classList.toggle('text-muted-foreground', !label);
if (this.hasClearButtonTarget) {
this.clearButtonTarget.hidden = !label;
}
}
#syncCheckIcons() {
if (!this.hasOptionTarget) return;
for (const option of this.optionTargets) {
const selected = option.dataset.value === this.valueValue;
option.setAttribute('aria-selected', String(selected));
const icon = option.querySelector('svg');
if (icon) {
icon.classList.toggle('opacity-0', !selected);
icon.classList.toggle('opacity-100', selected);
}
}
}
#setActive(index) {
if (this.#activeIndex !== -1 && this.optionTargets[this.#activeIndex]) {
delete this.optionTargets[this.#activeIndex].dataset.active;
}
this.#activeIndex = index;
if (index === -1) {
if (this.hasSearchTarget) {
this.searchTarget.removeAttribute('aria-activedescendant');
}
return;
}
const option = this.optionTargets[index];
if (!option) return;
option.dataset.active = '';
this.searchTarget.setAttribute('aria-activedescendant', option.id);
option.scrollIntoView({ block: 'nearest' });
}
#firstVisibleIndex() {
return this.optionTargets.findIndex((o) => !o.hidden);
}
#lastVisibleIndex() {
const targets = this.optionTargets;
for (let i = targets.length - 1; i >= 0; i--) {
if (!targets[i].hidden) return i;
}
return -1;
}
#nextVisibleIndex(from) {
const targets = this.optionTargets;
for (let i = from + 1; i < targets.length; i++) {
if (!targets[i].hidden) return i;
}
return from;
}
#prevVisibleIndex(from) {
const targets = this.optionTargets;
for (let i = from - 1; i >= 0; i--) {
if (!targets[i].hidden) return i;
}
return from;
}
#positionPopover() {
const triggerRect = this.triggerTarget.getBoundingClientRect();
const popover = this.popoverTarget;
const popoverHeight = popover.offsetHeight;
popover.style.position = 'fixed';
popover.style.width = `${triggerRect.width}px`;
popover.style.left = `${triggerRect.left}px`;
popover.style.zIndex = '50';
const spaceBelow = window.innerHeight - triggerRect.bottom;
if (spaceBelow < popoverHeight && triggerRect.top > spaceBelow) {
popover.style.top = `${Math.max(0, triggerRect.top - popoverHeight)}px`;
} else {
popover.style.top = `${triggerRect.bottom}px`;
}
}
#onOutsideClick(event) {
if (!this.element.contains(event.target) && !this.popoverTarget.contains(event.target)) {
this.#close();
}
}
}
{# @prop id string Unique identifier for ARIA references and internal element IDs. #}
{# @prop name string|null If set, renders a hidden input for form submission. #}
{# @prop value string The initially selected value. Must match a choices entry. #}
{# @prop choices array List of choices: `[{value: '...', label: '...'}]` or grouped `[{label: '...', choices: [{value: '...', label: '...'}]}]`. #}
{# @prop placeholder string Text shown on the trigger when nothing is selected. #}
{# @prop searchPlaceholder string Placeholder inside the search input. #}
{# @prop emptyMessage string Shown when the filter matches nothing. #}
{# @prop disabled boolean Whether the widget is disabled. #}
{# @prop required boolean Adds required to the hidden input (only applies when name is set). #}
{# @prop clearable boolean Shows a clear button when a value is selected. #}
{%- props id, name = null, value = '', choices = [], placeholder = 'Select option...', searchPlaceholder = 'Search...', emptyMessage = 'No results found.', disabled = false, required = false, clearable = false -%}
{%- set _is_grouped = choices is not empty and choices|first.choices is defined -%}
{%- set _selected_label = '' -%}
{%- if _is_grouped -%}
{%- for group in choices -%}
{%- for choice in group.choices -%}
{%- if choice.value == value -%}{%- set _selected_label = choice.label -%}{%- endif -%}
{%- endfor -%}
{%- endfor -%}
{%- else -%}
{%- for choice in choices -%}
{%- if choice.value == value -%}{%- set _selected_label = choice.label -%}{%- endif -%}
{%- endfor -%}
{%- endif -%}
<div
data-slot="combobox"
data-controller="combobox"
data-combobox-value-value="{{ value }}"
data-combobox-placeholder-value="{{ placeholder }}"
{{ attributes }}
>
<button
type="button"
role="combobox"
aria-expanded="false"
aria-controls="{{ id }}_listbox"
aria-haspopup="listbox"
data-combobox-target="trigger"
data-slot="combobox-trigger"
data-action="click->combobox#toggle keydown->combobox#onTriggerKeydown"
class="{{ ('flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 py-2 text-sm shadow-xs ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50')|tailwind_merge }}"
{% if disabled %}disabled{% endif %}
>
<span
data-combobox-target="label"
class="{{ _selected_label ? '' : 'text-muted-foreground' }}"
>{{ _selected_label ?: placeholder }}</span>
<span class="flex shrink-0 items-center gap-1">
{% if clearable %}
<span
aria-label="Clear selection"
data-combobox-target="clearButton"
data-action="click->combobox#clear"
data-slot="combobox-clear"
{% if not _selected_label %}hidden{% endif %}
class="rounded-sm opacity-50 hover:opacity-100"
><twig:ux:icon name="lucide:x" class="size-4 shrink-0" /></span>
{% endif %}
<twig:ux:icon name="lucide:chevrons-up-down" class="size-4 shrink-0 opacity-50" />
</span>
</button>
{% if name is not null %}
<input
type="hidden"
name="{{ name }}"
value="{{ value }}"
data-combobox-target="hiddenInput"
{% if required %}required{% endif %}
/>
{% endif %}
<div
id="{{ id }}_popover"
data-combobox-target="popover"
data-slot="combobox-content"
data-state="closed"
hidden
class="z-50 min-w-32 overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md"
>
<div class="flex items-center border-b border-border px-3" data-slot="combobox-input-wrapper">
<twig:ux:icon name="lucide:search" class="mr-2 size-4 shrink-0 opacity-50" />
<input
type="text"
role="searchbox"
aria-controls="{{ id }}_listbox"
aria-autocomplete="list"
placeholder="{{ searchPlaceholder }}"
data-combobox-target="search"
data-action="input->combobox#onSearch keydown->combobox#onSearchKeydown"
class="{{ ('flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50')|tailwind_merge }}"
/>
</div>
<div
id="{{ id }}_listbox"
role="listbox"
data-slot="combobox-list"
class="max-h-[300px] overflow-x-hidden overflow-y-auto p-1"
>
{% if _is_grouped %}
{% for group in choices %}
<div
role="group"
aria-labelledby="{{ id }}_group_{{ loop.index0 }}"
data-combobox-target="group"
data-slot="combobox-group"
>
<div
id="{{ id }}_group_{{ loop.index0 }}"
data-slot="combobox-group-label"
class="px-2 py-1.5 text-xs font-medium text-muted-foreground"
>{{ group.label }}</div>
{% for choice in group.choices %}
<div
role="option"
id="{{ id }}_option_{{ loop.parent.loop.index0 }}_{{ loop.index0 }}"
data-combobox-target="option"
data-value="{{ choice.value }}"
data-label="{{ choice.label }}"
data-slot="combobox-item"
aria-selected="{{ choice.value == value ? 'true' : 'false' }}"
data-action="click->combobox#onSelect mouseenter->combobox#onOptionHover"
class="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[active]:bg-accent data-[active]:text-accent-foreground"
>
<twig:ux:icon name="lucide:check" class="size-4 {{ choice.value == value ? 'opacity-100' : 'opacity-0' }}" />
{{ choice.label }}
</div>
{% endfor %}
</div>
{% endfor %}
{% else %}
{% for choice in choices %}
<div
role="option"
id="{{ id }}_option_{{ loop.index0 }}"
data-combobox-target="option"
data-value="{{ choice.value }}"
data-label="{{ choice.label }}"
data-slot="combobox-item"
aria-selected="{{ choice.value == value ? 'true' : 'false' }}"
data-action="click->combobox#onSelect mouseenter->combobox#onOptionHover"
class="relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[active]:bg-accent data-[active]:text-accent-foreground"
>
<twig:ux:icon name="lucide:check" class="size-4 {{ choice.value == value ? 'opacity-100' : 'opacity-0' }}" />
{{ choice.label }}
</div>
{% endfor %}
{% endif %}
<div
data-combobox-target="empty"
data-slot="combobox-empty"
hidden
class="py-6 text-center text-sm text-muted-foreground"
>{{ emptyMessage }}</div>
</div>
</div>
</div>
Usage
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<twig:Combobox
id="package-usage"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
/>
Examples
With Default Value
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<twig:Combobox
id="package-default"
value="live-component"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
/>
With Form
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<form method="post" action="#" class="flex max-w-xs flex-col gap-4">
<twig:Combobox
id="package-form"
name="package"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
:required="true"
/>
<button
type="submit"
class="inline-flex h-9 items-center justify-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-xs hover:bg-primary/90"
>Submit</button>
</form>
Disabled
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
] %}
<twig:Combobox
id="package-disabled"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
:disabled="true"
/>
Clearable
Loading...
{% set packages = [
{value: 'turbo', label: 'UX Turbo'},
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'icons', label: 'UX Icons'},
] %}
<twig:Combobox
id="package-clearable"
value="live-component"
placeholder="Select package..."
searchPlaceholder="Search packages..."
clearable
:choices="packages"
/>
With Groups
Loading...
{% set packages = [
{label: 'Stimulus', choices: [
{value: 'twig-component', label: 'UX Twig Component'},
{value: 'live-component', label: 'UX Live Component'},
{value: 'autocomplete', label: 'UX Autocomplete'},
{value: 'turbo', label: 'UX Turbo'},
]},
{label: 'Frontend Frameworks', choices: [
{value: 'react', label: 'UX React'},
{value: 'vue', label: 'UX Vue'},
{value: 'svelte', label: 'UX Svelte'},
]},
{label: 'UI & Visualization', choices: [
{value: 'icons', label: 'UX Icons'},
{value: 'chartjs', label: 'UX Chart.js'},
{value: 'map', label: 'UX Map'},
]},
] %}
<twig:Combobox
id="package-grouped"
placeholder="Select package..."
searchPlaceholder="Search packages..."
:choices="packages"
/>
Empty State
Loading...
{% set frameworks = [
{value: 'next', label: 'Next.js'},
{value: 'sveltekit', label: 'SvelteKit'},
] %}
<twig:Combobox
id="framework-empty"
placeholder="Select framework..."
searchPlaceholder="Search framework..."
emptyMessage="No frameworks found. Try a different search."
:choices="frameworks"
/>
Long List
Loading...
{% set languages = [
{value: 'php', label: 'PHP'},
{value: 'javascript', label: 'JavaScript'},
{value: 'typescript', label: 'TypeScript'},
{value: 'python', label: 'Python'},
{value: 'ruby', label: 'Ruby'},
{value: 'go', label: 'Go'},
{value: 'rust', label: 'Rust'},
{value: 'java', label: 'Java'},
{value: 'kotlin', label: 'Kotlin'},
{value: 'swift', label: 'Swift'},
{value: 'csharp', label: 'C#'},
{value: 'cpp', label: 'C++'},
{value: 'c', label: 'C'},
{value: 'scala', label: 'Scala'},
{value: 'elixir', label: 'Elixir'},
{value: 'erlang', label: 'Erlang'},
{value: 'haskell', label: 'Haskell'},
{value: 'ocaml', label: 'OCaml'},
{value: 'fsharp', label: 'F#'},
{value: 'clojure', label: 'Clojure'},
{value: 'dart', label: 'Dart'},
{value: 'r', label: 'R'},
{value: 'matlab', label: 'MATLAB'},
{value: 'perl', label: 'Perl'},
{value: 'lua', label: 'Lua'},
{value: 'julia', label: 'Julia'},
{value: 'groovy', label: 'Groovy'},
{value: 'shell', label: 'Shell'},
{value: 'powershell', label: 'PowerShell'},
{value: 'sql', label: 'SQL'},
{value: 'html', label: 'HTML'},
{value: 'css', label: 'CSS'},
{value: 'sass', label: 'Sass'},
{value: 'less', label: 'Less'},
{value: 'graphql', label: 'GraphQL'},
{value: 'yaml', label: 'YAML'},
{value: 'toml', label: 'TOML'},
{value: 'json', label: 'JSON'},
{value: 'xml', label: 'XML'},
{value: 'markdown', label: 'Markdown'},
{value: 'latex', label: 'LaTeX'},
{value: 'zig', label: 'Zig'},
{value: 'nim', label: 'Nim'},
{value: 'crystal', label: 'Crystal'},
{value: 'reason', label: 'ReasonML'},
{value: 'elm', label: 'Elm'},
{value: 'purescript', label: 'PureScript'},
{value: 'coffeescript', label: 'CoffeeScript'},
{value: 'objective-c', label: 'Objective-C'},
{value: 'vba', label: 'VBA'},
{value: 'cobol', label: 'COBOL'},
] %}
<twig:Combobox
id="language"
placeholder="Select language..."
searchPlaceholder="Search languages..."
:choices="languages"
/>
API Reference
<twig:Combobox>
| Prop | Type | Default |
|---|---|---|
id Unique identifier for ARIA references and internal element IDs.
|
string |
- |
name If set, renders a hidden input for form submission.
|
string|null |
null |
value The initially selected value. Must match a choices entry.
|
string |
'' |
choices List of choices:
[{value: '...', label: '...'}] or grouped [{label: '...', choices: [{value: '...', label: '...'}]}]. |
array |
[] |
placeholder Text shown on the trigger when nothing is selected.
|
string |
'Select option...' |
searchPlaceholder Placeholder inside the search input.
|
string |
'Search...' |
emptyMessage Shown when the filter matches nothing.
|
string |
'No results found.' |
disabled Whether the widget is disabled.
|
boolean |
false |
required Adds required to the hidden input (only applies when name is set).
|
boolean |
false |
clearable Shows a clear button when a value is selected.
|
boolean |
false |