Skip to content

Commit 4569cac

Browse files
committed
Merge branch 'refactor-multi-select'
2 parents a490cb1 + e6d12b7 commit 4569cac

File tree

3 files changed

+170
-25
lines changed

3 files changed

+170
-25
lines changed

docs/content/forms/multi-select.md

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ other_frameworks: multi-select
1616
A straightforward demonstration of how to implement a basic Bootstrap Multi Select dropdown, highlighting essential attributes and configurations.
1717

1818
{{< example >}}
19-
<select class="form-multi-select" id="ms1" multiple data-coreui-search="true">
19+
<select class="form-multi-select" id="ms1" multiple data-coreui-search="global">
2020
<option value="0">Angular</option>
2121
<option value="1">Bootstrap</option>
2222
<option value="2">React.js</option>
@@ -77,6 +77,63 @@ We use the following JavaScript to set up our multi-select:
7777

7878
{{< js-docs name="multi-select-array-data" file="docs/assets/js/snippets.js" >}}
7979

80+
## Search
81+
82+
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`.
83+
84+
{{< example >}}
85+
<select class="form-multi-select" multiple>
86+
<option value="0">Angular</option>
87+
<option value="1">Bootstrap</option>
88+
<option value="2">React.js</option>
89+
<option value="3">Vue.js</option>
90+
<optgroup label="backend">
91+
<option value="4">Django</option>
92+
<option value="5">Laravel</option>
93+
<option value="6">Node.js</option>
94+
</optgroup>
95+
</select>
96+
{{< /example >}}
97+
98+
### Standard search
99+
100+
To enable the default search input element with standard behavior, please add `data-coreui-search="true"` like in the example below:
101+
102+
{{< example >}}
103+
<select class="form-multi-select" multiple data-coreui-search="true">
104+
<option value="0">Angular</option>
105+
<option value="1">Bootstrap</option>
106+
<option value="2">React.js</option>
107+
<option value="3">Vue.js</option>
108+
<optgroup label="backend">
109+
<option value="4">Django</option>
110+
<option value="5">Laravel</option>
111+
<option value="6">Node.js</option>
112+
</optgroup>
113+
</select>
114+
{{< /example >}}
115+
116+
### Global search
117+
118+
{{< added-in "5.6.0" >}}
119+
120+
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.
121+
122+
{{< example >}}
123+
<select class="form-multi-select" multiple data-coreui-search="global">
124+
<option value="0">Angular</option>
125+
<option value="1">Bootstrap</option>
126+
<option value="2">React.js</option>
127+
<option value="3">Vue.js</option>
128+
<optgroup label="backend">
129+
<option value="4">Django</option>
130+
<option value="5">Laravel</option>
131+
<option value="6">Node.js</option>
132+
</optgroup>
133+
</select>
134+
{{< /example >}}
135+
136+
80137
## Selection types
81138

82139
Explore different selection modes, including single and multiple selections, allowing customization based on user requirements.
@@ -276,7 +333,9 @@ const mulitSelectList = mulitSelectElementList.map(mulitSelectEl => {
276333
{{< bs-table >}}
277334
| Name | Type | Default | Description |
278335
| --- | --- | --- | --- |
279-
| `cleaner`| boolean| `true` | Enables selection cleaner element. |
336+
| `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. |
337+
| `cleaner`| boolean | `true` | Enables selection cleaner element. |
338+
| `container` | string, element, false | `false` | Appends the dropdown to a specific element. Example: `container: 'body'`. |
280339
| `disabled` | boolean | `false` | Toggle the disabled state for the component. |
281340
| `invalid` | boolean | `false` | Toggle the invalid state for the component. |
282341
| `multiple` | boolean | `true` | It specifies that multiple options can be selected at once. |
@@ -285,7 +344,7 @@ const mulitSelectList = mulitSelectElementList.map(mulitSelectEl => {
285344
| `optionsMaxHeight` | number, string | `'auto'` | Sets `max-height` of options list. |
286345
| `optionsStyle` | string | `'checkbox'` | Sets option style. |
287346
| `placeholder` | string | `'Select...'` | Specifies a short hint that is visible in the input. |
288-
| `search` | boolean | `false` | Enables search input element. |
347+
| `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. |
289348
| `searchNoResultsLabel` | string | `'No results found'` | Sets the label for no results when filtering. |
290349
| `selectAll` | boolean | `true` | Enables select all button.|
291350
| `selectAllLabel` | string | `'Select all options'` | Sets the select all button label. |

js/src/multi-select.js

Lines changed: 97 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import SelectorEngine from './dom/selector-engine.js'
1313
import {
1414
defineJQueryPlugin,
1515
getNextActiveElement,
16+
getElement,
1617
isVisible,
1718
isRTL
1819
} from './util/index.js'
@@ -28,10 +29,13 @@ const DATA_KEY = 'coreui.multi-select'
2829
const EVENT_KEY = `.${DATA_KEY}`
2930
const DATA_API_KEY = '.data-api'
3031

31-
const ESCAPE_KEY = 'Escape'
32-
const TAB_KEY = 'Tab'
3332
const ARROW_UP_KEY = 'ArrowUp'
3433
const ARROW_DOWN_KEY = 'ArrowDown'
34+
const BACKSPACE_KEY = 'Backspace'
35+
const DELETE_KEY = 'Delete'
36+
const ENTER_KEY = 'Enter'
37+
const ESCAPE_KEY = 'Escape'
38+
const TAB_KEY = 'Tab'
3539
const RIGHT_MOUSE_BUTTON = 2 // MouseEvent.button value for the secondary button, usually the right button
3640

3741
const SELECTOR_CLEANER = '.form-multi-select-cleaner'
@@ -79,7 +83,9 @@ const CLASS_NAME_TAG = 'form-multi-select-tag'
7983
const CLASS_NAME_TAG_DELETE = 'form-multi-select-tag-delete'
8084

8185
const Default = {
86+
ariaCleanerLabel: 'Clear all selections',
8287
cleaner: true,
88+
container: false,
8389
disabled: false,
8490
invalid: false,
8591
multiple: true,
@@ -99,7 +105,9 @@ const Default = {
99105
}
100106

101107
const DefaultType = {
108+
ariaCleanerLabel: 'string',
102109
cleaner: 'boolean',
110+
container: '(string|element|boolean)',
103111
disabled: 'boolean',
104112
invalid: 'boolean',
105113
multiple: 'boolean',
@@ -109,7 +117,7 @@ const DefaultType = {
109117
optionsStyle: 'string',
110118
placeholder: 'string',
111119
required: 'boolean',
112-
search: 'boolean',
120+
search: '(boolean|string)',
113121
searchNoResultsLabel: 'string',
114122
selectAll: 'boolean',
115123
selectAllLabel: 'string',
@@ -179,6 +187,12 @@ class MultiSelect extends BaseComponent {
179187
EventHandler.trigger(this._element, EVENT_SHOW)
180188
this._clone.classList.add(CLASS_NAME_SHOW)
181189
this._clone.setAttribute('aria-expanded', true)
190+
191+
if (this._config.container) {
192+
this._menu.style.minWidth = `${this._clone.offsetWidth}px`
193+
this._menu.classList.add(CLASS_NAME_SHOW)
194+
}
195+
182196
EventHandler.trigger(this._element, EVENT_SHOWN)
183197

184198
this._createPopper()
@@ -195,10 +209,18 @@ class MultiSelect extends BaseComponent {
195209
this._popper.destroy()
196210
}
197211

198-
this._searchElement.value = ''
212+
if (this._config.search) {
213+
this._searchElement.value = ''
214+
}
215+
199216
this._onSearchChange(this._searchElement)
200217
this._clone.classList.remove(CLASS_NAME_SHOW)
201218
this._clone.setAttribute('aria-expanded', 'false')
219+
220+
if (this._config.container) {
221+
this._menu.classList.remove(CLASS_NAME_SHOW)
222+
}
223+
202224
EventHandler.trigger(this._element, EVENT_HIDDEN)
203225
}
204226

@@ -220,6 +242,7 @@ class MultiSelect extends BaseComponent {
220242
this._config = this._getConfig(config)
221243
this._options = this._getOptions()
222244
this._selected = this._getSelectedOptions(this._options)
245+
this._menu.remove()
223246
this._clone.remove()
224247
this._element.innerHTML = ''
225248
this._createNativeOptions(this._element, this._options)
@@ -273,6 +296,30 @@ class MultiSelect extends BaseComponent {
273296
EventHandler.on(this._clone, EVENT_KEYDOWN, event => {
274297
if (event.key === ESCAPE_KEY) {
275298
this.hide()
299+
return
300+
}
301+
302+
if (this._config.search === 'global' && (event.key.length === 1 || event.key === BACKSPACE_KEY || event.key === DELETE_KEY)) {
303+
this._searchElement.focus()
304+
}
305+
})
306+
307+
EventHandler.on(this._menu, EVENT_KEYDOWN, event => {
308+
if (this._config.search === 'global' && (event.key.length === 1 || event.key === BACKSPACE_KEY || event.key === DELETE_KEY)) {
309+
this._searchElement.focus()
310+
}
311+
})
312+
313+
EventHandler.on(this._togglerElement, EVENT_KEYDOWN, event => {
314+
if (!this._isShown() && (event.key === ENTER_KEY || event.key === ARROW_DOWN_KEY)) {
315+
event.preventDefault()
316+
this.show()
317+
return
318+
}
319+
320+
if (this._isShown() && event.key === ARROW_DOWN_KEY) {
321+
event.preventDefault()
322+
this._selectMenuItem(event)
276323
}
277324
})
278325

@@ -287,9 +334,16 @@ class MultiSelect extends BaseComponent {
287334
})
288335

289336
EventHandler.on(this._searchElement, EVENT_KEYDOWN, event => {
290-
const key = event.keyCode || event.charCode
337+
if (!this._isShown()) {
338+
this.show()
339+
}
291340

292-
if ((key === 8 || key === 46) && event.target.value.length === 0) {
341+
if (event.key === ARROW_DOWN_KEY && this._searchElement.value.length === this._searchElement.selectionStart) {
342+
this._selectMenuItem(event)
343+
return
344+
}
345+
346+
if ((event.key === BACKSPACE_KEY || event.key === DELETE_KEY) && event.target.value.length === 0) {
293347
this._deselectLastOption()
294348
}
295349

@@ -317,9 +371,7 @@ class MultiSelect extends BaseComponent {
317371
})
318372

319373
EventHandler.on(this._optionsElement, EVENT_KEYDOWN, event => {
320-
const key = event.keyCode || event.charCode
321-
322-
if (key === 13) {
374+
if (event.key === ENTER_KEY) {
323375
this._onOptionsClick(event.target)
324376
}
325377

@@ -471,6 +523,10 @@ class MultiSelect extends BaseComponent {
471523
togglerEl.classList.add(CLASS_NAME_INPUT_GROUP)
472524
this._togglerElement = togglerEl
473525

526+
if (!this._config.search && !this._config.disabled) {
527+
togglerEl.tabIndex = 0
528+
}
529+
474530
const selectionEl = document.createElement('div')
475531
selectionEl.classList.add(CLASS_NAME_SELECTION)
476532

@@ -494,6 +550,7 @@ class MultiSelect extends BaseComponent {
494550
cleaner.type = 'button'
495551
cleaner.classList.add(CLASS_NAME_CLEANER)
496552
cleaner.style.display = 'none'
553+
cleaner.setAttribute('aria-label', this._config.ariaCleanerLabel)
497554

498555
buttons.append(cleaner)
499556
this._selectionCleanerElement = cleaner
@@ -534,6 +591,7 @@ class MultiSelect extends BaseComponent {
534591
}],
535592
placement: isRTL() ? 'bottom-end' : 'bottom-start'
536593
}
594+
537595
this._popper = Popper.createPopper(this._togglerElement, this._menu, popperConfig)
538596
}
539597

@@ -575,7 +633,13 @@ class MultiSelect extends BaseComponent {
575633

576634
dropdownDiv.append(optionsDiv)
577635

578-
this._clone.append(dropdownDiv)
636+
const { container } = this._config
637+
if (container) {
638+
// this._clone.parentNode.insertBefore(dropdownDiv, this._clone.nextSibling)
639+
getElement(container).append(dropdownDiv)
640+
} else {
641+
this._clone.append(dropdownDiv)
642+
}
579643

580644
this._createOptions(optionsDiv, this._options)
581645
this._optionsElement = optionsDiv
@@ -649,7 +713,7 @@ class MultiSelect extends BaseComponent {
649713
}
650714

651715
const value = String(element.dataset.value)
652-
const { text } = this._options.find(option => option.value === value)
716+
const { text } = this._findOptionByValue(value)
653717

654718
if (this._config.multiple && element.classList.contains(CLASS_NAME_SELECTED)) {
655719
this._deselectOption(value)
@@ -666,6 +730,23 @@ class MultiSelect extends BaseComponent {
666730
}
667731
}
668732

733+
_findOptionByValue(value, options = this._options) {
734+
for (const option of options) {
735+
if (option.value === value) {
736+
return option
737+
}
738+
739+
if (option.options && Array.isArray(option.options)) {
740+
const found = this._findOptionByValue(value, option.options)
741+
if (found) {
742+
return found
743+
}
744+
}
745+
}
746+
747+
return null
748+
}
749+
669750
_selectOption(value, text) {
670751
if (!this._config.multiple) {
671752
this.deselectAll()
@@ -860,7 +941,7 @@ class MultiSelect extends BaseComponent {
860941
}
861942

862943
_filterOptionsList() {
863-
const options = SelectorEngine.find(SELECTOR_OPTION, this._clone)
944+
const options = SelectorEngine.find(SELECTOR_OPTION, this._menu)
864945
let visibleOptions = 0
865946

866947
for (const option of options) {
@@ -884,8 +965,8 @@ class MultiSelect extends BaseComponent {
884965
}
885966

886967
if (visibleOptions > 0) {
887-
if (SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._clone)) {
888-
SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._clone).remove()
968+
if (SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._menu)) {
969+
SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._menu).remove()
889970
}
890971

891972
return
@@ -896,8 +977,8 @@ class MultiSelect extends BaseComponent {
896977
placeholder.classList.add(CLASS_NAME_OPTIONS_EMPTY)
897978
placeholder.innerHTML = this._config.searchNoResultsLabel
898979

899-
if (!SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._clone)) {
900-
SelectorEngine.findOne(SELECTOR_OPTIONS, this._clone).append(placeholder)
980+
if (!SelectorEngine.findOne(SELECTOR_OPTIONS_EMPTY, this._menu)) {
981+
SelectorEngine.findOne(SELECTOR_OPTIONS, this._menu).append(placeholder)
901982
}
902983
}
903984
}

0 commit comments

Comments
 (0)