Skip to content

Commit

Permalink
feat(menu): Add selection event support
Browse files Browse the repository at this point in the history
* Add `MDLSimpleMenu:selected` event which is broadcast when a menu item
  is selected
* Change notion of "items" in menu to mean only item elements which are
  selectable as actual menu items, rather than just children of the
  items container.
* Add logic for validating that a correct `role` attribute is placed on
  the vanilla menu component.

Part of #4475
[#126819221]
  • Loading branch information
traviskaufman committed Nov 9, 2016
1 parent 78958b0 commit faa9532
Show file tree
Hide file tree
Showing 7 changed files with 467 additions and 32 deletions.
12 changes: 11 additions & 1 deletion demos/simple-menu.html
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ <h1>MDL Simple Menu</h1>
Dark mode
</label>
</p>
<div><button class="toggle">Toggle</button></div>
<div>
<button class="toggle">Toggle</button>
<span>Last Selected item: <em id="last-selected">&lt;none selected&gt;</em></span>
</div>
<div class="mdl-simple-menu">
<ul class="mdl-simple-menu__items mdl-list" role="menu" aria-hidden="true">
<li class="mdl-list-item" role="menuitem" tabindex="0">Back</li>
Expand Down Expand Up @@ -109,6 +112,13 @@ <h1>MDL Simple Menu</h1>
}
});
}

var lastSelected = document.getElementById('last-selected');
menuEl.addEventListener('MDLSimpleMenu:selected', function(evt) {
const detail = evt.detail;
lastSelected.textContent = '"' + detail.item.textContent.trim() +
'" at index ' + detail.index;
});
</script>
</body>
</html>
26 changes: 22 additions & 4 deletions packages/mdl-menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,13 @@ You can start the menu in its open state by adding the `mdl-simple-menu--open` c
</div>
```


### Using the JS Component

> **N.B.**: The use of `role` on both the menu's internal items list, as well as on each item, is
> _mandatory_. You may either use the role of `menu` on the list with a role of `menuitem` on each
> list item, or a role of `listbox` on the list with a role of `option` on each list item. Further
> composite roles may be supported in the future.
MDL Simple Menu ships with a Component / Foundation combo which allows for frameworks to richly integrate the
correct menu behaviors into idiomatic components.

Expand Down Expand Up @@ -108,6 +112,16 @@ import MDLSimpleMenu from 'mdl-menu';
const menu = new MDLSimpleMenu(document.querySelector('.mdl-simple-menu'));
```

#### Handling selection events

When a menu item is selected, the menu component will emit a `MDLSimpleMenu:selected` custom event
with the following `detail` data:

| property name | type | description |
| --- | --- | --- |
| `item` | `HTMLElement` | The DOM element for the selected item |
| `index` | `number` | The index of the selected item |

### Using the Foundation Class

MDL Simple Menu ships with an `MDLSimpleMenuFoundation` class that external frameworks and libraries can use to
Expand All @@ -123,6 +137,10 @@ The adapter for temporary drawers must provide the following functions, with cor
| `getInnerDimensions() => {width: number, height: number}` | Returns an object with the items container width and height |
| `setScale(x: string, y: string) => void` | Sets the transform on the root element to the provided (x, y) scale. |
| `setInnerScale(x: string, y: string) => void` | Sets the transform on the items container to the provided (x, y) scale. |
| `getNumberOfItems() => numbers` | Returns the number of elements inside the items container. |
| `getYParamsForItemAtIndex(index: number) => {top: number, height: number}` | Returns an object with the offset top and offset height values for the element inside the items container at the provided index |
| `setTransitionDelayForItemAtIndex(index: number, value: string) => void` | Sets the transition delay on the element inside the items container at the provided index to the provided value. |
| `getNumberOfItems() => numbers` | Returns the number of _item_ elements inside the items container. In our vanilla component, we determine this by counting the number of list items whose `role` attribute corresponds to the correct child role of the role present on the menu list element. For example, if the list element has a role of `menu` this queries for all elements that have a role of `menuitem`. |
| `registerInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type`. |
| `deregisterInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type`. |
| `getYParamsForItemAtIndex(index: number) => {top: number, height: number}` | Returns an object with the offset top and offset height values for the _item_ element inside the items container at the provided index. Note that this is an index into the list of _item_ elements, and not necessarily every child element of the list. |
| `setTransitionDelayForItemAtIndex(index: number, value: string) => void` | Sets the transition delay on the element inside the items container at the provided index to the provided value. The same notice for `index` applies here as above. |
| `getIndexForEventTarget(target: EventTarget) => number` | Checks to see if the `target` of an event pertains to one of the menu items, and if so returns the index of that item. Returns -1 if the target is not one of the menu items. The same notice for `index` applies here as above. |
| `notifySelected(evtData: {index: number}) => void` | Dispatches an event notifying listeners that a menu item has been selected. The function should accept an `evtData` parameter containing the an object with an `index` property representing the index of the selected item. Implementations may choose to supplement this data with additional data, such as the item itself. |
14 changes: 14 additions & 0 deletions packages/mdl-menu/simple/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export const strings = {
};

