Skip to content

Wc 7 popover component #30

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

Merged
merged 60 commits into from
Jan 11, 2017
Merged

Wc 7 popover component #30

merged 60 commits into from
Jan 11, 2017

Conversation

akdetrick
Copy link
Contributor

(marking prelim until tests are complete)

Popover menu component (wraps links into a drop down menu activated by a button)

screen shot 2016-12-28 at 3 53 57 pm

expect(el).not.toBeNull();
});
it('exists; menu hidden by default', () => {
console.warn(popoverEl);
Copy link
Contributor

Choose a reason for hiding this comment

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

you shouldn't need to log anything in tests - they should be self-documenting


it('menu dismissed on option selection', () => {
TestUtils.Simulate.click(triggerEl);
TestUtils.Simulate.click(optionEls[0]);
Copy link
Contributor

Choose a reason for hiding this comment

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

same


it('menu is keyboard navigatable with escape key', () => {
TestUtils.Simulate.click(triggerEl);
TestUtils.Simulate.keyDown(optionEls[0], {key: 'Escape'});
Copy link
Contributor

Choose a reason for hiding this comment

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

same


TestUtils.Simulate.click(triggerEl);
TestUtils.Simulate.keyUp(defaultSelected, {key: 'ArrowDown'});
Copy link
Contributor

Choose a reason for hiding this comment

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

same

this.closeMenu = this.closeMenu.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleClick = this.handleClick.bind(this);
this.handleBlur = this.handleBlur.bind(this);
Copy link
Contributor

Choose a reason for hiding this comment

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

for functional DRY kicks:

autobind(...names) {
  names.forEach(name => this[name] = this[name].bind(this));
}
constructor(props) {
  this.autobind(
    'toggleMenu',
    'closeMenu',
    'handleKeyDown',
    'handleClick',
    'handleBlur'
  );
}

we could even break out the autobind into a utility function

function bindAll(context, ...names) {
  names.forEach(name => context[name] = context[name].bind(context));
}

... it turns out that underscore has exactly this with _.bindAll

const { handleKeyDown } = this;
const isActive = this.state.isActive;
let menu;
React.Children.forEach(this.props.children, function(child) {
Copy link
Contributor

Choose a reason for hiding this comment

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

the two uses of React.Children.forEach in this component indicate that the children prop is probably being overloaded unnecessarily - instead, you can use other props to clearly separate the Trigger and the Menu, e.g.

const trigger = <PopoverTrigger ... />;
const menu = <PopoverMenu ... />;

<Popover trigger={trigger} menu={menu} ... />

and you can use Popover's propTypes to ensure that the props exist and are of the required type:

{
  trigger: React.PropTypes.instanceOf(PopoverTrigger).isRequired,
  menu: React.PropTypes.instanceOf(PopoverMenu).isRequired,
}

In general, use children for arbitrary content, and use props for things that have to conform to the component's "API".

This is related to a point made by one of the React maintainers that I came across here - I think he presents it nicely.

}
}

export default PopoverTrigger;
Copy link
Contributor

Choose a reason for hiding this comment

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

needs propTypes

className,
handleClick,
handleKeyDown,
handleBlur,
Copy link
Contributor

Choose a reason for hiding this comment

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

The React convention is to name event handlers with an on.. prefix to match up with the prop they get assigned to, e.g. onClick={onClick} - just a tiny bit of extra consistency.

});

it('menu appears on trigger click', () => {
TestUtils.Simulate.click(triggerEl);
Copy link
Contributor

Choose a reason for hiding this comment

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

for user interaction tests, it's good practice to test the state both before and after the simulated interaction so that you can be sure that something changed. It's not quite as big a deal here because the previous unit test tests the 'before' state, but it's also best practice to keep unit tests completely independent


it('menu dismissed on popover blur', () => {
TestUtils.Simulate.click(triggerEl);
TestUtils.Simulate.blur(popoverEl);
Copy link
Contributor

Choose a reason for hiding this comment

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

this would be a good place to put an expect, between the two simulated interactions, so that you can be sure the state didn't change to the final state before the blur.

const targetSelected = optionEls[1];

TestUtils.Simulate.click(triggerEl);
TestUtils.Simulate.keyUp(defaultSelected, {key: 'ArrowDown'});
Copy link
Contributor

Choose a reason for hiding this comment

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

It's worth checking for whether the 'up' arrow works since you have explicit logic for that event as well - should help your code coverage as well

@mmcgahan
Copy link
Contributor

sorry, i just saw that this was Prelim - apologies if some of the changes i recommended were already on the todo list!

const triggerEl = ReactDOM.findDOMNode(trigger);

TestUtils.Simulate.click(triggerEl);
TestUtils.Simulate.keyUp(defaultSelected, {key: 'ArrowDown'});
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 my previous comment got lost, but just to keep it visible: you should call expect before and after every simulated user interaction to ensure that the UI is in the intermediate state you expect. Without calling it before the interaction, you can't be certain that something actually changed as a consequence of the simulated interaction.

role='menu'
aria-hidden={!isActive}
>
{
Copy link
Contributor Author

@akdetrick akdetrick Jan 6, 2017

Choose a reason for hiding this comment

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

does anyone have a preference for breaking out option rendering into a separate function for readability?

Copy link
Contributor

@mmcgahan mmcgahan Jan 6, 2017

Choose a reason for hiding this comment

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

i do


edit: might as well break out the whole optionsList so that you get a complete <ul> from the separate function - it could even be a standalone component, although in this case I'm not sure it would make sense to make it into a separate module

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll go with renderOptionList as an instance method. Keeping Popover a single, self-contained component feels right.

// This zero-length timeout ensures the browser will return the
// actual focused element instead of `<body>`
window.setTimeout(() => {
const focusedOptionClass = document.activeElement.parentNode.getAttribute('class');
Copy link
Contributor

Choose a reason for hiding this comment

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

use classList to make this easier

const targetIndex = this.state.selectedIndex + delta;
const optionsLength = this.props.options.length;

if (targetIndex >= 0 && targetIndex <= optionsLength) {
Copy link
Contributor

Choose a reason for hiding this comment

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

targetIndex must be less than, not equal to, optionsLength

) {
this.selectedItemEl.props.onClick(e);
break;
} else {
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 else if you have a break in the block above, but it might be more straightforward to keep the else and put the break; outside of the if/else block

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; this felt a little messy. The line breaks made me feel better about keeping the expression in the if more readable, but is there a better way?


render() {
const isActive = this.state.isActive,
{
Copy link
Contributor

Choose a reason for hiding this comment

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

given that this set of const assignments is so long, I find it more readable to declare const at the beginning of each assignment rather than comma-separating the assignments. In theory, multiple const make for cleaner diffs in refactoring as well.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was wondering about this; sounds good to me. Chaining seems more reasonable for things like

let just,
   initializing,
   some,
   vars,
   lol;

onKeyUp,
onKeyDown,
onBlur
} = this;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nitpick, but destructuring instance properties into new consts feels like an over-abstraction that makes usage more ambiguous - leaving them as this.x makes it clear that each method is an instance method rather than a standalone function defined somewhere, with the associated expectation of context binding.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Makes sense; I can see where it could be an annoyance for someone reading this later on

) {
this.selectedItemEl.props.onClick(e);
break;
} else {
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 is how I'd write it, to completely separate the 'open' logic from the 'fire onClick' logic:

if (!this.state.isActive) {
  this.openMenu();
  return;
}
if (this.selectedItemEl && this.selectedItemEl.props.onClick) {
  this.selectedItemEl.props.onClick(e);
  return;
}

However, it does feel a little odd for this parent component to be responsible for firing the selectedItem's onClick. I wonder if you could inject an onKeyDown into each option that takes care of that functionality? It's still a little 'controlling', but at least the option fires its own action handlers. That might be over-engineering, though.

@akdetrick akdetrick removed the Prelim label Jan 9, 2017
@akdetrick akdetrick dismissed mmcgahan’s stale review January 9, 2017 21:50

issues in the first round of reviews addressed

Lori Hutchek and others added 3 commits January 9, 2017 16:59
…-components into WC-7_popover-component

* 'WC-7_popover-component' of github.com:meetup/meetup-web-components: (39 commits)
  clean up focus management
  fix tests for updated popover structure
  create dedicated renderer for option items
  rm unused icons from popover story
  rm unused components
  fire click when enter is pressed on selected option
  add story to demonstrate component flexibility
  treat user-provided `options` prop elements as the 'menuitem' role
  style fixes
  organize classNames
  working example
  add prop passed components to render
  stub popover as a single self-contained component
  assert component state for each simulated event in popover tests
  clean up popover tests
  use state and refs instead of DOM apporach to managing selected items
  fix bindAll export
  event handler naming convention
  accept string or obj for `to` prop of menu item
  manage selected index with state
  ...
@nlaz
Copy link
Contributor

nlaz commented Jan 10, 2017

Made sort of an opinionated change. Refactored options to menuItems because menuItems is more explicit.

menuItems={[
<span onClick={logSelection}>First option</span>,
<span to='somepath/' onClick={logSelection}>Second option</span>,
<span to='somepath/' onClick={logSelection}>Third option</span>,
Copy link
Contributor

Choose a reason for hiding this comment

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

this to would probably throw an error on a JSX span? 'unsupported property' or something?

React.cloneElement(menuItem,
{
tabIndex: '-1',
className: 'popover-menu-option-target',
Copy link
Contributor

Choose a reason for hiding this comment

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

Setting the className like this overrides the elements className. You can keep the className passed into the prop with:

className: `popover-menu-option-target ${menuItem.props.className}`

However, I'm curious if we want to prevent that by design. Should we allow users of the Popover component set the className of elements within menu? I'm leaning towards allowing it since it will be useful with SQ2 classes.

Copy link
Contributor

Choose a reason for hiding this comment

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

@nlaz and I talked about this on Slack, and decided to gracefully incorporate menuItem classes since MWC should try to stay as adaptable as possible.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.2%) to 74.146% when pulling 63cca90 on WC-7_popover-component into b7132c1 on master.

@coveralls
Copy link

Coverage Status

Coverage increased (+5.7%) to 80.097% when pulling 98dfbeb on WC-7_popover-component into b7132c1 on master.

@eilinora eilinora merged commit d6275df into master Jan 11, 2017
@chenrui333 chenrui333 deleted the WC-7_popover-component branch September 16, 2019 22:05
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.

5 participants