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

Menu widget #79

Closed
wants to merge 19 commits into from
Closed

Menu widget #79

wants to merge 19 commits into from

Conversation

mwistrand
Copy link
Contributor

Type: feature

The following has been addressed in the PR:

  • There is a related issue
  • All code matches the style guide
  • Unit or Functional tests are included in the PR

Description:

Provides the base Menu widget for #52. Once dojo/widget-core#415 lands, the DropDown widget that extends Menu, and the NavBar widget that relies on both can also be included to completely fulfill #52.

@codecov
Copy link

codecov bot commented Mar 21, 2017

Codecov Report

Merging #79 into master will decrease coverage by 0.1%.
The diff coverage is 99.61%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master      #79      +/-   ##
==========================================
- Coverage     100%   99.89%   -0.11%     
==========================================
  Files          16       18       +2     
  Lines         726      987     +261     
  Branches      202      297      +95     
==========================================
+ Hits          726      986     +260     
- Partials        0        1       +1
Impacted Files Coverage Δ
src/menu/MenuItem.ts 100% <100%> (ø)
src/menu/Menu.ts 99.53% <99.53%> (ø)

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update edf2b58...1d2f2e7. Read the comment docs.

src/menu/Menu.ts Outdated

@theme(css)
export class Menu extends ThemeableMixin(WidgetBase)<MenuProperties> {
protected _wasOpen = false;
Copy link
Member

Choose a reason for hiding this comment

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

I don't think protected properties should be prefixed with _, also is there any reason why this is protected?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah; didn't realize about the prefix. There is a lot of shared functionality between Menu and a future DropDown widget that will extend it, in which case using private is not really an option.

src/menu/Menu.ts Outdated
}

