diff --git a/.changeset/combobox-component.md b/.changeset/combobox-component.md new file mode 100644 index 000000000..f0f231f38 --- /dev/null +++ b/.changeset/combobox-component.md @@ -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"`. diff --git a/core/js/src/combobox.ts b/core/js/src/combobox.ts new file mode 100644 index 000000000..8ba9fe377 --- /dev/null +++ b/core/js/src/combobox.ts @@ -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 & Record + +/** + * 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 = '' + +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 = { + 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 ` + + ` + +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('.combobox-toggle')! + return { toggle, menu: fixtureEl.querySelector('.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('.combobox-toggle')! + new Combobox(toggle) + + expect(toggle.querySelector('.combobox-value')!.textContent).toBe('Bravo') + expect(fixtureEl.querySelector('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('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('.combobox-value')! + Object.assign(value.style, { display: 'flex', width: '110px', overflow: 'hidden' }) + combobox.show() + menu.querySelector('[data-bs-value="a"]')!.click() + menu.querySelector('[data-bs-value="b"]')!.click() + + const label = value.querySelector('.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('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 = '
None
' + + it('should filter items and show the empty message', () => { + setup('data-bs-search="true"', search) + const input = fixtureEl.querySelector('.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('.combobox-toggle')!) + const input = fixtureEl.querySelector('.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 => ` +
+ + +
` + + const setupSelect = (attributes = '', extra = '', config: Record = {}): { 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('.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('[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 = '' + 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('[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('[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('[data-bs-value="a"]')!.click() + menu.querySelector('[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('[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('[data-bs-value="a"]')!.click() + menu.querySelector('[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('.combobox-value')! + Object.assign(value.style, { display: 'flex', width: '90px', overflow: 'hidden' }) + combobox.show() + + for (const option of ['a', 'b', 'c']) { + menu.querySelector(`[data-bs-value="${option}"]`)!.click() + } + + for (const tag of toggle.querySelectorAll('.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('[data-bs-value="a"]')!.click() + menu.querySelector('[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('[data-bs-value="a"]')!.click() + menu.querySelector('[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('.combobox-search-input')! + input.value = 'apple' + input.dispatchEvent(new Event('input', { bubbles: true })) + + expect(menu.querySelector('.dropdown-header')!.style.display).toBe('none') + }) + + it('should move the menu into the container', () => { + setupSelect('', '', { container: 'body' }) + const menu = document.querySelector('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() + }) + }) +}) diff --git a/core/scss/_core.scss b/core/scss/_core.scss index ca30366c8..34e77b25f 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -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'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index caa133799..7e4de51ea 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -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; diff --git a/core/scss/ui/forms/_combobox.scss b/core/scss/ui/forms/_combobox.scss new file mode 100644 index 000000000..4990d8a2c --- /dev/null +++ b/core/scss/ui/forms/_combobox.scss @@ -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; + } +} diff --git a/docs/content/ui/forms/combobox.mdx b/docs/content/ui/forms/combobox.mdx new file mode 100644 index 000000000..a7ba3e120 --- /dev/null +++ b/docs/content/ui/forms/combobox.mdx @@ -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 ` + + + + +## 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. + + +
+ + +
+
+ +### Form field + +Wrap the combobox in a `.mb-3` with a label, like any other field. + + +
+ + + +
+
+ +### 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. + + +
+ + +
+
+ +### 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. + + +
+ + +
+
+ +### Groups + +Use `.dropdown-header` to label groups of items and `.dropdown-divider` to separate them. + + +
+ + +
+
+ +### 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. + + +
+ + +
+
+ +### Sizes + +Use `.form-select-sm` or `.form-select-lg` on the toggle. + + +
+ + +
+
+ + +
+
+ +### Disabled + +Add `disabled` to the toggle, or to single items. + + +
+ + +
+
+ + +
+
+ +## Native select + +Add `data-bs-toggle="combobox"` to a `` 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). + + +
+ + +
+
+ +- `` becomes a `.dropdown-header`, and `disabled` options stay disabled. +- The placeholder comes from `data-placeholder`, or from the text of an empty `