Skip to content

Commit

Permalink
feat(MultiSelect): add a global search option, improve keyboard suppo…
Browse files Browse the repository at this point in the history
…rt and accessibility
  • Loading branch information
mrholek committed Oct 23, 2024
1 parent 39e84de commit e6d12b7
Show file tree
Hide file tree
Showing 3 changed files with 119 additions and 16 deletions.
64 changes: 61 additions & 3 deletions docs/content/forms/multi-select.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 >}}
<select class="form-multi-select" id="ms1" multiple data-coreui-search="true">
<select class="form-multi-select" id="ms1" multiple data-coreui-search="global">
<option value="0">Angular</option>
<option value="1">Bootstrap</option>
<option value="2">React.js</option>
Expand Down Expand Up @@ -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 >}}
<select class="form-multi-select" multiple>
<option value="0">Angular</option>
<option value="1">Bootstrap</option>
<option value="2">React.js</option>
<option value="3">Vue.js</option>
<optgroup label="backend">
<option value="4">Django</option>
<option value="5">Laravel</option>
<option value="6">Node.js</option>
</optgroup>
</select>
{{< /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 >}}
<select class="form-multi-select" multiple data-coreui-search="true">
<option value="0">Angular</option>
<option value="1">Bootstrap</option>
<option value="2">React.js</option>
<option value="3">Vue.js</option>
<optgroup label="backend">
<option value="4">Django</option>
<option value="5">Laravel</option>
<option value="6">Node.js</option>
</optgroup>
</select>
{{< /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 >}}
<select class="form-multi-select" multiple data-coreui-search="global">
<option value="0">Angular</option>
<option value="1">Bootstrap</option>
<option value="2">React.js</option>
<option value="3">Vue.js</option>
<optgroup label="backend">
<option value="4">Django</option>
<option value="5">Laravel</option>
<option value="6">Node.js</option>
</optgroup>
</select>
{{< /example >}}


## Selection types

Explore different selection modes, including single and multiple selections, allowing customization based on user requirements.
Expand Down Expand Up @@ -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. |
Expand All @@ -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. |
Expand Down
60 changes: 51 additions & 9 deletions js/src/multi-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand All @@ -101,6 +105,7 @@ const Default = {
}

const DefaultType = {
ariaCleanerLabel: 'string',
cleaner: 'boolean',
container: '(string|element|boolean)',
disabled: 'boolean',
Expand All @@ -112,7 +117,7 @@ const DefaultType = {
optionsStyle: 'string',
placeholder: 'string',
required: 'boolean',
search: 'boolean',
search: '(boolean|string)',
searchNoResultsLabel: 'string',
selectAll: 'boolean',
selectAllLabel: 'string',
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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)
}
})

Expand All @@ -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()
}

Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand Down
11 changes: 7 additions & 4 deletions scss/forms/_form-multi-select.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand All @@ -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 & {
Expand Down

0 comments on commit e6d12b7

Please sign in to comment.