@cfpb/cfpb-design-system
Version:
CFPB's UI framework
615 lines (526 loc) • 18.2 kB
JavaScript
import {
EventObserver,
checkDom,
setInitFlag,
isMobileUserAgent,
instantiateAll,
} from '../../utilities';
import MultiselectModel, { MAX_SELECTIONS } from './multiselect-model.js';
import { create } from './multiselect-utils.js';
import * as MultiselectStyles from './multiselect.scss';
import * as closeIconSrc from '../cfpb-icons/icons/error.svg';
const closeIcon = closeIconSrc.default;
const BASE_CLASS = 'o-multiselect';
const CHECKBOX_INPUT_CLASS = 'a-checkbox';
const TEXT_INPUT_CLASS = 'a-text-input';
// Constants for direction.
const DIR_PREV = 'prev';
const DIR_NEXT = 'next';
// Constants for key binding.
const KEY_RETURN = 'Enter';
const KEY_SPACE = ' ';
const KEY_ESCAPE = 'Escape';
const KEY_UP = 'ArrowUp';
const KEY_DOWN = 'ArrowDown';
const KEY_TAB = 'Tab';
// Configuration default
const DEFAULT_CONFIG = {
// TODO: renderTags was added as a workaround for DS icons not rendering correctly when integrating with a React implementation.
renderTags: true, // Allow the Multiselect to generate the Tag elements in the DOM
maxSelections: MAX_SELECTIONS, // Maximum number of options a user can select
};
/**
* Multiselect
* @class
* @classdesc Initializes a new Multiselect molecule.
* @param {HTMLElement} element - The DOM element within which to search
* for the molecule.
* @returns {Multiselect} An instance.
*/
function Multiselect(element) {
/* TODO: As the multiselect is developed further
explore whether it should use an updated
class name or data-* attribute in the
markup so that it doesn't apply globally by default. */
element.classList.add(BASE_CLASS);
// Internal vars.
let _dom = checkDom(element, BASE_CLASS);
let _isBlurSkipped = false;
let _name;
let _placeholder;
let _model;
let _options;
let _config; // Multiselect configuration object
// Markup elems, convert this to templating engine in the future.
let _containerDom;
let _selectionsDom;
let _headerDom;
let _searchDom;
let _fieldsetDom;
let _optionsDom;
const _optionItemDoms = [];
let _instance;
/**
* Set the filtered matched state.
*/
function _filterMatches() {
_optionsDom.classList.remove('u-no-results');
_optionsDom.classList.add('u-filtered');
let filteredIndices = _model.getLastFilterIndices();
for (let i = 0, len = filteredIndices.length; i < len; i++) {
_optionItemDoms[filteredIndices[i]].classList.remove('u-filter-match');
}
filteredIndices = _model.getFilterIndices();
for (let j = 0, len = filteredIndices.length; j < len; j++) {
_optionItemDoms[filteredIndices[j]].classList.add('u-filter-match');
}
}
/**
* Resets the filtered option list.
*/
function _resetFilter() {
_optionsDom.classList.remove('u-filtered', 'u-no-results');
for (let i = 0, len = _optionsDom.children.length; i < len; i++) {
_optionsDom.children[i].classList.remove('u-filter-match');
}
_model.clearFilter();
}
/**
* Updates the list of options to show the user there
* are no matching results.
*/
function _filterNoMatches() {
_optionsDom.classList.add('u-no-results');
_optionsDom.classList.remove('u-filtered');
}
/**
* Filter the options list.
* Every time we filter we have two lists of indices:
* - The matching options (filterIndices).
* - The matching options of the last filter (_lastFilterIndices).
* We need to turn off the filter for any of the last filter matches
* that are not in the new set, and turn on the filter for the matches
* that are not in the last set.
* @param {Array} filterIndices - List of indices to filter from the options.
* @returns {boolean} True if options are filtered, false otherwise.
*/
function _filterList(filterIndices) {
if (filterIndices.length > 0) {
_filterMatches();
return true;
}
_filterNoMatches();
return false;
}
/**
* Evaluates the list of options based on the user's query in the
* search input.
* @param {string} value - Text the user has entered in the search query.
*/
function _evaluate(value) {
_resetFilter();
_model.resetIndex();
const matchedIndices = _model.filterIndices(value);
_filterList(matchedIndices);
}
/**
* Expand the multiselect drop down.
* @returns {Multiselect} An instance.
*/
function expand() {
_containerDom.classList.add('u-active');
_fieldsetDom.classList.remove('u-invisible');
_fieldsetDom.setAttribute('aria-hidden', false);
_instance.dispatchEvent('expandbegin', { target: _instance });
return _instance;
}
/**
* Collapse the multiselect drop down.
* @returns {Multiselect} An instance.
*/
function collapse() {
_containerDom.classList.remove('u-active');
_fieldsetDom.classList.add('u-invisible');
_fieldsetDom.setAttribute('aria-hidden', true);
_model.resetIndex();
_instance.dispatchEvent('collapsebegin', { target: _instance });
return _instance;
}
/**
* Highlights an option in the list.
* @param {string} direction
* Direction to highlight compared to the current focus.
*/
function _highlight(direction) {
if (direction === DIR_NEXT) {
_model.setIndex(_model.getIndex() + 1);
} else if (direction === DIR_PREV) {
_model.setIndex(_model.getIndex() - 1);
}
const index = _model.getIndex();
if (index > -1) {
let filteredIndex = index;
const filterIndices = _model.getFilterIndices();
if (filterIndices.length > 0) {
filteredIndex = filterIndices[index];
}
const option = _model.getOption(filteredIndex);
const value = option.value;
const item = _optionsDom.querySelector('[data-option="' + value + '"]');
const input = item.querySelector('input');
_isBlurSkipped = true;
input.focus();
} else {
_isBlurSkipped = false;
_searchDom.focus();
}
}
/**
* Resets the search input and filtering.
*/
function _resetSearch() {
_searchDom.value = '';
_resetFilter();
}
/**
* This passes the click of the selected item button down to the label it
* contains. This is only required for browsers (IE11) that prevent the
* click of a selected item from cascading from the button down to the label
* it contains.
* @param {MouseEvent} event - The mouse click event object.
*/
function _selectionClickHandler(event) {
const target = event.target;
if (target.tagName === 'BUTTON') {
event.preventDefault();
target.removeEventListener('click', _selectionClickHandler);
target.querySelector('label').click();
}
}
/**
* @param {KeyboardEvent} event - The key down event object.
*/
function _selectionKeyDownHandler(event) {
if (event.key === KEY_SPACE || event.key === KEY_RETURN) {
const label = event.target.querySelector('label');
const checkbox = _optionsDom.querySelector(
'#' + label.getAttribute('for'),
);
checkbox.click();
}
}
/**
* Create a unique ID based on a select's option HTML element.
* @param {HTMLElement} option - A option HTML element.
* @returns {string} A hopefully unique ID.
*/
function _getOptionId(option) {
/* Replace any character that is not a word character with a dash.
https://regex101.com/r/ShHmRw/1
*/
return (
_name + '-' + option.value.trim().replace(/[^\w]/g, '-').toLowerCase()
);
}
/**
* @param {HTMLElement} selectionsDom - The UL item to inject list item into.
* @param {HTMLElement} option - The OPTION item to extract content from.
*/
function _createSelectedItem(selectionsDom, option) {
const optionId = _getOptionId(option);
const selectionsItemDom = create('li', null, {
'data-option': option.value,
});
const selectionsItemLabelDom = create('button', selectionsItemDom, {
type: 'button',
class: 'a-tag-filter',
innerHTML:
'<label for=' + optionId + '>' + option.text + closeIcon + '</label>',
});
selectionsDom.appendChild(selectionsItemDom);
selectionsItemLabelDom.addEventListener('click', _selectionClickHandler);
selectionsItemLabelDom.addEventListener(
'keydown',
_selectionKeyDownHandler,
);
}
/**
* Tracks a user's selections and updates the list in the dom.
* @param {number} optionIndex - The index position of the chosen option.
*/
function _updateSelections(optionIndex) {
const option =
_model.getOption(optionIndex) || _model.getOption(_model.getIndex());
if (option) {
if (option.checked) {
if (_optionsDom.classList.contains('u-max-selections')) {
_optionsDom.classList.remove('u-max-selections');
}
const dataOptionSel = '[data-option="' + option.value + '"]';
const _selectionsItemDom = _selectionsDom.querySelector(dataOptionSel);
// If the <Tag> exists
if (typeof _selectionsItemDom !== 'undefined' && _selectionsItemDom) {
_selectionsDom?.removeChild(_selectionsItemDom);
}
}
// Else, if we are configured to display <Tag>s then render them
else if (_config?.renderTags && _selectionsDom) {
_createSelectedItem(_selectionsDom, option);
}
_model.toggleOption(optionIndex);
if (_model.isAtMaxSelections()) {
_optionsDom.classList.add('u-max-selections');
}
_instance.dispatchEvent('selectionsupdated', { target: _instance });
}
_model.resetIndex();
_isBlurSkipped = false;
if (_fieldsetDom.getAttribute('aria-hidden') === 'false') {
_searchDom.focus();
}
}
/**
* Handles the functions to trigger on the checkbox change.
* @param {Event} event - The checkbox change event.
*/
function _changeHandler(event) {
_updateSelections(Number(event.target.getAttribute('data-index')));
_resetSearch();
}
/**
* Binds events to the search input, option list, and checkboxes.
*/
function _bindEvents() {
_headerDom.addEventListener('mousemove', function (event) {
const target = event.target;
// Check if we're over the down-arrow on the right side of the input.
if (event.offsetX > target.offsetWidth - 35) {
target.style.cursor = 'pointer';
} else {
target.style.cursor = 'auto';
}
});
_headerDom.addEventListener('mouseup', function (event) {
const target = event.target;
/* Check if we're over the down-arrow on the right side of the input.
Also check if the fieldset is open.
35 = width of the arrow on the right of the search input.
140 = the max-height value set in multiselect.src for the fieldset.
*/
if (
event.offsetX > target.offsetWidth - 35 &&
_fieldsetDom.offsetHeight === 140
) {
_searchDom.blur();
}
});
_searchDom.addEventListener('input', function () {
_evaluate(this.value);
});
_searchDom.addEventListener('focus', function () {
if (_fieldsetDom.getAttribute('aria-hidden') === 'true') {
expand();
}
});
_searchDom.addEventListener('blur', function () {
if (
!_isBlurSkipped &&
_fieldsetDom.getAttribute('aria-hidden') === 'false'
) {
collapse();
}
});
_searchDom.addEventListener('keydown', function (event) {
const key = event.key;
if (
_fieldsetDom.getAttribute('aria-hidden') === 'true' &&
key !== KEY_TAB
) {
expand();
}
if (key === KEY_RETURN) {
event.preventDefault();
_highlight(DIR_NEXT);
} else if (key === KEY_ESCAPE) {
_resetSearch();
collapse();
} else if (key === KEY_DOWN) {
_highlight(DIR_NEXT);
} else if (
key === KEY_TAB &&
!event.shiftKey &&
_fieldsetDom.getAttribute('aria-hidden') === 'false'
) {
collapse();
}
});
_optionsDom.addEventListener('mousedown', function () {
_isBlurSkipped = true;
});
_optionsDom.addEventListener('keydown', function (event) {
const key = event.key;
const target = event.target;
const checked = target.checked;
if (key === KEY_RETURN) {
event.preventDefault();
/* Programmatically checking a checkbox does not fire a change event
so we need to manually create an event and dispatch it from the input.
*/
target.checked = !checked;
const evt = new Event('change', { bubbles: false, cancelable: true });
target.dispatchEvent(evt);
} else if (key === KEY_ESCAPE) {
_searchDom.focus();
collapse();
} else if (key === KEY_UP) {
_highlight(DIR_PREV);
} else if (key === KEY_DOWN) {
_highlight(DIR_NEXT);
}
});
_fieldsetDom.addEventListener('mousedown', function (event) {
if (event.target.tagName === 'LABEL') {
_isBlurSkipped = true;
}
});
const inputs = _optionsDom.querySelectorAll('input');
for (let i = 0, len = inputs.length; i < len; i++) {
inputs[i].addEventListener('change', _changeHandler);
}
// Add event listeners to any selections that are present at page load.
const labelButtons = _selectionsDom.querySelectorAll('button');
for (let j = 0, len = labelButtons.length; j < len; j++) {
labelButtons[j].addEventListener('click', _selectionClickHandler);
labelButtons[j].addEventListener('keydown', _selectionKeyDownHandler);
}
}
/**
* Populates and injects the markup for the custom multiselect.
* @returns {HTMLElement} Newly created <div> element to hold the multiselect.
*/
function _populateMarkup() {
// Add a container for our markup
_containerDom = document.createElement('div');
_containerDom.className = BASE_CLASS;
// Create all our markup but wait to manipulate the DOM just once
_selectionsDom = create('ul', null, {
className: 'm-tag-group',
});
_headerDom = create('header', _containerDom, {
className: BASE_CLASS + '__header',
});
_searchDom = create('input', _headerDom, {
className: BASE_CLASS + '__search ' + TEXT_INPUT_CLASS,
type: 'text',
placeholder: _placeholder || 'Select up to five',
id: _dom.id,
autocomplete: 'off',
});
_fieldsetDom = create('fieldset', _containerDom, {
className: BASE_CLASS + '__fieldset u-invisible',
'aria-hidden': 'true',
});
let optionsClasses = BASE_CLASS + '__options';
if (_model.isAtMaxSelections()) {
optionsClasses += ' u-max-selections';
}
_optionsDom = create('ul', _fieldsetDom, {
className: optionsClasses,
});
let option;
let optionId;
let isChecked;
for (let i = 0, len = _options.length; i < len; i++) {
option = _options[i];
optionId = _getOptionId(option);
isChecked = _model.getOption(i).checked;
const optionsItemDom = create('li', _optionsDom, {
'data-option': option.value,
'data-cy': 'multiselect-option',
class: 'm-form-field m-form-field--checkbox',
});
create('input', optionsItemDom, {
id: optionId,
// Type must come before value or IE fails
type: 'checkbox',
value: option.value,
name: _name,
class: CHECKBOX_INPUT_CLASS + ' ' + BASE_CLASS + '__checkbox',
checked: isChecked,
'data-index': i,
});
create('label', optionsItemDom, {
for: optionId,
textContent: option.text,
className: BASE_CLASS + '__label a-label',
});
_optionItemDoms.push(optionsItemDom);
// Create <Tag> if enabled
if (isChecked && _config?.renderTags) {
_createSelectedItem(_selectionsDom, option);
}
}
// Write our new markup to the DOM.
_containerDom.insertBefore(_selectionsDom, _headerDom);
_dom.parentNode.insertBefore(_containerDom, _dom);
_containerDom.appendChild(_dom);
return _containerDom;
}
/**
* Set up and create the multiselect.
* @param {object} multiselectConfig - Multiselect configuration options
* @returns {Multiselect} An instance.
*/
function init(multiselectConfig = DEFAULT_CONFIG) {
if (!setInitFlag(_dom)) {
return this;
}
if (isMobileUserAgent()) {
return this;
}
_instance = this;
_name = _dom.name || _dom.id;
_placeholder = _dom.getAttribute('placeholder');
_options = _dom.options || [];
// Allow devs to pass the config settings they want and not worry about the rest
_config = { ...DEFAULT_CONFIG, ...multiselectConfig };
if (_options.length > 0) {
// Store underlying model so we can expose it externally
_model = new MultiselectModel(_options, _name, _config).init();
const newDom = _populateMarkup();
/* Removes <select> element,
and re-assign DOM reference. */
_dom.parentNode.removeChild(_dom);
_dom = newDom;
/* We need to set init flag again since we've created a new <div>
to replace the <select> element. */
setInitFlag(_dom);
_bindEvents();
}
return this;
}
/**
* Allow external access to the underlying model for integration/customization when used in other applications.
* @returns {object} Model
*/
function getModel() {
return _model;
}
// Attach public events.
this.init = init;
this.expand = expand;
this.collapse = collapse;
const eventObserver = new EventObserver();
this.addEventListener = eventObserver.addEventListener;
this.removeEventListener = eventObserver.removeEventListener;
this.dispatchEvent = eventObserver.dispatchEvent;
this.getModel = getModel;
this.updateSelections = _updateSelections;
this.selectionClickHandler = _selectionClickHandler;
this.selectionKeyDownHandler = _selectionKeyDownHandler;
return this;
}
Multiselect.BASE_CLASS = BASE_CLASS;
Multiselect.init = (config) =>
instantiateAll(`.${BASE_CLASS}`, Multiselect, undefined, config);
export { Multiselect, MultiselectStyles };