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