UNPKG

@blueprintjs/select

Version:

Components related to selecting items from a list

504 lines 25.5 kB
/* * Copyright 2017 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { __assign, __extends, __rest } from "tslib"; import * as React from "react"; import { AbstractComponent2, DISPLAYNAME_PREFIX, Keys, Menu, Utils } from "@blueprintjs/core"; import { executeItemsEqual, getActiveItem, getCreateNewItem, isCreateNewItem, renderFilteredItems, } from "../../common"; /** * Query list component. * * @see https://blueprintjs.com/docs/#select/query-list */ var QueryList = /** @class */ (function (_super) { __extends(QueryList, _super); function QueryList(props, context) { var _this = this; var _a, _b; _this = _super.call(this, props, context) || this; _this.itemRefs = new Map(); _this.refHandlers = { itemsParent: function (ref) { return (_this.itemsParentRef = ref); }, }; /** * Flag indicating that we should check whether selected item is in viewport * after rendering, typically because of keyboard change. Set to `true` when * manipulating state in a way that may cause active item to scroll away. */ _this.shouldCheckActiveItemInViewport = false; /** * The item that we expect to be the next selected active item (based on click * or key interactions). When scrollToActiveItem = false, used to detect if * an unexpected external change to the active item has been made. */ _this.expectedNextActiveItem = null; /** * Flag which is set to true while in between an ENTER "keydown" event and its * corresponding "keyup" event. * * When entering text via an IME (https://en.wikipedia.org/wiki/Input_method), * the ENTER key is pressed to confirm the character(s) to be input from a list * of options. The operating system intercepts the ENTER "keydown" event and * prevents it from propagating to the application, but "keyup" is still * fired, triggering a spurious event which this component does not expect. * * To work around this quirk, we keep track of "real" key presses by setting * this flag in handleKeyDown. */ _this.isEnterKeyPressed = false; /** default `itemListRenderer` implementation */ _this.renderItemList = function (listProps) { var _a = _this.props, initialContent = _a.initialContent, noResults = _a.noResults; // omit noResults if createNewItemFromQuery and createNewItemRenderer are both supplied, and query is not empty var createItemView = listProps.renderCreateItem(); var maybeNoResults = createItemView != null ? null : noResults; var menuContent = renderFilteredItems(listProps, maybeNoResults, initialContent); if (menuContent == null && createItemView == null) { return null; } var createFirst = _this.isCreateItemFirst(); return (React.createElement(Menu, __assign({ role: "listbox" }, listProps.menuProps, { ulRef: listProps.itemsParentRef }), createFirst && createItemView, menuContent, !createFirst && createItemView)); }; /** wrapper around `itemRenderer` to inject props */ _this.renderItem = function (item, index) { if (_this.props.disabled !== true) { var _a = _this.state, activeItem = _a.activeItem, query = _a.query, filteredItems = _a.filteredItems; var modifiers = { active: executeItemsEqual(_this.props.itemsEqual, getActiveItem(activeItem), item), disabled: isItemDisabled(item, index, _this.props.itemDisabled), matchesPredicate: filteredItems.indexOf(item) >= 0, }; return _this.props.itemRenderer(item, { handleClick: function (e) { return _this.handleItemSelect(item, e); }, handleFocus: function () { return _this.setActiveItem(item); }, index: index, modifiers: modifiers, query: query, ref: function (node) { if (node) { _this.itemRefs.set(index, node); } else { _this.itemRefs.delete(index); } }, }); } return null; }; _this.renderCreateItemMenuItem = function () { if (_this.isCreateItemRendered(_this.state.createNewItem)) { var _a = _this.state, activeItem = _a.activeItem, query = _a.query; var trimmedQuery_1 = query.trim(); var handleClick = function (evt) { _this.handleItemCreate(trimmedQuery_1, evt); }; var isActive = isCreateNewItem(activeItem); return _this.props.createNewItemRenderer(trimmedQuery_1, isActive, handleClick); } return null; }; _this.handleItemCreate = function (query, evt) { var _a, _b, _c, _d; // we keep a cached createNewItem in state, but might as well recompute // the result just to be sure it's perfectly in sync with the query. var value = (_b = (_a = _this.props).createNewItemFromQuery) === null || _b === void 0 ? void 0 : _b.call(_a, query); if (value != null) { var newItems = Array.isArray(value) ? value : [value]; for (var _i = 0, newItems_1 = newItems; _i < newItems_1.length; _i++) { var item = newItems_1[_i]; (_d = (_c = _this.props).onItemSelect) === null || _d === void 0 ? void 0 : _d.call(_c, item, evt); } _this.maybeResetQuery(); } }; _this.handleItemSelect = function (item, event) { var _a, _b; _this.setActiveItem(item); (_b = (_a = _this.props).onItemSelect) === null || _b === void 0 ? void 0 : _b.call(_a, item, event); _this.maybeResetQuery(); }; _this.handlePaste = function (queries) { var _a = _this.props, createNewItemFromQuery = _a.createNewItemFromQuery, onItemsPaste = _a.onItemsPaste; var nextActiveItem; var nextQueries = []; // Find an exising item that exactly matches each pasted value, or // create a new item if possible. Ignore unmatched values if creating // items is disabled. var pastedItemsToEmit = []; for (var _i = 0, queries_1 = queries; _i < queries_1.length; _i++) { var query = queries_1[_i]; var equalItem = getMatchingItem(query, _this.props); if (equalItem !== undefined) { nextActiveItem = equalItem; pastedItemsToEmit.push(equalItem); } else if (_this.canCreateItems()) { var value = createNewItemFromQuery === null || createNewItemFromQuery === void 0 ? void 0 : createNewItemFromQuery(query); if (value !== undefined) { var newItems = Array.isArray(value) ? value : [value]; pastedItemsToEmit.push.apply(pastedItemsToEmit, newItems); } } else { nextQueries.push(query); } } // UX nicety: combine all unmatched queries into a single // comma-separated query in the input, so we don't lose any information. // And don't reset the active item; we'll do that ourselves below. _this.setQuery(nextQueries.join(", "), false); // UX nicety: update the active item if we matched with at least one // existing item. if (nextActiveItem !== undefined) { _this.setActiveItem(nextActiveItem); } onItemsPaste === null || onItemsPaste === void 0 ? void 0 : onItemsPaste(pastedItemsToEmit); }; _this.handleKeyDown = function (event) { var _a, _b; // eslint-disable-next-line deprecation/deprecation var keyCode = event.keyCode; if (keyCode === Keys.ARROW_UP || keyCode === Keys.ARROW_DOWN) { event.preventDefault(); var nextActiveItem = _this.getNextActiveItem(keyCode === Keys.ARROW_UP ? -1 : 1); if (nextActiveItem != null) { _this.setActiveItem(nextActiveItem); } } else if (keyCode === Keys.ENTER) { _this.isEnterKeyPressed = true; } (_b = (_a = _this.props).onKeyDown) === null || _b === void 0 ? void 0 : _b.call(_a, event); }; _this.handleKeyUp = function (event) { var onKeyUp = _this.props.onKeyUp; var activeItem = _this.state.activeItem; // eslint-disable-next-line deprecation/deprecation if (event.keyCode === Keys.ENTER && _this.isEnterKeyPressed) { // We handle ENTER in keyup here to play nice with the Button component's keyboard // clicking. Button is commonly used as the only child of Select. If we were to // instead process ENTER on keydown, then Button would click itself on keyup and // the Select popover would re-open. event.preventDefault(); if (activeItem == null || isCreateNewItem(activeItem)) { _this.handleItemCreate(_this.state.query, event); } else { _this.handleItemSelect(activeItem, event); } _this.isEnterKeyPressed = false; } onKeyUp === null || onKeyUp === void 0 ? void 0 : onKeyUp(event); }; _this.handleInputQueryChange = function (event) { var _a, _b; var query = event == null ? "" : event.target.value; _this.setQuery(query); (_b = (_a = _this.props).onQueryChange) === null || _b === void 0 ? void 0 : _b.call(_a, query, event); }; var _c = props.query, query = _c === void 0 ? "" : _c; var createNewItem = (_a = props.createNewItemFromQuery) === null || _a === void 0 ? void 0 : _a.call(props, query); var filteredItems = getFilteredItems(query, props); _this.state = { activeItem: props.activeItem !== undefined ? props.activeItem : (_b = props.initialActiveItem) !== null && _b !== void 0 ? _b : getFirstEnabledItem(filteredItems, props.itemDisabled), createNewItem: createNewItem, filteredItems: filteredItems, query: query, }; return _this; } /** @deprecated no longer necessary now that the TypeScript parser supports type arguments on JSX element tags */ QueryList.ofType = function () { return QueryList; }; QueryList.prototype.render = function () { var _a = this.props, className = _a.className, items = _a.items, renderer = _a.renderer, _b = _a.itemListRenderer, itemListRenderer = _b === void 0 ? this.renderItemList : _b, menuProps = _a.menuProps; var _c = this.state, createNewItem = _c.createNewItem, spreadableState = __rest(_c, ["createNewItem"]); return renderer(__assign(__assign({}, spreadableState), { className: className, handleItemSelect: this.handleItemSelect, handleKeyDown: this.handleKeyDown, handleKeyUp: this.handleKeyUp, handlePaste: this.handlePaste, handleQueryChange: this.handleInputQueryChange, itemList: itemListRenderer(__assign(__assign({}, spreadableState), { items: items, itemsParentRef: this.refHandlers.itemsParent, menuProps: menuProps, renderCreateItem: this.renderCreateItemMenuItem, renderItem: this.renderItem })) })); }; QueryList.prototype.componentDidUpdate = function (prevProps) { var _this = this; if (this.props.activeItem !== undefined && this.props.activeItem !== this.state.activeItem) { this.shouldCheckActiveItemInViewport = true; this.setState({ activeItem: this.props.activeItem }); } if (this.props.query != null && this.props.query !== prevProps.query) { // new query this.setQuery(this.props.query, this.props.resetOnQuery, this.props); } else if ( // same query (or uncontrolled query), but items in the list changed !Utils.shallowCompareKeys(this.props, prevProps, { include: ["items", "itemListPredicate", "itemPredicate"], })) { this.setQuery(this.state.query); } if (this.shouldCheckActiveItemInViewport) { // update scroll position immediately before repaint so DOM is accurate // (latest filteredItems) and to avoid flicker. this.requestAnimationFrame(function () { return _this.scrollActiveItemIntoView(); }); // reset the flag this.shouldCheckActiveItemInViewport = false; } }; QueryList.prototype.scrollActiveItemIntoView = function () { var scrollToActiveItem = this.props.scrollToActiveItem !== false; var externalChangeToActiveItem = !executeItemsEqual(this.props.itemsEqual, getActiveItem(this.expectedNextActiveItem), getActiveItem(this.props.activeItem)); this.expectedNextActiveItem = null; if (!scrollToActiveItem && externalChangeToActiveItem) { return; } var activeElement = this.getActiveElement(); if (this.itemsParentRef != null && activeElement != null) { var activeTop = activeElement.offsetTop, activeHeight = activeElement.offsetHeight; var _a = this.itemsParentRef, parentOffsetTop = _a.offsetTop, parentScrollTop = _a.scrollTop, parentHeight = _a.clientHeight; // compute padding on parent element to ensure we always leave space var _b = this.getItemsParentPadding(), paddingTop = _b.paddingTop, paddingBottom = _b.paddingBottom; // compute the two edges of the active item for comparison, including parent padding var activeBottomEdge = activeTop + activeHeight + paddingBottom - parentOffsetTop; var activeTopEdge = activeTop - paddingTop - parentOffsetTop; if (activeBottomEdge >= parentScrollTop + parentHeight) { // offscreen bottom: align bottom of item with bottom of viewport this.itemsParentRef.scrollTop = activeBottomEdge + activeHeight - parentHeight; } else if (activeTopEdge <= parentScrollTop) { // offscreen top: align top of item with top of viewport this.itemsParentRef.scrollTop = activeTopEdge - activeHeight; } } }; QueryList.prototype.setQuery = function (query, resetActiveItem, props) { var _a; if (resetActiveItem === void 0) { resetActiveItem = this.props.resetOnQuery; } if (props === void 0) { props = this.props; } var createNewItemFromQuery = props.createNewItemFromQuery; this.shouldCheckActiveItemInViewport = true; var hasQueryChanged = query !== this.state.query; if (hasQueryChanged) { (_a = props.onQueryChange) === null || _a === void 0 ? void 0 : _a.call(props, query); } // Leading and trailing whitespace can be confusing to display, so we remove it when passing it // to functions dealing with data, like createNewItemFromQuery. But we need the unaltered user-typed // query to remain in state to be able to render controlled text inputs properly. var trimmedQuery = query.trim(); var filteredItems = getFilteredItems(trimmedQuery, props); var createNewItem = createNewItemFromQuery != null && trimmedQuery !== "" ? createNewItemFromQuery(trimmedQuery) : undefined; this.setState({ createNewItem: createNewItem, filteredItems: filteredItems, query: query }); // always reset active item if it's now filtered or disabled var activeIndex = this.getActiveIndex(filteredItems); var shouldUpdateActiveItem = resetActiveItem || activeIndex < 0 || isItemDisabled(getActiveItem(this.state.activeItem), activeIndex, props.itemDisabled); if (shouldUpdateActiveItem) { // if the `createNewItem` is first, that should be the first active item. if (this.isCreateItemRendered(createNewItem) && this.isCreateItemFirst()) { this.setActiveItem(getCreateNewItem()); } else { this.setActiveItem(getFirstEnabledItem(filteredItems, props.itemDisabled)); } } }; QueryList.prototype.setActiveItem = function (activeItem) { var _a, _b, _c, _d; this.expectedNextActiveItem = activeItem; if (this.props.activeItem === undefined) { // indicate that the active item may need to be scrolled into view after update. this.shouldCheckActiveItemInViewport = true; this.setState({ activeItem: activeItem }); } if (isCreateNewItem(activeItem)) { (_b = (_a = this.props).onActiveItemChange) === null || _b === void 0 ? void 0 : _b.call(_a, null, true); } else { (_d = (_c = this.props).onActiveItemChange) === null || _d === void 0 ? void 0 : _d.call(_c, activeItem, false); } }; QueryList.prototype.getActiveElement = function () { var _a; var activeItem = this.state.activeItem; if (this.itemsParentRef != null) { if (isCreateNewItem(activeItem)) { var index = this.isCreateItemFirst() ? 0 : this.state.filteredItems.length; return this.itemsParentRef.children.item(index); } else { var activeIndex = this.getActiveIndex(); return ((_a = this.itemRefs.get(activeIndex)) !== null && _a !== void 0 ? _a : this.itemsParentRef.children.item(activeIndex)); } } return undefined; }; QueryList.prototype.getActiveIndex = function (items) { if (items === void 0) { items = this.state.filteredItems; } var activeItem = this.state.activeItem; if (activeItem == null || isCreateNewItem(activeItem)) { return -1; } // NOTE: this operation is O(n) so it should be avoided in render(). safe for events though. for (var i = 0; i < items.length; ++i) { if (executeItemsEqual(this.props.itemsEqual, items[i], activeItem)) { return i; } } return -1; }; QueryList.prototype.getItemsParentPadding = function () { // assert ref exists because it was checked before calling var _a = getComputedStyle(this.itemsParentRef), paddingTop = _a.paddingTop, paddingBottom = _a.paddingBottom; return { paddingBottom: pxToNumber(paddingBottom), paddingTop: pxToNumber(paddingTop), }; }; /** * Get the next enabled item, moving in the given direction from the start * index. A `null` return value means no suitable item was found. * * @param direction amount to move in each iteration, typically +/-1 * @param startIndex item to start iteration */ QueryList.prototype.getNextActiveItem = function (direction, startIndex) { if (startIndex === void 0) { startIndex = this.getActiveIndex(); } if (this.isCreateItemRendered(this.state.createNewItem)) { var reachedCreate = (startIndex === 0 && direction === -1) || (startIndex === this.state.filteredItems.length - 1 && direction === 1); if (reachedCreate) { return getCreateNewItem(); } } return getFirstEnabledItem(this.state.filteredItems, this.props.itemDisabled, direction, startIndex); }; /** * @param createNewItem Checks if this item would match the current query. Cannot check this.state.createNewItem * every time since state may not have been updated yet. */ QueryList.prototype.isCreateItemRendered = function (createNewItem) { return (this.canCreateItems() && this.state.query !== "" && // this check is unfortunately O(N) on the number of items, but // alas, hiding the "Create Item" option when it exactly matches an // existing item is much clearer. !this.wouldCreatedItemMatchSomeExistingItem(createNewItem)); }; QueryList.prototype.isCreateItemFirst = function () { return this.props.createNewItemPosition === "first"; }; QueryList.prototype.canCreateItems = function () { return this.props.createNewItemFromQuery != null && this.props.createNewItemRenderer != null; }; QueryList.prototype.wouldCreatedItemMatchSomeExistingItem = function (createNewItem) { var _this = this; // search only the filtered items, not the full items list, because we // only need to check items that match the current query. return this.state.filteredItems.some(function (item) { var newItems = Array.isArray(createNewItem) ? createNewItem : [createNewItem]; return newItems.some(function (newItem) { return executeItemsEqual(_this.props.itemsEqual, item, newItem); }); }); }; QueryList.prototype.maybeResetQuery = function () { if (this.props.resetOnSelect) { this.setQuery("", true); } }; QueryList.displayName = "".concat(DISPLAYNAME_PREFIX, ".QueryList"); QueryList.defaultProps = { disabled: false, resetOnQuery: true, }; return QueryList; }(AbstractComponent2)); export { QueryList }; function pxToNumber(value) { return value == null ? 0 : parseInt(value.slice(0, -2), 10); } function getMatchingItem(query, _a) { var items = _a.items, itemPredicate = _a.itemPredicate; if (Utils.isFunction(itemPredicate)) { // .find() doesn't exist in ES5. Alternative: use a for loop instead of // .filter() so that we can return as soon as we find the first match. for (var i = 0; i < items.length; i++) { var item = items[i]; if (itemPredicate(query, item, i, true)) { return item; } } } return undefined; } function getFilteredItems(query, _a) { var items = _a.items, itemPredicate = _a.itemPredicate, itemListPredicate = _a.itemListPredicate; if (Utils.isFunction(itemListPredicate)) { // note that implementations can reorder the items here return itemListPredicate(query, items); } else if (Utils.isFunction(itemPredicate)) { return items.filter(function (item, index) { return itemPredicate(query, item, index); }); } return items; } /** Wrap number around min/max values: if it exceeds one bound, return the other. */ function wrapNumber(value, min, max) { if (value < min) { return max; } else if (value > max) { return min; } return value; } function isItemDisabled(item, index, itemDisabled) { if (itemDisabled == null || item == null) { return false; } else if (Utils.isFunction(itemDisabled)) { return itemDisabled(item, index); } return !!item[itemDisabled]; } /** * Get the next enabled item, moving in the given direction from the start * index. A `null` return value means no suitable item was found. * * @param items the list of items * @param itemDisabled callback to determine if a given item is disabled * @param direction amount to move in each iteration, typically +/-1 * @param startIndex which index to begin moving from */ export function getFirstEnabledItem(items, itemDisabled, direction, startIndex) { if (direction === void 0) { direction = 1; } if (startIndex === void 0) { startIndex = items.length - 1; } if (items.length === 0) { return null; } // remember where we started to prevent an infinite loop var index = startIndex; var maxIndex = items.length - 1; do { // find first non-disabled item index = wrapNumber(index + direction, 0, maxIndex); if (!isItemDisabled(items[index], index, itemDisabled)) { return items[index]; } } while (index !== startIndex && startIndex !== -1); return null; } //# sourceMappingURL=queryList.js.map