From 39e84de172c4021ad7a71dc29d4ea1e4ec16c4b3 Mon Sep 17 00:00:00 2001 From: mrholek Date: Mon, 21 Oct 2024 21:36:55 +0200 Subject: [PATCH 1/2] feat(MultiSelect): allows to append the dropdown to a specific element --- docs/content/forms/multi-select.md | 1 + js/src/multi-select.js | 53 ++++++++++++++++++++++++++---- scss/forms/_form-multi-select.scss | 6 ++-- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/docs/content/forms/multi-select.md b/docs/content/forms/multi-select.md index 4af486426..62db0ba8b 100644 --- a/docs/content/forms/multi-select.md +++ b/docs/content/forms/multi-select.md @@ -277,6 +277,7 @@ const mulitSelectList = mulitSelectElementList.map(mulitSelectEl => { | Name | Type | Default | Description | | --- | --- | --- | --- | | `cleaner`| boolean| `true` | Enables selection cleaner element. | +| `container` | string, element, false | `false` | Appends the dropdown to a specific element. Example: `container: 'body'`. | | `disabled` | boolean | `false` | Toggle the disabled state for the component. | | `invalid` | boolean | `false` | Toggle the invalid state for the component. | | `multiple` | boolean | `true` | It specifies that multiple options can be selected at once. | diff --git a/js/src/multi-select.js b/js/src/multi-select.js index 9ea5c2822..432134ecf 100644 --- a/js/src/multi-select.js +++ b/js/src/multi-select.js @@ -13,6 +13,7 @@ import SelectorEngine from './dom/selector-engine.js' import { defineJQueryPlugin, getNextActiveElement, + getElement, isVisible, isRTL } from './util/index.js' @@ -80,6 +81,7 @@ const CLASS_NAME_TAG_DELETE = 'form-multi-select-tag-delete' const Default = { cleaner: true, + container: false, disabled: false, invalid: false, multiple: true, @@ -100,6 +102,7 @@ const Default = { const DefaultType = { cleaner: 'boolean', + container: '(string|element|boolean)', disabled: 'boolean', invalid: 'boolean', multiple: 'boolean', @@ -179,6 +182,12 @@ class MultiSelect extends BaseComponent { EventHandler.trigger(this._element, EVENT_SHOW) this._clone.classList.add(CLASS_NAME_SHOW) this._clone.setAttribute('aria-expanded', true) + + if (this._config.container) { + this._menu.style.minWidth = `${this._clone.offsetWidth}px` + this._menu.classList.add(CLASS_NAME_SHOW) + } + EventHandler.trigger(this._element, EVENT_SHOWN) this._createPopper() @@ -199,6 +208,11 @@ class MultiSelect extends BaseComponent { this._onSearchChange(this._searchElement) this._clone.classList.remove(CLASS_NAME_SHOW) this._clone.setAttribute('aria-expanded', 'false') + + if (this._config.container) { + this._menu.classList.remove(CLASS_NAME_SHOW) + } + EventHandler.trigger(this._element, EVENT_HIDDEN) } @@ -220,6 +234,7 @@ class MultiSelect extends BaseComponent { this._config = this._getConfig(config) this._options = this._getOptions() this._selected = this._getSelectedOptions(this._options) + this._menu.remove() this._clone.remove() this._element.innerHTML = '' this._createNativeOptions(this._element, this._options) @@ -534,6 +549,7 @@ class MultiSelect extends BaseComponent { }], placement: isRTL() ? 'bottom-end' : 'bottom-start' } + this._popper = Popper.createPopper(this._togglerElement, this._menu, popperConfig) } @@ -575,7 +591,13 @@ class MultiSelect extends BaseComponent { dropdownDiv.append(optionsDiv) - this._clone.append(dropdownDiv) + const { container } = this._config + if (container) { + // this._clone.parentNode.insertBefore(dropdownDiv, this._clone.nextSibling) + getElement(container).append(dropdownDiv) + } else { + this._clone.append(dropdownDiv) + } this._createOptions(optionsDiv, this._options) this._optionsElement = optionsDiv @@ -649,7 +671,7 @@ class MultiSelect extends BaseComponent { } const value = String(element.dataset.value) - const { text } = this._options.find(option => option.value === value) + const { text } = this._findOptionByValue(value) if (this._config.multiple && element.classList.contains(CLASS_NAME_SELECTED)) { this._deselectOption(value) @@ -666,6 +688,23 @@ class MultiSelect extends BaseComponent { } } + _findOptionByValue(value, options = this._options) { + for (const option of options) { + if (option.value === value) { + return option + } + + if (option.options && Array.isArray(option.options)) { + const found = this._findOptionByValue(value, option.options) + if (found) { + return found + } + } + } + + return null + } + _selectOption(value, text) { if (!this._config.multiple) { this.deselectAll() @@ -860,7 +899,7 @@ class MultiSelect extends BaseComponent { } _filterOptionsList() { - const options = SelectorEngine.find(SELECTOR_OPTION, this._clone) + const options = SelectorEngine.find(SELECTOR_OPTION, this._menu) let visibleOptions = 0 for (const option of options) { @@ -884,8 +923,8 @@ class MultiSelect extends BaseComponent { } if (visibleOptions > 0) { - if (SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._clone)) { - SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._clone).remove() + if (SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._menu)) { + SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._menu).remove() } return @@ -896,8 +935,8 @@ class MultiSelect extends BaseComponent { placeholder.classList.add(CLASS_NAME_OPTIONS_EMPTY) placeholder.innerHTML = this._config.searchNoResultsLabel - if (!SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._clone)) { - SelectorEngine.findOne(SELECTOR_OPTIONS, this._clone).append(placeholder) + if (!SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._menu)) { + SelectorEngine.findOne(SELECTOR_OPTIONS, this._menu).append(placeholder) } } } diff --git a/scss/forms/_form-multi-select.scss b/scss/forms/_form-multi-select.scss index 2ef371df8..04ba6ad67 100644 --- a/scss/forms/_form-multi-select.scss +++ b/scss/forms/_form-multi-select.scss @@ -1,4 +1,5 @@ -.form-multi-select { +.form-multi-select, +*:not(.form-multi-select) > .form-multi-select-dropdown { // scss-docs-start form-multi-select-css-vars --#{$prefix}form-multi-select-zindex: #{$form-multi-select-zindex}; --#{$prefix}form-multi-select-font-family: #{$form-multi-select-font-family}; @@ -326,7 +327,8 @@ select.form-multi-select { @include box-shadow(var(--#{$prefix}form-multi-select-dropdown-box-shadow)); @include elevation(4); - .form-multi-select.show & { + .form-multi-select.show &, + &.show { display: block; } } From e6d12b73add6db589adc70182ad659e2786a9738 Mon Sep 17 00:00:00 2001 From: mrholek Date: Wed, 23 Oct 2024 12:08:12 +0200 Subject: [PATCH 2/2] feat(MultiSelect): add a global search option, improve keyboard support and accessibility --- docs/content/forms/multi-select.md | 64 ++++++++++++++++++++++++++++-- js/src/multi-select.js | 60 +++++++++++++++++++++++----- scss/forms/_form-multi-select.scss | 11 +++-- 3 files changed, 119 insertions(+), 16 deletions(-) diff --git a/docs/content/forms/multi-select.md b/docs/content/forms/multi-select.md index 62db0ba8b..b1e97e9cb 100644 --- a/docs/content/forms/multi-select.md +++ b/docs/content/forms/multi-select.md @@ -16,7 +16,7 @@ other_frameworks: multi-select A straightforward demonstration of how to implement a basic Bootstrap Multi Select dropdown, highlighting essential attributes and configurations. {{< example >}} - @@ -77,6 +77,63 @@ We use the following JavaScript to set up our multi-select: {{< js-docs name="multi-select-array-data" file="docs/assets/js/snippets.js" >}} +## Search + +You can configure the search functionality within the component. The `data-coreui-search` option determines how the search input element is enabled and behaves. It accepts multiple types to provide flexibility in configuring search behavior. By default is set to `false`. + +{{< example >}} + +{{< /example >}} + +### Standard search + +To enable the default search input element with standard behavior, please add `data-coreui-search="true"` like in the example below: + +{{< example >}} + +{{< /example >}} + +### Global search + +{{< added-in "5.6.0" >}} + +To enable the global search functionality within the Multi Select component, please add `data-coreui-search="global"`. When `data-coreui-search` is set to `'global'`, the user can perform searches across the entire component, regardless of where their focus is within the component. This allows for a more flexible and intuitive search experience, ensuring the search input is recognized from any point within the component. + +{{< example >}} + +{{< /example >}} + + ## Selection types Explore different selection modes, including single and multiple selections, allowing customization based on user requirements. @@ -276,7 +333,8 @@ const mulitSelectList = mulitSelectElementList.map(mulitSelectEl => { {{< bs-table >}} | Name | Type | Default | Description | | --- | --- | --- | --- | -| `cleaner`| boolean| `true` | Enables selection cleaner element. | +| `ariaCleanerLabel`| string | `Clear all selections` | A string that provides an accessible label for the cleaner button. This label is read by screen readers to describe the action associated with the button. | +| `cleaner`| boolean | `true` | Enables selection cleaner element. | | `container` | string, element, false | `false` | Appends the dropdown to a specific element. Example: `container: 'body'`. | | `disabled` | boolean | `false` | Toggle the disabled state for the component. | | `invalid` | boolean | `false` | Toggle the invalid state for the component. | @@ -286,7 +344,7 @@ const mulitSelectList = mulitSelectElementList.map(mulitSelectEl => { | `optionsMaxHeight` | number, string | `'auto'` | Sets `max-height` of options list. | | `optionsStyle` | string | `'checkbox'` | Sets option style. | | `placeholder` | string | `'Select...'` | Specifies a short hint that is visible in the input. | -| `search` | boolean | `false` | Enables search input element. | +| `search` | boolean, string | `false` | Enables search input element. When set to `'global'`, the user can perform searches across the entire component, regardless of where their focus is within the component. | | `searchNoResultsLabel` | string | `'No results found'` | Sets the label for no results when filtering. | | `selectAll` | boolean | `true` | Enables select all button.| | `selectAllLabel` | string | `'Select all options'` | Sets the select all button label. | diff --git a/js/src/multi-select.js b/js/src/multi-select.js index 432134ecf..2b3d113ef 100644 --- a/js/src/multi-select.js +++ b/js/src/multi-select.js @@ -29,10 +29,13 @@ const DATA_KEY = 'coreui.multi-select' 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 BACKSPACE_KEY = 'Backspace' +const DELETE_KEY = 'Delete' +const ENTER_KEY = 'Enter' +const ESCAPE_KEY = 'Escape' +const TAB_KEY = 'Tab' const RIGHT_MOUSE_BUTTON = 2 // MouseEvent.button value for the secondary button, usually the right button const SELECTOR_CLEANER = '.form-multi-select-cleaner' @@ -80,6 +83,7 @@ const CLASS_NAME_TAG = 'form-multi-select-tag' const CLASS_NAME_TAG_DELETE = 'form-multi-select-tag-delete' const Default = { + ariaCleanerLabel: 'Clear all selections', cleaner: true, container: false, disabled: false, @@ -101,6 +105,7 @@ const Default = { } const DefaultType = { + ariaCleanerLabel: 'string', cleaner: 'boolean', container: '(string|element|boolean)', disabled: 'boolean', @@ -112,7 +117,7 @@ const DefaultType = { optionsStyle: 'string', placeholder: 'string', required: 'boolean', - search: 'boolean', + search: '(boolean|string)', searchNoResultsLabel: 'string', selectAll: 'boolean', selectAllLabel: 'string', @@ -204,7 +209,10 @@ class MultiSelect extends BaseComponent { this._popper.destroy() } - this._searchElement.value = '' + if (this._config.search) { + this._searchElement.value = '' + } + this._onSearchChange(this._searchElement) this._clone.classList.remove(CLASS_NAME_SHOW) this._clone.setAttribute('aria-expanded', 'false') @@ -288,6 +296,30 @@ class MultiSelect extends BaseComponent { EventHandler.on(this._clone, EVENT_KEYDOWN, event => { if (event.key === ESCAPE_KEY) { this.hide() + return + } + + if (this._config.search === 'global' && (event.key.length === 1 || event.key === BACKSPACE_KEY || event.key === DELETE_KEY)) { + this._searchElement.focus() + } + }) + + EventHandler.on(this._menu, EVENT_KEYDOWN, event => { + if (this._config.search === 'global' && (event.key.length === 1 || event.key === BACKSPACE_KEY || event.key === DELETE_KEY)) { + this._searchElement.focus() + } + }) + + EventHandler.on(this._togglerElement, EVENT_KEYDOWN, event => { + if (!this._isShown() && (event.key === ENTER_KEY || event.key === ARROW_DOWN_KEY)) { + event.preventDefault() + this.show() + return + } + + if (this._isShown() && event.key === ARROW_DOWN_KEY) { + event.preventDefault() + this._selectMenuItem(event) } }) @@ -302,9 +334,16 @@ class MultiSelect extends BaseComponent { }) EventHandler.on(this._searchElement, EVENT_KEYDOWN, event => { - const key = event.keyCode || event.charCode + if (!this._isShown()) { + this.show() + } + + if (event.key === ARROW_DOWN_KEY && this._searchElement.value.length === this._searchElement.selectionStart) { + this._selectMenuItem(event) + return + } - if ((key === 8 || key === 46) && event.target.value.length === 0) { + if ((event.key === BACKSPACE_KEY || event.key === DELETE_KEY) && event.target.value.length === 0) { this._deselectLastOption() } @@ -332,9 +371,7 @@ class MultiSelect extends BaseComponent { }) EventHandler.on(this._optionsElement, EVENT_KEYDOWN, event => { - const key = event.keyCode || event.charCode - - if (key === 13) { + if (event.key === ENTER_KEY) { this._onOptionsClick(event.target) } @@ -486,6 +523,10 @@ class MultiSelect extends BaseComponent { togglerEl.classList.add(CLASS_NAME_INPUT_GROUP) this._togglerElement = togglerEl + if (!this._config.search && !this._config.disabled) { + togglerEl.tabIndex = 0 + } + const selectionEl = document.createElement('div') selectionEl.classList.add(CLASS_NAME_SELECTION) @@ -509,6 +550,7 @@ class MultiSelect extends BaseComponent { cleaner.type = 'button' cleaner.classList.add(CLASS_NAME_CLEANER) cleaner.style.display = 'none' + cleaner.setAttribute('aria-label', this._config.ariaCleanerLabel) buttons.append(cleaner) this._selectionCleanerElement = cleaner diff --git a/scss/forms/_form-multi-select.scss b/scss/forms/_form-multi-select.scss index 04ba6ad67..d3e7e6d5c 100644 --- a/scss/forms/_form-multi-select.scss +++ b/scss/forms/_form-multi-select.scss @@ -143,7 +143,9 @@ select.form-multi-select { border-color: $input-disabled-border-color; } - .form-multi-select.show & { + .form-multi-select.show &, + &:has(*:focus), + &:focus { color: var(--#{$prefix}form-multi-select-focus-color); background-color: var(--#{$prefix}form-multi-select-focus-bg); border-color: var(--#{$prefix}form-multi-select-focus-border-color); @@ -173,8 +175,8 @@ select.form-multi-select { } .form-multi-select-search { - display: none; - flex: 1 1 auto; + display: flex; + flex: 0 1 0px; max-width: 100%; padding: 0; background: transparent; @@ -191,7 +193,8 @@ select.form-multi-select { .form-multi-select.show &, &:placeholder-shown { - display: flex; + flex: 1 1 auto; + // display: flex; } .form-multi-select-selection-tags & {