UNPKG

@atlassian/aui

Version:

Atlassian User Interface library

623 lines (524 loc) 21.2 kB
import $ from './jquery'; import './button'; import { I18n } from './i18n'; import './spin'; import Option from './internal/select/option'; import amdify from './internal/amdify'; import CustomEvent from './polyfills/custom-event'; import globalize from './internal/globalize'; import keyCode from './key-code'; import ProgressiveDataSet from './progressive-data-set'; import skate from './internal/skate'; import state from './internal/state'; import SuggestionModel from './internal/select/suggestion-model'; import SuggestionsModel from './internal/select/suggestions-model'; import SuggestionsView from './internal/select/suggestions-view'; import template from './internal/select/template'; import uniqueId from './unique-id'; import { INPUT_SUFFIX } from './internal/constants'; import { ifGone } from './internal/elements'; const DESELECTED = -1; const NO_HIGHLIGHT = -1; const DEFAULT_SS_PDS_SIZE = 20; const ASSISTIVE_STATUS_DELAY_MS = 50; function decodeHtmlEntities(input) { var doc = new DOMParser().parseFromString(input, 'text/html'); return doc.documentElement.textContent; } function clearElementImage(element) { element._input.removeAttribute('style'); $(element._input).removeClass('aui-select-has-inline-image'); } function deselect(element) { element._select.selectedIndex = DESELECTED; clearElementImage(element); } function hasResults(element) { return element._suggestionModel.getNumberOfResults(); } function waitForAssistive(callback) { setTimeout(callback, ASSISTIVE_STATUS_DELAY_MS); } function setBusyState(element) { if (!element._button.isBusy()) { element._button.busy(); element._input.setAttribute('aria-busy', 'true'); element._dropdown.setAttribute('aria-busy', 'true'); } } function setIdleState(element) { element._button.idle(); element._input.setAttribute('aria-busy', 'false'); element._dropdown.setAttribute('aria-busy', 'false'); } function matchPrefix(model, query) { var value = model.get('label').toLowerCase(); return value.indexOf(query.toLowerCase()) === 0; } function resetDropdown(element) { element._input.setAttribute('aria-expanded', 'false'); element._button.setAttribute('aria-expanded', 'false'); element._input.removeAttribute('aria-activedescendant'); updateAssistiveStatus(element, ''); } function resetAndCloseDropdown(element) { element._suggestionsView.hide(); resetDropdown(element); } function setInitialVisualState(element) { var initialHighlightedItem = hasResults(element) ? 0 : NO_HIGHLIGHT; element._suggestionModel.highlight(initialHighlightedItem); resetAndCloseDropdown(element); } function setElementImage(element, imageSource) { $(element._input).addClass('aui-select-has-inline-image'); element._input.setAttribute('style', 'background-image: url(' + encodeURI(imageSource) + ')'); } function suggest(element, autoHighlight, query) { element._autoHighlight = autoHighlight; if (query === undefined) { query = element._input.value; } element._progressiveDataSet.query(query); } function setInputImageToHighlightedSuggestion(element) { var imageSource = element._suggestionModel.highlighted() && element._suggestionModel.highlighted().get('img-src'); if (imageSource) { setElementImage(element, imageSource); } } function setValueAndDisplayFromModel(element, model) { if (!model) { return; } var option = document.createElement('option'); var select = element._select; var value = model.get('value') || model.get('label'); var label = decodeHtmlEntities(model.getLabel()); option.setAttribute('selected', ''); option.setAttribute('value', value); option.textContent = label; // Sync element value. element._input.value = label; select.innerHTML = ''; select.options.add(option); select.dispatchEvent(new CustomEvent('change', { bubbles: true })); } function clearValue(element) { element._input.value = ''; element._select.innerHTML = ''; } function selectHighlightedSuggestion(element) { setValueAndDisplayFromModel(element, element._suggestionModel.highlighted()); setInputImageToHighlightedSuggestion(element); resetAndCloseDropdown(element); } function convertOptionToModel(option) { return new SuggestionModel(option.serialize()); } function convertOptionsToModels(element) { var models = []; for (var i = 0; i < element._datalist.children.length; i++) { var option = element._datalist.children[i]; models.push(convertOptionToModel(option)); } return models; } /** * Replaces the suggestions with a new set. * @param element * @param data * @returns {boolean} true if the results are different from the previous set; false otherwise. */ function clearAndSet(element, data) { const before = element._suggestionModel.getNumberOfResults(); element._suggestionModel.set(); element._suggestionModel.set(data.results); const after = element._suggestionModel.getNumberOfResults(); return before !== after; } function getActiveId(select) { var active = select._dropdown.querySelector('.aui-select-active'); return active && active.id; } function getIndexInResults(id, results) { var resultsIds = $.map(results, function (result) { return result.id; }); return resultsIds.indexOf(id); } function createNewValueModel(element) { var option = new Option(); option.setAttribute('value', element._input.value); var newValueSuggestionModel = convertOptionToModel(option); newValueSuggestionModel.set('new-value', true); return newValueSuggestionModel; } function initialiseProgressiveDataSet(element) { element._progressiveDataSet = new ProgressiveDataSet(convertOptionsToModels(element), { model: SuggestionModel, matcher: matchPrefix, queryEndpoint: element._queryEndpoint, maxResults: DEFAULT_SS_PDS_SIZE, }); element._isSync = !element._queryEndpoint; // Progressive data set should indicate whether or not it is busy when processing any async requests. // Check if there's any active queries left, if so: set spinner and state to busy, else set to idle and remove // the spinner. element._progressiveDataSet.on('activity', function () { if (element._progressiveDataSet.activeQueryCount && !element._isSync) { setBusyState(element); state(element).set('should-flag-new-suggestions', false); } else { setIdleState(element); state(element).set('should-flag-new-suggestions', true); } }); // Progressive data set doesn't do anything if the query is empty so we // must manually convert all data list options into models. // // Otherwise progressive data set can do everything else for us: // 1. Sync matching // 2. Async fetching and matching element._progressiveDataSet.on('respond', function (data) { // This means that a query was made before the input was cleared and // we should cancel the response. if (data.query && !element._input.value) { return; } if (state(element).get('should-cancel-response')) { if (!element._progressiveDataSet.activeQueryCount) { state(element).set('should-cancel-response', false); } return; } if (!data.query) { data.results = convertOptionsToModels(element); } var isInputExactMatch = getIndexInResults(element._input.value, data.results) !== -1; var isInputEmpty = !element._input.value; if (element.hasAttribute('can-create-values') && !isInputExactMatch && !isInputEmpty) { data.results.push(createNewValueModel(element)); } let indexOfValueInResults = getIndexInResults(element.value, data.results); indexOfValueInResults = indexOfValueInResults === -1 ? 0 : indexOfValueInResults; const resultsChanged = clearAndSet(element, data); const optionToHighlight = data.results[indexOfValueInResults]; if (element._autoHighlight) { element._suggestionModel.setHighlighted(optionToHighlight); waitForAssistive(function () { const activeId = getActiveId(element); if (activeId !== undefined && activeId !== null) { element._input.setAttribute('aria-activedescendant', activeId); } else { element._input.removeAttribute('aria-activedescendant'); } }); } element._input.setAttribute('aria-expanded', 'true'); element._button.setAttribute('aria-expanded', 'true'); // If the response is async (append operation), has elements to append and has a highlighted element, we need to update the status. if ( !element._isSync && resultsChanged && element._suggestionsView.getActive() && state(element).get('should-flag-new-suggestions') ) { element.querySelector('.aui-select-status').innerHTML = I18n.getText( 'aui.select.new.suggestions' ); } element._suggestionsView.show(); }); } function associateDropdownAndTrigger(element) { element._dropdown.id = element._listId; element.querySelector('button').setAttribute('aria-controls', element._listId); } function bindHighlightMouseover(element) { $(element._dropdown).on('mouseover', 'li', function (e) { if (hasResults(element)) { element._suggestionModel.highlight($(e.target).index()); } }); } export function bindSelectMousedown(element) { let preventClosingContainerLayer = false; $(document).on('aui-close-layers-on-outer-click.single-select', (e) => { if (preventClosingContainerLayer) { e.preventDefault(); preventClosingContainerLayer = false; } }); $(element._dropdown).on('mousedown', 'li', function (e) { if (hasResults(element)) { element._suggestionModel.highlight($(e.target).index()); selectHighlightedSuggestion(element); if ($(element).closest('.aui-layer').length > 0) { preventClosingContainerLayer = true; } } else { return false; } }); } function updateAssistiveStatus(element, status) { clearTimeout(element.assistiveStatusTimerId); element.assistiveStatusTimerId = setTimeout(() => { const assistiveStatusText = element._assistiveStatus.innerText; if (status !== assistiveStatusText) { element._assistiveStatus.innerText = status; } }, ASSISTIVE_STATUS_DELAY_MS); } function initialiseValue(element) { var option = element._datalist.querySelector('aui-option[selected]'); if (option) { setValueAndDisplayFromModel(element, convertOptionToModel(option)); } } function isQueryInProgress(element) { return element._progressiveDataSet.activeQueryCount > 0; } function focusInHandler(element) { //if there is a selected value the single select should do an empty //search and return everything const searchValue = element.value ? '' : element._input.value; suggest(element, true, searchValue); } function cancelInProgressQueries(element) { if (isQueryInProgress(element)) { state(element).set('should-cancel-response', true); } } function getSelectedLabel(element) { if (element._select.selectedIndex >= 0) { return element._select.options[element._select.selectedIndex].textContent; } } function handleInvalidInputOnFocusOut(element) { var selectCanBeEmpty = !element.hasAttribute('no-empty-values'); var selectionIsEmpty = !element._input.value; var selectionNotExact = element._input.value !== getSelectedLabel(element); var selectionNotValid = selectionIsEmpty || selectionNotExact; if (selectionNotValid) { if (selectCanBeEmpty) { deselect(element); } else { var selection = getSelectedLabel(element); if (typeof selection === 'undefined') { deselect(element); } else { element._input.value = selection; } } } } function handleHighlightOnFocusOut(element) { // Forget the highlighted suggestion. element._suggestionModel.highlight(NO_HIGHLIGHT); } function focusOutHandler(element) { cancelInProgressQueries(element); handleInvalidInputOnFocusOut(element); handleHighlightOnFocusOut(element); resetAndCloseDropdown(element); } const SelectEl = skate('aui-select', { template: template, created: function (element) { element._listId = uniqueId(); element._input = element.querySelector('input'); element._select = element.querySelector('select'); element._dropdown = element.querySelector('.aui-popover'); element._datalist = element.querySelector('datalist'); element._button = element.querySelector('button'); element._assistiveStatus = element.querySelector('.aui-select-status.assistive'); element._suggestionsView = new SuggestionsView(element._dropdown, element._input); element._suggestionModel = new SuggestionsModel(); element._suggestionModel.onChange = function (oldSuggestions) { const suggestionsToAdd = []; element._suggestionModel._suggestions.forEach(function (newSuggestion) { const inArray = oldSuggestions.some( (oldSuggestion) => newSuggestion.id === oldSuggestion.id ); if (!inArray) { suggestionsToAdd.push(newSuggestion); } }); const results = convertOptionsToModels(element); const indexOfValueInResults = getIndexInResults(element.value, results); const numberOfItems = oldSuggestions.length + suggestionsToAdd.length; const status = numberOfItems ? '' : `${I18n.getText('aui.select.no.suggestions')}`; element._suggestionsView.render( suggestionsToAdd, oldSuggestions.length, element._listId, indexOfValueInResults ); updateAssistiveStatus(element, status); }; element._suggestionModel.onHighlightChange = function () { const active = element._suggestionModel.highlightedIndex(); element._suggestionsView.setActive(active); const activeId = getActiveId(element); if (activeId !== undefined && activeId !== null) { element._input.setAttribute('aria-activedescendant', activeId); } else { element._input.removeAttribute('aria-activedescendant'); } }; }, attached: function (element) { skate.init(element); initialiseProgressiveDataSet(element); associateDropdownAndTrigger(element); element._input.setAttribute('aria-controls', element._listId); bindHighlightMouseover(element); bindSelectMousedown(element); initialiseValue(element); setInitialVisualState(element); setInputImageToHighlightedSuggestion(element); }, detached: function (element) { $(document).off('aui-close-layers-on-outer-click'); ifGone(element).then(() => { resetAndCloseDropdown(element); element._suggestionsView.destroy(); }); }, attributes: { id(element, data) { if (element.id) { element.querySelector('input').id = data.newValue + INPUT_SUFFIX; } }, name(element, data) { element.querySelector('select').setAttribute('name', data.newValue); element.querySelector('select').setAttribute('aria-label', `${data.newValue} list`); element.querySelector('button').setAttribute('aria-label', `${data.newValue} list`); element .querySelector('ul[role="listbox"]') .setAttribute('aria-label', `${data.newValue} list`); }, placeholder(element, data) { element.querySelector('input').setAttribute('placeholder', data.newValue); }, src(element, data) { element._queryEndpoint = data.newValue; }, }, events: { 'blur input': function (element) { focusOutHandler(element); }, 'mousedown button': function (element) { if ( document.activeElement === element._input && !element._dropdown.hasAttribute('hidden') ) { state(element).set('prevent-open-on-button-click', true); } }, 'click input': function (element) { focusInHandler(element); }, 'click button': function (element) { var data = state(element); if (data.get('prevent-open-on-button-click')) { data.set('prevent-open-on-button-click', false); } else { state(element).set('button-clicked-prevent-dropdown-hide', true); element.focus(); } }, 'input': function (element) { if (!element._input.value) { if (state(element).get('button-clicked-prevent-dropdown-hide')) { state(element).set('button-clicked-prevent-dropdown-hide', false); } else { resetAndCloseDropdown(element); } } else { suggest(element, true); } }, 'keydown input': function (element, e) { var currentValue = element._input.value; var handled = false; if (e.keyCode === keyCode.ESCAPE) { cancelInProgressQueries(element); // There is no need to hide layer manually here. It will be handled by layering system. // Otherwise, it can fire ESC event to the next layer, so // it will close the next ESCapable layer. // The only what we need is clean the state of the component. resetDropdown(element); return; } var isSuggestionViewVisible = element._suggestionsView.isVisible(); if (isSuggestionViewVisible && hasResults(element)) { if (e.keyCode === keyCode.ENTER) { cancelInProgressQueries(element); selectHighlightedSuggestion(element); e.preventDefault(); } else if (e.keyCode === keyCode.TAB) { selectHighlightedSuggestion(element); handled = true; } else if (e.keyCode === keyCode.UP) { element._suggestionModel.highlightPrevious(); e.preventDefault(); } else if (e.keyCode === keyCode.DOWN) { element._suggestionModel.highlightNext(); e.preventDefault(); } } else if (e.keyCode === keyCode.UP || e.keyCode === keyCode.DOWN) { focusInHandler(element); e.preventDefault(); } handled = handled || e.defaultPrevented; setTimeout(function emulateCrossBrowserInputEvent() { if (element._input.value !== currentValue && !handled) { element.dispatchEvent(new CustomEvent('input', { bubbles: true })); } }, 0); }, }, prototype: { get value() { var selected = this._select.options[this._select.selectedIndex]; return selected ? selected.value : ''; }, set value(value) { if (value === '') { clearValue(this); } else if (value) { var data = this._progressiveDataSet; var model = data.findWhere({ value }) || data.findWhere({ label: value }); // Create a new value if allowed and the value doesn't exist. if (!model && this.hasAttribute('can-create-values')) { model = new SuggestionModel({ value: value, label: value }); } setValueAndDisplayFromModel(this, model); } return this; }, get displayValue() { return this._input.value; }, blur: function () { this._input.blur(); focusOutHandler(this); return this; }, focus: function () { this._input.focus(); focusInHandler(this); return this; }, }, }); amdify('aui/select', SelectEl); globalize('select', SelectEl); export default SelectEl;