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

fix(chips): Don't trigger ripple from trailing icon #2286

Merged
merged 12 commits into from
Feb 23, 2018
5 changes: 4 additions & 1 deletion packages/mdc-chips/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,10 @@ Method Signature | Description
`hasClass(className: string) => boolean` | Returns true if the root element contains the given class
`registerInteractionHandler(evtType: string, handler: EventListener) => void` | Registers an event listener on the root element
`deregisterInteractionHandler(evtType: string, handler: EventListener) => void` | Deregisters an event listener on the root element
`notifyInteraction() => void` | Emits a custom event "MDCChip:interaction" denoting the chip has been interacted with, which bubbles to the parent `mdc-chip-set` element
`registerTrailingIconInteractionHandler(evtType: string, handler: EventListener) => void` | Registers an event listener on the trailing icon element
`deregisterTrailingIconInteractionHandler(evtType: string, handler: EventListener) => void` | Deregisters an event listener on the trailing icon element
`notifyInteraction() => void` | Emits a custom event `MDCChip:interaction` denoting the chip has been interacted with, which bubbles to the parent `mdc-chip-set` element
`notifyTrailingIconInteraction() => void` | Emits a custom event `MDCChip:trailingIconInteraction` denoting the chip's trailing icon has been interacted with, which bubbles to the parent `mdc-chip-set` element

#### `MDCChipSetAdapter`

Expand Down
20 changes: 20 additions & 0 deletions packages/mdc-chips/chip/adapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,31 @@ class MDCChipAdapter {
*/
deregisterInteractionHandler(evtType, handler) {}

/**
* Registers an event listener on the trailing icon element for a given event.
* @param {string} evtType
* @param {function(!Event): undefined} handler
*/
registerTrailingIconInteractionHandler(evtType, handler) {}

/**
* Deregisters an event listener on the trailing icon element for a given event.
* @param {string} evtType
* @param {function(!Event): undefined} handler
*/
deregisterTrailingIconInteractionHandler(evtType, handler) {}

/**
* Emits a custom "MDCChip:interaction" event denoting the chip has been
* interacted with (typically on click or keydown).
*/
notifyInteraction() {}

/**
* Emits a custom "MDCChip:trailingIconInteraction" event denoting the trailing icon has been
* interacted with (typically on click or keydown).
*/
notifyTrailingIconInteraction() {}
}

export default MDCChipAdapter;
2 changes: 2 additions & 0 deletions packages/mdc-chips/chip/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
/** @enum {string} */
const strings = {
INTERACTION_EVENT: 'MDCChip:interaction',
TRAILING_ICON_INTERACTION_EVENT: 'MDCChip:trailingIconInteraction',
TRAILING_ICON_SELECTOR: '.mdc-chip__icon--trailing',
};

/** @enum {string} */
Expand Down
25 changes: 25 additions & 0 deletions packages/mdc-chips/chip/foundation.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,10 @@ class MDCChipFoundation extends MDCFoundation {
hasClass: () => {},
registerInteractionHandler: () => {},
deregisterInteractionHandler: () => {},
registerTrailingIconInteractionHandler: () => {},
deregisterTrailingIconInteractionHandler: () => {},
notifyInteraction: () => {},
notifyTrailingIconInteraction: () => {},
});
}

Expand All @@ -59,17 +62,27 @@ class MDCChipFoundation extends MDCFoundation {

/** @private {function(!Event): undefined} */
this.interactionHandler_ = (evt) => this.handleInteraction_(evt);
/** @private {function(!Event): undefined} */
this.trailingIconInteractionHandler_ = (evt) => this.handleTrailingIconInteraction_(evt);
}

init() {
['click', 'keydown'].forEach((evtType) => {
this.adapter_.registerInteractionHandler(evtType, this.interactionHandler_);
this.adapter_.registerTrailingIconInteractionHandler(evtType, this.trailingIconInteractionHandler_);
});
['touchstart', 'pointerdown', 'mousedown'].forEach((evtType) => {
this.adapter_.registerTrailingIconInteractionHandler(evtType, this.trailingIconInteractionHandler_);
});
}

destroy() {
['click', 'keydown'].forEach((evtType) => {
this.adapter_.deregisterInteractionHandler(evtType, this.interactionHandler_);
this.adapter_.deregisterTrailingIconInteractionHandler(evtType, this.trailingIconInteractionHandler_);
});
['touchstart', 'pointerdown', 'mousedown'].forEach((evtType) => {
this.adapter_.deregisterTrailingIconInteractionHandler(evtType, this.trailingIconInteractionHandler_);
});
}

