UNPKG

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