diff --git a/packages/mdc-textfield/adapter.js b/packages/mdc-textfield/adapter.js new file mode 100644 index 00000000000..17d78c9a6c9 --- /dev/null +++ b/packages/mdc-textfield/adapter.js @@ -0,0 +1,194 @@ +/** + * @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"}] */ + +/** + * @typedef {{ + * value: string, + * disabled: boolean, + * badInput: boolean, + * checkValidity: (function(): boolean) + * }} + */ +let NativeInputType; + +/** + * Adapter for MDC Textfield. + * + * Defines the shape of the adapter expected by the foundation. Implement this + * adapter to integrate the Textfield into your framework. See + * https://github.com/material-components/material-components-web/blob/master/docs/authoring-components.md + * for more information. + * + * @record + */ +class MDCTextfieldAdapter { + /** + * Adds a class to the root Element. + * @param {string} className + */ + addClass(className) {} + + /** + * Removes a class from the root Element. + * @param {string} className + */ + removeClass(className) {} + + /** + * Adds a class to the label Element. We recommend you add a conditional + * check here, and in removeClassFromLabel for whether or not the label is + * present so that the JS component could be used with text fields that don't + * require a label, such as the full-width text field. + * @param {string} className + */ + addClassToLabel(className) {} + + /** + * Removes a class from the label Element. + * @param {string} className + */ + removeClassFromLabel(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 {?HTMLElement} target + * @param {string} className + * @return {boolean} + */ + eventTargetHasClass(target, className) {} + + /** + * Registers an event handler on the root element for a given event. + * @param {string} type + * @param {function(!Event): undefined} handler + */ + registerTextFieldInteractionHandler(type, handler) {} + + /** + * Deregisters an event handler on the root element for a given event. + * @param {string} type + * @param {function(!Event): undefined} handler + */ + deregisterTextFieldInteractionHandler(type, handler) {} + + /** + * Emits a custom event "MDCTextfield:icon" denoting a user has clicked the icon. + */ + notifyIconAction() {} + + /** + * Adds a class to the bottom line element. + * @param {string} className + */ + addClassToBottomLine(className) {} + + /** + * Removes a class from the bottom line element. + * @param {string} className + */ + removeClassFromBottomLine(className) {} + + /** + * Adds a class to the help text element. Note that in our code we check for + * whether or not we have a help text element and if we don't, we simply + * return. + * @param {string} className + */ + addClassToHelptext(className) {} + + /** + * Removes a class from the help text element. + * @param {string} className + */ + removeClassFromHelptext(className) {} + + /** + * Returns whether or not the help text element contains the given class. + * @param {string} className + * @return {boolean} + */ + helptextHasClass(className) {} + + /** + * Registers an event listener on the native input element for a given event. + * @param {string} evtType + * @param {function(!Event): undefined} handler + */ + registerInputInteractionHandler(evtType, handler) {} + + /** + * Deregisters an event listener on the native input element for a given event. + * @param {string} evtType + * @param {function(!Event): undefined} handler + */ + deregisterInputInteractionHandler(evtType, handler) {} + + /** + * Registers an event listener on the bottom line element for a "transitionend" event. + * @param {function(!Event): undefined} handler + */ + registerTransitionEndHandler(handler) {} + + /** + * Deregisters an event listener on the bottom line element for a "transitionend" event. + * @param {function(!Event): undefined} handler + */ + deregisterTransitionEndHandler(handler) {} + + /** + * Sets an attribute with a given value on the bottom line element. + * @param {string} attr + * @param {string} value + */ + setBottomLineAttr(attr, value) {} + + /** + * Sets an attribute with a given value on the help text element. + * @param {string} name + * @param {string} value + */ + setHelptextAttr(name, value) {} + + /** + * Removes an attribute from the help text element. + * @param {string} name + */ + removeHelptextAttr(name) {} + + /** + * Returns an object representing the native text input element, with a + * similar API shape. The object returned should include the value, disabled + * and badInput properties, as well as the checkValidity() function. We never + * alter the value within our code, however we do update the disabled + * property, so if you choose to duck-type the return value for this method + * in your implementation it's important to keep this in mind. Also note that + * this method can return null, which the foundation will handle gracefully. + * @return {?HTMLInputElement|?NativeInputType} + */ + getNativeInput() {} +} + +export {MDCTextfieldAdapter, NativeInputType}; diff --git a/packages/mdc-textfield/constants.js b/packages/mdc-textfield/constants.js index ae21c4001e4..db23ea1c274 100644 --- a/packages/mdc-textfield/constants.js +++ b/packages/mdc-textfield/constants.js @@ -13,7 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export const strings = { + +/** @enum {string} */ +const strings = { ARIA_HIDDEN: 'aria-hidden', ROLE: 'role', INPUT_SELECTOR: '.mdc-textfield__input', @@ -23,7 +25,8 @@ export const strings = { BOTTOM_LINE_SELECTOR: '.mdc-textfield__bottom-line', }; -export const cssClasses = { +/** @enum {string} */ +const cssClasses = { ROOT: 'mdc-textfield', UPGRADED: 'mdc-textfield--upgraded', DISABLED: 'mdc-textfield--disabled', @@ -38,3 +41,5 @@ export const cssClasses = { TEXTAREA: 'mdc-textfield--textarea', BOTTOM_LINE_ACTIVE: 'mdc-textfield__bottom-line--active', }; + +export {cssClasses, strings}; diff --git a/packages/mdc-textfield/foundation.js b/packages/mdc-textfield/foundation.js index 5d668e998ce..647e2e2a15b 100644 --- a/packages/mdc-textfield/foundation.js +++ b/packages/mdc-textfield/foundation.js @@ -14,56 +14,81 @@ * limitations under the License. */ -import {MDCFoundation} from '@material/base'; +import MDCFoundation from '@material/base/foundation'; +import {MDCTextfieldAdapter, NativeInputType} from './adapter'; import {cssClasses, strings} from './constants'; -export default class MDCTextfieldFoundation extends MDCFoundation { + +/** + * @extends {MDCFoundation} + * @final + */ +class MDCTextfieldFoundation extends MDCFoundation { + /** @return enum {string} */ static get cssClasses() { return cssClasses; } + /** @return enum {string} */ static get strings() { return strings; } + /** + * {@see MDCTextfieldAdapter} for typing information on parameters and return + * types. + * @return {!MDCTextfieldAdapter} + */ static get defaultAdapter() { - return { - addClass: (/* className: string */) => {}, - removeClass: (/* className: string */) => {}, - addClassToLabel: (/* className: string */) => {}, - removeClassFromLabel: (/* className: string */) => {}, - setIconAttr: (/* name: string, value: string */) => {}, - eventTargetHasClass: (/* target: HTMLElement, className: string */) => {}, + return /** @type {!MDCTextfieldAdapter} */ ({ + addClass: () => {}, + removeClass: () => {}, + addClassToLabel: () => {}, + removeClassFromLabel: () => {}, + setIconAttr: () => {}, + eventTargetHasClass: () => {}, registerTextFieldInteractionHandler: () => {}, deregisterTextFieldInteractionHandler: () => {}, notifyIconAction: () => {}, - addClassToBottomLine: (/* className: string */) => {}, - removeClassFromBottomLine: (/* className: string */) => {}, - addClassToHelptext: (/* className: string */) => {}, - removeClassFromHelptext: (/* className: string */) => {}, - helptextHasClass: (/* className: string */) => /* boolean */ false, - registerInputInteractionHandler: (/* evtType: string, handler: EventListener */) => {}, - deregisterInputInteractionHandler: (/* evtType: string, handler: EventListener */) => {}, - registerTransitionEndHandler: (/* handler: EventListener */) => {}, - deregisterTransitionEndHandler: (/* handler: EventListener */) => {}, - setBottomLineAttr: (/* attr: string, value: string */) => {}, - setHelptextAttr: (/* name: string, value: string */) => {}, - removeHelptextAttr: (/* name: string */) => {}, - getNativeInput: () => /* HTMLInputElement */ {}, - }; + addClassToBottomLine: () => {}, + removeClassFromBottomLine: () => {}, + addClassToHelptext: () => {}, + removeClassFromHelptext: () => {}, + helptextHasClass: () => false, + registerInputInteractionHandler: () => {}, + deregisterInputInteractionHandler: () => {}, + registerTransitionEndHandler: () => {}, + deregisterTransitionEndHandler: () => {}, + setBottomLineAttr: () => {}, + setHelptextAttr: () => {}, + removeHelptextAttr: () => {}, + getNativeInput: () => {}, + }); } + /** + * @param {!MDCTextfieldAdapter=} adapter + */ constructor(adapter = {}) { super(Object.assign(MDCTextfieldFoundation.defaultAdapter, adapter)); + /** @private {boolean} */ this.isFocused_ = false; + /** @private {boolean} */ this.receivedUserInput_ = false; + /** @private {boolean} */ this.useCustomValidityChecking_ = false; + /** @private {function(): undefined} */ this.inputFocusHandler_ = () => this.activateFocus_(); + /** @private {function(): undefined} */ this.inputBlurHandler_ = () => this.deactivateFocus_(); + /** @private {function(): undefined} */ this.inputInputHandler_ = () => this.autoCompleteFocus_(); + /** @private {function(!Event): undefined} */ this.setPointerXOffset_ = (evt) => this.setBottomLineTransformOrigin_(evt); + /** @private {function(!Event): undefined} */ this.textFieldInteractionHandler_ = (evt) => this.handleTextFieldInteraction_(evt); + /** @private {function(!Event): undefined} */ this.transitionEndHandler_ = (evt) => this.transitionEnd_(evt); } @@ -100,6 +125,11 @@ export default class MDCTextfieldFoundation extends MDCFoundation { this.adapter_.deregisterTransitionEndHandler(this.transitionEndHandler_); } + /** + * Handles all user interactions with the Textfield. + * @param {!Event} evt + * @private + */ handleTextFieldInteraction_(evt) { if (this.adapter_.getNativeInput().disabled) { return; @@ -117,6 +147,10 @@ export default class MDCTextfieldFoundation extends MDCFoundation { } } + /** + * Activates the text field focus state. + * @private + */ activateFocus_() { const {BOTTOM_LINE_ACTIVE, FOCUSED, LABEL_FLOAT_ABOVE, LABEL_SHAKE} = MDCTextfieldFoundation.cssClasses; this.adapter_.addClass(FOCUSED); @@ -127,6 +161,12 @@ export default class MDCTextfieldFoundation extends MDCFoundation { this.isFocused_ = true; } + /** + * Sets the transform-origin of the bottom line, causing it to animate out + * from the user's click location. + * @param {!Event} evt + * @private + */ setBottomLineTransformOrigin_(evt) { const targetClientRect = evt.target.getBoundingClientRect(); const evtCoords = {x: evt.clientX, y: evt.clientY}; @@ -137,17 +177,32 @@ export default class MDCTextfieldFoundation extends MDCFoundation { this.adapter_.setBottomLineAttr('style', attributeString); } + /** + * Activates the Textfield's focus state in cases when the input value + * changes without user input (e.g. programatically). + * @private + */ autoCompleteFocus_() { if (!this.receivedUserInput_) { this.activateFocus_(); } } + /** + * Makes the help text visible to screen readers. + * @private + */ showHelptext_() { const {ARIA_HIDDEN} = MDCTextfieldFoundation.strings; this.adapter_.removeHelptextAttr(ARIA_HIDDEN); } + /** + * Fires when animation transition ends, performing actions that must wait + * for animations to finish. + * @param {!Event} evt + * @private + */ transitionEnd_(evt) { const {BOTTOM_LINE_ACTIVE} = MDCTextfieldFoundation.cssClasses; @@ -159,6 +214,10 @@ export default class MDCTextfieldFoundation extends MDCFoundation { } } + /** + * Deactives the Textfield's focus state. + * @private + */ deactivateFocus_() { const {FOCUSED, LABEL_FLOAT_ABOVE, LABEL_SHAKE} = MDCTextfieldFoundation.cssClasses; const input = this.getNativeInput_(); @@ -177,6 +236,11 @@ export default class MDCTextfieldFoundation extends MDCFoundation { } } + /** + * Updates the Textfield's valid state based on the supplied validity. + * @param {boolean} isValid + * @private + */ changeValidity_(isValid) { const {INVALID, LABEL_SHAKE} = MDCTextfieldFoundation.cssClasses; if (isValid) { @@ -188,6 +252,11 @@ export default class MDCTextfieldFoundation extends MDCFoundation { this.updateHelptext_(isValid); } + /** + * Updates the state of the Textfield's help text based on validity and + * the Textfield's options. + * @param {boolean} isValid + */ updateHelptext_(isValid) { const {HELPTEXT_PERSISTENT, HELPTEXT_VALIDATION_MSG} = MDCTextfieldFoundation.cssClasses; const {ROLE} = MDCTextfieldFoundation.strings; @@ -207,20 +276,34 @@ export default class MDCTextfieldFoundation extends MDCFoundation { this.hideHelptext_(); } + /** + * Hides the help text from screen readers. + * @private + */ hideHelptext_() { const {ARIA_HIDDEN} = MDCTextfieldFoundation.strings; this.adapter_.setHelptextAttr(ARIA_HIDDEN, 'true'); } + /** + * @return {boolean} True if the Textfield input fails validity checks. + * @private + */ isBadInput_() { const input = this.getNativeInput_(); return input.validity ? input.validity.badInput : input.badInput; } + /** + * @return {boolean} True if the Textfield is disabled. + */ isDisabled() { return this.getNativeInput_().disabled; } + /** + * @param {boolean} disabled Sets the textfield disabled or enabled. + */ setDisabled(disabled) { const {DISABLED} = MDCTextfieldFoundation.cssClasses; this.getNativeInput_().disabled = disabled; @@ -233,17 +316,28 @@ export default class MDCTextfieldFoundation extends MDCFoundation { } } + /** + * @return {!HTMLInputElement|!NativeInputType} The native text input from the + * host environment, or a dummy if none exists. + * @private + */ getNativeInput_() { - return this.adapter_.getNativeInput() || { + return this.adapter_.getNativeInput() || + /** @type {!NativeInputType} */ ({ checkValidity: () => true, value: '', disabled: false, badInput: false, - }; + }); } + /** + * @param {boolean} isValid Sets the validity state of the Textfield. + */ setValid(isValid) { this.useCustomValidityChecking_ = true; this.changeValidity_(isValid); } } + +export default MDCTextfieldFoundation; diff --git a/packages/mdc-textfield/index.js b/packages/mdc-textfield/index.js index 90e648dd1dd..5fa69ae2bb9 100644 --- a/packages/mdc-textfield/index.js +++ b/packages/mdc-textfield/index.js @@ -14,19 +14,48 @@ * limitations under the License. */ -import {MDCComponent} from '@material/base'; +import MDCComponent from '@material/base/component'; import {MDCRipple} from '@material/ripple'; import {cssClasses, strings} from './constants'; import MDCTextfieldFoundation from './foundation'; -export {MDCTextfieldFoundation}; +/** + * @extends {MDCComponent} + * @final + */ +class MDCTextfield extends MDCComponent { + /** + * @param {...?} args + */ + constructor(...args) { + super(...args); + /** @private {?HTMLInputElement} */ + this.input_; + /** @private {?HTMLElement} */ + this.label_; + /** @type {?HTMLElement} */ + this.helptextElement; + /** @type {?MDCRipple} */ + this.ripple; + /** @private {?HTMLElement} */ + this.bottomLine_; + /** @private {?HTMLElement} */ + this.icon_; + } -export class MDCTextfield extends MDCComponent { + /** + * @param {!HTMLElement} root + * @return {!MDCTextfield} + */ static attachTo(root) { return new MDCTextfield(root); } + /** + * @param {(function(!Element): !MDCRipple)=} rippleFactory A function which + * creates a new MDCRipple. + */ initialize(rippleFactory = (el) => new MDCRipple(el)) { this.input_ = this.root_.querySelector(strings.INPUT_SELECTOR); this.label_ = this.root_.querySelector(strings.LABEL_SELECTOR); @@ -53,22 +82,38 @@ export class MDCTextfield extends MDCComponent { super.destroy(); } + /** + * Initiliazes the Textfield's internal state based on the environment's + * state. + */ initialSyncWithDom() { this.disabled = this.input_.disabled; } + /** + * @return {boolean} True if the Textfield is disabled. + */ get disabled() { return this.foundation_.isDisabled(); } + /** + * @param {boolean} disabled Sets the Textfield disabled or enabled. + */ set disabled(disabled) { this.foundation_.setDisabled(disabled); } + /** + * @param {boolean} valid Sets the Textfield valid or invalid. + */ set valid(valid) { this.foundation_.setValid(valid); } + /** + * @return {!MDCTextfieldFoundation} + */ getDefaultFoundation() { return new MDCTextfieldFoundation(Object.assign({ addClass: (className) => this.root_.classList.add(className), @@ -89,10 +134,18 @@ export class MDCTextfield extends MDCComponent { registerTextFieldInteractionHandler: (evtType, handler) => this.root_.addEventListener(evtType, handler), deregisterTextFieldInteractionHandler: (evtType, handler) => this.root_.removeEventListener(evtType, handler), notifyIconAction: () => this.emit(MDCTextfieldFoundation.strings.ICON_EVENT), - }, this.getInputAdapterMethods_(), this.getHelptextAdapterMethods_(), this.getBottomLineAdapterMethods_(), + }, + this.getInputAdapterMethods_(), + this.getHelptextAdapterMethods_(), + this.getBottomLineAdapterMethods_(), this.getIconAdapterMethods_())); } + /** + * @return {!{ + * setIconAttr: function(string, string): undefined, + * }} + */ getIconAdapterMethods_() { return { setIconAttr: (name, value) => { @@ -103,6 +156,15 @@ export class MDCTextfield extends MDCComponent { }; } + /** + * @return {!{ + * addClassToBottomLine: function(string): undefined, + * removeClassFromBottomLine: function(string): undefined, + * setBottomLineAttr: function(string, string): undefined, + * registerTransitionEndHandler: function(function()): undefined, + * deregisterTransitionEndHandler: function(function()): undefined, + * }} + */ getBottomLineAdapterMethods_() { return { addClassToBottomLine: (className) => { @@ -133,6 +195,13 @@ export class MDCTextfield extends MDCComponent { }; } + /** + * @return {!{ + * registerInputInteractionHandler: function(string, function()): undefined, + * deregisterInputInteractionHandler: function(string, function()): undefined, + * getNativeInput: function(): ?Element, + * }} + */ getInputAdapterMethods_() { return { registerInputInteractionHandler: (evtType, handler) => this.input_.addEventListener(evtType, handler), @@ -141,6 +210,15 @@ export class MDCTextfield extends MDCComponent { }; } + /** + * @return {!{ + * addClassToHelptext: function(string): undefined, + * removeClassFromHelptext: function(string): undefined, + * helptextHasClass: function(string): boolean, + * setHelptextAttr: function(string, string): undefined, + * removeHelptextAttr: function(string): undefined, + * }} + */ getHelptextAdapterMethods_() { return { addClassToHelptext: (className) => { @@ -172,3 +250,5 @@ export class MDCTextfield extends MDCComponent { }; } } + +export {MDCTextfield, MDCTextfieldFoundation};