diff --git a/demos/checkbox.html b/demos/checkbox.html index beda84f97bb..581c2a9d973 100644 --- a/demos/checkbox.html +++ b/demos/checkbox.html @@ -141,7 +141,16 @@

Dark Theme

(function(global) { 'use strict'; var MDCCheckbox = global.mdc.checkbox.MDCCheckbox; - var checkbox = new MDCCheckbox(document.getElementById('mdc-js-checkbox')); + var MDCFormField = global.mdc.formField.MDCFormField; + + var checkbox = document.getElementById('mdc-js-checkbox'); + var checkboxInstance = new MDCCheckbox(document.getElementById('mdc-js-checkbox')); + + var formField = checkbox.parentElement; + var formFieldInstance = new MDCFormField(formField); + + formFieldInstance.input = checkboxInstance; + document.getElementById('make-ind').addEventListener('click', function() { checkbox.indeterminate = true; }); diff --git a/demos/radio.html b/demos/radio.html index 4434e167ad1..e8d26293bbe 100644 --- a/demos/radio.html +++ b/demos/radio.html @@ -172,9 +172,18 @@

Disabled

diff --git a/packages/material-components-web/index.js b/packages/material-components-web/index.js index 7afcc75dcd2..883f1c20042 100644 --- a/packages/material-components-web/index.js +++ b/packages/material-components-web/index.js @@ -16,6 +16,7 @@ import * as base from '@material/base'; import * as checkbox from '@material/checkbox'; +import * as formField from '@material/form-field'; import * as iconToggle from '@material/icon-toggle'; import * as radio from '@material/radio'; import * as ripple from '@material/ripple'; @@ -41,6 +42,7 @@ autoInit.register('MDCSelect', select.MDCSelect); export { base, checkbox, + formField, iconToggle, radio, ripple, diff --git a/packages/mdc-checkbox/index.js b/packages/mdc-checkbox/index.js index 5aae426d8e4..b62e3b09c4f 100644 --- a/packages/mdc-checkbox/index.js +++ b/packages/mdc-checkbox/index.js @@ -78,6 +78,10 @@ export class MDCCheckbox extends MDCComponent { }); } + get ripple() { + return this.ripple_; + } + get checked() { return this.foundation_.isChecked(); } diff --git a/packages/mdc-form-field/README.md b/packages/mdc-form-field/README.md index 19082664559..584ed270cfa 100644 --- a/packages/mdc-form-field/README.md +++ b/packages/mdc-form-field/README.md @@ -1,7 +1,8 @@ # MDC Form Field MDC Form Field provides an `mdc-form-field` helper class for easily making theme-aware, RTL-aware -form field + label combos. +form field + label combos. It also provides an `MDCFormField` class for easily making input ripples +respond to label events. ## Installation @@ -9,7 +10,7 @@ form field + label combos. npm install --save @material/form-field ``` -## Usage +## CSS Usage The `mdc-form-field` class can be used as a wrapper element with any `input` + `label` combo: @@ -71,3 +72,81 @@ checkbox: `mdc-form-field` is dark theme aware, and will change the text color to the "primary on dark" text color when used within a dark theme. + + +## JS Usage + +### Including in code + +#### ES2015 + +```javascript +import {MDCFormField, MDCFormFieldFoundation} from 'mdc-form-field'; +``` + +#### CommonJS + +```javascript +const mdcFormField = require('mdc-form-field'); +const MDCFormField = mdcFormField.MDCFormField; +const MDCFormFieldFoundation = mdcFormField.MDCFormFieldFoundation; +``` + +#### AMD + +```javascript +require(['path/to/mdc-form-field'], mdcFormField => { + const MDCFormField = mdcFormField.MDCFormField; + const MDCFormFieldFoundation = mdcFormField.MDCFormFieldFoundation; +}); +``` + +#### Global + +```javascript +const MDCFormField = mdc.radio.MDCFormField; +const MDCFormFieldFoundation = mdc.radio.MDCFormFieldFoundation; +``` + +### Instantiation + +```javascript +import {MDCFormField} from 'mdc-form-field'; + +const formField = new MDCFormField(document.querySelector('.mdc-form-field')); +``` + +### MDCFormField API + +The `MDCFormField` functionality is exposed through a single accessor method. + +#### MDCFormField.input + +Read-write property that works with an instance of an MDC-Web input element. + +In order for the label ripple integration to work correctly, this property needs to be set to a +valid instance of an MDC-Web input element which exposes a `ripple` getter. + +```javascript +const formField = new MDCFormField(document.querySelector('.mdc-form-field')); +const radio = new MDCRadio(document.querySelector('.mdc-radio')); + +formField.input = radio; +``` + +No action is taken if the `input` property is not set or the input instance doesn't expose a +`ripple` getter. + + +### Adapter + +The adapter for `MDCFormField` is extremely simple, providing only methods for adding and +removing event listeners from the label, as well as methods for activating and deactivating +the input ripple. + +| Method Signature | Description | +| --- | --- | +| `registerInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type` to the label. | +| `deregisterInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type` to the label. | +| `activateInputRipple() => void` | Activates the ripple on the input element. Should call `activate` on the input element's `ripple` property. | +| `deactivateInputRipple() => void` | Deactivates the ripple on the input element. Should call `deactivate` on the input element's `ripple` property. | diff --git a/packages/mdc-form-field/constants.js b/packages/mdc-form-field/constants.js new file mode 100644 index 00000000000..5bb9ccdc014 --- /dev/null +++ b/packages/mdc-form-field/constants.js @@ -0,0 +1,23 @@ +/** + * Copyright 2017 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. + */ + +export const cssClasses = { + ROOT: 'mdc-form-field', +}; + +export const strings = { + LABEL_SELECTOR: '.mdc-form-field > label', +}; diff --git a/packages/mdc-form-field/foundation.js b/packages/mdc-form-field/foundation.js new file mode 100644 index 00000000000..ad6d157647c --- /dev/null +++ b/packages/mdc-form-field/foundation.js @@ -0,0 +1,55 @@ +/** + * Copyright 2017 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 {MDCFoundation} from '@material/base'; +import {cssClasses, strings} from './constants'; + +export default class MDCFormFieldFoundation extends MDCFoundation { + static get cssClasses() { + return cssClasses; + } + + static get strings() { + return strings; + } + + static get defaultAdapter() { + return { + registerInteractionHandler: (/* type: string, handler: EventListener */) => {}, + deregisterInteractionHandler: (/* type: string, handler: EventListener */) => {}, + activateInputRipple: () => {}, + deactivateInputRipple: () => {}, + }; + } + + constructor(adapter) { + super(Object.assign(MDCFormFieldFoundation.defaultAdapter, adapter)); + this.clickHandler_ = (evt) => this.handleClick_(evt); + } + + init() { + this.adapter_.registerInteractionHandler('click', this.clickHandler_); + } + + destroy() { + this.adapter_.deregisterInteractionHandler('click', this.clickHandler_); + } + + handleClick_(evt) { + this.adapter_.activateInputRipple(); + requestAnimationFrame(() => this.adapter_.deactivateInputRipple()); + } +} diff --git a/packages/mdc-form-field/index.js b/packages/mdc-form-field/index.js new file mode 100644 index 00000000000..c0f1c69a78c --- /dev/null +++ b/packages/mdc-form-field/index.js @@ -0,0 +1,55 @@ +/** + * Copyright 2017 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 {MDCComponent} from '@material/base'; +import MDCFormFieldFoundation from './foundation'; + +export {MDCFormFieldFoundation}; + +export class MDCFormField extends MDCComponent { + static attachTo(root) { + return new MDCFormField(root); + } + + set input(input) { + this.input_ = input; + } + + get input() { + return this.input_; + } + + get label_() { + return this.root_.querySelector(MDCFormFieldFoundation.strings.LABEL_SELECTOR); + } + + getDefaultFoundation() { + return new MDCFormFieldFoundation({ + registerInteractionHandler: (type, handler) => this.label_.addEventListener(type, handler), + deregisterInteractionHandler: (type, handler) => this.label_.removeEventListener(type, handler), + activateInputRipple: () => { + if (this.input_ && this.input_.ripple) { + this.input_.ripple.activate(); + } + }, + deactivateInputRipple: () => { + if (this.input_ && this.input_.ripple) { + this.input_.ripple.deactivate(); + } + }, + }); + } +} diff --git a/packages/mdc-form-field/package.json b/packages/mdc-form-field/package.json index 36edd8e052a..6965bc9aeb9 100644 --- a/packages/mdc-form-field/package.json +++ b/packages/mdc-form-field/package.json @@ -1,6 +1,6 @@ { "name": "@material/form-field", - "description": "Material Components for the web wrapper styles for laying out form fields and labels next to one another", + "description": "Material Components for the web wrapper for laying out form fields and labels next to one another", "version": "0.1.1", "license": "Apache-2.0", "keywords": [ @@ -8,11 +8,13 @@ "material design", "form" ], + "main": "index.js", "repository": { "type": "git", "url": "https://github.com/material-components/material-components-web.git" }, "dependencies": { + "@material/base": "^0.1.1", "@material/rtl": "^0.1.1", "@material/theme": "^0.1.1", "@material/typography": "^0.1.1" diff --git a/packages/mdc-radio/index.js b/packages/mdc-radio/index.js index 0b5ebb969fc..7315ddab86f 100644 --- a/packages/mdc-radio/index.js +++ b/packages/mdc-radio/index.js @@ -42,6 +42,10 @@ export class MDCRadio extends MDCComponent { this.foundation_.setDisabled(disabled); } + get ripple() { + return this.ripple_; + } + constructor(...args) { super(...args); this.ripple_ = this.initRipple_(); diff --git a/test/unit/mdc-form-field/foundation.test.js b/test/unit/mdc-form-field/foundation.test.js new file mode 100644 index 00000000000..627e7e3f288 --- /dev/null +++ b/test/unit/mdc-form-field/foundation.test.js @@ -0,0 +1,64 @@ +/** + * Copyright 2017 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 td from 'testdouble'; + +import MDCFormFieldFoundation from '../../../packages/mdc-form-field/foundation'; +import {verifyDefaultAdapter} from '../helpers/foundation'; + +test('exports cssClasses', (t) => { + t.true('cssClasses' in MDCFormFieldFoundation); + t.end(); +}); + +test('exports strings', (t) => { + t.true('strings' in MDCFormFieldFoundation); + t.end(); +}); + +test('defaultAdapter returns a complete adapter implementation', (t) => { + verifyDefaultAdapter(MDCFormFieldFoundation, [ + 'registerInteractionHandler', 'deregisterInteractionHandler', 'activateInputRipple', 'deactivateInputRipple', + ], t); + + t.end(); +}); + +function setupTest() { + const mockAdapter = td.object(MDCFormFieldFoundation.defaultAdapter); + const foundation = new MDCFormFieldFoundation(mockAdapter); + return {foundation, mockAdapter}; +} + +test('#init calls event registrations', (t) => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.init(); + t.doesNotThrow(() => td.verify(mockAdapter.registerInteractionHandler('click', isA(Function)))); + t.end(); +}); + +test('#destroy calls event deregistrations', (t) => { + const {foundation, mockAdapter} = setupTest(); + const {isA} = td.matchers; + + foundation.init(); + foundation.destroy(); + t.doesNotThrow(() => td.verify(mockAdapter.deregisterInteractionHandler('click', isA(Function)))); + t.end(); +}); diff --git a/test/unit/mdc-form-field/mdc-form-field.test.js b/test/unit/mdc-form-field/mdc-form-field.test.js new file mode 100644 index 00000000000..abc8ea784b2 --- /dev/null +++ b/test/unit/mdc-form-field/mdc-form-field.test.js @@ -0,0 +1,135 @@ +/** + * Copyright 2017 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 domEvents from 'dom-events'; +import td from 'testdouble'; + +import {MDCFormField} from '../../../packages/mdc-form-field'; + +function getFixture() { + return bel` +
+ + +
+ `; +} + +function setupTest() { + const root = getFixture(); + const component = new MDCFormField(root); + return {root, component}; +} + +test('attachTo initializes and returns an MDCFormField instance', (t) => { + t.true(MDCFormField.attachTo(getFixture()) instanceof MDCFormField); + t.end(); +}); + +test('get/set input', (t) => { + const {component} = setupTest(); + const input = {}; + + component.input = input; + + t.true(component.input == input); + t.end(); +}); + +test('adapter#registerInteractionHandler adds an event listener to the label element', (t) => { + const {root, component} = setupTest(); + const handler = td.func('eventHandler'); + const label = root.querySelector('label'); + + component.getDefaultFoundation().adapter_.registerInteractionHandler('click', handler); + domEvents.emit(label, 'click'); + + t.doesNotThrow(() => td.verify(handler(td.matchers.anything()))); + t.end(); +}); + +test('adapter#deregisterInteractionHandler removes an event listener from the root element', (t) => { + const {root, component} = setupTest(); + const handler = td.func('eventHandler'); + const label = root.querySelector('label'); + label.addEventListener('click', handler); + + component.getDefaultFoundation().adapter_.deregisterInteractionHandler('click', handler); + domEvents.emit(label, 'click'); + + t.doesNotThrow(() => td.verify(handler(td.matchers.anything()), {times: 0})); + t.end(); +}); + +test('adapter#activateInputRipple calls activate on the input ripple', (t) => { + const {component} = setupTest(); + const ripple = td.object(); + const input = {ripple: ripple}; + + component.input = input; + component.getDefaultFoundation().adapter_.activateInputRipple(); + + t.doesNotThrow(() => td.verify(ripple.activate())); + t.end(); +}); + +test('adapter#activateInputRipple does not throw if there is no input', (t) => { + const {component} = setupTest(); + + t.doesNotThrow(() => component.getDefaultFoundation().adapter_.activateInputRipple()); + t.end(); +}); + +test('adapter#activateInputRipple does not throw if the input has no ripple getter', (t) => { + const {component} = setupTest(); + const input = {}; + + component.input = input; + + t.doesNotThrow(() => component.getDefaultFoundation().adapter_.activateInputRipple()); + t.end(); +}); + +test('adapter#deactivateInputRipple calls deactivate on the input ripple', (t) => { + const {component} = setupTest(); + const ripple = td.object(); + const input = {ripple: ripple}; + + component.input = input; + component.getDefaultFoundation().adapter_.deactivateInputRipple(); + + t.doesNotThrow(() => td.verify(ripple.deactivate())); + t.end(); +}); + +test('adapter#deactivateInputRipple does not throw if there is no input', (t) => { + const {component} = setupTest(); + + t.doesNotThrow(() => component.getDefaultFoundation().adapter_.deactivateInputRipple()); + t.end(); +}); + +test('adapter#deactivateInputRipple does not throw if the input has no ripple getter', (t) => { + const {component} = setupTest(); + const input = {}; + + component.input = input; + + t.doesNotThrow(() => component.getDefaultFoundation().adapter_.deactivateInputRipple()); + t.end(); +}); diff --git a/webpack.config.js b/webpack.config.js index 93936bae9ce..52c15b10c36 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -76,6 +76,7 @@ module.exports = [{ base: [path.resolve('./packages/mdc-base/index.js')], checkbox: [path.resolve('./packages/mdc-checkbox/index.js')], drawer: [path.resolve('./packages/mdc-drawer/index.js')], + formField: [path.resolve('./packages/mdc-form-field/index.js')], iconToggle: [path.resolve('./packages/mdc-icon-toggle/index.js')], menu: [path.resolve('./packages/mdc-menu/index.js')], radio: [path.resolve('./packages/mdc-radio/index.js')],