From 4e7fa3e664593d6cd8b003900efc19e43385fe10 Mon Sep 17 00:00:00 2001 From: Bonnie Zhou Date: Thu, 14 Dec 2017 09:59:02 -0800 Subject: [PATCH] chore(text-field): Split out icon into subelement (#1697) BREAKING CHANGE: Remove `setIconAttr`, `eventTargetHasClass` and `notifyIconAction` from `MDCTextFieldAdapter` implementations. --- packages/mdc-textfield/README.md | 36 +---- packages/mdc-textfield/adapter.js | 22 +-- packages/mdc-textfield/constants.js | 3 - packages/mdc-textfield/foundation.js | 28 ++-- packages/mdc-textfield/icon/README.md | 77 +++++++++++ packages/mdc-textfield/icon/adapter.js | 58 ++++++++ packages/mdc-textfield/icon/constants.js | 23 ++++ packages/mdc-textfield/icon/foundation.js | 92 +++++++++++++ packages/mdc-textfield/icon/index.js | 57 ++++++++ .../icon/mdc-text-field-icon.scss | 35 +++++ packages/mdc-textfield/index.js | 63 +++++---- packages/mdc-textfield/mdc-text-field.scss | 18 +-- test/unit/mdc-textfield/foundation.test.js | 72 ++-------- .../mdc-text-field-icon-foundation.test.js | 82 +++++++++++ .../mdc-textfield/mdc-text-field-icon.test.js | 74 ++++++++++ .../unit/mdc-textfield/mdc-text-field.test.js | 127 ++++++++++++------ 16 files changed, 647 insertions(+), 220 deletions(-) create mode 100644 packages/mdc-textfield/icon/README.md create mode 100644 packages/mdc-textfield/icon/adapter.js create mode 100644 packages/mdc-textfield/icon/constants.js create mode 100644 packages/mdc-textfield/icon/foundation.js create mode 100644 packages/mdc-textfield/icon/index.js create mode 100644 packages/mdc-textfield/icon/mdc-text-field-icon.scss create mode 100644 test/unit/mdc-textfield/mdc-text-field-icon-foundation.test.js create mode 100644 test/unit/mdc-textfield/mdc-text-field-icon.test.js diff --git a/packages/mdc-textfield/README.md b/packages/mdc-textfield/README.md index 82f9b903d0c..2be1b7612e0 100644 --- a/packages/mdc-textfield/README.md +++ b/packages/mdc-textfield/README.md @@ -117,36 +117,16 @@ By default an input's validity is checked via `checkValidity()` on blur, and the accordingly. Set the MDCTextField.valid field to set the input's validity explicitly. MDC Text Field automatically appends an asterisk to the label text if the required attribute is set. -### Leading and Trailing Icons -Leading and trailing icons can be added to MDC Text Fields as visual indicators -as well as interaction targets. To do so, add the relevant classes -(`mdc-text-field--with-leading-icon` or `mdc-text-field--with-trailing-icon`) to the root element, add -an `i` element with your preferred icon, and give it a class of `mdc-text-field__icon`. +### Using Helper Text -#### Leading: -```html -
- event - - -
-
-``` +The helper text provides supplemental information and/or validation messages to users. It appears on input field focus +and disappears on input field blur by default, or it can be persistent. +See [here](helper-text/) for more information on using helper text. -#### Trailing: -```html -
- - - event -
-
-``` - ->**NOTE:** if you would like to display un-clickable icons, simply remove `tabindex="0"`, -and the css will ensure the cursor is set to default, and that actioning on an icon doesn't -do anything unexpected. +### Leading and Trailing Icons +Leading and trailing icons can be added to MDC Text Fields as visual indicators as well as interaction targets. +See [here](icon/) for more information on using icons. ### Textarea @@ -325,10 +305,8 @@ complicated. | --- | --- | | addClass(className: string) => void | Adds a class to the root element | | removeClass(className: string) => void | Removes a class from the root element | -| eventTargetHasClass(target: HTMLElement, className: string) => boolean | Returns true if classname exists for a given target element | | registerTextFieldInteractionHandler(evtType: string, handler: EventListener) => void | Registers an event handler on the root element for a given event | | deregisterTextFieldInteractionHandler(evtType: string, handler: EventListener) => void | Deregisters an event handler on the root element for a given event | -| notifyIconAction() => void | Emits a custom event "MDCTextField:icon" denoting a user has clicked the icon | | registerInputInteractionHandler(evtType: string, handler: EventListener) => void | Registers an event listener on the native input element for a given event | | deregisterInputInteractionHandler(evtType: string, handler: EventListener) => void | Deregisters an event listener on the native input element for a given event | | registerBottomLineEventHandler(evtType: string, handler: EventListener) => void | Registers an event listener on the bottom line element for a given event | diff --git a/packages/mdc-textfield/adapter.js b/packages/mdc-textfield/adapter.js index 2d1c0c72463..306d8902ef3 100644 --- a/packages/mdc-textfield/adapter.js +++ b/packages/mdc-textfield/adapter.js @@ -18,6 +18,7 @@ /* eslint-disable no-unused-vars */ import MDCTextFieldBottomLineFoundation from './bottom-line/foundation'; import MDCTextFieldHelperTextFoundation from './helper-text/foundation'; +import MDCTextFieldIconFoundation from './icon/foundation'; import MDCTextFieldLabelFoundation from './label/foundation'; /* eslint no-unused-vars: [2, {"args": "none"}] */ @@ -36,6 +37,7 @@ let NativeInputType; * @typedef {{ * bottomLine: (!MDCTextFieldBottomLineFoundation|undefined), * helperText: (!MDCTextFieldHelperTextFoundation|undefined), + * icon: (!MDCTextFieldIconFoundation|undefined), * label: (!MDCTextFieldLabelFoundation|undefined) * }} */ @@ -64,21 +66,6 @@ class MDCTextFieldAdapter { */ removeClass(className) {} - /** - * Sets an attribute on the icon Element. - * @param {string} name - * @param {string} value - */ - setIconAttr(name, value) {} - - /** - * Returns true if classname exists for a given target element. - * @param {?EventTarget} target - * @param {string} className - * @return {boolean} - */ - eventTargetHasClass(target, className) {} - /** * Registers an event handler on the root element for a given event. * @param {string} type @@ -93,11 +80,6 @@ class MDCTextFieldAdapter { */ deregisterTextFieldInteractionHandler(type, handler) {} - /** - * Emits a custom event "MDCTextField:icon" denoting a user has clicked the icon. - */ - notifyIconAction() {} - /** * Registers an event listener on the native input element for a given event. * @param {string} evtType diff --git a/packages/mdc-textfield/constants.js b/packages/mdc-textfield/constants.js index 33a3cc4d4e9..001770187a0 100644 --- a/packages/mdc-textfield/constants.js +++ b/packages/mdc-textfield/constants.js @@ -21,7 +21,6 @@ const strings = { INPUT_SELECTOR: '.mdc-text-field__input', LABEL_SELECTOR: '.mdc-text-field__label', ICON_SELECTOR: '.mdc-text-field__icon', - ICON_EVENT: 'MDCTextField:icon', BOTTOM_LINE_SELECTOR: '.mdc-text-field__bottom-line', }; @@ -33,8 +32,6 @@ const cssClasses = { FOCUSED: 'mdc-text-field--focused', INVALID: 'mdc-text-field--invalid', BOX: 'mdc-text-field--box', - TEXT_FIELD_ICON: 'mdc-text-field__icon', - TEXTAREA: 'mdc-text-field--textarea', }; export {cssClasses, strings}; diff --git a/packages/mdc-textfield/foundation.js b/packages/mdc-textfield/foundation.js index 5e785fe457e..435b7576b3a 100644 --- a/packages/mdc-textfield/foundation.js +++ b/packages/mdc-textfield/foundation.js @@ -20,6 +20,7 @@ import {MDCTextFieldAdapter, NativeInputType, FoundationMapType} from './adapter import MDCTextFieldBottomLineFoundation from './bottom-line/foundation'; /* eslint-disable no-unused-vars */ import MDCTextFieldHelperTextFoundation from './helper-text/foundation'; +import MDCTextFieldIconFoundation from './icon/foundation'; import MDCTextFieldLabelFoundation from './label/foundation'; /* eslint-enable no-unused-vars */ import {cssClasses, strings} from './constants'; @@ -49,11 +50,8 @@ class MDCTextFieldFoundation extends MDCFoundation { return /** @type {!MDCTextFieldAdapter} */ ({ addClass: () => {}, removeClass: () => {}, - setIconAttr: () => {}, - eventTargetHasClass: () => {}, registerTextFieldInteractionHandler: () => {}, deregisterTextFieldInteractionHandler: () => {}, - notifyIconAction: () => {}, registerInputInteractionHandler: () => {}, deregisterInputInteractionHandler: () => {}, registerBottomLineEventHandler: () => {}, @@ -74,6 +72,8 @@ class MDCTextFieldFoundation extends MDCFoundation { this.bottomLine_ = foundationMap.bottomLine; /** @type {!MDCTextFieldHelperTextFoundation|undefined} */ this.helperText_ = foundationMap.helperText; + /** @type {!MDCTextFieldIconFoundation|undefined} */ + this.icon_ = foundationMap.icon; /** @type {!MDCTextFieldLabelFoundation|undefined} */ this.label_ = foundationMap.label; @@ -92,7 +92,7 @@ class MDCTextFieldFoundation extends MDCFoundation { /** @private {function(!Event): undefined} */ this.setPointerXOffset_ = (evt) => this.setBottomLineTransformOrigin(evt); /** @private {function(!Event): undefined} */ - this.textFieldInteractionHandler_ = (evt) => this.handleTextFieldInteraction(evt); + this.textFieldInteractionHandler_ = () => this.handleTextFieldInteraction(); /** @private {function(!Event): undefined} */ this.bottomLineAnimationEndHandler_ = () => this.handleBottomLineAnimationEnd(); } @@ -133,24 +133,13 @@ class MDCTextFieldFoundation extends MDCFoundation { } /** - * Handles all user interactions with the Text Field. - * @param {!Event} evt + * Handles user interactions with the Text Field. */ - handleTextFieldInteraction(evt) { + handleTextFieldInteraction() { if (this.adapter_.getNativeInput().disabled) { return; } - this.receivedUserInput_ = true; - - const {target, type} = evt; - const {TEXT_FIELD_ICON} = MDCTextFieldFoundation.cssClasses; - const targetIsIcon = this.adapter_.eventTargetHasClass(target, TEXT_FIELD_ICON); - const eventTriggersNotification = type === 'click' || evt.key === 'Enter' || evt.keyCode === 13; - - if (targetIsIcon && eventTriggersNotification) { - this.adapter_.notifyIconAction(); - } } /** @@ -272,10 +261,11 @@ class MDCTextFieldFoundation extends MDCFoundation { if (disabled) { this.adapter_.addClass(DISABLED); this.adapter_.removeClass(INVALID); - this.adapter_.setIconAttr('tabindex', '-1'); } else { this.adapter_.removeClass(DISABLED); - this.adapter_.setIconAttr('tabindex', '0'); + } + if (this.icon_) { + this.icon_.setDisabled(disabled); } } diff --git a/packages/mdc-textfield/icon/README.md b/packages/mdc-textfield/icon/README.md new file mode 100644 index 00000000000..7798196a0e5 --- /dev/null +++ b/packages/mdc-textfield/icon/README.md @@ -0,0 +1,77 @@ + + +# Text Field Icon + +Icons describe the type of input a text field requires. They can also be interaction targets. + +## Design & API Documentation + + + +## Usage + +### Leading and Trailing Icons +Leading and trailing icons can be added to MDC Text Fields as visual indicators +as well as interaction targets. To do so, add the relevant classes +(`mdc-text-field--with-leading-icon` or `mdc-text-field--with-trailing-icon`) to the root element, add +an `i` element with your preferred icon, and give it a class of `mdc-text-field__icon`. + +#### Leading: +```html +
+ event + + +
+
+``` + +#### Trailing: +```html +
+ + + event +
+
+``` + +>**NOTE:** if you would like to display un-clickable icons, simply remove `tabindex="0"`, +and the css will ensure the cursor is set to default, and that actioning on an icon doesn't +do anything unexpected. + +#### MDCTextFieldIcon API + +##### MDCTextFieldIcon.foundation + +MDCTextFieldIconFoundation. This allows the parent MDCTextField component to access the public methods on the MDCTextFieldIconFoundation class. + +### Using the foundation class + +Method Signature | Description +--- | --- +setAttr(attr: string, value: string) => void | Sets an attribute with a given value on the icon element +registerInteractionHandler(evtType: string, handler: EventListener) => void | Registers an event listener for a given event +deregisterInteractionHandler(evtType: string, handler: EventListener) => void | Deregisters an event listener for a given event +notifyIconAction() => void | Emits a custom event "MDCTextField:icon" denoting a user has clicked the icon, which bubbles to the top-level text field element + +#### The full foundation API + +##### MDCTextFieldIconFoundation.setDisabled(disabled: boolean) + +Updates the icon's disabled state. + +##### MDCTextFieldIconFoundation.handleInteraction(evt: Event) + +Handles a text field interaction event. diff --git a/packages/mdc-textfield/icon/adapter.js b/packages/mdc-textfield/icon/adapter.js new file mode 100644 index 00000000000..2b94abc7db5 --- /dev/null +++ b/packages/mdc-textfield/icon/adapter.js @@ -0,0 +1,58 @@ +/** + * @license + * 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. + */ + +/* eslint no-unused-vars: [2, {"args": "none"}] */ + +/** + * Adapter for MDC Text Field Icon. + * + * Defines the shape of the adapter expected by the foundation. Implement this + * adapter to integrate the text field icon into your framework. See + * https://github.com/material-components/material-components-web/blob/master/docs/authoring-components.md + * for more information. + * + * @record + */ +class MDCTextFieldIconAdapter { + /** + * Sets an attribute on the icon element. + * @param {string} attr + * @param {string} value + */ + setAttr(attr, value) {} + + /** + * Registers an event listener on the icon element for a given event. + * @param {string} evtType + * @param {function(!Event): undefined} handler + */ + registerInteractionHandler(evtType, handler) {} + + /** + * Deregisters an event listener on the icon element for a given event. + * @param {string} evtType + * @param {function(!Event): undefined} handler + */ + deregisterInteractionHandler(evtType, handler) {} + + /** + * Emits a custom event "MDCTextField:icon" denoting a user has clicked the icon. + */ + notifyIconAction() {} +} + +export default MDCTextFieldIconAdapter; diff --git a/packages/mdc-textfield/icon/constants.js b/packages/mdc-textfield/icon/constants.js new file mode 100644 index 00000000000..5c5dd81c1e7 --- /dev/null +++ b/packages/mdc-textfield/icon/constants.js @@ -0,0 +1,23 @@ +/** + * @license + * 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. + */ + +/** @enum {string} */ +const strings = { + ICON_EVENT: 'MDCTextField:icon', +}; + +export {strings}; diff --git a/packages/mdc-textfield/icon/foundation.js b/packages/mdc-textfield/icon/foundation.js new file mode 100644 index 00000000000..5ec0d8e4c4e --- /dev/null +++ b/packages/mdc-textfield/icon/foundation.js @@ -0,0 +1,92 @@ +/** + * @license + * 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/foundation'; +import MDCTextFieldIconAdapter from './adapter'; +import {strings} from './constants'; + + +/** + * @extends {MDCFoundation} + * @final + */ +class MDCTextFieldIconFoundation extends MDCFoundation { + /** @return enum {string} */ + static get strings() { + return strings; + } + + /** + * {@see MDCTextFieldIconAdapter} for typing information on parameters and return + * types. + * @return {!MDCTextFieldIconAdapter} + */ + static get defaultAdapter() { + return /** @type {!MDCTextFieldIconAdapter} */ ({ + setAttr: () => {}, + registerInteractionHandler: () => {}, + deregisterInteractionHandler: () => {}, + notifyIconAction: () => {}, + }); + } + + /** + * @param {!MDCTextFieldIconAdapter=} adapter + */ + constructor(adapter = /** @type {!MDCTextFieldIconAdapter} */ ({})) { + super(Object.assign(MDCTextFieldIconFoundation.defaultAdapter, adapter)); + + /** @private {function(!Event): undefined} */ + this.interactionHandler_ = (evt) => this.handleInteraction(evt); + } + + init() { + ['click', 'keydown'].forEach((evtType) => { + this.adapter_.registerInteractionHandler(evtType, this.interactionHandler_); + }); + } + + destroy() { + ['click', 'keydown'].forEach((evtType) => { + this.adapter_.deregisterInteractionHandler(evtType, this.interactionHandler_); + }); + } + + /** + * Sets the content of the helper text field. + * @param {boolean} disabled + */ + setDisabled(disabled) { + if (disabled) { + this.adapter_.setAttr('tabindex', '-1'); + } else { + this.adapter_.setAttr('tabindex', '0'); + } + } + + /** + * Handles an interaction event + * @param {!Event} evt + */ + handleInteraction(evt) { + if (evt.type === 'click' || evt.key === 'Enter' || evt.keyCode === 13) { + this.adapter_.notifyIconAction(); + } + } +} + +export default MDCTextFieldIconFoundation; diff --git a/packages/mdc-textfield/icon/index.js b/packages/mdc-textfield/icon/index.js new file mode 100644 index 00000000000..3f6f55e745a --- /dev/null +++ b/packages/mdc-textfield/icon/index.js @@ -0,0 +1,57 @@ +/** + * @license + * 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/component'; + +import MDCTextFieldIconAdapter from './adapter'; +import MDCTextFieldIconFoundation from './foundation'; + +/** + * @extends {MDCComponent} + * @final + */ +class MDCTextFieldIcon extends MDCComponent { + /** + * @param {!Element} root + * @return {!MDCTextFieldIcon} + */ + static attachTo(root) { + return new MDCTextFieldIcon(root); + } + + /** + * @return {!MDCTextFieldIconFoundation} + */ + get foundation() { + return this.foundation_; + } + + /** + * @return {!MDCTextFieldIconFoundation} + */ + getDefaultFoundation() { + return new MDCTextFieldIconFoundation(/** @type {!MDCTextFieldIconAdapter} */ (Object.assign({ + setAttr: (attr, value) => this.root_.setAttribute(attr, value), + registerInteractionHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler), + deregisterInteractionHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler), + notifyIconAction: () => this.emit( + MDCTextFieldIconFoundation.strings.ICON_EVENT, {} /* evtData */, true /* shouldBubble */), + }))); + } +} + +export {MDCTextFieldIcon, MDCTextFieldIconFoundation}; diff --git a/packages/mdc-textfield/icon/mdc-text-field-icon.scss b/packages/mdc-textfield/icon/mdc-text-field-icon.scss new file mode 100644 index 00000000000..a78ab12f1a6 --- /dev/null +++ b/packages/mdc-textfield/icon/mdc-text-field-icon.scss @@ -0,0 +1,35 @@ +// +// 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 "@material/theme/variables"; +@import "@material/theme/mixins"; + +.mdc-text-field--with-leading-icon .mdc-text-field__icon, +.mdc-text-field--with-trailing-icon .mdc-text-field__icon { + position: absolute; + bottom: 16px; + cursor: pointer; + + @include mdc-theme-dark(".mdc-text-field") { + @include mdc-theme-prop(color, text-secondary-on-dark); + } +} + +.mdc-text-field__icon:not([tabindex]), +.mdc-text-field__icon[tabindex="-1"] { + cursor: default; + pointer-events: none; +} diff --git a/packages/mdc-textfield/index.js b/packages/mdc-textfield/index.js index 790f26ede32..6f8e847379f 100644 --- a/packages/mdc-textfield/index.js +++ b/packages/mdc-textfield/index.js @@ -26,6 +26,7 @@ import MDCTextFieldFoundation from './foundation'; /* eslint-disable no-unused-vars */ import {MDCTextFieldBottomLine, MDCTextFieldBottomLineFoundation} from './bottom-line'; import {MDCTextFieldHelperText, MDCTextFieldHelperTextFoundation} from './helper-text'; +import {MDCTextFieldIcon, MDCTextFieldIconFoundation} from './icon'; import {MDCTextFieldLabel, MDCTextFieldLabelFoundation} from './label'; /* eslint-enable no-unused-vars */ @@ -49,7 +50,7 @@ class MDCTextField extends MDCComponent { this.bottomLine_; /** @private {?MDCTextFieldHelperText} */ this.helperText_; - /** @private {?Element} */ + /** @private {?MDCTextFieldIcon} */ this.icon_; } @@ -66,14 +67,23 @@ class MDCTextField extends MDCComponent { * creates a new MDCRipple. * @param {(function(!Element): !MDCTextFieldBottomLine)=} bottomLineFactory A function which * creates a new MDCTextFieldBottomLine. + * @param {(function(!Element): !MDCTextFieldHelperText)=} helperTextFactory A function which + * creates a new MDCTextFieldHelperText. + * @param {(function(!Element): !MDCTextFieldIcon)=} iconFactory A function which + * creates a new MDCTextFieldIcon. + * @param {(function(!Element): !MDCTextFieldLabel)=} labelFactory A function which + * creates a new MDCTextFieldLabel. */ initialize( rippleFactory = (el, foundation) => new MDCRipple(el, foundation), - bottomLineFactory = (el) => new MDCTextFieldBottomLine(el)) { + bottomLineFactory = (el) => new MDCTextFieldBottomLine(el), + helperTextFactory = (el) => new MDCTextFieldHelperText(el), + iconFactory = (el) => new MDCTextFieldIcon(el), + labelFactory = (el) => new MDCTextFieldLabel(el)) { this.input_ = this.root_.querySelector(strings.INPUT_SELECTOR); const labelElement = this.root_.querySelector(strings.LABEL_SELECTOR); if (labelElement) { - this.label_ = new MDCTextFieldLabel(labelElement); + this.label_ = labelFactory(labelElement); } this.ripple = null; if (this.root_.classList.contains(cssClasses.BOX)) { @@ -86,21 +96,20 @@ class MDCTextField extends MDCComponent { const foundation = new MDCRippleFoundation(adapter); this.ripple = rippleFactory(this.root_, foundation); }; - if (!this.root_.classList.contains(cssClasses.TEXTAREA)) { - const bottomLineElement = this.root_.querySelector(strings.BOTTOM_LINE_SELECTOR); - if (bottomLineElement) { - this.bottomLine_ = bottomLineFactory(bottomLineElement); - } - }; + const bottomLineElement = this.root_.querySelector(strings.BOTTOM_LINE_SELECTOR); + if (bottomLineElement) { + this.bottomLine_ = bottomLineFactory(bottomLineElement); + } if (this.input_.hasAttribute(strings.ARIA_CONTROLS)) { const helperTextElement = document.getElementById(this.input_.getAttribute(strings.ARIA_CONTROLS)); if (helperTextElement) { - this.helperText_ = new MDCTextFieldHelperText(helperTextElement); + this.helperText_ = helperTextFactory(helperTextElement); } } - if (!this.root_.classList.contains(cssClasses.TEXT_FIELD_ICON)) { - this.icon_ = this.root_.querySelector(strings.ICON_SELECTOR); - }; + const iconElement = this.root_.querySelector(strings.ICON_SELECTOR); + if (iconElement) { + this.icon_ = iconFactory(iconElement); + } } destroy() { @@ -116,6 +125,9 @@ class MDCTextField extends MDCComponent { if (this.label_) { this.label_.destroy(); } + if (this.icon_) { + this.icon_.destroy(); + } super.destroy(); } @@ -164,10 +176,8 @@ class MDCTextField extends MDCComponent { /** @type {!MDCTextFieldAdapter} */ (Object.assign({ addClass: (className) => this.root_.classList.add(className), removeClass: (className) => this.root_.classList.remove(className), - eventTargetHasClass: (target, className) => target.classList.contains(className), registerTextFieldInteractionHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler), deregisterTextFieldInteractionHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler), - notifyIconAction: () => this.emit(MDCTextFieldFoundation.strings.ICON_EVENT, {}), registerBottomLineEventHandler: (evtType, handler) => { if (this.bottomLine_) { this.bottomLine_.listen(evtType, handler); @@ -179,26 +189,10 @@ class MDCTextField extends MDCComponent { } }, }, - this.getInputAdapterMethods_(), - this.getIconAdapterMethods_())), + this.getInputAdapterMethods_())), this.getFoundationMap_()); } - /** - * @return {!{ - * setIconAttr: function(string, string): undefined, - * }} - */ - getIconAdapterMethods_() { - return { - setIconAttr: (name, value) => { - if (this.icon_) { - this.icon_.setAttribute(name, value); - } - }, - }; - } - /** * @return {!{ * registerInputInteractionHandler: function(string, function()): undefined, @@ -222,6 +216,7 @@ class MDCTextField extends MDCComponent { return { bottomLine: this.bottomLine_ ? this.bottomLine_.foundation : undefined, helperText: this.helperText_ ? this.helperText_.foundation : undefined, + icon: this.icon_ ? this.icon_.foundation : undefined, label: this.label_ ? this.label_.foundation : undefined, }; } @@ -229,4 +224,6 @@ class MDCTextField extends MDCComponent { export {MDCTextField, MDCTextFieldFoundation, MDCTextFieldBottomLine, MDCTextFieldBottomLineFoundation, - MDCTextFieldHelperText, MDCTextFieldHelperTextFoundation}; + MDCTextFieldHelperText, MDCTextFieldHelperTextFoundation, + MDCTextFieldIcon, MDCTextFieldIconFoundation, + MDCTextFieldLabel, MDCTextFieldLabelFoundation}; diff --git a/packages/mdc-textfield/mdc-text-field.scss b/packages/mdc-textfield/mdc-text-field.scss index 1e80688ff56..1530d750bf7 100644 --- a/packages/mdc-textfield/mdc-text-field.scss +++ b/packages/mdc-textfield/mdc-text-field.scss @@ -27,6 +27,7 @@ @import "@material/typography/variables"; @import "./bottom-line/mdc-text-field-bottom-line"; @import "./helper-text/mdc-text-field-helper-text"; +@import "./icon/mdc-text-field-icon"; @import "./label/mdc-text-field-label"; @include mdc-text-field-invalid-label-shake-keyframes_(standard, 100%); @include mdc-text-field-invalid-label-shake-keyframes_(box, 50%); @@ -220,17 +221,6 @@ // stylelint-enable plugin/selector-bem-pattern } -.mdc-text-field--with-leading-icon .mdc-text-field__icon, -.mdc-text-field--with-trailing-icon .mdc-text-field__icon { - position: absolute; - bottom: 16px; - cursor: pointer; - - @include mdc-theme-dark(".mdc-text-field") { - @include mdc-theme-prop(color, text-secondary-on-dark); - } -} - .mdc-text-field--with-leading-icon { .mdc-text-field__input { @include mdc-rtl-reflexive-property(padding, $mdc-text-field-icon-padding, $mdc-text-field-icon-position); @@ -255,12 +245,6 @@ } } -.mdc-text-field__icon:not([tabindex]), -.mdc-text-field__icon[tabindex="-1"] { - cursor: default; - pointer-events: none; -} - // stylelint-disable plugin/selector-bem-pattern .mdc-text-field--with-leading-icon.mdc-text-field--dense .mdc-text-field__icon, .mdc-text-field--with-trailing-icon.mdc-text-field--dense .mdc-text-field__icon { diff --git a/test/unit/mdc-textfield/foundation.test.js b/test/unit/mdc-textfield/foundation.test.js index 49dd6f386df..74bc17cff3b 100644 --- a/test/unit/mdc-textfield/foundation.test.js +++ b/test/unit/mdc-textfield/foundation.test.js @@ -35,9 +35,8 @@ test('exports cssClasses', () => { test('defaultAdapter returns a complete adapter implementation', () => { verifyDefaultAdapter(MDCTextFieldFoundation, [ - 'addClass', 'removeClass', 'setIconAttr', - 'eventTargetHasClass', 'registerTextFieldInteractionHandler', - 'deregisterTextFieldInteractionHandler', 'notifyIconAction', + 'addClass', 'removeClass', + 'registerTextFieldInteractionHandler', 'deregisterTextFieldInteractionHandler', 'registerInputInteractionHandler', 'deregisterInputInteractionHandler', 'registerBottomLineEventHandler', 'deregisterBottomLineEventHandler', 'getNativeInput', @@ -57,6 +56,12 @@ const setupTest = () => { showToScreenReader: () => {}, setValidity: () => {}, }); + const icon = td.object({ + setDisabled: () => {}, + registerInteractionHandler: () => {}, + deregisterInteractionHandler: () => {}, + handleInteraction: () => {}, + }); const label = td.object({ floatAbove: () => {}, deactivateFocus: () => {}, @@ -65,10 +70,11 @@ const setupTest = () => { const foundationMap = { bottomLine: bottomLine, helperText: helperText, + icon: icon, label: label, }; const foundation = new MDCTextFieldFoundation(mockAdapter, foundationMap); - return {foundation, mockAdapter, bottomLine, helperText, label}; + return {foundation, mockAdapter, bottomLine, helperText, icon, label}; }; test('#constructor sets disabled to false', () => { @@ -122,16 +128,10 @@ test('#setDisabled removes mdc-text-field--disabled when set to false', () => { td.verify(mockAdapter.removeClass(cssClasses.DISABLED)); }); -test('#setDisabled sets icon tabindex to -1 when set to true', () => { - const {foundation, mockAdapter} = setupTest(); +test('#setDisabled sets disabled on icon', () => { + const {foundation, icon} = setupTest(); foundation.setDisabled(true); - td.verify(mockAdapter.setIconAttr('tabindex', '-1')); -}); - -test('#setDisabled sets icon tabindex to 0 when set to false', () => { - const {foundation, mockAdapter} = setupTest(); - foundation.setDisabled(false); - td.verify(mockAdapter.setIconAttr('tabindex', '0')); + td.verify(icon.setDisabled(true)); }); test('#setValid adds mdc-textfied--invalid when set to false', () => { @@ -367,29 +367,6 @@ test('on blur handles getNativeInput() not returning anything gracefully', () => assert.doesNotThrow(blur); }); -test('on text field click notifies icon event if event target is an icon', () => { - const {foundation, mockAdapter} = setupTest(); - const evt = { - target: {}, - type: 'click', - }; - const mockInput = { - disabled: false, - }; - let iconEventHandler; - - td.when(mockAdapter.getNativeInput()).thenReturn(mockInput); - td.when(mockAdapter.eventTargetHasClass(evt.target, cssClasses.TEXT_FIELD_ICON)).thenReturn(true); - td.when(mockAdapter.registerTextFieldInteractionHandler('click', td.matchers.isA(Function))) - .thenDo((evtType, handler) => { - iconEventHandler = handler; - }); - - foundation.init(); - iconEventHandler(evt); - td.verify(mockAdapter.notifyIconAction()); -}); - test('on transition end deactivates the bottom line if this.isFocused_ is false', () => { const {foundation, mockAdapter, bottomLine} = setupTest(); const mockEvt = { @@ -457,26 +434,3 @@ test('touchstart on the input sets the bottom line origin', () => { td.verify(bottomLine.setTransformOrigin(mockEvt)); }); - -test('interacting with text field does not emit custom events if input is disabled', () => { - const {foundation, mockAdapter} = setupTest(); - const mockEvt = { - target: {}, - key: 'Enter', - }; - const mockInput = { - disabled: true, - }; - let textFieldInteraction; - - td.when(mockAdapter.getNativeInput()).thenReturn(mockInput); - td.when(mockAdapter.registerTextFieldInteractionHandler('keydown', td.matchers.isA(Function))) - .thenDo((evt, handler) => { - textFieldInteraction = handler; - }); - - foundation.init(); - textFieldInteraction(mockEvt); - - td.verify(mockAdapter.notifyIconAction(), {times: 0}); -}); diff --git a/test/unit/mdc-textfield/mdc-text-field-icon-foundation.test.js b/test/unit/mdc-textfield/mdc-text-field-icon-foundation.test.js new file mode 100644 index 00000000000..8c64eee38f5 --- /dev/null +++ b/test/unit/mdc-textfield/mdc-text-field-icon-foundation.test.js @@ -0,0 +1,82 @@ +/** + * 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 {assert} from 'chai'; +import td from 'testdouble'; + +import {verifyDefaultAdapter} from '../helpers/foundation'; +import {setupFoundationTest} from '../helpers/setup'; +import MDCTextFieldIconFoundation from '../../../packages/mdc-textfield/icon/foundation'; + +suite('MDCTextFieldIconFoundation'); + +test('exports strings', () => { + assert.isOk('strings' in MDCTextFieldIconFoundation); +}); + +test('defaultAdapter returns a complete adapter implementation', () => { + verifyDefaultAdapter(MDCTextFieldIconFoundation, [ + 'setAttr', 'registerInteractionHandler', 'deregisterInteractionHandler', + 'notifyIconAction', + ]); +}); + +const setupTest = () => setupFoundationTest(MDCTextFieldIconFoundation); + +test('#init adds event listeners', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.init(); + + td.verify(mockAdapter.registerInteractionHandler('click', td.matchers.isA(Function))); + td.verify(mockAdapter.registerInteractionHandler('keydown', td.matchers.isA(Function))); +}); + +test('#destroy removes event listeners', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.destroy(); + + td.verify(mockAdapter.deregisterInteractionHandler('click', td.matchers.isA(Function))); + td.verify(mockAdapter.deregisterInteractionHandler('keydown', td.matchers.isA(Function))); +}); + +test('#setDisabled sets icon tabindex to -1 when set to true', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.setDisabled(true); + td.verify(mockAdapter.setAttr('tabindex', '-1')); +}); + +test('#setDisabled sets icon tabindex to 0 when set to false', () => { + const {foundation, mockAdapter} = setupTest(); + foundation.setDisabled(false); + td.verify(mockAdapter.setAttr('tabindex', '0')); +}); + +test('on click notifies custom icon event', () => { + const {foundation, mockAdapter} = setupTest(); + const evt = { + target: {}, + type: 'click', + }; + let click; + + td.when(mockAdapter.registerInteractionHandler('click', td.matchers.isA(Function))).thenDo((evtType, handler) => { + click = handler; + }); + + foundation.init(); + click(evt); + td.verify(mockAdapter.notifyIconAction()); +}); diff --git a/test/unit/mdc-textfield/mdc-text-field-icon.test.js b/test/unit/mdc-textfield/mdc-text-field-icon.test.js new file mode 100644 index 00000000000..c9ee0776529 --- /dev/null +++ b/test/unit/mdc-textfield/mdc-text-field-icon.test.js @@ -0,0 +1,74 @@ +/** + * 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 bel from 'bel'; +import {assert} from 'chai'; +import td from 'testdouble'; +import domEvents from 'dom-events'; + +import {MDCTextFieldIcon, MDCTextFieldIconFoundation} from '../../../packages/mdc-textfield/icon'; + +const getFixture = () => bel` +
+`; + +suite('MDCTextFieldIcon'); + +test('attachTo returns an MDCTextFieldIcon instance', () => { + assert.isOk(MDCTextFieldIcon.attachTo(getFixture()) instanceof MDCTextFieldIcon); +}); + +function setupTest() { + const root = getFixture(); + const component = new MDCTextFieldIcon(root); + return {root, component}; +} + +test('#adapter.setAttr adds a given attribute to the element', () => { + const {root, component} = setupTest(); + component.getDefaultFoundation().adapter_.setAttr('aria-label', 'foo'); + assert.equal(root.getAttribute('aria-label'), 'foo'); +}); + +test('#adapter.registerInteractionHandler adds event listener for a given event to the element', () => { + const {root, component} = setupTest(); + const handler = td.func('keydown handler'); + component.getDefaultFoundation().adapter_.registerInteractionHandler('keydown', handler); + domEvents.emit(root, 'keydown'); + + td.verify(handler(td.matchers.anything())); +}); + +test('#adapter.deregisterInteractionHandler removes event listener for a given event from the element', () => { + const {root, component} = setupTest(); + const handler = td.func('keydown handler'); + + root.addEventListener('keydown', handler); + component.getDefaultFoundation().adapter_.deregisterInteractionHandler('keydown', handler); + domEvents.emit(root, 'keydown'); + + td.verify(handler(td.matchers.anything()), {times: 0}); +}); + +test('#adapter.notifyIconAction emits ' + `${MDCTextFieldIconFoundation.strings.ICON_EVENT}`, () => { + const {component} = setupTest(); + const handler = td.func('handler'); + + component.listen(MDCTextFieldIconFoundation.strings.ICON_EVENT, handler); + component.getDefaultFoundation().adapter_.notifyIconAction(); + + td.verify(handler(td.matchers.anything())); +}); diff --git a/test/unit/mdc-textfield/mdc-text-field.test.js b/test/unit/mdc-textfield/mdc-text-field.test.js index c6e730cd9bd..48b44094d04 100644 --- a/test/unit/mdc-textfield/mdc-text-field.test.js +++ b/test/unit/mdc-textfield/mdc-text-field.test.js @@ -20,10 +20,10 @@ import td from 'testdouble'; import {assert} from 'chai'; import {MDCRipple} from '../../../packages/mdc-ripple'; -import {MDCTextField, MDCTextFieldFoundation} from '../../../packages/mdc-textfield'; -import {MDCTextFieldHelperText} from '../../../packages/mdc-textfield/helper-text/index'; +import {MDCTextField, MDCTextFieldFoundation, MDCTextFieldBottomLine, + MDCTextFieldHelperText, MDCTextFieldIcon, MDCTextFieldLabel} from '../../../packages/mdc-textfield'; -const {cssClasses, strings} = MDCTextFieldFoundation; +const {cssClasses} = MDCTextFieldFoundation; const getFixture = () => bel`
@@ -52,6 +52,25 @@ class FakeBottomLine { constructor() { this.listen = td.func('bottomLine.listen'); this.unlisten = td.func('bottomLine.unlisten'); + this.destroy = td.func('.destroy'); + } +} + +class FakeHelperText { + constructor() { + this.destroy = td.func('.destroy'); + } +} + +class FakeIcon { + constructor() { + this.destroy = td.func('.destroy'); + } +} + +class FakeLabel { + constructor() { + this.destroy = td.func('.destroy'); } } @@ -75,10 +94,16 @@ test('#constructor when given a `mdc-text-field--box` element, initializes a def assert.instanceOf(component.ripple, MDCRipple); }); +test('#constructor instantiates a bottom line on the `.mdc-text-field__bottom-line` element if present', () => { + const root = getFixture(); + const component = new MDCTextField(root); + assert.instanceOf(component.bottomLine_, MDCTextFieldBottomLine); +}); + const getHelperTextElement = () => bel`

helper text

`; test('#constructor instantiates a helper text on the element with id specified in the input aria-controls' + - 'if present', () => { + 'if present', () => { const root = getFixture(); root.querySelector('.mdc-text-field__input').setAttribute('aria-controls', 'helper-text'); const helperText = getHelperTextElement(); @@ -88,6 +113,35 @@ test('#constructor instantiates a helper text on the element with id specified i document.body.removeChild(helperText); }); +test('#constructor instantiates an icon on the `.mdc-text-field__icon` element if present', () => { + const root = getFixture(); + const component = new MDCTextField(root); + assert.instanceOf(component.icon_, MDCTextFieldIcon); +}); + +test('#constructor instantiates a label on the `.mdc-text-field__label` element if present', () => { + const root = getFixture(); + const component = new MDCTextField(root); + assert.instanceOf(component.label_, MDCTextFieldLabel); +}); + +function setupTest(root = getFixture()) { + const bottomLine = new FakeBottomLine(); + const helperText = new FakeHelperText(); + const icon = new FakeIcon(); + const label = new FakeLabel(); + const component = new MDCTextField( + root, + undefined, + (el) => new FakeRipple(el), + () => bottomLine, + () => helperText, + () => icon, + () => label + ); + return {root, component, bottomLine, helperText, icon, label}; +} + test('#destroy cleans up the ripple if present', () => { const root = getFixture(); root.classList.add(cssClasses.BOX); @@ -96,19 +150,40 @@ test('#destroy cleans up the ripple if present', () => { td.verify(component.ripple.destroy()); }); +test('#destroy cleans up the bottom line if present', () => { + const {component, bottomLine} = setupTest(); + component.destroy(); + td.verify(bottomLine.destroy()); +}); + +test('#destroy cleans up the helper text if present', () => { + const root = getFixture(); + root.querySelector('.mdc-text-field__input').setAttribute('aria-controls', 'helper-text'); + const helperTextElement = getHelperTextElement(); + document.body.appendChild(helperTextElement); + const {component, helperText} = setupTest(root); + component.destroy(); + td.verify(helperText.destroy()); + document.body.removeChild(helperTextElement); +}); + +test('#destroy cleans up the icon if present', () => { + const {component, icon} = setupTest(); + component.destroy(); + td.verify(icon.destroy()); +}); + +test('#destroy cleans up the label if present', () => { + const {component, label} = setupTest(); + component.destroy(); + td.verify(label.destroy()); +}); + test('#destroy accounts for ripple nullability', () => { const component = new MDCTextField(getFixture()); assert.doesNotThrow(() => component.destroy()); }); -function setupTest() { - const root = getFixture(); - const icon = root.querySelector('.mdc-text-field__icon'); - const bottomLine = new FakeBottomLine(); - const component = new MDCTextField(root, undefined, (el) => new FakeRipple(el), () => bottomLine); - return {root, bottomLine, icon, component}; -} - test('#initialSyncWithDom sets disabled if input element is not disabled', () => { const {component} = setupTest(); component.initialSyncWithDom(); @@ -140,17 +215,6 @@ test('set valid updates the component styles', () => { assert.isNotOk(root.classList.contains(cssClasses.INVALID)); }); -test('set helperTextContent updates the helper text element content', () => { - const root = getFixture(); - root.querySelector('.mdc-text-field__input').setAttribute('aria-controls', 'helper-text'); - const helperText = getHelperTextElement(); - document.body.appendChild(helperText); - const component = new MDCTextField(root); - component.helperTextContent = 'foo'; - assert.equal(helperText.textContent, 'foo'); - document.body.removeChild(helperText); -}); - test('set helperTextContent has no effect when no helper text element is present', () => { const {component} = setupTest(); assert.doesNotThrow(() => { @@ -158,13 +222,6 @@ test('set helperTextContent has no effect when no helper text element is present }); }); -test('#adapter.setIconAttr sets a given attribute to a given value to the icon element', () => { - const {icon, component} = setupTest(); - - component.getDefaultFoundation().adapter_.setIconAttr('tabindex', '-1'); - assert.equal(icon.getAttribute('tabindex'), '-1'); -}); - test('#adapter.registerBottomLineEventHandler adds event listener to bottom line', () => { const {component, bottomLine} = setupTest(); const handler = () => {}; @@ -236,13 +293,3 @@ test('#adapter.getNativeInput returns the component input element', () => { root.querySelector('.mdc-text-field__input') ); }); - -test(`#adapter.notifyIconAction emits ${strings.ICON_EVENT}`, () => { - const {component} = setupTest(); - const handler = td.func('leadingHandler'); - - component.listen(strings.ICON_EVENT, handler); - component.getDefaultFoundation().adapter_.notifyIconAction(); - - td.verify(handler(td.matchers.anything())); -});