From 8939869bac14ad569a0794bf4a5a999c5a498636 Mon Sep 17 00:00:00 2001 From: Kevin Park <121665773+shim-flounce@users.noreply.github.com> Date: Fri, 30 Dec 2022 09:19:55 +0000 Subject: [PATCH 1/5] feat(QueryList): add `getActiveElement` prop Fixes #3369. --- .../select/src/components/query-list/queryList.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index d091c188f8..7fa59aef47 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -34,6 +34,12 @@ import { export type QueryListProps = IQueryListProps; /** @deprecated use QueryListProps */ export interface IQueryListProps extends ListItemsProps { + /** If provided, this function will be used in place of the default implementation. */ + getActiveElement?: (props: { + activeItem: CreateNewItem | T | null; + index: number; + itemsParent: HTMLElement; + }) => HTMLElement | undefined; /** * Initial active item, useful if the parent component is controlling its selectedItem but * not activeItem. @@ -417,11 +423,18 @@ export class QueryList extends AbstractComponent2, IQueryLi private getActiveElement() { const { activeItem } = this.state; if (this.itemsParentRef != null) { + const { getActiveElement } = this.props; if (isCreateNewItem(activeItem)) { const index = this.isCreateItemFirst() ? 0 : this.state.filteredItems.length; + if (typeof getActiveElement === "function") { + return getActiveElement({ activeItem, index, itemsParent: this.itemsParentRef }); + } return this.itemsParentRef.children.item(index) as HTMLElement; } else { const activeIndex = this.getActiveIndex(); + if (typeof getActiveElement === "function") { + return getActiveElement({ activeItem, index: activeIndex, itemsParent: this.itemsParentRef }); + } return this.itemsParentRef.children.item(activeIndex) as HTMLElement; } } From 5a7f623789ed42373f24fa80c3f9879795a57fb5 Mon Sep 17 00:00:00 2001 From: Kevin Park <121665773+shim-flounce@users.noreply.github.com> Date: Fri, 30 Dec 2022 10:07:14 +0000 Subject: [PATCH 2/5] Move `getActiveElement` into `ListItemsProps` to allow customization --- packages/select/src/common/listItemsProps.ts | 7 +++++++ packages/select/src/components/query-list/queryList.tsx | 6 ------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/select/src/common/listItemsProps.ts b/packages/select/src/common/listItemsProps.ts index c81a9fdfbf..e1f609bf87 100644 --- a/packages/select/src/common/listItemsProps.ts +++ b/packages/select/src/common/listItemsProps.ts @@ -46,6 +46,13 @@ export interface ListItemsProps extends Props { */ activeItem?: T | CreateNewItem | null; + /** If provided, this function will be used in place of the default implementation. */ + getActiveElement?: (props: { + activeItem: CreateNewItem | T | null; + index: number; + itemsParent: HTMLElement; + }) => HTMLElement | undefined; + /** Array of items in the list. */ items: T[]; diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index 7fa59aef47..8a4918ff76 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -34,12 +34,6 @@ import { export type QueryListProps = IQueryListProps; /** @deprecated use QueryListProps */ export interface IQueryListProps extends ListItemsProps { - /** If provided, this function will be used in place of the default implementation. */ - getActiveElement?: (props: { - activeItem: CreateNewItem | T | null; - index: number; - itemsParent: HTMLElement; - }) => HTMLElement | undefined; /** * Initial active item, useful if the parent component is controlling its selectedItem but * not activeItem. From a528e7069b73b028065e46fbbf44855634fe8919 Mon Sep 17 00:00:00 2001 From: Kevin Park <121665773+shim-flounce@users.noreply.github.com> Date: Fri, 30 Dec 2022 10:25:28 +0000 Subject: [PATCH 3/5] Expose `filteredItems` --- packages/select/src/common/listItemsProps.ts | 1 + .../select/src/components/query-list/queryList.tsx | 12 ++++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/select/src/common/listItemsProps.ts b/packages/select/src/common/listItemsProps.ts index e1f609bf87..769e5286dd 100644 --- a/packages/select/src/common/listItemsProps.ts +++ b/packages/select/src/common/listItemsProps.ts @@ -49,6 +49,7 @@ export interface ListItemsProps extends Props { /** If provided, this function will be used in place of the default implementation. */ getActiveElement?: (props: { activeItem: CreateNewItem | T | null; + filteredItems: T[]; index: number; itemsParent: HTMLElement; }) => HTMLElement | undefined; diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index 8a4918ff76..98d68ed7e3 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -415,21 +415,21 @@ export class QueryList extends AbstractComponent2, IQueryLi }; private getActiveElement() { - const { activeItem } = this.state; + const { activeItem, filteredItems } = this.state; if (this.itemsParentRef != null) { const { getActiveElement } = this.props; if (isCreateNewItem(activeItem)) { - const index = this.isCreateItemFirst() ? 0 : this.state.filteredItems.length; + const index = this.isCreateItemFirst() ? 0 : filteredItems.length; if (typeof getActiveElement === "function") { - return getActiveElement({ activeItem, index, itemsParent: this.itemsParentRef }); + return getActiveElement({ activeItem, filteredItems, index, itemsParent: this.itemsParentRef }); } return this.itemsParentRef.children.item(index) as HTMLElement; } else { - const activeIndex = this.getActiveIndex(); + const index = this.getActiveIndex(); if (typeof getActiveElement === "function") { - return getActiveElement({ activeItem, index: activeIndex, itemsParent: this.itemsParentRef }); + return getActiveElement({ activeItem, filteredItems, index, itemsParent: this.itemsParentRef }); } - return this.itemsParentRef.children.item(activeIndex) as HTMLElement; + return this.itemsParentRef.children.item(index) as HTMLElement; } } return undefined; From 7120387bee64ac6be5f4714cc007f510028c75f0 Mon Sep 17 00:00:00 2001 From: Kevin Park <121665773+shim-flounce@users.noreply.github.com> Date: Mon, 6 Feb 2023 11:47:51 +0000 Subject: [PATCH 4/5] Add grouped Select example --- .../select-examples/selectExample.tsx | 116 +++++++++++++++++- 1 file changed, 110 insertions(+), 6 deletions(-) diff --git a/packages/docs-app/src/examples/select-examples/selectExample.tsx b/packages/docs-app/src/examples/select-examples/selectExample.tsx index 6e71d05281..ddf6237cca 100644 --- a/packages/docs-app/src/examples/select-examples/selectExample.tsx +++ b/packages/docs-app/src/examples/select-examples/selectExample.tsx @@ -16,9 +16,10 @@ import * as React from "react"; -import { H5, MenuItem, Switch } from "@blueprintjs/core"; +import { H5, Menu, MenuDivider, MenuItem, Switch } from "@blueprintjs/core"; import { Example, ExampleProps } from "@blueprintjs/docs-theme"; -import { Film, FilmSelect, TOP_100_FILMS } from "@blueprintjs/select/examples"; +import { isCreateNewItem, ItemListRendererProps, ListItemsProps } from "@blueprintjs/select"; +import { Film, FilmSelect, filterFilm, TOP_100_FILMS } from "@blueprintjs/select/examples"; export interface ISelectExampleState { allowCreate: boolean; @@ -28,6 +29,7 @@ export interface ISelectExampleState { disabled: boolean; fill: boolean; filterable: boolean; + grouped: boolean; hasInitialContent: boolean; matchTargetWidth: boolean; minimal: boolean; @@ -46,6 +48,7 @@ export class SelectExample extends React.PureComponent - ) : undefined; + const initialContent = this.getInitialContent(); return ( @@ -92,7 +95,10 @@ export class SelectExample extends React.PureComponent @@ -105,6 +111,7 @@ export class SelectExample extends React.PureComponent
Props
+ ["getActiveElement"] = ({ + activeItem, + filteredItems, + index, + itemsParent, + }) => { + if (itemsParent != null) { + if (isCreateNewItem(activeItem)) { + return itemsParent.children.item(index) as HTMLElement; + } + + const group = this.getGroup(activeItem); + const groupedItems = this.getGroupedItems(filteredItems); + const groupIndex = groupedItems.findIndex(item => item.group === group); + return itemsParent.children.item(index + groupIndex + 1) as HTMLElement; + } + return undefined; + }; + + private getGroupedItems = (filteredItems: Film[]) => { + return filteredItems.reduce>( + (acc, item, index) => { + const group = this.getGroup(item); + + const lastGroup = acc.at(-1); + if (lastGroup && lastGroup.group === group) { + lastGroup.items.push(item); + } else { + acc.push({ group, index, items: [item], key: index }); + } + + return acc; + }, + [], + ); + }; + + private getInitialContent = () => { + return this.state.hasInitialContent ? ( + + ) : undefined; + }; + + private groupedItemListPredicate = (query: string, items: Film[]) => { + return items + .filter((item, index) => filterFilm(query, item, index)) + .sort((a, b) => this.getGroup(a).localeCompare(this.getGroup(b))); + }; + private handleSwitchChange(prop: keyof ISelectExampleState) { return (event: React.FormEvent) => { const checked = event.currentTarget.checked; @@ -167,4 +228,47 @@ export class SelectExample extends React.PureComponent this.state.disableItems && film.year < 2000; + + private renderGroupedItemList = (listProps: ItemListRendererProps) => { + const initialContent = this.getInitialContent(); + const noResults = ; + + // omit noResults if createNewItemFromQuery and createNewItemRenderer are both supplied, and query is not empty + const createItemView = listProps.renderCreateItem(); + const maybeNoResults = createItemView != null ? null : noResults; + + const menuContent = this.renderGroupedMenuContent(listProps, maybeNoResults, initialContent); + if (menuContent == null && createItemView == null) { + return null; + } + const { createFirst } = this.state; + return ( + + {createFirst && createItemView} + {menuContent} + {!createFirst && createItemView} + + ); + }; + + private renderGroupedMenuContent = ( + listProps: ItemListRendererProps, + noResults?: React.ReactNode, + initialContent?: React.ReactNode | null, + ) => { + if (listProps.query.length === 0 && initialContent !== undefined) { + return initialContent; + } + + const groupedItems = this.getGroupedItems(listProps.filteredItems); + + const menuContent = groupedItems.map(groupedItem => ( + + + {groupedItem.items.map((item, index) => listProps.renderItem(item, groupedItem.index + index))} + + )); + + return groupedItems.length > 0 ? menuContent : noResults; + }; } From 6578ac07eed50f07ceccb623aeac2a5bacf65b9f Mon Sep 17 00:00:00 2001 From: Kevin Park <121665773+shim-flounce@users.noreply.github.com> Date: Wed, 8 Feb 2023 10:20:03 +0000 Subject: [PATCH 5/5] Replace `getActiveElement` prop with `ref` in `ItemRendererProps` --- .../select-examples/selectExample.tsx | 22 +--------------- packages/select/src/__examples__/films.tsx | 3 ++- packages/select/src/common/itemRenderer.ts | 5 +++- packages/select/src/common/listItemsProps.ts | 8 ------ .../src/components/query-list/queryList.tsx | 26 +++++++++++-------- 5 files changed, 22 insertions(+), 42 deletions(-) diff --git a/packages/docs-app/src/examples/select-examples/selectExample.tsx b/packages/docs-app/src/examples/select-examples/selectExample.tsx index ddf6237cca..b428fe9522 100644 --- a/packages/docs-app/src/examples/select-examples/selectExample.tsx +++ b/packages/docs-app/src/examples/select-examples/selectExample.tsx @@ -18,7 +18,7 @@ import * as React from "react"; import { H5, Menu, MenuDivider, MenuItem, Switch } from "@blueprintjs/core"; import { Example, ExampleProps } from "@blueprintjs/docs-theme"; -import { isCreateNewItem, ItemListRendererProps, ListItemsProps } from "@blueprintjs/select"; +import { ItemListRendererProps } from "@blueprintjs/select"; import { Film, FilmSelect, filterFilm, TOP_100_FILMS } from "@blueprintjs/select/examples"; export interface ISelectExampleState { @@ -95,7 +95,6 @@ export class SelectExample extends React.PureComponent["getActiveElement"] = ({ - activeItem, - filteredItems, - index, - itemsParent, - }) => { - if (itemsParent != null) { - if (isCreateNewItem(activeItem)) { - return itemsParent.children.item(index) as HTMLElement; - } - - const group = this.getGroup(activeItem); - const groupedItems = this.getGroupedItems(filteredItems); - const groupIndex = groupedItems.findIndex(item => item.group === group); - return itemsParent.children.item(index + groupIndex + 1) as HTMLElement; - } - return undefined; - }; - private getGroupedItems = (filteredItems: Film[]) => { return filteredItems.reduce>( (acc, item, index) => { diff --git a/packages/select/src/__examples__/films.tsx b/packages/select/src/__examples__/films.tsx index 44c42b9ac4..2132d5c7d4 100644 --- a/packages/select/src/__examples__/films.tsx +++ b/packages/select/src/__examples__/films.tsx @@ -140,11 +140,12 @@ export const TOP_100_FILMS: Film[] = [ */ export function getFilmItemProps( film: Film, - { handleClick, handleFocus, modifiers, query }: ItemRendererProps, + { handleClick, handleFocus, modifiers, ref, query }: ItemRendererProps, ): MenuItemProps & React.Attributes & React.HTMLAttributes { return { active: modifiers.active, disabled: modifiers.disabled, + elementRef: ref, key: film.rank, label: film.year.toString(), onClick: handleClick, diff --git a/packages/select/src/common/itemRenderer.ts b/packages/select/src/common/itemRenderer.ts index ddc8e2e3d9..a4254e0e75 100644 --- a/packages/select/src/common/itemRenderer.ts +++ b/packages/select/src/common/itemRenderer.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { MouseEventHandler } from "react"; +import { MouseEventHandler, Ref } from "react"; /** @deprecated use ItemModifiers */ export type IItemModifiers = ItemModifiers; @@ -38,6 +38,9 @@ export type IItemRendererProps = ItemRendererProps; * An `itemRenderer` receives the item as its first argument, and this object as its second argument. */ export interface ItemRendererProps { + /** A ref that receives the native HTML element rendered by this item. */ + ref: Ref; + /** Click event handler to select this item. */ handleClick: MouseEventHandler; diff --git a/packages/select/src/common/listItemsProps.ts b/packages/select/src/common/listItemsProps.ts index 769e5286dd..c81a9fdfbf 100644 --- a/packages/select/src/common/listItemsProps.ts +++ b/packages/select/src/common/listItemsProps.ts @@ -46,14 +46,6 @@ export interface ListItemsProps extends Props { */ activeItem?: T | CreateNewItem | null; - /** If provided, this function will be used in place of the default implementation. */ - getActiveElement?: (props: { - activeItem: CreateNewItem | T | null; - filteredItems: T[]; - index: number; - itemsParent: HTMLElement; - }) => HTMLElement | undefined; - /** Array of items in the list. */ items: T[]; diff --git a/packages/select/src/components/query-list/queryList.tsx b/packages/select/src/components/query-list/queryList.tsx index 98d68ed7e3..b807a626cf 100644 --- a/packages/select/src/components/query-list/queryList.tsx +++ b/packages/select/src/components/query-list/queryList.tsx @@ -169,6 +169,8 @@ export class QueryList extends AbstractComponent2, IQueryLi private itemsParentRef?: HTMLElement | null; + private itemRefs = new Map(); + private refHandlers = { itemsParent: (ref: HTMLElement | null) => (this.itemsParentRef = ref), }; @@ -394,6 +396,13 @@ export class QueryList extends AbstractComponent2, IQueryLi index, modifiers, query, + ref: node => { + if (node) { + this.itemRefs.set(index, node); + } else { + this.itemRefs.delete(index); + } + }, }); } @@ -415,21 +424,16 @@ export class QueryList extends AbstractComponent2, IQueryLi }; private getActiveElement() { - const { activeItem, filteredItems } = this.state; + const { activeItem } = this.state; if (this.itemsParentRef != null) { - const { getActiveElement } = this.props; if (isCreateNewItem(activeItem)) { - const index = this.isCreateItemFirst() ? 0 : filteredItems.length; - if (typeof getActiveElement === "function") { - return getActiveElement({ activeItem, filteredItems, index, itemsParent: this.itemsParentRef }); - } + const index = this.isCreateItemFirst() ? 0 : this.state.filteredItems.length; return this.itemsParentRef.children.item(index) as HTMLElement; } else { - const index = this.getActiveIndex(); - if (typeof getActiveElement === "function") { - return getActiveElement({ activeItem, filteredItems, index, itemsParent: this.itemsParentRef }); - } - return this.itemsParentRef.children.item(index) as HTMLElement; + const activeIndex = this.getActiveIndex(); + return ( + this.itemRefs.get(activeIndex) ?? (this.itemsParentRef.children.item(activeIndex) as HTMLElement) + ); } } return undefined;