From 258ad6986608e88a27410fef9c84102979068014 Mon Sep 17 00:00:00 2001 From: Gilad Gray Date: Thu, 15 Mar 2018 14:24:55 -0700 Subject: [PATCH 01/12] pull initialContent, noResults, itemListRenderer up to QueryList props menuRenderer => itemListRenderer --- .../select/src/components/omnibar/omnibar.tsx | 11 ++----- .../src/components/query-list/queryList.tsx | 31 +++++++++++++++++-- .../src/components/select/multiSelect.tsx | 8 ----- .../select/src/components/select/select.tsx | 14 --------- .../select/src/components/select/suggest.tsx | 3 -- 5 files changed, 31 insertions(+), 36 deletions(-) diff --git a/packages/select/src/components/omnibar/omnibar.tsx b/packages/select/src/components/omnibar/omnibar.tsx index db5283b660..23d4360ea4 100644 --- a/packages/select/src/components/omnibar/omnibar.tsx +++ b/packages/select/src/components/omnibar/omnibar.tsx @@ -24,14 +24,6 @@ import * as Classes from "../../common/classes"; import { IListItemsProps, IQueryListRendererProps, QueryList } from "../query-list/queryList"; export interface IOmnibarProps extends IListItemsProps { - /** - * React child to render when query is empty. - */ - initialContent?: React.ReactChild; - - /** React child to render when filtering items returns zero results. */ - noResults?: React.ReactChild; - /** * Props to spread to `InputGroup`. All props are supported except `ref` (use `inputRef` instead). * If you want to control the filter input, you can pass `value` and `onChange` here @@ -90,11 +82,12 @@ export class Omnibar extends React.PureComponent, IOmnibarSt public render() { // omit props specific to this component, spread the rest. - const { initialContent, isOpen, inputProps, noResults, overlayProps, ...restProps } = this.props; + const { initialContent = null, isOpen, inputProps, overlayProps, ...restProps } = this.props; return ( extends IProps { * This method can reorder, add, or remove items at will. * (Supports filter algorithms that operate on the entire set, rather than individual items.) * - * If defined with `itemPredicate`, this prop takes priority and the other will be ignored. + * If `itemPredicate` is also defined, this prop takes priority and the other will be ignored. */ itemListPredicate?: ItemListPredicate; @@ -29,7 +30,7 @@ export interface IListItemsProps extends IProps { * This method will be invoked once for each item, so it should be performant. For more complex * queries, use `itemListPredicate` to operate once on the entire array. * - * If defined with `itemListPredicate`, this prop will be ignored. + * This prop is ignored if `itemListPredicate` is also defined. */ itemPredicate?: ItemPredicate; @@ -40,6 +41,32 @@ export interface IListItemsProps extends IProps { */ itemRenderer: ItemRenderer; + /** + * Custom renderer for the contents of the dropdown. + * + * The default implementation invokes `itemRenderer` for each item that passes the predicate + * and wraps them all in a `Menu` element. If the query is empty then `initialContent` is returned, + * and if all items are filtered away then `noResults` is returned. + */ + itemListRenderer?: MenuRenderer; + + /** + * React content to render when query is empty. + * If omitted, all items will be rendered (or result of `itemListPredicate` with empty query). + * If explicit `null`, nothing will be rendered when query is empty. + * + * This prop is ignored if a custom `menuRenderer` is supplied. + */ + initialContent?: React.ReactNode | null; + + /** + * React content to render when filtering items returns zero results. + * If omitted, nothing will be rendered in this case. + * + * This prop is ignored if a custom `menuRenderer` is supplied. + */ + noResults?: React.ReactNode; + /** * Callback invoked when an item from the list is selected, * typically by clicking or pressing `enter` key. diff --git a/packages/select/src/components/select/multiSelect.tsx b/packages/select/src/components/select/multiSelect.tsx index 5534b98c4f..64890e33f6 100644 --- a/packages/select/src/components/select/multiSelect.tsx +++ b/packages/select/src/components/select/multiSelect.tsx @@ -24,14 +24,6 @@ export interface IMultiSelectProps extends IListItemsProps { /** Controlled selected values. */ selectedItems?: T[]; - /** - * React child to render when query is empty. - */ - initialContent?: React.ReactChild; - - /** React child to render when filtering items returns zero results. */ - noResults?: React.ReactChild; - /** * Whether the popover opens on key down or when `TagInput` is focused. * @default false diff --git a/packages/select/src/components/select/select.tsx b/packages/select/src/components/select/select.tsx index 30b1e34ec4..81c24ff2b7 100644 --- a/packages/select/src/components/select/select.tsx +++ b/packages/select/src/components/select/select.tsx @@ -21,7 +21,6 @@ import { Utils, } from "@blueprintjs/core"; import * as Classes from "../../common/classes"; -import { IMenuRendererProps, MenuRenderer } from "../../common/menuRenderer"; import { IListItemsProps, IQueryListRendererProps, QueryList } from "../query-list/queryList"; export interface ISelectProps extends IListItemsProps { @@ -32,11 +31,6 @@ export interface ISelectProps extends IListItemsProps { */ filterable?: boolean; - /** - * React child to render when query is empty. - */ - initialContent?: React.ReactChild; - /** * Whether the component is non-interactive. * Note that you'll also need to disable the component's children, if appropriate. @@ -44,14 +38,6 @@ export interface ISelectProps extends IListItemsProps { */ disabled?: boolean; - /** React child to render when filtering items returns zero results. */ - noResults?: React.ReactChild; - - /** - * Custom renderer for the contents of the dropdown. - */ - menuRenderer?: MenuRenderer; - /** * Props to spread to `InputGroup`. All props are supported except `ref` (use `inputRef` instead). * If you want to control the filter input, you can pass `value` and `onChange` here diff --git a/packages/select/src/components/select/suggest.tsx b/packages/select/src/components/select/suggest.tsx index 948bfaa985..8091504ec2 100644 --- a/packages/select/src/components/select/suggest.tsx +++ b/packages/select/src/components/select/suggest.tsx @@ -38,9 +38,6 @@ export interface ISuggestProps extends IListItemsProps { /** Custom renderer to transform an item into a string for the input value. */ inputValueRenderer: (item: T) => string; - /** React child to render when filtering items returns zero results. */ - noResults?: string | JSX.Element; - /** * Whether the popover opens on key down or when the input is focused. * @default false From 81d189580c1ea8bd6d81a08a2fa5c682bb68cbca Mon Sep 17 00:00:00 2001 From: Gilad Gray Date: Thu, 15 Mar 2018 14:27:59 -0700 Subject: [PATCH 02/12] QueryList renderer receives rendered `itemList` ReactNode add filteredItems to renderer props. itemListRenderer is now the only one with access to renderItem/items/itemsParentRef. the default itemListRenderer produces a Menu using filteredItems (to preserve arrow keys order). --- packages/select/src/common/menuRenderer.ts | 13 ++- .../src/components/query-list/queryList.tsx | 109 ++++++++++-------- 2 files changed, 74 insertions(+), 48 deletions(-) diff --git a/packages/select/src/common/menuRenderer.ts b/packages/select/src/common/menuRenderer.ts index 225667de27..32c10a2b6c 100644 --- a/packages/select/src/common/menuRenderer.ts +++ b/packages/select/src/common/menuRenderer.ts @@ -9,7 +9,16 @@ * A `menuRenderer` receives this object as its sole argument. */ export interface IMenuRendererProps { - /** Array of all items in the list. */ + /** + * Array of items filtered by `itemListPredicate` or `itemPredicate`. + * See `items` for the full list of items. + */ + filteredItems: T[]; + + /** + * Array of all items in the list. + * See `filteredItems` for a filtered array based on `query` and predicate props. + */ items: T[]; /** @@ -18,7 +27,7 @@ export interface IMenuRendererProps { query: string; /** - * A ref handler that should be attached to the menu's outermost HTML element. + * A ref handler that should be attached to the parent HTML element of the menu items. * This is required for the active item to scroll into view automatically. */ itemsParentRef: (ref: HTMLElement | null) => void; diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index 5769acdd0c..48bd2800cc 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -6,7 +6,7 @@ import * as React from "react"; -import { IProps, Keys, Utils } from "@blueprintjs/core"; +import { IProps, Keys, Menu, Utils } from "@blueprintjs/core"; import { IItemModifiers, ItemRenderer } from "../../common/itemRenderer"; import { IMenuRendererProps, MenuRenderer } from "../../common/menuRenderer"; import { ItemListPredicate, ItemPredicate } from "../../common/predicate"; @@ -116,8 +116,16 @@ export interface IQueryListProps extends IListItemsProps { query: string; } -/** Interface for object passed to `QueryList` `renderer` function. */ +/** + * An object describing how to render a `QueryList`. + * A `QueryList` `renderer` receives this object as its sole argument. + */ export interface IQueryListRendererProps extends IProps { + /** + * Array of items filtered by `itemListPredicate` or `itemPredicate`. + */ + filteredItems: T[]; + /** * Selection handler that should be invoked when a new item has been chosen, * perhaps because the user clicked it. @@ -136,29 +144,10 @@ export interface IQueryListRendererProps extends IProps { */ handleKeyUp: React.KeyboardEventHandler; - /** - * Call this function to render an `item`. - * `QueryList` will retrieve modifiers for the item and delegate to `itemRenderer` prop for the actual rendering. - * The second parameter `index` is optional here; if provided, it will be passed through `itemRenderer` props. - */ - renderItem: (item: T, index?: number) => JSX.Element | null; + /** Rendered elements returned from `menuRenderer` prop. */ + itemList: React.ReactNode; - /** - * Array of all (unfiltered) items in the list. - * See `filteredItems` for a filtered array based on `query`. - */ - items: T[]; - - /** - * A ref handler that should be applied to the HTML element that contains the rendererd items. - * This is required for the `QueryList` to scroll the active item into view automatically. - */ - itemsParentRef: (ref: HTMLElement | null) => void; - - /** - * Controlled query string. Attach an `onChange` handler to the relevant - * element to control this prop from your application's state. - */ + /** The current query string. */ query: string; } @@ -173,6 +162,22 @@ export class QueryList extends React.Component, IQueryList return QueryList as new (props: IQueryListProps) => QueryList; } + /** + * Helper method for rendering `props.filteredItems`, with optional support for `noResults` + * (when filtered items is empty) and `initialContent` (when query is empty). + */ + public static renderFilteredItems( + props: IMenuRendererProps, + noResults?: React.ReactNode, + initialContent?: React.ReactNode | null, + ): React.ReactNode { + if (props.query.length === 0 && initialContent !== undefined) { + return initialContent; + } + const items = props.filteredItems.map(props.renderItem).filter(item => item != null); + return items.length > 0 ? items : noResults; + } + private itemsParentRef?: HTMLElement | null; private refHandlers = { itemsParent: (ref: HTMLElement | null) => (this.itemsParentRef = ref), @@ -185,16 +190,22 @@ export class QueryList extends React.Component, IQueryList private shouldCheckActiveItemInViewport: boolean = false; public render() { - const { className, items, renderer, query } = this.props; + const { className, items, renderer, query, itemListRenderer = this.defaultMenuRenderer } = this.props; + const { filteredItems } = this.state; return renderer({ className, + filteredItems, handleItemSelect: this.handleItemSelect, handleKeyDown: this.handleKeyDown, handleKeyUp: this.handleKeyUp, - items, - itemsParentRef: this.refHandlers.itemsParent, + itemList: itemListRenderer({ + filteredItems, + items, + itemsParentRef: this.refHandlers.itemsParent, + query, + renderItem: this.renderItem, + }), query, - renderItem: this.renderItem, }); } @@ -258,6 +269,30 @@ export class QueryList extends React.Component, IQueryList } } + private defaultMenuRenderer = (menuProps: IMenuRendererProps) => { + const { initialContent, noResults } = this.props; + const menuContent = QueryList.renderFilteredItems(menuProps, noResults, initialContent); + return {menuContent}; + }; + + private renderItem = (item: T, index?: number) => { + const { activeItem, itemListPredicate, itemPredicate = () => true, query } = this.props; + const matchesPredicate = Utils.isFunction(itemListPredicate) + ? this.state.filteredItems.indexOf(item) >= 0 + : itemPredicate(query, item, index); + const modifiers: IItemModifiers = { + active: activeItem === item, + disabled: false, + matchesPredicate, + }; + return this.props.itemRenderer(item, { + handleClick: e => this.handleItemSelect(item, e), + index, + modifiers, + query, + }); + }; + private getActiveElement() { if (this.itemsParentRef != null) { return this.itemsParentRef.children.item(this.getActiveIndex()) as HTMLElement; @@ -322,24 +357,6 @@ export class QueryList extends React.Component, IQueryList const nextActiveIndex = Utils.clamp(this.getActiveIndex() + direction, 0, maxIndex); Utils.safeInvoke(this.props.onActiveItemChange, filteredItems[nextActiveIndex]); } - - private renderItem = (item: T, index?: number) => { - const { activeItem, itemListPredicate, itemPredicate = () => true, query } = this.props; - const matchesPredicate = Utils.isFunction(itemListPredicate) - ? this.state.filteredItems.indexOf(item) >= 0 - : itemPredicate(query, item, index); - const modifiers: IItemModifiers = { - active: activeItem === item, - disabled: false, - matchesPredicate, - }; - return this.props.itemRenderer(item, { - handleClick: e => this.handleItemSelect(item, e), - index, - modifiers, - query, - }); - }; } function pxToNumber(value: string | null) { From c57295d269c06a82c12be9adedca58e80a27f77c Mon Sep 17 00:00:00 2001 From: Gilad Gray Date: Thu, 15 Mar 2018 14:28:34 -0700 Subject: [PATCH 03/12] delete repeated rendering code from components, use listProps.itemList instead --- .../select/src/components/omnibar/omnibar.tsx | 25 +------------ .../src/components/select/multiSelect.tsx | 12 +------ .../select/src/components/select/select.tsx | 35 +++---------------- .../select/src/components/select/suggest.tsx | 8 +---- 4 files changed, 7 insertions(+), 73 deletions(-) diff --git a/packages/select/src/components/omnibar/omnibar.tsx b/packages/select/src/components/omnibar/omnibar.tsx index 23d4360ea4..a0e7c30e52 100644 --- a/packages/select/src/components/omnibar/omnibar.tsx +++ b/packages/select/src/components/omnibar/omnibar.tsx @@ -15,7 +15,6 @@ import { InputGroup, IOverlayableProps, IOverlayProps, - Menu, Overlay, Utils, } from "@blueprintjs/core"; @@ -131,34 +130,12 @@ export class Omnibar extends React.PureComponent, IOmnibarSt {...inputProps} onChange={this.handleQueryChange} /> - {this.maybeRenderMenu(listProps)} + {listProps.itemList} ); }; - private renderItems({ items, renderItem }: IQueryListRendererProps) { - const renderedItems = items.map(renderItem).filter(item => item != null); - return renderedItems.length > 0 ? renderedItems : this.props.noResults; - } - - private maybeRenderMenu(listProps: IQueryListRendererProps) { - const { initialContent } = this.props; - let menuChildren: any; - - if (!this.isQueryEmpty()) { - menuChildren = this.renderItems(listProps); - } else if (initialContent != null) { - menuChildren = initialContent; - } - - if (menuChildren != null) { - return {menuChildren}; - } - - return undefined; - } - private isQueryEmpty = () => this.state.query.length === 0; private handleActiveItemChange = (activeItem?: T) => this.setState({ activeItem }); diff --git a/packages/select/src/components/select/multiSelect.tsx b/packages/select/src/components/select/multiSelect.tsx index 64890e33f6..097982193a 100644 --- a/packages/select/src/components/select/multiSelect.tsx +++ b/packages/select/src/components/select/multiSelect.tsx @@ -11,7 +11,6 @@ import { IPopoverProps, ITagInputProps, Keys, - Menu, Popover, Position, TagInput, @@ -141,21 +140,12 @@ export class MultiSelect extends React.PureComponent, IM />
- {this.renderItems(listProps)} + {listProps.itemList}
); }; - private renderItems({ items, renderItem }: IQueryListRendererProps) { - const { initialContent, noResults } = this.props; - if (initialContent != null && this.isQueryEmpty()) { - return initialContent; - } - const renderedItems = items.map(renderItem).filter(item => item != null); - return renderedItems.length > 0 ? renderedItems : noResults; - } - private isQueryEmpty = () => this.state.query.length === 0; private handleQueryChange = (evt: React.ChangeEvent) => { diff --git a/packages/select/src/components/select/select.tsx b/packages/select/src/components/select/select.tsx index 81c24ff2b7..f901327f8c 100644 --- a/packages/select/src/components/select/select.tsx +++ b/packages/select/src/components/select/select.tsx @@ -15,7 +15,6 @@ import { InputGroup, IPopoverProps, Keys, - Menu, Popover, Position, Utils, @@ -175,46 +174,20 @@ export class Select extends React.PureComponent, ISelectState
{filterable ? input : undefined} - {this.renderMenu(listProps)} + {listProps.itemList}
); }; - private renderMenu(listProps: IQueryListRendererProps) { - const { items, itemsParentRef, renderItem } = listProps; - const { menuRenderer = this.defaultMenuRenderer } = this.props; - - return menuRenderer({ - items, - itemsParentRef, - query: this.state.query, - renderItem, - }); - } - - private defaultMenuRenderer = (menuProps: IMenuRendererProps) => { - const { initialContent, noResults } = this.props; - const { items, itemsParentRef, renderItem } = menuProps; - const maybeInitialContent = initialContent != null && this.isQueryEmpty() ? initialContent : null; - const renderedItems = items.map(renderItem).filter(item => item != null); - return ( - - {maybeInitialContent || (renderedItems.length > 0 ? renderedItems : noResults)} - - ); - }; - private maybeRenderInputClearButton() { - return !this.isQueryEmpty() ? ( -