Add Combobox component

Markup-first select replacement built on the dropdown menu, with search,
multiple selection, form submission and keyboard support. It can also
enhance a native <select> (optgroups, tags with +N overflow, rich options,
container), so it can replace Tom Select later.
This commit is contained in:
Bartek committed 2026-09-29 13:24:02 +02:00
1 parent a15d5d71a1
commit 85f095cd4d
9 files changed
+1905

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@tabler/core": minor
"@tabler/docs": minor
---
Added Combobox component: a searchable, multi-select dropdown field with `.combobox-toggle` and `data-bs-toggle="combobox"`.
+895
View File
@@ -0,0 +1,895 @@
/**
* --------------------------------------------------------------------------
* Bootstrap combobox.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import * as Popper from '@popperjs/core'
import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine'
import { eventActionOnPlugin, initAll } from './bootstrap/util/component-functions'
import { getElement, getNextActiveElement, isDisabled, isRTL, isVisible } from './bootstrap/util/index'
import type { DelegatedEvent } from './bootstrap/dom/event-handler'
type ComponentConfig = {
boundary: Popper.Boundary
container: string | HTMLElement | null
maxItems: number | null
multiple: boolean
name: string | null
noResultsText: string
offset: number[] | string
placeholder: string
placement: string | null
search: boolean | null
searchNormalize: boolean
searchPlaceholder: string
tagsOverflow: 'count' | 'wrap'
}
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
/**
* Constants
*/
const NAME = 'combobox'
const DATA_KEY = 'bs.combobox'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const ESCAPE_KEY = 'Escape'
const TAB_KEY = 'Tab'
const ARROW_UP_KEY = 'ArrowUp'
const ARROW_DOWN_KEY = 'ArrowDown'
const HOME_KEY = 'Home'
const END_KEY = 'End'
const ENTER_KEY = 'Enter'
const SPACE_KEY = ' '
const BACKSPACE_KEY = 'Backspace'
const EVENT_CHANGE = `change${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_CLICK = `click${EVENT_KEY}`
const EVENT_KEYDOWN = `keydown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const EVENT_CLICK_OUTSIDE = `click${EVENT_KEY}.outside`
const EVENT_KEYUP_OUTSIDE = `keyup${EVENT_KEY}.outside`
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_SELECTED = 'selected'
const CLASS_NAME_PLACEHOLDER = 'combobox-placeholder'
const CLASS_NAME_TAGS = 'combobox-tags'
const CLASS_NAME_HAS_TAGS = 'combobox-has-tags'
const CLASS_NAME_TAGS_WRAP = 'combobox-tags-wrap'
const CLASS_NAME_TAG_MORE = 'combobox-tag-more'
const CLASS_NAME_HIDDEN = 'd-none'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="combobox"]'
const SELECTOR_MENU = '.dropdown-menu'
const SELECTOR_ITEM = '.dropdown-item[data-bs-value]'
const SELECTOR_VISIBLE_ITEMS = `${SELECTOR_ITEM}:not(.disabled):not(:disabled)`
const SELECTOR_ITEM_LABEL = '.dropdown-item-label, .dropdown-item-content > span:first-child'
const SELECTOR_ITEM_INDICATOR = '.dropdown-item-indicator'
const SELECTOR_VALUE = '.combobox-value'
const SELECTOR_SEARCH_INPUT = '.combobox-search-input'
const SELECTOR_NO_RESULTS = '.combobox-no-results'
const CHECK_ICON = '<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg>'
const Default: ComponentConfig = {
boundary: 'clippingParents',
container: null,
maxItems: null,
multiple: false,
name: null,
noResultsText: 'No results found',
offset: [0, 2],
placeholder: '',
placement: null,
search: null,
searchNormalize: false,
searchPlaceholder: 'Search…',
tagsOverflow: 'count',
}
const DefaultType: Record<keyof ComponentConfig, string> = {
boundary: '(string|element)',
container: '(string|element|null)',
maxItems: '(number|null)',
multiple: 'boolean',
name: '(string|null)',
noResultsText: 'string',
offset: '(array|string)',
placeholder: 'string',
placement: '(string|null)',
search: '(boolean|null)',
searchNormalize: 'boolean',
searchPlaceholder: 'string',
tagsOverflow: 'string',
}
/**
* Class definition
*
* Two modes share one implementation. On a `<button>` the items are written in
* the markup; on a `<select>` the toggle and the menu are built from its
* `<option>`s and the select stays in the DOM as the real form field.
*/
class Combobox extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
_select: HTMLSelectElement | null
_toggle: HTMLElement
_menu: HTMLElement
_valueDisplay: HTMLElement
_searchInput: HTMLInputElement | null
_noResults: HTMLElement | null
_hiddenInput: HTMLInputElement | null
_popper: Popper.Instance | null
_optionByItem: Map<HTMLElement, HTMLOptionElement>
_isSyncing: boolean
_resizeObserver: ResizeObserver | null
constructor(element: HTMLElement | string, config?: ComponentConfigInput) {
super(element, config)
this._select = this._element instanceof HTMLSelectElement ? this._element : null
this._optionByItem = new Map()
this._hiddenInput = null
this._popper = null
this._isSyncing = false
this._resizeObserver = null
if (this._select) {
this._toggle = this._createToggle(this._select)
this._valueDisplay = SelectorEngine.findOne(SELECTOR_VALUE, this._toggle) as HTMLElement
this._menu = document.createElement('div')
this._menu.className = 'dropdown-menu'
this._noResults = null
this._searchInput = null
this._buildMenu()
this._select.after(this._toggle)
this._toggle.after(this._menu)
} else {
this._toggle = this._element
this._menu = (SelectorEngine.next(this._toggle, SELECTOR_MENU)[0] || SelectorEngine.findOne(SELECTOR_MENU, this._toggle.parentNode as Element)) as HTMLElement
this._valueDisplay = SelectorEngine.findOne(SELECTOR_VALUE, this._toggle) as HTMLElement
this._searchInput = SelectorEngine.findOne(SELECTOR_SEARCH_INPUT, this._menu) as HTMLInputElement | null
this._noResults = SelectorEngine.findOne(SELECTOR_NO_RESULTS, this._menu)
this._createHiddenInput()
}
this._menu.classList.add('combobox-menu')
const container = this._config.container ? getElement(this._config.container) : null
container?.append(this._menu)
this._setAriaAttributes()
this._syncInitialSelection()
this._addEventListeners()
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
// Public
toggle(): void {
this._isShown() ? this.hide() : this.show()
}
show(): void {
if (isDisabled(this._toggle) || this._isShown()) {
return
}
if (EventHandler.trigger(this._element, EVENT_SHOW)?.defaultPrevented) {
return
}
this._menu.style.minWidth = `${this._toggle.offsetWidth}px`
this._popper = Popper.createPopper(this._toggle, this._menu, this._getPopperConfig())
this._menu.classList.add(CLASS_NAME_SHOW)
this._toggle.classList.add(CLASS_NAME_SHOW)
this._toggle.setAttribute('aria-expanded', 'true')
if (this._searchInput) {
this._searchInput.value = ''
this._filterItems('')
this._searchInput.focus()
}
this._addOutsideListeners()
EventHandler.trigger(this._element, EVENT_SHOWN)
}
hide(): void {
if (!this._isShown()) {
return
}
if (EventHandler.trigger(this._element, EVENT_HIDE)?.defaultPrevented) {
return
}
this._popper?.destroy()
this._popper = null
this._menu.classList.remove(CLASS_NAME_SHOW)
this._toggle.classList.remove(CLASS_NAME_SHOW)
this._toggle.setAttribute('aria-expanded', 'false')
this._removeOutsideListeners()
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
update(): void {
this._popper?.update()
}
// Rebuilds the menu from the `<select>` after its options changed, or re-reads its value after it was set from code.
refresh(): void {
if (!this._select) {
return
}
this._buildMenu()
this._syncInitialSelection()
}
dispose(): void {
this._popper?.destroy()
this._removeOutsideListeners()
this._hiddenInput?.remove()
this._hiddenInput = null
EventHandler.off(this._menu, EVENT_KEY)
EventHandler.off(this._toggle, EVENT_KEY)
this._searchInput?.removeEventListener('input', this._onSearchInput)
this._resizeObserver?.disconnect()
if (this._select) {
this._select.removeEventListener('change', this._onNativeChange)
this._select.removeEventListener('focus', this._onNativeFocus)
this._select.classList.remove('combobox-native')
this._select.removeAttribute('aria-hidden')
this._select.removeAttribute('tabindex')
this._toggle.remove()
this._menu.remove()
}
super.dispose()
}
// Private
_isShown(): boolean {
return this._menu.classList.contains(CLASS_NAME_SHOW)
}
_isMultipleTags(): boolean {
return this._select !== null && this._config.multiple
}
_getPopperConfig(): Partial<Popper.Options> {
const { boundary, container, offset, placement } = this._config
return {
placement: (placement || (isRTL() ? 'bottom-end' : 'bottom-start')) as Popper.Placement,
strategy: container ? 'fixed' : 'absolute',
modifiers: [
{ name: 'preventOverflow', options: { boundary } },
{
name: 'offset',
options: { offset: typeof offset === 'string' ? offset.split(',').map((value) => Number.parseInt(value, 10)) : offset },
},
],
}
}
_createToggle(select: HTMLSelectElement): HTMLElement {
const multiple = select.multiple
const toggle = document.createElement(multiple ? 'div' : 'button')
toggle.className = `${select.className} form-select combobox-toggle`
toggle.append(Object.assign(document.createElement('span'), { className: 'combobox-value' }))
if (multiple) {
toggle.setAttribute('role', 'combobox')
toggle.tabIndex = 0
} else {
toggle.setAttribute('type', 'button')
}
if (select.disabled) {
toggle.classList.add('disabled')
toggle.setAttribute('aria-disabled', 'true')
toggle.tabIndex = -1
if (!multiple) {
toggle.setAttribute('disabled', '')
}
}
const label = select.id ? document.querySelector(`label[for="${CSS.escape(select.id)}"]`) : null
if (label) {
label.id ||= `${select.id}-label`
toggle.setAttribute('aria-labelledby', label.id)
} else if (select.hasAttribute('aria-label')) {
toggle.setAttribute('aria-label', select.getAttribute('aria-label')!)
}
select.classList.add('combobox-native')
select.tabIndex = -1
select.setAttribute('aria-hidden', 'true')
const emptyOption = Array.from(select.options).find((option) => option.value === '')
this._config.multiple = multiple
this._config.placeholder ||= select.dataset.placeholder || ''
if (this._config.placeholder && !multiple && !Array.from(select.options).some((option) => option.defaultSelected)) {
select.selectedIndex = -1
}
this._config.placeholder ||= emptyOption?.textContent?.trim() || ''
return toggle
}
_buildMenu(): void {
const select = this._select!
const menu = this._menu
for (const element of SelectorEngine.find(`${SELECTOR_ITEM}, .dropdown-header`, menu)) {
element.remove()
}
this._optionByItem.clear()
const anchor = this._noResults ?? null
for (const child of Array.from(select.children)) {
if (child instanceof HTMLOptGroupElement) {
const header = Object.assign(document.createElement('div'), { className: 'dropdown-header', textContent: child.label })
menu.insertBefore(header, anchor)
for (const option of Array.from(child.children)) {
this._appendOption(option as HTMLOptionElement, child.disabled, anchor)
}
} else if (child instanceof HTMLOptionElement) {
this._appendOption(child, false, anchor)
}
}
if (!this._searchInput && this._config.search !== false) {
this._createSearch()
}
}
_appendOption(option: HTMLOptionElement, groupDisabled: boolean, anchor: Element | null): void {
if (option.value === '') {
return
}
const item = document.createElement('button')
item.type = 'button'
item.className = 'dropdown-item'
item.dataset.bsValue = option.value
item.disabled = option.disabled || groupDisabled
const custom = option.dataset.customProperties
if (custom) {
const indicator = document.createElement('span')
indicator.className = 'dropdown-item-indicator'
indicator.innerHTML = custom
item.append(indicator)
}
item.append(Object.assign(document.createElement('span'), { className: 'dropdown-item-label', textContent: option.textContent }))
item.insertAdjacentHTML('beforeend', CHECK_ICON)
this._menu.insertBefore(item, anchor)
this._optionByItem.set(item, option)
}
_createSearch(): void {
const wrapper = Object.assign(document.createElement('div'), { className: 'combobox-search' })
const input = Object.assign(document.createElement('input'), {
type: 'text',
className: 'form-control combobox-search-input',
placeholder: this._config.searchPlaceholder,
autocomplete: 'off',
})
input.setAttribute('aria-label', this._config.searchPlaceholder)
wrapper.append(input)
this._menu.prepend(wrapper)
this._noResults = Object.assign(document.createElement('div'), {
className: `combobox-no-results ${CLASS_NAME_HIDDEN}`,
textContent: this._config.noResultsText,
})
this._menu.append(this._noResults)
this._searchInput = input
input.addEventListener('input', this._onSearchInput)
}
_setAriaAttributes(): void {
this._toggle.setAttribute('aria-haspopup', 'listbox')
this._toggle.setAttribute('aria-expanded', 'false')
this._menu.setAttribute('role', 'listbox')
if (this._config.multiple) {
this._menu.setAttribute('aria-multiselectable', 'true')
}
for (const item of SelectorEngine.find(SELECTOR_ITEM, this._menu)) {
item.setAttribute('role', 'option')
if (!item.hasAttribute('aria-selected')) {
item.setAttribute('aria-selected', String(item.classList.contains(CLASS_NAME_SELECTED)))
}
if (item.hasAttribute('disabled')) {
item.setAttribute('aria-disabled', 'true')
}
}
}
_createHiddenInput(): void {
const { name } = this._config
if (!name) {
return
}
this._hiddenInput = document.createElement('input')
this._hiddenInput.type = 'hidden'
this._hiddenInput.name = name
this._toggle.before(this._hiddenInput)
}
_syncInitialSelection(): void {
if (this._select) {
for (const [item, option] of this._optionByItem) {
const selected = option.selected && option.value !== ''
item.classList.toggle(CLASS_NAME_SELECTED, selected)
item.setAttribute('aria-selected', String(selected))
}
this._setAriaAttributes()
}
this._updateToggleText()
this._updateHiddenInput()
}
_onNativeChange = (): void => {
if (!this._isSyncing) {
this._syncInitialSelection()
}
}
_onNativeFocus = (): void => {
this._toggle.focus()
}
_onSearchInput = (): void => {
this._filterItems(this._searchInput!.value)
}
_addEventListeners(): void {
EventHandler.on(this._menu, EVENT_CLICK, SELECTOR_ITEM, (event: DelegatedEvent<MouseEvent>) => {
const item = event.delegateTarget as HTMLElement
if (isDisabled(item)) {
return
}
event.preventDefault()
this._selectItem(item)
})
EventHandler.on(this._toggle, EVENT_KEYDOWN, (event: KeyboardEvent) => this._handleToggleKeydown(event))
EventHandler.on(this._menu, EVENT_KEYDOWN, (event: KeyboardEvent) => this._handleMenuKeydown(event))
this._searchInput?.addEventListener('input', this._onSearchInput)
if (this._config.multiple && typeof ResizeObserver !== 'undefined') {
this._resizeObserver = new ResizeObserver(() => this._layoutValue())
this._resizeObserver.observe(this._valueDisplay)
}
if (this._select) {
this._select.addEventListener('change', this._onNativeChange)
this._select.addEventListener('focus', this._onNativeFocus)
EventHandler.on(this._toggle, EVENT_CLICK, () => this.toggle())
}
}
_onOutsideClick = (event: MouseEvent): void => {
const path = event.composedPath()
if (!path.includes(this._toggle) && !path.includes(this._menu)) {
this.hide()
}
}
_onOutsideKeyup = (event: KeyboardEvent): void => {
if (event.key === TAB_KEY && !this._menu.contains(document.activeElement) && document.activeElement !== this._toggle) {
this.hide()
}
}
_addOutsideListeners(): void {
EventHandler.on(document, EVENT_CLICK_OUTSIDE, this._onOutsideClick)
EventHandler.on(document, EVENT_KEYUP_OUTSIDE, this._onOutsideKeyup)
}
_removeOutsideListeners(): void {
EventHandler.off(document, EVENT_CLICK_OUTSIDE, this._onOutsideClick)
EventHandler.off(document, EVENT_KEYUP_OUTSIDE, this._onOutsideKeyup)
}
_selectItem(item: HTMLElement): void {
const { maxItems, multiple } = this._config
if (multiple) {
const selected = !item.classList.contains(CLASS_NAME_SELECTED)
if (selected && maxItems !== null && this._getSelectedItems().length >= maxItems) {
return
}
item.classList.toggle(CLASS_NAME_SELECTED, selected)
item.setAttribute('aria-selected', String(selected))
} else {
for (const previous of this._getSelectedItems()) {
previous.classList.remove(CLASS_NAME_SELECTED)
previous.setAttribute('aria-selected', 'false')
}
item.classList.add(CLASS_NAME_SELECTED)
item.setAttribute('aria-selected', 'true')
}
this._commit(item)
if (!multiple) {
this.hide()
this._toggle.focus()
}
}
_removeValue(value: string): void {
const item = SelectorEngine.find(SELECTOR_ITEM, this._menu).find((element) => element.dataset.bsValue === value)
if (item?.classList.contains(CLASS_NAME_SELECTED)) {
item.classList.remove(CLASS_NAME_SELECTED)
item.setAttribute('aria-selected', 'false')
this._commit(item)
}
}
_removeLastValue(): void {
const selected = this._getSelectedItems()
const last = selected[selected.length - 1]
if (last) {
this._removeValue(last.dataset.bsValue!)
}
}
_commit(item: HTMLElement): void {
this._updateToggleText()
this._updateHiddenInput()
if (this._select) {
for (const [element, option] of this._optionByItem) {
option.selected = element.classList.contains(CLASS_NAME_SELECTED)
}
}
const selectedValues = this._getSelectedItems().map((element) => element.dataset.bsValue)
this._isSyncing = true
EventHandler.trigger(this._element, EVENT_CHANGE, {
value: this._config.multiple ? selectedValues : item.dataset.bsValue,
item,
})
if (this._select) {
this._select.dispatchEvent(new Event('change', { bubbles: true }))
}
this._isSyncing = false
this._popper?.update()
}
_getItemLabel(item: HTMLElement): string {
const label = SelectorEngine.findOne(SELECTOR_ITEM_LABEL, item)
return (label ?? item).textContent!.trim()
}
_labelNodes(item: HTMLElement): Node[] {
const nodes: Node[] = []
const indicator = SelectorEngine.findOne(SELECTOR_ITEM_INDICATOR, item)
if (indicator) {
nodes.push(indicator.cloneNode(true))
}
nodes.push(Object.assign(document.createElement('span'), { className: 'text-truncate', textContent: this._getItemLabel(item) }))
return nodes
}
_updateToggleText(): void {
const selectedItems = this._getSelectedItems()
if (selectedItems.length === 0) {
this._showPlaceholder()
return
}
this._valueDisplay.classList.remove(CLASS_NAME_PLACEHOLDER)
this._valueDisplay.classList.toggle(CLASS_NAME_TAGS, this._isMultipleTags())
this._valueDisplay.classList.toggle(CLASS_NAME_TAGS_WRAP, this._isMultipleTags() && this._config.tagsOverflow === 'wrap')
this._toggle.classList.toggle(CLASS_NAME_HAS_TAGS, this._isMultipleTags())
if (this._isMultipleTags()) {
this._valueDisplay.replaceChildren(...selectedItems.map((item) => this._createTag(item)))
this._layoutTags()
} else if (this._config.multiple) {
this._valueDisplay.replaceChildren(Object.assign(document.createElement('span'), { className: 'text-truncate' }))
this._layoutList(selectedItems.map((item) => this._getItemLabel(item)))
} else {
this._valueDisplay.replaceChildren(...this._labelNodes(selectedItems[0]))
}
}
_layoutValue(): void {
if (this._isMultipleTags()) {
this._layoutTags()
} else if (this._config.multiple) {
this._layoutList(this._getSelectedItems().map((item) => this._getItemLabel(item)))
}
}
// Lists the labels separated by commas and, when they do not fit, ends with "and N more".
_layoutList(labels: string[]): void {
const text = SelectorEngine.findOne('.text-truncate', this._valueDisplay)
if (!text || labels.length === 0) {
return
}
text.textContent = labels.join(', ')
if (labels.length < 2 || text.clientWidth === 0 || text.scrollWidth <= text.clientWidth) {
return
}
for (let shown = labels.length - 1; shown >= 1; shown--) {
text.textContent = `${labels.slice(0, shown).join(', ')} and ${labels.length - shown} more`
if (text.scrollWidth <= text.clientWidth) {
return
}
}
}
// Keeps the tags on one line: the ones that do not fit are hidden and a "+N" tag counts them.
_layoutTags(): void {
if (!this._isMultipleTags() || this._config.tagsOverflow !== 'count') {
return
}
const container = this._valueDisplay
const tags = SelectorEngine.find('.combobox-tag', container)
SelectorEngine.findOne(`.${CLASS_NAME_TAG_MORE}`, container)?.remove()
for (const tag of tags) {
tag.classList.remove(CLASS_NAME_HIDDEN)
}
if (tags.length < 2 || container.clientWidth === 0 || container.scrollWidth <= container.clientWidth) {
return
}
const more = Object.assign(document.createElement('span'), { className: `tag ${CLASS_NAME_TAG_MORE}` })
container.append(more)
let hidden = 0
while (hidden < tags.length - 1 && container.scrollWidth > container.clientWidth) {
tags[tags.length - 1 - hidden].classList.add(CLASS_NAME_HIDDEN)
hidden++
more.textContent = `+${hidden}`
more.title = `${hidden} more selected`
}
}
_createTag(item: HTMLElement): HTMLElement {
const tag = Object.assign(document.createElement('span'), { className: 'tag combobox-tag' })
tag.append(...this._labelNodes(item))
return tag
}
_showPlaceholder(): void {
this._valueDisplay.classList.remove(CLASS_NAME_TAGS)
this._toggle.classList.remove(CLASS_NAME_HAS_TAGS)
this._valueDisplay.classList.add(CLASS_NAME_PLACEHOLDER)
this._valueDisplay.replaceChildren(this._config.placeholder)
}
_updateHiddenInput(): void {
if (!this._hiddenInput) {
return
}
const values = this._getSelectedItems().map((element) => element.dataset.bsValue)
this._hiddenInput.value = this._config.multiple ? values.join(',') : (values[0] ?? '')
}
_getSelectedItems(): HTMLElement[] {
return SelectorEngine.find(`${SELECTOR_ITEM}.${CLASS_NAME_SELECTED}`, this._menu)
}
_getVisibleItems(): HTMLElement[] {
return SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter((item) => isVisible(item))
}
_filterItems(query: string): void {
const normalizedQuery = this._normalizeText(query.toLowerCase().trim())
let visibleCount = 0
let header: HTMLElement | null = null
let headerHasMatch = false
for (const element of Array.from(this._menu.children) as HTMLElement[]) {
if (element.matches('.dropdown-header')) {
header?.style.setProperty('display', headerHasMatch ? '' : 'none')
header = element
headerHasMatch = false
} else if (element.matches(SELECTOR_ITEM)) {
const matches = !normalizedQuery || this._normalizeText(element.textContent!.toLowerCase().trim()).includes(normalizedQuery)
element.style.display = matches ? '' : 'none'
if (matches) {
visibleCount++
headerHasMatch = true
}
}
}
header?.style.setProperty('display', headerHasMatch ? '' : 'none')
this._noResults?.classList.toggle(CLASS_NAME_HIDDEN, visibleCount > 0)
this._popper?.update()
}
_normalizeText(text: string): string {
return this._config.searchNormalize ? text.normalize('NFD').replace(/[̀-ͯ]/g, '') : text
}
_focusItem(items: HTMLElement[], index: 'first' | 'last'): void {
if (items.length > 0) {
items[index === 'first' ? 0 : items.length - 1].focus()
}
}
_handleToggleKeydown(event: KeyboardEvent): void {
const { key } = event
if (key === ARROW_DOWN_KEY || key === ARROW_UP_KEY) {
event.preventDefault()
this.show()
this._focusItem(this._getVisibleItems(), key === ARROW_DOWN_KEY ? 'first' : 'last')
return
}
if ((key === ENTER_KEY || key === SPACE_KEY) && !this._isShown()) {
event.preventDefault()
this.show()
return
}
if (key === BACKSPACE_KEY && this._isMultipleTags()) {
event.preventDefault()
this._removeLastValue()
return
}
if (this._searchInput && key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey && !this._isShown()) {
event.preventDefault()
this.show()
this._searchInput.value = key
this._filterItems(key)
}
}
_handleMenuKeydown(event: KeyboardEvent): void {
const { key } = event
const target = event.target as HTMLElement
if (key === ESCAPE_KEY) {
event.preventDefault()
event.stopPropagation()
this.hide()
this._toggle.focus()
return
}
if (key === TAB_KEY) {
this.hide()
return
}
if (key === BACKSPACE_KEY && target === this._searchInput && this._searchInput.value === '' && this._isMultipleTags()) {
this._removeLastValue()
return
}
const items = this._getVisibleItems()
if (key === ARROW_DOWN_KEY || key === ARROW_UP_KEY) {
event.preventDefault()
event.stopPropagation()
if (items.length > 0) {
getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()
}
return
}
if (key === HOME_KEY || key === END_KEY) {
if (target.matches('input')) {
return
}
event.preventDefault()
this._focusItem(items, key === HOME_KEY ? 'first' : 'last')
return
}
if ((key === ENTER_KEY || key === SPACE_KEY) && !target.matches('input')) {
const item = target.closest<HTMLElement>(SELECTOR_ITEM)
if (item && !isDisabled(item)) {
event.preventDefault()
this._selectItem(item)
}
}
}
}
/**
* Data API implementation
*/
// js-docs-start combobox-init
eventActionOnPlugin(Combobox, EVENT_CLICK_DATA_API, `${SELECTOR_DATA_TOGGLE}:not(select)`, 'toggle')
initAll(SELECTOR_DATA_TOGGLE, Combobox)
// js-docs-end combobox-init
export default Combobox
+2
View File
@@ -1,5 +1,6 @@
import './src/autosize'
import './src/clipboard'
import './src/combobox'
import './src/confetti'
import './src/countup'
import './src/datepicker'
@@ -22,6 +23,7 @@ export * from './src/bootstrap'
// Tabler's own components
export { default as Autosize } from './src/autosize'
export { default as Clipboard } from './src/clipboard'
export { default as Combobox } from './src/combobox'
export { default as Confetti } from './src/confetti'
export { default as CountUp } from './src/countup'
export { default as Datepicker } from './src/datepicker'
+453
View File
@@ -0,0 +1,453 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import { clearFixture, getFixture } from '../helpers/fixture'
import Combobox from '../../src/combobox'
const markup = (attributes = '', extra = ''): string => `
<form>
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Pick one" ${attributes}>
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
${extra}
<button class="dropdown-item" type="button" data-bs-value="a">Alpha</button>
<button class="dropdown-item" type="button" data-bs-value="b">Bravo</button>
<button class="dropdown-item" type="button" data-bs-value="c" disabled>Charlie</button>
</div>
</form>`
describe('Combobox', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
const setup = (attributes = '', extra = ''): { toggle: HTMLElement; menu: HTMLElement; combobox: Combobox } => {
fixtureEl.innerHTML = markup(attributes, extra)
const toggle = fixtureEl.querySelector<HTMLElement>('.combobox-toggle')!
return { toggle, menu: fixtureEl.querySelector<HTMLElement>('.dropdown-menu')!, combobox: new Combobox(toggle) }
}
const item = (value: string): HTMLElement => fixtureEl.querySelector(`[data-bs-value="${value}"]`)!
const key = (target: Element, name: string): void => {
target.dispatchEvent(new KeyboardEvent('keydown', { key: name, bubbles: true, cancelable: true }))
}
describe('constructor', () => {
it('should show the placeholder and set ARIA attributes', () => {
const { toggle, menu } = setup()
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Pick one')
expect(toggle.querySelector('.combobox-value')!.classList.contains('combobox-placeholder')).toBe(true)
expect(toggle.getAttribute('aria-haspopup')).toBe('listbox')
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(menu.getAttribute('role')).toBe('listbox')
expect(item('a').getAttribute('role')).toBe('option')
expect(item('c').getAttribute('aria-disabled')).toBe('true')
})
it('should read a pre-selected item', () => {
fixtureEl.innerHTML = markup('data-bs-name="letter"').replace('class="dropdown-item" type="button" data-bs-value="b"', 'class="dropdown-item selected" type="button" data-bs-value="b"')
const toggle = fixtureEl.querySelector<HTMLElement>('.combobox-toggle')!
new Combobox(toggle)
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Bravo')
expect(fixtureEl.querySelector<HTMLInputElement>('input[name="letter"]')!.value).toBe('b')
})
})
describe('show / hide', () => {
it('should toggle the menu and fire events', () => {
const { toggle, menu, combobox } = setup()
const shown = vi.fn()
const hidden = vi.fn()
toggle.addEventListener('shown.bs.combobox', shown)
toggle.addEventListener('hidden.bs.combobox', hidden)
combobox.show()
expect(menu.classList.contains('show')).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(shown).toHaveBeenCalledOnce()
combobox.hide()
expect(menu.classList.contains('show')).toBe(false)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(hidden).toHaveBeenCalledOnce()
})
it('should not show when show.bs.combobox is prevented', () => {
const { toggle, menu, combobox } = setup()
toggle.addEventListener('show.bs.combobox', (event) => event.preventDefault())
combobox.show()
expect(menu.classList.contains('show')).toBe(false)
})
it('should open on click of the toggle', () => {
const { toggle, menu } = setup()
toggle.click()
expect(menu.classList.contains('show')).toBe(true)
})
it('should not open a disabled toggle', () => {
const { menu, combobox } = setup('disabled')
combobox.show()
expect(menu.classList.contains('show')).toBe(false)
})
it('should close on an outside click and on Escape', () => {
const { toggle, menu, combobox } = setup()
combobox.show()
document.body.click()
expect(menu.classList.contains('show')).toBe(false)
combobox.show()
key(item('a'), 'Escape')
expect(menu.classList.contains('show')).toBe(false)
expect(document.activeElement).toBe(toggle)
})
})
describe('selection', () => {
it('should select an item, update text and hidden input, and close', () => {
const { toggle, menu, combobox } = setup('data-bs-name="letter"')
const change = vi.fn()
toggle.addEventListener('change.bs.combobox', change)
combobox.show()
item('b').click()
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Bravo')
expect(item('b').classList.contains('selected')).toBe(true)
expect(item('b').getAttribute('aria-selected')).toBe('true')
expect(fixtureEl.querySelector<HTMLInputElement>('input[name="letter"]')!.value).toBe('b')
expect(menu.classList.contains('show')).toBe(false)
expect(change).toHaveBeenCalledOnce()
expect((change.mock.calls[0][0] as CustomEvent & { value: string }).value).toBe('b')
})
it('should replace the previous selection', () => {
const { combobox } = setup()
combobox.show()
item('a').click()
combobox.show()
item('b').click()
expect(item('a').classList.contains('selected')).toBe(false)
expect(item('a').getAttribute('aria-selected')).toBe('false')
})
it('should ignore disabled items', () => {
const { toggle, combobox } = setup()
combobox.show()
item('c').click()
expect(item('c').classList.contains('selected')).toBe(false)
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Pick one')
})
it('should select with Enter on a focused item', () => {
const { combobox } = setup()
combobox.show()
key(item('a'), 'Enter')
expect(item('a').classList.contains('selected')).toBe(true)
})
it('should end the list with "and N more" when it does not fit', () => {
const { toggle, menu, combobox } = setup('data-bs-multiple="true"')
const value = toggle.querySelector<HTMLElement>('.combobox-value')!
Object.assign(value.style, { display: 'flex', width: '110px', overflow: 'hidden' })
combobox.show()
menu.querySelector<HTMLElement>('[data-bs-value="a"]')!.click()
menu.querySelector<HTMLElement>('[data-bs-value="b"]')!.click()
const label = value.querySelector<HTMLElement>('.text-truncate')!
Object.assign(label.style, { overflow: 'hidden', whiteSpace: 'nowrap', display: 'block', width: '60px' })
combobox._layoutList(['Alpha', 'Bravo', 'Charlie'])
expect(label.textContent).toMatch(/^Alpha and 2 more$/)
})
it('should support multiple selection and keep the menu open', () => {
const { toggle, menu, combobox } = setup('data-bs-multiple="true" data-bs-name="letters"')
combobox.show()
item('a').click()
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Alpha')
item('b').click()
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Alpha, Bravo')
expect(fixtureEl.querySelector<HTMLInputElement>('input[name="letters"]')!.value).toBe('a,b')
expect(menu.classList.contains('show')).toBe(true)
item('a').click()
expect(item('a').classList.contains('selected')).toBe(false)
})
})
describe('keyboard', () => {
it('should open on ArrowDown and focus the first item', () => {
const { toggle, menu } = setup()
key(toggle, 'ArrowDown')
expect(menu.classList.contains('show')).toBe(true)
expect(document.activeElement).toBe(item('a'))
})
it('should move between enabled items with arrows, Home and End', () => {
const { combobox } = setup()
combobox.show()
item('a').focus()
key(item('a'), 'ArrowDown')
expect(document.activeElement).toBe(item('b'))
key(item('b'), 'Home')
expect(document.activeElement).toBe(item('a'))
key(item('a'), 'End')
expect(document.activeElement).toBe(item('b'))
})
})
describe('search', () => {
const search = '<div class="combobox-search"><input type="text" class="combobox-search-input"></div><div class="combobox-no-results d-none">None</div>'
it('should filter items and show the empty message', () => {
setup('data-bs-search="true"', search)
const input = fixtureEl.querySelector<HTMLInputElement>('.combobox-search-input')!
const empty = fixtureEl.querySelector('.combobox-no-results')!
input.value = 'alp'
input.dispatchEvent(new Event('input', { bubbles: true }))
expect(item('a').style.display).toBe('')
expect(item('b').style.display).toBe('none')
expect(empty.classList.contains('d-none')).toBe(true)
input.value = 'zzz'
input.dispatchEvent(new Event('input', { bubbles: true }))
expect(empty.classList.contains('d-none')).toBe(false)
})
it('should ignore accents when searchNormalize is on', () => {
fixtureEl.innerHTML = markup('data-bs-search-normalize="true"', search).replace('Alpha', 'Ålpha')
new Combobox(fixtureEl.querySelector<HTMLElement>('.combobox-toggle')!)
const input = fixtureEl.querySelector<HTMLInputElement>('.combobox-search-input')!
input.value = 'alpha'
input.dispatchEvent(new Event('input', { bubbles: true }))
expect(item('a').style.display).toBe('')
})
})
describe('dispose', () => {
it('should remove the hidden input and the instance', () => {
const { toggle, combobox } = setup('data-bs-name="letter"')
combobox.dispose()
expect(fixtureEl.querySelector('input[name="letter"]')).toBeNull()
expect(Combobox.getInstance(toggle)).toBeNull()
})
})
describe('native select', () => {
const selectMarkup = (attributes = '', extra = ''): string => `
<form>
<label for="fruit">Fruit</label>
<select id="fruit" class="form-select is-invalid" data-placeholder="Pick a fruit" ${attributes}>
${extra}
<option value="a">Apple</option>
<option value="b" data-custom-properties='<span class="avatar">B</span>'>Banana</option>
<optgroup label="Citrus">
<option value="c">Cherry</option>
<option value="d" disabled>Date</option>
</optgroup>
</select>
</form>`
const setupSelect = (attributes = '', extra = '', config: Record<string, unknown> = {}): { select: HTMLSelectElement; toggle: HTMLElement; menu: HTMLElement; combobox: Combobox } => {
fixtureEl.innerHTML = selectMarkup(attributes, extra)
const select = fixtureEl.querySelector('select')!
const combobox = new Combobox(select, config)
return { select, toggle: select.nextElementSibling as HTMLElement, menu: fixtureEl.querySelector<HTMLElement>('.dropdown-menu')!, combobox }
}
it('should build the toggle and menu from options and optgroups', () => {
const { select, toggle, menu } = setupSelect()
expect(select.classList.contains('combobox-native')).toBe(true)
expect(toggle.classList.contains('combobox-toggle')).toBe(true)
expect(toggle.classList.contains('is-invalid')).toBe(true)
expect(toggle.getAttribute('aria-labelledby')).toBe('fruit-label')
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Pick a fruit')
expect(menu.querySelectorAll('.dropdown-item')).toHaveLength(4)
expect(menu.querySelector('.dropdown-header')!.textContent).toBe('Citrus')
expect(menu.querySelector<HTMLButtonElement>('[data-bs-value="d"]')!.disabled).toBe(true)
expect(menu.querySelector('.dropdown-item-indicator .avatar')).not.toBeNull()
expect(menu.querySelector('.combobox-search-input')).not.toBeNull()
})
it('should skip the empty placeholder option and use its text as placeholder', () => {
fixtureEl.innerHTML = '<select id="x"><option value="">Choose…</option><option value="a">Alpha</option></select>'
const select = fixtureEl.querySelector('select')!
new Combobox(select)
expect(fixtureEl.querySelectorAll('.dropdown-item')).toHaveLength(1)
expect(fixtureEl.querySelector('.combobox-value')!.textContent).toBe('Choose…')
})
it('should write the selection to the select and fire a native change', () => {
const { select, toggle, menu, combobox } = setupSelect()
const change = vi.fn()
select.addEventListener('change', change)
combobox.show()
menu.querySelector<HTMLElement>('[data-bs-value="c"]')!.click()
expect(select.value).toBe('c')
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Cherry')
expect(change).toHaveBeenCalled()
expect(menu.classList.contains('show')).toBe(false)
})
it('should show the option markup in the toggle', () => {
const { menu, toggle, combobox } = setupSelect()
combobox.show()
menu.querySelector<HTMLElement>('[data-bs-value="b"]')!.click()
expect(toggle.querySelector('.avatar')).not.toBeNull()
expect(toggle.textContent).toContain('Banana')
})
it('should pick up a value set from code after refresh', () => {
const { select, toggle, combobox } = setupSelect()
select.value = 'a'
combobox.refresh()
expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Apple')
})
it('should sync when the select fires change', () => {
const { select, toggle } = setupSelect()
select.value = 'b'
select.dispatchEvent(new Event('change', { bubbles: true }))
expect(toggle.textContent).toContain('Banana')
})
it('should render tags for multiple and deselect one from the menu', () => {
const { select, toggle, menu, combobox } = setupSelect('multiple')
combobox.show()
menu.querySelector<HTMLElement>('[data-bs-value="a"]')!.click()
menu.querySelector<HTMLElement>('[data-bs-value="c"]')!.click()
expect([...select.selectedOptions].map((option) => option.value)).toEqual(['a', 'c'])
expect(toggle.querySelectorAll('.combobox-tag')).toHaveLength(2)
expect(menu.classList.contains('show')).toBe(true)
expect(toggle.querySelector('.btn-close')).toBeNull()
menu.querySelector<HTMLElement>('[data-bs-value="a"]')!.click()
expect([...select.selectedOptions].map((option) => option.value)).toEqual(['c'])
})
it('should remove the last tag on Backspace', () => {
const { select, toggle, menu, combobox } = setupSelect('multiple')
combobox.show()
menu.querySelector<HTMLElement>('[data-bs-value="a"]')!.click()
menu.querySelector<HTMLElement>('[data-bs-value="c"]')!.click()
key(toggle, 'Backspace')
expect([...select.selectedOptions].map((option) => option.value)).toEqual(['a'])
})
it('should collapse tags that do not fit into a +N tag', () => {
const { toggle, menu, combobox } = setupSelect('multiple')
const value = toggle.querySelector<HTMLElement>('.combobox-value')!
Object.assign(value.style, { display: 'flex', width: '90px', overflow: 'hidden' })
combobox.show()
for (const option of ['a', 'b', 'c']) {
menu.querySelector<HTMLElement>(`[data-bs-value="${option}"]`)!.click()
}
for (const tag of toggle.querySelectorAll<HTMLElement>('.combobox-tag')) {
tag.style.flexShrink = '0'
}
combobox._layoutTags()
const more = toggle.querySelector('.combobox-tag-more')!
const hidden = toggle.querySelectorAll('.combobox-tag.d-none').length
expect(hidden).toBeGreaterThan(0)
expect(more.textContent).toBe(`+${hidden}`)
expect(toggle.querySelectorAll('.combobox-tag:not(.d-none)').length).toBeGreaterThanOrEqual(1)
})
it('should keep every tag when tagsOverflow is wrap', () => {
const { toggle, menu, combobox } = setupSelect('multiple', '', { tagsOverflow: 'wrap' })
combobox.show()
menu.querySelector<HTMLElement>('[data-bs-value="a"]')!.click()
menu.querySelector<HTMLElement>('[data-bs-value="b"]')!.click()
expect(toggle.querySelector('.combobox-tag-more')).toBeNull()
expect(toggle.querySelector('.combobox-tags-wrap')).not.toBeNull()
})
it('should respect maxItems', () => {
const { select, menu, combobox } = setupSelect('multiple', '', { maxItems: 1 })
combobox.show()
menu.querySelector<HTMLElement>('[data-bs-value="a"]')!.click()
menu.querySelector<HTMLElement>('[data-bs-value="b"]')!.click()
expect([...select.selectedOptions].map((option) => option.value)).toEqual(['a'])
})
it('should hide a group header when search matches none of its items', () => {
const { menu } = setupSelect()
const input = menu.querySelector<HTMLInputElement>('.combobox-search-input')!
input.value = 'apple'
input.dispatchEvent(new Event('input', { bubbles: true }))
expect(menu.querySelector<HTMLElement>('.dropdown-header')!.style.display).toBe('none')
})
it('should move the menu into the container', () => {
setupSelect('', '', { container: 'body' })
const menu = document.querySelector<HTMLElement>('body > .dropdown-menu')
expect(menu).not.toBeNull()
menu!.remove()
})
it('should not build a search input when search is false', () => {
const { menu } = setupSelect('', '', { search: false })
expect(menu.querySelector('.combobox-search-input')).toBeNull()
})
it('should disable the toggle for a disabled select', () => {
const { toggle, menu, combobox } = setupSelect('disabled')
combobox.show()
expect(menu.classList.contains('show')).toBe(false)
expect(toggle.hasAttribute('disabled')).toBe(true)
})
it('should forward focus from the select to the toggle', () => {
const { select, toggle } = setupSelect()
select.focus()
expect(document.activeElement).toBe(toggle)
})
it('should restore the select on dispose', () => {
const { select, combobox } = setupSelect()
combobox.dispose()
expect(select.classList.contains('combobox-native')).toBe(false)
expect(fixtureEl.querySelector('.combobox-toggle')).toBeNull()
expect(fixtureEl.querySelector('.dropdown-menu')).toBeNull()
})
})
})
+1
View File
@@ -68,6 +68,7 @@
@forward 'ui/forms/form-custom';
@forward 'ui/forms/form-check';
@forward 'ui/forms/form-otp';
@forward 'ui/forms/combobox';
@forward 'ui/forms/validation';
@forward 'ui/forms/strength';
@forward 'ui/legend';
+1
View File
@@ -1405,6 +1405,7 @@ $dropdown-header-padding-y: $dropdown-padding-y !default;
$dropdown-max-width: 25rem !default;
$dropdown-scrollable-height: 13rem !default;
$combobox-menu-max-height: 300px !default;
$dropdown-item-gap: 0.5rem !default;
$dropdown-header-padding-bottom: 0.25rem !default;
$dropdown-menu-columns-gap: 0.25rem !default;
+131
View File
@@ -0,0 +1,131 @@
@use '../../config' as *;
.combobox-toggle {
// scss-docs-start combobox-css-vars
--combobox-menu-max-height: #{$combobox-menu-max-height};
--combobox-placeholder-color: #{$input-placeholder-color};
// scss-docs-end combobox-css-vars
text-align: start;
cursor: pointer;
&:disabled,
&.disabled {
cursor: not-allowed;
}
&.disabled {
pointer-events: none;
opacity: 0.65;
}
}
.combobox-has-tags {
padding-block: calc(#{$input-padding-y} - (#{$tag-height} - 1lh) / 2);
&.form-select-sm {
padding-block: calc(#{$input-padding-y-sm} - (#{$tag-height} - 1lh) / 2);
}
&.form-select-lg {
padding-block: calc(#{$input-padding-y-lg} - (#{$tag-height} - 1lh) / 2);
}
}
.combobox-menu {
z-index: $zindex-popover;
max-height: var(--combobox-menu-max-height, #{$combobox-menu-max-height});
overflow-y: auto;
overscroll-behavior: contain;
}
.combobox-native {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
pointer-events: none;
border: 0;
opacity: 0;
}
.combobox-value {
display: flex;
gap: 0.5rem;
align-items: center;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.combobox-tags {
flex-wrap: nowrap;
gap: 0.25rem;
}
.combobox-tags-wrap {
flex-wrap: wrap;
white-space: normal;
}
.combobox-tag,
.combobox-tag-more {
flex-shrink: 0;
max-width: 100%;
.text-truncate {
min-width: 0;
}
}
.tag.combobox-tag,
.tag.combobox-tag-more {
background: var(--bg-surface-secondary);
}
.combobox-tag-more {
color: var(--secondary);
}
.combobox-placeholder {
color: var(--combobox-placeholder-color);
}
.combobox-search {
position: sticky;
top: 0;
z-index: 1;
padding: $dropdown-padding-y $dropdown-item-padding-x;
background-color: var(--dropdown-bg);
}
.combobox-no-results {
padding: $spacer;
color: var(--tertiary);
text-align: center;
}
.dropdown-item-content {
display: flex;
flex-grow: 1;
flex-direction: column;
min-width: 0;
}
.dropdown-item-description {
font-size: $font-size-sm;
color: var(--tertiary);
}
.dropdown-item-check {
flex-shrink: 0;
margin-inline-start: auto;
visibility: hidden;
.selected > & {
visibility: visible;
}
}
+412
View File
@@ -0,0 +1,412 @@
---
title: Combobox
seoTitle: Combobox select
order: 14
summary: A select-like field built from a toggle button and a dropdown menu, with custom item content, live search and multiple selection.
description: Add a customizable combobox (select replacement) with search, multiple selection, rich items, form submission and full keyboard support.
related: [/ui/forms/elements, /ui/components/dropdowns, /ui/forms/validation]
source: core/scss/ui/forms/_combobox.scss
css-vars: combobox
classnames:
component:
- class: combobox-toggle
desc: The button that shows the current value; use it together with `.form-select`
part:
- class: combobox-value
desc: Holds the selected label or the placeholder
- class: combobox-placeholder
desc: Added to `.combobox-value` while nothing is selected
- class: combobox-search
desc: Sticky wrapper for the search input inside the menu
- class: combobox-search-input
desc: The search field
- class: combobox-no-results
desc: Message shown when the search finds nothing
- class: dropdown-item-content
desc: Column layout for an item with a description
- class: dropdown-item-description
desc: Secondary text under the item label
- class: dropdown-item-check
desc: Check mark shown on the selected item
---
import Example from '@components/Example.astro';
import CodeDocs from '@components/CodeDocs.astro';
## Overview
A combobox looks like a select, but its options are ordinary HTML, so an item can hold an icon, a description, a badge or an image. It is a toggle button paired with a `.dropdown-menu`. JavaScript handles the menu, the selection, a hidden input for form submission and the keyboard.
Add `data-bs-toggle="combobox"` to a `<button class="form-select combobox-toggle">`, followed by a `.dropdown-menu` with `.dropdown-item` buttons. Every selectable item needs a `data-bs-value`.
<Example column overflow="visible">
<div class="w-100">
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Select an item…" data-bs-name="option">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-bs-value="1">Option one</button>
<button class="dropdown-item" type="button" data-bs-value="2">Option two</button>
<button class="dropdown-item" type="button" data-bs-value="3">Option three</button>
</div>
</div>
</Example>
## Usage
### Pre-selected value
Add `.selected` to an item to select it on load. The toggle text and the hidden input are filled in for you.
<Example column overflow="visible">
<div class="w-100">
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Choose a role…" data-bs-name="role">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-bs-value="admin">Admin</button>
<button class="dropdown-item selected" type="button" data-bs-value="editor">Editor</button>
<button class="dropdown-item" type="button" data-bs-value="viewer">Viewer</button>
</div>
</div>
</Example>
### Form field
Wrap the combobox in a `.mb-3` with a label, like any other field.
<Example column overflow="visible">
<div class="mb-3 w-100">
<label class="form-label">Country</label>
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Pick a country…" data-bs-name="country">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-bs-value="us">United States</button>
<button class="dropdown-item" type="button" data-bs-value="uk">United Kingdom</button>
<button class="dropdown-item" type="button" data-bs-value="ca">Canada</button>
</div>
</div>
</Example>
### Descriptions and check mark
Use `.dropdown-item-content` with `.dropdown-item-description` for secondary text, and a `.dropdown-item-check` icon to mark the selected item.
<Example column overflow="visible">
<div class="w-100">
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Select a role…" data-bs-name="access">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-bs-value="admin">
<span class="dropdown-item-content">
<span>Admin</span>
<small class="dropdown-item-description">Full access to all resources</small>
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg>
</button>
<button class="dropdown-item" type="button" data-bs-value="editor">
<span class="dropdown-item-content">
<span>Editor</span>
<small class="dropdown-item-description">Can edit and publish content</small>
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg>
</button>
<button class="dropdown-item" type="button" data-bs-value="viewer">
<span class="dropdown-item-content">
<span>Viewer</span>
<small class="dropdown-item-description">Read-only access</small>
</span>
<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg>
</button>
</div>
</div>
</Example>
### Search
Place a `.combobox-search` input inside the menu, before the items, and the combobox filters the items by their text as you type. Add an optional `.combobox-no-results` message after the items. Set `data-bs-search-normalize="true"` to ignore accents.
<Example column overflow="visible">
<div class="w-100">
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Select a country…" data-bs-search-normalize="true">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<div class="combobox-search">
<input type="text" class="form-control combobox-search-input" placeholder="Search…" autocomplete="off" aria-label="Search" />
</div>
<button class="dropdown-item" type="button" data-bs-value="us">United States</button>
<button class="dropdown-item" type="button" data-bs-value="uk">United Kingdom</button>
<button class="dropdown-item" type="button" data-bs-value="ca">Canada</button>
<button class="dropdown-item" type="button" data-bs-value="au">Australia</button>
<button class="dropdown-item" type="button" data-bs-value="de">Germany</button>
<button class="dropdown-item" type="button" data-bs-value="fr">France</button>
<button class="dropdown-item" type="button" data-bs-value="pl">Poland</button>
<button class="dropdown-item" type="button" data-bs-value="jp">Japan</button>
<div class="combobox-no-results d-none">No results found</div>
</div>
</div>
</Example>
### Groups
Use `.dropdown-header` to label groups of items and `.dropdown-divider` to separate them.
<Example column overflow="visible">
<div class="w-100">
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Select a language…">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<div class="dropdown-header">Frontend</div>
<button class="dropdown-item" type="button" data-bs-value="html">HTML</button>
<button class="dropdown-item" type="button" data-bs-value="css">CSS</button>
<div class="dropdown-divider"></div>
<div class="dropdown-header">Backend</div>
<button class="dropdown-item" type="button" data-bs-value="python">Python</button>
<button class="dropdown-item" type="button" data-bs-value="go">Go</button>
</div>
</div>
</Example>
### Multiple selection
Add `data-bs-multiple="true"`. Items toggle on click and the menu stays open. The toggle lists the chosen labels separated by commas and ends with “and N more” when they do not fit, and the hidden input holds a comma-separated list of the values.
<Example column overflow="visible">
<div class="w-100">
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Select skills…" data-bs-name="skills" data-bs-multiple="true">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item selected" type="button" data-bs-value="html" aria-selected="true">HTML<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item selected" type="button" data-bs-value="css" aria-selected="true">CSS<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item selected" type="button" data-bs-value="js" aria-selected="true">JavaScript<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item selected" type="button" data-bs-value="py" aria-selected="true">Python<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item" type="button" data-bs-value="rb">Ruby<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item" type="button" data-bs-value="go">Go<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item" type="button" data-bs-value="php">PHP<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item" type="button" data-bs-value="java">Java<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item" type="button" data-bs-value="rust">Rust<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
<button class="dropdown-item" type="button" data-bs-value="swift">Swift<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-check icon icon-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12l5 5l10 -10" /></svg></button>
</div>
</div>
</Example>
### Sizes
Use `.form-select-sm` or `.form-select-lg` on the toggle.
<Example column overflow="visible">
<div class="w-100">
<button class="form-select combobox-toggle form-select-sm" type="button" data-bs-toggle="combobox" data-bs-placeholder="Small select…">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-bs-value="1">Option one</button>
<button class="dropdown-item" type="button" data-bs-value="2">Option two</button>
</div>
</div>
<div class="w-100">
<button class="form-select combobox-toggle form-select-lg" type="button" data-bs-toggle="combobox" data-bs-placeholder="Large select…">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-bs-value="1">Option one</button>
<button class="dropdown-item" type="button" data-bs-value="2">Option two</button>
</div>
</div>
</Example>
### Disabled
Add `disabled` to the toggle, or to single items.
<Example column overflow="visible">
<div class="w-100">
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Cannot interact…" disabled>
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-bs-value="1">Option one</button>
<button class="dropdown-item" type="button" data-bs-value="2">Option two</button>
</div>
</div>
<div class="w-100">
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-placeholder="Some items disabled…">
<span class="combobox-value"></span>
</button>
<div class="dropdown-menu">
<button class="dropdown-item" type="button" data-bs-value="1">Available</button>
<button class="dropdown-item" type="button" data-bs-value="2" disabled>Unavailable</button>
<button class="dropdown-item" type="button" data-bs-value="3">Available</button>
</div>
</div>
</Example>
## Native select
Add `data-bs-toggle="combobox"` to a `<select class="form-select">` and the combobox builds the toggle and the menu from its options. The `<select>` stays in the DOM, keeps its value and fires a normal `change` event, so forms, validation and your own scripts work as before. This is the mode to use in place of [Tom Select](/ui/plugins/advanced-select).
<Example column overflow="visible">
<div class="w-100">
<label class="form-label" for="combobox-fruit">Fruit</label>
<select class="form-select" id="combobox-fruit" data-bs-toggle="combobox" data-placeholder="Pick a fruit">
<optgroup label="Citrus">
<option value="orange">Orange</option>
<option value="lemon">Lemon</option>
</optgroup>
<optgroup label="Berries">
<option value="strawberry">Strawberry</option>
<option value="blueberry">Blueberry</option>
</optgroup>
</select>
</div>
</Example>
- `<optgroup>` becomes a `.dropdown-header`, and `disabled` options stay disabled.
- The placeholder comes from `data-placeholder`, or from the text of an empty `<option value="">`.
- A search field is added automatically. Turn it off with `data-bs-search="false"`.
- Classes such as `.is-invalid` and `.form-select-sm` are copied to the toggle.
### Multiple
Add `multiple` and the picks show as tags. Click a selected item in the menu to remove it. <kbd>Backspace</kbd> removes the last tag. There is no limit by default; add `data-bs-max-items` if you want one.
The tags stay on one line so the field keeps its height. The ones that do not fit are replaced by a `+N` tag, and clicking it opens the menu. Set `data-bs-tags-overflow="wrap"` to let the tags wrap onto more lines instead.
<Example column overflow="visible">
<div class="w-100">
<select class="form-select" multiple data-bs-toggle="combobox" data-placeholder="Pick fruits">
<option value="apple" selected>Apple</option>
<option value="banana" selected>Banana</option>
<option value="cherry" selected>Cherry</option>
<option value="date" selected>Date</option>
<option value="elderberry" selected>Elderberry</option>
<option value="fig" selected>Fig</option>
<option value="grape" selected>Grape</option>
<option value="kiwi">Kiwi</option>
<option value="mango">Mango</option>
</select>
</div>
</Example>
### Rich options
Put markup in a `data-custom-properties` attribute to show an avatar, flag or badge next to the option, in the menu and in the toggle.
<Example column overflow="visible">
<div class="w-100">
<select class="form-select" data-bs-toggle="combobox" data-placeholder="Assign to…">
<option value="1" data-custom-properties='<span class="avatar avatar-xs">JD</span>'>Jane Doe</option>
<option value="2" data-custom-properties='<span class="avatar avatar-xs">MS</span>'>Mark Smith</option>
<option value="3" data-custom-properties='<span class="avatar avatar-xs">AK</span>'>Amy Kim</option>
</select>
</div>
</Example>
### Menu container
By default the menu sits right after the toggle. Set `data-bs-container="body"` to append it to the page body, so a card or modal with `overflow: hidden` cannot clip it.
### Changing options from code
After you change the `<option>`s or set `select.value` in a script, call `refresh()` so the combobox reads them again.
```javascript
select.append(new Option('Fig', 'fig'))
tabler.Combobox.getInstance(select).refresh()
```
### Moving from Tom Select
| Tom Select | Combobox |
| --- | --- |
| `new TomSelect(select)` | `data-bs-toggle="combobox"` on the `<select>`, or `new tabler.Combobox(select)` |
| `dropdownParent: 'body'` | `data-bs-container="body"` |
| `maxItems` | `data-bs-max-items` |
| `data-placeholder` | Same attribute |
| `render.option` with `data-custom-properties` | `data-custom-properties`, no render function needed |
| `onChange` | The native `change` event on the `<select>` |
| `sync()` | `refresh()` |
| `create`, remote `load`, other plugins | Not supported yet |
## Form submission
When `data-bs-name` is set, a hidden `<input>` with that name is inserted before the toggle and updated on every change.
```html
<!-- What you write -->
<button class="form-select combobox-toggle" type="button" data-bs-toggle="combobox" data-bs-name="country">…</button>
<!-- What the script adds before the toggle -->
<input type="hidden" name="country" value="us" />
```
## JavaScript
### Initialization
The combobox initializes itself on every `[data-bs-toggle="combobox"]` on page load.
<CodeDocs name="combobox-init" file="core/js/src/combobox.ts" />
### Via data attributes
| Attribute | Description |
| --- | --- |
| `data-bs-toggle="combobox"` | Initializes the combobox on the toggle button. |
| `data-bs-value` | On a `.dropdown-item`, the value used when that item is selected. |
### Options
Pass options as `data-bs-*` attributes on the toggle, or as an object when creating the instance.
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `multiple` | boolean | `false` | Allow several items; the menu stays open. |
| `name` | string \| null | `null` | Name of the hidden input used for form submission. |
| `placeholder` | string | `''` | Text shown while nothing is selected. |
| `search` | boolean \| null | `null` | `false` removes the search field on a `<select>`. Markup mode uses a search input when the menu has one. |
| `searchPlaceholder` | string | `'Search…'` | Placeholder of the generated search field. |
| `noResultsText` | string | `'No results found'` | Text of the generated empty message. |
| `searchNormalize` | boolean | `false` | Ignore accents when searching. |
| `maxItems` | number \| null | `null` | Most items a multiple select accepts. |
| `tagsOverflow` | `'count'` \| `'wrap'` | `'count'` | How a multiple `<select>` shows tags that do not fit: a `+N` tag, or extra lines. |
| `container` | string \| element | `null` | Where to append the menu, for example `'body'`. |
| `placement` | string \| null | `null` | Popper placement; `bottom-start` (`bottom-end` in RTL) when empty. |
| `offset` | array \| string | `[0, 2]` | Offset of the menu from the toggle. |
| `boundary` | string \| element | `'clippingParents'` | Overflow boundary of the menu. |
### Methods
| Method | Description |
| --- | --- |
| `toggle()` | Opens or closes the menu. |
| `show()` | Opens the menu. |
| `hide()` | Closes the menu. |
| `update()` | Repositions an open menu. |
| `refresh()` | Rebuilds the menu from the `<select>` and re-reads its value. |
| `dispose()` | Removes the instance and the hidden input. |
| `getInstance(element)` | Static. Returns the instance bound to the element, or `null`. |
| `getOrCreateInstance(element, config)` | Static. Returns the existing instance, or creates one. |
### Events
Events fire on the toggle button.
| Event | Description |
| --- | --- |
| `show.bs.combobox` | Fires when `show()` is called; can be cancelled. |
| `shown.bs.combobox` | Fires once the menu is visible. |
| `hide.bs.combobox` | Fires when `hide()` is called; can be cancelled. |
| `hidden.bs.combobox` | Fires once the menu is hidden. |
| `change.bs.combobox` | Fires when the selection changes. `event.value` holds the value (an array with `multiple`) and `event.item` the clicked item. |
## Accessibility
- The toggle gets `aria-haspopup="listbox"` and `aria-expanded`; the menu gets `role="listbox"` and each item `role="option"` with `aria-selected`.
- Keyboard: <kbd>↓</kbd> / <kbd>↑</kbd> open the menu and move between items, <kbd>Home</kbd> / <kbd>End</kbd> jump to the first or last item, <kbd>Enter</kbd> / <kbd>Space</kbd> select, <kbd>Esc</kbd> closes and returns focus to the toggle, <kbd>Tab</kbd> closes.
- Label the field with a visible `<label>` or `aria-label`, and give the search input an `aria-label`.
+4
View File
@@ -383,6 +383,10 @@
{
"title": "OTP input",
"url": "/ui/forms/otp-input"
},
{
"title": "Combobox",
"url": "/ui/forms/combobox"
}
]
},