mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
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:
1 parent
9f13141115
commit
a0c090bde0
6 files changed
+106
-22
No files matched your search
+60
-14
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]) => (
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
Reference in new issue
Block a user