@atlassian/aui
Version:
Atlassian User Interface library
623 lines (524 loc) • 21.2 kB
JavaScript
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;