-
Notifications
You must be signed in to change notification settings - Fork 7
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
Conversation
expect(el).not.toBeNull(); | ||
}); | ||
it('exists; menu hidden by default', () => { | ||
console.warn(popoverEl); |
There was a problem hiding this comment.
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]); |
There was a problem hiding this comment.
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'}); |
There was a problem hiding this comment.
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'}); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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'}); |
There was a problem hiding this comment.
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
sorry, i just saw that this was |
const triggerEl = ReactDOM.findDOMNode(trigger); | ||
|
||
TestUtils.Simulate.click(triggerEl); | ||
TestUtils.Simulate.keyUp(defaultSelected, {key: 'ArrowDown'}); |
There was a problem hiding this comment.
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} | ||
> | ||
{ |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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'); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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, | ||
{ |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 const
s 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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
issues in the first round of reviews addressed
…-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 ...
Made sort of an opinionated change. Refactored |
menuItems={[ | ||
<span onClick={logSelection}>First option</span>, | ||
<span to='somepath/' onClick={logSelection}>Second option</span>, | ||
<span to='somepath/' onClick={logSelection}>Third option</span>, |
There was a problem hiding this comment.
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?
…-components into WC-7_popover-component
React.cloneElement(menuItem, | ||
{ | ||
tabIndex: '-1', | ||
className: 'popover-menu-option-target', |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
(marking prelim until tests are complete)
Popover menu component (wraps links into a drop down menu activated by a button)