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 */
16
17import * as React from "react";
18
19import { AbstractComponent2, DISPLAYNAME_PREFIX, Props, Keys, Menu, Utils } from "@blueprintjs/core";
20
21import {
22    executeItemsEqual,
23    getActiveItem,
24    getCreateNewItem,
25    ICreateNewItem,
26    IItemListRendererProps,
27    IItemModifiers,
28    IListItemsProps,
29    isCreateNewItem,
30    renderFilteredItems,
31} from "../../common";
32
33// eslint-disable-next-line deprecation/deprecation
34export type QueryListProps<T> = IQueryListProps<T>;
35/** @deprecated use QueryListProps */
36export interface IQueryListProps<T> extends IListItemsProps<T> {
37    /**
38     * Initial active item, useful if the parent component is controlling its selectedItem but
39     * not activeItem.
40     */
41    initialActiveItem?: T;
42
43    /**
44     * Callback invoked when user presses a key, after processing `QueryList`'s own key events
45     * (up/down to navigate active item). This callback is passed to `renderer` and (along with
46     * `onKeyUp`) can be attached to arbitrary content elements to support keyboard selection.
47     */
48    onKeyDown?: React.KeyboardEventHandler<HTMLElement>;
49
50    /**
51     * Callback invoked when user releases a key, after processing `QueryList`'s own key events
52     * (enter to select active item). This callback is passed to `renderer` and (along with
53     * `onKeyDown`) can be attached to arbitrary content elements to support keyboard selection.
54     */
55    onKeyUp?: React.KeyboardEventHandler<HTMLElement>;
56
57    /**
58     * Customize rendering of the component.
59     * Receives an object with props that should be applied to elements as necessary.
60     */
61    renderer: (listProps: IQueryListRendererProps<T>) => JSX.Element;
62
63    /**
64     * Whether the list is disabled.
65     *
66     * @default false
67     */
68    disabled?: boolean;
69}
70
71/**
72 * An object describing how to render a `QueryList`.
73 * A `QueryList` `renderer` receives this object as its sole argument.
74 */
75export interface IQueryListRendererProps<T> // Omit `createNewItem`, because it's used strictly for internal tracking.
76    extends Pick<IQueryListState<T>, "activeItem" | "filteredItems" | "query">,
77        Props {
78    /**
79     * Selection handler that should be invoked when a new item has been chosen,
80     * perhaps because the user clicked it.
81     */
82    handleItemSelect: (item: T, event?: React.SyntheticEvent<HTMLElement>) => void;
83
84    /**
85     * Handler that should be invoked when the user pastes one or more values.
86     *
87     * This callback will use `itemPredicate` with `exactMatch=true` to find a
88     * subset of `items` exactly matching the pasted `values` provided, then it
89     * will invoke `onItemsPaste` with those found items. Each pasted value that
90     * does not exactly match an item will be ignored.
91     *
92     * If creating items is enabled (by providing both `createNewItemFromQuery`
93     * and `createNewItemRenderer`), then pasted values that do not exactly
94     * match an existing item will emit a new item as created via
95     * `createNewItemFromQuery`.
96     *
97     * If `itemPredicate` returns multiple matching items for a particular query
98     * in `queries`, then only the first matching item will be emitted.
99     */
100    handlePaste: (queries: string[]) => void;
101
102    /**
103     * Keyboard handler for up/down arrow keys to shift the active item.
104     * Attach this handler to any element that should support this interaction.
105     */
106    handleKeyDown: React.KeyboardEventHandler<HTMLElement>;
107
108    /**
109     * Keyboard handler for enter key to select the active item.
110     * Attach this handler to any element that should support this interaction.
111     */
112    handleKeyUp: React.KeyboardEventHandler<HTMLElement>;
113
114    /**
115     * Change handler for query string. Attach this to an input element to allow
116     * `QueryList` to control the query.
117     */
118    handleQueryChange: React.ChangeEventHandler<HTMLInputElement>;
119
120    /** Rendered elements returned from `itemListRenderer` prop. */
121    itemList: React.ReactNode;
122}
123
124export interface IQueryListState<T> {
125    /** The currently focused item (for keyboard interactions). */
126    activeItem: T | ICreateNewItem | null;
127
128    /**
129     * The item returned from `createNewItemFromQuery(this.state.query)`, cached
130     * to avoid continuous reinstantions within `isCreateItemRendered`, where
131     * this element will be used to hide the "Create Item" option if its value
132     * matches the current `query`.
133     */
134    createNewItem: T | undefined;
135
136    /** The original `items` array filtered by `itemListPredicate` or `itemPredicate`. */
137    filteredItems: T[];
138
139    /** The current query string. */
140    query: string;
141}
142
143export class QueryList<T> extends AbstractComponent2<QueryListProps<T>, IQueryListState<T>> {
144    public static displayName = `${DISPLAYNAME_PREFIX}.QueryList`;
145
146    public static defaultProps = {
147        disabled: false,
148        resetOnQuery: true,
149    };
150
151    public static ofType<U>() {
152        return QueryList as new (props: QueryListProps<U>) => QueryList<U>;
153    }
154
155    private itemsParentRef?: HTMLElement | null;
156
157    private refHandlers = {
158        itemsParent: (ref: HTMLElement | null) => (this.itemsParentRef = ref),
159    };
160
161    /**
162     * Flag indicating that we should check whether selected item is in viewport
163     * after rendering, typically because of keyboard change. Set to `true` when
164     * manipulating state in a way that may cause active item to scroll away.
165     */
166    private shouldCheckActiveItemInViewport = false;
167
168    /**
169     * The item that we expect to be the next selected active item (based on click
170     * or key interactions). When scrollToActiveItem = false, used to detect if
171     * an unexpected external change to the active item has been made.
172     */
173    private expectedNextActiveItem: T | ICreateNewItem | null = null;
174
175    /**
176     * Flag which is set to true while in between an ENTER "keydown" event and its
177     * corresponding "keyup" event.
178     *
179     * When entering text via an IME (https://en.wikipedia.org/wiki/Input_method),
180     * the ENTER key is pressed to confirm the character(s) to be input from a list
181     * of options. The operating system intercepts the ENTER "keydown" event and
182     * prevents it from propagating to the application, but "keyup" is still
183     * fired, triggering a spurious event which this component does not expect.
184     *
185     * To work around this quirk, we keep track of "real" key presses by setting
186     * this flag in handleKeyDown.
187     */
188    private isEnterKeyPressed = false;
189
190    public constructor(props: QueryListProps<T>, context?: any) {
191        super(props, context);
192
193        const { query = "" } = props;
194        const createNewItem = props.createNewItemFromQuery?.(query);
195        const filteredItems = getFilteredItems(query, props);
196
197        this.state = {
198            activeItem:
199                props.activeItem !== undefined
200                    ? props.activeItem
201                    : props.initialActiveItem ?? getFirstEnabledItem(filteredItems, props.itemDisabled),
202            createNewItem,
203            filteredItems,
204            query,
205        };
206    }
207
208    public render() {
209        const { className, items, renderer, itemListRenderer = this.renderItemList } = this.props;
210        const { createNewItem, ...spreadableState } = this.state;
211        return renderer({
212            ...spreadableState,
213            className,
214            handleItemSelect: this.handleItemSelect,
215            handleKeyDown: this.handleKeyDown,
216            handleKeyUp: this.handleKeyUp,
217            handlePaste: this.handlePaste,
218            handleQueryChange: this.handleInputQueryChange,
219            itemList: itemListRenderer({
220                ...spreadableState,
221                items,
222                itemsParentRef: this.refHandlers.itemsParent,
223                renderCreateItem: this.renderCreateItemMenuItem,
224                renderItem: this.renderItem,
225            }),
226        });
227    }
228
229    public componentDidUpdate(prevProps: QueryListProps<T>) {
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
235        if (this.props.query != null && this.props.query !== prevProps.query) {
236            // new query
237            this.setQuery(this.props.query, this.props.resetOnQuery, this.props);
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        ) {
244            this.setQuery(this.state.query);
245        }
246
247        if (this.shouldCheckActiveItemInViewport) {
248            // update scroll position immediately before repaint so DOM is accurate
249            // (latest filteredItems) and to avoid flicker.
250            this.requestAnimationFrame(() => this.scrollActiveItemIntoView());
251            // reset the flag
252            this.shouldCheckActiveItemInViewport = false;
253        }
254    }
255
256    public scrollActiveItemIntoView() {
257        const scrollToActiveItem = this.props.scrollToActiveItem !== false;
258        const externalChangeToActiveItem = !executeItemsEqual(
259            this.props.itemsEqual,
260            getActiveItem(this.expectedNextActiveItem),
261            getActiveItem(this.props.activeItem),
262        );
263        this.expectedNextActiveItem = null;
264
265        if (!scrollToActiveItem && externalChangeToActiveItem) {
266            return;
267        }
268
269        const activeElement = this.getActiveElement();
270        if (this.itemsParentRef != null && activeElement != null) {
271            const { offsetTop: activeTop, offsetHeight: activeHeight } = activeElement;
272            const {
273                offsetTop: parentOffsetTop,
274                scrollTop: parentScrollTop,
275                clientHeight: parentHeight,
276            } = this.itemsParentRef;
277            // compute padding on parent element to ensure we always leave space
278            const { paddingTop, paddingBottom } = this.getItemsParentPadding();
279
280            // compute the two edges of the active item for comparison, including parent padding
281            const activeBottomEdge = activeTop + activeHeight + paddingBottom - parentOffsetTop;
282            const activeTopEdge = activeTop - paddingTop - parentOffsetTop;
283
284            if (activeBottomEdge >= parentScrollTop + parentHeight) {
285                // offscreen bottom: align bottom of item with bottom of viewport
286                this.itemsParentRef.scrollTop = activeBottomEdge + activeHeight - parentHeight;
287            } else if (activeTopEdge <= parentScrollTop) {
288                // offscreen top: align top of item with top of viewport
289                this.itemsParentRef.scrollTop = activeTopEdge - activeHeight;
290            }
291        }
292    }
293
294    public setQuery(query: string, resetActiveItem = this.props.resetOnQuery, props = this.props) {
295        const { createNewItemFromQuery } = props;
296
297        this.shouldCheckActiveItemInViewport = true;
298        const hasQueryChanged = query !== this.state.query;
299        if (hasQueryChanged) {
300            props.onQueryChange?.(query);
301        }
302
303        // Leading and trailing whitespace can be confusing to display, so we remove it when passing it
304        // to functions dealing with data, like createNewItemFromQuery. But we need the unaltered user-typed
305        // query to remain in state to be able to render controlled text inputs properly.
306        const trimmedQuery = query.trim();
307        const filteredItems = getFilteredItems(trimmedQuery, props);
308        const createNewItem =
309            createNewItemFromQuery != null && trimmedQuery !== "" ? createNewItemFromQuery(trimmedQuery) : undefined;
310        this.setState({ createNewItem, filteredItems, query });
311
312        // always reset active item if it's now filtered or disabled
313        const activeIndex = this.getActiveIndex(filteredItems);
314        const shouldUpdateActiveItem =
315            resetActiveItem ||
316            activeIndex < 0 ||
317            isItemDisabled(getActiveItem(this.state.activeItem), activeIndex, props.itemDisabled);
318
319        if (shouldUpdateActiveItem) {
320            // if the `createNewItem` is first, that should be the first active item.
321            if (this.isCreateItemRendered() && this.isCreateItemFirst()) {
322                this.setActiveItem(getCreateNewItem());
323            } else {
324                this.setActiveItem(getFirstEnabledItem(filteredItems, props.itemDisabled));
325            }
326        }
327    }
328
329    public setActiveItem(activeItem: T | ICreateNewItem | null) {
330        this.expectedNextActiveItem = activeItem;
331        if (this.props.activeItem === undefined) {
332            // indicate that the active item may need to be scrolled into view after update.
333            this.shouldCheckActiveItemInViewport = true;
334            this.setState({ activeItem });
335        }
336
337        if (isCreateNewItem(activeItem)) {
338            this.props.onActiveItemChange?.(null, true);
339        } else {
340            this.props.onActiveItemChange?.(activeItem, false);
341        }
342    }
343
344    /** default `itemListRenderer` implementation */
345    private renderItemList = (listProps: IItemListRendererProps<T>) => {
346        const { initialContent, noResults } = this.props;
347
348        // omit noResults if createNewItemFromQuery and createNewItemRenderer are both supplied, and query is not empty
349        const createItemView = listProps.renderCreateItem();
350        const maybeNoResults = createItemView != null ? null : noResults;
351        const menuContent = renderFilteredItems(listProps, maybeNoResults, initialContent);
352        if (menuContent == null && createItemView == null) {
353            return null;
354        }
355        const createFirst = this.isCreateItemFirst();
356        return (
357            <Menu ulRef={listProps.itemsParentRef}>
358                {createFirst && createItemView}
359                {menuContent}
360                {!createFirst && createItemView}
361            </Menu>
362        );
363    };
364
365    /** wrapper around `itemRenderer` to inject props */
366    private renderItem = (item: T, index: number) => {
367        if (this.props.disabled !== true) {
368            const { activeItem, query } = this.state;
369            const matchesPredicate = this.state.filteredItems.indexOf(item) >= 0;
370            const modifiers: IItemModifiers = {
371                active: executeItemsEqual(this.props.itemsEqual, getActiveItem(activeItem), item),
372                disabled: isItemDisabled(item, index, this.props.itemDisabled),
373                matchesPredicate,
374            };
375            return this.props.itemRenderer(item, {
376                handleClick: e => this.handleItemSelect(item, e),
377                index,
378                modifiers,
379                query,
380            });
381        }
382
383        return null;
384    };
385
386    private renderCreateItemMenuItem = () => {
387        if (this.isCreateItemRendered()) {
388            const { activeItem, query } = this.state;
389            const trimmedQuery = query.trim();
390            const handleClick: React.MouseEventHandler<HTMLElement> = evt => {
391                this.handleItemCreate(trimmedQuery, evt);
392            };
393            const isActive = isCreateNewItem(activeItem);
394            return this.props.createNewItemRenderer!(trimmedQuery, isActive, handleClick);
395        }
396
397        return null;
398    };
399
400    private getActiveElement() {
401        const { activeItem } = this.state;
402        if (this.itemsParentRef != null) {
403            if (isCreateNewItem(activeItem)) {
404                const index = this.isCreateItemFirst() ? 0 : this.state.filteredItems.length;
405                return this.itemsParentRef.children.item(index) as HTMLElement;
406            } else {
407                const activeIndex = this.getActiveIndex();
408                return this.itemsParentRef.children.item(activeIndex) as HTMLElement;
409            }
410        }
411        return undefined;
412    }
413
414    private getActiveIndex(items = this.state.filteredItems) {
415        const { activeItem } = this.state;
416        if (activeItem == null || isCreateNewItem(activeItem)) {
417            return -1;
418        }
419        // NOTE: this operation is O(n) so it should be avoided in render(). safe for events though.
420        for (let i = 0; i < items.length; ++i) {
421            if (executeItemsEqual(this.props.itemsEqual, items[i], activeItem)) {
422                return i;
423            }
424        }
425        return -1;
426    }
427
428    private getItemsParentPadding() {
429        // assert ref exists because it was checked before calling
430        const { paddingTop, paddingBottom } = getComputedStyle(this.itemsParentRef!);
431        return {
432            paddingBottom: pxToNumber(paddingBottom),
433            paddingTop: pxToNumber(paddingTop),
434        };
435    }
436
437    private handleItemCreate = (query: string, evt?: React.SyntheticEvent<HTMLElement>) => {
438        // we keep a cached createNewItem in state, but might as well recompute
439        // the result just to be sure it's perfectly in sync with the query.
440        const item = this.props.createNewItemFromQuery?.(query);
441        if (item != null) {
442            this.props.onItemSelect?.(item, evt);
443            this.maybeResetQuery();
444        }
445    };
446
447    private handleItemSelect = (item: T, event?: React.SyntheticEvent<HTMLElement>) => {
448        this.setActiveItem(item);
449        this.props.onItemSelect?.(item, event);
450        this.maybeResetQuery();
451    };
452
453    private handlePaste = (queries: string[]) => {
454        const { createNewItemFromQuery, onItemsPaste } = this.props;
455
456        let nextActiveItem: T | undefined;
457        const nextQueries = [];
458
459        // Find an exising item that exactly matches each pasted value, or
460        // create a new item if possible. Ignore unmatched values if creating
461        // items is disabled.
462        const pastedItemsToEmit = [];
463
464        for (const query of queries) {
465            const equalItem = getMatchingItem(query, this.props);
466
467            if (equalItem !== undefined) {
468                nextActiveItem = equalItem;
469                pastedItemsToEmit.push(equalItem);
470            } else if (this.canCreateItems()) {
471                const newItem = createNewItemFromQuery?.(query);
472                if (newItem !== undefined) {
473                    pastedItemsToEmit.push(newItem);
474                }
475            } else {
476                nextQueries.push(query);
477            }
478        }
479
480        // UX nicety: combine all unmatched queries into a single
481        // comma-separated query in the input, so we don't lose any information.
482        // And don't reset the active item; we'll do that ourselves below.
483        this.setQuery(nextQueries.join(", "), false);
484
485        // UX nicety: update the active item if we matched with at least one
486        // existing item.
487        if (nextActiveItem !== undefined) {
488            this.setActiveItem(nextActiveItem);
489        }
490
491        onItemsPaste?.(pastedItemsToEmit);
492    };
493
494    private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
495        // eslint-disable-next-line deprecation/deprecation
496        const { keyCode } = event;
497        if (keyCode === Keys.ARROW_UP || keyCode === Keys.ARROW_DOWN) {
498            event.preventDefault();
499            const nextActiveItem = this.getNextActiveItem(keyCode === Keys.ARROW_UP ? -1 : 1);
500            if (nextActiveItem != null) {
501                this.setActiveItem(nextActiveItem);
502            }
503        } else if (keyCode === Keys.ENTER) {
504            this.isEnterKeyPressed = true;
505        }
506
507        this.props.onKeyDown?.(event);
508    };
509
510    private handleKeyUp = (event: React.KeyboardEvent<HTMLElement>) => {
511        const { onKeyUp } = this.props;
512        const { activeItem } = this.state;
513
514        // eslint-disable-next-line deprecation/deprecation
515        if (event.keyCode === Keys.ENTER && this.isEnterKeyPressed) {
516            // We handle ENTER in keyup here to play nice with the Button component's keyboard
517            // clicking. Button is commonly used as the only child of Select. If we were to
518            // instead process ENTER on keydown, then Button would click itself on keyup and
519            // the Select popover would re-open.
520            event.preventDefault();
521            if (activeItem == null || isCreateNewItem(activeItem)) {
522                this.handleItemCreate(this.state.query, event);
523            } else {
524                this.handleItemSelect(activeItem, event);
525            }
526            this.isEnterKeyPressed = false;
527        }
528
529        onKeyUp?.(event);
530    };
531
532    private handleInputQueryChange = (event?: React.ChangeEvent<HTMLInputElement>) => {
533        const query = event == null ? "" : event.target.value;
534        this.setQuery(query);
535        this.props.onQueryChange?.(query, event);
536    };
537
538    /**
539     * Get the next enabled item, moving in the given direction from the start
540     * index. A `null` return value means no suitable item was found.
541     *
542     * @param direction amount to move in each iteration, typically +/-1
543     * @param startIndex item to start iteration
544     */
545    private getNextActiveItem(direction: number, startIndex = this.getActiveIndex()): T | ICreateNewItem | null {
546        if (this.isCreateItemRendered()) {
547            const reachedCreate =
548                (startIndex === 0 && direction === -1) ||
549                (startIndex === this.state.filteredItems.length - 1 && direction === 1);
550            if (reachedCreate) {
551                return getCreateNewItem();
552            }
553        }
554        return getFirstEnabledItem(this.state.filteredItems, this.props.itemDisabled, direction, startIndex);
555    }
556
557    private isCreateItemRendered(): boolean {
558        return (
559            this.canCreateItems() &&
560            this.state.query !== "" &&
561            // this check is unfortunately O(N) on the number of items, but
562            // alas, hiding the "Create Item" option when it exactly matches an
563            // existing item is much clearer.
564            !this.wouldCreatedItemMatchSomeExistingItem()
565        );
566    }
567
568    private isCreateItemFirst(): boolean {
569        return this.props.createNewItemPosition === "first";
570    }
571
572    private canCreateItems(): boolean {
573        return this.props.createNewItemFromQuery != null && this.props.createNewItemRenderer != null;
574    }
575
576    private wouldCreatedItemMatchSomeExistingItem() {
577        // search only the filtered items, not the full items list, because we
578        // only need to check items that match the current query.
579        return this.state.filteredItems.some(item =>
580            executeItemsEqual(this.props.itemsEqual, item, this.state.createNewItem),
581        );
582    }
583
584    private maybeResetQuery() {
585        if (this.props.resetOnSelect) {
586            this.setQuery("", true);
587        }
588    }
589}
590
591function pxToNumber(value: string | null) {
592    return value == null ? 0 : parseInt(value.slice(0, -2), 10);
593}
594
595function getMatchingItem<T>(query: string, { items, itemPredicate }: QueryListProps<T>): T | undefined {
596    if (Utils.isFunction(itemPredicate)) {
597        // .find() doesn't exist in ES5. Alternative: use a for loop instead of
598        // .filter() so that we can return as soon as we find the first match.
599        for (let i = 0; i < items.length; i++) {
600            const item = items[i];
601            if (itemPredicate(query, item, i, true)) {
602                return item;
603            }
604        }
605    }
606    return undefined;
607}
608
609function getFilteredItems<T>(query: string, { items, itemPredicate, itemListPredicate }: QueryListProps<T>) {
610    if (Utils.isFunction(itemListPredicate)) {
611        // note that implementations can reorder the items here
612        return itemListPredicate(query, items);
613    } else if (Utils.isFunction(itemPredicate)) {
614        return items.filter((item, index) => itemPredicate(query, item, index));
615    }
616    return items;
617}
618
619/** Wrap number around min/max values: if it exceeds one bound, return the other. */
620function wrapNumber(value: number, min: number, max: number) {
621    if (value < min) {
622        return max;
623    } else if (value > max) {
624        return min;
625    }
626    return value;
627}
628
629function isItemDisabled<T>(item: T | null, index: number, itemDisabled?: IListItemsProps<T>["itemDisabled"]) {
630    if (itemDisabled == null || item == null) {
631        return false;
632    } else if (Utils.isFunction(itemDisabled)) {
633        return itemDisabled(item, index);
634    }
635    return !!item[itemDisabled];
636}
637
638/**
639 * Get the next enabled item, moving in the given direction from the start
640 * index. A `null` return value means no suitable item was found.
641 *
642 * @param items the list of items
643 * @param itemDisabled callback to determine if a given item is disabled
644 * @param direction amount to move in each iteration, typically +/-1
645 * @param startIndex which index to begin moving from
646 */
647export function getFirstEnabledItem<T>(
648    items: T[],
649    itemDisabled?: keyof T | ((item: T, index: number) => boolean),
650    direction = 1,
651    startIndex = items.length - 1,
652): T | ICreateNewItem | null {
653    if (items.length === 0) {
654        return null;
655    }
656    // remember where we started to prevent an infinite loop
657    let index = startIndex;
658    const maxIndex = items.length - 1;
659    do {
660        // find first non-disabled item
661        index = wrapNumber(index + direction, 0, maxIndex);
662        if (!isItemDisabled(items[index], index, itemDisabled)) {
663            return items[index];
664        }
665    } while (index !== startIndex && startIndex !== -1);
666    return null;
667}
668