Skip to content

Commit

Permalink
[select] fix: use renderTarget API to fix fill prop (#5354)
Browse files Browse the repository at this point in the history
adidahiya authored Jun 6, 2022

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
1 parent 6d259b4 commit 0c5823b
Showing 11 changed files with 274 additions and 131 deletions.
Original file line number Diff line number Diff line change
@@ -40,11 +40,13 @@ const INTENTS = [Intent.NONE, Intent.PRIMARY, Intent.SUCCESS, Intent.DANGER, Int
export interface IMultiSelectExampleState {
allowCreate: boolean;
createdItems: IFilm[];
disabled: boolean;
fill: boolean;
films: IFilm[];
hasInitialContent: boolean;
intent: boolean;
items: IFilm[];
matchTargetWidth: boolean;
openOnKeyDown: boolean;
popoverMinimal: boolean;
resetOnSelect: boolean;
@@ -55,11 +57,13 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
public state: IMultiSelectExampleState = {
allowCreate: false,
createdItems: [],
disabled: false,
fill: false,
films: [],
hasInitialContent: false,
intent: false,
items: filmSelectProps.items,
matchTargetWidth: false,
openOnKeyDown: false,
popoverMinimal: true,
resetOnSelect: true,
@@ -70,22 +74,27 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult

private handleAllowCreateChange = this.handleSwitchChange("allowCreate");

private handleKeyDownChange = this.handleSwitchChange("openOnKeyDown");
private handleDisabledChange = this.handleSwitchChange("disabled");

private handleResetChange = this.handleSwitchChange("resetOnSelect");
private handleFillChange = this.handleSwitchChange("fill");

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

private handleTagMinimalChange = this.handleSwitchChange("tagMinimal");
private handleIntentChange = this.handleSwitchChange("intent");

private handleFillChange = this.handleSwitchChange("fill");
private handleKeyDownChange = this.handleSwitchChange("openOnKeyDown");

private handleIntentChange = this.handleSwitchChange("intent");
private handleMatchTargetWidthChange = this.handleSwitchChange("matchTargetWidth");

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

private handleResetChange = this.handleSwitchChange("resetOnSelect");

private handleTagMinimalChange = this.handleSwitchChange("tagMinimal");

public render() {
const { allowCreate, films, hasInitialContent, tagMinimal, popoverMinimal, ...flags } = this.state;
const { allowCreate, films, hasInitialContent, tagMinimal, popoverMinimal, matchTargetWidth, ...flags } =
this.state;
const getTagProps = (_value: React.ReactNode, index: number): TagProps => ({
intent: this.state.intent ? INTENTS[index % INTENTS.length] : Intent.NONE,
minimal: tagMinimal,
@@ -99,7 +108,9 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
const maybeCreateNewItemRenderer = allowCreate ? renderCreateFilmOption : null;

const clearButton =
films.length > 0 ? <Button icon="cross" minimal={true} onClick={this.handleClear} /> : undefined;
films.length > 0 ? (
<Button disabled={flags.disabled} icon="cross" minimal={true} onClick={this.handleClear} />
) : undefined;

return (
<Example options={this.renderOptions()} {...this.props}>
@@ -117,7 +128,7 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
noResults={<MenuItem disabled={true} text="No results." />}
onItemSelect={this.handleFilmSelect}
onItemsPaste={this.handleFilmsPaste}
popoverProps={{ minimal: popoverMinimal }}
popoverProps={{ matchTargetWidth, minimal: popoverMinimal }}
popoverRef={this.popoverRef}
tagRenderer={this.renderTag}
tagInputProps={{
@@ -155,6 +166,8 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
checked={this.state.allowCreate}
onChange={this.handleAllowCreateChange}
/>
<H5>Appearance props</H5>
<Switch label="Disabled" checked={this.state.disabled} onChange={this.handleDisabledChange} />
<Switch label="Fill container width" checked={this.state.fill} onChange={this.handleFillChange} />
<H5>Tag props</H5>
<Switch
@@ -168,6 +181,11 @@ export class MultiSelectExample extends React.PureComponent<IExampleProps, IMult
onChange={this.handleIntentChange}
/>
<H5>Popover props</H5>
<Switch
label="Match target width"
checked={this.state.matchTargetWidth}
onChange={this.handleMatchTargetWidthChange}
/>
<Switch
label="Minimal popover style"
checked={this.state.popoverMinimal}
29 changes: 15 additions & 14 deletions packages/docs-app/src/examples/select-examples/selectExample.tsx
Original file line number Diff line number Diff line change
@@ -26,16 +26,16 @@ export interface ISelectExampleState {
allowCreate: boolean;
createFirst: boolean;
createdItems: IFilm[];
disableItems: boolean;
disabled: boolean;
fill: boolean;
filterable: boolean;
hasInitialContent: boolean;
matchTargetWidth: boolean;
minimal: boolean;
resetOnClose: boolean;
resetOnQuery: boolean;
resetOnSelect: boolean;
disableItems: boolean;
disabled: boolean;
matchTargetWidth: false;
}

/** Technically a Select2 example, since FilmSelect uses Select2. */
@@ -70,6 +70,8 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa

private handleItemDisabledChange = this.handleSwitchChange("disableItems");

private handleMatchTargetWidthChange = this.handleSwitchChange("matchTargetWidth");

private handleMinimalChange = this.handleSwitchChange("minimal");

private handleResetOnCloseChange = this.handleSwitchChange("resetOnClose");
@@ -78,10 +80,8 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa

private handleResetOnSelectChange = this.handleSwitchChange("resetOnSelect");

private handleMatchTargetWidthChange = this.handleSwitchChange("matchTargetWidth");

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

const initialContent = this.state.hasInitialContent ? (
<MenuItem disabled={true} text={`${TOP_100_FILMS.length} items loaded.`} />
@@ -96,7 +96,7 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
disabled={disabled}
itemDisabled={this.isItemDisabled}
initialContent={initialContent}
popoverProps={{ minimal }}
popoverProps={{ matchTargetWidth, minimal }}
/>
</Example>
);
@@ -106,7 +106,6 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
return (
<>
<H5>Props</H5>
<Switch label="Disabled" checked={this.state.disabled} onChange={this.handleDisabledChange} />
<Switch label="Filterable" checked={this.state.filterable} onChange={this.handleFilterableChange} />
<Switch
label="Reset on close"
@@ -123,7 +122,6 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
checked={this.state.resetOnSelect}
onChange={this.handleResetOnSelectChange}
/>
<Switch label="Fill container width" checked={this.state.fill} onChange={this.handleFillChange} />
<Switch
label="Use initial content"
checked={this.state.hasInitialContent}
@@ -134,11 +132,6 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
checked={this.state.disableItems}
onChange={this.handleItemDisabledChange}
/>
<Switch
label="Match target width"
checked={this.state.matchTargetWidth}
onChange={this.handleMatchTargetWidthChange}
/>
<Switch
label="Allow creating new items"
checked={this.state.allowCreate}
@@ -150,7 +143,15 @@ export class SelectExample extends React.PureComponent<IExampleProps, ISelectExa
checked={this.state.createFirst}
onChange={this.handleCreateFirstChange}
/>
<H5>Appearance props</H5>
<Switch label="Disabled" checked={this.state.disabled} onChange={this.handleDisabledChange} />
<Switch label="Fill container width" checked={this.state.fill} onChange={this.handleFillChange} />
<H5>Popover props</H5>
<Switch
label="Match target width"
checked={this.state.matchTargetWidth}
onChange={this.handleMatchTargetWidthChange}
/>
<Switch
label="Minimal popover style"
checked={this.state.minimal}
23 changes: 19 additions & 4 deletions packages/docs-app/src/examples/select-examples/suggestExample.tsx
Original file line number Diff line number Diff line change
@@ -37,9 +37,11 @@ export interface ISuggestExampleState {
allowCreate: boolean;
closeOnSelect: boolean;
createdItems: IFilm[];
disabled: boolean;
fill: boolean;
film: IFilm;
items: IFilm[];
matchTargetWidth: boolean;
minimal: boolean;
openOnKeyDown: boolean;
resetOnClose: boolean;
@@ -52,9 +54,11 @@ export class SuggestExample extends React.PureComponent<IExampleProps, ISuggestE
allowCreate: false,
closeOnSelect: true,
createdItems: [],
disabled: false,
fill: false,
film: TOP_100_FILMS[0],
items: filmSelectProps.items,
matchTargetWidth: false,
minimal: true,
openOnKeyDown: false,
resetOnClose: false,
@@ -66,11 +70,15 @@ export class SuggestExample extends React.PureComponent<IExampleProps, ISuggestE

private handleCloseOnSelectChange = this.handleSwitchChange("closeOnSelect");

private handleOpenOnKeyDownChange = this.handleSwitchChange("openOnKeyDown");
private handleDisabledChange = this.handleSwitchChange("disabled");

private handleFillChange = this.handleSwitchChange("fill");

private handleMatchTargetWidthChange = this.handleSwitchChange("matchTargetWidth");

private handleMinimalChange = this.handleSwitchChange("minimal");

private handleFillChange = this.handleSwitchChange("fill");
private handleOpenOnKeyDownChange = this.handleSwitchChange("openOnKeyDown");

private handleResetOnCloseChange = this.handleSwitchChange("resetOnClose");

@@ -79,7 +87,7 @@ export class SuggestExample extends React.PureComponent<IExampleProps, ISuggestE
private handleResetOnSelectChange = this.handleSwitchChange("resetOnSelect");

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

const maybeCreateNewItemFromQuery = allowCreate ? createFilm : undefined;
const maybeCreateNewItemRenderer = allowCreate ? renderCreateFilmOption : null;
@@ -98,7 +106,7 @@ export class SuggestExample extends React.PureComponent<IExampleProps, ISuggestE
items={this.state.items}
noResults={<MenuItem disabled={true} text="No results." />}
onItemSelect={this.handleValueChange}
popoverProps={{ minimal }}
popoverProps={{ matchTargetWidth, minimal }}
/>
</Example>
);
@@ -138,8 +146,15 @@ export class SuggestExample extends React.PureComponent<IExampleProps, ISuggestE
checked={this.state.allowCreate}
onChange={this.handleAllowCreateChange}
/>
<H5>Appearance props</H5>
<Switch label="Disabled" checked={this.state.disabled} onChange={this.handleDisabledChange} />
<Switch label="Fill container width" checked={this.state.fill} onChange={this.handleFillChange} />
<H5>Popover props</H5>
<Switch
label="Match target width"
checked={this.state.matchTargetWidth}
onChange={this.handleMatchTargetWidthChange}
/>
<Switch
label="Minimal popover style"
checked={this.state.minimal}
10 changes: 8 additions & 2 deletions packages/popover2/src/_popover2.scss
Original file line number Diff line number Diff line change
@@ -51,8 +51,8 @@ $dark-popover2-arrow-box-shadow:
}

&.#{$ns}-minimal {
// popover2s with no obvious trigger will never have margin because the arrow
// is hidden so it is safe to remove in all cases always
// Popover2 with no obvious trigger will never have margin because the arrow is hidden,
// so it is safe to remove.
margin: 0 !important; /* stylelint-disable-line declaration-no-important */

.#{$ns}-popover2-arrow {
@@ -131,3 +131,9 @@ span.#{$ns}-popover2-target {
// this is important for span tag as default inline display height only includes text.
// div tag can be used for display: block, which works fine.
}

.#{$ns}-popover2-target {
&.#{$ns}-fill {
width: 100%;
}
}
1 change: 1 addition & 0 deletions packages/popover2/src/popover2SharedProps.ts
Original file line number Diff line number Diff line change
@@ -30,6 +30,7 @@ export type Popover2TargetProps = IPopover2TargetProps;
* @deprecated use Popover2TargetProps
*/
export interface IPopover2TargetProps {
/** Target ref. */
ref: React.Ref<any>;

/** Whether the popover or tooltip is currently open. */
Original file line number Diff line number Diff line change
@@ -13,4 +13,13 @@
max-width: $select-popover-max-width;
overflow: auto;
}

&.#{$ns}-popover2-match-target-width {
width: 100%;

.#{$ns}-menu {
max-width: none;
min-width: 0;
}
}
}
119 changes: 80 additions & 39 deletions packages/select/src/components/multi-select/multiSelect2.tsx
Original file line number Diff line number Diff line change
@@ -29,14 +29,22 @@ import {
TagInputProps,
Utils,
} from "@blueprintjs/core";
import { Popover2, PopupKind } from "@blueprintjs/popover2";
import { Popover2, Popover2TargetProps, PopupKind } from "@blueprintjs/popover2";

import { Classes, IListItemsProps, SelectPopoverProps } from "../../common";
import { IQueryListRendererProps, QueryList } from "../query-list/queryList";

// N.B. selectedItems should really be a required prop, but is left optional for backwards compatibility

export interface MultiSelect2Props<T> extends IListItemsProps<T>, SelectPopoverProps {
/**
* Whether the component is non-interactive.
* If true, the list's item renderer will not be called.
*
* @default false
*/
disabled?: boolean;

/**
* Whether the component should take up the full width of its container.
* This overrides `popoverProps.fill` and `tagInputProps.fill`.
@@ -82,9 +90,14 @@ export interface MultiSelect2Props<T> extends IListItemsProps<T>, SelectPopoverP
/** Controlled selected values. */
selectedItems?: T[];

/** Props to spread to `TagInput`. Use `query` and `onQueryChange` to control the input. */
/**
* Props to spread to `TagInput`.
* If you wish to control the value of the input, use `query` and `onQueryChange` instead.
* Note that you are responsible for disabling any elements you may render in `tagInputProps.rightElement`
* when the overall `MultiSelect2` is disabled.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
tagInputProps?: Partial<TagInputProps> & object;
tagInputProps?: Partial<TagInputProps>;

/** Custom renderer to transform an item into tag content. */
tagRenderer: (item: T) => React.ReactNode;
@@ -100,6 +113,7 @@ export class MultiSelect2<T> extends AbstractPureComponent2<MultiSelect2Props<T>
private listboxId = Utils.uniqueId("listbox");

public static defaultProps = {
disabled: false,
fill: false,
placeholder: "Search...",
};
@@ -153,39 +167,20 @@ export class MultiSelect2<T> extends AbstractPureComponent2<MultiSelect2Props<T>
}

private renderQueryList = (listProps: IQueryListRendererProps<T>) => {
const {
fill,
tagInputProps = {},
popoverContentProps = {},
popoverProps = {},
popoverRef,
selectedItems = [],
placeholder,
popoverTargetProps = {},
} = this.props;
const { handlePaste, handleKeyDown, handleKeyUp } = listProps;

if (fill) {
popoverProps.fill = true;
tagInputProps.fill = true;
}

// add our own inputProps.className so that we can reference it in event handlers
const inputProps = {
...tagInputProps.inputProps,
className: classNames(tagInputProps.inputProps?.className, Classes.MULTISELECT_TAG_INPUT_INPUT),
};
const { disabled, popoverContentProps = {}, popoverProps = {} } = this.props;
const { handleKeyDown, handleKeyUp } = listProps;

const handleTagInputAdd = (values: any[], method: TagInputAddMethod) => {
if (method === "paste") {
handlePaste(values);
}
};
const popoverRef =
this.props.popoverRef === undefined
? this.refHandlers.popover
: mergeRefs(this.refHandlers.popover, this.props.popoverRef);

// N.B. no need to set `popoverProps.fill` since that is unused with the `renderTarget` API
return (
<Popover2
autoFocus={false}
canEscapeKeyClose={true}
disabled={disabled}
enforceFocus={false}
isOpen={this.state.isOpen}
placement={popoverProps.position || popoverProps.placement ? undefined : "bottom-start"}
@@ -201,18 +196,65 @@ export class MultiSelect2<T> extends AbstractPureComponent2<MultiSelect2Props<T>
onOpened={this.handlePopoverOpened}
popoverClassName={classNames(Classes.MULTISELECT_POPOVER, popoverProps.popoverClassName)}
popupKind={PopupKind.LISTBOX}
ref={
popoverRef === undefined
? this.refHandlers.popover
: mergeRefs(this.refHandlers.popover, popoverRef)
ref={popoverRef}
renderTarget={this.getPopoverTargetRenderer(listProps, this.state.isOpen)}
/>
);
};

// We use the renderTarget API to flatten the rendered DOM and make it easier to implement features like
// the "fill" prop. Note that we must take `isOpen` as an argument to force this render function to be called
// again after that state changes.
private getPopoverTargetRenderer =
(listProps: IQueryListRendererProps<T>, isOpen: boolean) =>
// N.B. pull out `isOpen` so that it's not forwarded to the DOM, but remember not to use it directly
// since it may be stale (`renderTarget` is not re-invoked on this.state changes).
// eslint-disable-next-line react/display-name
({ isOpen: _isOpen, ref, ...targetProps }: Popover2TargetProps & React.HTMLProps<HTMLDivElement>) => {
const {
disabled,
fill,
tagInputProps = {},
selectedItems = [],
placeholder,
popoverTargetProps = {},
} = this.props;
const { handlePaste, handleKeyDown, handleKeyUp } = listProps;

if (disabled) {
tagInputProps.disabled = true;
}
if (fill) {
tagInputProps.fill = true;
}

// add our own inputProps.className so that we can reference it in event handlers
const inputProps = {
...tagInputProps.inputProps,
className: classNames(tagInputProps.inputProps?.className, Classes.MULTISELECT_TAG_INPUT_INPUT),
};

const handleTagInputAdd = (values: any[], method: TagInputAddMethod) => {
if (method === "paste") {
handlePaste(values);
}
>
};
return (
<div
aria-controls={this.listboxId}
{...popoverTargetProps}
aria-expanded={this.state.isOpen}
{...targetProps}
aria-expanded={isOpen}
// Note that we must set FILL here in addition to TagInput to get the wrapper element to full width
className={classNames(targetProps.className, popoverTargetProps.className, {
[CoreClasses.FILL]: fill,
})}
// Normally, Popover2 would also need to attach its own `onKeyDown` handler via `targetProps`,
// but in our case we fully manage that interaction and listen for key events to open/close
// the popover, so we elide it from the DOM.
onKeyDown={this.getTagInputKeyDownHandler(handleKeyDown)}
onKeyUp={this.getTagInputKeyUpHandler(handleKeyUp)}
ref={ref}
role="combobox"
>
<TagInput
@@ -229,9 +271,8 @@ export class MultiSelect2<T> extends AbstractPureComponent2<MultiSelect2Props<T>
values={selectedItems.map(this.props.tagRenderer)}
/>
</div>
</Popover2>
);
};
);
};

private handleItemSelect = (item: T, evt?: React.SyntheticEvent<HTMLElement>) => {
if (this.input != null) {
18 changes: 9 additions & 9 deletions packages/select/src/components/select/_select.scss
Original file line number Diff line number Diff line change
@@ -4,15 +4,6 @@
@import "../../common/variables";

.#{$ns}-select-popover {
&.#{$ns}-select-match-target-width {
width: 100%;

.#{$ns}-menu {
max-width: none;
min-width: 0;
}
}

.#{$ns}-popover-content,
.#{$ns}-popover2-content {
// use padding on container rather than margin on input group
@@ -35,4 +26,13 @@
padding-top: $pt-grid-size * 0.5;
}
}

&.#{$ns}-popover2-match-target-width {
width: 100%;

.#{$ns}-menu {
max-width: none;
min-width: 0;
}
}
}
80 changes: 57 additions & 23 deletions packages/select/src/components/select/select2.tsx
Original file line number Diff line number Diff line change
@@ -22,6 +22,7 @@ import * as React from "react";
import {
AbstractPureComponent2,
Button,
Classes as CoreClasses,
DISPLAYNAME_PREFIX,
InputGroup,
InputGroupProps2,
@@ -32,14 +33,27 @@ import {
setRef,
Utils,
} from "@blueprintjs/core";
import { Popover2, PopupKind } from "@blueprintjs/popover2";
import { Popover2, Popover2TargetProps, PopupKind } from "@blueprintjs/popover2";

import { Classes, IListItemsProps, SelectPopoverProps } from "../../common";
import { IQueryListRendererProps, QueryList } from "../query-list/queryList";

export interface Select2Props<T> extends IListItemsProps<T>, SelectPopoverProps {
/**
* Element which triggers the select popover. In most cases, you should display
* the name or label of the curently selected item here.
*/
children?: React.ReactNode;

/**
* Whether the component is non-interactive.
* If true, the list's item renderer will not be called.
* Note that you'll also need to disable the component's children, if appropriate.
*
* @default false
*/
disabled?: boolean;

/**
* Whether the component should take up the full width of its container.
* This overrides `popoverProps.fill`. You also have to ensure that the child
@@ -55,15 +69,6 @@ export interface Select2Props<T> extends IListItemsProps<T>, SelectPopoverProps
*/
filterable?: boolean;

/**
* Whether the component is non-interactive.
* If true, the list's item renderer will not be called.
* Note that you'll also need to disable the component's children, if appropriate.
*
* @default false
*/
disabled?: boolean;

/**
* Props to spread to the query `InputGroup`. Use `query` and
* `onQueryChange` instead of `inputProps.value` and `inputProps.onChange`
@@ -92,18 +97,16 @@ export interface Select2State {
export class Select2<T> extends AbstractPureComponent2<Select2Props<T>, Select2State> {
public static displayName = `${DISPLAYNAME_PREFIX}.Select2`;

private listboxId = Utils.uniqueId("listbox");

public static ofType<U>() {
return Select2 as new (props: Select2Props<U>) => Select2<U>;
}

public state: Select2State = { isOpen: false };

private TypedQueryList = QueryList.ofType<T>();

public inputElement: HTMLInputElement | null = null;

private TypedQueryList = QueryList.ofType<T>();

private queryList: QueryList<T> | null = null;

private previousFocusedElement: HTMLElement | undefined;
@@ -112,6 +115,8 @@ export class Select2<T> extends AbstractPureComponent2<Select2Props<T>, Select2S

private handleQueryListRef = (ref: QueryList<T> | null) => (this.queryList = ref);

private listboxId = Utils.uniqueId("listbox");

public render() {
// omit props specific to this component, spread the rest.
const { filterable, inputProps, popoverProps, ...restProps } = this.props;
@@ -148,7 +153,6 @@ export class Select2<T> extends AbstractPureComponent2<Select2Props<T>, Select2S
inputProps = {},
popoverContentProps = {},
popoverProps = {},
popoverTargetProps = {},
popoverRef,
} = this.props;

@@ -191,33 +195,63 @@ export class Select2<T> extends AbstractPureComponent2<Select2Props<T>, Select2S
popoverClassName={classNames(Classes.SELECT_POPOVER, popoverProps.popoverClassName)}
popupKind={PopupKind.LISTBOX}
ref={popoverRef}
>
renderTarget={this.getPopoverTargetRenderer(listProps, this.state.isOpen)}
/>
);
};

// We use the renderTarget API to flatten the rendered DOM and make it easier to implement features like
// the "fill" prop. Note that we must take `isOpen` as an argument to force this render function to be called
// again after that state changes.
private getPopoverTargetRenderer =
(listProps: IQueryListRendererProps<T>, isOpen: boolean) =>
// N.B. pull out `isOpen` so that it's not forwarded to the DOM, but remember not to use it directly
// since it may be stale (`renderTarget` is not re-invoked on this.state changes).
// eslint-disable-next-line react/display-name
({ isOpen: _isOpen, ref, ...targetProps }: Popover2TargetProps & React.HTMLProps<HTMLDivElement>) => {
const { popoverTargetProps } = this.props;
const { handleKeyDown, handleKeyUp } = listProps;
return (
<div
aria-controls={this.listboxId}
{...popoverTargetProps}
aria-expanded={this.state.isOpen}
onKeyDown={this.state.isOpen ? handleKeyDown : this.handleTargetKeyDown}
onKeyUp={this.state.isOpen ? handleKeyUp : undefined}
{...targetProps}
aria-expanded={isOpen}
// Note that we must set FILL here in addition to children to get the wrapper element to full width
className={classNames(targetProps.className, {
[CoreClasses.FILL]: this.props.fill,
})}
// Normally, Popover2 would also need to attach its own `onKeyDown` handler via `targetProps`,
// but in our case we fully manage that interaction and listen for key events to open/close
// the popover, so we elide it from the DOM.
onKeyDown={isOpen ? handleKeyDown : this.handleTargetKeyDown}
onKeyUp={isOpen ? handleKeyUp : undefined}
ref={ref}
role="combobox"
>
{this.props.children}
</div>
</Popover2>
);
};
);
};

private maybeRenderClearButton(query: string) {
return query.length > 0 ? <Button icon="cross" minimal={true} onClick={this.resetQuery} /> : undefined;
}

/**
* Target wrapper element "keydown" handler while the popover is closed.
*/
private handleTargetKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
// open popover when arrow key pressed on target while closed
// HACKHACK: https://github.com/palantir/blueprint/issues/4165
// eslint-disable-next-line deprecation/deprecation
/* eslint-disable deprecation/deprecation */
if (event.which === Keys.ARROW_UP || event.which === Keys.ARROW_DOWN) {
event.preventDefault();
this.setState({ isOpen: true });
} else if (Keys.isKeyboardClick(event.keyCode)) {
this.setState({ isOpen: true });
}
/* eslint-enable deprecation/deprecation */
};

private handleItemSelect = (item: T, event?: React.SyntheticEvent<HTMLElement>) => {
74 changes: 46 additions & 28 deletions packages/select/src/components/suggest/suggest2.tsx
Original file line number Diff line number Diff line change
@@ -26,12 +26,13 @@ import {
InputGroupProps2,
IRef,
Keys,
mergeRefs,
Position,
refHandler,
setRef,
Utils,
} from "@blueprintjs/core";
import { Popover2, PopupKind } from "@blueprintjs/popover2";
import { Popover2, Popover2TargetProps, PopupKind } from "@blueprintjs/popover2";

import { Classes, IListItemsProps, SelectPopoverProps } from "../../common";
import { IQueryListRendererProps, QueryList } from "../query-list/queryList";
@@ -104,8 +105,6 @@ export interface Suggest2State<T> {
export class Suggest2<T> extends AbstractPureComponent2<Suggest2Props<T>, Suggest2State<T>> {
public static displayName = `${DISPLAYNAME_PREFIX}.Suggest2`;

private listboxId = Utils.uniqueId("listbox");

public static defaultProps: Partial<Suggest2Props<any>> = {
closeOnSelect: true,
fill: false,
@@ -132,6 +131,8 @@ export class Suggest2<T> extends AbstractPureComponent2<Suggest2Props<T>, Sugges

private handleQueryListRef = (ref: QueryList<T> | null) => (this.queryList = ref);

private listboxId = Utils.uniqueId("listbox");

public render() {
// omit props specific to this component, spread the rest.
const { disabled, inputProps, popoverProps, ...restProps } = this.props;
@@ -174,25 +175,11 @@ export class Suggest2<T> extends AbstractPureComponent2<Suggest2Props<T>, Sugges
}

private renderQueryList = (listProps: IQueryListRendererProps<T>) => {
const { fill, inputProps = {}, popoverContentProps = {}, popoverProps = {}, popoverRef } = this.props;
const { isOpen, selectedItem } = this.state;
const { popoverContentProps = {}, popoverProps = {}, popoverRef } = this.props;
const { isOpen } = this.state;
const { handleKeyDown, handleKeyUp } = listProps;
const { autoComplete = "off", placeholder = "Search..." } = inputProps;

const selectedItemText = selectedItem ? this.props.inputValueRenderer(selectedItem) : "";
// placeholder shows selected item while open.
const inputPlaceholder = isOpen && selectedItemText ? selectedItemText : placeholder;
// value shows query when open, and query remains when closed if nothing is selected.
// if resetOnClose is enabled, then hide query when not open. (see handlePopoverOpening)
const inputValue = isOpen
? listProps.query
: selectedItemText || (this.props.resetOnClose ? "" : listProps.query);

if (fill) {
popoverProps.fill = true;
inputProps.fill = true;
}

// N.B. no need to set `popoverProps.fill` since that is unused with the `renderTarget` API
return (
<Popover2
autoFocus={false}
@@ -213,27 +200,58 @@ export class Suggest2<T> extends AbstractPureComponent2<Suggest2Props<T>, Sugges
popoverClassName={classNames(Classes.SELECT_POPOVER, popoverProps.popoverClassName)}
popupKind={PopupKind.LISTBOX}
ref={popoverRef}
>
renderTarget={this.getPopoverTargetRenderer(listProps, isOpen)}
/>
);
};

// We use the renderTarget API to flatten the rendered DOM and make it easier to implement features like
// the "fill" prop. Note that we must take `isOpen` as an argument to force this render function to be called
// again after that state changes.
private getPopoverTargetRenderer =
(listProps: IQueryListRendererProps<T>, isOpen: boolean) =>
// eslint-disable-next-line react/display-name
({
// pull out `isOpen` so that it's not forwarded to the DOM
isOpen: _isOpen,
// pull out `defaultValue` due to type incompatibility with InputGroup.
defaultValue,
ref,
...targetProps
}: Popover2TargetProps & React.HTMLProps<HTMLInputElement>) => {
const { disabled, fill, inputProps = {}, inputValueRenderer, resetOnClose } = this.props;
const { selectedItem } = this.state;
const { handleKeyDown, handleKeyUp } = listProps;

const selectedItemText = selectedItem == null ? "" : inputValueRenderer(selectedItem);
const { autoComplete = "off", placeholder = "Search..." } = inputProps;
// placeholder shows selected item while open.
const inputPlaceholder = isOpen && selectedItemText ? selectedItemText : placeholder;
// value shows query when open, and query remains when closed if nothing is selected.
// if resetOnClose is enabled, then hide query when not open. (see handlePopoverOpening)
const inputValue = isOpen ? listProps.query : selectedItemText ?? (resetOnClose ? "" : listProps.query);

return (
<InputGroup
autoComplete={autoComplete}
disabled={this.props.disabled}
disabled={disabled}
aria-controls={this.listboxId}
{...targetProps}
{...inputProps}
aria-autocomplete="list"
aria-expanded={this.state.isOpen}
inputRef={this.handleInputRef}
aria-expanded={isOpen}
fill={fill}
inputRef={mergeRefs(this.handleInputRef, ref)}
onChange={listProps.handleQueryChange}
onFocus={this.handleInputFocus}
onKeyDown={this.getTargetKeyDownHandler(handleKeyDown)}
onKeyUp={this.getTargetKeyUpHandler(handleKeyUp)}
placeholder={inputPlaceholder}
role="combobox"
type="text"
value={inputValue}
/>
</Popover2>
);
};
);
};

private selectText = () => {
// wait until the input is properly focused to select the text inside of it
4 changes: 2 additions & 2 deletions packages/select/test/suggest2Tests.tsx
Original file line number Diff line number Diff line change
@@ -202,12 +202,12 @@ describe("Suggest2", () => {
const ITEM_INDEX = 4;
const wrapper = suggest();

assert.isFalse(handlers.inputValueRenderer.called, "should not call inputValueRenderer before selection");
assert.isFalse(handlers.inputValueRenderer.called, "should not call inputValueRenderer before selection");
selectItem(wrapper, ITEM_INDEX);
const selectedItem = TOP_100_FILMS[ITEM_INDEX];
const expectedValue = inputValueRenderer(selectedItem);

assert.isTrue(handlers.inputValueRenderer.called, "should call inputValueRenderer after selection");
assert.isTrue(handlers.inputValueRenderer.called, "should call inputValueRenderer after selection");
assert.strictEqual(wrapper.find(InputGroup).prop("value"), expectedValue);
});
});

1 comment on commit 0c5823b

@blueprint-bot
Copy link

Choose a reason for hiding this comment

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

[select] fix: use renderTarget API to fix fill prop (#5354)

Previews: documentation | landing | table | demo

Please sign in to comment.