Skip to content

Commit 259ce71

Browse files
liuruenshengkatsev
authored andcommitted
fix: remove event handlers when menu item is removed (#5748)
1 parent e890923 commit 259ce71

File tree

2 files changed

+183
-14
lines changed

2 files changed

+183
-14
lines changed

src/js/menu/menu.js

Lines changed: 94 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,60 @@ class Menu extends Component {
3636
this.focusedChild_ = -1;
3737

3838
this.on('keydown', this.handleKeyPress);
39+
40+
// All the menu item instances share the same blur handler provided by the menu container.
41+
this.boundHandleBlur_ = Fn.bind(this, this.handleBlur);
42+
this.boundHandleTapClick_ = Fn.bind(this, this.handleTapClick);
43+
}
44+
45+
/**
46+
* Add event listeners to the {@link MenuItem}.
47+
*
48+
* @param {Object} component
49+
* The instance of the `MenuItem` to add listeners to.
50+
*
51+
*/
52+
addEventListenerForItem(component) {
53+
if (!(component instanceof Component)) {
54+
return;
55+
}
56+
57+
component.on('blur', this.boundHandleBlur_);
58+
component.on(['tap', 'click'], this.boundHandleTapClick_);
59+
}
60+
61+
/**
62+
* Remove event listeners from the {@link MenuItem}.
63+
*
64+
* @param {Object} component
65+
* The instance of the `MenuItem` to remove listeners.
66+
*
67+
*/
68+
removeEventListenerForItem(component) {
69+
if (!(component instanceof Component)) {
70+
return;
71+
}
72+
73+
component.off('blur', this.boundHandleBlur_);
74+
component.off(['tap', 'click'], this.boundHandleTapClick_);
75+
}
76+
77+
/**
78+
* This method will be called indirectly when the component has been added
79+
* before the component adds to the new menu instance by `addItem`.
80+
* In this case, the original menu instance will remove the component
81+
* by calling `removeChild`.
82+
*
83+
* @param {Object} component
84+
* The instance of the `MenuItem`
85+
*/
86+
removeChild(component) {
87+
if (typeof component === 'string') {
88+
component = this.getChild(component);
89+
}
90+
91+
this.removeEventListenerForItem(component);
92+
super.removeChild(component);
3993
}
4094

4195
/**
@@ -46,20 +100,11 @@ class Menu extends Component {
46100
*
47101
*/
48102
addItem(component) {
49-
this.addChild(component);
50-
component.on('blur', Fn.bind(this, this.handleBlur));
51-
component.on(['tap', 'click'], Fn.bind(this, function(event) {
52-
// Unpress the associated MenuButton, and move focus back to it
53-
if (this.menuButton_) {
54-
this.menuButton_.unpressButton();
55-
56-
// don't focus menu button if item is a caption settings item
57-
// because focus will move elsewhere
58-
if (component.name() !== 'CaptionSettingsMenuItem') {
59-
this.menuButton_.focus();
60-
}
61-
}
62-
}));
103+
const childComponent = this.addChild(component);
104+
105+
if (childComponent) {
106+
this.addEventListenerForItem(childComponent);
107+
}
63108
}
64109

65110
/**
@@ -96,6 +141,8 @@ class Menu extends Component {
96141

97142
dispose() {
98143
this.contentEl_ = null;
144+
this.boundHandleBlur_ = null;
145+
this.boundHandleTapClick_ = null;
99146

100147
super.dispose();
101148
}
@@ -123,6 +170,39 @@ class Menu extends Component {
123170
}
124171
}
125172

173+
/**
174+
* Called when a `MenuItem` gets clicked or tapped.
175+
*
176+
* @param {EventTarget~Event} event
177+
* The `click` or `tap` event that caused this function to be called.
178+
*
179+
* @listens click,tap
180+
*/
181+
handleTapClick(event) {
182+
// Unpress the associated MenuButton, and move focus back to it
183+
if (this.menuButton_) {
184+
this.menuButton_.unpressButton();
185+
186+
const childComponents = this.children();
187+
188+
if (!Array.isArray(childComponents)) {
189+
return;
190+
}
191+
192+
const foundComponent = childComponents.filter(component => component.el() === event.target)[0];
193+
194+
if (!foundComponent) {
195+
return;
196+
}
197+
198+
// don't focus menu button if item is a caption settings item
199+
// because focus will move elsewhere
200+
if (foundComponent.name() !== 'CaptionSettingsMenuItem') {
201+
this.menuButton_.focus();
202+
}
203+
}
204+
}
205+
126206
/**
127207
* Handle a `keydown` event on this menu. This listener is added in the constructor.
128208
*

test/unit/menu.test.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
/* eslint-env qunit */
2+
import * as DomData from '../../src/js/utils/dom-data';
23
import MenuButton from '../../src/js/menu/menu-button.js';
4+
import Menu from '../../src/js/menu/menu.js';
5+
import CaptionSettingsMenuItem from '../../src/js/control-bar/text-track-controls/caption-settings-menu-item';
36
import MenuItem from '../../src/js/menu/menu-item.js';
47
import TestHelpers from './test-helpers.js';
58
import * as Events from '../../src/js/utils/events.js';
9+
import sinon from 'sinon';
610

711
QUnit.module('MenuButton');
812

@@ -106,3 +110,88 @@ QUnit.test('should keep all the added menu items', function(assert) {
106110
assert.ok(menuButton.el().contains(menuItem1.el()), 'the menu button contains the DOM element of `menuItem1` after second update');
107111
assert.ok(menuButton.el().contains(menuItem2.el()), 'the menu button contains the DOM element of `menuItem2` after second update');
108112
});
113+
114+
QUnit.test('should remove old event listeners when the menu item adds to the new menu', function(assert) {
115+
const player = TestHelpers.makePlayer();
116+
const menuButton = new MenuButton(player, {});
117+
const oldMenu = new Menu(player, { menuButton });
118+
const newMenu = new Menu(player, { menuButton });
119+
120+
oldMenu.addItem('MenuItem');
121+
122+
const menuItem = oldMenu.children()[0];
123+
124+
assert.ok(menuItem instanceof MenuItem, '`menuItem` should be the instanceof of `MenuItem`');
125+
126+
/**
127+
* A reusable collection of assertions.
128+
*/
129+
function validateMenuEventListeners(watchedMenu) {
130+
const eventData = DomData.getData(menuItem.eventBusEl_);
131+
// `MenuButton`.`unpressButton` will be called when triggering click event on the menu item.
132+
const unpressButtonSpy = sinon.spy(menuButton, 'unpressButton');
133+
// `MenuButton`.`focus` will be called when triggering click event on the menu item.
134+
const focusSpy = sinon.spy(menuButton, 'focus');
135+
136+
// `Menu`.`children` will be called when triggering blur event on the menu item.
137+
const menuChildrenSpy = sinon.spy(watchedMenu, 'children');
138+
139+
// The number of blur listeners is two because `ClickableComponent`
140+
// adds the blur event listener during the construction and
141+
// `MenuItem` inherits from `ClickableComponent`.
142+
assert.strictEqual(eventData.handlers.blur.length, 2, 'the number of blur listeners is two');
143+
// Same reason mentioned above.
144+
assert.strictEqual(eventData.handlers.click.length, 2, 'the number of click listeners is two');
145+
146+
const blurListenerAddedByMenu = eventData.handlers.blur[1];
147+
const clickListenerAddedByMenu = eventData.handlers.click[1];
148+
149+
assert.strictEqual(
150+
typeof blurListenerAddedByMenu.calledOnce,
151+
'undefined',
152+
'previous blur listener wrapped in the spy should be removed'
153+
);
154+
155+
assert.strictEqual(
156+
typeof clickListenerAddedByMenu.calledOnce,
157+
'undefined',
158+
'previous click listener wrapped in the spy should be removed'
159+
);
160+
161+
const blurListenerSpy = eventData.handlers.blur[1] = sinon.spy(blurListenerAddedByMenu);
162+
const clickListenerSpy = eventData.handlers.click[1] = sinon.spy(clickListenerAddedByMenu);
163+
164+
TestHelpers.triggerDomEvent(menuItem.el(), 'blur');
165+
166+
assert.ok(blurListenerSpy.calledOnce, 'blur event listener should be called');
167+
assert.strictEqual(blurListenerSpy.getCall(0).args[0].target, menuItem.el(), 'event target should be the `menuItem`');
168+
assert.ok(menuChildrenSpy.calledOnce, '`watchedMenu`.`children` has been called');
169+
170+
TestHelpers.triggerDomEvent(menuItem.el(), 'click');
171+
172+
assert.ok(clickListenerSpy.calledOnce, 'click event listener should be called');
173+
assert.strictEqual(clickListenerSpy.getCall(0).args[0].target, menuItem.el(), 'event target should be the `menuItem`');
174+
assert.ok(unpressButtonSpy.calledOnce, '`menuButton`.`unpressButtion` has been called');
175+
assert.ok(focusSpy.calledOnce, '`menuButton`.`focus` has been called');
176+
177+
unpressButtonSpy.restore();
178+
focusSpy.restore();
179+
menuChildrenSpy.restore();
180+
}
181+
182+
validateMenuEventListeners(oldMenu);
183+
184+
newMenu.addItem(menuItem);
185+
validateMenuEventListeners(newMenu);
186+
187+
const focusSpy = sinon.spy(menuButton, 'focus');
188+
const captionMenuItem = new CaptionSettingsMenuItem(player, {
189+
kind: 'subtitles'
190+
});
191+
192+
newMenu.addItem(captionMenuItem);
193+
TestHelpers.triggerDomEvent(captionMenuItem.el(), 'click');
194+
assert.ok(!focusSpy.called, '`menuButton`.`focus` should never be called');
195+
196+
focusSpy.restore();
197+
});

0 commit comments

Comments
 (0)