Skip to content

Commit e6d12b7

Browse files
committed
feat(MultiSelect): add a global search option, improve keyboard support and accessibility
1 parent 39e84de commit e6d12b7

File tree

3 files changed

+119
-16
lines changed

3 files changed

+119
-16
lines changed

docs/content/forms/multi-select.md

Lines changed: 61 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,8 @@ 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. |
280338
| `container` | string, element, false | `false` | Appends the dropdown to a specific element. Example: `container: 'body'`. |
281339
| `disabled` | boolean | `false` | Toggle the disabled state for the component. |
282340
| `invalid` | boolean | `false` | Toggle the invalid state for the component. |
@@ -286,7 +344,7 @@ const mulitSelectList = mulitSelectElementList.map(mulitSelectEl => {
286344
| `optionsMaxHeight` | number, string | `'auto'` | Sets `max-height` of options list. |
287345
| `optionsStyle` | string | `'checkbox'` | Sets option style. |
288346
| `placeholder` | string | `'Select...'` | Specifies a short hint that is visible in the input. |
289-
| `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. |
290348
| `searchNoResultsLabel` | string | `'No results found'` | Sets the label for no results when filtering. |
291349
| `selectAll` | boolean | `true` | Enables select all button.|
292350
| `selectAllLabel` | string | `'Select all options'` | Sets the select all button label. |

js/src/multi-select.js

Lines changed: 51 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@ const DATA_KEY = 'coreui.multi-select'
2929
const EVENT_KEY = `.${DATA_KEY}`
3030
const DATA_API_KEY = '.data-api'
3131

32-
const ESCAPE_KEY = 'Escape'
33-
const TAB_KEY = 'Tab'
3432
const ARROW_UP_KEY = 'ArrowUp'
3533
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'
3639
const RIGHT_MOUSE_BUTTON = 2 // MouseEvent.button value for the secondary button, usually the right button
3740

3841
const SELECTOR_CLEANER = '.form-multi-select-cleaner'
@@ -80,6 +83,7 @@ const CLASS_NAME_TAG = 'form-multi-select-tag'
8083
const CLASS_NAME_TAG_DELETE = 'form-multi-select-tag-delete'
8184

8285
const Default = {
86+
ariaCleanerLabel: 'Clear all selections',
8387
cleaner: true,
8488
container: false,
8589
disabled: false,
@@ -101,6 +105,7 @@ const Default = {
101105
}
102106

103107
const DefaultType = {
108+
ariaCleanerLabel: 'string',
104109
cleaner: 'boolean',
105110
container: '(string|element|boolean)',
106111
disabled: 'boolean',
@@ -112,7 +117,7 @@ const DefaultType = {
112117
optionsStyle: 'string',
113118
placeholder: 'string',
114119
required: 'boolean',
115-
search: 'boolean',
120+
search: '(boolean|string)',
116121
searchNoResultsLabel: 'string',
117122
selectAll: 'boolean',
118123
selectAllLabel: 'string',
@@ -204,7 +209,10 @@ class MultiSelect extends BaseComponent {
204209
this._popper.destroy()
205210
}
206211

207-
this._searchElement.value = ''
212+
if (this._config.search) {
213+
this._searchElement.value = ''
214+
}
215+
208216
this._onSearchChange(this._searchElement)
209217
this._clone.classList.remove(CLASS_NAME_SHOW)
210218
this._clone.setAttribute('aria-expanded', 'false')
@@ -288,6 +296,30 @@ class MultiSelect extends BaseComponent {
288296
EventHandler.on(this._clone, EVENT_KEYDOWN, event => {
289297
if (event.key === ESCAPE_KEY) {
290298
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)
291323
}
292324
})
293325

@@ -302,9 +334,16 @@ class MultiSelect extends BaseComponent {
302334
})
303335

304336
EventHandler.on(this._searchElement, EVENT_KEYDOWN, event => {
305-
const key = event.keyCode || event.charCode
337+
if (!this._isShown()) {
338+
this.show()
339+
}
340+
341+
if (event.key === ARROW_DOWN_KEY && this._searchElement.value.length === this._searchElement.selectionStart) {
342+
this._selectMenuItem(event)
343+
return
344+
}
306345

307-
if ((key === 8 || key === 46) && event.target.value.length === 0) {
346+
if ((event.key === BACKSPACE_KEY || event.key === DELETE_KEY) && event.target.value.length === 0) {
308347
this._deselectLastOption()
309348
}
310349

@@ -332,9 +371,7 @@ class MultiSelect extends BaseComponent {
332371
})
333372

334373
EventHandler.on(this._optionsElement, EVENT_KEYDOWN, event => {
335-
const key = event.keyCode || event.charCode
336-
337-
if (key === 13) {
374+
if (event.key === ENTER_KEY) {
338375
this._onOptionsClick(event.target)
339376
}
340377

@@ -486,6 +523,10 @@ class MultiSelect extends BaseComponent {
486523
togglerEl.classList.add(CLASS_NAME_INPUT_GROUP)
487524
this._togglerElement = togglerEl
488525

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

@@ -509,6 +550,7 @@ class MultiSelect extends BaseComponent {
509550
cleaner.type = 'button'
510551
cleaner.classList.add(CLASS_NAME_CLEANER)
511552
cleaner.style.display = 'none'
553+
cleaner.setAttribute('aria-label', this._config.ariaCleanerLabel)
512554

513555
buttons.append(cleaner)
514556
this._selectionCleanerElement = cleaner

scss/forms/_form-multi-select.scss

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ select.form-multi-select {
143143
border-color: $input-disabled-border-color;
144144
}
145145

146-
.form-multi-select.show & {
146+
.form-multi-select.show &,
147+
&:has(*:focus),
148+
&:focus {
147149
color: var(--#{$prefix}form-multi-select-focus-color);
148150
background-color: var(--#{$prefix}form-multi-select-focus-bg);
149151
border-color: var(--#{$prefix}form-multi-select-focus-border-color);
@@ -173,8 +175,8 @@ select.form-multi-select {
173175
}
174176

175177
.form-multi-select-search {
176-
display: none;
177-
flex: 1 1 auto;
178+
display: flex;
179+
flex: 0 1 0px;
178180
max-width: 100%;
179181
padding: 0;
180182
background: transparent;
@@ -191,7 +193,8 @@ select.form-multi-select {
191193

192194
.form-multi-select.show &,
193195
&:placeholder-shown {
194-
display: flex;
196+
flex: 1 1 auto;
197+
// display: flex;
195198
}
196199

197200
.form-multi-select-selection-tags & {

0 commit comments

Comments
 (0)