@theme(css)
export class Menu extends ThemeableMixin(WidgetBase)<MenuProperties> {
Copy link
Member

Choose a reason for hiding this comment

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

You need to declare a const with the result of ThemableMixin(WidgetBase). Usually with the naming convention const MenuBase = Themeable(WidgetBase) and used to extend like class Menu extends MenuBase<MenuProperties> {.

Copy link
Member

Choose a reason for hiding this comment

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

You also need to export that const. So export const MenuBase = Themeable(WidgetBase).

Copy link
Member

Choose a reason for hiding this comment

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

Yup sorry, I should have put that (basically need to export all the things that are used publicly)

src/menu/Menu.ts Outdated
private _hideTimer: Handle;
private _initialRender = true;

onLabelClick() {
Copy link
Member

Choose a reason for hiding this comment

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

Most of the class functions should be private

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These types of functions aren't private in the other widgets?

Copy link
Member

Choose a reason for hiding this comment

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

They should be if they are not an API intended to be overridden to modify behaviour in widgets that extend it. I think @bitpshr did get round to updating Combo.

Copy link
Member

Choose a reason for hiding this comment

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

The visibility throughout was updated as part of the ComboBox PR. Most things are private as @agubler said.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ok. I'll have to find some way to test those methods that doesn't require using <any> hacks that will break once we start emitting directly to ES6. I should be able to access the event listeners directly of vnode.properties, correct?

Copy link
Member

Choose a reason for hiding this comment

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

All of our existing tests use the hacky (<any> menu)._onFoo() method for testing. I'd just use that for now; we'll have to do a full sweep of the components once @kitsonk's harness lands anyway.

src/menu/Menu.ts Outdated
this._animate(element, hidden);
}

protected _animate(element: HTMLElement, hidden: boolean) {
Copy link
Member

Choose a reason for hiding this comment

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

Is this not possible with css?

Copy link
Member

Choose a reason for hiding this comment

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

Shouldn't the hidden value be taken from this.properties?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Animating to and from auto is not possible with CSS.

Copy link
Member

Choose a reason for hiding this comment

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

The animation is being done using CSS. It just needs an explicit value to animate to besides auto. @mwistrand see comment above re: marginTop and offsetHeight.

Copy link
Member

Choose a reason for hiding this comment

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

Okay. Do you think that we should expose a property that can provide an overriding function for the animation?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@agubler We already do. properties.animate can be a boolean or a function. If it's a boolean, then it enables/disables the default animation. If it's a function, then the built-in _animate function is bypassed in favor of the property function.

Copy link
Member

@agubler agubler Mar 21, 2017

Choose a reason for hiding this comment

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

I guess my question was, do we think it is valid to accept an overriding function for the animate?

(Sorry if that wasn't clear, I'm happy with the boolean switch)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Gotcha. animate as a function was a last minute add. Given that as long as the height is set in the build-in function, most animations can be handled correctly in CSS, I'll remove this option.

src/menu/Menu.ts Outdated
id,
role,
classes: this.classes.apply(this, this._getMenuClasses(hidden, Boolean(label))),
afterCreate: this._afterCreate,
Copy link
Member

Choose a reason for hiding this comment

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

Can use the onElementCreated and onElementUpdated functions provided by WidgetBase now (dojo/widget-core#382)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great. I'll update those.

src/menu/Menu.ts Outdated
} = this.properties;

if (typeof isHidden === 'undefined') {
const { hidden = this._getDefaultHidden() } = this.properties;
Copy link
Member

Choose a reason for hiding this comment

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

This can be done in the original destructuring.

Copy link
Member

Choose a reason for hiding this comment

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

Also what is the difference between isHidden and this.properties.hidden?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

isHidden is a way to force which state is requested. I'll update the parameter name to make that obvious.

Copy link
Member

Choose a reason for hiding this comment

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

What do you think about using argument defaults?

class Test { 

    properties: any = {};

    private _getDefaultHidden(): boolean {
        return true;
    }

    private _toggleDisplay(isHidden = this.properties.hidden || this._getDefaultHidden()) { 

    }
}

Copy link
Member

Choose a reason for hiding this comment

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

Could be cleaner if this_getDefaultHidden returns the property hidden if it existed

}), label ? [ label ] : undefined);

if (this.children.length) {
const children = this.children as HNode[];
Copy link
Member

Choose a reason for hiding this comment

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

Why do you need the cast?

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 might not anymore. I'll double check, but I did need it at one point.

/**
* Returns an object of aria properties to apply to the widget's DOM element.
*/
getAriaProperties?: () => { [key: string]: string; };
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't feel like an external property?

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 doesn't, but this will be used in a few different ways requiring different ARIA properties. For example, use in Menu requires one set of properties, whereas use with a drop down would require other properties. This seemed like the easiest way to provide any flexibility needed with hard-coding a bunch of scenarios directly within MenuItem.

Copy link
Member

Choose a reason for hiding this comment

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

Do @bitpshr or @smhigley have any suggestions for the ARIA properties of Menu?

Copy link
Member

Choose a reason for hiding this comment

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

@smhigley may have specifics, but you can check https://www.w3.org/WAI/tutorials/menus/. @smhigley and I have been adding a camel-cased property without the "aria" for each ARIA attribute that needs to be set externally. Example. We should stay consistent.

}

@theme(css)
export class MenuItem extends ThemeableMixin(WidgetBase)<MenuItemProperties> {
Copy link
Member

Choose a reason for hiding this comment

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

Same comment about creating a base const for the expression result.

Copy link
Member

Choose a reason for hiding this comment

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

And exporting!

@@ -0,0 +1,26 @@
<!DOCTYPE html>
Copy link
Member

Choose a reason for hiding this comment

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

Don't need the specific widget example index.html anymore, this is centralised in common

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, I just remembered I hadn't moved this yet last night.

src/menu/Menu.ts Outdated
export type Role = 'menu' | 'menubar';

export interface MenuProperties extends ThemeableProperties {
/**
Copy link
Member

Choose a reason for hiding this comment

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

These comments should be formatted using TypeDoc.

<body>
<script src="../../../../node_modules/@dojo/loader/loader.min.js"></script>
<link rel="stylesheet" href="../../common/styles/widgets.css">
<link rel="stylesheet" href="./styles/app.css">
Copy link
Member

Choose a reason for hiding this comment

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

Looks like you put structural styling in the component CSS and theme styling in this example CSS. For now, I'd just put everything in the component CSS. This stuff will be properly separated as part of #51.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sounds good. I knew that was being separated out eventually, so I just did that here. I'll move it back into the main menu.css, perhaps with notes disambiguating any styles that could go either way.

src/menu/Menu.ts Outdated
}

@theme(css)
export class Menu extends ThemeableMixin(WidgetBase)<MenuProperties> {
Copy link
Member

Choose a reason for hiding this comment

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

You also need to export that const. So export const MenuBase = Themeable(WidgetBase).

src/menu/Menu.ts Outdated
/**
* Indicates whether the menu is disabled. If true, then the widget will ignore events.
*/
disabled?: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

Would an entire menu really be disabled? Wouldn't each item within it be disabled instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If label is specified, then a MenuItem is created and disabled is passed to it, which keeps it from being expanded.

src/menu/Menu.ts Outdated
* Indicates whether the menu is nested within another menu. Useful for styling, this does not affect
* functionality. Defaults to false.
*/
nested?: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

Do we need this? If it's just for styling, can we use child selectors to detect if something is nested?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Strictly speaking, we could use child selectors to manage the same thing. But since composes only works with single selectors, I added the extra class to be safe.

/**
* Applies only when a URL is provided. If `true`, the URL will be opened in a new window.
*/
external?: boolean;
Copy link
Member

Choose a reason for hiding this comment

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

Wouldn't a user just put an anchor as the child to this MenuItem? I don't think the MenuItem should handle link behavior.

/**
* Returns an object of aria properties to apply to the widget's DOM element.
*/
getAriaProperties?: () => { [key: string]: string; };
Copy link
Member

Choose a reason for hiding this comment

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

@smhigley may have specifics, but you can check https://www.w3.org/WAI/tutorials/menus/. @smhigley and I have been adding a camel-cased property without the "aria" for each ARIA attribute that needs to be set externally. Example. We should stay consistent.

}

@theme(css)
export class MenuItem extends ThemeableMixin(WidgetBase)<MenuItemProperties> {
Copy link
Member

Choose a reason for hiding this comment

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

And exporting!

selected ? css.selected : null
);

const labelNode = v('a', assign(ariaProperties, {
Copy link
Member

Choose a reason for hiding this comment

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

You could spread ariaProperties instead of assigning.

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 update to explicitly list all possible ARIA attributes, which will also make destructuring possible/assigning unnecessary.

role: 'menuitem',
tabIndex : disabled ? -1 : tabIndex,
target: url && external ? '_blank' : undefined
}), label ? [ label ] : undefined);
Copy link
Member

Choose a reason for hiding this comment

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

This is odd to me. Menus don't have to contain links so I don't think we should be always creating an anchor. I also don't think the MenuItem should handle hrefs or targets or anything links-specific. It should just put its children in an element so it can still capture events, and that's it. Let the user make the child an anchor if they want.

@dylans dylans added this to the 2017.03 milestone Mar 22, 2017
@mwistrand mwistrand force-pushed the 52-menu-widget branch 3 times, most recently from f7a8679 to 52f22b9 Compare March 28, 2017 20:35
Copy link
Contributor

@smhigley smhigley left a comment

Choose a reason for hiding this comment

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

Added some accessibility changes. On a high level, each menu should only have one element in the tab index at any given time. Once focus moves into the menu, arrow keys should be the main mode of navigating through it. So lots of keyboard event handling, yay!

onkeypress: this.onKeypress,
role: 'menuitem',
tabIndex : disabled ? -1 : tabIndex
}, this.children);
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think widget consumers should be able to mess with the tabIndex. It should be fully controlled by the menu widget, and there should only be one focusable menu item per menu (i.e. all but the active menu item should have tabIndex: -1, and the active item should have tabIndex: 0. disabled shouldn't affect the tabindex at all, actually.

return v('span', {
'aria-controls': controls,
'aria-expanded': expanded,
'aria-hasdropdown': hasDropDown,
Copy link
Contributor

Choose a reason for hiding this comment

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

This is aria-haspopup, and needs to be converted to a string. I would do 'aria-haspopup': hasDropDown ? 'true' : null, since aria-haspopup="false" is unnecessary, and I think it looks cleaner to leave it off entirely. Also, since we already use hasPopup, it might be better to use that property name for consistency.

Copy link
Contributor

Choose a reason for hiding this comment

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

Additionally, both aria-expanded and aria-disabled need to be cast to strings

src/menu/Menu.ts Outdated
} = this.properties;

const label = this.renderLabel(id);
const menu = v('nav', {
Copy link
Contributor

Choose a reason for hiding this comment

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

This results in multiple nested nav elements if the menu has sub-menus. Additionally, some of the use cases for even non-nested menus probably shouldn't use a nav (e.g. the browser actions menu in the example). I'd be a little inclined to just make it a div and let users wrap it in a v('nav' ...) themselves, but I suppose we could also add a property to control it.

Copy link
Contributor

@smhigley smhigley Mar 29, 2017

Choose a reason for hiding this comment

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

Let's also add an aria-labelledby that points back to the label, just for funsies. (only if it's a dropdown)

src/menu/Menu.ts Outdated
const {
id = uuid(),
nested,
role = 'menu'
Copy link
Contributor

Choose a reason for hiding this comment

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

The top-level menu should have a role of menubar, at least if it's visually persistent

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Again, this depends on the default use case. If we are assuming that Menu will be used predominantly for main app navigation, then, yes, menubar should be the default.

Copy link
Contributor

Choose a reason for hiding this comment

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

True. It should probably be tied to whatever determines whether or not we use nav, if we decide to control that.

src/menu/Menu.ts Outdated
this._hideTimer && this._hideTimer.destroy();
this.toggleDisplay(true);
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

When the dropdown can be opened on hover, we should probably consider having it open on focus as well

src/menu/Menu.ts Outdated
if (!disabled && (key === 'Enter' || key === 13)) {
this.toggleDisplay();
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The menu should show when you hit the down, enter, or space keys. All of them should place focus on the first item in the sub menu when menu opens, and focus should return to the menu trigger before it closes.

Copy link
Contributor

Choose a reason for hiding this comment

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

Oh, and all the keypress events should probably be keydown so they catch arrow keys

const { disabled, onKeypress } = this.properties;
if (!disabled && typeof onKeypress === 'function') {
onKeypress(event);
}
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 we should manage key events in a little more depth:

  • up/down arrow keys should move focus to the previous or next menu item, and wrap beginning <--> end.
  • left/right should do the same on horizontal menus
  • space/enter: activates the menu item and closes the dropdown menu (if applicable)
  • space on a checkbox: toggles the checkbox but doesn't close the menu
  • escape: closes the menu

src/menu/Menu.ts Outdated
nested?: boolean;
onRequestHide?: () => void;
onRequestShow?: () => void;
role?: Role;
Copy link
Contributor

Choose a reason for hiding this comment

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

We should probably also have an orientation attribute that defaults to horizontal, at least for top-level menus.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

An orientation attribute makes sense, but given the generic use case for the Menu widget, do we really want to make it the default? @bitpshr?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I wasn't all that sure about horizontal either. I don't have strong opinions either way, I just suggested horizontal because I think that's the default browser assumption for menus.

src/menu/Menu.ts Outdated
overrideClasses: overrideClasses || css,
onClick: this.onLabelClick,
onKeypress: this.onLabelKeypress
}, [ label ]);
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 probably have an id, so the dropdown can refer back to it

@dylans dylans modified the milestones: 2017.03, 2017.04 Apr 2, 2017
selected ? css.selected : null
);

return v(tag, assign(Object.create(null), properties, {
Copy link
Member

Choose a reason for hiding this comment

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

It is necessary to pass the tag through here, I don't believe we are doing this anywhere else.

Copy link
Member

Choose a reason for hiding this comment

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

Is there any need for the assign call here over simple passing properties ?

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'd be fine with internally determining the tag name based on the specified type property. As for assign, it cleanly separates the properties that are specific to and required by MenuItem from those needed only by the underlying DOM node and have nothing to do with Menu functionality. Also, since other widgets (e.g., Button) use generic property names for the aria- properties, I tried to do the same thing here.

'aria-haspopup': hasPopup ? 'true' : undefined,
'aria-disabled': String(disabled),
classes,
key: 'menu-item',
Copy link
Member

Choose a reason for hiding this comment

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

I think that the key should be set by the parent or should at least use the menu item id or there will be no difference.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm. Both ComboBox and SlidePane use a static key for controlling how onElementCreated behaves.

}), this.children);
}

protected onClick(event: MouseEvent) {
Copy link
Member

Choose a reason for hiding this comment

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

these are usually private

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Correct; initially I included all the functionality for dropdowns within Menu, but after the widget logic became too complicated decided that it might be better to have a dedicated DropDown widget that extends Menu, in which case using private limits what can be done.

protected onKeydown(event: KeyboardEvent) {
const { disabled, onKeydown } = this.properties;
if (!disabled) {
if (event.keyCode === 32) { // space
Copy link
Member

Choose a reason for hiding this comment

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

do you think it would be better to define a const SPACE_KEY = 32 and use that here instead of needing a comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for catching that. I thought I'd updated all of those.

if (event.keyCode === 32) { // space
(<HTMLElement> event.target).click();
}
typeof onKeydown === 'function' && onKeydown(event);
Copy link
Member

Choose a reason for hiding this comment

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

The pattern elsewhere for consistency is onKeyDown && onKeyDown(event).
Can onKeydown take any form other than undefined or a function? Also do you think down should be changed to have a capital D?

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 update to onKeyDown since that's the pattern used everywhere else.

@@ -0,0 +1,66 @@
@import '../../common/styles/variables.css';

.container {
Copy link
Member

Choose a reason for hiding this comment

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

can you change this to root please?

transition: height 0.5s ease-in-out;
}

.hidden { height: 0; }
Copy link
Member

Choose a reason for hiding this comment

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

I think that these classes should be using the base.css classes. We should have a consistent approach to show / hidden

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately .hidden in base.css uses display: none, which makes animation difficult; hence the need for the local classes. One option would be to change the class name to collapsed, but when applied to drop downs hidden feels more natural.

box-sizing: border-box;
overflow: hidden;
padding: 0 15px;
transition: height 0.5s ease-in-out;
Copy link
Member

Choose a reason for hiding this comment

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

Can you please use the short animation time variable here

protected onClick(event: MouseEvent) {
const { disabled, onClick } = this.properties;
if (!disabled && typeof onClick === 'function') {
onClick(event);
Copy link
Member

Choose a reason for hiding this comment

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

Would it be more advantageous to return the item id / key here instead of the click event? There's likely no need to expose the dom click event to the parent Menu.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The Menu widget does not need access to the event, but there may be instances in which an application needs to call event.preventDefault on a MenuItem click event.

Matt Wistrand added 15 commits April 4, 2017 14:34
- Use `properties.hidden` directly instead of passing it around.
- Disambiguate `isHidden` to `requestShow` in `_toggleDisplay`.
- Create an export base classes from Menu and MenuItem.
- Remove underscore prefix from protected class members.
- Fix doc comments for Menu/MenuItem properties.
- Fix MenuItem child type.
- Remove the ability to specify `animate` as a function.
- Remove `getAriaProperties` in favor of individual properties.
- Simplify the MenuItem widget.
- Remove unused import
- Remove menu/example.html
- Fix example page
- Use `KeyboardEvent#keyCode` where `key` is unavailable
- Consolidate menu CSS into a single module
- Ensure menu is scrolled to top when animating open
- Use `onElementCreated` and `onElementUpdated`
- Correct use of `KeyboardEvent#keyCode`
- Privacy updates
`MenuItem`:
- Allow tag and vnode properties to be set.
- Fix ARIA properties.
- Allow different `role`s to be applied.

`Menu`:
- Update keyboard handling to more accurately match the WAI-ARIA spec.
- Add handling for right/left arrows, space key.
- Internalize active index management.
- Add an `orientation` property.
- Rename event listener names for consistency with other widgets.
- Change `container`/`nestedMenuContainer` CSS classes to
  `root`/`nestedMenuRoot`, respectively.
- Use CSS variable for menu animation duration.
- Simplify function property checks.
- Only move focus from the label into the menu when navigating via the
  keyboard.
- Add `hideOnActivate` options.
- Always expand submenus when the designated "descend" key is pressed.
Copy link
Contributor

@smhigley smhigley left a comment

Choose a reason for hiding this comment

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

Seems like enough for round 2 :)

src/menu/Menu.ts Outdated
onfocusout: this.onMenuFocusOut,
onkeydown: this.onMenuKeyDown,
role,
tabIndex: this.state.active || label ? -1 : 0
Copy link
Contributor

Choose a reason for hiding this comment

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

I actually don't think the container needs to have a tabIndex at all. Semantically it doesn't make much sense, it creates extra work managing initial focus, and it should be more straightforward to just have a single menu item with tabIndex: 0 in menus without a label.

Copy link
Contributor

Choose a reason for hiding this comment

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

There are also some weird things happening with my ability to focus on the second menu after I've opened and closed its sub-menu. I think everything would be simplified by always having exactly one top-level menu item with tabIndex: 0, and directly moving focus as little as possible.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Based on our private conversation, I can update MenuItem to store a reference to its DOM node so that focus can be moved out of onElementUpdated and changed in response to keyboard events. That way, this hack workaround can be removed.

src/menu/Menu.ts Outdated
} = this.properties;

if (id) {
this._id = id;
Copy link
Contributor

Choose a reason for hiding this comment

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

Could use destructuring instead, i.e.

const {
   id = this._id,
   ...
} = this.properties


protected onElementUpdated(element: HTMLElement, key: string) {
if (key === 'root') {
this.properties.active && element.focus();
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 one problem is properties.active is controlling both current focus and tabIndex. There are times when a menu item should have tabIndex: 0 without being the currently focused page element.

This is also messing with normal focus behavior. For example, clicking on something else should move focus, but it doesn't b/c this sets focus back on the menu.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

MenuItems now have both an active property for determining whether it should be focused and a focusable property for determining the tabIndex.

'aria-controls': controls,
'aria-expanded': String(expanded),
'aria-haspopup': hasPopup ? 'true' : undefined,
'aria-disabled': String(disabled),
Copy link
Contributor

Choose a reason for hiding this comment

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

aria-expanded and aria-disabled should only show up if true: 'aria-expanded': expanded ? 'true' : null

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm. The WAI-ARIA example always shows aria-expanded.

Copy link
Contributor

Choose a reason for hiding this comment

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

@mwistrand yeah, sorry, it should be aria-expanded: hasPopup ? String(expanded) : null

return v(tag, assign(Object.create(null), properties, itemProperties), this.children);
}

protected onClick(event: MouseEvent) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Clicking on a menu item also needs to update it to active, same as navigating to it with the keyboard.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed!

const { disabled, onKeyDown } = this.properties;
if (!disabled) {
if (event.keyCode === SPACE_KEY) {
(<HTMLElement> event.target).click();
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you need to trigger a fake click on event.target? Why not just do onClick && onClick(event)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If clicking a MenuItem just opens a link in a new window, for example, then calling onClick 1) requires the MenuItem to manually navigate to the underlying URL within its onClick handler and 2) potentially causes problems with popup blockers that are circumvented by just calling click().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

By "popup blocker", I mean built-in browser functionality.

if (event.keyCode === SPACE_KEY) {
(<HTMLElement> event.target).click();
}
onKeyDown && onKeyDown(event);
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 probably be the first thing called, since onkeydown would be called before onclick with the enter key.

src/menu/Menu.ts Outdated
return true;
}

return label ? true : false;
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 not sure why you need the if (label && disabled) block, since it still boils down to label ? true : false.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

lol. You're right.

src/menu/Menu.ts Outdated
this.toggleDisplay();
}
else if (key === keys.descend) {
this.toggleDisplay(true);
Copy link
Contributor

Choose a reason for hiding this comment

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

Also need to preventDefault if the key is up/down to prevent page scrolling, same for onkeydown on menu items

}
},
onClick: toggleSelected,
selected: <boolean> this.state[`${key}Selected`]
Copy link
Contributor

Choose a reason for hiding this comment

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

These seem like they're functioning as checkboxes with the selected property. Should they have role: checkbox even though the text seems to imply otherwise?

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 a good point. Since they are using selected, they should have role: checkbox.

Matt Wistrand added 3 commits April 5, 2017 13:14
- general cleanup
- remove tabIndex from menu container
- reset active index on hide
- move focus on click
- Prevent the default action when the down arrow key is pressed while
  the menu trigger is focused.
- Add `parentOrientation` property to ensure that vertical submenus of
  horizontal menus are opened with down arrow key instead of the right
  arrow key.
@mwistrand
Copy link
Contributor Author

Closing in favor of #104.

@mwistrand mwistrand closed this Apr 10, 2017
@tomdye tomdye mentioned this pull request Jan 30, 2020
29 tasks
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