@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
JavaScript
'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