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

[Labs] New MultiSelect component #1275

Merged
merged 16 commits into from
Jul 8, 2017
Merged

[Labs] New MultiSelect component #1275

merged 16 commits into from
Jul 8, 2017

Conversation

llorca
Copy link
Contributor

@llorca llorca commented Jun 23, 2017

Fixes #118

Checklist

  • Include tests
  • Update documentation

Changes proposed in this pull request:

Final piece of #118. The new MultiSelect<T> component is similar to Select<T>. It composes QueryList<T> and TagInput together. Usage is very similar to Select<T>.

Reviewers should focus on:

  • Having a hard time defining default props. I keep getting this error: Static members cannot reference class type parameters, so for now I had to resort to componentDidMount.
  • I have a bunch of private functions to handle list manipulation for selectedItems. I'm far behind on algorithms and data structures, what can be done better here?
  • Spent a bunch of time on all the micro-interactions, I'm happy with the current boolean props and keyboard navigation. Am I missing anything?
  • Note that in the future, I'd like to pull out the single/multi selection behavior, either into its own component or baked into QueryList<T>. There's too much duplicated code across Select<T> and MultiSelect<T> at the moment.

Screenshot

screen recording google chrome

@blueprint-bot
Copy link

Merge remote-tracking branch 'origin/master' into al/multiselect2

Preview: documentation
Coverage: core | datetime

@blueprint-bot
Copy link

Fix TagInput styles in dark theme

Preview: documentation
Coverage: core | datetime

Copy link
Contributor

@giladgray giladgray left a comment

Choose a reason for hiding this comment

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

🔥 🔥 dude wow this is hot!

onKeyUp={this.state.isOpen && handleKeyUp}
>
<TagInput
inputProps={defaultInputProps}
Copy link
Contributor

Choose a reason for hiding this comment

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

oof this'll get clobbered if the user defines even one custom inputProp... gonna have to spread tagInputProps.inputProps into defaultProps.

Copy link
Contributor Author

Choose a reason for hiding this comment

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


