diff --git a/packages/mdc-checkbox/README.md b/packages/mdc-checkbox/README.md index 709d539ea94..5187d3b0644 100644 --- a/packages/mdc-checkbox/README.md +++ b/packages/mdc-checkbox/README.md @@ -159,10 +159,6 @@ If you are using a JavaScript framework, such as React or Angular, you can creat | --- | --- | | `addClass(className: string) => void` | Adds a class to the root element. | | `removeClass(className: string) => void` | Removes a class from the root element. | -| `registerAnimationEndHandler(handler: EventListener) => void` | Registers an event handler to be called when an `animationend` event is triggered on the root element. Note that you must account for vendor prefixes in order for this to work correctly. | -| `deregisterAnimationEndHandler(handler: EventListener) => void` | Deregisters an event handler from an `animationend` event listener. This will only be called with handlers that have previously been passed to `registerAnimationEndHandler` calls. | -| `registerChangeHandler(handler: EventListener) => void` | Registers an event handler to be called when a `change` event is triggered on the native control (_not_ the root element). | -| `deregisterChangeHandler(handler: EventListener) => void` | Deregisters an event handler that was previously passed to `registerChangeHandler`. | | `getNativeControl() => HTMLInputElement?` | Returns the native checkbox control, if available. Note that if this control is not available, the methods that rely on it will exit gracefully.| | `forceLayout() => void` | Force-trigger a layout on the root element. This is needed to restart animations correctly. If you find that you do not need to do this, you can simply make it a no-op. | | `isAttachedToDOM() => boolean` | Returns true if the component is currently attached to the DOM, false otherwise. | @@ -179,3 +175,5 @@ Method Signature | Description `setDisabled(disabled: boolean) => void` | Updates the `disabled` property on the underlying input. Does nothing when the underlying input is not present. `getValue() => string` | Returns the value of `MDCCheckboxAdapter.getNativeControl().value`. Returns `null` if `getNativeControl()` does not return an object. `setValue(value: string) => void` | Sets the value of `adapter.getNativeControl().value`. Does nothing if `getNativeControl()` does not return an object. +`handleAnimationEnd() => void` | `animationend` event handler that should be applied to the root element. +`handleChange() => void` | `change` event handler that should be applied to the checkbox element. diff --git a/packages/mdc-checkbox/adapter.js b/packages/mdc-checkbox/adapter.js index a251f3ee253..cf75b7a0029 100644 --- a/packages/mdc-checkbox/adapter.js +++ b/packages/mdc-checkbox/adapter.js @@ -62,18 +62,6 @@ class MDCCheckboxAdapter { */ removeNativeControlAttr(attr) {} - /** @param {!EventListener} handler */ - registerAnimationEndHandler(handler) {} - - /** @param {!EventListener} handler */ - deregisterAnimationEndHandler(handler) {} - - /** @param {!EventListener} handler */ - registerChangeHandler(handler) {} - - /** @param {!EventListener} handler */ - deregisterChangeHandler(handler) {} - /** @return {!MDCSelectionControlState} */ getNativeControl() {} diff --git a/packages/mdc-checkbox/foundation.js b/packages/mdc-checkbox/foundation.js index a76237cd8d6..068e13237a2 100644 --- a/packages/mdc-checkbox/foundation.js +++ b/packages/mdc-checkbox/foundation.js @@ -57,10 +57,6 @@ class MDCCheckboxFoundation extends MDCFoundation { removeClass: (/* className: string */) => {}, setNativeControlAttr: (/* attr: string, value: string */) => {}, removeNativeControlAttr: (/* attr: string */) => {}, - registerAnimationEndHandler: (/* handler: EventListener */) => {}, - deregisterAnimationEndHandler: (/* handler: EventListener */) => {}, - registerChangeHandler: (/* handler: EventListener */) => {}, - deregisterChangeHandler: (/* handler: EventListener */) => {}, getNativeControl: () => /* !MDCSelectionControlState */ {}, forceLayout: () => {}, isAttachedToDOM: () => /* boolean */ {}, @@ -79,11 +75,8 @@ class MDCCheckboxFoundation extends MDCFoundation { /** @private {number} */ this.animEndLatchTimer_ = 0; - this.animEndHandler_ = /** @private {!EventListener} */ ( - () => this.handleAnimationEnd()); - - this.changeHandler_ = /** @private {!EventListener} */ ( - () => this.handleChange()); + /** @private {boolean} */ + this.enableAnimationEndHandler_ = false; } /** @override */ @@ -91,13 +84,11 @@ class MDCCheckboxFoundation extends MDCFoundation { this.currentCheckState_ = this.determineCheckState_(this.getNativeControl_()); this.updateAriaChecked_(); this.adapter_.addClass(cssClasses.UPGRADED); - this.adapter_.registerChangeHandler(this.changeHandler_); this.installPropertyChangeHooks_(); } /** @override */ destroy() { - this.adapter_.deregisterChangeHandler(this.changeHandler_); this.uninstallPropertyChangeHooks_(); } @@ -150,10 +141,13 @@ class MDCCheckboxFoundation extends MDCFoundation { * Handles the animationend event for the checkbox */ handleAnimationEnd() { + if (!this.enableAnimationEndHandler_) return; + clearTimeout(this.animEndLatchTimer_); + this.animEndLatchTimer_ = setTimeout(() => { this.adapter_.removeClass(this.currentAnimationClass_); - this.adapter_.deregisterAnimationEndHandler(this.animEndHandler_); + this.enableAnimationEndHandler_ = false; }, numbers.ANIM_END_LATCH_MS); } @@ -231,7 +225,7 @@ class MDCCheckboxFoundation extends MDCFoundation { // to the DOM. if (this.adapter_.isAttachedToDOM() && this.currentAnimationClass_.length > 0) { this.adapter_.addClass(this.currentAnimationClass_); - this.adapter_.registerAnimationEndHandler(this.animEndHandler_); + this.enableAnimationEndHandler_ = true; } } @@ -297,6 +291,8 @@ class MDCCheckboxFoundation extends MDCFoundation { this.adapter_.setNativeControlAttr( strings.ARIA_CHECKED_ATTR, strings.ARIA_CHECKED_INDETERMINATE_VALUE); } else { + // The on/off state does not need to keep track of aria-checked, since + // the screenreader uses the checked property on the checkbox element. this.adapter_.removeNativeControlAttr(strings.ARIA_CHECKED_ATTR); } } diff --git a/packages/mdc-checkbox/index.js b/packages/mdc-checkbox/index.js index fc42c14c874..e24e6e3d026 100644 --- a/packages/mdc-checkbox/index.js +++ b/packages/mdc-checkbox/index.js @@ -56,6 +56,17 @@ class MDCCheckbox extends MDCComponent { /** @private {!MDCRipple} */ this.ripple_ = this.initRipple_(); + /** @private {!Function} */ + this.handleChange_; + /** @private {!Function} */ + this.handleAnimationEnd_; + } + + initialSyncWithDOM() { + this.handleChange_ = () => this.foundation_.handleChange(); + this.handleAnimationEnd_= () => this.foundation_.handleAnimationEnd(); + this.nativeCb_.addEventListener('change', this.handleChange_); + this.listen(getCorrectEventName(window, 'animationend'), this.handleAnimationEnd_); } /** @@ -81,12 +92,6 @@ class MDCCheckbox extends MDCComponent { removeClass: (className) => this.root_.classList.remove(className), setNativeControlAttr: (attr, value) => this.nativeCb_.setAttribute(attr, value), removeNativeControlAttr: (attr) => this.nativeCb_.removeAttribute(attr), - registerAnimationEndHandler: - (handler) => this.root_.addEventListener(getCorrectEventName(window, 'animationend'), handler), - deregisterAnimationEndHandler: - (handler) => this.root_.removeEventListener(getCorrectEventName(window, 'animationend'), handler), - registerChangeHandler: (handler) => this.nativeCb_.addEventListener('change', handler), - deregisterChangeHandler: (handler) => this.nativeCb_.removeEventListener('change', handler), getNativeControl: () => this.nativeCb_, forceLayout: () => this.root_.offsetWidth, isAttachedToDOM: () => Boolean(this.root_.parentNode), @@ -140,6 +145,8 @@ class MDCCheckbox extends MDCComponent { destroy() { this.ripple_.destroy(); + this.nativeCb_.removeEventListener('change', this.handleChange_); + this.unlisten(getCorrectEventName(window, 'animationend'), this.handleAnimationEnd_); super.destroy(); } } diff --git a/test/unit/mdc-checkbox/foundation.test.js b/test/unit/mdc-checkbox/foundation.test.js index 61444333d83..5c8642091d8 100644 --- a/test/unit/mdc-checkbox/foundation.test.js +++ b/test/unit/mdc-checkbox/foundation.test.js @@ -74,19 +74,14 @@ function withMockCheckboxDescriptorReturning(descriptor, runTests) { // `change({checked: true, indeterminate: false})` simulates a change event as the result of a checkbox // being checked. function setupChangeHandlerTest() { - let changeHandler; const {foundation, mockAdapter} = setupTest(); - const {isA} = td.matchers; - td.when(mockAdapter.registerChangeHandler(isA(Function))).thenDo(function(handler) { - changeHandler = handler; - }); td.when(mockAdapter.isAttachedToDOM()).thenReturn(true); foundation.init(); const change = (newState) => { td.when(mockAdapter.getNativeControl()).thenReturn(newState); - changeHandler(); + foundation.handleChange(); }; return {foundation, mockAdapter, change}; @@ -117,9 +112,8 @@ test('exports numbers', () => { test('defaultAdapter returns a complete adapter implementation', () => { verifyDefaultAdapter(MDCCheckboxFoundation, [ - 'addClass', 'removeClass', 'setNativeControlAttr', 'removeNativeControlAttr', 'registerAnimationEndHandler', - 'deregisterAnimationEndHandler', 'registerChangeHandler', 'deregisterChangeHandler', 'getNativeControl', - 'forceLayout', 'isAttachedToDOM', + 'addClass', 'removeClass', 'setNativeControlAttr', 'removeNativeControlAttr', + 'getNativeControl', 'forceLayout', 'isAttachedToDOM', ]); }); @@ -138,14 +132,6 @@ test('#init adds aria-checked="mixed" if checkbox is initially indeterminate', ( td.verify(mockAdapter.setNativeControlAttr('aria-checked', strings.ARIA_CHECKED_INDETERMINATE_VALUE)); }); -test('#init calls adapter.registerChangeHandler() with a change handler function', () => { - const {foundation, mockAdapter} = setupTest(); - const {isA} = td.matchers; - - foundation.init(); - td.verify(mockAdapter.registerChangeHandler(isA(Function))); -}); - test('#init handles case where getNativeControl() does not return anything', () => { const {foundation, mockAdapter} = setupTest(); td.when(mockAdapter.getNativeControl()).thenReturn(undefined); @@ -169,20 +155,6 @@ test('#init handles case when property descriptors are not returned at all (Andr }); }); -test('#destroy calls adapter.deregisterChangeHandler() with a registerChangeHandler function', () => { - const {foundation, mockAdapter} = setupTest(); - const {isA} = td.matchers; - - let changeHandler; - td.when(mockAdapter.registerChangeHandler(isA(Function))).thenDo(function(handler) { - changeHandler = handler; - }); - foundation.init(); - - foundation.destroy(); - td.verify(mockAdapter.deregisterChangeHandler(changeHandler)); -}); - test('#destroy handles case where getNativeControl() does not return anything', () => { const {foundation, mockAdapter} = setupTest(); foundation.init(); @@ -386,59 +358,39 @@ testChangeHandler('no transition classes applied when no state change', [ }, ], cssClasses.ANIM_UNCHECKED_CHECKED, {times: 1}); -test('animation end handler one-off removes animation class after short delay', () => { +test('animation end handler removes animation class after short delay', () => { const clock = lolex.install(); - const {mockAdapter, change} = setupChangeHandlerTest(); - const {isA} = td.matchers; - - let animEndHandler; - td.when(mockAdapter.registerAnimationEndHandler(isA(Function))).thenDo(function(handler) { - animEndHandler = handler; - }); - - change({checked: true, indeterminate: false}); - assert.isOk(animEndHandler instanceof Function, 'animationend handler registeration sanity test'); - - animEndHandler(); const {ANIM_UNCHECKED_CHECKED} = cssClasses; + const {mockAdapter, foundation} = setupTest(); + + foundation.enableAnimationEndHandler_ = true; + foundation.currentAnimationClass_ = ANIM_UNCHECKED_CHECKED; td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED), {times: 0}); + foundation.handleAnimationEnd(); + clock.tick(numbers.ANIM_END_LATCH_MS); - td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED)); - td.verify(mockAdapter.deregisterAnimationEndHandler(animEndHandler)); + td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED), {times: 1}); + assert.isFalse(foundation.enableAnimationEndHandler_); clock.uninstall(); }); -test('change handler debounces changes within the animation end delay period', () => { +test('animation end is debounced if event is called twice', () => { const clock = lolex.install(); - const {mockAdapter, change} = setupChangeHandlerTest(); - const {isA} = td.matchers; + const {ANIM_UNCHECKED_CHECKED} = cssClasses; + const {mockAdapter, foundation} = setupChangeHandlerTest(); + foundation.enableAnimationEndHandler_ = true; + foundation.currentAnimationClass_ = ANIM_UNCHECKED_CHECKED; - let animEndHandler; - td.when(mockAdapter.registerAnimationEndHandler(isA(Function))).thenDo(function(handler) { - animEndHandler = handler; - }); + foundation.handleAnimationEnd(); - change({checked: true, indeterminate: false}); - assert.isOk(animEndHandler instanceof Function, 'animationend handler registeration sanity test'); - // Queue up initial timer - animEndHandler(); + td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED), {times: 0}); - const {ANIM_UNCHECKED_CHECKED, ANIM_CHECKED_INDETERMINATE} = cssClasses; + foundation.handleAnimationEnd(); - change({checked: true, indeterminate: true}); - // Without ticking the clock, check that the prior class has been removed. - td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED)); - // The animation end handler should not yet have been removed. - td.verify(mockAdapter.deregisterAnimationEndHandler(animEndHandler), {times: 0}); - - // Call animEndHandler again, and tick the clock. The original timer should have been cleared, and the - // current timer should remove the correct, latest animation class, along with deregistering the handler. - animEndHandler(); clock.tick(numbers.ANIM_END_LATCH_MS); - td.verify(mockAdapter.removeClass(ANIM_CHECKED_INDETERMINATE), {times: 1}); - td.verify(mockAdapter.deregisterAnimationEndHandler(animEndHandler), {times: 1}); + td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED), {times: 1}); clock.uninstall(); }); diff --git a/test/unit/mdc-checkbox/mdc-checkbox.test.js b/test/unit/mdc-checkbox/mdc-checkbox.test.js index 345d6d8ad3f..6848815b1cb 100644 --- a/test/unit/mdc-checkbox/mdc-checkbox.test.js +++ b/test/unit/mdc-checkbox/mdc-checkbox.test.js @@ -31,7 +31,6 @@ import {createMockRaf} from '../helpers/raf'; import {MDCCheckbox} from '../../../packages/mdc-checkbox'; import {MDCRipple} from '../../../packages/mdc-ripple'; import {strings} from '../../../packages/mdc-checkbox/constants'; -import {getCorrectEventName} from '../../../packages/mdc-animation'; import {getMatchesProperty} from '../../../packages/mdc-ripple/util'; function getFixture() { @@ -139,6 +138,36 @@ test('get ripple returns a MDCRipple instance', () => { assert.isOk(component.ripple instanceof MDCRipple); }); +test('checkbox change event calls #foundation.handleChange', () => { + const {cb, component} = setupTest(); + component.foundation_.handleChange = td.func(); + domEvents.emit(cb, 'change'); + td.verify(component.foundation_.handleChange(), {times: 1}); +}); + +test('root animationend event calls #foundation.handleAnimationEnd', () => { + const {root, component} = setupTest(); + component.foundation_.handleAnimationEnd = td.func(); + domEvents.emit(root, 'animationend'); + td.verify(component.foundation_.handleAnimationEnd(), {times: 1}); +}); + +test('checkbox change event handler is destroyed on #destroy', () => { + const {cb, component} = setupTest(); + component.foundation_.handleChange = td.func(); + component.destroy(); + domEvents.emit(cb, 'change'); + td.verify(component.foundation_.handleChange(), {times: 0}); +}); + +test('root animationend event handler is destroyed on #destroy', () => { + const {root, component} = setupTest(); + component.foundation_.handleAnimationEnd = td.func(); + component.destroy(); + domEvents.emit(root, 'animationend'); + td.verify(component.foundation_.handleAnimationEnd(), {times: 0}); +}); + test('adapter#addClass adds a class to the root element', () => { const {root, component} = setupTest(); component.getDefaultFoundation().adapter_.addClass('foo'); @@ -165,50 +194,6 @@ test('adapter#removeNativeControlAttr removes an attribute from the input elemen assert.isFalse(cb.hasAttribute('aria-checked')); }); -test('adapter#registerAnimationEndHandler adds an animation end event listener on the root element', () => { - const {root, component} = setupTest(); - const handler = td.func('animationEndHandler'); - component.getDefaultFoundation().adapter_.registerAnimationEndHandler(handler); - domEvents.emit(root, getCorrectEventName(window, 'animationend')); - - td.verify(handler(td.matchers.anything())); -}); - -test('adapter#deregisterAnimationEndHandler removes an animation end event listener on the root el', () => { - const {root, component} = setupTest(); - const handler = td.func('animationEndHandler'); - const animEndEvtName = getCorrectEventName(window, 'animationend'); - root.addEventListener(animEndEvtName, handler); - - component.getDefaultFoundation().adapter_.deregisterAnimationEndHandler(handler); - domEvents.emit(root, animEndEvtName); - - td.verify(handler(td.matchers.anything()), {times: 0}); -}); - -test('adapter#registerChangeHandler adds a change event listener to the native checkbox element', () => { - const {root, component} = setupTest(); - const nativeCb = root.querySelector(strings.NATIVE_CONTROL_SELECTOR); - const handler = td.func('changeHandler'); - - component.getDefaultFoundation().adapter_.registerChangeHandler(handler); - domEvents.emit(nativeCb, 'change'); - - td.verify(handler(td.matchers.anything())); -}); - -test('adapter#deregisterChangeHandler adds a change event listener to the native checkbox element', () => { - const {root, component} = setupTest(); - const nativeCb = root.querySelector(strings.NATIVE_CONTROL_SELECTOR); - const handler = td.func('changeHandler'); - nativeCb.addEventListener('change', handler); - - component.getDefaultFoundation().adapter_.deregisterChangeHandler(handler); - domEvents.emit(nativeCb, 'change'); - - td.verify(handler(td.matchers.anything()), {times: 0}); -}); - test('adapter#getNativeControl returns the native checkbox element', () => { const {root, component} = setupTest(); const nativeCb = root.querySelector(strings.NATIVE_CONTROL_SELECTOR);