Skip to content

Commit

Permalink
Merge branch 'refactor-multi-select'
Browse files Browse the repository at this point in the history
  • Loading branch information
mrholek committed Oct 27, 2024
2 parents a490cb1 + e6d12b7 commit 4569cac
Show file tree
Hide file tree
Showing 3 changed files with 170 additions and 25 deletions.
65 changes: 62 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,9 @@ 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. |
| `multiple` | boolean | `true` | It specifies that multiple options can be selected at once. |
Expand All @@ -285,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
113 changes: 97 additions & 16 deletions js/src/multi-select.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import SelectorEngine from './dom/selector-engine.js'
import {
defineJQueryPlugin,
getNextActiveElement,
getElement,
isVisible,
isRTL
} from './util/index.js'
Expand All @@ -28,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 @@ -79,7 +83,9 @@ 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,
invalid: false,
multiple: true,
Expand All @@ -99,7 +105,9 @@ const Default = {
}

const DefaultType = {
ariaCleanerLabel: 'string',
cleaner: 'boolean',
container: '(string|element|boolean)',
disabled: 'boolean',
invalid: 'boolean',
multiple: 'boolean',
Expand All @@ -109,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 @@ -179,6 +187,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()
Expand All @@ -195,10 +209,18 @@ 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')

if (this._config.container) {
this._menu.classList.remove(CLASS_NAME_SHOW)
}

EventHandler.trigger(this._element, EVENT_HIDDEN)
}

Expand All @@ -220,6 +242,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)
Expand Down Expand Up @@ -273,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 @@ -287,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 ((key === 8 || key === 46) && event.target.value.length === 0) {
if (event.key === ARROW_DOWN_KEY && this._searchElement.value.length === this._searchElement.selectionStart) {
this._selectMenuItem(event)
return
}

if ((event.key === BACKSPACE_KEY || event.key === DELETE_KEY) && event.target.value.length === 0) {
this._deselectLastOption()
}

Expand Down Expand Up @@ -317,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 @@ -471,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 @@ -494,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 Expand Up @@ -534,6 +591,7 @@ class MultiSelect extends BaseComponent {
}],
placement: isRTL() ? 'bottom-end' : 'bottom-start'
}

this._popper = Popper.createPopper(this._togglerElement, this._menu, popperConfig)
}

Expand Down Expand Up @@ -575,7 +633,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
Expand Down Expand Up @@ -649,7 +713,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)
Expand All @@ -666,6 +730,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()
Expand Down Expand Up @@ -860,7 +941,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) {
Expand All @@ -884,8 +965,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
Expand All @@ -896,8 +977,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)
}
}
}
Expand Down
Loading

0 comments on commit 4569cac

Please sign in to comment.