From c9a8308125e44f99958882405e887ecce6ceed94 Mon Sep 17 00:00:00 2001 From: Matthew Hartstonge Date: Tue, 26 Nov 2024 11:47:47 +1300 Subject: [PATCH 1/4] fix(addon/components/-focusable): stops setting `focus=true` if the element is disabled. --- addon/components/-focusable.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/addon/components/-focusable.js b/addon/components/-focusable.js index 6c94e0304..e5bffaa95 100644 --- a/addon/components/-focusable.js +++ b/addon/components/-focusable.js @@ -89,6 +89,11 @@ export default class Focusable extends Component { * They bubble by default. */ @action handleFocusIn(e) { + if (this.disabled) { + // elements should not be able to be focused if disabled. + return; + } + if ((!this.disabled && !this.focusOnlyOnKey) || !this.pressed) { this.focused = true; if (this.args.onFocusIn) { From daa96433dbf15f8794795d7e067f6ee3c4ec6e2e Mon Sep 17 00:00:00 2001 From: Matthew Hartstonge Date: Tue, 26 Nov 2024 16:39:51 +1300 Subject: [PATCH 2/4] feat(addon/lib/validation): adds `Validation`, a class to enable tracking the validity of a value/input element. --- addon/lib/validation.js | 387 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 addon/lib/validation.js diff --git a/addon/lib/validation.js b/addon/lib/validation.js new file mode 100644 index 000000000..72853780a --- /dev/null +++ b/addon/lib/validation.js @@ -0,0 +1,387 @@ +import requiredValidator from 'ember-paper/validators/required'; +import minValidator from 'ember-paper/validators/min'; +import maxValidator from 'ember-paper/validators/max'; +import minlengthValidator from 'ember-paper/validators/minlength'; +import maxlengthValidator from 'ember-paper/validators/maxlength'; +import { isArray, A } from '@ember/array'; +import { assert, warn } from '@ember/debug'; +import { isBlank } from '@ember/utils'; +import { tracked } from '@glimmer/tracking'; + +/** + * A validator provides a function that can validate the specified input. + * + * @typedef {Object} validator + * @property {string} param - the input attribute this is validating. + * @property {string} message - the error message to show to the end user if input is considered invalid. + * @property {function} validate - the function to be called to validate the param's value against the param. + */ + +/** + * This callback is used to notify when the validity changes on the input field. + * + * @callback onValidityChange + * @param {boolean} isValid - whether the input element is considered valid. + * @param {string} elementId - The unique paper-input identifier. + */ + +/** + * Validation provides input validation. + * + * @class Validation + */ +export default class Validation { + /** + * id is returned on notifying on validity to enable unique identification of + * the input that is in an invalid state. + * + * @type {string} + * @private + */ + #id; + /** + * #inputElement stores a reference to the input element for updating the + * underlying value and checking the browser's native validity status. + * + * @type {HTMLInputElement} + * @private + */ + #inputElement; + /** + * + * @type {onValidityChange|undefined} + * @private + */ + #onValidityChange = null; + /** + * an array of validators to be run to ascertain the input's validity. + * + * @type {validator[]} + */ + #validators = defaultValidations(); + + // State + /** + * Key-Value map of param to the message the user wants to override. + * For example, `@errorMessages={{hash required="Address is required."}}` + * + * @type {A} + */ + #errorMessageOverrides = A(); + /** + * #previousErrors stores the last state of errors to be able to track and + * notify on changes. + * + * @type {A} + * @private + */ + #previousErrors; + /** + * previousIsTouched stores the last state of isTouched to be able to track + * and notify on changes. + * + * @type {boolean} + * @private + */ + #previousIsTouched = undefined; + /** + * previousIsValid stores the last state of isValid to be able to track and + * notify on changes. + * + * @type {boolean} + * @private + */ + #previousIsValid = undefined; + /** + * previousValue stores the last state of value to be able to track and + * notify on changes. + * + * @type {*} + * @private + */ + #previousValue = ''; + + /** + * an array of the latest validation error messages, suitable for end user + * display. + * + * @type {A} + * @public + */ + @tracked errorMessages = A(); + /** + * An array of the latest validation errors. + * Can be overridden in order to supply your own external errors. + * + * @type {A} + * @public + */ + @tracked errors = A(); + /** + * tracks if the input has been touched. + * Useful for detecting when validation error messages should be displayed to + * the end user. + * + * @type {boolean} + * @public + */ + @tracked isTouched = false; + /** + * value contains the datum to be validated. + * + * @type {any} + * @public + */ + @tracked value = null; + + /** + * @constructor + * @param {string} id - a unique identifier to identify the input element. + * @param {onValidityChange} onValidityChange - callback to notify when a change has occurred to the validity of a field. + * @param {validator[]|undefined} [validations] - validators to override the default set. + * @param {validator[]|undefined} [customValidations] - validators to append to the default set. + * @param {A|undefined} [errors] - input errors that should be reported. + * @param {A|undefined} [errorMessageOverrides] - validation error message overrides. + * @param {boolean|undefined} [isTouched] - a flag to mark the input as touched on creation. + */ + constructor( + id, + onValidityChange, + validations, + customValidations, + errors, + errorMessageOverrides, + isTouched + ) { + this.#id = id; + + if (validations) { + this.#validators = validations + .map((v) => v.param) + .filter((v) => !isBlank(v)); + } + if (customValidations) { + assert( + '`customValidations` must be an array', + isArray(customValidations) + ); + this.#validators.pushObjects(customValidations); + } + if (errorMessageOverrides) { + this.#errorMessageOverrides = errorMessageOverrides; + } + if (isTouched) { + this.isTouched = isTouched; + } + if (errors) { + this.errors = errors; + } + + assert( + 'Validation requires an `onValidityChange` action or null for no action.', + onValidityChange !== undefined + ); + this.#onValidityChange = onValidityChange; + } + + /** + * didInsertNode provides a way to push in an input element to track. + * + * This allows the practitioner to construct the validator for availability on + * component creation, but where the node you need to track hasn't been + * inserted into the DOM yet. + * + * @param {HTMLInputElement} inputElement + */ + didInsertNode(inputElement) { + this.#inputElement = inputElement; + } + + /** + * true if validation errors have been found. + * + * @return {boolean} + */ + get hasErrorMessages() { + return this.errorMessages.length > 0; + } + + /** + * The result of isInvalid is appropriate for controlling the display of + * validation error messages. It also may be used to distinguish whether + * the input would be considered valid after it is touched. + * + * @public + * @return {boolean} Whether the input is or would be invalid. + * false: input is valid (touched or not), or is no longer rendered + * true: input has been touched and is invalid. + */ + get isInvalid() { + return this.hasErrorMessages || this.isNativeInvalid; + } + + /** + * returns true if the input element is considered valid. + * + * @return {boolean} + */ + get isValid() { + return !this.isInvalid; + } + + /** + * returns invalid based on the native input element's state of validity. + * + * @return {boolean} + */ + get isNativeInvalid() { + let inputElement = this.#inputElement; + if (!inputElement) { + return false; + } + + if (inputElement.type === 'date' && inputElement.value === '') { + // Chrome doesn't fire the onInput event when clearing the second and third date components. + // This means that we won't see another event when badInput becomes false if the user is clearing + // the date field. The reported value is empty, though, so we can already mark it as valid. + return false; + } + + return (inputElement.validity && inputElement.validity.badInput) || false; + } + + /** + * returns true if the input is considered invalid and has been touched. + * + * @return {boolean} + */ + get isInvalidAndTouched() { + return this.isInvalid && this.isTouched; + } + + /** + * validate executes configured validators and sets an array of errors and + * error messages for consumption. + * + * @param {Object} args - the values that are being supplied to the input. + */ + validate(args) { + let messages = A(); + + let currentValue = this.value; + this.#validators.forEach((v) => { + assert( + 'validation must include a `validate(value)` function', + v && v.validate && typeof v.validate === 'function' + ); + try { + let valParam = v.param; + let paramValue = valParam ? args[valParam] : undefined; + if (!v.validate(currentValue, paramValue)) { + let message = this.#errorMessageOverrides[valParam] || v.message; + messages.pushObject({ + message: fmt(message.string || message, paramValue, currentValue), + }); + } + } catch (error) { + warn(`Exception with validation: ${v} ${error}`, false); + } + }); + + // build the error messages array + let errors = this.errors || []; + assert('`errors` must be an array', isArray(errors)); + messages.pushObjects( + errors.map((e) => { + return e.message ? e : { message: e }; + }) + ); + + this.errors = errors; + this.errorMessages = messages; + } + + /** + * notifyOnChange will only notify if the value and errors have changed since + * last notification check. + * + * If values have changed, falls through to {@link notifyOnValidityChange}. + */ + notifyOnChange() { + const previousErrors = this.#previousErrors; + const errors = this.errors; + const previousValue = this.#previousValue; + const value = this.value; + if (previousValue !== value || previousErrors !== errors) { + this.notifyOnValidityChange(); + + this.#previousErrors = errors; + this.#previousValue = value; + } + } + + /** + * notifyOnValidityChange calls the provided onValidityChange callback with + * the value of valid and the id of the element to help track the validity of + * certain inputs. + */ + notifyOnValidityChange() { + const prevIsValid = this.#previousIsValid; + const isValid = this.isValid; + const prevIsTouched = this.#previousIsTouched; + const isTouched = this.isTouched; + if (prevIsValid !== isValid || prevIsTouched !== isTouched) { + if (this.#onValidityChange) { + this.#onValidityChange(isValid, this.#id); + } + + this.#previousIsValid = isValid; + this.#previousIsTouched = isTouched; + } + } +} + +/** + * defaultValidations returns the default set of validations to run for an input. + * + * @return {validator[]} + */ +function defaultValidations() { + return A([ + requiredValidator, + minValidator, + maxValidator, + minlengthValidator, + maxlengthValidator, + ]); +} + +/** + * fmt provides an implementation of a format string that replaces %@ with the + * passed in arguments. It replaces usage of `loc` which was deprecated in + * `ember@v3.X`. + * + * @see https://github.com/emberjs/ember.js/blob/v3.22.2/packages/%40ember/string/index.ts + * @param {string} str - the format string. + * @param {string[]} formats - the ordered positional replacements. + * @returns {string} + */ +function fmt(str, formats) { + if (!Array.isArray(formats) || arguments.length > 2) { + formats = Array.prototype.slice.call(arguments, 1); + } + + // first, replace any ORDERED replacements. + let idx = 0; // the current index for non-numerical replacements + return str.replace(/%@([0-9]+)?/g, (_s, argIndex) => { + let i = argIndex ? parseInt(argIndex, 10) - 1 : idx++; + let r = i < formats.length ? formats[i] : undefined; + return typeof r === 'string' + ? r + : r === null + ? '(null)' + : r === undefined + ? '' + : String(r); + }); +} From 65b7ad97a71c5ef4c7ae410cfa92190be65af933 Mon Sep 17 00:00:00 2001 From: Matthew Hartstonge Date: Tue, 26 Nov 2024 18:23:08 +1300 Subject: [PATCH 3/4] feat(addon/components/paper-input): converts to a glimmer component. --- addon/components/paper-input.hbs | 261 +++++++++------- addon/components/paper-input.js | 511 ++++++++++++++++++++++--------- 2 files changed, 511 insertions(+), 261 deletions(-) diff --git a/addon/components/paper-input.hbs b/addon/components/paper-input.hbs index 2041b50bd..9cdca8e0f 100644 --- a/addon/components/paper-input.hbs +++ b/addon/components/paper-input.hbs @@ -1,115 +1,158 @@ -{{! template-lint-disable no-action no-curly-component-invocation no-down-event-binding no-positive-tabindex }} -{{#if @label}} - -{{/if}} - -{{#if @icon}} - {{component this.iconComponent @icon}} -{{/if}} - -{{#if @textarea}} - -{{else}} - -{{/if}} + {{#if @textarea}} + + {{else}} + + {{/if}} -{{#unless this.hideAllMessages}} -
- {{#if @maxlength}} -
{{this.currentLength}}/{{@maxlength}}
- {{/if}} -
- {{#if this.isInvalidAndTouched}} -
- {{#each this.validationErrorMessages as |error index|}} -
- {{error.message}} -
- {{/each}} + {{#unless @hideAllMessages}} +
+ {{#if @maxlength}} +
{{this.currentLength}}/{{@maxlength}}
+ {{/if}}
- {{/if}} -{{/unless}} + {{#if this.validation.isInvalidAndTouched}} +
+ {{#each this.validation.errorMessages as |error index|}} +
+ {{error.message}} +
+ {{/each}} +
+ {{/if}} + {{/unless}} -{{yield (hash - charCount=this.currentLength - isInvalid=this.isInvalid - isTouched=this.isTouched - isInvalidAndTouched=this.isInvalidAndTouched - hasValue=this.hasValue - validationErrorMessages=this.validationErrorMessages -)}} + {{yield + (hash + charCount=this.currentLength + isInvalid=this.validation.isInvalid + isTouched=this.validation.isTouched + isInvalidAndTouched=this.validation.isInvalidAndTouched + hasValue=this.hasValue + validationErrorMessages=this.validation.errorMessages + ) + }} -{{#if @iconRight}} - {{component this.iconComponent @iconRight}} -{{/if}} + {{#if @iconRight}} + {{component this.iconComponent @iconRight}} + {{/if}} + \ No newline at end of file diff --git a/addon/components/paper-input.js b/addon/components/paper-input.js index 46702fc6d..5426f0d2b 100644 --- a/addon/components/paper-input.js +++ b/addon/components/paper-input.js @@ -1,150 +1,334 @@ -/* eslint-disable ember/no-actions-hash, ember/no-classic-components, ember/no-component-lifecycle-hooks, ember/no-get, ember/no-mixins, ember/require-computed-property-dependencies, ember/require-tagless-components */ /** * @module ember-paper */ -import { or, bool, and } from '@ember/object/computed'; - -import Component from '@ember/component'; -import { computed, set } from '@ember/object'; +import Focusable from './-focusable'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { guidFor } from '@ember/object/internals'; import { isEmpty } from '@ember/utils'; -import { bind, next } from '@ember/runloop'; import { assert } from '@ember/debug'; -import FocusableMixin from 'ember-paper/mixins/focusable-mixin'; -import ChildMixin from 'ember-paper/mixins/child-mixin'; -import ValidationMixin from 'ember-paper/mixins/validation-mixin'; -import { invokeAction } from 'ember-paper/utils/invoke-action'; +import Validation from '../lib/validation'; +import { A } from '@ember/array'; /** * @class PaperInput - * @extends Ember.Component - * @uses FocusableMixin - * @uses ChildMixin - * @uses ValidationMixin + * @extends Component */ -export default Component.extend(FocusableMixin, ChildMixin, ValidationMixin, { - tagName: 'md-input-container', - classNames: ['md-default-theme'], - - classNameBindings: [ - 'hasValue:md-input-has-value', - 'isInvalidAndTouched:md-input-invalid', - 'hasLeftIcon:md-icon-left', - 'hasRightIcon:md-icon-right', - 'focused:md-input-focused', - 'block:md-block', - 'placeholder:md-input-has-placeholder', - 'warn:md-warn', - 'accent:md-accent', - 'primary:md-primary', - ], - - type: 'text', - autofocus: false, - tabindex: null, - hideAllMessages: false, - isTouched: false, - iconComponent: 'paper-icon', - - // override validation mixin `isInvalid` to account for the native input validity - isInvalid: or('hasErrorMessages', 'isNativeInvalid'), - - hasValue: computed('value', 'isNativeInvalid', function () { - let value = this.value; - let isNativeInvalid = this.isNativeInvalid; - return !isEmpty(value) || isNativeInvalid; - }), - - shouldAddPlaceholder: computed('label', 'focused', function () { - // if has label, only add placeholder when focused - return isEmpty(this.label) || this.focused; - }), - - inputElementId: computed('elementId', { - get() { - return `input-${this.elementId}`; - }, - // elementId can be set from outside and it will override the computed value. - // Please check the deprecations for further details - // https://deprecations.emberjs.com/v3.x/#toc_computed-property-override - set(key, value) { - // To make sure the context updates properly, We are manually set value using @ember/object#set as recommended. - return set(this, 'elementId', value); - }, - }), - - currentLength: computed('value', function () { - return this.value ? this.value.length : 0; - }), +export default class PaperInput extends Focusable { + /** + * tracks the validity of an input. + * + * @type {Validation} + * @private + */ + validation; + + /** + * A unique id to identify the input element. + * + * @type{string} + * @readonly + */ + inputElementId; + /** + * Stores a reference to the component's input element. + * + * @type {HTMLInputElement} + * @private + */ + #inputElement; + /** + * The parent this component is bound to. + * + * @type {PaperRadioGroup|PaperForm|PaperItem|PaperTabs} + * @private + */ + #parent; + /** + * Marks whether the component should register itself to the supplied parent. + * + * @type {Boolean} + * @public + */ + shouldRegister; + /** + * Marks whether the component should skip being proxied. + * + * @type {Boolean} + * @public + */ + skipProxy; + /** + * iconComponent specifies the icon component to use. + * + * @type {string} + * @readonly + * @default "paper-icon" + */ + iconComponent; + /** + * type specifies the input's type. See {@link https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#input_types MDN} + * for a list of available input types. + * + * @type {string} + * @readonly + * @default "text" + */ + type; + /** + * used to calculate how much to grow a textarea by. + * + * @type {number} + * @private + */ + @tracked lineHeight; + + /** + * @constructor + * @param owner + * @param args + */ + constructor(owner, args) { + super(owner, args); - hasLeftIcon: bool('icon'), - hasRightIcon: bool('iconRight'), - isInvalidAndTouched: and('isInvalid', 'isTouched'), + this.iconComponent = this.args.iconComponent || 'paper-icon'; + this.type = this.args.type || 'text'; + const elementId = + this.args.elementId || this.args.inputElementId || guidFor(this); + this.inputElementId = this.args.inputElementId || `input-${elementId}`; + this.lineHeight = this.args.lineHeight || null; - // property that validations should be run on - validationProperty: 'value', + // Construct Input Validation and pass through of custom attributes. + this.validation = new Validation( + elementId, + this.args.onValidityChange || null, + this.args.validations, + this.args.customValidations, + this.args.errors, + this.args.errorMessages, + this.args.isTouched + ); + + if (this.shouldRegister) { + assert( + 'A parent component should be supplied to when shouldRegister=true', + this.args.parentComponent + ); + this.#parent = this.args.parentComponent; + } - // Lifecycle hooks - didReceiveAttrs() { - this._super(...arguments); assert( - '{{paper-input}} requires an `onChange` action or null for no action.', - this.onChange !== undefined + ' requires an `onChange` action or null for no action.', + this.args.onChange !== undefined ); + } + + /** + * Performs any required DOM setup. + * + * @param {HTMLElement} element - the node that has been added to the DOM. + */ + @action didInsertNode(element) { + this.registerListeners(element); + + let inputElement = element.querySelector('input, textarea'); + this.#inputElement = inputElement; + this.validation.didInsertNode(inputElement); + + // setValue ensures that the input value is the same as this.value + this.setValue(this.value); + this.growTextarea(); + + if (this.args.textarea) { + window.addEventListener('resize', this.growTextarea.bind(this)); + } - let { value, errors } = this; - let { _prevValue, _prevErrors } = this; - if (value !== _prevValue || errors !== _prevErrors) { - this.notifyValidityChange(); + if (this.shouldRegister) { + this.#parent.registerChild(this); } - this._prevValue = value; - this._prevErrors = errors; - }, - - didInsertElement() { - this._super(...arguments); - if (this.textarea) { - this._growTextareaOnResize = bind(this, this.growTextarea); - window.addEventListener('resize', this._growTextareaOnResize); + } + + /** + * didUpdateNode is called when tracked component attributes change. + */ + @action didUpdateNode() { + if (this.args.errors) { + this.validation.errors = this.args.errors; } - }, - didRender() { - this._super(...arguments); - // setValue below ensures that the input value is the same as this.value + // setValue ensures that the input value is the same as this.value this.setValue(this.value); this.growTextarea(); - }, + } + + /** + * Performs any required DOM teardown. + * + * @param {HTMLElement} element - the node to be removed from the DOM. + */ + @action willDestroyNode(element) { + this.unregisterListeners(element); + + if (this.args.textarea) { + window.removeEventListener('resize', this.growTextarea.bind(this)); + } + } + + /** + * lifecycle hook to perform non-DOM related teardown. + */ + willDestroy() { + super.willDestroy(...arguments); + + if (this.shouldRegister) { + this.#parent.unregisterChild(this); + } + } + + /** + * isBlock adds css class `md-block` which sets `display: block`. + * + * This indirection is required to maintain api compatibility as @block is + * reserved by glimmer. + * + * @returns {boolean} + */ + get isBlock() { + return this.args.block || false; + } + + /** + * This is a little bit of a hack to un-proxy the values in args so that we + * can track all changes. As users can define dynamic validations, we need to + * be able to account for random args coming in. + * + * @returns {Object} + */ + get params() { + return { ...this.args }; + } + + /** + * returns an array of errors supplied as an argument to the component. + * + * @returns {A} + */ + get errors() { + return this.args.errors || A([]); + } + + /** + * value returns the value passed in to the component, or an empty string if + * undefined. + * + * @returns {string} + */ + get value() { + return this.args.value || ''; + } - willDestroyElement() { - this._super(...arguments); - if (this.textarea) { - window.removeEventListener('resize', this._growTextareaOnResize); - this._growTextareaOnResize = null; + /** + * returns true if we have a non-empty value, or is considered natively + * invalid. + * + * @returns {boolean} + */ + get hasValue() { + return !isEmpty(this.value) || this.validation.isNativeInvalid; + } + + /** + * returns true if a label has been supplied, or if the input is focused. + * + * @returns {boolean} + */ + get shouldAddPlaceholder() { + // if input has label, only add placeholder when focused + return isEmpty(this.args.label) || this.focused; + } + + /** + * returns the current number of characters that {@link value} contains. + * + * @returns {number} + */ + get currentLength() { + return this.value ? this.value.length : 0; + } + + /** + * returns true if icon has been passed in to the component. + * + * @returns {boolean} + */ + get hasLeftIcon() { + return !isEmpty(this.args.icon); + } + + /** + * returns true if iconRight has been passed in to the component. + * + * @returns {boolean} + */ + get hasRightIcon() { + return !isEmpty(this.args.iconRight); + } + + /** + * minRows returns the user specified minimum number of rows. + * + * @returns {number} + * @default 0 + */ + get minRows() { + if (this.args.passThru && this.args.passThru.rows) { + return this.args.passThru.rows; } - }, + return 0; + } + + /** + * minRows returns the user specified maximum number of rows. + * + * @returns {number} + * @default Number.MAX_VALUE + */ + get maxRows() { + if (this.args.passThru && this.args.passThru.maxRows) { + return this.args.passThru.maxRows; + } + + return Number.MAX_VALUE; + } + + /** + * calculates and grows a text area based on line-height. + */ growTextarea() { - if (this.textarea) { - let inputElement = this.element.querySelector('input, textarea'); + if (this.args.textarea) { + let inputElement = this.#inputElement; + inputElement.classList.add('md-no-flex'); - inputElement.setAttribute('rows', 1); + inputElement.setAttribute('rows', '1'); - let minRows = this.get('passThru.rows'); + let minRows = this.minRows; let height = this.getHeight(inputElement); if (minRows) { - if (!this.lineHeight) { - inputElement.style.minHeight = 0; - this.lineHeight = inputElement.clientHeight; + let lineHeight = this.lineHeight; + if (!lineHeight) { + inputElement.style.minHeight = '0'; + lineHeight = this.#inputElement.clientHeight; inputElement.style.minHeight = null; } - if (this.lineHeight) { - height = Math.max(height, this.lineHeight * minRows); + if (lineHeight) { + height = Math.max(height, lineHeight * minRows); } - let proposedHeight = Math.round(height / this.lineHeight); - let maxRows = this.get('passThru.maxRows') || Number.MAX_VALUE; - let rowsToSet = Math.min(proposedHeight, maxRows); + let proposedHeight = Math.round(height / lineHeight); + let maxRows = this.maxRows; + let rowsToSet = Math.min(proposedHeight, maxRows).toString(); - inputElement.style.height = `${this.lineHeight * rowsToSet}px`; + inputElement.style.height = `${lineHeight * rowsToSet}px`; inputElement.setAttribute('rows', rowsToSet); if (proposedHeight >= maxRows) { @@ -152,6 +336,8 @@ export default Component.extend(FocusableMixin, ChildMixin, ValidationMixin, { } else { inputElement.classList.remove('md-textarea-scrollable'); } + + this.lineHeight = lineHeight; } else { inputElement.style.height = 'auto'; inputElement.scrollTop = 0; @@ -163,51 +349,72 @@ export default Component.extend(FocusableMixin, ChildMixin, ValidationMixin, { inputElement.classList.remove('md-no-flex'); } - }, + } + /** + * returns the input elements current height. + * + * @param {HTMLInputElement} inputElement + * @returns {number} + */ getHeight(inputElement) { let { offsetHeight } = inputElement; let line = inputElement.scrollHeight - offsetHeight; return offsetHeight + (line > 0 ? line : 0); - }, + } + /** + * pushes the given value into the input field. + * + * @param {*} value + */ setValue(value) { // normalize falsy values to empty string value = isEmpty(value) ? '' : value; - if (this.element.querySelector('input, textarea').value !== value) { - this.element.querySelector('input, textarea').value = value; + if (this.#inputElement.value !== value) { + this.#inputElement.value = value; } - }, - - actions: { - handleInput(e) { - invokeAction(this, 'onChange', e.target.value); - // setValue below ensures that the input value is the same as this.value - next(() => { - if (this.isDestroyed) { - return; - } - this.setValue(this.value); - }); - this.growTextarea(); - let inputElement = this.element.querySelector('input'); - let isNativeInvalid = - inputElement && inputElement.validity && inputElement.validity.badInput; - if (this.type === 'date' && e.target.value === '') { - // Chrome doesn't fire the onInput event when clearing the second and third date components. - // This means that we won't see another event when badInput becomes false if the user is clearing - // the date field. The reported value is empty, though, so we can already mark it as valid. - isNativeInvalid = false; - } - this.set('isNativeInvalid', isNativeInvalid); - this.notifyValidityChange(); - }, - - handleBlur(e) { - invokeAction(this, 'onBlur', e); - this.set('isTouched', true); - this.notifyValidityChange(); - }, - }, -}); + + // Calculate Input Validity + this.validation.value = value; + this.validation.validate(this.args); + this.validation.notifyOnChange(); + } + + /** + * handleInput is called when input is received. + * Calls onChange if supplied. + * + * @param {Event} e - the input event. + */ + @action handleInput(e) { + if (this.args.onChange) { + this.args.onChange(e.target.value); + } + + if (this.isDestroyed) { + return; + } + + // setValue below ensures that the input value is the same as this.value + this.setValue(this.value); + this.growTextarea(); + } + + /** + * handleBlur is called when the input element has lost focus. + * Calls onBlur if supplied. + * + * @param {Event} e - the input event. + */ + @action handleBlur(e) { + if (this.args.onBlur) { + this.args.onBlur(e); + } + + this.validation.isTouched = true; + this.validation.validate(this.args); + this.validation.notifyOnValidityChange(); + } +} From c1281160a6ad454e3edd0e20e0625ce7e5a52a38 Mon Sep 17 00:00:00 2001 From: Matthew Hartstonge Date: Tue, 26 Nov 2024 18:38:05 +1300 Subject: [PATCH 4/4] ci(addon/components/paper-input): disable lints on `no-down-event-binding` and `no-positive-tabindex` due to being user passthroughs. --- addon/components/paper-input.hbs | 1 + 1 file changed, 1 insertion(+) diff --git a/addon/components/paper-input.hbs b/addon/components/paper-input.hbs index 9cdca8e0f..ac21aa579 100644 --- a/addon/components/paper-input.hbs +++ b/addon/components/paper-input.hbs @@ -1,3 +1,4 @@ +{{! template-lint-disable no-down-event-binding no-positive-tabindex }}