From b5e832d033776225cda6a3d3ddb717ab85ca2636 Mon Sep 17 00:00:00 2001 From: Travis Kaufman Date: Tue, 2 Aug 2016 16:55:13 -0400 Subject: [PATCH] feat(checkbox): Add initial checkbox implementation This commit converts the checkbox POC code over to the new architecture, adds unit tests, and ports the demos over to working implementations. It also adds a README, and modifies .stylelintrc.yaml to increase the number of compound selectors we can use. Note that ink ripple functionality will be covered in a separate issue. Finishes #4471 [Delivers #126819487] --- .stylelintrc.yaml | 20 +- demos/checkbox.html | 131 ++++++- examples/README.md | 6 + package.json | 3 +- packages/mdl-checkbox/README.md | 179 +++++++++ packages/mdl-checkbox/_keyframes.scss | 67 +++- packages/mdl-checkbox/_variables.scss | 38 +- packages/mdl-checkbox/constants.js | 5 +- packages/mdl-checkbox/foundation.js | 194 +++++++++ packages/mdl-checkbox/index.js | 100 ++--- packages/mdl-checkbox/mdl-checkbox.scss | 302 ++++++++------ test/unit/mdl-checkbox/foundation.test.js | 411 ++++++++++++++++++++ test/unit/mdl-checkbox/mdl-checkbox.test.js | 202 ++++++++++ 13 files changed, 1415 insertions(+), 243 deletions(-) create mode 100644 examples/README.md create mode 100644 packages/mdl-checkbox/README.md create mode 100644 packages/mdl-checkbox/foundation.js create mode 100644 test/unit/mdl-checkbox/foundation.test.js create mode 100644 test/unit/mdl-checkbox/mdl-checkbox.test.js diff --git a/.stylelintrc.yaml b/.stylelintrc.yaml index 4ee43f75c9..e80ef32cc1 100644 --- a/.stylelintrc.yaml +++ b/.stylelintrc.yaml @@ -188,7 +188,7 @@ rules: # Because we adhere to BEM we can limit the amount of necessary compound selectors. Most should # just be 1 level selector. However, modifiers can introduce an additional compound selector. # Futhermore, generic qualifying selectors (e.g. `[dir="rtl"]`) can introduce yet another level. - selector-max-compound-selectors: 3 + selector-max-compound-selectors: 4 # For specificity: disallow IDs (0). Allow for complex combinations of classes, pseudo-classes, # attribute modifiers based on selector-max-compound-selectors, plus an addition for # pseudo-classes (4). Allow for pseudo-elements (1). @@ -208,9 +208,14 @@ rules: selector-no-qualifying-type: true # In general, we should *never* be modifying elements within our components, since we can't # predict the use cases in which users would add a certain type of element into a component. - # An exception to this may be in packages/material-design-lite, in which case this rule could be - # disabled for that file, with an explanation. - selector-no-type: true + # The only hard exception to this are `fieldset` elements, which can be disabled and in that case + # we want our UI components within that fieldset to be disabled as well. + # Other exceptions to this may be in packages/material-design-lite, in which case this rule could + # be disabled for that file, with an explanation. + selector-no-type: + - true + - ignoreTypes: + - fieldset # Styles for components should never need the universal selector. selector-no-universal: true # Ensure any defined symbols are prefixed with "mdl-" @@ -219,8 +224,7 @@ rules: custom-property-pattern: ^mdl?-.+ selector-class-pattern: - ^mdl?-.+ - - - resolveNestedSelectors: true + - resolveNestedSelectors: true selector-id-pattern: ^mdl?-.+ # Names are more semantic than numbers font-weight-notation: named-where-possible @@ -235,8 +239,7 @@ rules: - - /^TODO:/ - /^FIXME:/ - - - severity: warning + - severity: warning # Part of google's style guide number-leading-zero: never @@ -249,6 +252,7 @@ rules: componentSelectors: ^\.mdl?-{componentName}(?:__[a-z]+(?:-[a-z]+)*)*(?:--[a-z]+(?:-[a-z]+)*)*(?:\[.+\])*$ ignoreSelectors: - ^fieldset + - ^\[aria\-disabled=(?:.+)\] # SCSS naming patterns, just like our CSS conventions above. # (note for $-vars we use a leading underscore for "private" variables) diff --git a/demos/checkbox.html b/demos/checkbox.html index 1260959df3..24d21a0dc2 100644 --- a/demos/checkbox.html +++ b/demos/checkbox.html @@ -14,50 +14,145 @@ See the License for the specific language governing permissions and limitations under the License --> - + MDL Checkbox Demo +
-

MDL Checkbox Hello

+

MDL Checkbox

-

Auto-initialized checkbox

- -
-
-
+

Basic Example, no Javascript

+
+
+
+ +
+ + + +
+
+
+ +
+
+ + + + + +
+
+

With Javascript

+
+
+
+ +
+ + + +
+
+
+ +
+
+ +
+
+

Dark Theme