Expand All @@ -93,6 +106,18 @@ class MDCChipFoundation extends MDCFoundation {
this.adapter_.notifyInteraction();
}
}

/**
* Handles an interaction event on the trailing icon element. This is used to
* prevent the ripple from activating on interaction with the trailing icon.
* @param {!Event} evt
*/
handleTrailingIconInteraction_(evt) {
evt.stopPropagation();
Copy link
Contributor

Choose a reason for hiding this comment

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

Are we eventually going to emit our own event on trailing icon click? I'm very wary of anything that adds stopPropagation with no alternative, since it tends to bite us later.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call! I updated it so the trailing icon emits a custom event.

In a later PR we'll capture this event in the chip and handle it if it's a remove icon.

if (evt.type === 'click' || evt.key === 'Enter' || evt.keyCode === 13) {
this.adapter_.notifyTrailingIconInteraction();
}
}
}

export default MDCChipFoundation;
14 changes: 14 additions & 0 deletions packages/mdc-chips/chip/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,21 @@ class MDCChip extends MDCComponent {
hasClass: (className) => this.root_.classList.contains(className),
registerInteractionHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler),
deregisterInteractionHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler),
registerTrailingIconInteractionHandler: (evtType, handler) => {
const trailingIconEl = this.root_.querySelector(strings.TRAILING_ICON_SELECTOR);
if (trailingIconEl) {
trailingIconEl.addEventListener(evtType, handler);
}
},
deregisterTrailingIconInteractionHandler: (evtType, handler) => {
const trailingIconEl = this.root_.querySelector(strings.TRAILING_ICON_SELECTOR);
if (trailingIconEl) {
trailingIconEl.removeEventListener(evtType, handler);
}
},
notifyInteraction: () => this.emit(strings.INTERACTION_EVENT, {chip: this}, true /* shouldBubble */),
notifyTrailingIconInteraction: () => this.emit(
strings.TRAILING_ICON_INTERACTION_EVENT, {chip: this}, true /* shouldBubble */),
})));
}

Expand Down
37 changes: 30 additions & 7 deletions test/unit/mdc-chips/mdc-chip.foundation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import {assert} from 'chai';
import td from 'testdouble';

import {verifyDefaultAdapter} from '../helpers/foundation';
import {verifyDefaultAdapter, captureHandlers} from '../helpers/foundation';
import {setupFoundationTest} from '../helpers/setup';
import MDCChipFoundation from '../../../packages/mdc-chips/chip/foundation';

Expand All @@ -36,7 +36,9 @@ test('exports cssClasses', () => {
test('defaultAdapter returns a complete adapter implementation', () => {
verifyDefaultAdapter(MDCChipFoundation, [
'addClass', 'removeClass', 'hasClass',
'registerTrailingIconInteractionHandler', 'deregisterTrailingIconInteractionHandler',
'registerInteractionHandler', 'deregisterInteractionHandler', 'notifyInteraction',
'notifyTrailingIconInteraction',
]);
});

Expand All @@ -48,6 +50,11 @@ test('#init adds event listeners', () => {

td.verify(mockAdapter.registerInteractionHandler('click', td.matchers.isA(Function)));
td.verify(mockAdapter.registerInteractionHandler('keydown', td.matchers.isA(Function)));
td.verify(mockAdapter.registerTrailingIconInteractionHandler('click', td.matchers.isA(Function)));
td.verify(mockAdapter.registerTrailingIconInteractionHandler('keydown', td.matchers.isA(Function)));
td.verify(mockAdapter.registerTrailingIconInteractionHandler('touchstart', td.matchers.isA(Function)));
td.verify(mockAdapter.registerTrailingIconInteractionHandler('pointerdown', td.matchers.isA(Function)));
td.verify(mockAdapter.registerTrailingIconInteractionHandler('mousedown', td.matchers.isA(Function)));
});

test('#destroy removes event listeners', () => {
Expand All @@ -56,6 +63,11 @@ test('#destroy removes event listeners', () => {

td.verify(mockAdapter.deregisterInteractionHandler('click', td.matchers.isA(Function)));
td.verify(mockAdapter.deregisterInteractionHandler('keydown', td.matchers.isA(Function)));
td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('click', td.matchers.isA(Function)));
td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('keydown', td.matchers.isA(Function)));
td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('touchstart', td.matchers.isA(Function)));
td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('pointerdown', td.matchers.isA(Function)));
td.verify(mockAdapter.deregisterTrailingIconInteractionHandler('mousedown', td.matchers.isA(Function)));
});