public componentDidMount() {
// can't use defaultProps because "static members
// cannot reference class type parameters"
Copy link
Contributor

Choose a reason for hiding this comment

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

i'm sure you can use defaultProps, but you probably don't need to. safe to remove this comment as it doesn't really make sense on its own (def makes sense in PR description tho).

think two months from now--will we even know what this means? why it matters?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's the problem I'm hitting:
image

I'd still like to be able to set default props for openOnKeyDown and resetOnSelect

public componentDidMount() {
// can't use defaultProps because "static members
// cannot reference class type parameters"
this.setState({ selectedItems: this.props.selectedItems || [] });
Copy link
Contributor

Choose a reason for hiding this comment

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

going to need to update state based on prop in componentWillReceiveProps too (you've only done the initial render, not all subsequent renders).

but actually, i would kill the state field entirely and make it only controlled via props.

activeItem?: T;
isOpen?: boolean;
query?: string;
selectedItems?: T[];
Copy link
Contributor

Choose a reason for hiding this comment

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

i would argue for removing this from state entirely and supporting only controlled usage. the other state fields are about internal interactions, but selectedItems is the big deal and the user will want to track it anyway. this'll make usage and testing much simpler and more reliable.

note how Select<T> doesn't even have a prop for its value, just onItemSelect event handler for user to receive updates and manage their own state.

Copy link
Contributor

Choose a reason for hiding this comment

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

perhaps echo TagInput's API: onItemAdd, onItemRemove.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's fully controlled now, let me know what you think

Utils.safeInvoke(this.props.tagInputProps.onRemove, _item, index);
}

private handleItemSelect = (item: T, _event: React.SyntheticEvent<HTMLElement>) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

no need for _ prefix cuz it's used on the last line. same with handleTagRemove above.

// the input is no longer focused so we can close the popover
let nextState = { isOpen: false };

if (this.props.resetOnSelect) {
Copy link
Contributor

Choose a reason for hiding this comment

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

hmmm this logic happens at least three times. i'm sure we can come up with a helpful util to handle it only once, but i don't have the mental space for that right now 🌴

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let me know if you think of anything 🥇


private handleKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.which === Keys.ESCAPE
|| event.which === Keys.TAB) {
Copy link
Contributor

Choose a reason for hiding this comment

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

one line, def not long enough for two. avoid awkward alignment.

this.setState(nextState);
} else if (!(event.which === Keys.ARROW_LEFT
|| event.which === Keys.ARROW_RIGHT
|| event.which === Keys.BACKSPACE)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

deconstructing which might let this all fit on one line...

which === Keys.ARROW_LEFT || which === Keys.ARROW_RIGHT || which === Keys.BACKSPACE

Copy link
Contributor

Choose a reason for hiding this comment

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

but actually TagInput calls preventDefault() when those keys are triggered... maybe you can just check event.isDefaultPrevented instead of redefining this logic?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good catch, seems to be working!

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nevermind, event.isDefaultPrevented didn't work out so I had to put back those 3 keys

}

if (this.queryListKeyDown) {
Utils.safeInvoke(this.queryListKeyDown, event);
Copy link
Contributor

Choose a reason for hiding this comment

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

oh snap i see what's going on here. listProps.handleKeyDown is only accessible inside the callback. ok, c'est la vie 🌴

* Whether the filtering state should be reset to initial when an item is selected
* (immediately before `onItemSelect` is invoked), or when the popover closes.
* The query will become the empty string and the first item will be made active.
* @default true
Copy link
Contributor

Choose a reason for hiding this comment

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

i think this should default to false. it's quite jarring when it's on.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nah, check it out again. Makes more sense to reset the query after selection, because you're then ready to search again without having to manually clear the field. Select2 and react-select also have this default

@pkwi
Copy link
Contributor

pkwi commented Jun 26, 2017

@llorca there is some text overlapping in the droppdown:

screen shot 2017-06-26 at 2 35 39 pm

@llorca
Copy link
Contributor Author

llorca commented Jun 26, 2017

@pkwi #1136

@blueprint-bot
Copy link

Remove selected items internal state, fully controlled approach

Preview: documentation
Coverage: core | datetime

@blueprint-bot
Copy link

Better logic for opening/closing popover

Preview: documentation | table
Coverage: core | datetime

@cmslewis
Copy link
Contributor

cmslewis commented Jun 27, 2017

A couple interaction questions:

  1. Mouthful: when you keydown on the Enter key, then press the Up/Down arrows, then keyup on the Enter key; the newly focused item's selection state is toggled. This is jarring if you rapidly press Up/Down, Enter, Up/Down, Enter, Up/Down, Enter. In that case, I'd expect every item in succession to get selected, but there can be little race conditions that break that assumption if I keypress Up/Down before I keyup on Enter:

multiselect-keypresses

  1. If I navigate through the list using arrow keys, I'd expect Space to toggle the selected state of the focused item, but it actually just types a ' ' (space character) into the ghosted input. Is there any value in "reclaiming" the space bar to toggle selection if and only if the input is currently empty?

multiselect-spacebar

@@ -0,0 +1,228 @@
/*
Copy link
Contributor

Choose a reason for hiding this comment

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

i think both these files belong in the select/ directory as they share interfaces and concepts

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@blueprint-bot
Copy link

Move files to `select/` directory

Preview: documentation | table
Coverage: core | datetime

@llorca
Copy link
Contributor Author

llorca commented Jun 27, 2017

@cmslewis

  1. Nice catch! Unfortunately, this is tough to handle because our Buttons react to onKeyUp. I can nicely implement what you suggest for MultiSelect, but it'd then break Select usages that contain a button as the target. This functionality comes from QueryList. Gilad commenting on keyUp vs keyDown for enter, related to Select:

using keyup for enter to play nice with Button's keyboard clicking. if we were to process enter on keydown, then Button would click itself on keyup and the popover would re-open out of our control :(.

  1. Also a good thought! I played with it but wasn't able to get a nice UX: the space character would appear in the field, then the item would get selected, and finally the field would clear. I'm not sure how to prevent the space character from showing up when the input is focused. FWIW, my hunch is that this interaction will be more confusing than helpful as we break the consistency of a focused input receiving typed characters.

@blueprint-bot
Copy link

Properly reset activeItem after selection, if necessary

Preview: documentation | table
Coverage: core | datetime

@blueprint-bot
Copy link

Update import paths

Preview: documentation
Coverage: core | datetime

Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

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

functionality looks pretty great, nice work!

@@ -5,6 +5,7 @@
* and https://github.com/palantir/blueprint/blob/master/PATENTS
*/

export const MULTISELECT = "pt-multiselect";
Copy link
Contributor

Choose a reason for hiding this comment

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

this should be pt-multi-select

popoverProps,
resetOnSelect,
tagInputProps,
...props,
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: would prefer naming this restProps for clarity through verbosity

}

private renderQueryList = (listProps: IQueryListRendererProps<T>) => {
const { tagInputProps = {}, popoverProps = {} } = this.props;
Copy link
Contributor

Choose a reason for hiding this comment

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

minor: set these to {} with public static defaultProps instead of as destructuring defaults

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah that's what I wanted, but hit a road block. We went over this earlier in this thread. That's the problem I'm hitting:
image

I also want to be able to set default props for openOnKeyDown and resetOnSelect

Copy link
Contributor

Choose a reason for hiding this comment

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

ah, I can see that Select has this code issue as well. we can address them both outside this PR.

const { handleKeyDown, handleKeyUp, query } = listProps;
const defaultInputProps: HTMLInputProps = {
placeholder: "Search...",
...this.props.tagInputProps.inputProps,
Copy link
Contributor

Choose a reason for hiding this comment

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

this looks like it should be tagInputProps.inputProps, otherwise, this could throw a null pointer exception

const defaultInputProps: HTMLInputProps = {
placeholder: "Search...",
...this.props.tagInputProps.inputProps,
// tslint:disable-next-line:object-literal-sort-keys
Copy link
Contributor

Choose a reason for hiding this comment

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

looks like tslint is outdated (I believe we fixed this bug). but this is fine for this PR

value: query,
};

this.queryListKeyDown = handleKeyDown;
Copy link
Contributor

Choose a reason for hiding this comment

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

a little strange to see a class member being assigned in a render method... what's going on here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That handleKeyDown is given to my renderQueryList function by the renderer prop of QueryList, therefore I don't have access to it outside of this function even though I need it. Had to store it in a class member, any better option?

Copy link
Contributor

@adidahiya adidahiya Jul 7, 2017

Choose a reason for hiding this comment

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

meh yeah this is kind of hairy. would prefer to make an exception to the jsx lambda rule here and refactor this.handleKeyDown into a getter:

    <div
        onKeyDown={this.getTargetKeyDownHandler(listProps.handleKeyDown)}
        onKeyUp={this.state.isOpen && handleKeyUp}
    >

...

    private getTargetKeyDownHandler(handleQueryListKeyDown: React.EventHandler<React.KeyboardEvent<HTMLElement>>) {
        return (e: React.KeyboardEvent<HTMLElement>) => {
            const { which } = e;
            const { resetOnSelect } = this.props;

            ...

            if (this.state.isOpen) {
                Utils.safeInvoke(handleQueryListKeyDown, e);
            }
        };
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Nice

private handleItemSelect = (item: T, event: React.SyntheticEvent<HTMLElement>) => {
this.input.focus();
// make sure the query is valid by checking if activeItem is defined
if (this.state.activeItem) {
Copy link
Contributor

Choose a reason for hiding this comment

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

please compare against null (this.state.activeItem != null), don't implicitly coerce to boolean

this.setState({ query, isOpen: !(query.length === 0 && this.props.openOnKeyDown) });
}

private handleItemSelect = (item: T, event: React.SyntheticEvent<HTMLElement>) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

don't name events event (annoyingly, it conflicts with window.event), just go with e here


private handlePopoverInteraction = (nextOpenState: boolean) => requestAnimationFrame(() => {
// deferring to rAF to get properly updated activeElement
const { popoverProps = {}, resetOnSelect } = this.props;
Copy link
Contributor

Choose a reason for hiding this comment

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

again use defaultProps so that you're not setting the default in multiple places

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed above

query: resetOnSelect ? "" : this.state.query,
});
} else if (!(which === Keys.BACKSPACE || which === Keys.ARROW_LEFT || which === Keys.ARROW_RIGHT)) {
this.setState({ isOpen: true });
Copy link
Contributor

Choose a reason for hiding this comment

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

expected 4-space indentation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

How does this even happen

@blueprint-bot
Copy link

Address nits

Preview: documentation | table
Coverage: core | datetime

Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

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

lgtm after fixing merge conflicts and the one small refactor I mentioned

@blueprint-bot
Copy link

Refactor class member into getter

Preview: documentation
Coverage: core | datetime

@blueprint-bot
Copy link

Fix docs URL

Preview: documentation
Coverage: core | datetime

@adidahiya adidahiya merged commit 6a2aa12 into master Jul 8, 2017
@adidahiya adidahiya deleted the al/multiselect2 branch July 8, 2017 03:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants