Skip to content
This repository was archived by the owner on Jan 13, 2025. It is now read-only.

Commit 02fe795

Browse files
authored
feat(menu): Fix menu to only fire one event per interaction
BREAKING CHANGE: Adds an adapter method eventTargetHasClass to check if a given event target has a given class
1 parent 7466eff commit 02fe795

File tree

7 files changed

+83
-13
lines changed

7 files changed

+83
-13
lines changed

packages/mdc-menu/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@ The adapter for simple menu must provide the following functions, with correct s
269269
| `hasClass(className: string) => boolean` | Returns boolean indicating whether element has a given class. |
270270
| `hasNecessaryDom() => boolean` | Returns boolean indicating whether the necessary DOM is present (namely, the `mdc-simple-menu__items` container). |
271271
| `getAttributeForEventTarget(target: EventTarget, attributeName: string) => string` | Returns the value of a given attribute on an event target. |
272+
| `eventTargetHasClass: (target: EventTarget, className: string) => boolean` | Returns true if the event target has a given class. |
272273
| `getInnerDimensions() => {width: number, height: number}` | Returns an object with the items container width and height |
273274
| `hasAnchor: () => boolean` | Returns whether the menu has an anchor for positioning. |
274275
| `getAnchorDimensions() => { width: number, height: number, top: number, right: number, bottom: number, left: number }` | Returns an object with the dimensions and position of the anchor (same semantics as `DOMRect`). |

packages/mdc-menu/simple/adapter.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,13 @@ class MDCSimpleMenuAdapter {
5959
*/
6060
getAttributeForEventTarget(target, attributeName) {}
6161

62+
/**
63+
* @param {EventTarget} target
64+
* @param {string} className
65+
* @return {boolean}
66+
*/
67+
eventTargetHasClass(target, className) {}
68+
6269
/** @return {{ width: number, height: number }} */
6370
getInnerDimensions() {}
6471

packages/mdc-menu/simple/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ const cssClasses = {
2323
TOP_RIGHT: 'mdc-simple-menu--open-from-top-right',
2424
BOTTOM_LEFT: 'mdc-simple-menu--open-from-bottom-left',
2525
BOTTOM_RIGHT: 'mdc-simple-menu--open-from-bottom-right',
26+
LIST_ITEM: 'mdc-list-item',
2627
};
2728

2829
/** @enum {string} */

packages/mdc-menu/simple/foundation.js

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ class MDCSimpleMenuFoundation extends MDCFoundation {
5151
hasClass: () => false,
5252
hasNecessaryDom: () => false,
5353
getAttributeForEventTarget: () => {},
54+
eventTargetHasClass: () => {},
5455
getInnerDimensions: () => ({}),
5556
hasAnchor: () => false,
5657
getAnchorDimensions: () => ({}),
@@ -91,10 +92,7 @@ class MDCSimpleMenuFoundation extends MDCFoundation {
9192
/** @private {function(!Event)} */
9293
this.keyupHandler_ = (evt) => this.handleKeyboardUp_(evt);
9394
/** @private {function(!Event)} */
94-
this.documentClickHandler_ = (evt) => {
95-
this.adapter_.notifyCancel();
96-
this.close(evt);
97-
};
95+
this.documentClickHandler_ = (evt) => this.handleDocumentClick_(evt);
9896
/** @private {boolean} */
9997
this.isOpen_ = false;
10098
/** @private {number} */
@@ -275,6 +273,25 @@ class MDCSimpleMenuFoundation extends MDCFoundation {
275273
}
276274
}
277275

276+
/**
277+
* Handle clicks and cancel the menu if not a list item
278+
* @param {!Event} evt
279+
* @private
280+
*/
281+
handleDocumentClick_(evt) {
282+
let el = evt.target;
283+
284+
while (el && el !== document.documentElement) {
285+
if (this.adapter_.eventTargetHasClass(el, cssClasses.LIST_ITEM)) {
286+
return;
287+
}
288+
el = el.parentNode;
289+
}
290+
291+
this.adapter_.notifyCancel();
292+
this.close(evt);
293+
};
294+
278295
/**
279296
* Handle keys that we want to repeat on hold (tab and arrows).
280297
* @param {!Event} evt

packages/mdc-menu/simple/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ class MDCSimpleMenu extends MDCComponent {
8888
hasClass: (className) => this.root_.classList.contains(className),
8989
hasNecessaryDom: () => Boolean(this.itemsContainer_),
9090
getAttributeForEventTarget: (target, attributeName) => target.getAttribute(attributeName),
91+
eventTargetHasClass: (target, className) => target.classList.contains(className),
9192
getInnerDimensions: () => {
9293
const {itemsContainer_: itemsContainer} = this;
9394
return {width: itemsContainer.offsetWidth, height: itemsContainer.offsetHeight};

test/unit/mdc-menu/mdc-simple-menu.test.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,9 @@ function getFixture(open) {
3737

3838
function setupTest(open = false) {
3939
const root = getFixture(open);
40+
const listItem = root.querySelector('.mdc-list-item');
4041
const component = new MDCSimpleMenu(root);
41-
return {root, component};
42+
return {root, listItem, component};
4243
}
4344

4445
suite('MDCSimpleMenu');
@@ -121,6 +122,13 @@ test('adapter#getAttributeForEventTarget returns the value of an attribute for a
121122
assert.equal(component.getDefaultFoundation().adapter_.getAttributeForEventTarget(target, attrName), attrVal);
122123
});
123124

125+
test('adapter#eventTargetHasClass returns true if a target has a given classname', () => {
126+
const {listItem, component} = setupTest();
127+
listItem.classList.add('foo');
128+
129+
assert.isTrue(component.getDefaultFoundation().adapter_.eventTargetHasClass(listItem, 'foo'));
130+
});
131+
124132
test('adapter#hasNecessaryDom returns false if the DOM does not include the items container', () => {
125133
const {root, component} = setupTest();
126134
const items = root.querySelector(strings.ITEMS_SELECTOR);

test/unit/mdc-menu/simple.foundation.test.js

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,12 +62,13 @@ test('exports numbers', () => {
6262

6363
test('defaultAdapter returns a complete adapter implementation', () => {
6464
verifyDefaultAdapter(MDCSimpleMenuFoundation, [
65-
'addClass', 'removeClass', 'hasClass', 'hasNecessaryDom', 'getAttributeForEventTarget', 'getInnerDimensions',
66-
'hasAnchor', 'getAnchorDimensions', 'getWindowDimensions', 'setScale', 'setInnerScale', 'getNumberOfItems',
67-
'registerInteractionHandler', 'deregisterInteractionHandler', 'registerBodyClickHandler',
68-
'deregisterBodyClickHandler', 'getYParamsForItemAtIndex', 'setTransitionDelayForItemAtIndex',
69-
'getIndexForEventTarget', 'notifySelected', 'notifyCancel', 'saveFocus', 'restoreFocus', 'isFocused', 'focus',
70-
'getFocusedItemIndex', 'focusItemAtIndex', 'isRtl', 'setTransformOrigin', 'setPosition', 'getAccurateTime',
65+
'addClass', 'removeClass', 'hasClass', 'hasNecessaryDom', 'getAttributeForEventTarget',
66+
'eventTargetHasClass', 'getInnerDimensions', 'hasAnchor', 'getAnchorDimensions', 'getWindowDimensions',
67+
'setScale', 'setInnerScale', 'getNumberOfItems', 'registerInteractionHandler', 'deregisterInteractionHandler',
68+
'registerBodyClickHandler', 'deregisterBodyClickHandler', 'getYParamsForItemAtIndex',
69+
'setTransitionDelayForItemAtIndex', 'getIndexForEventTarget', 'notifySelected', 'notifyCancel', 'saveFocus',
70+
'restoreFocus', 'isFocused', 'focus', 'getFocusedItemIndex', 'focusItemAtIndex', 'isRtl', 'setTransformOrigin',
71+
'setPosition', 'getAccurateTime',
7172
]);
7273
});
7374

@@ -837,17 +838,26 @@ test('on spacebar keydown prevents default on the event', () => {
837838
clock.uninstall();
838839
});
839840

840-
testFoundation('on document click cancels and closes the menu', ({foundation, mockAdapter, mockRaf}) => {
841+
test('on document click cancels and closes the menu', () => {
842+
const {foundation, mockAdapter} = setupTest();
843+
const mockRaf = createMockRaf();
844+
const mockEvt = {
845+
target: {},
846+
};
841847
let documentClickHandler;
842848
td.when(mockAdapter.registerBodyClickHandler(td.matchers.isA(Function))).thenDo((handler) => {
843849
documentClickHandler = handler;
844850
});
851+
td.when(mockAdapter.eventTargetHasClass(td.matchers.anything(), cssClasses.LIST_ITEM))
852+
.thenReturn(false);
853+
854+
td.when(mockAdapter.hasClass(MDCSimpleMenuFoundation.cssClasses.OPEN)).thenReturn(true);
845855

846856
foundation.init();
847857
foundation.open();
848858
mockRaf.flush();
849859

850-
documentClickHandler();
860+
documentClickHandler(mockEvt);
851861
mockRaf.flush();
852862

853863
td.verify(mockAdapter.removeClass(cssClasses.OPEN));
@@ -856,6 +866,31 @@ testFoundation('on document click cancels and closes the menu', ({foundation, mo
856866
mockRaf.restore();
857867
});
858868

869+
test('on menu item click does not emit cancel', () => {
870+
const {foundation, mockAdapter} = setupTest();
871+
const mockRaf = createMockRaf();
872+
const mockEvt = {
873+
target: {},
874+
};
875+
let documentClickHandler;
876+
td.when(mockAdapter.registerBodyClickHandler(td.matchers.isA(Function))).thenDo((handler) => {
877+
documentClickHandler = handler;
878+
});
879+
td.when(mockAdapter.eventTargetHasClass(td.matchers.anything(), cssClasses.LIST_ITEM))
880+
.thenReturn(true);
881+
882+
foundation.init();
883+
foundation.open();
884+
mockRaf.flush();
885+
886+
documentClickHandler(mockEvt);
887+
mockRaf.flush();
888+
889+
td.verify(mockAdapter.notifyCancel(), {times: 0});
890+
891+
mockRaf.restore();
892+
});
893+
859894
testFoundation('should cancel animation after destroy', ({foundation, mockAdapter, mockRaf}) => {
860895
foundation.init();
861896
mockRaf.flush();

0 commit comments

Comments
 (0)