test('#toggleActive adds mdc-chip--activated class if the class does not exist', () => {
Expand All @@ -76,17 +88,28 @@ test('#toggleActive removes mdc-chip--activated class if the class exists', () =

test('on click, emit custom event', () => {
const {foundation, mockAdapter} = setupTest();
const handlers = captureHandlers(mockAdapter, 'registerInteractionHandler');
const mockEvt = {
type: 'click',
};
let click;

td.when(mockAdapter.registerInteractionHandler('click', td.matchers.isA(Function))).thenDo((evtType, handler) => {
click = handler;
});

foundation.init();
click(mockEvt);
handlers.click(mockEvt);

td.verify(mockAdapter.notifyInteraction());
});

test('on click in trailing icon, emit custom event', () => {
Copy link
Contributor

Choose a reason for hiding this comment

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

You might be able to save some code here by using test/unit/helpers.captureHandlers. Example usage within ripple: https://github.com/material-components/material-components-web/blob/master/test/unit/mdc-ripple/foundation-activation.test.js#L27

(You can pass additional properties to the functions it returns, to be assigned to the event object.)

Also, should we actually stub stopPropagation and test that it gets called? (I realize right now it needs to exist simply to not throw an error in the first place, but it could also test unintended behavior changes later.)

const {foundation, mockAdapter} = setupTest();
const handlers = captureHandlers(mockAdapter, 'registerTrailingIconInteractionHandler');
const mockEvt = {
type: 'click',
stopPropagation: td.func('stopPropagation'),
};

foundation.init();
handlers.click(mockEvt);

td.verify(mockAdapter.notifyTrailingIconInteraction());
td.verify(mockEvt.stopPropagation());
});
45 changes: 43 additions & 2 deletions test/unit/mdc-chips/mdc-chip.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,37 @@ test('#adapter.deregisterInteractionHandler removes event listener for a given e
td.verify(handler(td.matchers.anything()), {times: 0});
});

test('#adapter.notifyInteraction emits ' +
`${MDCChipFoundation.strings.INTERACTION_EVENT}`, () => {
test('#adapter.registerTrailingIconInteractionHandler adds event listener for a given event to the trailing' +
'icon element', () => {
const {root, component} = setupTest();
const icon = bel`
<i class="material-icons mdc-chip__icon mdc-chip__icon--trailing" tabindex="0" role="button">cancel</i>
`;
root.appendChild(icon);
const handler = td.func('click handler');
component.getDefaultFoundation().adapter_.registerTrailingIconInteractionHandler('click', handler);
domEvents.emit(icon, 'click');

td.verify(handler(td.matchers.anything()));
});

test('#adapter.deregisterTrailingIconInteractionHandler removes event listener for a given event from the trailing ' +
'icon element', () => {
const {root, component} = setupTest();
const icon = bel`
<i class="material-icons mdc-chip__icon mdc-chip__icon--trailing" tabindex="0" role="button">cancel</i>
`;
root.appendChild(icon);
const handler = td.func('click handler');

icon.addEventListener('click', handler);
component.getDefaultFoundation().adapter_.deregisterTrailingIconInteractionHandler('click', handler);
domEvents.emit(icon, 'click');

td.verify(handler(td.matchers.anything()), {times: 0});
});

test('#adapter.notifyInteraction emits ' + MDCChipFoundation.strings.INTERACTION_EVENT, () => {
const {component} = setupTest();
const handler = td.func('interaction handler');

Expand All @@ -90,6 +119,18 @@ test('#adapter.notifyInteraction emits ' +
td.verify(handler(td.matchers.anything()));
});

test('#adapter.notifyTrailingIconInteraction emits ' +
MDCChipFoundation.strings.TRAILING_ICON_INTERACTION_EVENT, () => {
const {component} = setupTest();
const handler = td.func('interaction handler');

component.listen(
MDCChipFoundation.strings.TRAILING_ICON_INTERACTION_EVENT, handler);
component.getDefaultFoundation().adapter_.notifyTrailingIconInteraction();

td.verify(handler(td.matchers.anything()));
});

function setupMockFoundationTest(root = getFixture()) {
const MockFoundationConstructor = td.constructor(MDCChipFoundation);
const mockFoundation = new MockFoundationConstructor();
Expand Down