mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 21:27:06 +04:00
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:
1 parent
a15d5d71a1
commit
85f095cd4d
9 files changed
+1905
No files matched your search
@@ -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"`.
|
||||
@@ -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
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -383,6 +383,10 @@
|
||||
{
|
||||
"title": "OTP input",
|
||||
"url": "/ui/forms/otp-input"
|
||||
},
|
||||
{
|
||||
"title": "Combobox",
|
||||
"url": "/ui/forms/combobox"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user