UNPKG

@vaadin/number-field

Version:
537 lines (475 loc) 15.2 kB
/** * @license * Copyright (c) 2021 - 2026 Vaadin Ltd. * This program is available under Apache License Version 2.0, available at https://vaadin.com/license/ */ import { getDeepActiveElement } from '@vaadin/a11y-base/src/focus-utils.js'; import { TooltipController } from '@vaadin/component-base/src/tooltip-controller.js'; import { InputController } from '@vaadin/field-base/src/input-controller.js'; import { InputFieldMixin } from '@vaadin/field-base/src/input-field-mixin.js'; import { LabelledInputController } from '@vaadin/field-base/src/labelled-input-controller.js'; // [type=number] value returns an empty string for invalid numbers, // while valueAsNumber returns NaN for empty strings, which makes // invalid and empty values indistinguishable. It's only possible // to detect unparsable input by checking the validity.badInput // boolean property. This string is used in _inputElementValue // as a marker to help Flow detect and clear unparsable values // through that property. const BAD_INPUT_STRING = 'NaN'; /** * A mixin providing common number field functionality. * * @polymerMixin * @mixes InputFieldMixin */ export const NumberFieldMixin = (superClass) => class NumberFieldMixinClass extends InputFieldMixin(superClass) { static get properties() { return { /** * The minimum value of the field. */ min: { type: Number, }, /** * The maximum value of the field. */ max: { type: Number, }, /** * Specifies the allowed number intervals of the field. */ step: { type: Number, }, /** * Set to true to show increase/decrease buttons. * @attr {boolean} step-buttons-visible */ stepButtonsVisible: { type: Boolean, value: false, reflectToAttribute: true, }, }; } static get observers() { return ['_stepChanged(step, inputElement)']; } static get delegateProps() { return [...super.delegateProps, 'min', 'max']; } static get constraints() { return [...super.constraints, 'min', 'max', 'step']; } constructor() { super(); this._setType('number'); this.__onWheel = this.__onWheel.bind(this); } /** @protected */ get slotStyles() { const tag = this.localName; return [ ` ${tag} input[type="number"]::-webkit-outer-spin-button, ${tag} input[type="number"]::-webkit-inner-spin-button { appearance: none; margin: 0; } ${tag} input[type="number"] { appearance: textfield; } ${tag}[dir='rtl'] input[type="number"]::placeholder { direction: rtl; } ${tag}[dir='rtl']:not([step-buttons-visible]) input[type="number"]::placeholder { text-align: left; } `, ]; } /** * Used by `InputControlMixin` as a reference to the clear button element. * @protected */ get clearElement() { return this.$.clearButton; } /** * Whether the input element's value is unparsable. * * @private */ get __hasUnparsableValue() { return this._inputElementValue === BAD_INPUT_STRING; } /** @protected */ ready() { super.ready(); this.addController( new InputController(this, (input) => { this._setInputElement(input); this._setFocusElement(input); this.stateTarget = input; this.ariaTarget = input; }), ); this.addController(new LabelledInputController(this.inputElement, this._labelController)); this._tooltipController = new TooltipController(this); this.addController(this._tooltipController); this._tooltipController.setPosition('top'); this._tooltipController.setAriaTarget(this.inputElement); } /** * Override the method from `InputConstraintsMixin` * to enforce HTML constraint validation even if * the user didn't add any constraints explicitly: * the field has to be regardless checked for bad input. * * @override */ checkValidity() { if (this.inputElement) { return this.inputElement.checkValidity(); } return !this.invalid; } /** * Override the method from `InputMixin` to add * a wheel event listener to the input element. * * @param {HTMLElement} input * @override * @protected */ _addInputListeners(input) { super._addInputListeners(input); input.addEventListener('wheel', this.__onWheel); } /** * Override the method from `InputMixin` to remove * the wheel event listener from the input element. * * @param {HTMLElement} input * @override * @protected */ _removeInputListeners(input) { super._removeInputListeners(input); input.removeEventListener('wheel', this.__onWheel); } /** * Prevents default browser behavior for wheel events on the input element * when it's focused. More precisely, this prevents the browser from attempting * to increment or decrement the value when the mouse wheel is used within * the input element. * * CAVEAT: As a side-effect, this also prevents page scrolling when * the pointer is positioned over the field and the field is focused. * * @param {WheelEvent} event * @private */ __onWheel(event) { if (this.hasAttribute('focused')) { event.preventDefault(); } } /** @protected */ _onDecreaseButtonTouchend(e) { // Cancel the following click and focus events. If the event is not cancelable, // it means scrolling is in progress, therefore we shouldn't update field value. if (e.cancelable) { e.preventDefault(); this.__blurActiveElement(); this._decreaseValue(); } } /** @protected */ _onIncreaseButtonTouchend(e) { // Cancel the following click and focus events. If the event is not cancelable, // it means scrolling is in progress, therefore we shouldn't update field value. if (e.cancelable) { e.preventDefault(); this.__blurActiveElement(); this._increaseValue(); } } /** @private */ __blurActiveElement() { // If another element is focused, blur it on step button touch to hide // the mobile keyboard that might still be open for the other element. // See https://github.com/vaadin/web-components/issues/7494 const activeElement = getDeepActiveElement(); if (activeElement && activeElement !== this.inputElement) { activeElement.blur(); } } /** @protected */ _onDecreaseButtonClick() { this._decreaseValue(); } /** @protected */ _onIncreaseButtonClick() { this._increaseValue(); } /** @private */ _decreaseValue() { this._incrementValue(-1); } /** @private */ _increaseValue() { this._incrementValue(1); } /** @private */ _incrementValue(incr) { if (this.disabled || this.readonly) { return; } const step = this.step || 1; let value = parseFloat(this.value); if (!this.value) { if ((this.min === 0 && incr < 0) || (this.max === 0 && incr > 0) || (this.max === 0 && this.min === 0)) { incr = 0; value = 0; } else if ((this.max == null || this.max >= 0) && (this.min == null || this.min <= 0)) { value = 0; } else if (this.min > 0) { value = this.min; if (this.max < 0 && incr < 0) { value = this.max; } incr = 0; } else if (this.max < 0) { value = this.max; if (incr < 0) { incr = 0; } else if (this._getIncrement(1, value - step) > this.max) { value -= 2 * step; // FIXME(yuriy): find a proper solution to make correct step back } else { value -= step; } } } else if (value < this.min) { incr = 0; value = this.min; } else if (value > this.max) { incr = 0; value = this.max; } const newValue = this._getIncrement(incr, value); if (!this.value || incr === 0 || this._incrementIsInsideTheLimits(incr, value)) { this.inputElement.value = String(parseFloat(newValue)); this.inputElement.dispatchEvent(new Event('input', { bubbles: true, composed: true })); this.__commitValueChange(); } } /** @private */ _getIncrement(incr, currentValue) { let step = this.step || 1, min = this.min || 0; // To avoid problems with decimal math, multiplying to operate with integers. const multiplier = Math.max( this._getMultiplier(currentValue), this._getMultiplier(step), this._getMultiplier(min), ); step *= multiplier; currentValue = Math.round(currentValue * multiplier); min *= multiplier; const margin = (currentValue - min) % step; if (incr > 0) { return (currentValue - margin + step) / multiplier; } else if (incr < 0) { return (currentValue - (margin || step)) / multiplier; } return currentValue / multiplier; } /** @private */ _getDecimalCount(number) { const s = String(number); const i = s.indexOf('.'); return i === -1 ? 1 : s.length - i - 1; } /** @private */ _getMultiplier(number) { if (!isNaN(number)) { return 10 ** this._getDecimalCount(number); } } /** @private */ _incrementIsInsideTheLimits(incr, value) { if (incr < 0) { return this.min == null || this._getIncrement(incr, value) >= this.min; } else if (incr > 0) { return this.max == null || this._getIncrement(incr, value) <= this.max; } return this._getIncrement(incr, value) <= this.max && this._getIncrement(incr, value) >= this.min; } /** @protected */ _isButtonEnabled(sign) { const incr = sign * (this.step || 1); const value = parseFloat(this.value); return !this.value || (!this.disabled && this._incrementIsInsideTheLimits(incr, value)); } /** * @param {number} step * @param {HTMLElement | undefined} inputElement * @protected */ _stepChanged(step, inputElement) { if (inputElement) { inputElement.step = step || 'any'; } } /** * @param {unknown} newVal * @param {unknown} oldVal * @protected * @override */ _valueChanged(newVal, oldVal) { // Validate value to be numeric if (newVal && isNaN(parseFloat(newVal))) { this.value = ''; } else if (typeof this.value !== 'string') { this.value = String(this.value); } super._valueChanged(this.value, oldVal); if (!this.__keepCommittedValue) { this.__committedValue = this.value; this.__committedUnparsableValueStatus = false; } } /** * Override an event listener from `InputControlMixin` * to avoid adding a separate listener. * @param {!KeyboardEvent} event * @protected * @override */ _onKeyDown(event) { if (event.key === 'ArrowUp') { event.preventDefault(); this._increaseValue(); } else if (event.key === 'ArrowDown') { event.preventDefault(); this._decreaseValue(); } super._onKeyDown(event); } /** * Override this method from `InputMixin` to prevent * the value change caused by user input from being treated * as initiated programmatically by the developer and therefore * from getting silently committed by the value observer without * any change event. The value change will be committed later * on blur or Enter. * * @param {InputEvent} event * @override * @protected */ _onInput(event) { this.__keepCommittedValue = true; super._onInput(event); this.__keepCommittedValue = false; } /** * Override this method from `InputControlMixin` * to stop propagation of the native change event. * * @param {Event} event * @override * @protected */ _onChange(event) { event.stopPropagation(); } /** * Override this method from `ClearButtonMixin` * to properly commit the empty value since * the change handler doesn't do that anymore. * * @param {MouseEvent} event * @override * @protected */ _onClearAction(event) { super._onClearAction(event); this.__commitValueChange(); } /** * Override this method from `FocusMixin` * to commit a possible pending value change on blur. * * @param {boolean} focused * @override * @protected */ _setFocused(focused) { super._setFocused(focused); if (!focused) { this.__commitValueChange(); } } /** * Override this method from `KeyboardMixin` * to commit a possible pending value change on Enter. * * @param {KeyboardEvent} event * @override * @protected */ _onEnter(event) { super._onEnter(event); this.__commitValueChange(); } /** * Depending on the nature of the value change that has occurred since * the last commit attempt, triggers validation and fires an event: * * Value change | Event * :------------------------|:------------------ * empty => parsable | change * empty => unparsable | unparsable-change * parsable => empty | change * parsable => parsable | change * parsable => unparsable | change * unparsable => empty | unparsable-change * unparsable => parsable | change * unparsable => unparsable | - * * Note, there is currently no way to detect unparsable => unparsable changes * because the browser doesn't provide access to unparsable values of native * [type=number] inputs. * * @private */ __commitValueChange() { if (this.__committedValue !== this.value) { this._requestValidation(); this.dispatchEvent(new CustomEvent('change', { bubbles: true })); } else if (this.__committedUnparsableValueStatus !== this.__hasUnparsableValue) { this._requestValidation(); this.dispatchEvent(new CustomEvent('unparsable-change')); } this.__committedValue = this.value; this.__committedUnparsableValueStatus = this.__hasUnparsableValue; } /** @override */ get _inputElementValue() { if (this.inputElement && this.inputElement.validity.badInput) { return BAD_INPUT_STRING; } return super._inputElementValue; } /** @override */ set _inputElementValue(value) { super._inputElementValue = value; } };