export const numbers = {
// Amount of time to wait before triggering a selected event on the menu. Note that this time
// will most likely be bumped up once interactive lists are supported to allow for the ripple to
// animate before closing the menu
SELECTED_TRIGGER_DELAY: 50,
// Total duration of the menu animation.
TRANSITION_DURATION_MS: 300,
// The menu starts its open animation with the X axis at this time value (0 - 1).
Expand All @@ -42,3 +46,13 @@ export const numbers = {
TRANSITION_X2: 0.2,
TRANSITION_Y2: 1
};

// Mapping between composite aria roles supported by the simple menu to roles owned
// by that composite role. This should be used in order to query for DOM elements within
// the menu that represent actual menu items, e.g. `[role="menuitem"]` for a simple menu with
// role="menu", or `[role="option"]` for a simple menu with role="listbox". For more information
// see https://www.w3.org/TR/wai-aria/roles#composite.
export const PARENT_CHILD_ROLES = {
menu: 'menuitem',
listbox: 'option'
};
55 changes: 46 additions & 9 deletions packages/mdl-menu/simple/foundation.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,34 @@ export default class MDLSimpleMenuFoundation extends MDLFoundation {
setScale: (/* x: number, y: number */) => {},
setInnerScale: (/* x: number, y: number */) => {},
getNumberOfItems: () => /* number */ 0,
registerInteractionHandler: (/* type: string, handler: EventListener */) => {},
deregisterInteractionHandler: (/* type: string, handler: EventListener */) => {},
getYParamsForItemAtIndex: (/* index: number */) => /* {top: number, height: number} */ ({}),
setTransitionDelayForItemAtIndex: (/* index: number, value: string */) => {}
setTransitionDelayForItemAtIndex: (/* index: number, value: string */) => {},
getIndexForEventTarget: (/* target: EventTarget */) => /* number */ 0,
notifySelected: (/* evtData: {index: number} */) => {}
};
}

constructor(adapter) {
super(Object.assign(MDLSimpleMenuFoundation.defaultAdapter, adapter));
this.keyupHandler_ = evt => {
const {keyCode, key} = evt;
const isEnter = key === 'Enter' || keyCode === 13;
const isSpace = key === 'Space' || keyCode === 32;
if (isEnter || isSpace) {
this.handlePossibleSelected_(evt);
}
};
this.clickHandler_ = evt => this.handlePossibleSelected_(evt);
this.isOpen_ = false;
this.startScaleX_ = 0;
this.startScaleY_ = 0;
this.targetScale_ = 1;
this.scaleX_ = 0;
this.scaleY_ = 0;
this.running_ = false;
this.selectedTriggerTimerId_ = 0;
}

