Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[select] feat(QueryList): add ref to ItemRendererProps #5815

Merged
merged 5 commits into from Feb 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
96 changes: 90 additions & 6 deletions packages/docs-app/src/examples/select-examples/selectExample.tsx
Expand Up @@ -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 { ItemListRendererProps } from "@blueprintjs/select";
import { Film, FilmSelect, filterFilm, TOP_100_FILMS } from "@blueprintjs/select/examples";

export interface ISelectExampleState {
allowCreate: boolean;
Expand All @@ -28,6 +29,7 @@ export interface ISelectExampleState {
disabled: boolean;
fill: boolean;
filterable: boolean;
grouped: boolean;
hasInitialContent: boolean;
matchTargetWidth: boolean;
minimal: boolean;
Expand All @@ -46,6 +48,7 @@ export class SelectExample extends React.PureComponent<ExampleProps, ISelectExam
disabled: false,
fill: false,
filterable: true,
grouped: false,
hasInitialContent: false,
matchTargetWidth: false,
minimal: false,
Expand All @@ -64,6 +67,8 @@ export class SelectExample extends React.PureComponent<ExampleProps, ISelectExam

private handleFilterableChange = this.handleSwitchChange("filterable");

private handleGroupedChange = this.handleSwitchChange("grouped");

private handleInitialContentChange = this.handleSwitchChange("hasInitialContent");

private handleItemDisabledChange = this.handleSwitchChange("disableItems");
Expand All @@ -79,11 +84,9 @@ export class SelectExample extends React.PureComponent<ExampleProps, ISelectExam
private handleResetOnSelectChange = this.handleSwitchChange("resetOnSelect");

public render() {
const { allowCreate, disabled, disableItems, matchTargetWidth, minimal, ...flags } = this.state;
const { allowCreate, disabled, disableItems, grouped, matchTargetWidth, minimal, ...flags } = this.state;

const initialContent = this.state.hasInitialContent ? (
<MenuItem disabled={true} text={`${TOP_100_FILMS.length} items loaded.`} roleStructure="listoption" />
) : undefined;
const initialContent = this.getInitialContent();

return (
<Example options={this.renderOptions()} {...this.props}>
Expand All @@ -93,6 +96,8 @@ export class SelectExample extends React.PureComponent<ExampleProps, ISelectExam
createNewItemPosition={this.state.createFirst ? "first" : "last"}
disabled={disabled}
itemDisabled={this.isItemDisabled}
itemListRenderer={grouped ? this.renderGroupedItemList : undefined}
itemListPredicate={grouped ? this.groupedItemListPredicate : undefined}
initialContent={initialContent}
popoverProps={{ matchTargetWidth, minimal }}
/>
Expand All @@ -105,6 +110,7 @@ export class SelectExample extends React.PureComponent<ExampleProps, ISelectExam
<>
<H5>Props</H5>
<Switch label="Filterable" checked={this.state.filterable} onChange={this.handleFilterableChange} />
<Switch label="Grouped" checked={this.state.grouped} onChange={this.handleGroupedChange} />
<Switch
label="Reset on close"
checked={this.state.resetOnClose}
Expand Down Expand Up @@ -159,6 +165,41 @@ export class SelectExample extends React.PureComponent<ExampleProps, ISelectExam
);
}

private getGroup(item: Film) {
const firstLetter = item.title[0].toUpperCase();
return /[0-9]/.test(firstLetter) ? "0-9" : firstLetter;
}

private getGroupedItems = (filteredItems: Film[]) => {
return filteredItems.reduce<Array<{ group: string; index: number; items: Film[]; key: number }>>(
(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 ? (
<MenuItem disabled={true} text={`${TOP_100_FILMS.length} items loaded.`} roleStructure="listoption" />
) : 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<HTMLInputElement>) => {
const checked = event.currentTarget.checked;
Expand All @@ -167,4 +208,47 @@ export class SelectExample extends React.PureComponent<ExampleProps, ISelectExam
}

private isItemDisabled = (film: Film) => this.state.disableItems && film.year < 2000;

private renderGroupedItemList = (listProps: ItemListRendererProps<Film>) => {
const initialContent = this.getInitialContent();
const noResults = <MenuItem disabled={true} text="No results." roleStructure="listoption" />;

// 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 (
<Menu role="listbox" {...listProps.menuProps} ulRef={listProps.itemsParentRef}>
{createFirst && createItemView}
{menuContent}
{!createFirst && createItemView}
</Menu>
);
};

private renderGroupedMenuContent = (
listProps: ItemListRendererProps<Film>,
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 => (
<React.Fragment key={groupedItem.key}>
<MenuDivider title={groupedItem.group} />
{groupedItem.items.map((item, index) => listProps.renderItem(item, groupedItem.index + index))}
</React.Fragment>
));

return groupedItems.length > 0 ? menuContent : noResults;
};
}
3 changes: 2 additions & 1 deletion packages/select/src/__examples__/films.tsx
Expand Up @@ -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<HTMLAnchorElement> {
return {
active: modifiers.active,
disabled: modifiers.disabled,
elementRef: ref,
key: film.rank,
label: film.year.toString(),
onClick: handleClick,
Expand Down
5 changes: 4 additions & 1 deletion packages/select/src/common/itemRenderer.ts
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { MouseEventHandler } from "react";
import { MouseEventHandler, Ref } from "react";

/** @deprecated use ItemModifiers */
export type IItemModifiers = ItemModifiers;
Expand All @@ -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<any>;

/** Click event handler to select this item. */
handleClick: MouseEventHandler<HTMLElement>;

Expand Down
13 changes: 12 additions & 1 deletion packages/select/src/components/query-list/queryList.tsx
Expand Up @@ -169,6 +169,8 @@ export class QueryList<T> extends AbstractComponent2<QueryListProps<T>, IQueryLi

private itemsParentRef?: HTMLElement | null;

private itemRefs = new Map<number, HTMLElement>();

private refHandlers = {
itemsParent: (ref: HTMLElement | null) => (this.itemsParentRef = ref),
};
Expand Down Expand Up @@ -394,6 +396,13 @@ export class QueryList<T> extends AbstractComponent2<QueryListProps<T>, IQueryLi
index,
modifiers,
query,
ref: node => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if (node) {
this.itemRefs.set(index, node);
} else {
this.itemRefs.delete(index);
}
},
});
}

Expand Down Expand Up @@ -422,7 +431,9 @@ export class QueryList<T> extends AbstractComponent2<QueryListProps<T>, IQueryLi
return this.itemsParentRef.children.item(index) as HTMLElement;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strictly speaking, we could also wire up a ref to createNewItemRenderer so that we can easily look it up, but I think even without doing so 0 and this.state.filteredItems.length should be good enough as indices.

} else {
const activeIndex = this.getActiveIndex();
return this.itemsParentRef.children.item(activeIndex) as HTMLElement;
return (
this.itemRefs.get(activeIndex) ?? (this.itemsParentRef.children.item(activeIndex) as HTMLElement)
);
}
}
return undefined;
Expand Down