Scroll only the combobox list and add data-display, data-hint

Wrap the items in .combobox-list so the search field stays in place while
the list scrolls, make the search field small, open a long list at the
chosen item, show data-display in the toggle and data-hint in the menu, and
set the menu min-width from script because the CSS variable was prefixed.
This commit is contained in:
Bartek committed 2026-09-29 15:52:02 +02:00
1 parent 9f13141115
commit a0c090bde0
6 files changed
+106 -22

No files matched your search

+60 -14
View File
@@ -131,6 +131,7 @@ class Combobox extends BaseComponent {
_menu: HTMLElement
_valueDisplay: HTMLElement
_searchInput: HTMLInputElement | null
_list: HTMLElement
_noResults: HTMLElement | null
_hiddenInput: HTMLInputElement | null
_popper: Popper.Instance | null
@@ -153,6 +154,7 @@ class Combobox extends BaseComponent {
this._valueDisplay = SelectorEngine.findOne(SELECTOR_VALUE, this._toggle) as HTMLElement
this._menu = document.createElement('div')
this._menu.className = 'dropdown-menu'
this._list = this._createList()
this._noResults = null
this._searchInput = null
this._buildMenu()
@@ -164,6 +166,7 @@ class Combobox extends BaseComponent {
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._list = this._createList()
this._createHiddenInput()
}
@@ -204,7 +207,8 @@ class Combobox extends BaseComponent {
return
}
this._menu.style.minWidth = `${this._toggle.offsetWidth}px`
this._menu.style.minWidth = ''
this._menu.style.minWidth = `${Math.max(Number.parseFloat(getComputedStyle(this._menu).minWidth) || 0, this._toggle.offsetWidth)}px`
this._popper = Popper.createPopper(this._toggle, this._menu, this._getPopperConfig())
this._menu.classList.add(CLASS_NAME_SHOW)
@@ -217,6 +221,7 @@ class Combobox extends BaseComponent {
this._searchInput.focus()
}
this._scrollToSelected()
this._addOutsideListeners()
EventHandler.trigger(this._element, EVENT_SHOWN)
}
@@ -245,6 +250,11 @@ class Combobox extends BaseComponent {
this._popper?.update()
}
// Re-reads the chosen option of the `<select>` without rebuilding the menu.
sync(): void {
this._syncInitialSelection()
}
// 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) {
@@ -280,6 +290,37 @@ class Combobox extends BaseComponent {
}
// Private
// A long menu opens at the chosen item instead of at the top.
_scrollToSelected(): void {
const [selected] = this._getSelectedItems()
if (selected && this._list.scrollHeight > this._list.clientHeight) {
this._list.scrollTop = Math.max(0, selected.offsetTop - (this._list.clientHeight - selected.offsetHeight) / 2)
}
}
// The items sit in their own wrapper, so only the list scrolls and the search field stays in place above it.
_createList(): HTMLElement {
const existing = SelectorEngine.findOne('.combobox-list', this._menu)
if (existing) {
return existing
}
const list = Object.assign(document.createElement('div'), { className: 'combobox-list' })
const search = this._searchInput?.closest('.combobox-search')
for (const child of Array.from(this._menu.children)) {
if (child !== search && child !== this._noResults) {
list.append(child)
}
}
this._menu.insertBefore(list, this._noResults)
return list
}
_isShown(): boolean {
return this._menu.classList.contains(CLASS_NAME_SHOW)
}
@@ -344,26 +385,22 @@ class Combobox extends BaseComponent {
_buildMenu(): void {
const select = this._select!
const menu = this._menu
for (const element of SelectorEngine.find(`${SELECTOR_ITEM}, .dropdown-header`, menu)) {
for (const element of SelectorEngine.find(`${SELECTOR_ITEM}, .dropdown-header`, this._list)) {
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)
this._list.append(Object.assign(document.createElement('div'), { className: 'dropdown-header', textContent: child.label }))
for (const option of Array.from(child.children)) {
this._appendOption(option as HTMLOptionElement, child.disabled, anchor)
this._appendOption(option as HTMLOptionElement, child.disabled)
}
} else if (child instanceof HTMLOptionElement) {
this._appendOption(child, false, anchor)
this._appendOption(child, false)
}
}
@@ -372,7 +409,7 @@ class Combobox extends BaseComponent {
}
}
_appendOption(option: HTMLOptionElement, groupDisabled: boolean, anchor: Element | null): void {
_appendOption(option: HTMLOptionElement, groupDisabled: boolean): void {
if (option.value === '') {
return
}
@@ -392,10 +429,19 @@ class Combobox extends BaseComponent {
item.append(indicator)
}
if (option.dataset.display) {
item.dataset.bsDisplay = option.dataset.display
}
item.append(Object.assign(document.createElement('span'), { className: 'dropdown-item-label', textContent: option.textContent }))
if (option.dataset.hint) {
item.append(Object.assign(document.createElement('span'), { className: 'dropdown-item-hint', textContent: option.dataset.hint }))
}
item.insertAdjacentHTML('beforeend', CHECK_ICON)
this._menu.insertBefore(item, anchor)
this._list.append(item)
this._optionByItem.set(item, option)
}
@@ -403,7 +449,7 @@ class Combobox extends BaseComponent {
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',
className: 'form-control form-control-sm combobox-search-input',
placeholder: this._config.searchPlaceholder,
autocomplete: 'off',
})
@@ -626,7 +672,7 @@ class Combobox extends BaseComponent {
nodes.push(indicator.cloneNode(true))
}
nodes.push(Object.assign(document.createElement('span'), { className: 'text-truncate', textContent: this._getItemLabel(item) }))
nodes.push(Object.assign(document.createElement('span'), { className: 'text-truncate', textContent: item.dataset.bsDisplay ?? this._getItemLabel(item) }))
for (const addon of SelectorEngine.children(item, 'kbd, .badge')) {
nodes.push(addon.cloneNode(true))
@@ -761,7 +807,7 @@ class Combobox extends BaseComponent {
let header: HTMLElement | null = null
let headerHasMatch = false
for (const element of Array.from(this._menu.children) as HTMLElement[]) {
for (const element of Array.from(this._list.children) as HTMLElement[]) {
if (element.matches('.dropdown-header')) {
header?.style.setProperty('display', headerHasMatch ? '' : 'none')
header = element
+25
View File
@@ -274,6 +274,31 @@ describe('Combobox', () => {
return { select, toggle: select.nextElementSibling as HTMLElement, menu: fixtureEl.querySelector<HTMLElement>('.dropdown-menu')!, combobox }
}
it('should open a long menu at the chosen item', () => {
const { menu, combobox } = setupSelect('', '', { search: false })
const list = menu.querySelector<HTMLElement>('.combobox-list')!
Object.assign(list.style, { maxHeight: '60px', overflowY: 'auto', position: 'relative' })
for (const item of menu.querySelectorAll<HTMLElement>('.dropdown-item')) {
Object.assign(item.style, { display: 'block', height: '30px' })
}
combobox._select!.value = 'c'
combobox.sync()
combobox.show()
expect(list.scrollTop).toBeGreaterThan(0)
})
it('should make the menu at least as wide as the toggle', () => {
const { toggle, menu, combobox } = setupSelect()
toggle.style.display = 'block'
toggle.style.width = '240px'
combobox.show()
expect(Number.parseFloat(menu.style.minWidth)).toBeGreaterThanOrEqual(240)
})
it('should build the toggle and menu from options and optgroups', () => {
const { select, toggle, menu } = setupSelect()
+16 -5
View File
@@ -50,9 +50,14 @@
.combobox-menu {
z-index: $zindex-popover;
}
.combobox-list {
position: relative;
max-height: var(--combobox-menu-max-height, #{$combobox-menu-max-height});
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.combobox-native {
@@ -114,11 +119,7 @@
}
.combobox-search {
position: sticky;
top: 0;
z-index: 1;
padding: $dropdown-padding-y $dropdown-item-padding-x;
background-color: var(--dropdown-bg);
padding: 0 calc(0.75 * #{$dropdown-padding-y}) calc(0.75 * #{$dropdown-padding-y});
}
.combobox-search-input {
@@ -149,6 +150,16 @@
color: var(--tertiary);
}
.dropdown-item-hint {
flex-shrink: 0;
margin-inline-start: auto;
color: var(--secondary);
}
.dropdown-item-hint + .dropdown-item-check {
margin-inline-start: 0;
}
.dropdown-item-check {
flex-shrink: 0;
margin-inline-start: auto;
+3 -1
View File
@@ -17,6 +17,8 @@ classnames:
desc: Holds the selected label or the placeholder
- class: combobox-placeholder
desc: Added to `.combobox-value` while nothing is selected
- class: combobox-list
desc: Wrapper the script puts around the items; only the list scrolls, the search field stays above it
- class: combobox-search
desc: Sticky wrapper for the search input inside the menu
- class: combobox-search-input
@@ -136,7 +138,7 @@ Place a `.combobox-search` input inside the menu, before the items, and the comb
</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" />
<input type="text" class="form-control form-control-sm 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>
+1 -1
View File
@@ -26,7 +26,7 @@ const check = '<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-chec
</button>
<div class="dropdown-menu show w-100">
<div class="combobox-search">
<input type="text" class="form-control combobox-search-input" placeholder="Search…" autocomplete="off" aria-label="Search countries" />
<input type="text" class="form-control form-control-sm combobox-search-input" placeholder="Search…" autocomplete="off" aria-label="Search countries" />
</div>
{
countries.map(([code, name]) => (
+1 -1
View File
@@ -50,7 +50,7 @@ const check = '<svg xmlns="http://www.w3.org/2000/svg" class="dropdown-item-chec
</button>
<div class="dropdown-menu show w-100">
<div class="combobox-search">
<input type="text" class="form-control combobox-search-input" placeholder="Search…" autocomplete="off" aria-label="Search people" />
<input type="text" class="form-control form-control-sm combobox-search-input" placeholder="Search…" autocomplete="off" aria-label="Search people" />
</div>
{
team.map((person, index) => (