Skip to content

Commit

Permalink
feat(chips): Consolidate interaction event handlers (material-compone…
Browse files Browse the repository at this point in the history
…nts#5251)

Consolidate interaction event handlers into discrete root-level handlers: handleClick and handleKeydown.

BREAKING CHANGE: the handleInteraction and handleTrailingIconInteraction handlers have been removed from the MDCChipFoundation. The handleClick handler has been added to the MDCChipFoundation
  • Loading branch information
patrickrodee authored Nov 20, 2019
1 parent 591a6ad commit 5729943
Show file tree
Hide file tree
Showing 5 changed files with 122 additions and 107 deletions.
10 changes: 4 additions & 6 deletions packages/mdc-chips/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,10 +445,9 @@ Method Signature | Description
`setShouldRemoveOnTrailingIconClick(shouldRemove: boolean) => void` | Sets whether a trailing icon click should trigger exit/removal of the chip
`getDimensions() => ClientRect` | Returns the dimensions of the chip. This is used for applying ripple to the chip.
`beginExit() => void` | Begins the exit animation which leads to removal of the chip
`handleInteraction(evt: Event) => void` | Handles an interaction event on the root element
`handleTransitionEnd(evt: Event) => void` | Handles a transition end event on the root element
`handleTrailingIconInteraction(evt: Event) => void` | Handles an interaction event on the trailing icon element
`handleClick(evt: Event) => void` | Handles a click event on the root element
`handleKeydown(evt: Event) => void` | Handles a keydown event on the root element
`handleTransitionEnd(evt: Event) => void` | Handles a transition end event on the root element
`removeFocus() => void` | Removes focusability from the chip

#### `MDCChipFoundation` Event Handlers
Expand All @@ -457,10 +456,9 @@ When wrapping the Chip foundation, the following events must be bound to the ind

Events | Element Selector | Foundation Handler
--- | --- | ---
`click`, `keydown` | `.mdc-chip` (root) | `handleInteraction()`
`click`, `keydown` | `.mdc-chip__icon--trailing` (if present) | `handleTrailingIconInteraction()`
`transitionend` | `.mdc-chip` (root) | `handleTransitionEnd()`
`click` | `.mdc-chip` (root) | `handleClick()`
`keydown` | `.mdc-chip` (root) | `handleKeydown()`
`transitionend` | `.mdc-chip` (root) | `handleTransitionEnd()`

#### `MDCChipSetFoundation`

Expand Down
34 changes: 4 additions & 30 deletions packages/mdc-chips/chip/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,6 @@ import {MDCChipFoundation} from './foundation';
import {MDCChipInteractionEventDetail, MDCChipNavigationEventDetail, MDCChipRemovalEventDetail,
MDCChipSelectionEventDetail} from './types';

type InteractionType = 'click' | 'keydown';

const INTERACTION_EVENTS: InteractionType[] = ['click', 'keydown'];

export type MDCChipFactory = (el: Element, foundation?: MDCChipFoundation) => MDCChip;

export class MDCChip extends MDCComponent<MDCChipFoundation> implements MDCRippleCapableSurface {
Expand Down Expand Up @@ -84,20 +80,17 @@ export class MDCChip extends MDCComponent<MDCChipFoundation> implements MDCRippl
root_!: HTMLElement; // assigned in MDCComponent constructor

private leadingIcon_!: Element | null; // assigned in initialize()
private trailingIcon_!: Element | null; // assigned in initialize()
private checkmark_!: Element | null; // assigned in initialize()
private ripple_!: MDCRipple; // assigned in initialize()
private primaryAction_!: Element | null; // assigned in initialize()
private trailingAction_!: Element | null; // assigned in initialize()

private handleInteraction_!: SpecificEventListener<InteractionType>; // assigned in initialSyncWithDOM()
private handleClick_!: SpecificEventListener<'click'>; // assigned in initialSyncWithDOM()
private handleTransitionEnd_!: SpecificEventListener<'transitionend'>; // assigned in initialSyncWithDOM()
private handleTrailingIconInteraction_!: SpecificEventListener<InteractionType>; // assigned in initialSyncWithDOM()
private handleKeydown_!: SpecificEventListener<'keydown'>; // assigned in initialSyncWithDOM()

initialize(rippleFactory: MDCRippleFactory = (el, foundation) => new MDCRipple(el, foundation)) {
this.leadingIcon_ = this.root_.querySelector(strings.LEADING_ICON_SELECTOR);
this.trailingIcon_ = this.root_.querySelector(strings.TRAILING_ICON_SELECTOR);
this.checkmark_ = this.root_.querySelector(strings.CHECKMARK_SELECTOR);
this.primaryAction_ = this.root_.querySelector(strings.PRIMARY_ACTION_SELECTOR);
this.trailingAction_ = this.root_.querySelector(strings.TRAILING_ACTION_SELECTOR);
Expand All @@ -112,40 +105,21 @@ export class MDCChip extends MDCComponent<MDCChipFoundation> implements MDCRippl
}

initialSyncWithDOM() {
this.handleInteraction_ = (evt: MouseEvent | KeyboardEvent) => this.foundation_.handleInteraction(evt);
this.handleClick_ = (evt: MouseEvent) => this.foundation_.handleClick(evt);
this.handleTransitionEnd_ = (evt: TransitionEvent) => this.foundation_.handleTransitionEnd(evt);
this.handleTrailingIconInteraction_ = (evt: MouseEvent | KeyboardEvent) =>
this.foundation_.handleTrailingIconInteraction(evt);
this.handleKeydown_ = (evt: KeyboardEvent) => this.foundation_.handleKeydown(evt);

INTERACTION_EVENTS.forEach((evtType) => {
this.listen(evtType, this.handleInteraction_);
});
this.listen('click', this.handleClick_);
this.listen('transitionend', this.handleTransitionEnd_);
this.listen('keydown', this.handleKeydown_);

if (this.trailingIcon_) {
INTERACTION_EVENTS.forEach((evtType) => {
this.trailingIcon_!.addEventListener(evtType, this.handleTrailingIconInteraction_ as EventListener);
});
}
}

destroy() {
this.ripple_.destroy();

INTERACTION_EVENTS.forEach((evtType) => {
this.unlisten(evtType, this.handleInteraction_);
});
this.unlisten('click', this.handleClick_);
this.unlisten('transitionend', this.handleTransitionEnd_);
this.unlisten('keydown', this.handleKeydown_);

if (this.trailingIcon_) {
INTERACTION_EVENTS.forEach((evtType) => {
this.trailingIcon_!.removeEventListener(evtType, this.handleTrailingIconInteraction_ as EventListener);
});
}

super.destroy();
}

Expand Down
52 changes: 28 additions & 24 deletions packages/mdc-chips/chip/foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,13 @@ export class MDCChipFoundation extends MDCFoundation<MDCChipAdapter> {
/**
* Handles an interaction event on the root element.
*/
handleInteraction(evt: MouseEvent | KeyboardEvent) {
if (this.shouldHandleInteraction_(evt)) {
this.adapter_.notifyInteraction();
this.focusPrimaryAction_();
handleClick(evt: MouseEvent) {
const trailingIconIsSource = this.adapter_.eventTargetHasClass(evt.target, cssClasses.TRAILING_ICON);
if (trailingIconIsSource) {
return this.notifyTrailingIconInteractionAndRemove_(evt);
}

this.notifyInteractionAndFocus_();
}

/**
Expand Down Expand Up @@ -202,28 +204,25 @@ export class MDCChipFoundation extends MDCFoundation<MDCChipAdapter> {
}
}

/**
* Handles an interaction event on the trailing icon element. This is used to
* prevent the ripple from activating on interaction with the trailing icon.
*/
handleTrailingIconInteraction(evt: MouseEvent | KeyboardEvent) {
if (this.shouldHandleInteraction_(evt)) {
this.adapter_.notifyTrailingIconInteraction();
this.removeChip_(evt);
}
}

/**
* Handles a keydown event from the root element.
*/
handleKeydown(evt: KeyboardEvent) {
const trailingIconIsSource = this.adapter_.eventTargetHasClass(evt.target, cssClasses.TRAILING_ICON);
if (trailingIconIsSource && this.shouldProcessKeydownAsClick_(evt)) {
return this.notifyTrailingIconInteractionAndRemove_(evt);
}

if (this.shouldProcessKeydownAsClick_(evt)) {
return this.notifyInteractionAndFocus_();
}

if (this.shouldRemoveChip_(evt)) {
return this.removeChip_(evt);
}

const key = evt.key;
// Early exit if the key is not usable
if (!navigationKeys.has(key)) {
if (!navigationKeys.has(evt.key)) {
return;
}

Expand Down Expand Up @@ -308,20 +307,20 @@ export class MDCChipFoundation extends MDCFoundation<MDCChipAdapter> {
this.adapter_.setPrimaryActionAttr(strings.TAB_INDEX, '-1');
}

private removeChip_(evt: MouseEvent|KeyboardEvent) {
private removeChip_(evt: Event) {
evt.stopPropagation();
if (this.shouldRemoveOnTrailingIconClick_) {
this.beginExit();
}
}

private shouldHandleInteraction_(evt: MouseEvent|KeyboardEvent): boolean {
if (evt.type === 'click') {
return true;
}
private notifyTrailingIconInteractionAndRemove_(evt: Event) {
this.adapter_.notifyTrailingIconInteraction();
this.removeChip_(evt);
}

const keyEvt = evt as KeyboardEvent;
return keyEvt.key === strings.ENTER_KEY || keyEvt.key === strings.SPACEBAR_KEY;
private shouldProcessKeydownAsClick_(evt: KeyboardEvent): boolean {
return evt.key === strings.ENTER_KEY || evt.key === strings.SPACEBAR_KEY;
}

private shouldRemoveChip_(evt: KeyboardEvent): boolean {
Expand All @@ -346,6 +345,11 @@ export class MDCChipFoundation extends MDCFoundation<MDCChipAdapter> {
private notifyIgnoredSelection_(selected: boolean) {
this.adapter_.notifySelection(selected, true);
}

private notifyInteractionAndFocus_() {
this.adapter_.notifyInteraction();
this.focusPrimaryAction_();
}
}

// tslint:disable-next-line:no-default-export Needed for backward compatibility with MDC Web v0.44.0 and earlier.
Expand Down
110 changes: 84 additions & 26 deletions test/unit/mdc-chips/mdc-chip.foundation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -149,21 +149,19 @@ test(`#beginExit adds ${cssClasses.CHIP_EXIT} class`, () => {
td.verify(mockAdapter.addClass(cssClasses.CHIP_EXIT));
});

test('#handleInteraction does not emit event on invalid key', () => {
test('#handleKeydown does not emit event on invalid key', () => {
const {foundation, mockAdapter} = setupTest();
const mockEvt = {
const mockKeydown = {
type: 'keydown',
key: 'Shift',
};

foundation.handleInteraction(mockEvt);
foundation.handleKeydown(mockKeydown);
td.verify(mockAdapter.notifyInteraction(), {times: 0});
});

const validEvents = [
{
type: 'click',
}, {
type: 'keydown',
key: 'Enter',
}, {
Expand All @@ -173,23 +171,39 @@ const validEvents = [
];

validEvents.forEach((evt) => {
test(`#handleInteraction(${evt}) notifies interaction`, () => {
test(`#handleKeydown(${evt}) notifies interaction`, () => {
const {foundation, mockAdapter} = setupTest();

foundation.handleInteraction(evt);
foundation.handleKeydown(evt);
td.verify(mockAdapter.notifyInteraction());
});

test(`#handleInteraction(${evt}) focuses the primary action`, () => {
test(`#handleKeydown(${evt}) focuses the primary action`, () => {
const {foundation, mockAdapter} = setupTest();

foundation.handleInteraction(evt);
foundation.handleKeydown(evt);
td.verify(mockAdapter.setPrimaryActionAttr(strings.TAB_INDEX, '0'));
td.verify(mockAdapter.setTrailingActionAttr(strings.TAB_INDEX, '-1'));
td.verify(mockAdapter.focusPrimaryAction());
});
});

test('#handleClick(evt) notifies interaction', () => {
const {foundation, mockAdapter} = setupTest();

foundation.handleClick({type: 'click'});
td.verify(mockAdapter.notifyInteraction());
});

test('#handleClick(evt) focuses the primary action', () => {
const {foundation, mockAdapter} = setupTest();

foundation.handleClick({type: 'click'});
td.verify(mockAdapter.setPrimaryActionAttr(strings.TAB_INDEX, '0'));
td.verify(mockAdapter.setTrailingActionAttr(strings.TAB_INDEX, '-1'));
td.verify(mockAdapter.focusPrimaryAction());
});

test('#handleTransitionEnd notifies removal of chip on width transition end', () => {
const {foundation, mockAdapter} = setupTest();
const mockEvt = {
Expand Down Expand Up @@ -304,62 +318,106 @@ test('#handleTransitionEnd does nothing for width property when not exiting', ()
td.verify(mockAdapter.removeClassFromLeadingIcon(cssClasses.HIDDEN_LEADING_ICON), {times: 0});
});

test('#handleTrailingIconInteraction emits no event on invalid keys', () => {
test('#handleKeydown emits no custom event on invalid keys', () => {
const {foundation, mockAdapter} = setupTest();
const mockEvt = {
type: 'keydowb',
type: 'keydown',
key: 'Shift',
stopPropagation: td.func('stopPropagation'),
target: {},
};

foundation.handleTrailingIconInteraction(mockEvt);
td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.TRAILING_ICON)).thenReturn(true);

foundation.handleKeydown(mockEvt);
td.verify(mockAdapter.notifyTrailingIconInteraction(), {times: 0});
});

test('#handleTrailingIconInteraction emits custom event on click or enter key in trailing icon', () => {
const validKeys = [
' ', // Space
'Enter',
];

validKeys.forEach((key) => {
test(`#handleKeydown() from trailing icon emits custom event on "${key}"`, () => {
const {foundation, mockAdapter} = setupTest();
const mockEvt = {
type: 'keydown',
stopPropagation: td.func('stopPropagation'),
target: {},
key,
};

td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.TRAILING_ICON)).thenReturn(true);

foundation.handleKeydown(mockEvt);
td.verify(mockAdapter.notifyTrailingIconInteraction(), {times: 1});
});
});

test('#handleClick() from trailing icon emits custom event', () => {
const {foundation, mockAdapter} = setupTest();
const mockEvt = {
type: 'click',
stopPropagation: td.func('stopPropagation'),
target: {},
};

foundation.handleTrailingIconInteraction(mockEvt);
td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.TRAILING_ICON)).thenReturn(true);

foundation.handleClick(mockEvt);
td.verify(mockAdapter.notifyTrailingIconInteraction(), {times: 1});
td.verify(mockEvt.stopPropagation(), {times: 1});

foundation.handleTrailingIconInteraction(Object.assign(mockEvt, {type: 'keydown', key: ' '}));
td.verify(mockAdapter.notifyTrailingIconInteraction(), {times: 2});
td.verify(mockEvt.stopPropagation(), {times: 2});

foundation.handleTrailingIconInteraction(Object.assign(mockEvt, {type: 'keydown', key: 'Enter'}));
td.verify(mockAdapter.notifyTrailingIconInteraction(), {times: 3});
td.verify(mockEvt.stopPropagation(), {times: 3});
});

test(`#handleTrailingIconInteraction adds ${cssClasses.CHIP_EXIT} class by default on click in trailing icon`, () => {
test(`#handleClick() from trailing icon adds ${cssClasses.CHIP_EXIT} class by default`, () => {
const {foundation, mockAdapter} = setupTest();
const mockEvt = {
type: 'click',
stopPropagation: td.func('stopPropagation'),
target: {},
};

foundation.handleTrailingIconInteraction(mockEvt);
td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.TRAILING_ICON)).thenReturn(true);

foundation.handleClick(mockEvt);
assert.isTrue(foundation.getShouldRemoveOnTrailingIconClick());
td.verify(mockAdapter.addClass(cssClasses.CHIP_EXIT));
td.verify(mockEvt.stopPropagation());
});

test(`#handleTrailingIconInteraction does not add ${cssClasses.CHIP_EXIT} class on click in trailing icon ` +
validKeys.forEach((key) => {
test(`#handleKeydown({key: "${key}"}) from trailing icon adds ${cssClasses.CHIP_EXIT} class by default`, () => {
const {foundation, mockAdapter} = setupTest();
const mockEvt = {
type: 'keydown',
stopPropagation: td.func('stopPropagation'),
target: {},
key,
};

td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.TRAILING_ICON)).thenReturn(true);

foundation.handleKeydown(mockEvt);
assert.isTrue(foundation.getShouldRemoveOnTrailingIconClick());
td.verify(mockAdapter.addClass(cssClasses.CHIP_EXIT));
td.verify(mockEvt.stopPropagation());
});
});

test(`#handleClick() from trailing icon does not add ${cssClasses.CHIP_EXIT} class to trailing icon ` +
'if shouldRemoveOnTrailingIconClick_ is false', () => {
const {foundation, mockAdapter} = setupTest();
const mockEvt = {
type: 'click',
stopPropagation: td.func('stopPropagation'),
target: {},
};

td.when(mockAdapter.eventTargetHasClass(mockEvt.target, cssClasses.TRAILING_ICON)).thenReturn(true);

foundation.setShouldRemoveOnTrailingIconClick(false);
foundation.handleTrailingIconInteraction(mockEvt);
foundation.handleClick(mockEvt);

assert.isFalse(foundation.getShouldRemoveOnTrailingIconClick());
td.verify(mockAdapter.addClass(cssClasses.CHIP_EXIT), {times: 0});
Expand Down
Loading

0 comments on commit 5729943

Please sign in to comment.