UNPKG

25.5 kBJavaScriptView Raw
1/*
2 * Copyright 2017 Palantir Technologies, Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16import { __assign, __extends, __rest } from "tslib";
17import * as React from "react";
18import { AbstractComponent2, DISPLAYNAME_PREFIX, Keys, Menu, Utils } from "@blueprintjs/core";
19import { executeItemsEqual, getActiveItem, getCreateNewItem, isCreateNewItem, renderFilteredItems, } from "../../common";
20/**
21 * Query list component.
22 *
23 * @see https://blueprintjs.com/docs/#select/query-list
24 */
25var QueryList = /** @class */ (function (_super) {
26 __extends(QueryList, _super);
27 function QueryList(props, context) {
28 var _this = this;
29 var _a, _b;
30 _this = _super.call(this, props, context) || this;
31 _this.itemRefs = new Map();
32 _this.refHandlers = {
33 itemsParent: function (ref) { return (_this.itemsParentRef = ref); },
34 };
35 /**
36 * Flag indicating that we should check whether selected item is in viewport
37 * after rendering, typically because of keyboard change. Set to `true` when
38 * manipulating state in a way that may cause active item to scroll away.
39 */
40 _this.shouldCheckActiveItemInViewport = false;
41 /**
42 * The item that we expect to be the next selected active item (based on click
43 * or key interactions). When scrollToActiveItem = false, used to detect if
44 * an unexpected external change to the active item has been made.
45 */
46 _this.expectedNextActiveItem = null;
47 /**
48 * Flag which is set to true while in between an ENTER "keydown" event and its
49 * corresponding "keyup" event.
50 *
51 * When entering text via an IME (https://en.wikipedia.org/wiki/Input_method),
52 * the ENTER key is pressed to confirm the character(s) to be input from a list
53 * of options. The operating system intercepts the ENTER "keydown" event and
54 * prevents it from propagating to the application, but "keyup" is still
55 * fired, triggering a spurious event which this component does not expect.
56 *
57 * To work around this quirk, we keep track of "real" key presses by setting
58 * this flag in handleKeyDown.
59 */
60 _this.isEnterKeyPressed = false;
61 /** default `itemListRenderer` implementation */
62 _this.renderItemList = function (listProps) {
63 var _a = _this.props, initialContent = _a.initialContent, noResults = _a.noResults;
64 // omit noResults if createNewItemFromQuery and createNewItemRenderer are both supplied, and query is not empty
65 var createItemView = listProps.renderCreateItem();
66 var maybeNoResults = createItemView != null ? null : noResults;
67 var menuContent = renderFilteredItems(listProps, maybeNoResults, initialContent);
68 if (menuContent == null && createItemView == null) {
69 return null;
70 }
71 var createFirst = _this.isCreateItemFirst();
72 return (React.createElement(Menu, __assign({ role: "listbox" }, listProps.menuProps, { ulRef: listProps.itemsParentRef }),
73 createFirst && createItemView,
74 menuContent,
75 !createFirst && createItemView));
76 };
77 /** wrapper around `itemRenderer` to inject props */
78 _this.renderItem = function (item, index) {
79 if (_this.props.disabled !== true) {
80 var _a = _this.state, activeItem = _a.activeItem, query = _a.query, filteredItems = _a.filteredItems;
81 var modifiers = {
82 active: executeItemsEqual(_this.props.itemsEqual, getActiveItem(activeItem), item),
83 disabled: isItemDisabled(item, index, _this.props.itemDisabled),
84 matchesPredicate: filteredItems.indexOf(item) >= 0,
85 };
86 return _this.props.itemRenderer(item, {
87 handleClick: function (e) { return _this.handleItemSelect(item, e); },
88 handleFocus: function () { return _this.setActiveItem(item); },
89 index: index,
90 modifiers: modifiers,
91 query: query,
92 ref: function (node) {
93 if (node) {
94 _this.itemRefs.set(index, node);
95 }
96 else {
97 _this.itemRefs.delete(index);
98 }
99 },
100 });
101 }
102 return null;
103 };
104 _this.renderCreateItemMenuItem = function () {
105 if (_this.isCreateItemRendered(_this.state.createNewItem)) {
106 var _a = _this.state, activeItem = _a.activeItem, query = _a.query;
107 var trimmedQuery_1 = query.trim();
108 var handleClick = function (evt) {
109 _this.handleItemCreate(trimmedQuery_1, evt);
110 };
111 var isActive = isCreateNewItem(activeItem);
112 return _this.props.createNewItemRenderer(trimmedQuery_1, isActive, handleClick);
113 }
114 return null;
115 };
116 _this.handleItemCreate = function (query, evt) {
117 var _a, _b, _c, _d;
118 // we keep a cached createNewItem in state, but might as well recompute
119 // the result just to be sure it's perfectly in sync with the query.
120 var value = (_b = (_a = _this.props).createNewItemFromQuery) === null || _b === void 0 ? void 0 : _b.call(_a, query);
121 if (value != null) {
122 var newItems = Array.isArray(value) ? value : [value];
123 for (var _i = 0, newItems_1 = newItems; _i < newItems_1.length; _i++) {
124 var item = newItems_1[_i];
125 (_d = (_c = _this.props).onItemSelect) === null || _d === void 0 ? void 0 : _d.call(_c, item, evt);
126 }
127 _this.maybeResetQuery();
128 }
129 };
130 _this.handleItemSelect = function (item, event) {
131 var _a, _b;
132 _this.setActiveItem(item);
133 (_b = (_a = _this.props).onItemSelect) === null || _b === void 0 ? void 0 : _b.call(_a, item, event);
134 _this.maybeResetQuery();
135 };
136 _this.handlePaste = function (queries) {
137 var _a = _this.props, createNewItemFromQuery = _a.createNewItemFromQuery, onItemsPaste = _a.onItemsPaste;
138 var nextActiveItem;
139 var nextQueries = [];
140 // Find an exising item that exactly matches each pasted value, or
141 // create a new item if possible. Ignore unmatched values if creating
142 // items is disabled.
143 var pastedItemsToEmit = [];
144 for (var _i = 0, queries_1 = queries; _i < queries_1.length; _i++) {
145 var query = queries_1[_i];
146 var equalItem = getMatchingItem(query, _this.props);
147 if (equalItem !== undefined) {
148 nextActiveItem = equalItem;
149 pastedItemsToEmit.push(equalItem);
150 }
151 else if (_this.canCreateItems()) {
152 var value = createNewItemFromQuery === null || createNewItemFromQuery === void 0 ? void 0 : createNewItemFromQuery(query);
153 if (value !== undefined) {
154 var newItems = Array.isArray(value) ? value : [value];
155 pastedItemsToEmit.push.apply(pastedItemsToEmit, newItems);
156 }
157 }
158 else {
159 nextQueries.push(query);
160 }
161 }
162 // UX nicety: combine all unmatched queries into a single
163 // comma-separated query in the input, so we don't lose any information.
164 // And don't reset the active item; we'll do that ourselves below.
165 _this.setQuery(nextQueries.join(", "), false);
166 // UX nicety: update the active item if we matched with at least one
167 // existing item.
168 if (nextActiveItem !== undefined) {
169 _this.setActiveItem(nextActiveItem);
170 }
171 onItemsPaste === null || onItemsPaste === void 0 ? void 0 : onItemsPaste(pastedItemsToEmit);
172 };
173 _this.handleKeyDown = function (event) {
174 var _a, _b;
175 // eslint-disable-next-line deprecation/deprecation
176 var keyCode = event.keyCode;
177 if (keyCode === Keys.ARROW_UP || keyCode === Keys.ARROW_DOWN) {
178 event.preventDefault();
179 var nextActiveItem = _this.getNextActiveItem(keyCode === Keys.ARROW_UP ? -1 : 1);
180 if (nextActiveItem != null) {
181 _this.setActiveItem(nextActiveItem);
182 }
183 }
184 else if (keyCode === Keys.ENTER) {
185 _this.isEnterKeyPressed = true;
186 }
187 (_b = (_a = _this.props).onKeyDown) === null || _b === void 0 ? void 0 : _b.call(_a, event);
188 };
189 _this.handleKeyUp = function (event) {
190 var onKeyUp = _this.props.onKeyUp;
191 var activeItem = _this.state.activeItem;
192 // eslint-disable-next-line deprecation/deprecation
193 if (event.keyCode === Keys.ENTER && _this.isEnterKeyPressed) {
194 // We handle ENTER in keyup here to play nice with the Button component's keyboard
195 // clicking. Button is commonly used as the only child of Select. If we were to
196 // instead process ENTER on keydown, then Button would click itself on keyup and
197 // the Select popover would re-open.
198 event.preventDefault();
199 if (activeItem == null || isCreateNewItem(activeItem)) {
200 _this.handleItemCreate(_this.state.query, event);
201 }
202 else {
203 _this.handleItemSelect(activeItem, event);
204 }
205 _this.isEnterKeyPressed = false;
206 }
207 onKeyUp === null || onKeyUp === void 0 ? void 0 : onKeyUp(event);
208 };
209 _this.handleInputQueryChange = function (event) {
210 var _a, _b;
211 var query = event == null ? "" : event.target.value;
212 _this.setQuery(query);
213 (_b = (_a = _this.props).onQueryChange) === null || _b === void 0 ? void 0 : _b.call(_a, query, event);
214 };
215 var _c = props.query, query = _c === void 0 ? "" : _c;
216 var createNewItem = (_a = props.createNewItemFromQuery) === null || _a === void 0 ? void 0 : _a.call(props, query);
217 var filteredItems = getFilteredItems(query, props);
218 _this.state = {
219 activeItem: props.activeItem !== undefined
220 ? props.activeItem
221 : (_b = props.initialActiveItem) !== null && _b !== void 0 ? _b : getFirstEnabledItem(filteredItems, props.itemDisabled),
222 createNewItem: createNewItem,
223 filteredItems: filteredItems,
224 query: query,
225 };
226 return _this;
227 }
228 /** @deprecated no longer necessary now that the TypeScript parser supports type arguments on JSX element tags */
229 QueryList.ofType = function () {
230 return QueryList;
231 };
232 QueryList.prototype.render = function () {
233 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;
234 var _c = this.state, createNewItem = _c.createNewItem, spreadableState = __rest(_c, ["createNewItem"]);
235 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 })) }));
236 };
237 QueryList.prototype.componentDidUpdate = function (prevProps) {
238 var _this = this;
239 if (this.props.activeItem !== undefined && this.props.activeItem !== this.state.activeItem) {
240 this.shouldCheckActiveItemInViewport = true;
241 this.setState({ activeItem: this.props.activeItem });
242 }
243 if (this.props.query != null && this.props.query !== prevProps.query) {
244 // new query
245 this.setQuery(this.props.query, this.props.resetOnQuery, this.props);
246 }
247 else if (
248 // same query (or uncontrolled query), but items in the list changed
249 !Utils.shallowCompareKeys(this.props, prevProps, {
250 include: ["items", "itemListPredicate", "itemPredicate"],
251 })) {
252 this.setQuery(this.state.query);
253 }
254 if (this.shouldCheckActiveItemInViewport) {
255 // update scroll position immediately before repaint so DOM is accurate
256 // (latest filteredItems) and to avoid flicker.
257 this.requestAnimationFrame(function () { return _this.scrollActiveItemIntoView(); });
258 // reset the flag
259 this.shouldCheckActiveItemInViewport = false;
260 }
261 };
262 QueryList.prototype.scrollActiveItemIntoView = function () {
263 var scrollToActiveItem = this.props.scrollToActiveItem !== false;
264 var externalChangeToActiveItem = !executeItemsEqual(this.props.itemsEqual, getActiveItem(this.expectedNextActiveItem), getActiveItem(this.props.activeItem));
265 this.expectedNextActiveItem = null;
266 if (!scrollToActiveItem && externalChangeToActiveItem) {
267 return;
268 }
269 var activeElement = this.getActiveElement();
270 if (this.itemsParentRef != null && activeElement != null) {
271 var activeTop = activeElement.offsetTop, activeHeight = activeElement.offsetHeight;
272 var _a = this.itemsParentRef, parentOffsetTop = _a.offsetTop, parentScrollTop = _a.scrollTop, parentHeight = _a.clientHeight;
273 // compute padding on parent element to ensure we always leave space
274 var _b = this.getItemsParentPadding(), paddingTop = _b.paddingTop, paddingBottom = _b.paddingBottom;
275 // compute the two edges of the active item for comparison, including parent padding
276 var activeBottomEdge = activeTop + activeHeight + paddingBottom - parentOffsetTop;
277 var activeTopEdge = activeTop - paddingTop - parentOffsetTop;
278 if (activeBottomEdge >= parentScrollTop + parentHeight) {
279 // offscreen bottom: align bottom of item with bottom of viewport
280 this.itemsParentRef.scrollTop = activeBottomEdge + activeHeight - parentHeight;
281 }
282 else if (activeTopEdge <= parentScrollTop) {
283 // offscreen top: align top of item with top of viewport
284 this.itemsParentRef.scrollTop = activeTopEdge - activeHeight;
285 }
286 }
287 };
288 QueryList.prototype.setQuery = function (query, resetActiveItem, props) {
289 var _a;
290 if (resetActiveItem === void 0) { resetActiveItem = this.props.resetOnQuery; }
291 if (props === void 0) { props = this.props; }
292 var createNewItemFromQuery = props.createNewItemFromQuery;
293 this.shouldCheckActiveItemInViewport = true;
294 var hasQueryChanged = query !== this.state.query;
295 if (hasQueryChanged) {
296 (_a = props.onQueryChange) === null || _a === void 0 ? void 0 : _a.call(props, query);
297 }
298 // Leading and trailing whitespace can be confusing to display, so we remove it when passing it
299 // to functions dealing with data, like createNewItemFromQuery. But we need the unaltered user-typed
300 // query to remain in state to be able to render controlled text inputs properly.
301 var trimmedQuery = query.trim();
302 var filteredItems = getFilteredItems(trimmedQuery, props);
303 var createNewItem = createNewItemFromQuery != null && trimmedQuery !== "" ? createNewItemFromQuery(trimmedQuery) : undefined;
304 this.setState({ createNewItem: createNewItem, filteredItems: filteredItems, query: query });
305 // always reset active item if it's now filtered or disabled
306 var activeIndex = this.getActiveIndex(filteredItems);
307 var shouldUpdateActiveItem = resetActiveItem ||
308 activeIndex < 0 ||
309 isItemDisabled(getActiveItem(this.state.activeItem), activeIndex, props.itemDisabled);
310 if (shouldUpdateActiveItem) {
311 // if the `createNewItem` is first, that should be the first active item.
312 if (this.isCreateItemRendered(createNewItem) && this.isCreateItemFirst()) {
313 this.setActiveItem(getCreateNewItem());
314 }
315 else {
316 this.setActiveItem(getFirstEnabledItem(filteredItems, props.itemDisabled));
317 }
318 }
319 };
320 QueryList.prototype.setActiveItem = function (activeItem) {
321 var _a, _b, _c, _d;
322 this.expectedNextActiveItem = activeItem;
323 if (this.props.activeItem === undefined) {
324 // indicate that the active item may need to be scrolled into view after update.
325 this.shouldCheckActiveItemInViewport = true;
326 this.setState({ activeItem: activeItem });
327 }
328 if (isCreateNewItem(activeItem)) {
329 (_b = (_a = this.props).onActiveItemChange) === null || _b === void 0 ? void 0 : _b.call(_a, null, true);
330 }
331 else {
332 (_d = (_c = this.props).onActiveItemChange) === null || _d === void 0 ? void 0 : _d.call(_c, activeItem, false);
333 }
334 };
335 QueryList.prototype.getActiveElement = function () {
336 var _a;
337 var activeItem = this.state.activeItem;
338 if (this.itemsParentRef != null) {
339 if (isCreateNewItem(activeItem)) {
340 var index = this.isCreateItemFirst() ? 0 : this.state.filteredItems.length;
341 return this.itemsParentRef.children.item(index);
342 }
343 else {
344 var activeIndex = this.getActiveIndex();
345 return ((_a = this.itemRefs.get(activeIndex)) !== null && _a !== void 0 ? _a : this.itemsParentRef.children.item(activeIndex));
346 }
347 }
348 return undefined;
349 };
350 QueryList.prototype.getActiveIndex = function (items) {
351 if (items === void 0) { items = this.state.filteredItems; }
352 var activeItem = this.state.activeItem;
353 if (activeItem == null || isCreateNewItem(activeItem)) {
354 return -1;
355 }
356 // NOTE: this operation is O(n) so it should be avoided in render(). safe for events though.
357 for (var i = 0; i < items.length; ++i) {
358 if (executeItemsEqual(this.props.itemsEqual, items[i], activeItem)) {
359 return i;
360 }
361 }
362 return -1;
363 };
364 QueryList.prototype.getItemsParentPadding = function () {
365 // assert ref exists because it was checked before calling
366 var _a = getComputedStyle(this.itemsParentRef), paddingTop = _a.paddingTop, paddingBottom = _a.paddingBottom;
367 return {
368 paddingBottom: pxToNumber(paddingBottom),
369 paddingTop: pxToNumber(paddingTop),
370 };
371 };
372 /**
373 * Get the next enabled item, moving in the given direction from the start
374 * index. A `null` return value means no suitable item was found.
375 *
376 * @param direction amount to move in each iteration, typically +/-1
377 * @param startIndex item to start iteration
378 */
379 QueryList.prototype.getNextActiveItem = function (direction, startIndex) {
380 if (startIndex === void 0) { startIndex = this.getActiveIndex(); }
381 if (this.isCreateItemRendered(this.state.createNewItem)) {
382 var reachedCreate = (startIndex === 0 && direction === -1) ||
383 (startIndex === this.state.filteredItems.length - 1 && direction === 1);
384 if (reachedCreate) {
385 return getCreateNewItem();
386 }
387 }
388 return getFirstEnabledItem(this.state.filteredItems, this.props.itemDisabled, direction, startIndex);
389 };
390 /**
391 * @param createNewItem Checks if this item would match the current query. Cannot check this.state.createNewItem
392 * every time since state may not have been updated yet.
393 */
394 QueryList.prototype.isCreateItemRendered = function (createNewItem) {
395 return (this.canCreateItems() &&
396 this.state.query !== "" &&
397 // this check is unfortunately O(N) on the number of items, but
398 // alas, hiding the "Create Item" option when it exactly matches an
399 // existing item is much clearer.
400 !this.wouldCreatedItemMatchSomeExistingItem(createNewItem));
401 };
402 QueryList.prototype.isCreateItemFirst = function () {
403 return this.props.createNewItemPosition === "first";
404 };
405 QueryList.prototype.canCreateItems = function () {
406 return this.props.createNewItemFromQuery != null && this.props.createNewItemRenderer != null;
407 };
408 QueryList.prototype.wouldCreatedItemMatchSomeExistingItem = function (createNewItem) {
409 var _this = this;
410 // search only the filtered items, not the full items list, because we
411 // only need to check items that match the current query.
412 return this.state.filteredItems.some(function (item) {
413 var newItems = Array.isArray(createNewItem) ? createNewItem : [createNewItem];
414 return newItems.some(function (newItem) { return executeItemsEqual(_this.props.itemsEqual, item, newItem); });
415 });
416 };
417 QueryList.prototype.maybeResetQuery = function () {
418 if (this.props.resetOnSelect) {
419 this.setQuery("", true);
420 }
421 };
422 QueryList.displayName = "".concat(DISPLAYNAME_PREFIX, ".QueryList");
423 QueryList.defaultProps = {
424 disabled: false,
425 resetOnQuery: true,
426 };
427 return QueryList;
428}(AbstractComponent2));
429export { QueryList };
430function pxToNumber(value) {
431 return value == null ? 0 : parseInt(value.slice(0, -2), 10);
432}
433function getMatchingItem(query, _a) {
434 var items = _a.items, itemPredicate = _a.itemPredicate;
435 if (Utils.isFunction(itemPredicate)) {
436 // .find() doesn't exist in ES5. Alternative: use a for loop instead of
437 // .filter() so that we can return as soon as we find the first match.
438 for (var i = 0; i < items.length; i++) {
439 var item = items[i];
440 if (itemPredicate(query, item, i, true)) {
441 return item;
442 }
443 }
444 }
445 return undefined;
446}
447function getFilteredItems(query, _a) {
448 var items = _a.items, itemPredicate = _a.itemPredicate, itemListPredicate = _a.itemListPredicate;
449 if (Utils.isFunction(itemListPredicate)) {
450 // note that implementations can reorder the items here
451 return itemListPredicate(query, items);
452 }
453 else if (Utils.isFunction(itemPredicate)) {
454 return items.filter(function (item, index) { return itemPredicate(query, item, index); });
455 }
456 return items;
457}
458/** Wrap number around min/max values: if it exceeds one bound, return the other. */
459function wrapNumber(value, min, max) {
460 if (value < min) {
461 return max;
462 }
463 else if (value > max) {
464 return min;
465 }
466 return value;
467}
468function isItemDisabled(item, index, itemDisabled) {
469 if (itemDisabled == null || item == null) {
470 return false;
471 }
472 else if (Utils.isFunction(itemDisabled)) {
473 return itemDisabled(item, index);
474 }
475 return !!item[itemDisabled];
476}
477/**
478 * Get the next enabled item, moving in the given direction from the start
479 * index. A `null` return value means no suitable item was found.
480 *
481 * @param items the list of items
482 * @param itemDisabled callback to determine if a given item is disabled
483 * @param direction amount to move in each iteration, typically +/-1
484 * @param startIndex which index to begin moving from
485 */
486export function getFirstEnabledItem(items, itemDisabled, direction, startIndex) {
487 if (direction === void 0) { direction = 1; }
488 if (startIndex === void 0) { startIndex = items.length - 1; }
489 if (items.length === 0) {
490 return null;
491 }
492 // remember where we started to prevent an infinite loop
493 var index = startIndex;
494 var maxIndex = items.length - 1;
495 do {
496 // find first non-disabled item
497 index = wrapNumber(index + direction, 0, maxIndex);
498 if (!isItemDisabled(items[index], index, itemDisabled)) {
499 return items[index];
500 }
501 } while (index !== startIndex && startIndex !== -1);
502 return null;
503}
504//# sourceMappingURL=queryList.js.map
\No newline at end of file