init() {
Expand All @@ -63,16 +84,32 @@ export default class MDLSimpleMenuFoundation extends MDLFoundation {

if (this.adapter_.hasClass(OPEN)) {
this.isOpen_ = true;
} else {
this.isOpen_ = false;
}

this.startScaleX_ = 0;
this.startScaleY_ = 0;
this.targetScale_ = 1;
this.scaleX_ = 0;
this.scaleY_ = 0;
this.running_ = false;
this.adapter_.registerInteractionHandler('click', this.clickHandler_);
this.adapter_.registerInteractionHandler('keyup', this.keyupHandler_);
}

destroy() {
clearTimeout(this.selectedTriggerTimerId_);
this.adapter_.deregisterInteractionHandler('click', this.clickHandler_);
this.adapter_.deregisterInteractionHandler('keyup', this.keyupHandler_);
}

handlePossibleSelected_(evt) {
const targetIndex = this.adapter_.getIndexForEventTarget(evt.target);
if (targetIndex < 0) {
return;
}
// Debounce multiple selections
if (this.selectedTriggerTimerId_) {
return;
}
this.selectedTriggerTimerId_ = setTimeout(() => {
this.selectedTriggerTimerId_ = 0;
this.close();
this.adapter_.notifySelected({index: targetIndex});
}, numbers.SELECTED_TRIGGER_DELAY);
}

// Calculate transition delays for individual menu items, so that they fade in one at a time.
Expand Down
52 changes: 44 additions & 8 deletions packages/mdl-menu/simple/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import {MDLComponent} from 'mdl-base';
import {PARENT_CHILD_ROLES} from './constants';
import MDLSimpleMenuFoundation from './foundation';
import {getTransformPropertyName} from '../util';

Expand All @@ -38,33 +39,68 @@ export class MDLSimpleMenu extends MDLComponent {
}

/* Return the item container element inside the component. */
get items() {
get itemsContainer_() {
return this.root_.querySelector(MDLSimpleMenuFoundation.strings.ITEMS_SELECTOR);
}

/* Return the items within the menu. Note that this only contains the set of elements within
* the items container that are proper list items, and not supplemental / presentational DOM
* elements.
*/
get items() {
const {itemsContainer_: itemsContainer} = this;
const childRole = PARENT_CHILD_ROLES[itemsContainer.getAttribute('role')];
return [].slice.call(itemsContainer.querySelectorAll(`[role="${childRole}"]`));
}

getDefaultFoundation() {
return new MDLSimpleMenuFoundation({
addClass: className => this.root_.classList.add(className),
removeClass: className => this.root_.classList.remove(className),
hasClass: className => this.root_.classList.contains(className),
hasNecessaryDom: () => Boolean(this.items),
hasNecessaryDom: () => Boolean(this.itemsContainer_),
getInnerDimensions: () => {
const {items} = this;
return {width: items.offsetWidth, height: items.offsetHeight};
const {itemsContainer_: itemsContainer} = this;
return {width: itemsContainer.offsetWidth, height: itemsContainer.offsetHeight};
},
setScale: (x, y) => {
this.root_.style[getTransformPropertyName(window)] = `scale(${x}, ${y})`;
},
setInnerScale: (x, y) => {
this.items.style[getTransformPropertyName(window)] = `scale(${x}, ${y})`;
this.itemsContainer_.style[getTransformPropertyName(window)] = `scale(${x}, ${y})`;
},
getNumberOfItems: () => this.items.children.length,
getNumberOfItems: () => this.items.length,
registerInteractionHandler: (type, handler) => this.root_.addEventListener(type, handler),
deregisterInteractionHandler: (type, handler) => this.root_.removeEventListener(type, handler),
getYParamsForItemAtIndex: index => {
const {offsetTop: top, offsetHeight: height} = this.items.children[index];
const {offsetTop: top, offsetHeight: height} = this.items[index];
return {top, height};
},
setTransitionDelayForItemAtIndex: (index, value) =>
this.items.children[index].style.setProperty('transition-delay', value)
this.items[index].style.setProperty('transition-delay', value),
getIndexForEventTarget: target => this.items.indexOf(target),
notifySelected: evtData => this.emit('MDLSimpleMenu:selected', {
index: evtData.index,
item: this.items[evtData.index]
})
});
}

initialSyncWithDOM() {
this.validateRole_();
}

validateRole_() {
const VALID_ROLES = Object.keys(PARENT_CHILD_ROLES);
const role = this.itemsContainer_.getAttribute('role');
if (!role) {
throw new Error(
'Missing "role" attribute on menu items list element. A "role" attribute is needed for the menu to ' +
`function properly. Please choose one of ${VALID_ROLES}`
);
}
if (VALID_ROLES.indexOf(role) < 0) {
throw new Error(`Invalid menu items list role "${role}." Please choose one of ${VALID_ROLES}`);
}
}
}
79 changes: 71 additions & 8 deletions test/unit/mdl-menu/mdl-simple-menu.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import test from 'tape';
import bel from 'bel';
import domEvents from 'dom-events';
import td from 'testdouble';

import {MDLSimpleMenu} from '../../../packages/mdl-menu/simple';
import {strings} from '../../../packages/mdl-menu/simple/constants';
Expand All @@ -24,8 +26,10 @@ import {getTransformPropertyName} from '../../../packages/mdl-menu/util';
function getFixture() {
return bel`
<div class="mdl-simple-menu">
<nav class="mdl-simple-menu__items mdl-list">
<a class="mdl-list-item" href="#">Item</a>
<ul class="mdl-simple-menu__items mdl-list" role="menu">
<li class="mdl-list-item" role="menuitem">Item</a>
<li role="separator"></li>
<li class="mdl-list-item" role="menuitem">Another Item</a>
</nav>
</div>
`;
Expand All @@ -52,10 +56,24 @@ test('get/set open', t => {
t.end();
});

test('items returns the container element for the menu items', t => {
test('constructor throws if no role on menu items', t => {
t.throws(() => new MDLSimpleMenu(bel`
<div class="mdl-simple-menu"><ul class="mdl-simple-menu__items"></ul></div>
`));
t.end();
});

test('constructor throws if role on menu is invalid (not menu or listbox)', t => {
t.throws(() => new MDLSimpleMenu(bel`
<div class="mdl-simple-menu"><ul class="mdl-simple-menu__items" role="dialog"></ul></div>
`));
t.end();
});

test('items returns all menu items', t => {
const {root, component} = setupTest();
const items = root.querySelector(strings.ITEMS_SELECTOR);
t.equal(component.items, items);
const items = [].slice.call(root.querySelectorAll('[role="menuitem"]'));
t.deepEqual(component.items, items);
t.end();
});

Expand Down Expand Up @@ -124,10 +142,29 @@ test('adapter#setInnerScale sets the correct transform on the items container',
t.end();
});

test('adapter#getNumberOfItems returns the number of elements within the items container', t => {
test('adapter#getNumberOfItems returns the number of item elements within the items container', t => {
const {root, component} = setupTest();
const items = root.querySelector(strings.ITEMS_SELECTOR);
t.equal(component.getDefaultFoundation().adapter_.getNumberOfItems(), items.children.length);
const numberOfItems = root.querySelectorAll('[role="menuitem"]').length;
t.equal(component.getDefaultFoundation().adapter_.getNumberOfItems(), numberOfItems);
t.end();
});

test('adapter#registerInteractionHandler proxies to addEventListener', t => {
const {root, component} = setupTest();
const handler = td.func('interactionHandler');
component.getDefaultFoundation().adapter_.registerInteractionHandler('foo', handler);
domEvents.emit(root, 'foo');
t.doesNotThrow(() => td.verify(handler(td.matchers.anything())));
t.end();
});

test('adapter#deregisterInteractionHandler proxies to removeEventListener', t => {
const {root, component} = setupTest();
const handler = td.func('interactionHandler');
root.addEventListener('foo', handler);
component.getDefaultFoundation().adapter_.deregisterInteractionHandler('foo', handler);
domEvents.emit(root, 'foo');
t.doesNotThrow(() => td.verify(handler(td.matchers.anything()), {times: 0}));
t.end();
});

Expand Down Expand Up @@ -155,3 +192,29 @@ test('adapter#setTransitionDelayForItemAtIndex sets the correct transition-delay
t.equal(root.querySelector(strings.ITEMS_SELECTOR).children[0].style.transitionDelay, '0.42s');
t.end();
});

test('adapter#getIndexForEventTarget returns the item index of the event target', t => {
const {root, component} = setupTest();
const target = root.querySelectorAll('[role="menuitem"]')[1];
t.equal(component.getDefaultFoundation().adapter_.getIndexForEventTarget(target), 1);
t.equal(component.getDefaultFoundation().adapter_.getIndexForEventTarget({}), -1, 'missing index = -1');
t.end();
});

test('adapter#notifySelected fires an "MDLSimpleMenu:selected" custom event with the item and index', t => {
const {root, component} = setupTest();
const item = root.querySelectorAll('[role="menuitem"]')[0];
const handler = td.func('notifySelected handler');
let evtData = null;
td.when(handler(td.matchers.isA(Object))).thenDo(evt => {
evtData = evt.detail;
});
root.addEventListener('MDLSimpleMenu:selected', handler);
component.getDefaultFoundation().adapter_.notifySelected({index: 0});
t.true(evtData !== null);
t.deepEqual(evtData, {
item,
index: 0
});
t.end();
});
Loading

0 comments on commit faa9532

Please sign in to comment.