UNPKG

@uswds/uswds

Version:

Open source UI components and visual style guide for U.S. government websites

1,606 lines (1,514 loc) 808 kB
(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){ "use strict"; // element-closest | CC0-1.0 | github.com/jonathantneal/closest (function (ElementProto) { if (typeof ElementProto.matches !== 'function') { ElementProto.matches = ElementProto.msMatchesSelector || ElementProto.mozMatchesSelector || ElementProto.webkitMatchesSelector || function matches(selector) { var element = this; var elements = (element.document || element.ownerDocument).querySelectorAll(selector); var index = 0; while (elements[index] && elements[index] !== element) { ++index; } return Boolean(elements[index]); }; } if (typeof ElementProto.closest !== 'function') { ElementProto.closest = function closest(selector) { var element = this; while (element && element.nodeType === 1) { if (element.matches(selector)) { return element; } element = element.parentNode; } return null; }; } })(window.Element.prototype); },{}],2:[function(require,module,exports){ "use strict"; /* global define, KeyboardEvent, module */ (function () { var keyboardeventKeyPolyfill = { polyfill: polyfill, keys: { 3: 'Cancel', 6: 'Help', 8: 'Backspace', 9: 'Tab', 12: 'Clear', 13: 'Enter', 16: 'Shift', 17: 'Control', 18: 'Alt', 19: 'Pause', 20: 'CapsLock', 27: 'Escape', 28: 'Convert', 29: 'NonConvert', 30: 'Accept', 31: 'ModeChange', 32: ' ', 33: 'PageUp', 34: 'PageDown', 35: 'End', 36: 'Home', 37: 'ArrowLeft', 38: 'ArrowUp', 39: 'ArrowRight', 40: 'ArrowDown', 41: 'Select', 42: 'Print', 43: 'Execute', 44: 'PrintScreen', 45: 'Insert', 46: 'Delete', 48: ['0', ')'], 49: ['1', '!'], 50: ['2', '@'], 51: ['3', '#'], 52: ['4', '$'], 53: ['5', '%'], 54: ['6', '^'], 55: ['7', '&'], 56: ['8', '*'], 57: ['9', '('], 91: 'OS', 93: 'ContextMenu', 144: 'NumLock', 145: 'ScrollLock', 181: 'VolumeMute', 182: 'VolumeDown', 183: 'VolumeUp', 186: [';', ':'], 187: ['=', '+'], 188: [',', '<'], 189: ['-', '_'], 190: ['.', '>'], 191: ['/', '?'], 192: ['`', '~'], 219: ['[', '{'], 220: ['\\', '|'], 221: [']', '}'], 222: ["'", '"'], 224: 'Meta', 225: 'AltGraph', 246: 'Attn', 247: 'CrSel', 248: 'ExSel', 249: 'EraseEof', 250: 'Play', 251: 'ZoomOut' } }; // Function keys (F1-24). var i; for (i = 1; i < 25; i++) { keyboardeventKeyPolyfill.keys[111 + i] = 'F' + i; } // Printable ASCII characters. var letter = ''; for (i = 65; i < 91; i++) { letter = String.fromCharCode(i); keyboardeventKeyPolyfill.keys[i] = [letter.toLowerCase(), letter.toUpperCase()]; } function polyfill() { if (!('KeyboardEvent' in window) || 'key' in KeyboardEvent.prototype) { return false; } // Polyfill `key` on `KeyboardEvent`. var proto = { get: function (x) { var key = keyboardeventKeyPolyfill.keys[this.which || this.keyCode]; if (Array.isArray(key)) { key = key[+this.shiftKey]; } return key; } }; Object.defineProperty(KeyboardEvent.prototype, 'key', proto); return proto; } if (typeof define === 'function' && define.amd) { define('keyboardevent-key-polyfill', keyboardeventKeyPolyfill); } else if (typeof exports !== 'undefined' && typeof module !== 'undefined') { module.exports = keyboardeventKeyPolyfill; } else if (window) { window.keyboardeventKeyPolyfill = keyboardeventKeyPolyfill; } })(); },{}],3:[function(require,module,exports){ /* object-assign (c) Sindre Sorhus @license MIT */ 'use strict'; /* eslint-disable no-unused-vars */ var getOwnPropertySymbols = Object.getOwnPropertySymbols; var hasOwnProperty = Object.prototype.hasOwnProperty; var propIsEnumerable = Object.prototype.propertyIsEnumerable; function toObject(val) { if (val === null || val === undefined) { throw new TypeError('Object.assign cannot be called with null or undefined'); } return Object(val); } function shouldUseNative() { try { if (!Object.assign) { return false; } // Detect buggy property enumeration order in older V8 versions. // https://bugs.chromium.org/p/v8/issues/detail?id=4118 var test1 = new String('abc'); // eslint-disable-line no-new-wrappers test1[5] = 'de'; if (Object.getOwnPropertyNames(test1)[0] === '5') { return false; } // https://bugs.chromium.org/p/v8/issues/detail?id=3056 var test2 = {}; for (var i = 0; i < 10; i++) { test2['_' + String.fromCharCode(i)] = i; } var order2 = Object.getOwnPropertyNames(test2).map(function (n) { return test2[n]; }); if (order2.join('') !== '0123456789') { return false; } // https://bugs.chromium.org/p/v8/issues/detail?id=3056 var test3 = {}; 'abcdefghijklmnopqrst'.split('').forEach(function (letter) { test3[letter] = letter; }); if (Object.keys(Object.assign({}, test3)).join('') !== 'abcdefghijklmnopqrst') { return false; } return true; } catch (err) { // We don't expect any of the above to throw, but better to be safe. return false; } } module.exports = shouldUseNative() ? Object.assign : function (target, source) { var from; var to = toObject(target); var symbols; for (var s = 1; s < arguments.length; s++) { from = Object(arguments[s]); for (var key in from) { if (hasOwnProperty.call(from, key)) { to[key] = from[key]; } } if (getOwnPropertySymbols) { symbols = getOwnPropertySymbols(from); for (var i = 0; i < symbols.length; i++) { if (propIsEnumerable.call(from, symbols[i])) { to[symbols[i]] = from[symbols[i]]; } } } } return to; }; },{}],4:[function(require,module,exports){ "use strict"; const assign = require('object-assign'); const delegate = require('../delegate'); const delegateAll = require('../delegateAll'); const DELEGATE_PATTERN = /^(.+):delegate\((.+)\)$/; const SPACE = ' '; const getListeners = function (type, handler) { var match = type.match(DELEGATE_PATTERN); var selector; if (match) { type = match[1]; selector = match[2]; } var options; if (typeof handler === 'object') { options = { capture: popKey(handler, 'capture'), passive: popKey(handler, 'passive') }; } var listener = { selector: selector, delegate: typeof handler === 'object' ? delegateAll(handler) : selector ? delegate(selector, handler) : handler, options: options }; if (type.indexOf(SPACE) > -1) { return type.split(SPACE).map(function (_type) { return assign({ type: _type }, listener); }); } else { listener.type = type; return [listener]; } }; var popKey = function (obj, key) { var value = obj[key]; delete obj[key]; return value; }; module.exports = function behavior(events, props) { const listeners = Object.keys(events).reduce(function (memo, type) { var listeners = getListeners(type, events[type]); return memo.concat(listeners); }, []); return assign({ add: function addBehavior(element) { listeners.forEach(function (listener) { element.addEventListener(listener.type, listener.delegate, listener.options); }); }, remove: function removeBehavior(element) { listeners.forEach(function (listener) { element.removeEventListener(listener.type, listener.delegate, listener.options); }); } }, props); }; },{"../delegate":6,"../delegateAll":7,"object-assign":3}],5:[function(require,module,exports){ "use strict"; module.exports = function compose(functions) { return function (e) { return functions.some(function (fn) { return fn.call(this, e) === false; }, this); }; }; },{}],6:[function(require,module,exports){ "use strict"; // polyfill Element.prototype.closest require('element-closest'); module.exports = function delegate(selector, fn) { return function delegation(event) { var target = event.target.closest(selector); if (target) { return fn.call(target, event); } }; }; },{"element-closest":1}],7:[function(require,module,exports){ "use strict"; const delegate = require('../delegate'); const compose = require('../compose'); const SPLAT = '*'; module.exports = function delegateAll(selectors) { const keys = Object.keys(selectors); // XXX optimization: if there is only one handler and it applies to // all elements (the "*" CSS selector), then just return that // handler if (keys.length === 1 && keys[0] === SPLAT) { return selectors[SPLAT]; } const delegates = keys.reduce(function (memo, selector) { memo.push(delegate(selector, selectors[selector])); return memo; }, []); return compose(delegates); }; },{"../compose":5,"../delegate":6}],8:[function(require,module,exports){ "use strict"; module.exports = function ignore(element, fn) { return function ignorance(e) { if (element !== e.target && !element.contains(e.target)) { return fn.call(this, e); } }; }; },{}],9:[function(require,module,exports){ "use strict"; module.exports = { behavior: require('./behavior'), delegate: require('./delegate'), delegateAll: require('./delegateAll'), ignore: require('./ignore'), keymap: require('./keymap') }; },{"./behavior":4,"./delegate":6,"./delegateAll":7,"./ignore":8,"./keymap":10}],10:[function(require,module,exports){ "use strict"; require('keyboardevent-key-polyfill'); // these are the only relevant modifiers supported on all platforms, // according to MDN: // <https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/getModifierState> const MODIFIERS = { 'Alt': 'altKey', 'Control': 'ctrlKey', 'Ctrl': 'ctrlKey', 'Shift': 'shiftKey' }; const MODIFIER_SEPARATOR = '+'; const getEventKey = function (event, hasModifiers) { var key = event.key; if (hasModifiers) { for (var modifier in MODIFIERS) { if (event[MODIFIERS[modifier]] === true) { key = [modifier, key].join(MODIFIER_SEPARATOR); } } } return key; }; module.exports = function keymap(keys) { const hasModifiers = Object.keys(keys).some(function (key) { return key.indexOf(MODIFIER_SEPARATOR) > -1; }); return function (event) { var key = getEventKey(event, hasModifiers); return [key, key.toLowerCase()].reduce(function (result, _key) { if (_key in keys) { result = keys[key].call(this, event); } return result; }, undefined); }; }; module.exports.MODIFIERS = MODIFIERS; },{"keyboardevent-key-polyfill":2}],11:[function(require,module,exports){ "use strict"; module.exports = function once(listener, options) { var wrapped = function wrappedOnce(e) { e.currentTarget.removeEventListener(e.type, wrapped, options); return listener.call(this, e); }; return wrapped; }; },{}],12:[function(require,module,exports){ "use strict"; const behavior = require("../../uswds-core/src/js/utils/behavior"); const toggleFormInput = require("../../uswds-core/src/js/utils/toggle-form-input"); const { CLICK } = require("../../uswds-core/src/js/events"); const { prefix: PREFIX } = require("../../uswds-core/src/js/config"); const LINK = `.${PREFIX}-show-password`; function toggle(event) { event.preventDefault(); toggleFormInput(this); } module.exports = behavior({ [CLICK]: { [LINK]: toggle } }); },{"../../uswds-core/src/js/config":34,"../../uswds-core/src/js/events":35,"../../uswds-core/src/js/utils/behavior":39,"../../uswds-core/src/js/utils/toggle-form-input":49}],13:[function(require,module,exports){ "use strict"; const select = require("../../uswds-core/src/js/utils/select"); const behavior = require("../../uswds-core/src/js/utils/behavior"); const toggle = require("../../uswds-core/src/js/utils/toggle"); const isElementInViewport = require("../../uswds-core/src/js/utils/is-in-viewport"); const { CLICK } = require("../../uswds-core/src/js/events"); const { prefix: PREFIX } = require("../../uswds-core/src/js/config"); const ACCORDION = `.${PREFIX}-accordion, .${PREFIX}-accordion--bordered`; const BANNER_BUTTON = `.${PREFIX}-banner__button`; const BUTTON = `.${PREFIX}-accordion__button[aria-controls]:not(${BANNER_BUTTON})`; const EXPANDED = "aria-expanded"; const MULTISELECTABLE = "data-allow-multiple"; /** * Get an Array of button elements belonging directly to the given * accordion element. * @param {HTMLElement} accordion * @return {array<HTMLButtonElement>} */ const getAccordionButtons = accordion => { const buttons = select(BUTTON, accordion); return buttons.filter(button => button.closest(ACCORDION) === accordion); }; /** * Toggle a button's "pressed" state, optionally providing a target * state. * * @param {HTMLButtonElement} button * @param {boolean?} expanded If no state is provided, the current * state will be toggled (from false to true, and vice-versa). * @return {boolean} the resulting state */ const toggleButton = (button, expanded) => { const accordion = button.closest(ACCORDION); let safeExpanded = expanded; if (!accordion) { throw new Error(`${BUTTON} is missing outer ${ACCORDION}`); } safeExpanded = toggle(button, expanded); // XXX multiselectable is opt-in, to preserve legacy behavior const multiselectable = accordion.hasAttribute(MULTISELECTABLE); if (safeExpanded && !multiselectable) { getAccordionButtons(accordion).forEach(other => { if (other !== button) { toggle(other, false); } }); } }; /** * @param {HTMLButtonElement} button * @return {boolean} true */ const showButton = button => toggleButton(button, true); /** * @param {HTMLButtonElement} button * @return {boolean} false */ const hideButton = button => toggleButton(button, false); const accordion = behavior({ [CLICK]: { [BUTTON]() { toggleButton(this); if (this.getAttribute(EXPANDED) === "true") { // We were just expanded, but if another accordion was also just // collapsed, we may no longer be in the viewport. This ensures // that we are still visible, so the user isn't confused. if (!isElementInViewport(this)) this.scrollIntoView(); } } } }, { init(root) { select(BUTTON, root).forEach(button => { const expanded = button.getAttribute(EXPANDED) === "true"; toggleButton(button, expanded); }); }, ACCORDION, BUTTON, show: showButton, hide: hideButton, toggle: toggleButton, getButtons: getAccordionButtons }); module.exports = accordion; },{"../../uswds-core/src/js/config":34,"../../uswds-core/src/js/events":35,"../../uswds-core/src/js/utils/behavior":39,"../../uswds-core/src/js/utils/is-in-viewport":42,"../../uswds-core/src/js/utils/select":47,"../../uswds-core/src/js/utils/toggle":50}],14:[function(require,module,exports){ "use strict"; const behavior = require("../../uswds-core/src/js/utils/behavior"); const select = require("../../uswds-core/src/js/utils/select"); const { CLICK } = require("../../uswds-core/src/js/events"); const { prefix: PREFIX } = require("../../uswds-core/src/js/config"); const toggle = require("../../uswds-core/src/js/utils/toggle"); const HEADER = `.${PREFIX}-banner__header`; const EXPANDED_CLASS = `${PREFIX}-banner__header--expanded`; const BANNER_BUTTON = `${HEADER} [aria-controls]`; /** * Toggle Banner display and class. * @param {Event} event */ const toggleBanner = function toggleEl(event) { event.preventDefault(); const trigger = event.target.closest(BANNER_BUTTON); toggle(trigger); this.closest(HEADER).classList.toggle(EXPANDED_CLASS); }; module.exports = behavior({ [CLICK]: { [BANNER_BUTTON]: toggleBanner } }, { init(root) { select(BANNER_BUTTON, root).forEach(button => { const expanded = button.getAttribute(EXPANDED_CLASS) === "true"; toggle(button, expanded); }); } }); },{"../../uswds-core/src/js/config":34,"../../uswds-core/src/js/events":35,"../../uswds-core/src/js/utils/behavior":39,"../../uswds-core/src/js/utils/select":47,"../../uswds-core/src/js/utils/toggle":50}],15:[function(require,module,exports){ "use strict"; const keymap = require("receptor/keymap"); const behavior = require("../../uswds-core/src/js/utils/behavior"); const ANCHOR_BUTTON = `a[class*="usa-button"]`; const toggleButton = event => { event.preventDefault(); event.target.click(); }; const anchorButton = behavior({ keydown: { [ANCHOR_BUTTON]: keymap({ " ": toggleButton }) } }); module.exports = anchorButton; },{"../../uswds-core/src/js/utils/behavior":39,"receptor/keymap":10}],16:[function(require,module,exports){ "use strict"; const select = require("../../uswds-core/src/js/utils/select"); const behavior = require("../../uswds-core/src/js/utils/behavior"); const debounce = require("../../uswds-core/src/js/utils/debounce"); const { prefix: PREFIX } = require("../../uswds-core/src/js/config"); const CHARACTER_COUNT_CLASS = `${PREFIX}-character-count`; const CHARACTER_COUNT = `.${CHARACTER_COUNT_CLASS}`; const FORM_GROUP_CLASS = `${PREFIX}-form-group`; const FORM_GROUP_ERROR_CLASS = `${FORM_GROUP_CLASS}--error`; const FORM_GROUP = `.${FORM_GROUP_CLASS}`; const LABEL_CLASS = `${PREFIX}-label`; const LABEL_ERROR_CLASS = `${LABEL_CLASS}--error`; const INPUT = `.${PREFIX}-character-count__field`; const INPUT_ERROR_CLASS = `${PREFIX}-input--error`; const MESSAGE = `.${PREFIX}-character-count__message`; const VALIDATION_MESSAGE = "The content is too long."; const MESSAGE_INVALID_CLASS = `${PREFIX}-character-count__status--invalid`; const STATUS_MESSAGE_CLASS = `${CHARACTER_COUNT_CLASS}__status`; const STATUS_MESSAGE_SR_ONLY_CLASS = `${CHARACTER_COUNT_CLASS}__sr-status`; const STATUS_MESSAGE = `.${STATUS_MESSAGE_CLASS}`; const STATUS_MESSAGE_SR_ONLY = `.${STATUS_MESSAGE_SR_ONLY_CLASS}`; const DEFAULT_STATUS_LABEL = `characters allowed`; /** * Returns the root, form group, label, and message elements for an character count input * * @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element * @returns {CharacterCountElements} elements The root form group, input ID, label, and message element. */ const getCharacterCountElements = inputEl => { const characterCountEl = inputEl.closest(CHARACTER_COUNT); if (!characterCountEl) { throw new Error(`${INPUT} is missing outer ${CHARACTER_COUNT}`); } const formGroupEl = characterCountEl.querySelector(FORM_GROUP); const inputID = inputEl.getAttribute("id"); const labelEl = document.querySelector(`label[for=${inputID}]`); const messageEl = characterCountEl.querySelector(MESSAGE); if (!messageEl) { throw new Error(`${CHARACTER_COUNT} is missing inner ${MESSAGE}`); } return { characterCountEl, formGroupEl, inputID, labelEl, messageEl }; }; /** * Move maxlength attribute to a data attribute on usa-character-count * * @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element */ const setDataLength = inputEl => { const { characterCountEl } = getCharacterCountElements(inputEl); const maxlength = inputEl.getAttribute("maxlength"); if (!maxlength) return; inputEl.removeAttribute("maxlength"); characterCountEl.setAttribute("data-maxlength", maxlength); }; /** * Create and append status messages for visual and screen readers * * @param {HTMLDivElement} characterCountEl - Div with `.usa-character-count` class * @description Create two status messages for number of characters left; * one visual status and another for screen readers */ const createStatusMessages = characterCountEl => { const statusMessage = document.createElement("div"); const srStatusMessage = document.createElement("div"); const maxLength = characterCountEl.dataset.maxlength; const defaultMessage = `${maxLength} ${DEFAULT_STATUS_LABEL}`; statusMessage.classList.add(`${STATUS_MESSAGE_CLASS}`, "usa-hint"); srStatusMessage.classList.add(`${STATUS_MESSAGE_SR_ONLY_CLASS}`, "usa-sr-only"); statusMessage.setAttribute("aria-hidden", true); srStatusMessage.setAttribute("aria-live", "polite"); statusMessage.textContent = defaultMessage; srStatusMessage.textContent = defaultMessage; characterCountEl.append(statusMessage, srStatusMessage); }; /** * Returns message with how many characters are left * * @param {number} currentLength - The number of characters used * @param {number} maxLength - The total number of characters allowed * @returns {string} A string description of how many characters are left */ const getCountMessage = (currentLength, maxLength) => { let newMessage = ""; if (currentLength === 0) { newMessage = `${maxLength} ${DEFAULT_STATUS_LABEL}`; } else { const difference = Math.abs(maxLength - currentLength); const characters = `character${difference === 1 ? "" : "s"}`; const guidance = currentLength > maxLength ? "over limit" : "left"; newMessage = `${difference} ${characters} ${guidance}`; } return newMessage; }; /** * Updates the character count status for screen readers after a 1000ms delay. * * @param {HTMLElement} msgEl - The screen reader status message element * @param {string} statusMessage - A string of the current character status */ const srUpdateStatus = debounce((msgEl, statusMessage) => { const srStatusMessage = msgEl; srStatusMessage.textContent = statusMessage; }, 1000); /** * Update the character count component * * @description On input, it will update visual status, screenreader * status and update input validation (if over character length) * @param {HTMLInputElement|HTMLTextAreaElement} inputEl The character count input element */ const updateCountMessage = inputEl => { const { characterCountEl, labelEl, formGroupEl } = getCharacterCountElements(inputEl); const currentLength = inputEl.value.length; const maxLength = parseInt(characterCountEl.getAttribute("data-maxlength"), 10); const statusMessage = characterCountEl.querySelector(STATUS_MESSAGE); const srStatusMessage = characterCountEl.querySelector(STATUS_MESSAGE_SR_ONLY); const currentStatusMessage = getCountMessage(currentLength, maxLength); if (!maxLength) return; const isOverLimit = currentLength && currentLength > maxLength; statusMessage.textContent = currentStatusMessage; srUpdateStatus(srStatusMessage, currentStatusMessage); if (isOverLimit && !inputEl.validationMessage) { inputEl.setCustomValidity(VALIDATION_MESSAGE); } if (!isOverLimit && inputEl.validationMessage === VALIDATION_MESSAGE) { inputEl.setCustomValidity(""); } if (formGroupEl) { formGroupEl.classList.toggle(FORM_GROUP_ERROR_CLASS, isOverLimit); } if (labelEl) { labelEl.classList.toggle(LABEL_ERROR_CLASS, isOverLimit); } inputEl.classList.toggle(INPUT_ERROR_CLASS, isOverLimit); statusMessage.classList.toggle(MESSAGE_INVALID_CLASS, isOverLimit); }; /** * Initialize component * * @description On init this function will create elements and update any * attributes so it can tell the user how many characters are left. * @param {HTMLInputElement|HTMLTextAreaElement} inputEl the components input */ const enhanceCharacterCount = inputEl => { const { characterCountEl, messageEl } = getCharacterCountElements(inputEl); // Hide hint and remove aria-live for backwards compatibility messageEl.classList.add("usa-sr-only"); messageEl.removeAttribute("aria-live"); setDataLength(inputEl); createStatusMessages(characterCountEl); }; const characterCount = behavior({ input: { [INPUT]() { updateCountMessage(this); } } }, { init(root) { select(INPUT, root).forEach(input => enhanceCharacterCount(input)); }, FORM_GROUP_ERROR_CLASS, LABEL_ERROR_CLASS, INPUT_ERROR_CLASS, MESSAGE_INVALID_CLASS, VALIDATION_MESSAGE, STATUS_MESSAGE_CLASS, STATUS_MESSAGE_SR_ONLY_CLASS, DEFAULT_STATUS_LABEL, createStatusMessages, getCountMessage, updateCountMessage }); module.exports = characterCount; },{"../../uswds-core/src/js/config":34,"../../uswds-core/src/js/utils/behavior":39,"../../uswds-core/src/js/utils/debounce":40,"../../uswds-core/src/js/utils/select":47}],17:[function(require,module,exports){ "use strict"; const keymap = require("receptor/keymap"); const selectOrMatches = require("../../uswds-core/src/js/utils/select-or-matches"); const behavior = require("../../uswds-core/src/js/utils/behavior"); const Sanitizer = require("../../uswds-core/src/js/utils/sanitizer"); const { prefix: PREFIX } = require("../../uswds-core/src/js/config"); const { CLICK } = require("../../uswds-core/src/js/events"); const COMBO_BOX_CLASS = `${PREFIX}-combo-box`; const COMBO_BOX_PRISTINE_CLASS = `${COMBO_BOX_CLASS}--pristine`; const SELECT_CLASS = `${COMBO_BOX_CLASS}__select`; const INPUT_CLASS = `${COMBO_BOX_CLASS}__input`; const CLEAR_INPUT_BUTTON_CLASS = `${COMBO_BOX_CLASS}__clear-input`; const CLEAR_INPUT_BUTTON_WRAPPER_CLASS = `${CLEAR_INPUT_BUTTON_CLASS}__wrapper`; const INPUT_BUTTON_SEPARATOR_CLASS = `${COMBO_BOX_CLASS}__input-button-separator`; const TOGGLE_LIST_BUTTON_CLASS = `${COMBO_BOX_CLASS}__toggle-list`; const TOGGLE_LIST_BUTTON_WRAPPER_CLASS = `${TOGGLE_LIST_BUTTON_CLASS}__wrapper`; const LIST_CLASS = `${COMBO_BOX_CLASS}__list`; const LIST_OPTION_CLASS = `${COMBO_BOX_CLASS}__list-option`; const LIST_OPTION_FOCUSED_CLASS = `${LIST_OPTION_CLASS}--focused`; const LIST_OPTION_SELECTED_CLASS = `${LIST_OPTION_CLASS}--selected`; const STATUS_CLASS = `${COMBO_BOX_CLASS}__status`; const COMBO_BOX = `.${COMBO_BOX_CLASS}`; const SELECT = `.${SELECT_CLASS}`; const INPUT = `.${INPUT_CLASS}`; const CLEAR_INPUT_BUTTON = `.${CLEAR_INPUT_BUTTON_CLASS}`; const TOGGLE_LIST_BUTTON = `.${TOGGLE_LIST_BUTTON_CLASS}`; const LIST = `.${LIST_CLASS}`; const LIST_OPTION = `.${LIST_OPTION_CLASS}`; const LIST_OPTION_FOCUSED = `.${LIST_OPTION_FOCUSED_CLASS}`; const LIST_OPTION_SELECTED = `.${LIST_OPTION_SELECTED_CLASS}`; const STATUS = `.${STATUS_CLASS}`; const DEFAULT_FILTER = ".*{{query}}.*"; const noop = () => {}; /** * set the value of the element and dispatch a change event * * @param {HTMLInputElement|HTMLSelectElement} el The element to update * @param {string} value The new value of the element */ const changeElementValue = (el, value = "") => { const elementToChange = el; elementToChange.value = value; const event = new CustomEvent("change", { bubbles: true, cancelable: true, detail: { value } }); elementToChange.dispatchEvent(event); }; /** * The elements within the combo box. * @typedef {Object} ComboBoxContext * @property {HTMLElement} comboBoxEl * @property {HTMLSelectElement} selectEl * @property {HTMLInputElement} inputEl * @property {HTMLUListElement} listEl * @property {HTMLDivElement} statusEl * @property {HTMLLIElement} focusedOptionEl * @property {HTMLLIElement} selectedOptionEl * @property {HTMLButtonElement} toggleListBtnEl * @property {HTMLButtonElement} clearInputBtnEl * @property {boolean} isPristine * @property {boolean} disableFiltering */ /** * Get an object of elements belonging directly to the given * combo box component. * * @param {HTMLElement} el the element within the combo box * @returns {ComboBoxContext} elements */ const getComboBoxContext = el => { const comboBoxEl = el.closest(COMBO_BOX); if (!comboBoxEl) { throw new Error(`Element is missing outer ${COMBO_BOX}`); } const selectEl = comboBoxEl.querySelector(SELECT); const inputEl = comboBoxEl.querySelector(INPUT); const listEl = comboBoxEl.querySelector(LIST); const statusEl = comboBoxEl.querySelector(STATUS); const focusedOptionEl = comboBoxEl.querySelector(LIST_OPTION_FOCUSED); const selectedOptionEl = comboBoxEl.querySelector(LIST_OPTION_SELECTED); const toggleListBtnEl = comboBoxEl.querySelector(TOGGLE_LIST_BUTTON); const clearInputBtnEl = comboBoxEl.querySelector(CLEAR_INPUT_BUTTON); const isPristine = comboBoxEl.classList.contains(COMBO_BOX_PRISTINE_CLASS); const disableFiltering = comboBoxEl.dataset.disableFiltering === "true"; return { comboBoxEl, selectEl, inputEl, listEl, statusEl, focusedOptionEl, selectedOptionEl, toggleListBtnEl, clearInputBtnEl, isPristine, disableFiltering }; }; /** * Disable the combo-box component * * @param {HTMLInputElement} el An element within the combo box component */ const disable = el => { const { inputEl, toggleListBtnEl, clearInputBtnEl } = getComboBoxContext(el); clearInputBtnEl.hidden = true; clearInputBtnEl.disabled = true; toggleListBtnEl.disabled = true; inputEl.disabled = true; }; /** * Check for aria-disabled on initialization * * @param {HTMLInputElement} el An element within the combo box component */ const ariaDisable = el => { const { inputEl, toggleListBtnEl, clearInputBtnEl } = getComboBoxContext(el); clearInputBtnEl.hidden = true; clearInputBtnEl.setAttribute("aria-disabled", true); toggleListBtnEl.setAttribute("aria-disabled", true); inputEl.setAttribute("aria-disabled", true); }; /** * Enable the combo-box component * * @param {HTMLInputElement} el An element within the combo box component */ const enable = el => { const { inputEl, toggleListBtnEl, clearInputBtnEl } = getComboBoxContext(el); clearInputBtnEl.hidden = false; clearInputBtnEl.disabled = false; toggleListBtnEl.disabled = false; inputEl.disabled = false; }; /** * Enhance a select element into a combo box component. * * @param {HTMLElement} _comboBoxEl The initial element of the combo box component */ const enhanceComboBox = _comboBoxEl => { const comboBoxEl = _comboBoxEl.closest(COMBO_BOX); if (comboBoxEl.dataset.enhanced) return; const selectEl = comboBoxEl.querySelector("select"); if (!selectEl) { throw new Error(`${COMBO_BOX} is missing inner select`); } const selectId = selectEl.id; const selectLabel = document.querySelector(`label[for="${selectId}"]`); const listId = `${selectId}--list`; const listIdLabel = `${selectId}-label`; const additionalAttributes = []; const { defaultValue } = comboBoxEl.dataset; const { placeholder } = comboBoxEl.dataset; let selectedOption; if (placeholder) { additionalAttributes.push({ placeholder }); } if (defaultValue) { for (let i = 0, len = selectEl.options.length; i < len; i += 1) { const optionEl = selectEl.options[i]; if (optionEl.value === defaultValue) { selectedOption = optionEl; break; } } } /** * Throw error if combobox is missing a label or label is missing * `for` attribute. Otherwise, set the ID to match the <ul> aria-labelledby */ if (!selectLabel || !selectLabel.matches(`label[for="${selectId}"]`)) { throw new Error(`${COMBO_BOX} for ${selectId} is either missing a label or a "for" attribute`); } else { selectLabel.setAttribute("id", listIdLabel); } selectLabel.setAttribute("id", listIdLabel); selectEl.setAttribute("aria-hidden", "true"); selectEl.setAttribute("tabindex", "-1"); selectEl.classList.add("usa-sr-only", SELECT_CLASS); selectEl.id = ""; selectEl.value = ""; ["required", "aria-label", "aria-labelledby"].forEach(name => { if (selectEl.hasAttribute(name)) { const value = selectEl.getAttribute(name); additionalAttributes.push({ [name]: value }); selectEl.removeAttribute(name); } }); // sanitize doesn't like functions in template literals const input = document.createElement("input"); input.setAttribute("id", selectId); input.setAttribute("aria-owns", listId); input.setAttribute("aria-controls", listId); input.setAttribute("aria-autocomplete", "list"); input.setAttribute("aria-expanded", "false"); input.setAttribute("autocapitalize", "off"); input.setAttribute("autocomplete", "off"); input.setAttribute("class", INPUT_CLASS); input.setAttribute("type", "text"); input.setAttribute("role", "combobox"); additionalAttributes.forEach(attr => Object.keys(attr).forEach(key => { const value = Sanitizer.escapeHTML`${attr[key]}`; input.setAttribute(key, value); })); comboBoxEl.insertAdjacentElement("beforeend", input); comboBoxEl.insertAdjacentHTML("beforeend", Sanitizer.escapeHTML` <span class="${CLEAR_INPUT_BUTTON_WRAPPER_CLASS}" tabindex="-1"> <button type="button" class="${CLEAR_INPUT_BUTTON_CLASS}" aria-label="Clear the select contents">&nbsp;</button> </span> <span class="${INPUT_BUTTON_SEPARATOR_CLASS}">&nbsp;</span> <span class="${TOGGLE_LIST_BUTTON_WRAPPER_CLASS}" tabindex="-1"> <button type="button" tabindex="-1" class="${TOGGLE_LIST_BUTTON_CLASS}" aria-label="Toggle the dropdown list">&nbsp;</button> </span> <ul tabindex="-1" id="${listId}" class="${LIST_CLASS}" role="listbox" aria-labelledby="${listIdLabel}" hidden> </ul> <div class="${STATUS_CLASS} usa-sr-only" role="status"></div>`); if (selectedOption) { const { inputEl } = getComboBoxContext(comboBoxEl); changeElementValue(selectEl, selectedOption.value); changeElementValue(inputEl, selectedOption.text); comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); } if (selectEl.disabled) { disable(comboBoxEl); selectEl.disabled = false; } if (selectEl.hasAttribute("aria-disabled")) { ariaDisable(comboBoxEl); selectEl.removeAttribute("aria-disabled"); } comboBoxEl.dataset.enhanced = "true"; }; /** * Manage the focused element within the list options when * navigating via keyboard. * * @param {HTMLElement} el An anchor element within the combo box component * @param {HTMLElement} nextEl An element within the combo box component * @param {Object} options options * @param {boolean} options.skipFocus skip focus of highlighted item * @param {boolean} options.preventScroll should skip procedure to scroll to element */ const highlightOption = (el, nextEl, { skipFocus, preventScroll } = {}) => { const { inputEl, listEl, focusedOptionEl } = getComboBoxContext(el); if (focusedOptionEl) { focusedOptionEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); focusedOptionEl.setAttribute("tabIndex", "-1"); } if (nextEl) { inputEl.setAttribute("aria-activedescendant", nextEl.id); nextEl.setAttribute("tabIndex", "0"); nextEl.classList.add(LIST_OPTION_FOCUSED_CLASS); if (!preventScroll) { const optionBottom = nextEl.offsetTop + nextEl.offsetHeight; const currentBottom = listEl.scrollTop + listEl.offsetHeight; if (optionBottom > currentBottom) { listEl.scrollTop = optionBottom - listEl.offsetHeight; } if (nextEl.offsetTop < listEl.scrollTop) { listEl.scrollTop = nextEl.offsetTop; } } if (!skipFocus) { nextEl.focus({ preventScroll }); } } else { inputEl.setAttribute("aria-activedescendant", ""); inputEl.focus(); } }; /** * Generate a dynamic regular expression based off of a replaceable and possibly filtered value. * * @param {string} el An element within the combo box component * @param {string} query The value to use in the regular expression * @param {object} extras An object of regular expressions to replace and filter the query */ const generateDynamicRegExp = (filter, query = "", extras = {}) => { const escapeRegExp = text => text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&"); let find = filter.replace(/{{(.*?)}}/g, (m, $1) => { const key = $1.trim(); const queryFilter = extras[key]; if (key !== "query" && queryFilter) { const matcher = new RegExp(queryFilter, "i"); const matches = query.match(matcher); if (matches) { return escapeRegExp(matches[1]); } return ""; } return escapeRegExp(query); }); find = `^(?:${find})$`; return new RegExp(find, "i"); }; /** * Display the option list of a combo box component. * * @param {HTMLElement} el An element within the combo box component */ const displayList = el => { const { comboBoxEl, selectEl, inputEl, listEl, statusEl, isPristine, disableFiltering } = getComboBoxContext(el); let selectedItemId; let firstFoundId; const listOptionBaseId = `${listEl.id}--option-`; const inputValue = (inputEl.value || "").toLowerCase(); const filter = comboBoxEl.dataset.filter || DEFAULT_FILTER; const regex = generateDynamicRegExp(filter, inputValue, comboBoxEl.dataset); let options = []; const optionsStartsWith = []; const optionsContains = []; const optionList = [...selectEl.options]; /** * Builds and sorts options array. * * Option param is passed through regex test before passing into this function. * When filtering is enabled, the array will be sorted by options that start with the query, followed by * options that contain the query. * When filtering is disabled, all options will be included in the array unsorted. * * These array items will populate the list that is displayed to the user after a search query is entered. * Array attributes are also used to set option IDs and aria-setsize attributes. * * @param {HTMLOptionElement} option - Option element from select array */ const buildOptionsArray = option => { if (disableFiltering || isPristine) { options.push(option); return; } const matchStartsWith = option.text.toLowerCase().startsWith(inputValue); if (matchStartsWith) { optionsStartsWith.push(option); } else { optionsContains.push(option); } options = [...optionsStartsWith, ...optionsContains]; }; /** * Compares option text to query using generated regex filter. * * @param {HTMLOptionElement} option * @returns {boolean} - True when option text matches user input query. */ const optionMatchesQuery = option => regex.test(option.text); /** * Logic check to determine if options array needs to be updated. * * @param {HTMLOptionElement} option * @returns {boolean} - True when option has value && if filtering is disabled, combo box has an active selection, * there is no inputValue, or if option matches user query */ const arrayNeedsUpdate = option => option.value && (disableFiltering || isPristine || !inputValue || optionMatchesQuery(option)); /** * Checks if firstFoundId should be assigned, which is then used to set itemToFocus. * * @param {HTMLOptionElement} option * @return {boolean} - Returns true if filtering is disabled, no firstFoundId is assigned, and the option matches the query. */ const isFirstMatch = option => disableFiltering && !firstFoundId && optionMatchesQuery(option); /** * Checks if isCurrentSelection should be assigned, which is then used to set itemToFocus. * * @param {HTMLOptionElement} option * @returns {boolean} - Returns true if option.value matches selectEl.value. */ const isCurrentSelection = option => selectEl.value && option.value === selectEl.value; /** * Update the array of options that should be displayed on the page. * Assign an ID to each displayed option. * Identify and assign the option that should receive focus. */ optionList.forEach(option => { if (arrayNeedsUpdate(option)) { buildOptionsArray(option); const optionId = `${listOptionBaseId}${options.indexOf(option)}`; if (isFirstMatch(option)) { firstFoundId = optionId; } if (isCurrentSelection(option)) { selectedItemId = optionId; } } }); const numOptions = options.length; const optionHtml = options.map((option, index) => { const optionId = `${listOptionBaseId}${index}`; const classes = [LIST_OPTION_CLASS]; let tabindex = "-1"; let ariaSelected = "false"; if (optionId === selectedItemId) { classes.push(LIST_OPTION_SELECTED_CLASS, LIST_OPTION_FOCUSED_CLASS); tabindex = "0"; ariaSelected = "true"; } if (!selectedItemId && index === 0) { classes.push(LIST_OPTION_FOCUSED_CLASS); tabindex = "0"; } const li = document.createElement("li"); li.setAttribute("aria-setsize", options.length); li.setAttribute("aria-posinset", index + 1); li.setAttribute("aria-selected", ariaSelected); li.setAttribute("id", optionId); li.setAttribute("class", classes.join(" ")); li.setAttribute("tabindex", tabindex); li.setAttribute("role", "option"); li.setAttribute("data-value", option.value); li.textContent = option.text; return li; }); const noResults = document.createElement("li"); noResults.setAttribute("class", `${LIST_OPTION_CLASS}--no-results`); noResults.textContent = "No results found"; listEl.hidden = false; if (numOptions) { listEl.innerHTML = ""; optionHtml.forEach(item => listEl.insertAdjacentElement("beforeend", item)); } else { listEl.innerHTML = ""; listEl.insertAdjacentElement("beforeend", noResults); } inputEl.setAttribute("aria-expanded", "true"); statusEl.textContent = numOptions ? `${numOptions} result${numOptions > 1 ? "s" : ""} available.` : "No results."; let itemToFocus; if (isPristine && selectedItemId) { itemToFocus = listEl.querySelector(`#${selectedItemId}`); } else if (disableFiltering && firstFoundId) { itemToFocus = listEl.querySelector(`#${firstFoundId}`); } if (itemToFocus) { highlightOption(listEl, itemToFocus, { skipFocus: true }); } }; /** * Hide the option list of a combo box component. * * @param {HTMLElement} el An element within the combo box component */ const hideList = el => { const { inputEl, listEl, statusEl, focusedOptionEl } = getComboBoxContext(el); statusEl.innerHTML = ""; inputEl.setAttribute("aria-expanded", "false"); inputEl.setAttribute("aria-activedescendant", ""); if (focusedOptionEl) { focusedOptionEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); } listEl.scrollTop = 0; listEl.hidden = true; }; /** * Select an option list of the combo box component. * * @param {HTMLElement} listOptionEl The list option being selected */ const selectItem = listOptionEl => { const { comboBoxEl, selectEl, inputEl } = getComboBoxContext(listOptionEl); changeElementValue(selectEl, listOptionEl.dataset.value); changeElementValue(inputEl, listOptionEl.textContent); comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); hideList(comboBoxEl); inputEl.focus(); }; /** * Clear the input of the combo box * * @param {HTMLButtonElement} clearButtonEl The clear input button */ const clearInput = clearButtonEl => { const { comboBoxEl, listEl, selectEl, inputEl } = getComboBoxContext(clearButtonEl); const listShown = !listEl.hidden; if (selectEl.value) changeElementValue(selectEl); if (inputEl.value) changeElementValue(inputEl); comboBoxEl.classList.remove(COMBO_BOX_PRISTINE_CLASS); if (listShown) displayList(comboBoxEl); inputEl.focus(); }; /** * Reset the select based off of currently set select value * * @param {HTMLElement} el An element within the combo box component */ const resetSelection = el => { const { comboBoxEl, selectEl, inputEl } = getComboBoxContext(el); const selectValue = selectEl.value; const inputValue = (inputEl.value || "").toLowerCase(); if (selectValue) { for (let i = 0, len = selectEl.options.length; i < len; i += 1) { const optionEl = selectEl.options[i]; if (optionEl.value === selectValue) { if (inputValue !== optionEl.text) { changeElementValue(inputEl, optionEl.text); } comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); return; } } } if (inputValue) { changeElementValue(inputEl); } }; /** * Select an option list of the combo box component based off of * having a current focused list option or * having test that completely matches a list option. * Otherwise it clears the input and select. * * @param {HTMLElement} el An element within the combo box component */ const completeSelection = el => { const { comboBoxEl, selectEl, inputEl, statusEl } = getComboBoxContext(el); statusEl.textContent = ""; const inputValue = (inputEl.value || "").toLowerCase(); if (inputValue) { for (let i = 0, len = selectEl.options.length; i < len; i += 1) { const optionEl = selectEl.options[i]; if (optionEl.text.toLowerCase() === inputValue) { changeElementValue(selectEl, optionEl.value); changeElementValue(inputEl, optionEl.text); comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); return; } } } resetSelection(comboBoxEl); }; /** * Handle the escape event within the combo box component. * * @param {KeyboardEvent} event An event within the combo box component */ const handleEscape = event => { const { comboBoxEl, inputEl } = getComboBoxContext(event.target); hideList(comboBoxEl); resetSelection(comboBoxEl); inputEl.focus(); }; /** * Handle the down event within the combo box component. * * @param {KeyboardEvent} event An event within the combo box component */ const handleDownFromInput = event => { const { comboBoxEl, listEl } = getComboBoxContext(event.target); if (listEl.hidden) { displayList(comboBoxEl); } const nextOptionEl = listEl.querySelector(LIST_OPTION_FOCUSED) || listEl.querySelector(LIST_OPTION); if (nextOptionEl) { highlightOption(comboBoxEl, nextOptionEl); } event.preventDefault(); }; /** * Handle the enter event from an input element within the combo box component. * * @param {KeyboardEvent} event An event within the combo box component */ const handleEnterFromInput = event => { const { comboBoxEl, listEl } = getComboBoxContext(event.target); const listShown = !listEl.hidden; completeSelection(comboBoxEl); if (listShown) { hideList(comboBoxEl); } event.preventDefault(); }; /** * Handle the down event within the combo box component. * * @param {KeyboardEvent} event An event within the combo box component */ const handleDownFromListOption = event => { const focusedOptionEl = event.target; const nextOptionEl = focusedOptionEl.nextSibling; if (nextOptionEl) { highlightOption(focusedOptionEl, nextOptionEl); } event.preventDefault(); }; /** * Handle the space event from an list option element within the combo box component. * * @param {KeyboardEvent} event An event within the combo box component */ const handleSpaceFromListOption = event => { selectItem(event.target); event.preventDefault(); }; /** * Handle the enter event from list option within the combo box component. * * @param {KeyboardEvent} event An event within the combo box component */ const handleEnterFromListOption = event => { selectItem(event.target); event.preventDefault(); }; /** * Handle the up event from list option within the combo box component. * * @param {KeyboardEvent} event An event within the combo box component */ const handleUpFromListOption = event => { const { comboBoxEl, listEl, focusedOptionEl } = getComboBoxContext(event.target); const nextOptionEl = focusedOptionEl && focusedOptionEl.previousSibling; const listShown = !listEl.hidden; highlightOption(comboBoxEl, nextOptionEl); if (listShown) { event.preventDefault(); } if (!nextOptionEl) { hideList(comboBoxEl); } }; /** * Select list option on the mouseover event. * * @param {MouseEvent} event The mouseover event * @param {HTMLLIElement} listOptionEl An element within the combo box component */ const handleMouseover = listOptionEl => { const isCurrentlyFocused = listOptionEl.classList.contains(LIST_OPTION_FOCUSED_CLASS); if (isCurrentlyFocused) return; highlightOption(listOptionEl, listOptionEl, { preventScroll: true }); }; /** * Toggle the list when the button is clicked * * @param {HTMLElement} el An element within the combo box component */ const toggleList = el => { const { comboBoxEl, listEl, inputEl } = getComboBoxContext(el); if (listEl.hidden) { displayList(comboBoxEl); } else { hideList(comboBoxEl); } inputEl.focus(); }; /** * Handle click from input * * @param {HTMLInputElement} el An element within the combo box component */ const handleClickFromInput = el => { const { comboBoxEl, listEl } = getComboBoxContext(el); if (listEl.hidden) { displayList(comboBoxEl); } }; const comboBox = behavior({ [CLICK]: { [INPUT]() { if (this.disabled) return; handleClickFromInput(this); }, [TOGGLE_LIST_BUTTON]() { if (this.disabled) return; toggleList(this); }, [LIST_OPTION]() { if (this.disabled) return; selectItem(this); }, [CLEAR_INPUT_BUTTON]() { if (this.disabled) return; clearInput(this); } }, focusout: { [COMBO_BOX](event) { if (!this.contains(event.relatedTarget)) { resetSelection(this); hideList(