+
+
+
-
-
+
- + d="M1.73,12.91 8.1,19.28 22.79,4.59"/> -
+
- +
+ +
- diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000..13b7959e78 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,6 @@ +# NOTE: Not currently functional. + +We've recently [changed our architecture]() such that these examples are outdated and probably don't +work. We will fix them up before our beta release, as things may still change until then. + +Working framework examples for v2 can be found on our [POC branch](https://github.com/google/material-design-lite/tree/experimental/v2-architecture-poc/examples). diff --git a/package.json b/package.json index 4e0854fa3c..92ad8e8420 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "css-loader": "^0.23.1", "cz-conventional-changelog": "^1.1.6", "del-cli": "^0.2.0", + "dom-compare": "^0.2.1", "dom-events": "^0.1.1", "es6-promise": "^3.2.1", "eslint": "^2.12.0", @@ -60,7 +61,7 @@ "sass-loader": "^3.2.0", "style-loader": "^0.13.1", "stylefmt": "^4.1.1", - "stylelint": "^7.0.3", + "stylelint": "^7.1.0", "stylelint-config-standard": "^11.0.0", "stylelint-scss": "^1.2.1", "stylelint-selector-bem-pattern": "^1.0.0", diff --git a/packages/mdl-checkbox/README.md b/packages/mdl-checkbox/README.md new file mode 100644 index 0000000000..aecf598376 --- /dev/null +++ b/packages/mdl-checkbox/README.md @@ -0,0 +1,179 @@ +# MDL Checkbox + +The MDL Checkbox component is a spec-aligned checkbox component adhering to the +[Material Design checkbox requirements](https://material.google.com/components/selection-controls.html#selection-controls-checkbox). +It works without JavaScript with basic functionality for all states. If you use the JavaScript object for a checkbox, then it will be add more intricate animation effects when switching between states. + +## Installation + +> Note: Installation via the npm registry will be available after alpha. + +## Usage + +### Standalone Checkbox + +```html +
+ +
+ + + +
+
+ +``` + +The checkbox component is driven by an underlying native checkbox element. This element is sized and +positioned the same way as the checkbox component itself, allowing for proper behavior of assistive +devices. + +You can also add an `mdl-checkbox--theme-dark` modifier class to the component to use the dark theme +checkbox styles. + +### Checkbox wrapper class + +MDL Checkbox comes with an `mdl-checkbox-wrapper` class which you can use to easily lay out a +checkbox / label combo side-by-side. The wrapper is RTL-aware and supports start and end alignment. + +```html +
+
+
+ +
+ + + +
+
+ +
+
+``` + +To switch the order of the checkbox and label, no DOM modification is necessary. Simply add a +`mdl-checkbox-wrapper--align-end` modifier class the the wrapper block. + +```html +
+
+ +
+
+``` + +### Using the JS Component + +MDL Checkbox ships with a Component / Foundation combo which progressively enhances the checkbox +state transitions to achieve full parity with the material design motion for switching checkbox +states. + +#### Including in code + +##### ES2015 + +```javascript +import MDLCheckbox, {MDLCheckboxFoundation} from 'mdl-checkbox'; +``` + +##### CommonJS + +```javascript +const mdlCheckbox = require('mdl-checkbox'); +const MDLCheckbox = mdlCheckbox.default; +const MDLCheckboxFoundation = mdlCheckbox.MDLCheckboxFoundation; +``` + +##### AMD + +```javascript +require(['path/to/mdl-checkbox'], mdlCheckbox => { + const MDLCheckbox = mdlCheckbox.default; + const MDLCheckboxFoundation = mdlCheckbox.MDLCheckboxFoundation; +}); +``` + +##### Global + +```javascript +const MDLCheckbox = mdl.Checkbox.default; +const MDLCheckboxFoundation = mdl.Checkbox.MDLCheckboxFoundation; +``` + +#### Fully-automatic: DOM Rendering + Initialization + +```javascript +const root = MDLCheckbox.buildDom({id: 'my-checkbox', labelId: 'my-checkbox-label'}); +const checkbox = MDLCheckbox.attachTo(root); +// append root to element, etc... +``` + +You can use `MDLCheckbox.buildDom` to dynamically construct checkbox DOM for you. +`MDLCheckbox.buildDom` takes an options object with values described below: + +| Option | Type | Default | Description | +| --- | --- | --- | --- | +| `id` | `string` | `mdl-checkbox-` | The id for the native checkbox control. | +| `labelId` | `string` | `mdl-checkbox-label-` | The id of the element which label's this checkbox. The default will use the `id` param and prefix it with `mdl-checkbox-label`. This value is +attached to the `aria-labelledby` attribute on the native control. | + +> **NOTE**: Regardless of how you instantiate a checkbox element, you should always strive to +> provide an id for the checkbox that's used within its label's `for` attribute, as well as an id +> for its label which is used in the native control's `aria-labelledby` attribute. This will ensure +> that assistive devices function properly when using this component. + +#### Using an existing element. + +If you do not care about retaining the component instance for the checkbox, simply call `attachTo()` +and pass it a DOM element. + +```javascript +mdl.Checkbox.attachTo(document.querySelector('.mdl-checkbox')); +``` + +#### Manual Instantiation + +Checkboxes can easily be initialized using their default constructors as well, similar to `attachTo`. + +```javascript +import MDLCheckbox from 'mdl-checkbox'; + +const checkbox = new MDLCheckbox(document.querySelector('.mdl-checkbox')); +``` + +### Using the Foundation Class + +MDL Checkbox ships with an `MDLCheckboxFoundation` class that external frameworks and libraries can +use to integrate the component. As with all foundation classes, an adapter object must be provided. +The adapter for checkboxes must provide the following functions, with correct signatures: + +| Method Signature | Description | +| --- | --- | +| `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.` | + +## Theming + +> TK once mdl-theming lands. diff --git a/packages/mdl-checkbox/_keyframes.scss b/packages/mdl-checkbox/_keyframes.scss index a971808e37..99bf109f05 100644 --- a/packages/mdl-checkbox/_keyframes.scss +++ b/packages/mdl-checkbox/_keyframes.scss @@ -16,31 +16,60 @@ @import "./variables"; -@keyframes md-checkbox-fade-in-background { +@keyframes mdl-checkbox-fade-in-background { 0% { - opacity: 0; + border-color: $mdl-checkbox-border-color; + background-color: transparent; } 50% { - opacity: 1; + border-color: $mdl-checkbox-background-color; + background-color: $mdl-checkbox-background-color; } } -@keyframes md-checkbox-fade-out-background { +@keyframes mdl-checkbox-fade-out-background { 0%, + 80% { + border-color: $mdl-checkbox-background-color; + background-color: $mdl-checkbox-background-color; + } + + 100% { + border-color: $mdl-checkbox-border-color; + background-color: transparent; + } +} + +@keyframes mdl-checkbox-fade-in-background-dark { + 0% { + border-color: $mdl-checkbox-border-color-dark; + background-color: transparent; + } + 50% { - opacity: 1; + border-color: $mdl-checkbox-background-color; + background-color: $mdl-checkbox-background-color; + } +} + +@keyframes mdl-checkbox-fade-out-background-dark { + 0%, + 80% { + border-color: $mdl-checkbox-background-color; + background-color: $mdl-checkbox-background-color; } 100% { - opacity: 0; + border-color: $mdl-checkbox-border-color-dark; + background-color: transparent; } } -@keyframes md-checkbox-unchecked-checked-checkmark-path { +@keyframes mdl-checkbox-unchecked-checked-checkmark-path { 0%, 50% { - stroke-dashoffset: $_md-checkbox-mark-path-length; + stroke-dashoffset: $_mdl-checkbox-mark-path-length; } 50% { @@ -52,7 +81,7 @@ } } -@keyframes md-checkbox-unchecked-indeterminate-mixedmark { +@keyframes mdl-checkbox-unchecked-indeterminate-mixedmark { 0%, 68.2% { transform: scaleX(0); @@ -67,18 +96,20 @@ } } -@keyframes md-checkbox-checked-unchecked-checkmark-path { +@keyframes mdl-checkbox-checked-unchecked-checkmark-path { from { @include mdl-animation-fast-out-linear-in; + opacity: 1; stroke-dashoffset: 0; } to { - stroke-dashoffset: $_md-checkbox-mark-path-length * -1; + opacity: 0; + stroke-dashoffset: $_mdl-checkbox-mark-path-length * -1; } } -@keyframes md-checkbox-checked-indeterminate-checkmark { +@keyframes mdl-checkbox-checked-indeterminate-checkmark { from { transform: rotate(0deg); opacity: 1; @@ -92,11 +123,11 @@ } } -@keyframes md-checkbox-indeterminate-checked-checkmark { +@keyframes mdl-checkbox-indeterminate-checked-checkmark { from { transform: rotate(45deg); opacity: 0; - animation-timing-function: $_md-checkbox-indeterminate-checked-easing-function; + animation-timing-function: $_mdl-checkbox-indeterminate-checked-easing-function; } to { @@ -105,7 +136,7 @@ } } -@keyframes md-checkbox-checked-indeterminate-mixedmark { +@keyframes mdl-checkbox-checked-indeterminate-mixedmark { from { transform: rotate(-45deg); opacity: 0; @@ -119,11 +150,11 @@ } } -@keyframes md-checkbox-indeterminate-checked-mixedmark { +@keyframes mdl-checkbox-indeterminate-checked-mixedmark { from { transform: rotate(0deg); opacity: 1; - animation-timing-function: $_md-checkbox-indeterminate-checked-easing-function; + animation-timing-function: $_mdl-checkbox-indeterminate-checked-easing-function; } to { @@ -132,7 +163,7 @@ } } -@keyframes md-checkbox-indeterminate-unchecked-mixedmark { +@keyframes mdl-checkbox-indeterminate-unchecked-mixedmark { 0% { transform: scaleX(1); opacity: 1; diff --git a/packages/mdl-checkbox/_variables.scss b/packages/mdl-checkbox/_variables.scss index 3adb6d59f0..c722acbeb9 100644 --- a/packages/mdl-checkbox/_variables.scss +++ b/packages/mdl-checkbox/_variables.scss @@ -14,26 +14,20 @@ * limitations under the License. */ -// -// Theme variables -// -$md-checkbox-background-color: #212121 !default; -$md-checkbox-mark-color: #fff !default; -$md-checkbox-border-color: rgba(black, .54) !default; -// NOTE(traviskaufman): While the spec calls for translucent blacks/whites for disabled colors, -// this does not work well with elements layered on top of one another. To get around this we -// blend the colors together based on the base color and the theme background. -// Black 26% opacity on white background. -$md-checkbox-disabled-color: #b0b0b0 !default; +/* TODO: Revisit once MDL theming lands */ +$mdl-checkbox-background-color: #3f51b5 !default; +$mdl-checkbox-mark-color: white !default; +$mdl-checkbox-border-color: rgba(black, .54) !default; +$mdl-checkbox-border-color-dark: white !default; +$mdl-checkbox-disabled-color: rgba(black, .26) !default; +$mdl-checkbox-disabled-color-dark: rgba(white, .3) !default; -// -// Other variables -// -$md-checkbox-size: 18px; -$md-checkbox-mark-stroke-size: 2/15 * $md-checkbox-size; -$md-checkbox-border-width: 2px; -$md-checkbox-transition-duration: 90ms; -$md-checkbox-item-spacing: 4px; -// Manual calculation done on SVG -$_md-checkbox-mark-path-length: 22.910259; -$_md-checkbox-indeterminate-checked-easing-function: cubic-bezier(.14, 0, 0, 1); +$mdl-checkbox-size: 18px; +$mdl-checkbox-mark-stroke-size: 2/15 * $mdl-checkbox-size; +$mdl-checkbox-border-width: 2px; +$mdl-checkbox-transition-duration: 90ms; +$mdl-checkbox-item-spacing: 4px; + +/* Manual calculation done on SVG */ +$_mdl-checkbox-mark-path-length: 29.7833385; +$_mdl-checkbox-indeterminate-checked-easing-function: cubic-bezier(.14, 0, 0, 1); diff --git a/packages/mdl-checkbox/constants.js b/packages/mdl-checkbox/constants.js index d41b2ace72..44edc5a28f 100644 --- a/packages/mdl-checkbox/constants.js +++ b/packages/mdl-checkbox/constants.js @@ -14,14 +14,13 @@ * limitations under the License. */ -const ROOT = 'md-checkbox'; +const ROOT = 'mdl-checkbox'; const ANIM = `${ROOT}--anim`; export const cssClasses = { ROOT, CHECKED: `${ROOT}--checked`, INDETERMINATE: `${ROOT}--indeterminate`, - FOCUSED: `${ROOT}--focused`, ANIM_UNCHECKED_CHECKED: `${ANIM}-unchecked-checked`, ANIM_UNCHECKED_INDETERMINATE: `${ANIM}-unchecked-indeterminate`, ANIM_CHECKED_UNCHECKED: `${ANIM}-checked-unchecked`, @@ -39,7 +38,7 @@ export const strings = { // which use the 'webkit' prefix. return 'animation' in el.style ? 'animationend' : 'webkitAnimationEnd'; })(), - NATIVE_CONTROL_SELECTOR: '.md-checkbox__native-control', + NATIVE_CONTROL_SELECTOR: `.${ROOT}__native-control`, TRANSITION_STATE_INIT: 'init', TRANSITION_STATE_CHECKED: 'checked', TRANSITION_STATE_UNCHECKED: 'unchecked', diff --git a/packages/mdl-checkbox/foundation.js b/packages/mdl-checkbox/foundation.js new file mode 100644 index 0000000000..8189ebe4ba --- /dev/null +++ b/packages/mdl-checkbox/foundation.js @@ -0,0 +1,194 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {MDLFoundation} from 'mdl-base'; +import {cssClasses, strings, numbers} from './constants'; + +const CB_PROTO_PROPS = ['checked', 'indeterminate']; + +export default class MDLCheckboxFoundation extends MDLFoundation { + static get cssClasses() { + return cssClasses; + } + + static get strings() { + return strings; + } + + static get numbers() { + return numbers; + } + + static get defaultAdapter() { + return { + addClass: (/* className: string */) => {}, + removeClass: (/* className: string */) => {}, + registerAnimationEndHandler: (/* handler: EventListener */) => {}, + deregisterAnimationEndHandler: (/* handler: EventListener */) => {}, + registerChangeHandler: (/* handler: EventListener */) => {}, + deregisterChangeHandler: (/* handler: EventListener */) => {}, + getNativeControl: () => /* HTMLInputElement */ {}, + forceLayout: () => {}, + isAttachedToDOM: () => /* boolean */ {} + }; + } + + constructor(adapter) { + super(Object.assign(MDLCheckboxFoundation.defaultAdapter, adapter)); + + this.currentCheckState_ = strings.TRANSITION_STATE_INIT; + this.currentAnimationClass_ = ''; + this.animEndLatchTimer_ = 0; + this.animEndHandler_ = () => { + clearTimeout(this.animEndLatchTimer_); + this.animEndLatchTimer_ = setTimeout(() => { + this.adapter_.removeClass(this.currentAnimationClass_); + this.adapter_.deregisterAnimationEndHandler(this.animEndHandler_); + }, numbers.ANIM_END_LATCH_MS); + }; + this.changeHandler_ = () => this.transitionCheckState_(); + } + + init() { + this.adapter_.registerChangeHandler(this.changeHandler_); + this.installPropertyChangeHooks_(); + } + + destroy() { + this.adapter_.deregisterChangeHandler(this.changeHandler_); + this.uninstallPropertyChangeHooks_(); + } + + installPropertyChangeHooks_() { + const nativeCb = this.adapter_.getNativeControl(); + if (!nativeCb) { + return; + } + const cbProto = Object.getPrototypeOf(nativeCb); + + CB_PROTO_PROPS.forEach(controlState => { + const desc = Object.getOwnPropertyDescriptor(cbProto, controlState); + // We have to check for this descriptor, since some browsers (Safari) don't support its return. + // See: https://bugs.webkit.org/show_bug.cgi?id=49739 + if (validDescriptor(desc)) { + Object.defineProperty(nativeCb, controlState, { + get: desc.get, + set: state => { + desc.set.call(nativeCb, state); + this.transitionCheckState_(); + }, + configurable: desc.configurable, + enumerable: desc.enumerable + }); + } + }); + } + + uninstallPropertyChangeHooks_() { + const nativeCb = this.adapter_.getNativeControl(); + if (!nativeCb) { + return; + } + const cbProto = Object.getPrototypeOf(nativeCb); + + CB_PROTO_PROPS.forEach(controlState => { + const desc = Object.getOwnPropertyDescriptor(cbProto, controlState); + if (validDescriptor(desc)) { + Object.defineProperty(nativeCb, controlState, desc); + } + }); + } + + transitionCheckState_() { + const nativeCb = this.adapter_.getNativeControl(); + if (!nativeCb) { + return; + } + const oldState = this.currentCheckState_; + const newState = this.determineCheckState_(nativeCb); + if (oldState === newState) { + return; + } + + // Check to ensure that there isn't a previously existing animation class, in case for example + // the user interacted with the checkbox before the animation was finished. + if (this.currentAnimationClass_.length > 0) { + clearTimeout(this.animEndLatchTimer_); + this.adapter_.forceLayout(); + this.adapter_.removeClass(this.currentAnimationClass_); + } + + this.currentAnimationClass_ = this.getTransitionAnimationClass_(oldState, newState); + this.currentCheckState_ = newState; + + // Check for parentNode so that animations are only run when the element is attached + // to the DOM. + if (this.adapter_.isAttachedToDOM() && this.currentAnimationClass_.length > 0) { + this.adapter_.addClass(this.currentAnimationClass_); + this.adapter_.registerAnimationEndHandler(this.animEndHandler_); + } + } + + determineCheckState_(nativeCb) { + const { + TRANSITION_STATE_INDETERMINATE, + TRANSITION_STATE_CHECKED, + TRANSITION_STATE_UNCHECKED + } = strings; + + if (nativeCb.indeterminate) { + return TRANSITION_STATE_INDETERMINATE; + } + return nativeCb.checked ? TRANSITION_STATE_CHECKED : TRANSITION_STATE_UNCHECKED; + } + + getTransitionAnimationClass_(oldState, newState) { + const { + TRANSITION_STATE_INIT, + TRANSITION_STATE_CHECKED, + TRANSITION_STATE_UNCHECKED + } = strings; + + const { + ANIM_UNCHECKED_CHECKED, + ANIM_UNCHECKED_INDETERMINATE, + ANIM_CHECKED_UNCHECKED, + ANIM_CHECKED_INDETERMINATE, + ANIM_INDETERMINATE_CHECKED, + ANIM_INDETERMINATE_UNCHECKED + } = MDLCheckboxFoundation.cssClasses; + + switch (oldState) { + case TRANSITION_STATE_INIT: + if (newState === TRANSITION_STATE_UNCHECKED) { + return ''; + } + // fallthrough + case TRANSITION_STATE_UNCHECKED: + return newState === TRANSITION_STATE_CHECKED ? ANIM_UNCHECKED_CHECKED : ANIM_UNCHECKED_INDETERMINATE; + case TRANSITION_STATE_CHECKED: + return newState === TRANSITION_STATE_UNCHECKED ? ANIM_CHECKED_UNCHECKED : ANIM_CHECKED_INDETERMINATE; + // TRANSITION_STATE_INDETERMINATE + default: + return newState === TRANSITION_STATE_CHECKED ? + ANIM_INDETERMINATE_CHECKED : ANIM_INDETERMINATE_UNCHECKED; + } + } +} + +function validDescriptor(inputPropDesc) { + return inputPropDesc && typeof inputPropDesc.set === 'function'; +} diff --git a/packages/mdl-checkbox/index.js b/packages/mdl-checkbox/index.js index d7c7a8c541..3678031f74 100644 --- a/packages/mdl-checkbox/index.js +++ b/packages/mdl-checkbox/index.js @@ -15,70 +15,56 @@ */ import MDLComponent from 'mdl-base'; -import MDLCheckboxMixin from './mixin'; -import {cssClasses, strings, numbers} from './constants'; +import MDLCheckboxFoundation from './foundation'; -/** - * A Material Design Checkbox. - * @constructor - * @final - * @extends MDLBaseComponent - */ -export default class MDLCheckbox extends MDLComponent { - static attachTo(root) { - return new MDLCheckbox(root); - } +let idCounter = 0; - static mixInto(CtorOrProto, options) { - const proto = typeof CtorOrProto === 'function' ? CtorOrProto.prototype : CtorOrProto; - MDLCheckboxMixin.call(proto, options); - } +export default class MDLCheckbox extends MDLComponent { + static buildDom({id = `mdl-checkbox-${++idCounter}`, labelId = `mdl-checkbox-label-${id}`} = {}) { + const {ROOT: CSS_ROOT} = MDLCheckboxFoundation.cssClasses; - static get cssClasses() { - return cssClasses; - } + const root = document.createElement('div'); + root.classList.add(CSS_ROOT); + root.innerHTML = ` + +
+
+ + + +
+
+ `; - static get strings() { - return strings; + return root; } - static get numbers() { - return numbers; + static attachTo(root) { + return new MDLCheckbox(root); } - constructor(root) { - super(root); - this.nativeCb_ = this.root_.querySelector(strings.NATIVE_CONTROL_SELECTOR); - this.initMdlCheckbox_(); + getDefaultFoundation() { + const {ANIM_END_EVENT_NAME, NATIVE_CONTROL_SELECTOR} = MDLCheckboxFoundation.strings; + const nativeCb = this.root_.querySelector(NATIVE_CONTROL_SELECTOR); + return new MDLCheckboxFoundation({ + addClass: className => this.root_.classList.add(className), + removeClass: className => this.root_.classList.remove(className), + registerAnimationEndHandler: handler => this.root_.addEventListener(ANIM_END_EVENT_NAME, handler), + deregisterAnimationEndHandler: handler => this.root_.removeEventListener(ANIM_END_EVENT_NAME, handler), + registerChangeHandler: handler => nativeCb.addEventListener('change', handler), + deregisterChangeHandler: handler => nativeCb.removeEventListener('change', handler), + getNativeControl: () => nativeCb, + forceLayout: () => this.root_.offsetWidth, + isAttachedToDOM: () => Boolean(this.root_.parentNode) + }); } } -MDLCheckboxMixin.call(MDLCheckbox.prototype, { - addClass(className) { - this.root_.classList.add(className); - }, - removeClass(className) { - this.root_.classList.remove(className); - }, - addEventListener(type, listener) { - this.root_.addEventListener(type, listener); - }, - removeEventListener(type, listener) { - this.root_.removeEventListener(type, listener); - }, - getNativeCheckbox() { - return this.nativeCb_; - }, - addNativeCheckboxListener(type, listener) { - this.nativeCb_.addEventListener(type, listener); - }, - removeNativeCheckboxListener(type, listener) { - this.nativeCb_.removeEventListener(type, listener); - }, - forceLayout() { - // Return to prevent optimizers thinking this is dead code. - return this.root_.offsetWidth; - }, - isAttachedToDOM() { - return Boolean(this.root_.parentNode); - } -}); diff --git a/packages/mdl-checkbox/mdl-checkbox.scss b/packages/mdl-checkbox/mdl-checkbox.scss index 16a132cbaa..b403e06a71 100644 --- a/packages/mdl-checkbox/mdl-checkbox.scss +++ b/packages/mdl-checkbox/mdl-checkbox.scss @@ -18,10 +18,24 @@ @import "./variables"; @import "./keyframes"; -/** - * Applied to elements that cover the checkbox"s entire inner container. - */ -@mixin md-checkbox-cover-element { +// TODO: Docs +// TODO: x-browser test! + +@function mdl-checkbox-transition($property, $timing-function, $delay: 0ms, $duration: $mdl-checkbox-transition-duration) { + @return $property $duration $timing-function $delay; +} + +@function mdl-checkbox-transition-enter($property, $delay: 0ms, $duration: $mdl-checkbox-transition-duration) { + @return mdl-checkbox-transition( + $property, $mdl-animation-linear-out-slow-in-timing-function, $delay, $duration); +} + +@function mdl-checkbox-transition-exit($property, $delay: 0ms, $duration: $mdl-checkbox-transition-duration) { + @return mdl-checkbox-transition( + $property, $mdl-animation-fast-out-linear-in-timing-function, $delay, $duration); +} + +@mixin mdl-checkbox-cover-element { position: absolute; top: 0; right: 0; @@ -29,28 +43,22 @@ left: 0; } -/** - * Applied to elements that are considered "marks" within the checkbox, e.g. the checkmark and - * the mixedmark. - */ -@mixin md-checkbox-mark { - width: calc(100% - #{2 * $md-checkbox-border-width}); -} - -/** - * Applied to elements that appear to make up the outer box of the checkmark, such as the frame - * that contains the border and the actual background element that contains the marks. - */ -@mixin md-checkbox-outer-box { +@mixin mdl-checkbox-outer-box { border-radius: 2px; - @include md-checkbox-cover-element; + @include mdl-checkbox-cover-element; box-sizing: border-box; pointer-events: none; } -// postcss-bem-linter: define checkbox-wrapper -.md-checkbox-wrapper { +@mixin mdl-checkbox-focus-ring-transition($timing-fn) { + transition: + mdl-checkbox-transition(opacity, $timing-fn, 0ms, 80ms), + mdl-checkbox-transition(transform, $timing-fn, 0ms, 80ms); +} + +/* postcss-bem-linter: define checkbox-wrapper */ +.mdl-checkbox-wrapper { display: inline-block; &__layout { @@ -58,66 +66,80 @@ align-items: baseline; vertical-align: middle; } - // postcss-bem-linter: ignore - .md-checkbox { + + /* postcss-bem-linter: ignore */ + .mdl-checkbox { order: 0; margin: auto; - margin-right: $md-checkbox-item-spacing; + margin-right: $mdl-checkbox-item-spacing; - // postcss-bem-linter: ignore + /* postcss-bem-linter: ignore */ [dir="rtl"] & { margin-right: auto; - margin-left: $md-checkbox-item-spacing; + margin-left: $mdl-checkbox-item-spacing; } } } -.md-checkbox-wrapper--align-end { - // postcss-bem-linter: ignore - .md-checkbox { +.mdl-checkbox-wrapper--align-end { + /* postcss-bem-linter: ignore */ + .mdl-checkbox { order: 1; margin-right: auto; - margin-left: $md-checkbox-item-spacing; + margin-left: $mdl-checkbox-item-spacing; - // postcss-bem-linter: ignore + /* postcss-bem-linter: ignore */ [dir="rtl"] & { - margin-right: $md-checkbox-item-spacing; + margin-right: $mdl-checkbox-item-spacing; margin-left: auto; } } } -// postcss-bem-linter: end -// postcss-bem-linter: define checkbox -.md-checkbox { +/* postcss-bem-linter: end */ + +/* postcss-bem-linter: define checkbox */ +.mdl-checkbox { display: inline-block; position: relative; - width: $md-checkbox-size; - height: $md-checkbox-size; + width: $mdl-checkbox-size; + height: $mdl-checkbox-size; line-height: 0; white-space: nowrap; cursor: pointer; vertical-align: bottom; - &__frame { - transition: border-color $md-checkbox-transition-duration $mdl-animation-linear-out-slow-in-timing-function; - border: $md-checkbox-border-width solid $md-checkbox-border-color; - background-color: transparent; - - @include md-checkbox-outer-box; - will-change: border-color; - } - &__background { + @include mdl-checkbox-outer-box; + display: inline-flex; align-items: center; justify-content: center; - transition: background-color $md-checkbox-transition-duration $mdl-animation-linear-out-slow-in-timing-function; - background-color: $md-checkbox-background-color; - opacity: 0; + transition: + mdl-checkbox-transition-exit(background-color), + mdl-checkbox-transition-exit(border-color); + + border: $mdl-checkbox-border-width solid $mdl-checkbox-border-color; + background-color: transparent; + will-change: background-color, border-color; - @include md-checkbox-outer-box; - will-change: background-color, opacity; + .mdl-checkbox--dark-theme & { + border-color: $mdl-checkbox-border-color-dark; + } + + /** The frame's ::before element is used as a focus indicator for the checkbox */ + &::before { + @include mdl-checkbox-cover-element; + + transform: scale(0, 0); + transition: mdl-checkbox-transition-exit(opacity), mdl-checkbox-transition-exit(transform); + border-radius: 50%; + background: $mdl-checkbox-background-color; + content: ""; + opacity: 0; + pointer-events: none; + will-change: opacity, transform; + } } &__native-control { @@ -133,41 +155,59 @@ } &__checkmark { - width: 100%; + @include mdl-checkbox-cover-element; - @include md-checkbox-cover-element; - @include md-checkbox-mark; - fill: $md-checkbox-mark-color; + width: 100%; + transition: mdl-checkbox-transition-exit(opacity, 0ms, $mdl-checkbox-transition-duration * 2); + opacity: 0; + fill: $mdl-checkbox-mark-color; &__path { + transition: + mdl-checkbox-transition-exit( + stroke-dashoffset, + 0ms, + $mdl-checkbox-transition-duration * 2 + ); // !important is needed here because a stroke must be set as an attribute on the SVG in order // for line animation to work properly. - stroke: $md-checkbox-mark-color !important; - stroke-width: $md-checkbox-mark-stroke-size; - stroke-dashoffset: $_md-checkbox-mark-path-length; - stroke-dasharray: $_md-checkbox-mark-path-length; + stroke: $mdl-checkbox-mark-color !important; + stroke-width: $mdl-checkbox-mark-stroke-size * 1.3; + stroke-dashoffset: $_mdl-checkbox-mark-path-length; + stroke-dasharray: $_mdl-checkbox-mark-path-length; } } &__mixedmark { - height: floor($md-checkbox-mark-stroke-size); + width: 100%; + height: floor($mdl-checkbox-mark-stroke-size); transform: scaleX(0) rotate(0deg); - background-color: $md-checkbox-mark-color; + transition: mdl-checkbox-transition-exit(opacity), mdl-checkbox-transition-exit(transform); + background-color: $mdl-checkbox-mark-color; opacity: 0; - - @include md-checkbox-mark; } } -.md-checkbox--focused { - outline: $md-checkbox-background-color auto 5px; +.mdl-checkbox__native-control:focus { + ~ .mdl-checkbox__background::before { + @include mdl-checkbox-focus-ring-transition($mdl-animation-linear-out-slow-in-timing-function); + transform: scale(2.75, 2.75); + opacity: .26; + } } -.md-checkbox__native-control:checked { - ~ .md-checkbox__background { - opacity: 1; - - .md-checkbox__checkmark { +.mdl-checkbox__native-control:checked { + ~ .mdl-checkbox__background { + transition: + mdl-checkbox-transition-enter(border-color), + mdl-checkbox-transition-enter(background-color); + border-color: $mdl-checkbox-background-color; + background-color: $mdl-checkbox-background-color; + + .mdl-checkbox__checkmark { + transition: + mdl-checkbox-transition-enter(opacity, 0ms, $mdl-checkbox-transition-duration * 2), + mdl-checkbox-transition-enter(transform, 0ms, $mdl-checkbox-transition-duration * 2); opacity: 1; &__path { @@ -175,18 +215,22 @@ } } - .md-checkbox__mixedmark { + .mdl-checkbox__mixedmark { transform: scaleX(1) rotate(-45deg); } } } -.md-checkbox__native-control:indeterminate { - ~ .md-checkbox__background { - opacity: 1; +.mdl-checkbox__native-control:indeterminate { + ~ .mdl-checkbox__background { + border-color: $mdl-checkbox-background-color; + background-color: $mdl-checkbox-background-color; - .md-checkbox__checkmark { + .mdl-checkbox__checkmark { transform: rotate(45deg); + transition: + mdl-checkbox-transition-exit(opacity, 0ms, $mdl-checkbox-transition-duration), + mdl-checkbox-transition-exit(transform, 0ms, $mdl-checkbox-transition-duration); opacity: 0; &__path { @@ -194,94 +238,120 @@ } } - .md-checkbox__mixedmark { + .mdl-checkbox__mixedmark { transform: scaleX(1) rotate(0deg); opacity: 1; } } } -.md-checkbox__native-control:disabled { +.mdl-checkbox__native-control:disabled, +fieldset:disabled .mdl-checkbox__native-control, +[aria-disabled="true"] .mdl-checkbox__native-control { cursor: default; + /* postcss-bem-linter: ignore */ + ~ .mdl-checkbox__background { + border-color: $mdl-checkbox-disabled-color; + + .mdl-checkbox--dark-theme & { + border-color: $mdl-checkbox-disabled-color-dark; + } + } + &:checked, &:indeterminate { - ~ .md-checkbox__background { - background-color: $md-checkbox-disabled-color; + ~ .mdl-checkbox__background { + border-color: transparent; + background-color: $mdl-checkbox-disabled-color; + /* stylelint-disable selector-max-compound-selectors, selector-max-specificity */ + .mdl-checkbox--dark-theme & { + background-color: $mdl-checkbox-disabled-color-dark; + } + /* stylelint-enable selector-max-compound-selectors, selector-max-specificity */ } } +} - &:not(:checked) { - ~ .md-checkbox__frame { - border-color: $md-checkbox-disabled-color; +.mdl-checkbox--anim { + $_mdl-checkbox-indeterminate-change-duration: 500ms; + + &-unchecked-checked, + &-unchecked-indeterminate { + .mdl-checkbox__background { + animation: mdl-checkbox-fade-in-background $mdl-checkbox-transition-duration * 2 linear; + + .mdl-checkbox--dark-theme & { + animation-name: mdl-checkbox-fade-in-background-dark; + } } } -} -.md-checkbox--anim { - $_md-checkbox-indeterminate-change-duration: 500ms; + &-checked-unchecked, + &-indeterminate-unchecked { + .mdl-checkbox__background { + animation: mdl-checkbox-fade-out-background $mdl-checkbox-transition-duration * 2 linear; - &-unchecked-checked { - .md-checkbox__background { - animation: $md-checkbox-transition-duration * 2 linear 0s md-checkbox-fade-in-background; + .mdl-checkbox--dark-theme & { + animation-name: mdl-checkbox-fade-out-background-dark; + } } + } - .md-checkbox__checkmark__path { + &-unchecked-checked { + .mdl-checkbox__checkmark__path { // Instead of delaying the animation, we simply multiply its length by 2 and begin the // animation at 50% in order to prevent a flash of styles applied to a checked checkmark // as the background is fading in before the animation begins. - animation: $md-checkbox-transition-duration * 2 linear 0s md-checkbox-unchecked-checked-checkmark-path; + animation: $mdl-checkbox-transition-duration * 2 linear 0s mdl-checkbox-unchecked-checked-checkmark-path; + transition: none; } } &-unchecked-indeterminate { - .md-checkbox__background { - animation: $md-checkbox-transition-duration * 2 linear 0s md-checkbox-fade-in-background; - } - - .md-checkbox__mixedmark { - animation: $md-checkbox-transition-duration linear 0s md-checkbox-unchecked-indeterminate-mixedmark; + .mdl-checkbox__mixedmark { + animation: $mdl-checkbox-transition-duration linear 0s mdl-checkbox-unchecked-indeterminate-mixedmark; + transition: none; } } &-checked-unchecked { - .md-checkbox__background { - animation: $md-checkbox-transition-duration * 2 linear 0s md-checkbox-fade-out-background; - } - - .md-checkbox__checkmark__path { - animation: $md-checkbox-transition-duration linear 0s md-checkbox-checked-unchecked-checkmark-path; + .mdl-checkbox__checkmark__path { + animation: $mdl-checkbox-transition-duration linear 0s mdl-checkbox-checked-unchecked-checkmark-path; + transition: none; } } &-checked-indeterminate { - .md-checkbox__checkmark { - animation: $md-checkbox-transition-duration linear 0s md-checkbox-checked-indeterminate-checkmark; + .mdl-checkbox__checkmark { + animation: $mdl-checkbox-transition-duration linear 0s mdl-checkbox-checked-indeterminate-checkmark; + transition: none; } - .md-checkbox__mixedmark { - animation: $md-checkbox-transition-duration linear 0s md-checkbox-checked-indeterminate-mixedmark; + .mdl-checkbox__mixedmark { + animation: $mdl-checkbox-transition-duration linear 0s mdl-checkbox-checked-indeterminate-mixedmark; + transition: none; } } &-indeterminate-checked { - .md-checkbox__checkmark { - animation: $_md-checkbox-indeterminate-change-duration linear 0s md-checkbox-indeterminate-checked-checkmark; + .mdl-checkbox__checkmark { + animation: $_mdl-checkbox-indeterminate-change-duration linear 0s mdl-checkbox-indeterminate-checked-checkmark; + transition: none; } - .md-checkbox__mixedmark { - animation: $_md-checkbox-indeterminate-change-duration linear 0s md-checkbox-indeterminate-checked-mixedmark; + .mdl-checkbox__mixedmark { + animation: $_mdl-checkbox-indeterminate-change-duration linear 0s mdl-checkbox-indeterminate-checked-mixedmark; + transition: none; } } &-indeterminate-unchecked { - .md-checkbox__background { - animation: $md-checkbox-transition-duration * 2 linear 0s md-checkbox-fade-out-background; - } - - .md-checkbox__mixedmark { - animation: $_md-checkbox-indeterminate-change-duration * .6 linear 0s md-checkbox-indeterminate-unchecked-mixedmark; + .mdl-checkbox__mixedmark { + animation: $_mdl-checkbox-indeterminate-change-duration * .6 linear 0s mdl-checkbox-indeterminate-unchecked-mixedmark; + transition: none; } } } -// postcss-bem-linter: end + +/* postcss-bem-linter: end */ diff --git a/test/unit/mdl-checkbox/foundation.test.js b/test/unit/mdl-checkbox/foundation.test.js new file mode 100644 index 0000000000..62c9e16106 --- /dev/null +++ b/test/unit/mdl-checkbox/foundation.test.js @@ -0,0 +1,411 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'tape'; +import bel from 'bel'; +import lolex from 'lolex'; +import td from 'testdouble'; +import MDLCheckboxFoundation from '../../../packages/mdl-checkbox/foundation'; +import {cssClasses, strings, numbers} from '../../../packages/mdl-checkbox/constants'; + +const DESC_UNDEFINED = { + get: undefined, + set: undefined, + enumerable: false, + configurable: true +}; + +function setupTest() { + const nativeControl = bel``; + const mockAdapter = td.object(MDLCheckboxFoundation.defaultAdapter); + td.when(mockAdapter.getNativeControl()).thenReturn(nativeControl); + + const foundation = new MDLCheckboxFoundation(mockAdapter); + return {foundation, mockAdapter, nativeControl}; +} + +// Shims Object.getOwnPropertyDescriptor for the checkbox's WebIDL attributes. Used to test +// the behavior of overridding WebIDL properties in different browser environments. For example, +// in Safari WebIDL attributes don't return get/set in descriptors. +function withMockCheckboxDescriptorReturning(descriptor, runTests) { + const originalDesc = Object.getOwnPropertyDescriptor(Object, 'getOwnPropertyDescriptor'); + const mockGetOwnPropertyDescriptor = td.func('.getOwnPropertyDescriptor'); + const oneOf = (...validArgs) => td.matchers.argThat(x => validArgs.indexOf(x) >= 0); + + td.when(mockGetOwnPropertyDescriptor(HTMLInputElement.prototype, oneOf('checked', 'indeterminate'))) + .thenReturn(descriptor); + + Object.defineProperty(Object, 'getOwnPropertyDescriptor', Object.assign({}, originalDesc, { + value: mockGetOwnPropertyDescriptor + })); + runTests(mockGetOwnPropertyDescriptor); + Object.defineProperty(Object, 'getOwnPropertyDescriptor', originalDesc); +} + +// Sets up tests which execute change events through the change handler which the foundation +// registers. Returns an object containing the following properties: +// - foundation - The MDLCheckboxFoundation 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 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(); + }; + + return {foundation, mockAdapter, change}; +} + +function testChangeHandler(desc, changes, expectedClass, verificationOpts) { + changes = Array.isArray(changes) ? changes : [changes]; + test(`changeHandler: ${desc}`, t => { + const {mockAdapter, change} = setupChangeHandlerTest(); + changes.forEach(change); + t.doesNotThrow(() => { + td.verify(mockAdapter.addClass(expectedClass), verificationOpts); + }); + + t.end(); + }); +} + +test('exports strings', t => { + t.deepEqual(MDLCheckboxFoundation.strings, strings); + t.end(); +}); + +test('exports cssClasses', t => { + t.deepEqual(MDLCheckboxFoundation.cssClasses, cssClasses); + t.end(); +}); + +test('exports numbers', t => { + t.deepEqual(MDLCheckboxFoundation.numbers, numbers); + t.end(); +}); + +test('defaultAdapter returns a complete adapter implementation', t => { + const {defaultAdapter} = MDLCheckboxFoundation; + const methods = Object.keys(defaultAdapter).filter(k => typeof defaultAdapter[k] === 'function'); + + t.equal(methods.length, Object.keys(defaultAdapter).length, 'Every adapter key must be a function'); + t.deepEqual(methods, [ + 'addClass', 'removeClass', 'registerAnimationEndHandler', 'deregisterAnimationEndHandler', + 'registerChangeHandler', 'deregisterChangeHandler', 'getNativeControl', 'forceLayout', + 'isAttachedToDOM' + ]); + // Test default methods + methods.forEach(m => t.doesNotThrow(defaultAdapter[m])); + + t.end(); +}); + +test('#init calls adapter.registerChangeHandler() with a change handler function', t => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.init(); + t.doesNotThrow(() => td.verify(mockAdapter.registerChangeHandler(isA(Function)))); + t.end(); +}); + +test('#init handles case where getNativeControl() does not return anything', t => { + const {foundation, mockAdapter} = setupTest(); + td.when(mockAdapter.getNativeControl()).thenReturn(undefined); + t.doesNotThrow(() => foundation.init()); + t.end(); +}); + +test('#init handles case when WebIDL attrs cannot be overridden (Safari)', t => { + const {foundation, nativeControl} = setupTest(); + withMockCheckboxDescriptorReturning(DESC_UNDEFINED, () => { + t.doesNotThrow(() => { + foundation.init(); + nativeControl.checked = !nativeControl.checked; + }); + }); + t.end(); +}); + +test('#init handles case when property descriptors are not returned at all (Android Browser)', t => { + const {foundation} = setupTest(); + withMockCheckboxDescriptorReturning(undefined, () => { + t.doesNotThrow(() => foundation.init()); + }); + t.end(); +}); + +test('#destroy calls adapter.deregisterChangeHandler() with a registerChangeHandler function', t => { + 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(); + t.doesNotThrow(() => td.verify(mockAdapter.deregisterChangeHandler(changeHandler))); + + t.end(); +}); + +test('#destroy handles case where getNativeControl() does not return anything', t => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + + td.when(mockAdapter.getNativeControl()).thenReturn(undefined); + t.doesNotThrow(() => foundation.destroy()); + + t.end(); +}); + +test('#destroy handles case when WebIDL attrs cannot be overridden (Safari)', t => { + const {foundation} = setupTest(); + withMockCheckboxDescriptorReturning(DESC_UNDEFINED, () => { + t.doesNotThrow(() => foundation.init(), 'init sanity check'); + t.doesNotThrow(() => foundation.destroy()); + }); + t.end(); +}); + +testChangeHandler('unchecked -> checked animation class', { + checked: true, + indeterminate: false +}, cssClasses.ANIM_UNCHECKED_CHECKED); + +testChangeHandler('unchecked -> indeterminate animation class', { + checked: false, + indeterminate: true +}, cssClasses.ANIM_UNCHECKED_INDETERMINATE); + +testChangeHandler('checked -> unchecked animation class', [ + { + checked: true, + indeterminate: false + }, + { + checked: false, + indeterminate: false + } +], cssClasses.ANIM_CHECKED_UNCHECKED); + +testChangeHandler('checked -> indeterminate animation class', [ + { + checked: true, + indeterminate: false + }, + { + checked: true, + indeterminate: true + } +], cssClasses.ANIM_CHECKED_INDETERMINATE); + +testChangeHandler('indeterminate -> checked animation class', [ + { + checked: false, + indeterminate: true + }, + { + checked: true, + indeterminate: false + } +], cssClasses.ANIM_INDETERMINATE_CHECKED); + +testChangeHandler('indeterminate -> unchecked animation class', [ + { + checked: true, + indeterminate: true + }, + { + checked: false, + indeterminate: false + } +], cssClasses.ANIM_INDETERMINATE_UNCHECKED); + +testChangeHandler('no transition classes applied when no state change', [ + { + checked: true, + indeterminate: false + }, + { + checked: true, + indeterminate: false + } +], cssClasses.ANIM_UNCHECKED_CHECKED, {times: 1}); + +test('animation end handler one-off removes animation class after short delay', t => { + 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}); + t.true(animEndHandler instanceof Function, 'animationend handler registeration sanity test'); + + animEndHandler(); + const {ANIM_UNCHECKED_CHECKED} = cssClasses; + t.doesNotThrow(() => td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED), {times: 0})); + + clock.tick(numbers.ANIM_END_LATCH_MS); + t.doesNotThrow(() => td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED))); + t.doesNotThrow(() => td.verify(mockAdapter.deregisterAnimationEndHandler(animEndHandler))); + + clock.uninstall(); + t.end(); +}); + +test('change handler debounces changes within the animation end delay period', t => { + 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}); + t.true(animEndHandler instanceof Function, 'animationend handler registeration sanity test'); + // Queue up initial timer + animEndHandler(); + + const {ANIM_UNCHECKED_CHECKED, ANIM_CHECKED_INDETERMINATE} = cssClasses; + + change({checked: true, indeterminate: true}); + // Without ticking the clock, check that the prior class has been removed. + t.doesNotThrow(() => td.verify(mockAdapter.removeClass(ANIM_UNCHECKED_CHECKED))); + // The animation end handler should not yet have been removed. + t.doesNotThrow(() => 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); + t.doesNotThrow(() => td.verify(mockAdapter.removeClass(ANIM_CHECKED_INDETERMINATE), {times: 1})); + t.doesNotThrow(() => td.verify(mockAdapter.deregisterAnimationEndHandler(animEndHandler), {times: 1})); + + clock.uninstall(); + t.end(); +}); + +test('change handler triggers layout for changes within the same frame to correctly restart anims', t => { + const {mockAdapter, change} = setupChangeHandlerTest(); + + change({checked: true, indeterminate: false}); + t.doesNotThrow(() => td.verify(mockAdapter.forceLayout(), {times: 0})); + + change({checked: true, indeterminate: true}); + t.doesNotThrow(() => td.verify(mockAdapter.forceLayout())); + + t.end(); +}); + +test('change handler does not add animation classes when isAttachedToDOM() is falsy', t => { + const {mockAdapter, change} = setupChangeHandlerTest(); + td.when(mockAdapter.isAttachedToDOM()).thenReturn(false); + + change({checked: true, indeterminate: false}); + t.doesNotThrow(() => td.verify(mockAdapter.addClass(td.matchers.anything()), {times: 0})); + + t.end(); +}); + +test('change handler does not add animation classes for bogus changes (init -> unchecked)', t => { + const {mockAdapter, change} = setupChangeHandlerTest(); + + change({checked: false, indeterminate: false}); + t.doesNotThrow(() => td.verify(mockAdapter.addClass(td.matchers.anything()), {times: 0})); + t.end(); +}); + +test('change handler gracefully exits when getNativeControl() returns nothing', t => { + const {change} = setupChangeHandlerTest(); + t.doesNotThrow(() => change(undefined)); + t.end(); +}); + +test('"checked" property change hook works correctly', t => { + const {foundation, mockAdapter, nativeControl} = setupTest(); + const clock = lolex.install(); + td.when(mockAdapter.isAttachedToDOM()).thenReturn(true); + + withMockCheckboxDescriptorReturning({ + get: () => {}, + set: () => {}, + enumerable: false, + configurable: true + }, () => { + foundation.init(); + td.when(mockAdapter.getNativeControl()).thenReturn({ + checked: true, + indeterminate: false + }); + nativeControl.checked = !nativeControl.checked; + t.doesNotThrow(() => { + td.verify(mockAdapter.addClass(cssClasses.ANIM_UNCHECKED_CHECKED)); + }); + }); + + clock.uninstall(); + t.end(); +}); + +test('"indeterminate" property change hook works correctly', t => { + const {foundation, mockAdapter, nativeControl} = setupTest(); + const clock = lolex.install(); + td.when(mockAdapter.isAttachedToDOM()).thenReturn(true); + + withMockCheckboxDescriptorReturning({ + get: () => {}, + set: () => {}, + enumerable: false, + configurable: true + }, () => { + foundation.init(); + td.when(mockAdapter.getNativeControl()).thenReturn({ + checked: false, + indeterminate: true + }); + nativeControl.indeterminate = !nativeControl.indeterminate; + t.doesNotThrow(() => { + td.verify(mockAdapter.addClass(cssClasses.ANIM_UNCHECKED_INDETERMINATE)); + }); + }); + + clock.uninstall(); + t.end(); +}); diff --git a/test/unit/mdl-checkbox/mdl-checkbox.test.js b/test/unit/mdl-checkbox/mdl-checkbox.test.js new file mode 100644 index 0000000000..4a12594f6a --- /dev/null +++ b/test/unit/mdl-checkbox/mdl-checkbox.test.js @@ -0,0 +1,202 @@ +/** + * Copyright 2016 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import test from 'tape'; +import bel from 'bel'; +import {compare} from 'dom-compare'; +import domEvents from 'dom-events'; +import td from 'testdouble'; + +import MDLCheckbox from '../../../packages/mdl-checkbox'; +import {strings} from '../../../packages/mdl-checkbox/constants'; + +function getFixture() { + return bel` +
+ +
+
+ + + +
+
+
+ `; +} + +function setupTest() { + const root = getFixture(); + const component = new MDLCheckbox(root); + return {root, component}; +} + +test('buildDom returns a built checkbox element using the supplied id + label id', t => { + const expected = getFixture(); + const actual = MDLCheckbox.buildDom({id: 'my-checkbox', labelId: 'my-checkbox-label'}); + // NOTE: bel chokes on xmlns. See https://github.com/shama/bel/issues/21 + const svg = actual.querySelector('svg'); + t.equal(svg.getAttribute('xmlns'), 'http://www.w3.org/2000/svg'); + svg.removeAttribute('xmlns'); + + const comparison = compare(expected, actual); + const diffs = comparison.getDifferences(); + + if (diffs.length) { + const diffMsgs = diffs.map(({node, message}) => `\t* ${node} - ${message}`).join('\n'); + t.fail(`Improper DOM Object. Diff failed:\n${diffMsgs}\n`); + } else { + t.pass(); + } + + t.end(); +}); + +test('buildDom attaches a generated id / label-id when none supplied', t => { + const dom = MDLCheckbox.buildDom(); + const id = dom.querySelector('.mdl-checkbox__native-control').id; + const labelId = dom.querySelector('.mdl-checkbox__native-control').getAttribute('aria-labelledby'); + t.true(/^mdl\-checkbox-\d$/.test(id), 'id matches "mdl-checkbox-"'); + t.equal(labelId, `mdl-checkbox-label-${id}`, 'labelId matches "mdl-checkbox-label-"'); + t.end(); +}); + +test('buildDom ensures generated ids are unique', t => { + const dom1 = MDLCheckbox.buildDom(); + const dom2 = MDLCheckbox.buildDom(); + const id1 = dom1.querySelector('.mdl-checkbox__native-control').id; + const id2 = dom2.querySelector('.mdl-checkbox__native-control').id; + t.notEqual(id1, id2); + t.end(); +}); + +test('buildDom ensures labelId matches supplied id when only id given', t => { + const dom = MDLCheckbox.buildDom({id: 'foo'}); + const labelId = dom.querySelector('.mdl-checkbox__native-control').getAttribute('aria-labelledby'); + t.equal(labelId, 'mdl-checkbox-label-foo', 'labelId matches "mdl-checkbox-label-"'); + t.end(); +}); + +test('attachTo initializes and returns a MDLCheckbox instance', t => { + t.true(MDLCheckbox.attachTo(getFixture()) instanceof MDLCheckbox); + t.end(); +}); + +test('foundationAdapter#addClass adds a class to the root element', t => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.addClass('foo'); + t.true(root.classList.contains('foo')); + t.end(); +}); + +test('adapter#removeClass removes a class from the root element', t => { + const {root, component} = setupTest(); + root.classList.add('foo'); + component.getDefaultFoundation().adapter_.removeClass('foo'); + t.false(root.classList.contains('foo')); + t.end(); +}); + +test('adapter#registerAnimationEndHandler adds an animation end event listener on the root element', t => { + const {root, component} = setupTest(); + const handler = td.func('animationEndHandler'); + component.getDefaultFoundation().adapter_.registerAnimationEndHandler(handler); + domEvents.emit(root, strings.ANIM_END_EVENT_NAME); + + t.doesNotThrow(() => td.verify(handler(td.matchers.anything()))); + t.end(); +}); + +test('adapter#deregisterAnimationEndHandler removes an animation end event listener on the root el', t => { + const {root, component} = setupTest(); + const handler = td.func('animationEndHandler'); + root.addEventListener(strings.ANIM_END_EVENT_NAME, handler); + + component.getDefaultFoundation().adapter_.deregisterAnimationEndHandler(handler); + domEvents.emit(root, strings.ANIM_END_EVENT_NAME); + + t.doesNotThrow(() => td.verify(handler(td.matchers.anything()), {times: 0})); + t.end(); +}); + +test('adapter#registerChangeHandler adds a change event listener to the native checkbox element', t => { + 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'); + + t.doesNotThrow(() => td.verify(handler(td.matchers.anything()))); + t.end(); +}); + +test('adapter#deregisterChangeHandler adds a change event listener to the native checkbox element', t => { + 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'); + + t.doesNotThrow(() => td.verify(handler(td.matchers.anything()), {times: 0})); + t.end(); +}); + +test('adapter#getNativeControl returns the native checkbox element', t => { + const {root, component} = setupTest(); + const nativeCb = root.querySelector(strings.NATIVE_CONTROL_SELECTOR); + t.equal(component.getDefaultFoundation().adapter_.getNativeControl(), nativeCb); + t.end(); +}); + +test('adapter#forceLayout touches "offsetWidth" on the root in order to force layout', t => { + const {root, component} = setupTest(); + const mockGetter = td.func('.offsetWidth'); + Object.defineProperty(root, 'offsetWidth', { + get: mockGetter, + set() {}, + enumerable: false, + configurable: true + }); + + component.getDefaultFoundation().adapter_.forceLayout(); + t.doesNotThrow(() => td.verify(mockGetter())); + t.end(); +}); + +test('adapter#isAttachedToDOM returns true when root is attached to DOM', t => { + const {root, component} = setupTest(); + document.body.appendChild(root); + t.true(component.getDefaultFoundation().adapter_.isAttachedToDOM()); + document.body.removeChild(root); + t.end(); +}); + +test('adapter#isAttachedToDOM returns false when root is not attached to DOM', t => { + const {component} = setupTest(); + t.false(component.getDefaultFoundation().adapter_.isAttachedToDOM()); + t.end(); +});