diff --git a/packages/mdc-checkbox/README.md b/packages/mdc-checkbox/README.md index 05488a9e9df..745bc1e84a0 100644 --- a/packages/mdc-checkbox/README.md +++ b/packages/mdc-checkbox/README.md @@ -182,6 +182,7 @@ Method Signature | Description `isIndeterminate() => boolean` | Returns true if the component is in the indeterminate state. `isChecked() => boolean` | Returns true if the component is checked. `hasNativeControl() => boolean` | Returns true if the input is present in the component. +`setChecked(checkStatus: boolean) => void` | Sets the check status of the component. `setNativeControlDisabled(disabled: boolean) => void` | Sets the input to disabled. `setNativeControlAttr(attr: string, value: string) => void` | Sets an HTML attribute to the given value on the native input element. `removeNativeControlAttr(attr: string) => void` | Removes an attribute from the native input element. @@ -192,4 +193,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. `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. +`handleClick() => void` | `click` event handler that should be applied to the checkbox element. +`handleChange() => void` | Handles check status changes. diff --git a/packages/mdc-checkbox/adapter.ts b/packages/mdc-checkbox/adapter.ts index e68f7f3183b..e3f039d988b 100644 --- a/packages/mdc-checkbox/adapter.ts +++ b/packages/mdc-checkbox/adapter.ts @@ -37,6 +37,7 @@ export interface MDCCheckboxAdapter { isIndeterminate(): boolean; removeClass(className: string): void; removeNativeControlAttr(attr: string): void; + setChecked(checkStatus: boolean): void; setNativeControlAttr(attr: string, value: string): void; setNativeControlDisabled(disabled: boolean): void; } diff --git a/packages/mdc-checkbox/component.ts b/packages/mdc-checkbox/component.ts index be005ecee65..756f7789d93 100644 --- a/packages/mdc-checkbox/component.ts +++ b/packages/mdc-checkbox/component.ts @@ -86,20 +86,20 @@ export class MDCCheckbox extends MDCComponent implements root_!: Element; // assigned in MDCComponent constructor private readonly ripple_: MDCRipple = this.createRipple_(); - private handleChange_!: EventListener; // assigned in initialSyncWithDOM() + private handleClick_!: EventListener; // assigned in initialSyncWithDOM() private handleAnimationEnd_!: EventListener; // assigned in initialSyncWithDOM() initialSyncWithDOM() { - this.handleChange_ = () => this.foundation_.handleChange(); + this.handleClick_ = () => this.foundation_.handleClick(); this.handleAnimationEnd_ = () => this.foundation_.handleAnimationEnd(); - this.nativeControl_.addEventListener('change', this.handleChange_); + this.nativeControl_.addEventListener('click', this.handleClick_); this.listen(getCorrectEventName(window, 'animationend'), this.handleAnimationEnd_); this.installPropertyChangeHooks_(); } destroy() { this.ripple_.destroy(); - this.nativeControl_.removeEventListener('change', this.handleChange_); + this.nativeControl_.removeEventListener('click', this.handleClick_); this.unlisten(getCorrectEventName(window, 'animationend'), this.handleAnimationEnd_); this.uninstallPropertyChangeHooks_(); super.destroy(); @@ -117,6 +117,7 @@ export class MDCCheckbox extends MDCComponent implements isIndeterminate: () => this.indeterminate, removeClass: (className) => this.root_.classList.remove(className), removeNativeControlAttr: (attr) => this.nativeControl_.removeAttribute(attr), + setChecked: (checkStatus) => this.checked = checkStatus, setNativeControlAttr: (attr, value) => this.nativeControl_.setAttribute(attr, value), setNativeControlDisabled: (disabled) => this.nativeControl_.disabled = disabled, }; diff --git a/packages/mdc-checkbox/foundation.ts b/packages/mdc-checkbox/foundation.ts index bd73b69cf94..fdfefb3bb8a 100644 --- a/packages/mdc-checkbox/foundation.ts +++ b/packages/mdc-checkbox/foundation.ts @@ -48,12 +48,17 @@ export class MDCCheckboxFoundation extends MDCFoundation { isIndeterminate: () => false, removeClass: () => undefined, removeNativeControlAttr: () => undefined, + setChecked: () => undefined, setNativeControlAttr: () => undefined, setNativeControlDisabled: () => undefined, }; } private currentCheckState_ = strings.TRANSITION_STATE_INIT; + // Native checkboxes can only have two real states: checked/true or unchecked/false + // The indeterminate state is visual only. + // See https://stackoverflow.com/a/33529024 for more details. + private realCheckState_ = false; private currentAnimationClass_ = ''; private animEndLatchTimer_ = 0; private enableAnimationEndHandler_ = false; @@ -64,6 +69,7 @@ export class MDCCheckboxFoundation extends MDCFoundation { init() { this.currentCheckState_ = this.determineCheckState_(); + this.realCheckState_ = this.adapter_.isChecked(); this.updateAriaChecked_(); this.adapter_.addClass(cssClasses.UPGRADED); } @@ -98,7 +104,20 @@ export class MDCCheckboxFoundation extends MDCFoundation { } /** - * Handles the change event for the checkbox + * Handles the click event for the checkbox + */ + handleClick() { + // added for IE browser to fix compatibility issue: + // https://github.com/material-components/material-components-web/issues/4893 + const {TRANSITION_STATE_INDETERMINATE} = strings; + if (this.currentCheckState_ === TRANSITION_STATE_INDETERMINATE) { + this.adapter_.setChecked(!this.realCheckState_); + } + this.transitionCheckState_(); + } + + /** + * Handles the actions after check status changes */ handleChange() { this.transitionCheckState_(); @@ -118,11 +137,16 @@ export class MDCCheckboxFoundation extends MDCFoundation { this.updateAriaChecked_(); const {TRANSITION_STATE_UNCHECKED} = strings; + const {TRANSITION_STATE_CHECKED} = strings; const {SELECTED} = cssClasses; if (newState === TRANSITION_STATE_UNCHECKED) { this.adapter_.removeClass(SELECTED); + this.realCheckState_ = false; } else { this.adapter_.addClass(SELECTED); + if (newState === TRANSITION_STATE_CHECKED) { + this.realCheckState_ = true; + } } // Check to ensure that there isn't a previously existing animation class, in case for example diff --git a/packages/mdc-checkbox/test/component.test.ts b/packages/mdc-checkbox/test/component.test.ts index 2a9cdbfcbb3..4fec4b29e9f 100644 --- a/packages/mdc-checkbox/test/component.test.ts +++ b/packages/mdc-checkbox/test/component.test.ts @@ -150,11 +150,11 @@ describe('MDCCheckbox', () => { expect(component.ripple instanceof MDCRipple).toBeTruthy(); }); - it('checkbox change event calls #foundation.handleChange', () => { + it('checkbox click event calls #foundation.handleClick', () => { const {cb, component} = setupTest(); - (component as any).foundation_.handleChange = jasmine.createSpy(); - emitEvent(cb, 'change'); - expect((component as any).foundation_.handleChange).toHaveBeenCalled(); + (component as any).foundation_.handleClick = jasmine.createSpy(); + emitEvent(cb, 'click'); + expect((component as any).foundation_.handleClick).toHaveBeenCalled(); }); it('root animationend event calls #foundation.handleAnimationEnd', () => { @@ -178,12 +178,12 @@ describe('MDCCheckbox', () => { expect(mockFoundation.handleChange).toHaveBeenCalled(); }); - it('checkbox change event handler is destroyed on #destroy', () => { + it('checkbox click event handler is destroyed on #destroy', () => { const {cb, component} = setupTest(); - (component as any).foundation_.handleChange = jasmine.createSpy(); + (component as any).foundation_.handleClick = jasmine.createSpy(); component.destroy(); - emitEvent(cb, 'change'); - expect((component as any).foundation_.handleChange).not.toHaveBeenCalled(); + emitEvent(cb, 'click'); + expect((component as any).foundation_.handleClick).not.toHaveBeenCalled(); }); it('root animationend event handler is destroyed on #destroy', () => { @@ -304,6 +304,20 @@ describe('MDCCheckbox', () => { .toBe(false); }); + it('#adapter.setChecked returns true when checkbox is checked', () => { + const {cb, component} = setupTest(); + (component.getDefaultFoundation() as any) + .adapter_.setChecked(true); + expect(cb.checked).toBe(true); + }); + + it('#adapter.setChecked returns false when checkbox is not checked', () => { + const {cb, component} = setupTest(); + (component.getDefaultFoundation() as any) + .adapter_.setChecked(false); + expect(cb.checked).toBe(false); + }); + it('#adapter.hasNativeControl returns true when checkbox exists', () => { const {component} = setupTest(); expect( diff --git a/packages/mdc-checkbox/test/foundation.test.ts b/packages/mdc-checkbox/test/foundation.test.ts index 67f0c6870d5..ad95cac46fe 100644 --- a/packages/mdc-checkbox/test/foundation.test.ts +++ b/packages/mdc-checkbox/test/foundation.test.ts @@ -82,7 +82,7 @@ function setupChangeHandlerTest() { function testChangeHandler( desc: string, changes: CheckboxState|CheckboxState[], expectedClass: string) { - changes = Array.isArray(changes) ? changes : [changes] + changes = Array.isArray(changes) ? changes : [changes]; it(`changeHandler: ${desc}`, () => { const {mockAdapter, change} = setupChangeHandlerTest(); @@ -91,6 +91,50 @@ function testChangeHandler( }); } +/** + * Sets up tests which execute click events through the click handler which + * the foundation registers. Returns an object containing the following + * properties: + * - foundation - The MDCCheckboxFoundation instance + * - mockAdapter - The adapter given to the foundation. The adapter is + * pre-configured to capture the changeHandler registered as well as respond + * with different mock objects for native controls based on the state given + * to the change() function. + * - change - A function that's passed an object containing two "checked" and + * "boolean" properties, representing the state of the native control after + * it was changed. E.g. `change({checked: true, indeterminate: false})` + * simulates a change event as the result of a checkbox being checked. + */ +function setupClickHandlerTest() { + const {foundation, mockAdapter} = setupTest(); + mockAdapter.isAttachedToDOM.and.returnValue(true); + mockAdapter.isIndeterminate.and.returnValue(false); + mockAdapter.isChecked.and.returnValue(false); + + foundation.init(); + + const change = (newState: CheckboxState) => { + mockAdapter.hasNativeControl.and.returnValue(true); + mockAdapter.isChecked.and.returnValue(newState.checked); + mockAdapter.isIndeterminate.and.returnValue(newState.indeterminate); + foundation.handleClick(); + }; + + return {foundation, mockAdapter, change}; +} + +function testClickHandler( + desc: string, changes: CheckboxState|CheckboxState[], + expectedClass: string) { + changes = Array.isArray(changes) ? changes : [changes]; + it(`clickHandler: ${desc}`, () => { + const {mockAdapter, change} = setupClickHandlerTest(); + + (changes as any).forEach(change); + expect(mockAdapter.addClass).toHaveBeenCalledWith(expectedClass); + }); +} + describe('MDCCheckboxFoundation', () => { setUpMdcTestEnvironment(); @@ -112,6 +156,7 @@ describe('MDCCheckboxFoundation', () => { 'isChecked', 'hasNativeControl', 'setNativeControlDisabled', + 'setChecked', ]); }); @@ -363,7 +408,7 @@ describe('MDCCheckboxFoundation', () => { expect(foundation.enableAnimationEndHandler_).toBe(false); }); - it('animation end is debounced if event is called twice', () => { + it('animation end is debounced if change event is called twice', () => { const {ANIM_UNCHECKED_CHECKED} = cssClasses; const {mockAdapter, foundation} = setupChangeHandlerTest(); foundation.enableAnimationEndHandler_ = true; @@ -432,4 +477,209 @@ describe('MDCCheckboxFoundation', () => { expect(mockAdapter.setNativeControlAttr).not.toHaveBeenCalled(); expect(mockAdapter.removeNativeControlAttr).not.toHaveBeenCalled(); }); + testClickHandler( + 'unchecked -> checked animation class', { + checked: true, + indeterminate: false, + }, + cssClasses.ANIM_UNCHECKED_CHECKED); + + testClickHandler( + 'unchecked -> indeterminate animation class', { + checked: false, + indeterminate: true, + }, + cssClasses.ANIM_UNCHECKED_INDETERMINATE); + + testClickHandler( + 'checked -> unchecked animation class', + [ + { + checked: true, + indeterminate: false, + }, + { + checked: false, + indeterminate: false, + }, + ], + cssClasses.ANIM_CHECKED_UNCHECKED); + + testClickHandler( + 'checked -> indeterminate animation class', + [ + { + checked: true, + indeterminate: false, + }, + { + checked: true, + indeterminate: true, + }, + ], + cssClasses.ANIM_CHECKED_INDETERMINATE); + + testClickHandler( + 'indeterminate -> checked animation class', + [ + { + checked: false, + indeterminate: true, + }, + { + checked: true, + indeterminate: false, + }, + ], + cssClasses.ANIM_INDETERMINATE_CHECKED); + + testClickHandler( + 'indeterminate -> unchecked animation class', + [ + { + checked: true, + indeterminate: true, + }, + { + checked: false, + indeterminate: false, + }, + ], + cssClasses.ANIM_INDETERMINATE_UNCHECKED); + + testClickHandler( + 'no transition classes applied when no state change', + [ + { + checked: true, + indeterminate: false, + }, + { + checked: true, + indeterminate: false, + }, + ], + cssClasses.ANIM_UNCHECKED_CHECKED); + + it('changing from unchecked to checked adds selected class', () => { + const {mockAdapter, change} = setupClickHandlerTest(); + change({ + checked: false, + indeterminate: false, + }); + change({ + checked: true, + indeterminate: false, + }); + expect(mockAdapter.addClass).toHaveBeenCalledWith(cssClasses.SELECTED); + }); + + it('changing from unchecked to indeterminate adds selected class', () => { + const {mockAdapter, change} = setupClickHandlerTest(); + change({ + checked: false, + indeterminate: false, + }); + change({ + checked: false, + indeterminate: true, + }); + expect(mockAdapter.addClass).toHaveBeenCalledWith(cssClasses.SELECTED); + }); + + it('changing from checked to unchecked removes selected class', () => { + const {mockAdapter, change} = setupClickHandlerTest(); + change({ + checked: true, + indeterminate: false, + }); + change({ + checked: false, + indeterminate: false, + }); + expect(mockAdapter.removeClass).toHaveBeenCalledWith(cssClasses.SELECTED); + }); + + it('changing from indeterminate to unchecked removes selected class', () => { + const {mockAdapter, change} = setupClickHandlerTest(); + change({ + checked: false, + indeterminate: true, + }); + change({ + checked: false, + indeterminate: false, + }); + expect(mockAdapter.removeClass).toHaveBeenCalledWith(cssClasses.SELECTED); + }); + + it('animation end is debounced if click event is called twice', () => { + const {ANIM_UNCHECKED_CHECKED} = cssClasses; + const {mockAdapter, foundation} = setupClickHandlerTest(); + foundation.enableAnimationEndHandler_ = true; + foundation.currentAnimationClass_ = ANIM_UNCHECKED_CHECKED; + + foundation.handleAnimationEnd(); + + expect(mockAdapter.removeClass).not.toHaveBeenCalled(); + + foundation.handleAnimationEnd(); + + jasmine.clock().tick(numbers.ANIM_END_LATCH_MS); + expect(mockAdapter.removeClass) + .toHaveBeenCalledWith(ANIM_UNCHECKED_CHECKED); + }); + + it('click handler triggers layout for changes within the same frame to correctly restart anims', + () => { + const {mockAdapter, change} = setupClickHandlerTest(); + + change({checked: true, indeterminate: false}); + expect(mockAdapter.forceLayout).not.toHaveBeenCalled(); + + change({checked: true, indeterminate: true}); + expect(mockAdapter.forceLayout).toHaveBeenCalled(); + }); + + it('click handler updates aria-checked attribute correctly.', () => { + const {mockAdapter, change} = setupClickHandlerTest(); + + change({checked: true, indeterminate: true}); + expect(mockAdapter.setNativeControlAttr) + .toHaveBeenCalledWith('aria-checked', 'mixed'); + + change({checked: true, indeterminate: false}); + expect(mockAdapter.removeNativeControlAttr) + .toHaveBeenCalledWith('aria-checked'); + }); + + it('click handler does not add animation classes when isAttachedToDOM() is falsy', + () => { + const {mockAdapter, change} = setupClickHandlerTest(); + mockAdapter.isAttachedToDOM.and.returnValue(false); + + change({checked: true, indeterminate: false}); + expect(mockAdapter.addClass) + .not.toHaveBeenCalledWith( + jasmine.stringMatching('mdc-checkbox--anim')); + }); + + it('click handler does not add animation classes for bogus changes (init -> unchecked)', + () => { + const {mockAdapter, change} = setupClickHandlerTest(); + + change({checked: false, indeterminate: false}); + expect(mockAdapter.addClass) + .not.toHaveBeenCalledWith( + jasmine.stringMatching('mdc-checkbox--anim')); + }); + + it('click handler does not do anything if checkbox element is not found', + () => { + const {foundation, mockAdapter} = setupTest(); + mockAdapter.hasNativeControl.and.returnValue(false); + expect(() => foundation.handleClick).not.toThrow(); + expect(mockAdapter.setNativeControlAttr).not.toHaveBeenCalled(); + expect(mockAdapter.removeNativeControlAttr).not.toHaveBeenCalled(); + }); });