UNPKG

@hipsterbrown/downshift

Version:

🏎 A set of primitives to build simple, flexible, WAI-ARIA compliant React autocomplete, combobox or select dropdown components.

1,378 lines (1,330 loc) 134 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var preact = require('preact'); var reactIs = require('react-is'); var computeScrollIntoView = require('compute-scroll-into-view'); var tslib = require('tslib'); var PropTypes = require('prop-types'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var computeScrollIntoView__default = /*#__PURE__*/_interopDefaultLegacy(computeScrollIntoView); var PropTypes__default = /*#__PURE__*/_interopDefaultLegacy(PropTypes); let idCounter = 0; /** * Accepts a parameter and returns it if it's a function * or a noop function if it's not. This allows us to * accept a callback, but not worry about it if it's not * passed. * @param {Function} cb the callback * @return {Function} a function */ function cbToCb(cb) { return typeof cb === 'function' ? cb : noop; } function noop() {} /** * Scroll node into view if necessary * @param {HTMLElement} node the element that should scroll into view * @param {HTMLElement} menuNode the menu element of the component */ function scrollIntoView(node, menuNode) { if (!node) { return; } const actions = computeScrollIntoView__default["default"](node, { boundary: menuNode, block: 'nearest', scrollMode: 'if-needed' }); actions.forEach(_ref => { let { el, top, left } = _ref; el.scrollTop = top; el.scrollLeft = left; }); } /** * @param {HTMLElement} parent the parent node * @param {HTMLElement} child the child node * @param {Window} environment The window context where downshift renders. * @return {Boolean} whether the parent is the child or the child is in the parent */ function isOrContainsNode(parent, child, environment) { const result = parent === child || child instanceof environment.Node && parent.contains && parent.contains(child); return result; } /** * Simple debounce implementation. Will call the given * function once after the time given has passed since * it was last called. * @param {Function} fn the function to call after the time * @param {Number} time the time to wait * @return {Function} the debounced function */ function debounce(fn, time) { let timeoutId; function cancel() { if (timeoutId) { clearTimeout(timeoutId); } } function wrapper() { for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { args[_key] = arguments[_key]; } cancel(); timeoutId = setTimeout(() => { timeoutId = null; fn(...args); }, time); } wrapper.cancel = cancel; return wrapper; } /** * This is intended to be used to compose event handlers. * They are executed in order until one of them sets * `event.preventDownshiftDefault = true`. * @param {...Function} fns the event handler functions * @return {Function} the event handler to add to an element */ function callAllEventHandlers() { for (var _len2 = arguments.length, fns = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { fns[_key2] = arguments[_key2]; } return function (event) { for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) { args[_key3 - 1] = arguments[_key3]; } return fns.some(fn => { if (fn) { fn(event, ...args); } return event.preventDownshiftDefault || event.hasOwnProperty('nativeEvent') && event.nativeEvent.preventDownshiftDefault; }); }; } function handleRefs() { for (var _len4 = arguments.length, refs = new Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { refs[_key4] = arguments[_key4]; } return node => { refs.forEach(ref => { if (typeof ref === 'function') { ref(node); } else if (ref) { ref.current = node; } }); }; } /** * This generates a unique ID for an instance of Downshift * @return {String} the unique ID */ function generateId() { return String(idCounter++); } /** * Resets idCounter to 0. Used for SSR. */ function resetIdCounter() { idCounter = 0; } /** * Default implementation for status message. Only added when menu is open. * Will specify if there are results in the list, and if so, how many, * and what keys are relevant. * * @param {Object} param the downshift state and other relevant properties * @return {String} the a11y status message */ function getA11yStatusMessage$1(_ref2) { let { isOpen, resultCount, previousResultCount } = _ref2; if (!isOpen) { return ''; } if (!resultCount) { return 'No results are available.'; } if (resultCount !== previousResultCount) { return `${resultCount} result${resultCount === 1 ? ' is' : 's are'} available, use up and down arrow keys to navigate. Press Enter key to select.`; } return ''; } /** * Takes an argument and if it's an array, returns the first item in the array * otherwise returns the argument * @param {*} arg the maybe-array * @param {*} defaultValue the value if arg is falsey not defined * @return {*} the arg or it's first item */ function unwrapArray(arg, defaultValue) { arg = Array.isArray(arg) ? /* istanbul ignore next (preact) */arg[0] : arg; if (!arg && defaultValue) { return defaultValue; } else { return arg; } } /** * @param {Object} element (P)react element * @return {Boolean} whether it's a DOM element */ function isDOMElement(element) { /* istanbul ignore if */ { // then this is preact or preact X return typeof element.type === 'string' || typeof element.nodeName === 'string'; } } /** * @param {Object} element (P)react element * @return {Object} the props */ function getElementProps(element) { // props for react, attributes for preact /* istanbul ignore if */ { return element.props || element.attributes; } } /** * Throws a helpful error message for required properties. Useful * to be used as a default in destructuring or object params. * @param {String} fnName the function name * @param {String} propName the prop name */ function requiredProp(fnName, propName) { // eslint-disable-next-line no-console console.error(`The property "${propName}" is required in "${fnName}"`); } const stateKeys = ['highlightedIndex', 'inputValue', 'isOpen', 'selectedItem', 'type']; /** * @param {Object} state the state object * @return {Object} state that is relevant to downshift */ function pickState(state) { if (state === void 0) { state = {}; } const result = {}; stateKeys.forEach(k => { if (state.hasOwnProperty(k)) { result[k] = state[k]; } }); return result; } /** * This will perform a shallow merge of the given state object * with the state coming from props * (for the controlled component scenario) * This is used in state updater functions so they're referencing * the right state regardless of where it comes from. * * @param {Object} state The state of the component/hook. * @param {Object} props The props that may contain controlled values. * @returns {Object} The merged controlled state. */ function getState(state, props) { return Object.keys(state).reduce((prevState, key) => { prevState[key] = isControlledProp(props, key) ? props[key] : state[key]; return prevState; }, {}); } /** * This determines whether a prop is a "controlled prop" meaning it is * state which is controlled by the outside of this component rather * than within this component. * * @param {Object} props The props that may contain controlled values. * @param {String} key the key to check * @return {Boolean} whether it is a controlled controlled prop */ function isControlledProp(props, key) { return props[key] !== undefined; } /** * Normalizes the 'key' property of a KeyboardEvent in IE/Edge * @param {Object} event a keyboardEvent object * @return {String} keyboard key */ function normalizeArrowKey(event) { const { key, keyCode } = event; /* istanbul ignore next (ie) */ if (keyCode >= 37 && keyCode <= 40 && key.indexOf('Arrow') !== 0) { return `Arrow${key}`; } return key; } /** * Simple check if the value passed is object literal * @param {*} obj any things * @return {Boolean} whether it's object literal */ function isPlainObject(obj) { return Object.prototype.toString.call(obj) === '[object Object]'; } /** * Returns the new index in the list, in a circular way. If next value is out of bonds from the total, * it will wrap to either 0 or itemCount - 1. * * @param {number} moveAmount Number of positions to move. Negative to move backwards, positive forwards. * @param {number} baseIndex The initial position to move from. * @param {number} itemCount The total number of items. * @param {Function} getItemNodeFromIndex Used to check if item is disabled. * @param {boolean} circular Specify if navigation is circular. Default is true. * @returns {number} The new index after the move. */ function getNextWrappingIndex(moveAmount, baseIndex, itemCount, getItemNodeFromIndex, circular) { if (circular === void 0) { circular = true; } if (itemCount === 0) { return -1; } const itemsLastIndex = itemCount - 1; if (typeof baseIndex !== 'number' || baseIndex < 0 || baseIndex >= itemCount) { baseIndex = moveAmount > 0 ? -1 : itemsLastIndex + 1; } let newIndex = baseIndex + moveAmount; if (newIndex < 0) { newIndex = circular ? itemsLastIndex : 0; } else if (newIndex > itemsLastIndex) { newIndex = circular ? 0 : itemsLastIndex; } const nonDisabledNewIndex = getNextNonDisabledIndex(moveAmount, newIndex, itemCount, getItemNodeFromIndex, circular); if (nonDisabledNewIndex === -1) { return baseIndex >= itemCount ? -1 : baseIndex; } return nonDisabledNewIndex; } /** * Returns the next index in the list of an item that is not disabled. * * @param {number} moveAmount Number of positions to move. Negative to move backwards, positive forwards. * @param {number} baseIndex The initial position to move from. * @param {number} itemCount The total number of items. * @param {Function} getItemNodeFromIndex Used to check if item is disabled. * @param {boolean} circular Specify if navigation is circular. Default is true. * @returns {number} The new index. Returns baseIndex if item is not disabled. Returns next non-disabled item otherwise. If no non-disabled found it will return -1. */ function getNextNonDisabledIndex(moveAmount, baseIndex, itemCount, getItemNodeFromIndex, circular) { const currentElementNode = getItemNodeFromIndex(baseIndex); if (!currentElementNode || !currentElementNode.hasAttribute('disabled')) { return baseIndex; } if (moveAmount > 0) { for (let index = baseIndex + 1; index < itemCount; index++) { if (!getItemNodeFromIndex(index).hasAttribute('disabled')) { return index; } } } else { for (let index = baseIndex - 1; index >= 0; index--) { if (!getItemNodeFromIndex(index).hasAttribute('disabled')) { return index; } } } if (circular) { return moveAmount > 0 ? getNextNonDisabledIndex(1, 0, itemCount, getItemNodeFromIndex, false) : getNextNonDisabledIndex(-1, itemCount - 1, itemCount, getItemNodeFromIndex, false); } return -1; } /** * Checks if event target is within the downshift elements. * * @param {EventTarget} target Target to check. * @param {HTMLElement[]} downshiftElements The elements that form downshift (list, toggle button etc). * @param {Window} environment The window context where downshift renders. * @param {boolean} checkActiveElement Whether to also check activeElement. * * @returns {boolean} Whether or not the target is within downshift elements. */ function targetWithinDownshift(target, downshiftElements, environment, checkActiveElement) { if (checkActiveElement === void 0) { checkActiveElement = true; } return downshiftElements.some(contextNode => contextNode && (isOrContainsNode(contextNode, target, environment) || checkActiveElement && isOrContainsNode(contextNode, environment.document.activeElement, environment))); } // eslint-disable-next-line import/no-mutable-exports let validateControlledUnchanged = noop; /* istanbul ignore next */ if (process.env.NODE_ENV !== 'production') { validateControlledUnchanged = (state, prevProps, nextProps) => { const warningDescription = `This prop should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled Downshift element for the lifetime of the component. More info: https://github.com/downshift-js/downshift#control-props`; Object.keys(state).forEach(propKey => { if (prevProps[propKey] !== undefined && nextProps[propKey] === undefined) { // eslint-disable-next-line no-console console.error(`downshift: A component has changed the controlled prop "${propKey}" to be uncontrolled. ${warningDescription}`); } else if (prevProps[propKey] === undefined && nextProps[propKey] !== undefined) { // eslint-disable-next-line no-console console.error(`downshift: A component has changed the uncontrolled prop "${propKey}" to be controlled. ${warningDescription}`); } }); }; } const cleanupStatus = debounce(documentProp => { getStatusDiv(documentProp).textContent = ''; }, 500); /** * @param {String} status the status message * @param {Object} documentProp document passed by the user. */ function setStatus(status, documentProp) { const div = getStatusDiv(documentProp); if (!status) { return; } div.textContent = status; cleanupStatus(documentProp); } /** * Get the status node or create it if it does not already exist. * @param {Object} documentProp document passed by the user. * @return {HTMLElement} the status node. */ function getStatusDiv(documentProp) { if (documentProp === void 0) { documentProp = document; } let statusDiv = documentProp.getElementById('a11y-status-message'); if (statusDiv) { return statusDiv; } statusDiv = documentProp.createElement('div'); statusDiv.setAttribute('id', 'a11y-status-message'); statusDiv.setAttribute('role', 'status'); statusDiv.setAttribute('aria-live', 'polite'); statusDiv.setAttribute('aria-relevant', 'additions text'); Object.assign(statusDiv.style, { border: '0', clip: 'rect(0 0 0 0)', height: '1px', margin: '-1px', overflow: 'hidden', padding: '0', position: 'absolute', width: '1px' }); documentProp.body.appendChild(statusDiv); return statusDiv; } const unknown = process.env.NODE_ENV !== "production" ? '__autocomplete_unknown__' : 0; const mouseUp = process.env.NODE_ENV !== "production" ? '__autocomplete_mouseup__' : 1; const itemMouseEnter = process.env.NODE_ENV !== "production" ? '__autocomplete_item_mouseenter__' : 2; const keyDownArrowUp = process.env.NODE_ENV !== "production" ? '__autocomplete_keydown_arrow_up__' : 3; const keyDownArrowDown = process.env.NODE_ENV !== "production" ? '__autocomplete_keydown_arrow_down__' : 4; const keyDownEscape = process.env.NODE_ENV !== "production" ? '__autocomplete_keydown_escape__' : 5; const keyDownEnter = process.env.NODE_ENV !== "production" ? '__autocomplete_keydown_enter__' : 6; const keyDownHome = process.env.NODE_ENV !== "production" ? '__autocomplete_keydown_home__' : 7; const keyDownEnd = process.env.NODE_ENV !== "production" ? '__autocomplete_keydown_end__' : 8; const clickItem = process.env.NODE_ENV !== "production" ? '__autocomplete_click_item__' : 9; const blurInput = process.env.NODE_ENV !== "production" ? '__autocomplete_blur_input__' : 10; const changeInput = process.env.NODE_ENV !== "production" ? '__autocomplete_change_input__' : 11; const keyDownSpaceButton = process.env.NODE_ENV !== "production" ? '__autocomplete_keydown_space_button__' : 12; const clickButton = process.env.NODE_ENV !== "production" ? '__autocomplete_click_button__' : 13; const blurButton = process.env.NODE_ENV !== "production" ? '__autocomplete_blur_button__' : 14; const controlledPropUpdatedSelectedItem = process.env.NODE_ENV !== "production" ? '__autocomplete_controlled_prop_updated_selected_item__' : 15; const touchEnd = process.env.NODE_ENV !== "production" ? '__autocomplete_touchend__' : 16; var stateChangeTypes$3 = /*#__PURE__*/Object.freeze({ __proto__: null, unknown: unknown, mouseUp: mouseUp, itemMouseEnter: itemMouseEnter, keyDownArrowUp: keyDownArrowUp, keyDownArrowDown: keyDownArrowDown, keyDownEscape: keyDownEscape, keyDownEnter: keyDownEnter, keyDownHome: keyDownHome, keyDownEnd: keyDownEnd, clickItem: clickItem, blurInput: blurInput, changeInput: changeInput, keyDownSpaceButton: keyDownSpaceButton, clickButton: clickButton, blurButton: blurButton, controlledPropUpdatedSelectedItem: controlledPropUpdatedSelectedItem, touchEnd: touchEnd }); /* eslint camelcase:0 */ const Downshift = /*#__PURE__*/(() => { class Downshift extends preact.Component { constructor(_props) { var _this; super(_props); _this = this; this.id = this.props.id || `downshift-${generateId()}`; this.menuId = this.props.menuId || `${this.id}-menu`; this.labelId = this.props.labelId || `${this.id}-label`; this.inputId = this.props.inputId || `${this.id}-input`; this.getItemId = this.props.getItemId || (index => `${this.id}-item-${index}`); this.input = null; this.items = []; this.itemCount = null; this.previousResultCount = 0; this.timeoutIds = []; this.internalSetTimeout = (fn, time) => { const id = setTimeout(() => { this.timeoutIds = this.timeoutIds.filter(i => i !== id); fn(); }, time); this.timeoutIds.push(id); }; this.setItemCount = count => { this.itemCount = count; }; this.unsetItemCount = () => { this.itemCount = null; }; this.setHighlightedIndex = function (highlightedIndex, otherStateToSet) { if (highlightedIndex === void 0) { highlightedIndex = _this.props.defaultHighlightedIndex; } if (otherStateToSet === void 0) { otherStateToSet = {}; } otherStateToSet = pickState(otherStateToSet); _this.internalSetState({ highlightedIndex, ...otherStateToSet }); }; this.clearSelection = cb => { this.internalSetState({ selectedItem: null, inputValue: '', highlightedIndex: this.props.defaultHighlightedIndex, isOpen: this.props.defaultIsOpen }, cb); }; this.selectItem = (item, otherStateToSet, cb) => { otherStateToSet = pickState(otherStateToSet); this.internalSetState({ isOpen: this.props.defaultIsOpen, highlightedIndex: this.props.defaultHighlightedIndex, selectedItem: item, inputValue: this.props.itemToString(item), ...otherStateToSet }, cb); }; this.selectItemAtIndex = (itemIndex, otherStateToSet, cb) => { const item = this.items[itemIndex]; if (item == null) { return; } this.selectItem(item, otherStateToSet, cb); }; this.selectHighlightedItem = (otherStateToSet, cb) => { return this.selectItemAtIndex(this.getState().highlightedIndex, otherStateToSet, cb); }; this.internalSetState = (stateToSet, cb) => { let isItemSelected, onChangeArg; const onStateChangeArg = {}; const isStateToSetFunction = typeof stateToSet === 'function'; // we want to call `onInputValueChange` before the `setState` call // so someone controlling the `inputValue` state gets notified of // the input change as soon as possible. This avoids issues with // preserving the cursor position. // See https://github.com/downshift-js/downshift/issues/217 for more info. if (!isStateToSetFunction && stateToSet.hasOwnProperty('inputValue')) { this.props.onInputValueChange(stateToSet.inputValue, { ...this.getStateAndHelpers(), ...stateToSet }); } return this.setState(state => { state = this.getState(state); let newStateToSet = isStateToSetFunction ? stateToSet(state) : stateToSet; // Your own function that could modify the state that will be set. newStateToSet = this.props.stateReducer(state, newStateToSet); // checks if an item is selected, regardless of if it's different from // what was selected before // used to determine if onSelect and onChange callbacks should be called isItemSelected = newStateToSet.hasOwnProperty('selectedItem'); // this keeps track of the object we want to call with setState const nextState = {}; // we need to call on change if the outside world is controlling any of our state // and we're trying to update that state. OR if the selection has changed and we're // trying to update the selection if (isItemSelected && newStateToSet.selectedItem !== state.selectedItem) { onChangeArg = newStateToSet.selectedItem; } newStateToSet.type = newStateToSet.type || unknown; Object.keys(newStateToSet).forEach(key => { // onStateChangeArg should only have the state that is // actually changing if (state[key] !== newStateToSet[key]) { onStateChangeArg[key] = newStateToSet[key]; } // the type is useful for the onStateChangeArg // but we don't actually want to set it in internal state. // this is an undocumented feature for now... Not all internalSetState // calls support it and I'm not certain we want them to yet. // But it enables users controlling the isOpen state to know when // the isOpen state changes due to mouseup events which is quite handy. if (key === 'type') { return; } newStateToSet[key]; // if it's coming from props, then we don't care to set it internally if (!isControlledProp(this.props, key)) { nextState[key] = newStateToSet[key]; } }); // if stateToSet is a function, then we weren't able to call onInputValueChange // earlier, so we'll call it now that we know what the inputValue state will be. if (isStateToSetFunction && newStateToSet.hasOwnProperty('inputValue')) { this.props.onInputValueChange(newStateToSet.inputValue, { ...this.getStateAndHelpers(), ...newStateToSet }); } return nextState; }, () => { // call the provided callback if it's a function cbToCb(cb)(); // only call the onStateChange and onChange callbacks if // we have relevant information to pass them. const hasMoreStateThanType = Object.keys(onStateChangeArg).length > 1; if (hasMoreStateThanType) { this.props.onStateChange(onStateChangeArg, this.getStateAndHelpers()); } if (isItemSelected) { this.props.onSelect(stateToSet.selectedItem, this.getStateAndHelpers()); } if (onChangeArg !== undefined) { this.props.onChange(onChangeArg, this.getStateAndHelpers()); } // this is currently undocumented and therefore subject to change // We'll try to not break it, but just be warned. this.props.onUserAction(onStateChangeArg, this.getStateAndHelpers()); }); }; this.rootRef = node => this._rootNode = node; this.getRootProps = function (_temp, _temp2) { let { refKey = 'ref', ref, ...rest } = _temp === void 0 ? {} : _temp; let { suppressRefError = false } = _temp2 === void 0 ? {} : _temp2; // this is used in the render to know whether the user has called getRootProps. // It uses that to know whether to apply the props automatically _this.getRootProps.called = true; _this.getRootProps.refKey = refKey; _this.getRootProps.suppressRefError = suppressRefError; const { isOpen } = _this.getState(); return { [refKey]: handleRefs(ref, _this.rootRef), role: 'combobox', 'aria-expanded': isOpen, 'aria-haspopup': 'listbox', 'aria-owns': isOpen ? _this.menuId : null, 'aria-labelledby': _this.labelId, ...rest }; }; this.keyDownHandlers = { ArrowDown(event) { event.preventDefault(); if (this.getState().isOpen) { const amount = event.shiftKey ? 5 : 1; this.moveHighlightedIndex(amount, { type: keyDownArrowDown }); } else { this.internalSetState({ isOpen: true, type: keyDownArrowDown }, () => { const itemCount = this.getItemCount(); if (itemCount > 0) { const { highlightedIndex } = this.getState(); const nextHighlightedIndex = getNextWrappingIndex(1, highlightedIndex, itemCount, index => this.getItemNodeFromIndex(index)); this.setHighlightedIndex(nextHighlightedIndex, { type: keyDownArrowDown }); } }); } }, ArrowUp(event) { event.preventDefault(); if (this.getState().isOpen) { const amount = event.shiftKey ? -5 : -1; this.moveHighlightedIndex(amount, { type: keyDownArrowUp }); } else { this.internalSetState({ isOpen: true, type: keyDownArrowUp }, () => { const itemCount = this.getItemCount(); if (itemCount > 0) { const { highlightedIndex } = this.getState(); const nextHighlightedIndex = getNextWrappingIndex(-1, highlightedIndex, itemCount, index => this.getItemNodeFromIndex(index)); this.setHighlightedIndex(nextHighlightedIndex, { type: keyDownArrowUp }); } }); } }, Enter(event) { if (event.which === 229) { return; } const { isOpen, highlightedIndex } = this.getState(); if (isOpen && highlightedIndex != null) { event.preventDefault(); const item = this.items[highlightedIndex]; const itemNode = this.getItemNodeFromIndex(highlightedIndex); if (item == null || itemNode && itemNode.hasAttribute('disabled')) { return; } this.selectHighlightedItem({ type: keyDownEnter }); } }, Escape(event) { event.preventDefault(); this.reset({ type: keyDownEscape, ...(!this.state.isOpen && { selectedItem: null, inputValue: '' }) }); } }; this.buttonKeyDownHandlers = { ...this.keyDownHandlers, ' '(event) { event.preventDefault(); this.toggleMenu({ type: keyDownSpaceButton }); } }; this.inputKeyDownHandlers = { ...this.keyDownHandlers, Home(event) { const { isOpen } = this.getState(); if (!isOpen) { return; } event.preventDefault(); const itemCount = this.getItemCount(); if (itemCount <= 0 || !isOpen) { return; } // get next non-disabled starting downwards from 0 if that's disabled. const newHighlightedIndex = getNextNonDisabledIndex(1, 0, itemCount, index => this.getItemNodeFromIndex(index), false); this.setHighlightedIndex(newHighlightedIndex, { type: keyDownHome }); }, End(event) { const { isOpen } = this.getState(); if (!isOpen) { return; } event.preventDefault(); const itemCount = this.getItemCount(); if (itemCount <= 0 || !isOpen) { return; } // get next non-disabled starting upwards from last index if that's disabled. const newHighlightedIndex = getNextNonDisabledIndex(-1, itemCount - 1, itemCount, index => this.getItemNodeFromIndex(index), false); this.setHighlightedIndex(newHighlightedIndex, { type: keyDownEnd }); } }; this.getToggleButtonProps = function (_temp3) { let { onClick, onPress, onKeyDown, onKeyUp, onBlur, ...rest } = _temp3 === void 0 ? {} : _temp3; const { isOpen } = _this.getState(); const enabledEventHandlers = { onClick: callAllEventHandlers(onClick, _this.buttonHandleClick), onKeyDown: callAllEventHandlers(onKeyDown, _this.buttonHandleKeyDown), onKeyUp: callAllEventHandlers(onKeyUp, _this.buttonHandleKeyUp), onBlur: callAllEventHandlers(onBlur, _this.buttonHandleBlur) }; const eventHandlers = rest.disabled ? {} : enabledEventHandlers; return { type: 'button', role: 'button', 'aria-label': isOpen ? 'close menu' : 'open menu', 'aria-haspopup': true, 'data-toggle': true, ...eventHandlers, ...rest }; }; this.buttonHandleKeyUp = event => { // Prevent click event from emitting in Firefox event.preventDefault(); }; this.buttonHandleKeyDown = event => { const key = normalizeArrowKey(event); if (this.buttonKeyDownHandlers[key]) { this.buttonKeyDownHandlers[key].call(this, event); } }; this.buttonHandleClick = event => { event.preventDefault(); // handle odd case for Safari and Firefox which // don't give the button the focus properly. /* istanbul ignore if (can't reasonably test this) */ if (this.props.environment.document.activeElement === this.props.environment.document.body) { event.target.focus(); } // to simplify testing components that use downshift, we'll not wrap this in a setTimeout // if the NODE_ENV is test. With the proper build system, this should be dead code eliminated // when building for production and should therefore have no impact on production code. if (process.env.NODE_ENV === 'test') { this.toggleMenu({ type: clickButton }); } else { // Ensure that toggle of menu occurs after the potential blur event in iOS this.internalSetTimeout(() => this.toggleMenu({ type: clickButton })); } }; this.buttonHandleBlur = event => { const blurTarget = event.target; // Save blur target for comparison with activeElement later // Need setTimeout, so that when the user presses Tab, the activeElement is the next focused element, not body element this.internalSetTimeout(() => { if (!this.isMouseDown && (this.props.environment.document.activeElement == null || this.props.environment.document.activeElement.id !== this.inputId) && this.props.environment.document.activeElement !== blurTarget // Do nothing if we refocus the same element again (to solve issue in Safari on iOS) ) { this.reset({ type: blurButton }); } }); }; this.getLabelProps = props => { return { htmlFor: this.inputId, id: this.labelId, ...props }; }; this.getInputProps = function (_temp4) { let { onKeyDown, onBlur, onChange, onInput, onChangeText, ...rest } = _temp4 === void 0 ? {} : _temp4; let onChangeKey; let eventHandlers = {}; /* istanbul ignore next (preact) */ { onChangeKey = 'onInput'; } const { inputValue, isOpen, highlightedIndex } = _this.getState(); if (!rest.disabled) { eventHandlers = { [onChangeKey]: callAllEventHandlers(onChange, onInput, _this.inputHandleChange), onKeyDown: callAllEventHandlers(onKeyDown, _this.inputHandleKeyDown), onBlur: callAllEventHandlers(onBlur, _this.inputHandleBlur) }; } return { 'aria-autocomplete': 'list', 'aria-activedescendant': isOpen && typeof highlightedIndex === 'number' && highlightedIndex >= 0 ? _this.getItemId(highlightedIndex) : null, 'aria-controls': isOpen ? _this.menuId : null, 'aria-labelledby': _this.labelId, // https://developer.mozilla.org/en-US/docs/Web/Security/Securing_your_site/Turning_off_form_autocompletion // revert back since autocomplete="nope" is ignored on latest Chrome and Opera autoComplete: 'off', value: inputValue, id: _this.inputId, ...eventHandlers, ...rest }; }; this.inputHandleKeyDown = event => { const key = normalizeArrowKey(event); if (key && this.inputKeyDownHandlers[key]) { this.inputKeyDownHandlers[key].call(this, event); } }; this.inputHandleChange = event => { this.internalSetState({ type: changeInput, isOpen: true, inputValue: event.target.value, highlightedIndex: this.props.defaultHighlightedIndex }); }; this.inputHandleBlur = () => { // Need setTimeout, so that when the user presses Tab, the activeElement is the next focused element, not the body element this.internalSetTimeout(() => { const downshiftButtonIsActive = this.props.environment.document && !!this.props.environment.document.activeElement && !!this.props.environment.document.activeElement.dataset && this.props.environment.document.activeElement.dataset.toggle && this._rootNode && this._rootNode.contains(this.props.environment.document.activeElement); if (!this.isMouseDown && !downshiftButtonIsActive) { this.reset({ type: blurInput }); } }); }; this.menuRef = node => { this._menuNode = node; }; this.getMenuProps = function (_temp5, _temp6) { let { refKey = 'ref', ref, ...props } = _temp5 === void 0 ? {} : _temp5; let { suppressRefError = false } = _temp6 === void 0 ? {} : _temp6; _this.getMenuProps.called = true; _this.getMenuProps.refKey = refKey; _this.getMenuProps.suppressRefError = suppressRefError; return { [refKey]: handleRefs(ref, _this.menuRef), role: 'listbox', 'aria-labelledby': props && props['aria-label'] ? null : _this.labelId, id: _this.menuId, ...props }; }; this.getItemProps = function (_temp7) { let { onMouseMove, onMouseDown, onClick, onPress, index, item = process.env.NODE_ENV === 'production' ? /* istanbul ignore next */undefined : requiredProp('getItemProps', 'item'), ...rest } = _temp7 === void 0 ? {} : _temp7; if (index === undefined) { _this.items.push(item); index = _this.items.indexOf(item); } else { _this.items[index] = item; } const onSelectKey = 'onClick'; const customClickHandler = onClick; const enabledEventHandlers = { // onMouseMove is used over onMouseEnter here. onMouseMove // is only triggered on actual mouse movement while onMouseEnter // can fire on DOM changes, interrupting keyboard navigation onMouseMove: callAllEventHandlers(onMouseMove, () => { if (index === _this.getState().highlightedIndex) { return; } _this.setHighlightedIndex(index, { type: itemMouseEnter }); // We never want to manually scroll when changing state based // on `onMouseMove` because we will be moving the element out // from under the user which is currently scrolling/moving the // cursor _this.avoidScrolling = true; _this.internalSetTimeout(() => _this.avoidScrolling = false, 250); }), onMouseDown: callAllEventHandlers(onMouseDown, event => { // This prevents the activeElement from being changed // to the item so it can remain with the current activeElement // which is a more common use case. event.preventDefault(); }), [onSelectKey]: callAllEventHandlers(customClickHandler, () => { _this.selectItemAtIndex(index, { type: clickItem }); }) }; // Passing down the onMouseDown handler to prevent redirect // of the activeElement if clicking on disabled items const eventHandlers = rest.disabled ? { onMouseDown: enabledEventHandlers.onMouseDown } : enabledEventHandlers; return { id: _this.getItemId(index), role: 'option', 'aria-selected': _this.getState().highlightedIndex === index, ...eventHandlers, ...rest }; }; this.clearItems = () => { this.items = []; }; this.reset = function (otherStateToSet, cb) { if (otherStateToSet === void 0) { otherStateToSet = {}; } otherStateToSet = pickState(otherStateToSet); _this.internalSetState(_ref => { let { selectedItem } = _ref; return { isOpen: _this.props.defaultIsOpen, highlightedIndex: _this.props.defaultHighlightedIndex, inputValue: _this.props.itemToString(selectedItem), ...otherStateToSet }; }, cb); }; this.toggleMenu = function (otherStateToSet, cb) { if (otherStateToSet === void 0) { otherStateToSet = {}; } otherStateToSet = pickState(otherStateToSet); _this.internalSetState(_ref2 => { let { isOpen } = _ref2; return { isOpen: !isOpen, ...(isOpen && { highlightedIndex: _this.props.defaultHighlightedIndex }), ...otherStateToSet }; }, () => { const { isOpen, highlightedIndex } = _this.getState(); if (isOpen) { if (_this.getItemCount() > 0 && typeof highlightedIndex === 'number') { _this.setHighlightedIndex(highlightedIndex, otherStateToSet); } } cbToCb(cb)(); }); }; this.openMenu = cb => { this.internalSetState({ isOpen: true }, cb); }; this.closeMenu = cb => { this.internalSetState({ isOpen: false }, cb); }; this.updateStatus = debounce(() => { const state = this.getState(); const item = this.items[state.highlightedIndex]; const resultCount = this.getItemCount(); const status = this.props.getA11yStatusMessage({ itemToString: this.props.itemToString, previousResultCount: this.previousResultCount, resultCount, highlightedItem: item, ...state }); this.previousResultCount = resultCount; setStatus(status, this.props.environment.document); }, 200); // fancy destructuring + defaults + aliases // this basically says each value of state should either be set to // the initial value or the default value if the initial value is not provided const { defaultHighlightedIndex, initialHighlightedIndex: _highlightedIndex = defaultHighlightedIndex, defaultIsOpen, initialIsOpen: _isOpen = defaultIsOpen, initialInputValue: _inputValue = '', initialSelectedItem: _selectedItem = null } = this.props; const _state = this.getState({ highlightedIndex: _highlightedIndex, isOpen: _isOpen, inputValue: _inputValue, selectedItem: _selectedItem }); if (_state.selectedItem != null && this.props.initialInputValue === undefined) { _state.inputValue = this.props.itemToString(_state.selectedItem); } this.state = _state; } /** * Clear all running timeouts */ internalClearTimeouts() { this.timeoutIds.forEach(id => { clearTimeout(id); }); this.timeoutIds = []; } /** * Gets the state based on internal state or props * If a state value is passed via props, then that * is the value given, otherwise it's retrieved from * stateToMerge * * @param {Object} stateToMerge defaults to this.state * @return {Object} the state */ getState(stateToMerge) { if (stateToMerge === void 0) { stateToMerge = this.state; } return getState(stateToMerge, this.props); } getItemCount() { // things read better this way. They're in priority order: // 1. `this.itemCount` // 2. `this.props.itemCount` // 3. `this.items.length` let itemCount = this.items.length; if (this.itemCount != null) { itemCount = this.itemCount; } else if (this.props.itemCount !== undefined) { itemCount = this.props.itemCount; } return itemCount; } getItemNodeFromIndex(index) { return this.props.environment.document.getElementById(this.getItemId(index)); } scrollHighlightedItemIntoView() { /* istanbul ignore else (react-native) */ { const node = this.getItemNodeFromIndex(this.getState().highlightedIndex); this.props.scrollIntoView(node, this._menuNode); } } moveHighlightedIndex(amount, otherStateToSet) { const itemCount = this.getItemCount(); const { highlightedIndex } = this.getState(); if (itemCount > 0) { const nextHighlightedIndex = getNextWrappingIndex(amount, highlightedIndex, itemCount, index => this.getItemNodeFromIndex(index)); this.setHighlightedIndex(nextHighlightedIndex, otherStateToSet); } } getStateAndHelpers() { const { highlightedIndex, inputValue, selectedItem, isOpen } = this.getState(); const { itemToString } = this.props; const { id } = this; const { getRootProps, getToggleButtonProps, getLabelProps, getMenuProps, getInputProps, getItemProps, openMenu, closeMenu, toggleMenu, selectItem, selectItemAtIndex, selectHighlightedItem, setHighlightedIndex, clearSelection, clearItems, reset, setItemCount, unsetItemCount, internalSetState: setState } = this; return { // prop getters getRootProps, getToggleButtonProps, getLabelProps, getMenuProps, getInputProps, getItemProps, // actions reset, openMenu, closeMenu, toggleMenu, selectItem, selectItemAtIndex, selectHighlightedItem, setHighlightedIndex, clearSelection, clearItems, setItemCount, unsetItemCount, setState, // props itemToString, // derived id, // state highlightedIndex, inputValue, isOpen, selectedItem }; } //////////////////////////// ROOT componentDidMount() { /* istanbul ignore if (react-native) */ if (process.env.NODE_ENV !== 'production' && !false && this.getMenuProps.called && !this.getMenuProps.suppressRefError) { validateGetMenuPropsCalledCorrectly(this._menuNode, this.getMenuProps); } /* istanbul ignore if (react-native) */ { // this.isMouseDown helps us track whether the mouse is currently held down. // This is useful when the user clicks on an item in the list, but holds the mouse // down long enough for the list to disappear (because the blur event fires on the input) // this.isMouseDown is used in the blur handler on the input to determine whether the blur event should // trigger hiding the menu. const onMouseDown = () => { this.isMouseDown = true; }; const onMouseUp = event => { this.isMouseDown = false; // if the target element or the activeElement is within a downshift node // then we don't want to reset downshift const contextWithinDownshift = targetWithinDownshift(event.target, [this._rootNode, this._menuNode], this.props.environment); if (!contextWithinDownshift && this.getState().isOpen) { this.reset({ type: mouseUp }, () => this.props.onOuterClick(this.getStateAndHelpers())); } }; // Touching an element in iOS gives focus and hover states, but touching out of // the element will remove hover, and persist the focus state, resulting in the // blur event not being triggered. // this.isTouchMove helps us track whether the user is tapping or swiping on a touch screen. // If the user taps outside of Downshift, the component should be reset, // but not if the user is swiping const onTouchStart = () => { this.isTouchMove = false; }; const onTouchMove = () => { this.isTouchMove = true; }; const onTouchEnd = event => { const contextWithinDownshift = targetWithinDownshift(event.target, [this._rootNode, this._menuNode], this.props.environment, false); if (!this.isTouchMove && !contextWithinDownshift && this.getState().isOpen) { this.reset({ type: touchEnd }, () => this.props.onOuterClick(this.getStateAndHelpers())); } }; const { environment } = this.props; environment.addEventListener('mousedown', onMouseDown); environment.addEventListener('mouseup', onMouseUp); environment.addEventListener('touchstart', onTouchStart); environment.addEventListener('touchmove', onTouchMove); environment.addEventListener('touchend', onTouchEnd); this.cleanup = () => { this.internalClearTimeouts(); this.updateStatus.cancel(); environment.removeEventListener('mousedown', onMouseDown); environment.removeEventListener('mouseup', onMouseUp); environment.removeEventListener('touchstart', onTouchStart); environment.removeEventListener('touchmove', onTouchMove); environment.removeEventListener('touchend', onTouchEnd); }; } } shouldScroll(prevState, prevProps) { const { highlightedIndex: currentHighlightedIndex } = this.props.highlightedIndex === undefined ? this.getState() : this.props; const { highlightedIndex: prevHighlightedIndex } = prevProps.highlightedIndex === undefined ? prevState : prevProps; const scrollWhenOpen = currentHighlightedIndex && this.getState().isOpen && !prevState.isOpen; const scrollWhenNavigating = currentHighlightedIndex !== prevHighlightedIndex; return scrollWhenOpen || scrollWhenNavigating; } componentDidUpdate(prevProps, prevState) { if (process.env.NODE_ENV !== 'production') { validateControlledUnchanged(this.state, prevProps, this.props); /* istanbul ignore if (react-native) */ if (this.getMenuProps.called && !this.getMenuProps.suppressRefError) { validateGetMenuPropsCalledCorrectly(this._menuNode, this.getMenuProps); } } if (isControlledProp(this.props, 'selectedItem') && this.props.selectedItemChanged(prevProps.selectedItem, this.props.selectedItem)) { this.internalSetState({ type: controlledPropUpdatedSelectedItem, inputValue: this.props.itemToString(this.props.selectedItem) }); } if (!this.avoidScrolling && this.shouldScroll(prevState, prevProps)) { this.scrollHighlightedItemIntoView(); } /* istanbul ignore else (react-native) */ { this.updateStatus(); } } componentWillUnmount() { this.cleanup(); // avoids memory leak } render() { const children = unwrapArray(this.props.children, noop); // because the items are rerendered every time we call the children // we clear this out each render and it will be populated again as // getItemProps is called. this.clearItems(); // we reset this so we know