Skip to content

Commit

Permalink
[select] un/controlled activeItem & query! (#2747)
Browse files Browse the repository at this point in the history
* move resetOnSelect to IListItemsProps so all components share it

* ⭐ allow activeItem and query to be un/controlled

move required props from IQueryListProps to optional in IListItemsProps!
add handleQueryChange to renderer props.
move activeItem/query state into IQueryListState

* QueryList initialize state and update renderer usage

* refactor QueryList to control activeItem and query and always pick an enabled first item

* refactor four friends to push state into QueryList

* little fixes

* common suite of tests for select components

* update docs: no value/onChange in inputProps

* tzp tests

* only invoke onQueryChange if it changed

* revert Suggest (followup PR with more extensive changes)

* [Suggest] refactor to support new QueryList state (#2748)

* refactor Suggest to use QueryList's activeItem and query

also remove isTyping state, which fixes double render.
when open, selectedItem appears in placeholder instead of input value.
this is a nicer experience and works great with resetOnSelect.

* add resetOnSelect switch to Suggest example

* revert disabled tests

* fix esc/tab tests

these keys used to clear the selection, now they just cancel a selection in progress and keep the previous selection state.

* default placeholder

* remove omnibar query prop (semantic conflict)

* activeItem: T | null

- explicit null for "no active item"
- undefined optional prop puts it in uncontrolled mode

* maybeRenderClearButton
  • Loading branch information
giladgray committed Aug 1, 2018
1 parent cf37dea commit d202674
Show file tree
Hide file tree
Showing 16 changed files with 227 additions and 355 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface ISuggestExampleState {
film: IFilm;
minimal: boolean;
openOnKeyDown: boolean;
resetOnSelect: boolean;
}

export class SuggestExample extends React.PureComponent<IExampleProps, ISuggestExampleState> {
Expand All @@ -26,11 +27,13 @@ export class SuggestExample extends React.PureComponent<IExampleProps, ISuggestE
film: TOP_100_FILMS[0],
minimal: true,
openOnKeyDown: false,
resetOnSelect: false,
};

private handleCloseOnSelectChange = this.handleSwitchChange("closeOnSelect");
private handleOpenOnKeyDownChange = this.handleSwitchChange("openOnKeyDown");
private handleMinimalChange = this.handleSwitchChange("minimal");
private handleResetOnSelectChange = this.handleSwitchChange("resetOnSelect");

public render() {
const { film, minimal, ...flags } = this.state;
Expand Down Expand Up @@ -62,6 +65,11 @@ export class SuggestExample extends React.PureComponent<IExampleProps, ISuggestE
checked={this.state.openOnKeyDown}
onChange={this.handleOpenOnKeyDownChange}
/>
<Switch
label="Reset on select"
checked={this.state.resetOnSelect}
onChange={this.handleResetOnSelectChange}
/>
<H5>Popover props</H5>
<Switch
label="Minimal popover style"
Expand Down
3 changes: 3 additions & 0 deletions packages/select/src/common/itemListRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
* An `itemListRenderer` receives this object as its sole argument.
*/
export interface IItemListRendererProps<T> {
/** The currently focused item (for keyboard interactions). */
activeItem: T | null;

/**
* Array of items filtered by `itemListPredicate` or `itemPredicate`.
* See `items` for the full list of items.
Expand Down
35 changes: 35 additions & 0 deletions packages/select/src/common/listItemsProps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import { ItemListPredicate, ItemPredicate } from "./predicate";

/** Reusable generic props for a component that operates on a filterable, selectable list of `items`. */
export interface IListItemsProps<T> extends IProps {
/**
* The currently focused item for keyboard interactions, or `null` to
* indicate that no item is active. If omitted, this prop will be
* uncontrolled (managed by the component's state). Use `onActiveItemChange`
* to listen for updates.
*/
activeItem?: T | null;

/** Array of items in the list. */
items: T[];

Expand Down Expand Up @@ -72,9 +80,36 @@ export interface IListItemsProps<T> extends IProps {
*/
noResults?: React.ReactNode;

/**
* Invoked when user interaction should change the active item: arrow keys move it up/down
* in the list, selecting an item makes it active, and changing the query may reset it to
* the first item in the list if it no longer matches the filter.
*/
onActiveItemChange?: (activeItem: T | null) => void;

/**
* Callback invoked when an item from the list is selected,
* typically by clicking or pressing `enter` key.
*/
onItemSelect: (item: T, event?: React.SyntheticEvent<HTMLElement>) => void;

/**
* Callback invoked when the query string changes.
*/
onQueryChange?: (query: string, event?: React.ChangeEvent<HTMLInputElement>) => void;

/**
* Whether the querying state should be reset to initial when an item is
* selected (immediately before `onItemSelect` is invoked). The query will
* become the empty string and the first item will be made active.
* @default false
*/
resetOnSelect?: boolean;

/**
* Query string passed to `itemListPredicate` or `itemPredicate` to filter items.
* This value is controlled: its state must be managed externally by attaching an `onChange`
* handler to the relevant element in your `renderer` implementation.
*/
query?: string;
}
57 changes: 7 additions & 50 deletions packages/select/src/components/omnibar/omnibar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ import { IQueryListRendererProps, QueryList } from "../query-list/queryList";

export interface IOmnibarProps<T> extends IListItemsProps<T> {
/**
* 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
* to override `Select`'s own behavior.
* Props to spread to the query `InputGroup`. Use `query` and
* `onQueryChange` instead of `inputProps.value` and `inputProps.onChange`
* to control this input. Use `inputRef` instead of `ref`.
*/
inputProps?: IInputGroupProps & HTMLInputProps;

Expand All @@ -48,35 +48,15 @@ export interface IOmnibarProps<T> extends IListItemsProps<T> {

/** Props to spread to `Overlay`. */
overlayProps?: Partial<IOverlayProps>;

/** The query string. */
query?: string;

/**
* Whether the filtering state should be reset to initial when an item is selected
* (immediately before `onItemSelect` is invoked). The query will become the empty string
* and the first item will be made active.
* @default false
*/
resetOnSelect?: boolean;
}

export interface IOmnibarState<T> {
activeItem?: T;
query: string;
}

export class Omnibar<T> extends React.PureComponent<IOmnibarProps<T>, IOmnibarState<T>> {
export class Omnibar<T> extends React.PureComponent<IOmnibarProps<T>> {
public static displayName = `${DISPLAYNAME_PREFIX}.Omnibar`;

public static ofType<T>() {
return Omnibar as new (props: IOmnibarProps<T>) => Omnibar<T>;
}

public state: IOmnibarState<T> = {
query: this.props.query || "",
};

private TypedQueryList = QueryList.ofType<T>();
private queryList?: QueryList<T> | null;
private refHandlers = {
Expand All @@ -90,31 +70,18 @@ export class Omnibar<T> extends React.PureComponent<IOmnibarProps<T>, IOmnibarSt
return (
<this.TypedQueryList
{...restProps}
activeItem={this.state.activeItem}
initialContent={initialContent}
onActiveItemChange={this.handleActiveItemChange}
onItemSelect={this.props.onItemSelect}
query={this.state.query}
ref={this.refHandlers.queryList}
renderer={this.renderQueryList}
/>
);
}

public componentWillReceiveProps(nextProps: IOmnibarProps<T>) {
const { isOpen } = nextProps;
const canClearQuery = !this.props.isOpen && isOpen && this.props.resetOnSelect;

this.setState({
activeItem: canClearQuery ? this.props.items[0] : this.state.activeItem,
query: canClearQuery ? "" : this.state.query,
});
}

private renderQueryList = (listProps: IQueryListRendererProps<T>) => {
const { inputProps = {}, isOpen, overlayProps = {} } = this.props;
const { handleKeyDown, handleKeyUp } = listProps;
const handlers = isOpen && !this.isQueryEmpty() ? { onKeyDown: handleKeyDown, onKeyUp: handleKeyUp } : {};
const handlers = isOpen && listProps.query.length > 0 ? { onKeyDown: handleKeyDown, onKeyUp: handleKeyUp } : {};

return (
<Overlay
Expand All @@ -130,26 +97,16 @@ export class Omnibar<T> extends React.PureComponent<IOmnibarProps<T>, IOmnibarSt
large={true}
leftIcon="search"
placeholder="Search..."
value={listProps.query}
{...inputProps}
onChange={this.handleQueryChange}
onChange={listProps.handleQueryChange}
value={listProps.query}
/>
{listProps.itemList}
</div>
</Overlay>
);
};

private isQueryEmpty = () => this.state.query.length === 0;

private handleActiveItemChange = (activeItem?: T) => this.setState({ activeItem });

private handleQueryChange = (event: React.FormEvent<HTMLInputElement>) => {
const { inputProps = {} } = this.props;
this.setState({ query: event.currentTarget.value });
Utils.safeInvoke(inputProps.onChange, event);
};

private handleOverlayClose = (event?: React.SyntheticEvent<HTMLElement>) => {
const { overlayProps = {} } = this.props;
Utils.safeInvoke(overlayProps.onClose, event);
Expand Down
Loading

1 comment on commit d202674

@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] un/controlled activeItem & query! (#2747)

Preview: documentation | landing | table

Please sign in to comment.