~nicolaskempf57/select-a11y

55c324aef5f99bdaf63ffe5bafe3b65a986c4ca2 — Nicolas KEMPF 4 months ago b89912d
feat: allow custom search with async handling
7 files changed, 644 insertions(+), 307 deletions(-)

M dist/main.js
M dist/module.js
M package-lock.json
M package.json
M public/index.html
M src/select-a11y.js
M tests/index.js
M dist/main.js => dist/main.js +156 -52
@@ 27,6 27,16 @@ if (!$21b19656d700fd08$var$closest) $21b19656d700fd08$var$closest = function(s) 
    }while (el !== null && el.nodeType === 1)
    return null;
};
const $21b19656d700fd08$var$DEEP_CLONE = true;
/**
 * Deep copy of an {@link Iterable} as {@link Array}
 * @template {HTMLElement} T
 * @param {Iterable<T>} array 
 * @returns {Array<T>}
 */ function $21b19656d700fd08$var$deepCopy(array) {
    return /** @type {Array<T>} */ Array.from(array).map((option)=>option.cloneNode($21b19656d700fd08$var$DEEP_CLONE)
    );
}
class $21b19656d700fd08$var$Select {
    /**
   * @param {HTMLSelectElement} el - Select HTML element


@@ 39,7 49,7 @@ class $21b19656d700fd08$var$Select {
   * @param {object} [options.text.deleteItem] - text used as title for "x" close button for selected option (see options.showSelected below)
   * @param {object} [options.text.delete] - text used for assistive technologies for the "x" close button for selected option (see options.showSelected below)
   * @param {object} [options.text.clear] - text used for assistive technologies for the "x" clear button for clearable single select (see options.clearable below)
   * @param {boolean} [options.enableTextFilter=true] - filtrer options based on search input content
   * @param {FillSuggestions} [options.fillSuggestions] - fill suggestions based on search input content
   * @param {boolean} [options.showSelected=true] - show selected options for multiple select
   * @param {boolean} [options.useLabelAsButton=false] - use label as button even for single select. 
   * Only work if select value is set to `null` otherwise its value defaults to first option.


@@ 52,15 62,16 @@ class $21b19656d700fd08$var$Select {
        this.open = false;
        this.multiple = this.el.multiple;
        this.search = '';
        this.suggestions = [];
        /** @type {Array<HTMLElement>} */ this.suggestions = [];
        this.focusIndex = null;
        const passedOptions = Object.assign({}, options);
        const textOptions = Object.assign({}, $21b19656d700fd08$var$text, passedOptions.text);
        delete passedOptions.text;
        this._defaultSearch = this._defaultSearch.bind(this);
        this._options = Object.assign({
            text: textOptions,
            showSelected: true,
            enableTextFilter: true,
            fillSuggestions: this._defaultSearch,
            useLabelAsButton: false,
            clearable: false
        }, passedOptions);


@@ 75,6 86,35 @@ class $21b19656d700fd08$var$Select {
        this._removeOption = this._removeOption.bind(this);
        this.setText = this.setText.bind(this);
        this._setButtonText = this._setButtonText.bind(this);
        if (!this.multiple) {
            const hasSelectedOption = Array.from(this.el.options).some((option)=>option.selected
            );
            if (this._options.useLabelAsButton && !hasSelectedOption) {
                const option = document.createElement('option');
                option.innerText = this.label.innerText;
                option.setAttribute('value', '');
                option.setAttribute('selected', 'selected');
                option.setAttribute('disabled', 'disabled');
                option.setAttribute('hidden', 'hidden');
                this.el.options.add(option, 0);
            }
        }
        /** 
     * Select original options at initialization of the component.
     * They are never modified and are used to handle reset.
     * @type {Array<HTMLOptionElement>} 
     */ this.originalOptions = $21b19656d700fd08$var$deepCopy(this.el.options);
        /** 
     * Select original options at initialization of the component.
     * They are updated based on select / unselect of options but no options are added or removed to it.
     * This is the set of options passed to {@link FillSuggestions} callback.
     * @type {Array<HTMLOptionElement>}
     */ this.updatedOriginalOptions = Array.from(this.el.options);
        /** 
     * Select current options. These can be completely differents options than {@link originalOptions} 
     * if the provided promise fetches some from an API.
     * @type {Array<HTMLOptionElement>} 
     */ this.currentOptions = Array.from(this.el.options);
        this._disable();
        this.button = this._createButton();
        this._setButtonText();


@@ 106,7 146,7 @@ class $21b19656d700fd08$var$Select {
   * Select new value
   * @param {*} value option value
   */ selectOption(value) {
        const optionIndex = Array.from(this.el.options).findIndex((option)=>option.value === value
        const optionIndex = this.currentOptions.findIndex((option)=>option.value === value
        );
        if (optionIndex === -1) return;
        const shouldClose = this.multiple ? false : true;


@@ 121,17 161,6 @@ class $21b19656d700fd08$var$Select {
        text.className = 'select-a11y-button__text';
        if (this.multiple) text.innerText = this.label.innerText;
        else {
            const hasSelectedOption = Array.from(this.el.options).some((option)=>option.selected
            );
            if (this._options.useLabelAsButton && !hasSelectedOption) {
                const option = document.createElement('option');
                option.innerText = this.label.innerText;
                option.setAttribute('value', '');
                option.setAttribute('selected', 'selected');
                option.setAttribute('disabled', 'disabled');
                option.setAttribute('hidden', 'hidden');
                this.el.options.add(option, 0);
            }
            if (!this.label.id) this.label.id = `${this.el.id}-label`;
            button.setAttribute('id', this.el.id + '-button');
            button.setAttribute('aria-labelledby', this.label.id + ' ' + button.id);


@@ 177,45 206,103 @@ class $21b19656d700fd08$var$Select {
    _disable() {
        this.el.setAttribute('tabindex', '-1');
    }
    _fillSuggestions() {
    /**
   * 
   * @typedef Suggestion
   * @property {boolean} hidden - if suggestion is hidden
   * @property {boolean} disabled - if suggestion is disabled
   * @property {boolean} selected - if suggestion is selected
   * @property {string} label - label shown
   * @property {any} value - suggestion value
   * @property {string} [image] - suggestion image
   * @property {string} [alt] - suggestion image alt
   */ /**
   * 
   * @param {HTMLOptionElement} option 
   * @returns {Suggestion} - a suggestion
   */ _mapToSuggestion(option) {
        return {
            hidden: option.hidden,
            disabled: option.disabled,
            selected: option.hasAttribute('selected'),
            label: option.label,
            value: option.value,
            image: option.dataset.image,
            alt: option.dataset.alt
        };
    }
    /**
   * 
   * @param {Suggestion} suggestion 
   * @returns {HTMLOptionElement} - an option
   */ _mapToOption(suggestion) {
        const option = document.createElement('option');
        option.label = suggestion.label;
        option.value = suggestion.value;
        if (suggestion.hidden) option.setAttribute('hidden', 'hidden');
        if (suggestion.disabled) option.setAttribute('disabled', 'disabled');
        if (suggestion.selected) option.setAttribute('selected', 'selected');
        if (suggestion.image) option.dataset.image = suggestion.image;
        if (suggestion.alt) option.dataset.alt = suggestion.alt;
        return option;
    }
    /**
   * @callback FillSuggestions
   * @param {string} search - searched term
   * @param {Array<HTMLOptionElement>} options - original select options
   * @returns {Promise<Array<Suggestion>>} suggestions
   */ /**
   * 
   * @type {FillSuggestions} 
   */ _defaultSearch(search, options) {
        const newOptions = options.filter((option)=>{
            const text = option.label || option.value;
            return text.toLocaleLowerCase().indexOf(search) !== -1;
        }).map(this._mapToSuggestion);
        return Promise.resolve(newOptions);
    }
    /**
   * 
   * @returns {Promise<Array<Suggestion>>}
   */ async _fillSuggestions() {
        const search = this.search.toLowerCase();
        // loop over the
        this.suggestions = Array.from(this.el.options).map((option, index)=>{
            if (option.hidden) return;
            const text = option.label || option.value;
            const formatedText = text.toLowerCase();
            // test if search text match the current option
            if (this._options.enableTextFilter && formatedText.indexOf(search) === -1) return;
            // create the option
            const suggestion = document.createElement('div');
            suggestion.setAttribute('role', 'option');
            suggestion.setAttribute('tabindex', '0');
            suggestion.setAttribute('data-index', index);
            suggestion.classList.add('select-a11y-suggestion');
        const suggestions = await this._options.fillSuggestions(search, this.updatedOriginalOptions);
        this.currentOptions = suggestions.map(this._mapToOption);
        this.el.replaceChildren(...this.currentOptions);
        const suggestionElements = suggestions.map((suggestion, index)=>{
            if (suggestion.hidden || suggestion.disabled) return;
            const suggestionElement = document.createElement('div');
            suggestionElement.setAttribute('role', 'option');
            suggestionElement.setAttribute('tabindex', '0');
            suggestionElement.setAttribute('data-index', index.toString());
            suggestionElement.classList.add('select-a11y-suggestion');
            suggestionElement.innerText = suggestion.label || suggestion.value;
            // check if the option is selected
            if (option.selected) suggestion.setAttribute('aria-selected', 'true');
            suggestion.innerText = option.label || option.value;
            if (option.dataset.image) {
            if (suggestion.selected) suggestionElement.setAttribute('aria-selected', 'true');
            if (suggestion.image) {
                const image = document.createElement('img');
                image.setAttribute('src', option.dataset.image);
                image.setAttribute('alt', option.dataset.alt ? option.dataset.alt : '');
                image.setAttribute('src', suggestion.image);
                image.setAttribute('alt', suggestion.alt ? suggestion.alt : '');
                image.classList.add('select-a11y-suggestion__image');
                suggestion.prepend(image);
                suggestionElement.prepend(image);
            }
            return suggestion;
            return suggestionElement;
        }).filter(Boolean);
        if (!this.suggestions.length) this.list.innerHTML = `<p class="select-a11y__no-suggestion">${this._options.text.noResult}</p>`;
        this.suggestions = suggestionElements;
        if (!suggestionElements.length) this.list.innerHTML = `<p class="select-a11y__no-suggestion">${this._options.text.noResult}</p>`;
        else {
            const listBox = document.createElement('div');
            listBox.setAttribute('role', 'listbox');
            if (this.multiple) listBox.setAttribute('aria-multiselectable', 'true');
            this.suggestions.forEach((function(suggestion) {
                listBox.appendChild(suggestion);
            }).bind(this));
            suggestionElements.forEach((suggestionElement)=>{
                listBox.appendChild(suggestionElement);
            });
            this.list.innerHTML = '';
            this.list.appendChild(listBox);
        }
        this._setLiveZone();
        return suggestions;
    }
    _handleOpener(event) {
        this._toggleOverlay();


@@ 239,11 326,14 @@ class $21b19656d700fd08$var$Select {
    }
    _handleReset() {
        clearTimeout(this._resetTimeout);
        this._resetTimeout = setTimeout(()=>{
            this._fillSuggestions();
            if (this.multiple && this._options.showSelected) this._updateSelectedList();
            this._setButtonText();
        this._resetTimeout = setTimeout(async ()=>{
            this.search = '';
            this.updatedOriginalOptions = $21b19656d700fd08$var$deepCopy(this.originalOptions);
            this.currentOptions = $21b19656d700fd08$var$deepCopy(this.originalOptions);
            await this._fillSuggestions();
            this.el.dispatchEvent(new Event('change'));
            this._setButtonText();
            if (this.multiple && this._options.showSelected) this._updateSelectedList();
        }, 10);
    }
    _handleSuggestionClick(event) {


@@ 360,28 450,42 @@ class $21b19656d700fd08$var$Select {
            // reset aria-live
            this._setLiveZone();
            if (state === undefined || focusBack) // fix bug that will trigger a click on the button when focusing directly
            setTimeout((function() {
            setTimeout(()=>{
                this.button.focus();
            }).bind(this));
            });
        }
    }
    _toggleSelection(optionIndex, close = true) {
        const option1 = this.el.item(optionIndex);
        if (this.multiple) this.el.item(optionIndex).selected = !this.el.item(optionIndex).selected;
        else this.el.selectedIndex = optionIndex;
        this.el.dispatchEvent(new Event('change'));
        this.suggestions.forEach((suggestion)=>{
        const toggledOption = this.el.item(optionIndex);
        if (this.multiple) {
            if (toggledOption.hasAttribute('selected')) toggledOption.removeAttribute('selected');
            else toggledOption.setAttribute('selected', 'selected');
        } else {
            toggledOption.setAttribute('selected', 'selected');
            this.el.selectedIndex = optionIndex;
        }
        this.updatedOriginalOptions = this.updatedOriginalOptions.map((option)=>{
            if (option.value === toggledOption.value) {
                if (toggledOption.hasAttribute('selected')) option.setAttribute('selected', 'selected');
                else option.removeAttribute('selected');
            }
            if (!this.multiple && option.value !== toggledOption.value) option.removeAttribute('selected');
            return option;
        });
        this.suggestions = this.suggestions.map((suggestion)=>{
            const index = parseInt(suggestion.getAttribute('data-index'), 10);
            const option = this.el.item(index);
            if (option && option.selected) suggestion.setAttribute('aria-selected', 'true');
            else suggestion.removeAttribute('aria-selected');
            return suggestion;
        });
        this.el.dispatchEvent(new Event('change'));
        this._setButtonText();
        if (this.multiple && this._options.showSelected) this._updateSelectedList();
        if (close && this.open) this._toggleOverlay();
    }
    _updateSelectedList() {
        const items = Array.prototype.map.call(this.el.options, (function(option, index) {
        const items = this.currentOptions.map((option, index)=>{
            if (!option.selected) return;
            const text = option.label || option.value;
            return `


@@ 392,7 496,7 @@ class $21b19656d700fd08$var$Select {
            <span class="select-a11y-delete__icon" aria-hidden="true"></span>
          </button>
        </li>`;
        }).bind(this)).filter(Boolean);
        }).filter(Boolean);
        this.selectedList.innerHTML = items.join('');
        if (items.length) {
            if (!this.selectedList.parentElement) this.wrap.appendChild(this.selectedList);

M dist/module.js => dist/module.js +156 -52
@@ 17,6 17,16 @@ if (!$5a3b80354f588438$var$closest) $5a3b80354f588438$var$closest = function(s) 
    }while (el !== null && el.nodeType === 1)
    return null;
};
const $5a3b80354f588438$var$DEEP_CLONE = true;
/**
 * Deep copy of an {@link Iterable} as {@link Array}
 * @template {HTMLElement} T
 * @param {Iterable<T>} array 
 * @returns {Array<T>}
 */ function $5a3b80354f588438$var$deepCopy(array) {
    return /** @type {Array<T>} */ Array.from(array).map((option)=>option.cloneNode($5a3b80354f588438$var$DEEP_CLONE)
    );
}
class $5a3b80354f588438$var$Select {
    /**
   * @param {HTMLSelectElement} el - Select HTML element


@@ 29,7 39,7 @@ class $5a3b80354f588438$var$Select {
   * @param {object} [options.text.deleteItem] - text used as title for "x" close button for selected option (see options.showSelected below)
   * @param {object} [options.text.delete] - text used for assistive technologies for the "x" close button for selected option (see options.showSelected below)
   * @param {object} [options.text.clear] - text used for assistive technologies for the "x" clear button for clearable single select (see options.clearable below)
   * @param {boolean} [options.enableTextFilter=true] - filtrer options based on search input content
   * @param {FillSuggestions} [options.fillSuggestions] - fill suggestions based on search input content
   * @param {boolean} [options.showSelected=true] - show selected options for multiple select
   * @param {boolean} [options.useLabelAsButton=false] - use label as button even for single select. 
   * Only work if select value is set to `null` otherwise its value defaults to first option.


@@ 42,15 52,16 @@ class $5a3b80354f588438$var$Select {
        this.open = false;
        this.multiple = this.el.multiple;
        this.search = '';
        this.suggestions = [];
        /** @type {Array<HTMLElement>} */ this.suggestions = [];
        this.focusIndex = null;
        const passedOptions = Object.assign({}, options);
        const textOptions = Object.assign({}, $5a3b80354f588438$var$text, passedOptions.text);
        delete passedOptions.text;
        this._defaultSearch = this._defaultSearch.bind(this);
        this._options = Object.assign({
            text: textOptions,
            showSelected: true,
            enableTextFilter: true,
            fillSuggestions: this._defaultSearch,
            useLabelAsButton: false,
            clearable: false
        }, passedOptions);


@@ 65,6 76,35 @@ class $5a3b80354f588438$var$Select {
        this._removeOption = this._removeOption.bind(this);
        this.setText = this.setText.bind(this);
        this._setButtonText = this._setButtonText.bind(this);
        if (!this.multiple) {
            const hasSelectedOption = Array.from(this.el.options).some((option)=>option.selected
            );
            if (this._options.useLabelAsButton && !hasSelectedOption) {
                const option = document.createElement('option');
                option.innerText = this.label.innerText;
                option.setAttribute('value', '');
                option.setAttribute('selected', 'selected');
                option.setAttribute('disabled', 'disabled');
                option.setAttribute('hidden', 'hidden');
                this.el.options.add(option, 0);
            }
        }
        /** 
     * Select original options at initialization of the component.
     * They are never modified and are used to handle reset.
     * @type {Array<HTMLOptionElement>} 
     */ this.originalOptions = $5a3b80354f588438$var$deepCopy(this.el.options);
        /** 
     * Select original options at initialization of the component.
     * They are updated based on select / unselect of options but no options are added or removed to it.
     * This is the set of options passed to {@link FillSuggestions} callback.
     * @type {Array<HTMLOptionElement>}
     */ this.updatedOriginalOptions = Array.from(this.el.options);
        /** 
     * Select current options. These can be completely differents options than {@link originalOptions} 
     * if the provided promise fetches some from an API.
     * @type {Array<HTMLOptionElement>} 
     */ this.currentOptions = Array.from(this.el.options);
        this._disable();
        this.button = this._createButton();
        this._setButtonText();


@@ 96,7 136,7 @@ class $5a3b80354f588438$var$Select {
   * Select new value
   * @param {*} value option value
   */ selectOption(value) {
        const optionIndex = Array.from(this.el.options).findIndex((option)=>option.value === value
        const optionIndex = this.currentOptions.findIndex((option)=>option.value === value
        );
        if (optionIndex === -1) return;
        const shouldClose = this.multiple ? false : true;


@@ 111,17 151,6 @@ class $5a3b80354f588438$var$Select {
        text.className = 'select-a11y-button__text';
        if (this.multiple) text.innerText = this.label.innerText;
        else {
            const hasSelectedOption = Array.from(this.el.options).some((option)=>option.selected
            );
            if (this._options.useLabelAsButton && !hasSelectedOption) {
                const option = document.createElement('option');
                option.innerText = this.label.innerText;
                option.setAttribute('value', '');
                option.setAttribute('selected', 'selected');
                option.setAttribute('disabled', 'disabled');
                option.setAttribute('hidden', 'hidden');
                this.el.options.add(option, 0);
            }
            if (!this.label.id) this.label.id = `${this.el.id}-label`;
            button.setAttribute('id', this.el.id + '-button');
            button.setAttribute('aria-labelledby', this.label.id + ' ' + button.id);


@@ 167,45 196,103 @@ class $5a3b80354f588438$var$Select {
    _disable() {
        this.el.setAttribute('tabindex', '-1');
    }
    _fillSuggestions() {
    /**
   * 
   * @typedef Suggestion
   * @property {boolean} hidden - if suggestion is hidden
   * @property {boolean} disabled - if suggestion is disabled
   * @property {boolean} selected - if suggestion is selected
   * @property {string} label - label shown
   * @property {any} value - suggestion value
   * @property {string} [image] - suggestion image
   * @property {string} [alt] - suggestion image alt
   */ /**
   * 
   * @param {HTMLOptionElement} option 
   * @returns {Suggestion} - a suggestion
   */ _mapToSuggestion(option) {
        return {
            hidden: option.hidden,
            disabled: option.disabled,
            selected: option.hasAttribute('selected'),
            label: option.label,
            value: option.value,
            image: option.dataset.image,
            alt: option.dataset.alt
        };
    }
    /**
   * 
   * @param {Suggestion} suggestion 
   * @returns {HTMLOptionElement} - an option
   */ _mapToOption(suggestion) {
        const option = document.createElement('option');
        option.label = suggestion.label;
        option.value = suggestion.value;
        if (suggestion.hidden) option.setAttribute('hidden', 'hidden');
        if (suggestion.disabled) option.setAttribute('disabled', 'disabled');
        if (suggestion.selected) option.setAttribute('selected', 'selected');
        if (suggestion.image) option.dataset.image = suggestion.image;
        if (suggestion.alt) option.dataset.alt = suggestion.alt;
        return option;
    }
    /**
   * @callback FillSuggestions
   * @param {string} search - searched term
   * @param {Array<HTMLOptionElement>} options - original select options
   * @returns {Promise<Array<Suggestion>>} suggestions
   */ /**
   * 
   * @type {FillSuggestions} 
   */ _defaultSearch(search, options) {
        const newOptions = options.filter((option)=>{
            const text = option.label || option.value;
            return text.toLocaleLowerCase().indexOf(search) !== -1;
        }).map(this._mapToSuggestion);
        return Promise.resolve(newOptions);
    }
    /**
   * 
   * @returns {Promise<Array<Suggestion>>}
   */ async _fillSuggestions() {
        const search = this.search.toLowerCase();
        // loop over the
        this.suggestions = Array.from(this.el.options).map((option, index)=>{
            if (option.hidden) return;
            const text = option.label || option.value;
            const formatedText = text.toLowerCase();
            // test if search text match the current option
            if (this._options.enableTextFilter && formatedText.indexOf(search) === -1) return;
            // create the option
            const suggestion = document.createElement('div');
            suggestion.setAttribute('role', 'option');
            suggestion.setAttribute('tabindex', '0');
            suggestion.setAttribute('data-index', index);
            suggestion.classList.add('select-a11y-suggestion');
        const suggestions = await this._options.fillSuggestions(search, this.updatedOriginalOptions);
        this.currentOptions = suggestions.map(this._mapToOption);
        this.el.replaceChildren(...this.currentOptions);
        const suggestionElements = suggestions.map((suggestion, index)=>{
            if (suggestion.hidden || suggestion.disabled) return;
            const suggestionElement = document.createElement('div');
            suggestionElement.setAttribute('role', 'option');
            suggestionElement.setAttribute('tabindex', '0');
            suggestionElement.setAttribute('data-index', index.toString());
            suggestionElement.classList.add('select-a11y-suggestion');
            suggestionElement.innerText = suggestion.label || suggestion.value;
            // check if the option is selected
            if (option.selected) suggestion.setAttribute('aria-selected', 'true');
            suggestion.innerText = option.label || option.value;
            if (option.dataset.image) {
            if (suggestion.selected) suggestionElement.setAttribute('aria-selected', 'true');
            if (suggestion.image) {
                const image = document.createElement('img');
                image.setAttribute('src', option.dataset.image);
                image.setAttribute('alt', option.dataset.alt ? option.dataset.alt : '');
                image.setAttribute('src', suggestion.image);
                image.setAttribute('alt', suggestion.alt ? suggestion.alt : '');
                image.classList.add('select-a11y-suggestion__image');
                suggestion.prepend(image);
                suggestionElement.prepend(image);
            }
            return suggestion;
            return suggestionElement;
        }).filter(Boolean);
        if (!this.suggestions.length) this.list.innerHTML = `<p class="select-a11y__no-suggestion">${this._options.text.noResult}</p>`;
        this.suggestions = suggestionElements;
        if (!suggestionElements.length) this.list.innerHTML = `<p class="select-a11y__no-suggestion">${this._options.text.noResult}</p>`;
        else {
            const listBox = document.createElement('div');
            listBox.setAttribute('role', 'listbox');
            if (this.multiple) listBox.setAttribute('aria-multiselectable', 'true');
            this.suggestions.forEach((function(suggestion) {
                listBox.appendChild(suggestion);
            }).bind(this));
            suggestionElements.forEach((suggestionElement)=>{
                listBox.appendChild(suggestionElement);
            });
            this.list.innerHTML = '';
            this.list.appendChild(listBox);
        }
        this._setLiveZone();
        return suggestions;
    }
    _handleOpener(event) {
        this._toggleOverlay();


@@ 229,11 316,14 @@ class $5a3b80354f588438$var$Select {
    }
    _handleReset() {
        clearTimeout(this._resetTimeout);
        this._resetTimeout = setTimeout(()=>{
            this._fillSuggestions();
            if (this.multiple && this._options.showSelected) this._updateSelectedList();
            this._setButtonText();
        this._resetTimeout = setTimeout(async ()=>{
            this.search = '';
            this.updatedOriginalOptions = $5a3b80354f588438$var$deepCopy(this.originalOptions);
            this.currentOptions = $5a3b80354f588438$var$deepCopy(this.originalOptions);
            await this._fillSuggestions();
            this.el.dispatchEvent(new Event('change'));
            this._setButtonText();
            if (this.multiple && this._options.showSelected) this._updateSelectedList();
        }, 10);
    }
    _handleSuggestionClick(event) {


@@ 350,28 440,42 @@ class $5a3b80354f588438$var$Select {
            // reset aria-live
            this._setLiveZone();
            if (state === undefined || focusBack) // fix bug that will trigger a click on the button when focusing directly
            setTimeout((function() {
            setTimeout(()=>{
                this.button.focus();
            }).bind(this));
            });
        }
    }
    _toggleSelection(optionIndex, close = true) {
        const option1 = this.el.item(optionIndex);
        if (this.multiple) this.el.item(optionIndex).selected = !this.el.item(optionIndex).selected;
        else this.el.selectedIndex = optionIndex;
        this.el.dispatchEvent(new Event('change'));
        this.suggestions.forEach((suggestion)=>{
        const toggledOption = this.el.item(optionIndex);
        if (this.multiple) {
            if (toggledOption.hasAttribute('selected')) toggledOption.removeAttribute('selected');
            else toggledOption.setAttribute('selected', 'selected');
        } else {
            toggledOption.setAttribute('selected', 'selected');
            this.el.selectedIndex = optionIndex;
        }
        this.updatedOriginalOptions = this.updatedOriginalOptions.map((option)=>{
            if (option.value === toggledOption.value) {
                if (toggledOption.hasAttribute('selected')) option.setAttribute('selected', 'selected');
                else option.removeAttribute('selected');
            }
            if (!this.multiple && option.value !== toggledOption.value) option.removeAttribute('selected');
            return option;
        });
        this.suggestions = this.suggestions.map((suggestion)=>{
            const index = parseInt(suggestion.getAttribute('data-index'), 10);
            const option = this.el.item(index);
            if (option && option.selected) suggestion.setAttribute('aria-selected', 'true');
            else suggestion.removeAttribute('aria-selected');
            return suggestion;
        });
        this.el.dispatchEvent(new Event('change'));
        this._setButtonText();
        if (this.multiple && this._options.showSelected) this._updateSelectedList();
        if (close && this.open) this._toggleOverlay();
    }
    _updateSelectedList() {
        const items = Array.prototype.map.call(this.el.options, (function(option, index) {
        const items = this.currentOptions.map((option, index)=>{
            if (!option.selected) return;
            const text = option.label || option.value;
            return `


@@ 382,7 486,7 @@ class $5a3b80354f588438$var$Select {
            <span class="select-a11y-delete__icon" aria-hidden="true"></span>
          </button>
        </li>`;
        }).bind(this)).filter(Boolean);
        }).filter(Boolean);
        this.selectedList.innerHTML = items.join('');
        if (items.length) {
            if (!this.selectedList.parentElement) this.wrap.appendChild(this.selectedList);

M package-lock.json => package-lock.json +61 -61
@@ 1,38 1,53 @@
{
  "name": "@conciergerie-dev/select-a11y",
  "name": "@conciergerie.dev/select-a11y",
  "version": "2.1.2",
  "lockfileVersion": 2,
  "requires": true,
  "packages": {
    "": {
      "name": "@conciergerie-dev/select-a11y",
      "name": "@conciergerie.dev/select-a11y",
      "version": "2.1.2",
      "license": "MIT",
      "devDependencies": {
        "@parcel/transformer-sass": "^2.4.0",
        "parcel": "^2.4.0",
        "puppeteer": "^13.5.1",
        "tape": "^5.5.2"
        "tape": "^5.5.3"
      }
    },
    "node_modules/@babel/code-frame": {
      "version": "7.0.0",
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz",
      "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==",
      "version": "7.16.7",
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
      "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
      "dev": true,
      "dependencies": {
        "@babel/highlight": "^7.0.0"
        "@babel/highlight": "^7.16.7"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/helper-validator-identifier": {
      "version": "7.16.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz",
      "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==",
      "dev": true,
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/highlight": {
      "version": "7.0.0",
      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0.tgz",
      "integrity": "sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw==",
      "version": "7.17.9",
      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz",
      "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==",
      "dev": true,
      "dependencies": {
        "@babel/helper-validator-identifier": "^7.16.7",
        "chalk": "^2.0.0",
        "esutils": "^2.0.2",
        "js-tokens": "^4.0.0"
      },
      "engines": {
        "node": ">=6.9.0"
      }
    },
    "node_modules/@babel/highlight/node_modules/ansi-styles": {


@@ 2129,15 2144,6 @@
        "node": ">=0.8.0"
      }
    },
    "node_modules/esutils": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
      "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=",
      "dev": true,
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/extract-zip": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",


@@ 2879,13 2885,10 @@
      "dev": true
    },
    "node_modules/json5": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
      "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
      "version": "2.2.1",
      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
      "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
      "dev": true,
      "dependencies": {
        "minimist": "^1.2.5"
      },
      "bin": {
        "json5": "lib/cli.js"
      },


@@ 2944,9 2947,9 @@
      }
    },
    "node_modules/minimist": {
      "version": "1.2.5",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
      "version": "1.2.6",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
      "dev": true
    },
    "node_modules/mkdirp-classic": {


@@ 3694,9 3697,9 @@
      }
    },
    "node_modules/tape": {
      "version": "5.5.2",
      "resolved": "https://registry.npmjs.org/tape/-/tape-5.5.2.tgz",
      "integrity": "sha512-N9Ss672dFE3QlppiXGh2ieux8Ophau/HSAQguW5cXQworKxV0QvnZCYI35W1OYySTJk0OC9OPuS+0xNO6lhiTQ==",
      "version": "5.5.3",
      "resolved": "https://registry.npmjs.org/tape/-/tape-5.5.3.tgz",
      "integrity": "sha512-hPBJZBL9S7bH9vECg/KSM24slGYV589jJr4dmtiJrLD71AL66+8o4b9HdZazXZyvnilqA7eE8z5/flKiy0KsBg==",
      "dev": true,
      "dependencies": {
        "array.prototype.every": "^1.1.3",


@@ 3711,7 3714,7 @@
        "has-dynamic-import": "^2.0.1",
        "inherits": "^2.0.4",
        "is-regex": "^1.1.4",
        "minimist": "^1.2.5",
        "minimist": "^1.2.6",
        "object-inspect": "^1.12.0",
        "object-is": "^1.1.5",
        "object-keys": "^1.1.1",


@@ 4014,22 4017,28 @@
  },
  "dependencies": {
    "@babel/code-frame": {
      "version": "7.0.0",
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.0.0.tgz",
      "integrity": "sha512-OfC2uemaknXr87bdLUkWog7nYuliM9Ij5HUcajsVcMCpQrcLmtxRbVFTIqmcSkSeYRBFBRxs2FiUqFJDLdiebA==",
      "version": "7.16.7",
      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
      "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
      "dev": true,
      "requires": {
        "@babel/highlight": "^7.0.0"
        "@babel/highlight": "^7.16.7"
      }
    },
    "@babel/helper-validator-identifier": {
      "version": "7.16.7",
      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz",
      "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==",
      "dev": true
    },
    "@babel/highlight": {
      "version": "7.0.0",
      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.0.0.tgz",
      "integrity": "sha512-UFMC4ZeFC48Tpvj7C8UgLvtkaUuovQX+5xNWrsIoMG8o2z+XFKjKaN9iVmS84dPwVN00W4wPmqvYoZF3EGAsfw==",
      "version": "7.17.9",
      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.17.9.tgz",
      "integrity": "sha512-J9PfEKCbFIv2X5bjTMiZu6Vf341N05QIY+d6FvVKynkG1S7G0j3I0QoRtWIrXhZ+/Nlb5Q0MzqL7TokEJ5BNHg==",
      "dev": true,
      "requires": {
        "@babel/helper-validator-identifier": "^7.16.7",
        "chalk": "^2.0.0",
        "esutils": "^2.0.2",
        "js-tokens": "^4.0.0"
      },
      "dependencies": {


@@ 5404,12 5413,6 @@
      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
      "dev": true
    },
    "esutils": {
      "version": "2.0.2",
      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz",
      "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=",
      "dev": true
    },
    "extract-zip": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",


@@ 5905,13 5908,10 @@
      "dev": true
    },
    "json5": {
      "version": "2.2.0",
      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
      "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
      "dev": true,
      "requires": {
        "minimist": "^1.2.5"
      }
      "version": "2.2.1",
      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
      "integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
      "dev": true
    },
    "lines-and-columns": {
      "version": "1.2.4",


@@ 5957,9 5957,9 @@
      }
    },
    "minimist": {
      "version": "1.2.5",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
      "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==",
      "version": "1.2.6",
      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==",
      "dev": true
    },
    "mkdirp-classic": {


@@ 6521,9 6521,9 @@
      }
    },
    "tape": {
      "version": "5.5.2",
      "resolved": "https://registry.npmjs.org/tape/-/tape-5.5.2.tgz",
      "integrity": "sha512-N9Ss672dFE3QlppiXGh2ieux8Ophau/HSAQguW5cXQworKxV0QvnZCYI35W1OYySTJk0OC9OPuS+0xNO6lhiTQ==",
      "version": "5.5.3",
      "resolved": "https://registry.npmjs.org/tape/-/tape-5.5.3.tgz",
      "integrity": "sha512-hPBJZBL9S7bH9vECg/KSM24slGYV589jJr4dmtiJrLD71AL66+8o4b9HdZazXZyvnilqA7eE8z5/flKiy0KsBg==",
      "dev": true,
      "requires": {
        "array.prototype.every": "^1.1.3",


@@ 6538,7 6538,7 @@
        "has-dynamic-import": "^2.0.1",
        "inherits": "^2.0.4",
        "is-regex": "^1.1.4",
        "minimist": "^1.2.5",
        "minimist": "^1.2.6",
        "object-inspect": "^1.12.0",
        "object-is": "^1.1.5",
        "object-keys": "^1.1.1",

M package.json => package.json +3 -2
@@ 29,7 29,8 @@
    "url": "https://git.sr.ht/~nicolaskempf57/select-a11y"
  },
  "scripts": {
    "build": "parcel build",
    "build": "rm -rf .parcel-cache && parcel build",
    "dev": "rm -rf .parcel-cache && parcel watch",
    "prepublishOnly": "npm run build",
    "test": "tape tests/index.js"
  },


@@ 39,7 40,7 @@
    "@parcel/transformer-sass": "^2.4.0",
    "parcel": "^2.4.0",
    "puppeteer": "^13.5.1",
    "tape": "^5.5.2"
    "tape": "^5.5.3"
  },
  "bugs": {
    "url": "https://todo.sr.ht/~nicolaskempf57/select-a11y",

M public/index.html => public/index.html +1 -1
@@ 83,7 83,7 @@
              <option>Sleeping</option>
              <option>Climbing trees</option>
              <option>Knitting socks</option>
              <option selected>Riding bikes</option>
              <option selected="selected">Riding bikes</option>
              <option>Eating cupcakes</option>
          </select>
        </div>

M src/select-a11y.js => src/select-a11y.js +255 -127
@@ 12,7 12,7 @@ const matches = Element.prototype.matches;
let closest = Element.prototype.closest;

if (!closest) {
  closest = function(s) {
  closest = function (s) {
    var el = this;

    do {


@@ 23,6 23,18 @@ if (!closest) {
  };
}

const DEEP_CLONE = true;

/**
 * Deep copy of an {@link Iterable} as {@link Array}
 * @template {HTMLElement} T
 * @param {Iterable<T>} array 
 * @returns {Array<T>}
 */
function deepCopy (array) {
  return /** @type {Array<T>} */ (Array.from(array).map(option => option.cloneNode(DEEP_CLONE)));
}

class Select {
  /**
   * @param {HTMLSelectElement} el - Select HTML element


@@ 35,14 47,14 @@ class Select {
   * @param {object} [options.text.deleteItem] - text used as title for "x" close button for selected option (see options.showSelected below)
   * @param {object} [options.text.delete] - text used for assistive technologies for the "x" close button for selected option (see options.showSelected below)
   * @param {object} [options.text.clear] - text used for assistive technologies for the "x" clear button for clearable single select (see options.clearable below)
   * @param {boolean} [options.enableTextFilter=true] - filtrer options based on search input content
   * @param {FillSuggestions} [options.fillSuggestions] - fill suggestions based on search input content
   * @param {boolean} [options.showSelected=true] - show selected options for multiple select
   * @param {boolean} [options.useLabelAsButton=false] - use label as button even for single select. 
   * Only work if select value is set to `null` otherwise its value defaults to first option.
   * @param {boolean} [options.clearable=false] - show clear icon for single select. 
   * Only work if select value is set. It resets it to `null`.
   */
  constructor( el, options ){
  constructor(el, options) {
    /** @type {HTMLSelectElement} */
    this.el = el;
    /** @type {HTMLLabelElement} */


@@ 51,20 63,22 @@ class Select {
    this.open = false;
    this.multiple = this.el.multiple;
    this.search = '';
    /** @type {Array<HTMLElement>} */
    this.suggestions = [];
    this.focusIndex = null;

    const passedOptions = Object.assign({}, options);
    const textOptions = Object.assign({}, text, passedOptions.text);
    delete passedOptions.text;
    this._defaultSearch = this._defaultSearch.bind(this);

    this._options = Object.assign({
      text: textOptions,
      showSelected: true,
      enableTextFilter: true,
      fillSuggestions: this._defaultSearch,
      useLabelAsButton: false,
      clearable: false,
    }, passedOptions );
    }, passedOptions);

    this._handleFocus = this._handleFocus.bind(this);
    this._handleInput = this._handleInput.bind(this);


@@ 78,6 92,41 @@ class Select {
    this.setText = this.setText.bind(this);
    this._setButtonText = this._setButtonText.bind(this);

    if(!this.multiple) {
      const hasSelectedOption = Array.from(this.el.options).some(option => option.selected);
      if (this._options.useLabelAsButton && !hasSelectedOption) {
        const option = document.createElement('option');
        option.innerText = this.label.innerText;
        option.setAttribute('value', '');
        option.setAttribute('selected', 'selected');
        option.setAttribute('disabled', 'disabled');
        option.setAttribute('hidden', 'hidden');
        this.el.options.add(option, 0);
      }
    }
    
    /** 
     * Select original options at initialization of the component.
     * They are never modified and are used to handle reset.
     * @type {Array<HTMLOptionElement>} 
     */
    this.originalOptions =  deepCopy(this.el.options);

    /** 
     * Select original options at initialization of the component.
     * They are updated based on select / unselect of options but no options are added or removed to it.
     * This is the set of options passed to {@link FillSuggestions} callback.
     * @type {Array<HTMLOptionElement>}
     */
    this.updatedOriginalOptions = Array.from(this.el.options);

    /** 
     * Select current options. These can be completely differents options than {@link originalOptions} 
     * if the provided promise fetches some from an API.
     * @type {Array<HTMLOptionElement>} 
     */
    this.currentOptions = Array.from(this.el.options);

    this._disable();

    this.button = this._createButton();


@@ 87,7 136,7 @@ class Select {
    this.overlay = this._createOverlay();
    this.wrap = this._wrap();

    if(this.multiple && this._options.showSelected) {
    if (this.multiple && this._options.showSelected) {
      this.selectedList = this._createSelectedList();
      this._updateSelectedList();



@@ 120,8 169,8 @@ class Select {
   * @param {*} value option value
   */
  selectOption(value) {
    const optionIndex = Array.from(this.el.options).findIndex(option => option.value === value);
    if(optionIndex === -1) {
    const optionIndex = this.currentOptions.findIndex(option => option.value === value);
    if (optionIndex === -1) {
      return;
    }
    const shouldClose = this.multiple ? false : true;


@@ 137,25 186,15 @@ class Select {
    const text = document.createElement('div');
    text.className = 'select-a11y-button__text';

    if(this.multiple){
    if (this.multiple) {
      text.innerText = this.label.innerText;
    }
    else {
      const hasSelectedOption = Array.from(this.el.options).some(option => option.selected);
      if (this._options.useLabelAsButton && !hasSelectedOption) {
          const option = document.createElement('option');
          option.innerText = this.label.innerText;
          option.setAttribute('value', '');
          option.setAttribute('selected', 'selected');
          option.setAttribute('disabled', 'disabled');
          option.setAttribute('hidden', 'hidden');
          this.el.options.add(option, 0);
      }
      if(!this.label.id){
      if (!this.label.id) {
        this.label.id = `${this.el.id}-label`;
      }
      button.setAttribute('id',this.el.id+'-button');
      button.setAttribute('aria-labelledby', this.label.id+' '+button.id);
      button.setAttribute('id', this.el.id + '-button');
      button.setAttribute('aria-labelledby', this.label.id + ' ' + button.id);
    }

    button.appendChild(text);


@@ 213,92 252,162 @@ class Select {
    this.el.setAttribute('tabindex', '-1');
  }

  _fillSuggestions() {
    const search = this.search.toLowerCase();
  /**
   * 
   * @typedef Suggestion
   * @property {boolean} hidden - if suggestion is hidden
   * @property {boolean} disabled - if suggestion is disabled
   * @property {boolean} selected - if suggestion is selected
   * @property {string} label - label shown
   * @property {any} value - suggestion value
   * @property {string} [image] - suggestion image
   * @property {string} [alt] - suggestion image alt
   */

    // loop over the
    this.suggestions = Array.from(this.el.options).map((option, index) => {
      if(option.hidden) {
        return;
      }
  /**
   * 
   * @param {HTMLOptionElement} option 
   * @returns {Suggestion} - a suggestion
   */
  _mapToSuggestion(option) {
    return {
      hidden: option.hidden,
      disabled: option.disabled,
      selected: option.hasAttribute('selected'),
      label: option.label,
      value: option.value,
      image: option.dataset.image,
      alt: option.dataset.alt,
    }
  }

  /**
   * 
   * @param {Suggestion} suggestion 
   * @returns {HTMLOptionElement} - an option
   */
  _mapToOption(suggestion) {
    const option = document.createElement('option');
    option.label = suggestion.label;
    option.value = suggestion.value;
    if(suggestion.hidden) {
      option.setAttribute('hidden', 'hidden');
    }
    if(suggestion.disabled) {
      option.setAttribute('disabled', 'disabled');
    }
    if(suggestion.selected) {
      option.setAttribute('selected', 'selected');
    }
    if(suggestion.image) {
      option.dataset.image = suggestion.image;
    }
    if(suggestion.alt) {
      option.dataset.alt = suggestion.alt;
    }
    return option;
  }

  /**
   * @callback FillSuggestions
   * @param {string} search - searched term
   * @param {Array<HTMLOptionElement>} options - original select options
   * @returns {Promise<Array<Suggestion>>} suggestions
   */

  /**
   * 
   * @type {FillSuggestions} 
   */
  _defaultSearch(search, options) {
    const newOptions = options.filter(option => {
      const text = option.label || option.value;
      const formatedText = text.toLowerCase();
      return text.toLocaleLowerCase().indexOf(search) !== -1;
    }).map(this._mapToSuggestion);
    return Promise.resolve(newOptions);
  }

  /**
   * 
   * @returns {Promise<Array<Suggestion>>}
   */
  async _fillSuggestions() {
    const search = this.search.toLowerCase();

      // test if search text match the current option
      if(this._options.enableTextFilter && formatedText.indexOf(search) === -1) {
    // loop over the
    const suggestions = await this._options.fillSuggestions(search, this.updatedOriginalOptions);
    this.currentOptions = suggestions.map(this._mapToOption);
    this.el.replaceChildren(...this.currentOptions);
    const suggestionElements = suggestions.map((suggestion, index) => {
      if (suggestion.hidden || suggestion.disabled) {
        return;
      }

      // create the option
      const suggestion = document.createElement('div');
      suggestion.setAttribute('role', 'option');
      suggestion.setAttribute('tabindex', '0');
      suggestion.setAttribute('data-index', index);
      suggestion.classList.add('select-a11y-suggestion');

      const suggestionElement = document.createElement('div');
      suggestionElement.setAttribute('role', 'option');
      suggestionElement.setAttribute('tabindex', '0');
      suggestionElement.setAttribute('data-index', index.toString());
      suggestionElement.classList.add('select-a11y-suggestion');
      suggestionElement.innerText = suggestion.label || suggestion.value;
      // check if the option is selected
      if (option.selected) {
        suggestion.setAttribute('aria-selected', 'true');
      if (suggestion.selected) {
        suggestionElement.setAttribute('aria-selected', 'true');
      }

      suggestion.innerText = option.label || option.value;

      if (option.dataset.image) {
      if (suggestion.image) {
        const image = document.createElement('img');
        image.setAttribute('src', option.dataset.image);
        image.setAttribute('alt', option.dataset.alt ? option.dataset.alt : '');
        image.setAttribute('src', suggestion.image);
        image.setAttribute('alt', suggestion.alt ? suggestion.alt : '');
        image.classList.add('select-a11y-suggestion__image');
        suggestion.prepend(image);
        suggestionElement.prepend(image);
      }

      return suggestion;
      return suggestionElement;
    }).filter(Boolean);

    if(!this.suggestions.length){
    this.suggestions = suggestionElements;
    if (!suggestionElements.length) {
      this.list.innerHTML = `<p class="select-a11y__no-suggestion">${this._options.text.noResult}</p>`;
    }
    else {
      const listBox = document.createElement('div');
      listBox.setAttribute('role', 'listbox');

      if(this.multiple){
      if (this.multiple) {
        listBox.setAttribute('aria-multiselectable', 'true');
      }

      this.suggestions.forEach(function(suggestion){
        listBox.appendChild(suggestion);
      }.bind(this));
      suggestionElements.forEach((suggestionElement) => {
        listBox.appendChild(suggestionElement);
      });

      this.list.innerHTML = '';
      this.list.appendChild(listBox);
    }

    this._setLiveZone();
    return suggestions;
  }

  _handleOpener(event){
  _handleOpener(event) {
    this._toggleOverlay();
  }

  _handleFocus(){
    if(!this.open){
  _handleFocus() {
    if (!this.open) {
      return;
    }

    clearTimeout(this._focusTimeout);

    this._focusTimeout = setTimeout(() => {
      if(!this.overlay.contains(document.activeElement) && this.button !== document.activeElement){
        this._toggleOverlay( false, document.activeElement === document.body);
      if (!this.overlay.contains(document.activeElement) && this.button !== document.activeElement) {
        this._toggleOverlay(false, document.activeElement === document.body);
      }
      else if(document.activeElement === this.input){
      else if (document.activeElement === this.input) {
        // reset the focus index
        this.focusIndex =  null;
        this.focusIndex = null;
      }
      else {
        const optionIndex = this.suggestions.indexOf(document.activeElement);
        const optionIndex = this.suggestions.indexOf(/** @type HTMLElement */ (document.activeElement));

        if(optionIndex !== -1){
        if (optionIndex !== -1) {
          this.focusIndex = optionIndex;
        }
      }


@@ 313,20 422,23 @@ class Select {
  _handleReset() {
    clearTimeout(this._resetTimeout);

    this._resetTimeout = setTimeout(() => {
      this._fillSuggestions();
      if(this.multiple && this._options.showSelected){
    this._resetTimeout = setTimeout(async () => {
      this.search = '';
      this.updatedOriginalOptions = deepCopy(this.originalOptions);
      this.currentOptions = deepCopy(this.originalOptions);
      await this._fillSuggestions();
      this.el.dispatchEvent(new Event('change'));
      this._setButtonText();
      if (this.multiple && this._options.showSelected) {
        this._updateSelectedList();
      }
      this._setButtonText();
      this.el.dispatchEvent(new Event('change'));
    }, 10);
  }

  _handleSuggestionClick(event){
  _handleSuggestionClick(event) {
    const option = closest.call(event.target, '[role="option"]');

    if(!option){
    if (!option) {
      return;
    }



@@ 336,9 448,9 @@ class Select {
    this._toggleSelection(optionIndex, shouldClose);
  }

  _handleInput(){
  _handleInput() {
    // prevent event fireing on focus and blur
    if( this.search === this.input.value ){
    if (this.search === this.input.value) {
      return;
    }



@@ 346,64 458,64 @@ class Select {
    this._fillSuggestions();
  }

  _handleKeyboard(event){
  _handleKeyboard(event) {
    const option = closest.call(event.target, '[role="option"]');
    const input = closest.call(event.target, 'input');

    if(event.keyCode === 27){
    if (event.keyCode === 27) {
      this._toggleOverlay();
      return;
    }

    if(input && event.keyCode === 13){
    if (input && event.keyCode === 13) {
      event.preventDefault();
      return;
    }

    if(event.keyCode === 40){
    if (event.keyCode === 40) {
      event.preventDefault();
      this._moveIndex(1);
      return
    }

    if(!option){
    if (!option) {
      return;
    }

    if(event.keyCode === 39){
    if (event.keyCode === 39) {
      event.preventDefault();
      this._moveIndex(1);
      return
    }

    if(event.keyCode === 37 || event.keyCode === 38){
    if (event.keyCode === 37 || event.keyCode === 38) {
      event.preventDefault();
      this._moveIndex(-1);
      return;
    }

    if(( !this.multiple && event.keyCode === 13 ) || event.keyCode === 32){
    if ((!this.multiple && event.keyCode === 13) || event.keyCode === 32) {
      event.preventDefault();
      this._toggleSelection(parseInt(option.getAttribute('data-index'), 10), this.multiple ? false : true);
    }

    if(this.multiple && event.keyCode === 13){
    if (this.multiple && event.keyCode === 13) {
      this._toggleOverlay();
    }
  }

  _moveIndex(step){
    if(this.focusIndex === null){
  _moveIndex(step) {
    if (this.focusIndex === null) {
      this.focusIndex = 0;
    }
    else {
      const nextIndex = this.focusIndex + step;
      const selectionItems = this.suggestions.length - 1;

      if(nextIndex > selectionItems){
      if (nextIndex > selectionItems) {
        this.focusIndex = 0;
      }
      else if(nextIndex < 0){
      else if (nextIndex < 0) {
        this.focusIndex = selectionItems;
      }
      else {


@@ 424,23 536,23 @@ class Select {
  _removeOption(event) {
    const button = closest.call(event.target, 'button');

    if(!button) {
    if (!button) {
      return;
    }

    const currentButtons = this.selectedList.querySelectorAll('button');
    const buttonPreviousIndex = Array.prototype.indexOf.call(currentButtons, button) - 1;
    const optionIndex = parseInt( button.getAttribute('data-index'), 10);
    const optionIndex = parseInt(button.getAttribute('data-index'), 10);

    // disable the option
    this._toggleSelection(optionIndex);

    // manage the focus if there's still the selected list
    if(this.selectedList.parentElement) {
    if (this.selectedList.parentElement) {
      const buttons = this.selectedList.querySelectorAll('button');

      // look for the bouton before the one clicked
      if(buttons[buttonPreviousIndex]){
      if (buttons[buttonPreviousIndex]) {
        buttons[buttonPreviousIndex].focus();
      }
      // fallback to the first button in the list if there's none


@@ 453,26 565,26 @@ class Select {
  }

  _setButtonText() {
    if(!this.multiple) {
    if (!this.multiple) {
      const selectedOption = this.el.item(this.el.selectedIndex);
      if(selectedOption && selectedOption.value) {
      if (selectedOption && selectedOption.value) {
        this.button.classList.remove('select-a11y-button--no-selected-option');
      } else {
        this.button.classList.add('select-a11y-button--no-selected-option');
      }
      const child = this.button.firstElementChild;
      if(child instanceof HTMLElement) {
      if (child instanceof HTMLElement) {
        child.innerText = selectedOption.label || selectedOption.value;
      }
    }
  }

  _setLiveZone(){
  _setLiveZone() {
    const suggestions = this.suggestions.length;
    let text = '';

    if(this.open){
      if(!suggestions){
    if (this.open) {
      if (!suggestions) {
        text = this._options.text.noResult;
      }
      else {


@@ 483,20 595,20 @@ class Select {
    this.liveZone.innerText = text;
  }

  _toggleOverlay(state, focusBack){
  _toggleOverlay(state, focusBack) {
    this.open = state !== undefined ? state : !this.open;
    this.button.setAttribute('aria-expanded', this.open);

    if(this.open){
    if (this.open) {
      this._fillSuggestions();
      this.button.insertAdjacentElement('afterend', this.overlay);
      this.input.focus();
    }
    else if(this.wrap.contains(this.overlay)){
    else if (this.wrap.contains(this.overlay)) {
      this.wrap.removeChild(this.overlay);

      // reset the focus index
      this.focusIndex =  null;
      this.focusIndex = null;

      // reset search values
      this.input.value = '';


@@ 505,52 617,68 @@ class Select {

      // reset aria-live
      this._setLiveZone();
      if(state === undefined || focusBack){
      if (state === undefined || focusBack) {
        // fix bug that will trigger a click on the button when focusing directly
        setTimeout(function(){
        setTimeout(() => {
          this.button.focus();
        }.bind(this))
        });
      }
    }
  }

  _toggleSelection(optionIndex, close = true){
    const option = this.el.item(optionIndex);

    if(this.multiple){
      this.el.item(optionIndex).selected = !this.el.item(optionIndex).selected;
  _toggleSelection(optionIndex, close = true) {
    const toggledOption = this.el.item(optionIndex);
    if (this.multiple) {
      if(toggledOption.hasAttribute('selected')) {
        toggledOption.removeAttribute('selected');
      } else {
        toggledOption.setAttribute('selected', 'selected');
      }
    }
    else {
      toggledOption.setAttribute('selected', 'selected');
      this.el.selectedIndex = optionIndex;
    }
    this.el.dispatchEvent(new Event('change'));
    this.suggestions.forEach((suggestion) => {
    this.updatedOriginalOptions = this.updatedOriginalOptions.map(option => {
      if(option.value === toggledOption.value) {
        if(toggledOption.hasAttribute('selected')) {
          option.setAttribute('selected', 'selected');
        } else {
          option.removeAttribute('selected');
        }
      }
      if(!this.multiple && option.value !== toggledOption.value) {
        option.removeAttribute('selected');
      }
      return option;
    });
    this.suggestions = this.suggestions.map((suggestion) => {
      const index = parseInt(suggestion.getAttribute('data-index'), 10);
      const option = this.el.item(index);
      if(option && option.selected) {
      if (option && option.selected) {
        suggestion.setAttribute('aria-selected', 'true');
      }
      else{
      else {
        suggestion.removeAttribute('aria-selected');
      }
      return suggestion;
    });

    this.el.dispatchEvent(new Event('change'));
    this._setButtonText();
    if(this.multiple && this._options.showSelected) {
    if (this.multiple && this._options.showSelected) {
      this._updateSelectedList();
    }

    if(close && this.open){
    if (close && this.open) {
      this._toggleOverlay();
    }
  }

  _updateSelectedList() {
    const items = Array.prototype.map.call(this.el.options, function(option, index) {
      if(!option.selected){
    const items = this.currentOptions.map((option, index) => {
      if(!option.selected) {
        return;
      }

      const text = option.label || option.value;

      return `


@@ 561,21 689,21 @@ class Select {
            <span class="select-a11y-delete__icon" aria-hidden="true"></span>
          </button>
        </li>`;
    }.bind(this)).filter(Boolean);
    }).filter(Boolean);

    this.selectedList.innerHTML = items.join('');

    if(items.length){
      if(!this.selectedList.parentElement){
    if (items.length) {
      if (!this.selectedList.parentElement) {
        this.wrap.appendChild(this.selectedList);
      }
    }
    else if(this.selectedList.parentElement){
    else if (this.selectedList.parentElement) {
      this.wrap.removeChild(this.selectedList);
    }
  }

  _wrap(){
  _wrap() {
    const wrapper = document.createElement('div');
    wrapper.classList.add('select-a11y');
    this.el.parentElement.appendChild(wrapper);


@@ 584,7 712,7 @@ class Select {
    tagHidden.classList.add('select-a11y__hidden');
    tagHidden.setAttribute('aria-hidden', 'true');

    if(this.multiple || this._options.useLabelAsButton){
    if (this.multiple || this._options.useLabelAsButton) {
      tagHidden.appendChild(this.label);
    }
    tagHidden.appendChild(this.el);


@@ 592,7 720,7 @@ class Select {
    wrapper.appendChild(tagHidden);
    wrapper.appendChild(this.liveZone);
    wrapper.appendChild(this.button);
    if(this._options.clearable) {
    if (this._options.clearable) {
      wrapper.appendChild(this.clearButton);
    }


M tests/index.js => tests/index.js +12 -12
@@ 84,10 84,9 @@ test( 'Programmatically assign value to select-a11y', async t => {
    var select = document.querySelector('.form-group select[data-select-a11y]');
    if(select instanceof HTMLSelectElement) {
      const selectA11y = window.selectA11ys.shift();
      const button = document.querySelector('.form-group button');
      const item = select.options.item(2);
      selectA11y.selectOption(item.value);

      const button = document.querySelector('.form-group button');
      return {
        selectedOption: item.label,
        value: button.firstElementChild.textContent.trim()


@@ 95,7 94,7 @@ test( 'Programmatically assign value to select-a11y', async t => {
    }
  });

  t.same(selectedOption, value, 'Programmatically selecting an option updates <select> value');
  t.same(value, selectedOption, 'Programmatically selecting an option updates <select> value');

  await browser.close();



@@ 259,7 258,7 @@ test('Création de la liste lors de l’ouverture du select simple', async t => 
  t.true(data.help.isParagraph, 'Le texte explicatif est présent');
  t.same(data.help.id, data.input.describedby, 'Le texte explicatif est lié au champ de recherche via l’attribut « aria-describedby »');
  t.same(data.label.for, data.input.id, 'Le label est lié au champ de recherche via l’attribut « for »');
  t.same(data.list.length, data.options.length, 'La liste crée contient le même nombre d’options que le select');
  t.same(data.list.length, data.options.length, 'La liste créée contient le même nombre d’options que le select');
  t.false(data.listBox.multiple, 'La liste pour le select ne contient pas d’attribut « aria-multiselectable »');

  await browser.close();


@@ 311,7 310,7 @@ test('Création de la liste lors de l’ouverture du select simple avec affichag
  t.true(data.help.isParagraph, 'Le texte explicatif est présent');
  t.same(data.help.id, data.input.describedby, 'Le texte explicatif est lié au champ de recherche via l’attribut « aria-describedby »');
  t.same(data.label.for, data.input.id, 'Le label est lié au champ de recherche via l’attribut « for »');
  t.same(data.list.length, data.options.length - 1, 'La liste crée contient une option de moins que le select, celle ajoutée par select-a11y');
  t.same(data.list.length, data.options.length - 1, 'La liste créée contient une option de moins que le select, celle ajoutée par select-a11y');
  t.false(data.listBox.multiple, 'La liste pour le select ne contient pas d’attribut « aria-multiselectable »');

  await browser.close();


@@ 930,17 929,17 @@ test( 'Reset du formulaire', async t => {

  await page.click('[type="reset"]');

  await page.waitForTimeout(10);
  await page.waitForTimeout(50);

  const {singleState, multipleState} = await page.evaluate(() => {
    const singleSelect = document.querySelector('select[data-select-a11y]:not([multiple])');
    const multipleSelect = document.querySelector('select[data-select-a11y][multiple]');
    const singleSelect = document.querySelector('.form-group select');
    const multipleSelect = document.querySelector('.multiple select');
    const list = Array.from(document.querySelectorAll('.multiple .select-a11y__selected-list li'));
    const singleState = {};
    const multipleState = {};
    if(singleSelect instanceof HTMLSelectElement) {
      singleState.selectedValue = singleSelect.value;
      singleState.selectedOption = singleSelect.item(singleSelect.selectedIndex).text;
      singleState.selectedOption = singleSelect.item(singleSelect.selectedIndex).label;
      singleState.label = document.querySelector('.form-group button div').textContent.trim();
    }
    if(multipleSelect instanceof HTMLSelectElement) {


@@ 951,10 950,11 @@ test( 'Reset du formulaire', async t => {
  });

  t.same(singleState.selectedOption, singleState.label, 'Le reset de formulaire change le texte du bouton d’ouverture')

  const selectedOptionsMatches = multipleState.selectedOptions.every((option, index) =>{
  console.log('selected options: ' + multipleState.selectedOptions);
  console.log('selected list: ' + multipleState.selectedItems);
  const selectedOptionsMatches = multipleState.selectedOptions.every((option, index) => {
    return option === multipleState.selectedItems[index];
  })
  });

  t.true(selectedOptionsMatches, 'Le reset de formulaire change la liste des éléments sélectionnés')