Reference Source

coral-spectrum/coral-component-numberinput/src/scripts/NumberInput.js

/**
 * Copyright 2019 Adobe. All rights reserved.
 * This file is licensed to you 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 REPRESENTATIONS
 * OF ANY KIND, either express or implied. See the License for the specific language
 * governing permissions and limitations under the License.
 */

import {BaseComponent} from '../../../coral-base-component';
import {BaseFormField} from '../../../coral-base-formfield';
import '../../../coral-component-button';
import '../../../coral-component-textfield';
import {Icon} from '../../../coral-component-icon';
import base from '../templates/base';
import {transform, commons, i18n} from '../../../coral-utils';
import {Decorator} from '../../../coral-decorator';

const CLASSNAME = '_coral-Stepper';
let clearLiveRegionTimeout;
const LIVEREGION_TIMEOUT_DELAY = 3000;
const MSPOINTER_TYPE_MOUSE = 0x00000004;
let flagTouchStart = false;
let flagStepButtonClick = false;

const exponentialToDecimalString = (value) => {
  const notation = value.toString();
  if (notation.indexOf('e') !== -1) {
    const negative = notation.indexOf('-') === 0;
    const exponent = parseInt(notation.split('-')[negative ? 2 : 1], 10);
    return value.toFixed(exponent).toString();
  }

  return value.toString();
};

const handleDecimalOperation = (operator, value1, value2) => {
  let result;

  const operation = (operator, value1, value2) => {
    if (operator === '+') {
      return value1 + value2;
    } else if (operator === '-') {
      return value1 - value2;
    } else if (operator === '%') {
      return value1 % value2;
    }
  };

  // Check if we have decimals
  if (value1 % 1 !== 0 || value2 % 1 !== 0) {
    const value1Decimal = exponentialToDecimalString(value1).split('.');
    const value2Decimal = exponentialToDecimalString(value2).split('.');
    const value1DecimalLength = value1Decimal[1] && value1Decimal[1].length || 0;
    const value2DecimalLength = value2Decimal[1] && value2Decimal[1].length || 0;
    const multiplier = Math.pow(10, Math.max(value1DecimalLength, value2DecimalLength));

    // Transform the decimals to integers based on the multiplier
    value1 = Math.round(value1 * multiplier);
    value2 = Math.round(value2 * multiplier);

    // Perform the operation on integers values to make sure we don't get a fancy decimal value
    result = operation(operator, value1, value2);

    // Transform the integer result back to decimal
    result /= multiplier;
  } else {
    result = operation(operator, value1, value2);
  }

  return result;
};

/**
 @class Coral.NumberInput
 @classdesc A NumberInput component is a numeric control form field. It follows the ARIA specification for spinbutton.
 This means the following keys are valid for interacting with it: <code>up</code>, <code>down</code>, <code>left</code>,
 <code>right</code>, <code>pageup</code>, <code>pagedown</code>, <code>home</code>, <code>end</code> and the Mouse Wheel.
 @htmltag coral-numberinput
 @extends {HTMLElement}
 @extends {BaseComponent}
 @extends {BaseFormField}
 */
const NumberInput = Decorator(class extends BaseFormField(BaseComponent(HTMLElement)) {
  /** @ignore */
  constructor() {
    super();

    this._delegateEvents(commons.extend(this._events, {
      'key:up': '_onKeyUp',
      'key:pageup': '_onKeyUp',
      'key:down': '_onKeyDown',
      'key:pagedown': '_onKeyDown',
      'key:home': '_onKeyHome',
      'key:end': '_onKeyEnd',
      'touchstart [handle=stepUp], [handle=stepDown]': '_onTouchStart',
      'pointerdown [handle=stepUp], [handle=stepDown]': '_onTouchStart',
      'MSPointerDown [handle=stepUp], [handle=stepUp]': '_onTouchStart',
      'MSPointerDown [handle=stepDown], [handle=stepDown]': '_onTouchStart',

      'click [handle=stepUp]': '_onStepUpButtonClick',
      'click [handle=stepDown]': '_onStepDownButtonClick',

      'mousewheel [handle="input"]': '_onInputMouseWheel',
      'DOMMouseScroll [handle="input"]': '_onInputMouseWheel',

      'capture:focus': '_onFocus',
      'capture:blur': '_onBlur'
    }));

    // Prepare templates
    this._elements = {};
    base.call(this._elements, {i18n, commons, Icon});

    // Pre-define labellable element
    this._labellableElement = this._elements.input;

    // Default is null
    this._min = this._max = null;
  }

  /**
   This field's current value.
   @type {String}
   @default ""
   @htmlattribute value
   */
  get value() {
    return this._elements.input.value;
  }

  set value(value) {
    value = isNaN(value) ? '' : String(value);

    // sets the value immediately so it is picked up in form submits
    this._elements.input.value = value;

    // in order to keep the reset value in sync, we need to handle the "value" attribute of the inner input
    const valueAttribute = this.getAttribute('value');
    this._elements.input[valueAttribute ? 'setAttribute' : 'removeAttribute']('value', valueAttribute);

    // @a11y: aria-valuetext is used so that VoiceOver does not announce a percentage
    if (this.value) {
      this._elements.input.setAttribute('aria-valuenow', this.value);
      this._elements.input.setAttribute('aria-valuetext', this.value);
    } else {
      this._elements.input.removeAttribute('aria-valuenow');
      this._elements.input.removeAttribute('aria-valuetext');
    }

    // If the event triggering a value change is a click on a +/- button,
    // announce the new value using the live region.
    if (flagStepButtonClick || !!window.chrome) {
      this._updateLiveRegion(this.value);
      // Otherwise, clear the live region.
    } else {
      this._updateLiveRegion();
    }

    flagStepButtonClick = false;

    this.invalid = this.hasAttribute('invalid');
    this.disabled = this.hasAttribute('disabled');
  }

  /**
   The value returned as a Number. Value is <code>NaN</code> if conversion to Number is not possible.

   @type {Number}
   @default NaN
   */
  get valueAsNumber() {
    let valueAsNumber = this._valueAsNumber;

    if (typeof valueAsNumber !== 'undefined' && valueAsNumber !== null) {
      return valueAsNumber;
    }

    valueAsNumber = transform.number(this.value);
    if (valueAsNumber !== null) {
      return valueAsNumber;
    }

    return NaN;
  }

  set valueAsNumber(value) {
    this._valueAsNumber = transform.number(value);

    this.value = this._valueAsNumber;
    this.invalid = this.hasAttribute('invalid');
    this.disabled = this.hasAttribute('disabled');
  }

  /**
   The minimum value for the NumberInput. If a value below the minimum is set, the NumberInput will be marked as
   invalid but the value will be preserved. Stepping down the NumberInput via {@link Coral.NumberInput#stepDown}
   or the decrement button respects the minimum value. It reflects the <code>min</code> attribute to the DOM.

   @type {?Number}
   @default null
   @htmlattribute min
   @htmlattributereflected
   */
  get min() {
    return this._min;
  }

  set min(value) {
    value = transform.number(value);

    this._min = isNaN(value) ? null : value;

    if (this._min === null) {
      this._reflectAttribute('min', false);

      this._elements.input.removeAttribute('aria-valuemin');
      this._elements.input.removeAttribute('min');
    } else {
      this._reflectAttribute('min', this._min);
      // sets the min in the input so that keyboard handles this component
      this._elements.input.setAttribute('aria-valuemin', this._min);
      this._elements.input.min = this._min;
    }

    this.invalid = this.hasAttribute('invalid');
    this.disabled = this.hasAttribute('disabled');
  }

  /**
   The maximum value for the NumberInput. If a value above the maximum is set, the NumberInput will be marked as
   invalid but the value will be preserved. Stepping up the NumberInput via {@link Coral.NumberInput#stepUp} or
   the increment button respects the maximum value. It reflects the <code>max</code> attribute to the DOM.

   @type {?Number}
   @default null
   @htmlattribute max
   @htmlattributereflected
   */
  get max() {
    return this._max;
  }

  set max(value) {
    value = transform.number(value);

    this._max = isNaN(value) ? null : value;

    if (this.max === null) {
      this._reflectAttribute('max', false);

      this._elements.input.removeAttribute('aria-valuemax');
      this._elements.input.removeAttribute('max');
    } else {
      this._reflectAttribute('max', this._max);
      // sets the max in the input so that keyboard handles this component
      this._elements.input.setAttribute('aria-valuemax', this._max);
      this._elements.input.max = this._max;
    }

    this.invalid = this.hasAttribute('invalid');
    this.disabled = this.hasAttribute('disabled');
  }

  /**
   The amount to increment by when stepping up or down. It can be the string <code>any</code> or any positive
   floating point number. If this is not set to <code>any<code>, the control accepts only values at multiples of
   the step value greater than the minimum.

   @type {Number|String}
   @default 1
   @htmlattribute step
   @htmlattributereflected
   */
  get step() {
    return this._step || 1;
  }

  set step(value) {
    if (value !== null && (value > 0 || value === 'any')) {
      this._step = value === 'any' ? value : transform.number(value);
      this._reflectAttribute('step', this._step);

      this._elements.input.step = this._step;
    }
  }

  /**
   Name used to submit the data in a form.
   @type {String}
   @default ""
   @htmlattribute name
   @htmlattributereflected
   */
  get name() {
    return this._elements.input.name;
  }

  set name(value) {
    this._reflectAttribute('name', value);

    this._elements.input.name = value;
  }

  /**
   Whether this field is disabled or not.
   @type {Boolean}
   @default false
   @htmlattribute disabled
   @htmlattributereflected
   */
  get disabled() {
    return this._disabled || false;
  }

  set disabled(value) {
    this._disabled = transform.booleanAttr(value);
    this._reflectAttribute('disabled', this._disabled);

    this[this._disabled ? 'setAttribute' : 'removeAttribute']('aria-disabled', this._disabled);
    this.classList.toggle('is-disabled', this._disabled);
    this._elements.input.disabled = this._disabled;

    this._setButtonState();
  }

  /**
   Whether this field is required or not.
   @type {Boolean}
   @default false
   @htmlattribute required
   @htmlattributereflected
   */
  get required() {
    return this._required || false;
  }

  set required(value) {
    this._required = transform.booleanAttr(value);
    this._reflectAttribute('required', this._required);

    this._elements.input.required = this._required;
  }

  /**
   Whether this field is readOnly or not. Indicating that the user cannot modify the value of the control.
   @type {Boolean}
   @default false
   @htmlattribute readonly
   @htmlattributereflected
   */
  get readOnly() {
    return this._readOnly || false;
  }

  set readOnly(value) {
    this._readOnly = transform.booleanAttr(value);
    this._reflectAttribute('readonly', this._readOnly);

    this._elements.input.readOnly = this._readOnly;
    this._setButtonState();
  }

  /**
   Inherited from {@link BaseFormField#invalid}.
   */
  get invalid() {
    return super.invalid;
  }

  set invalid(value) {
    super.invalid = value;
    this._elements.input.invalid = this._invalid;
  }

  /**
   Inherited from {@link BaseFormField#labelledBy}.
   */
  get labelledBy() {
    return super.labelledBy;
  }

  set labelledBy(value) {
    super.labelledBy = value;
    // in case the user focuses the buttons, he will still get a notion of the usage of the component
    this[this.labelledBy ? 'setAttribute' : 'removeAttribute']('aria-labelledby', this.labelledBy);
  }

  /**
   Short hint that describes the expected value of the NumberInput. It is displayed when the NumberInput is empty.

   @type {String}
   @default ""
   @htmlattribute placeholder
   @htmlattributereflected
   */
  get placeholder() {
    return this._elements.input.placeholder || '';
  }

  set placeholder(value) {
    value = transform.string(value);
    this._reflectAttribute('placeholder', value);

    this._elements.input.placeholder = value;
  }

  // overrides the behavior from BaseFormField
  reset() {
    // since there is an internal value, this one handles the reset
    this._elements.input.reset();
  }

  // overrides the behavior from BaseFormField
  clear() {
    // since there is an internal value, this one handles the clear
    this._elements.input.clear();
  }

  /**
   Increments the value by <code>step</code>. If the current value is <code>null</code> or <code>''</code>, it is
   considered as 0. The new value will always respect the <code>min</code> and <code>max</code> values if available.
   */
  stepUp() {
    // uses the Number representation since it simplifies the calculations
    const value = this.valueAsNumber;
    const step = this._getActualStep();
    if (isNaN(value)) {
      this.value = this.max !== null ? Math.min(step, this.max) : step;
    } else {
      const newValue = handleDecimalOperation('+', value, step);

      this.value = this.max !== null ? Math.min(newValue, this.max) : newValue;
    }
  }

  /**
   Decrements the value by <code>step</code>. If the current value is <code>null</code> or <code>''</code>, it is
   considered as 0. The new value will always respect the <code>min</code> and <code>max</code> values if available.
   */
  stepDown() {
    // uses the Number representation since it simplifies the calculations
    const value = this.valueAsNumber;
    const step = this._getActualStep();
    if (isNaN(value)) {
      this.value = this.min !== null ? Math.max(-step, this.min) : -step;
    } else {
      const newValue = handleDecimalOperation('-', value, step);

      this.value = this.min !== null ? Math.max(newValue, this.min) : newValue;
    }
  }

  /**
   If the value is 'any' there is no allowed step, that means we incremenet with the default (as if the step was not
   defined).

   @returns {Number} the valid step according to the specs.

   @ignore
   */
  _getActualStep() {
    return this.step === 'any' ? 1 : this.step;
  }

  /**
   Checks if the current NumberInput is valid or not. This is done by checking that the current value is between the
   provided <code>min</code> and <code>max</code> values. This check is only performed on user interaction.

   @ignore
   */
  _validateInputValue() {
    this.invalid = this.value !== '' && (isNaN(Number(this.value)) ||
      (this.max !== null && this.value > this.max || this.min !== null && this.value < this.min) ||
      this.step !== 'any' && handleDecimalOperation('%', Number(this.value), this._getActualStep()) !== 0);
  }

  /**
   Sets the correct state of the buttons based on <code>disabled</code>, <code>min</code>, <code>max</code> and
   <code>readOnly</code> properties.

   @ignore
   */
  _setButtonState() {
    this._elements.stepUp.disabled = this.disabled || this.max !== null && this.value >= this.max || this.readOnly;
    this._elements.stepDown.disabled = this.disabled || this.min !== null && this.value <= this.min || this.readOnly;
  }

  /**
   Triggers a change event. This is only done if the provided values are different.

   @param {String} newValue
   The new value of the component.
   @param {String} oldValue
   The old value of the component.

   @private
   */
  _triggerChange(newValue, oldValue) {
    // if the underlaying value stayed the same, there no need to trigger an event
    if (newValue !== oldValue) {
      this.trigger('change');
    }
  }

  /**
   Flags a touchstart or pointer event so that we can determine if an event originates from a touch screen interaction
   or from a mouse interaction. An event originating from a mouse interaction should shift the focus to the input,
   while an event originating from a touch interaction should not change the focus. On a touch screen, if the user
   presses the increment or decrement button, focus should not shift to the input and open the software keyboard.

   @ignore
   */
  _onTouchStart(event) {
    if (event.type === 'touchstart' || event.pointerType !== 'mouse' && event.pointerType !== MSPOINTER_TYPE_MOUSE) {
      flagTouchStart = true;
    }
  }

  /**
   Per WAI-ARIA spinbutton design pattern, http://www.w3.org/TR/wai-aria-practices/#spinbutton, shift focus to the
   input if it does not currently have focus. We make an exception for touch devices, because a better user
   experience is for the focus to remain on an increment or decrement button without shifting focus and opening the
   soft keyboard.

   @ignore
   */
  _setFocusToInput() {
    if (!flagTouchStart && document.activeElement !== this._elements.input) {
      this._elements.input.focus();
    }
    flagTouchStart = false;
  }

  /**
   Handles the click on the step up button. It causes the NumberInput to step up its value and returns the focus back
   to the input. This way the clicked button does not get focus.

   @emits {change}
   @ignore
   */
  _onStepUpButtonClick(event) {
    event.preventDefault();

    // stores the old value before stepup
    const oldValue = this.value;

    flagStepButtonClick = event.type === 'click';

    this._setFocusToInput();

    this.stepUp();

    // we only do this on user interaction
    this._validateInputValue();

    // checks if we need to trigger a change event
    this._triggerChange(this.value, oldValue);
  }

  /**
   Handles the click on the step down button. It causes the NumberInput to step down its value and returns the focus
   back to the input. This way the clicked button does not get focus.

   @emits {change}
   @ignore
   */
  _onStepDownButtonClick(event) {
    event.preventDefault();

    // stores the old value before stepdown
    const oldValue = this.value;

    flagStepButtonClick = event.type === 'click';

    this._setFocusToInput();

    this.stepDown();

    // we only do this on user interaction
    this._validateInputValue();

    // checks if we need to trigger a change event
    this._triggerChange(this.value, oldValue);
  }

  /**
   Handles the home key press. If a max has been set, the value will be modified to match it, otherwise the key is
   ignored.

   @ignore
   */
  _onKeyHome(event) {
    event.preventDefault();

    // stops interaction if the numberinput is disabled or readonly
    if (this.disabled || this.readOnly) {
      return;
    }

    // sets the max value only if it exists
    if (this.max !== null) {
      // stores the old value before setting the max
      const oldValue = this.value;

      // When appropriate flagStepButtonClick will trigger a live region update.
      flagStepButtonClick = true;

      this.value = this.max;

      // checks if we need to trigger a change event
      this._triggerChange(this.value, oldValue);
    }

    this._setFocusToInput();
  }

  /**
   Handles the end key press. If a min has been set, the value will be modified to match it, otherwise the key is
   ignored.

   @ignore
   */
  _onKeyEnd(event) {
    event.preventDefault();

    // stops interaction if the numberinput is disabled or readonly
    if (this.disabled || this.readOnly) {
      return;
    }

    // sets the min value only if it exists
    if (this.min !== null) {
      // stores the old value before setting the min
      const oldValue = this.value;

      // When appropriate, flagStepButtonClick will trigger a live region update.
      flagStepButtonClick = true;

      this.value = this.min;

      // checks if we need to trigger a change event
      this._triggerChange(this.value, oldValue);
    }

    this._setFocusToInput();
  }

  /**
   Handles the up action by steping up the NumberInput. It prevents the default action.

   @ignore
   */
  _onKeyUp(event) {
    event.preventDefault();

    // stops interaction if the numberinput is disabled or readonly
    if (this.disabled || this.readOnly) {
      return;
    }

    this._onStepUpButtonClick(event);
  }

  /**
   Handles the down action by steping down the NumberInput. It prevents the default action.

   @ignore
   */
  _onKeyDown(event) {
    event.preventDefault();

    // stops interaction if the numberinput is disabled or readonly
    if (this.disabled || this.readOnly) {
      return;
    }

    this._onStepDownButtonClick(event);
  }

  /**
   Handles the Mousewheel to increment/decrement values.

   @ignore
   */
  _onInputMouseWheel(event) {
    // stops interaction if the numberinput is disabled or readonly or is not focused (this is the case where its hovered but not focused)
    if (this.disabled || this.readOnly || this._elements.input !== document.activeElement) {
      return;
    }

    // else we prevent the default event like user scrolling the page and handle the mouse wheel input
    event.preventDefault();

    // stores the old value to calculate the change
    const oldValue = this.value;

    const delta = Math.max(-1, Math.min(1, event.wheelDelta || -event.detail || event.deltaY));
    if (delta < 0) {
      this.stepDown();
    } else {
      this.stepUp();
    }

    // checks if we need to trigger a change event
    this._triggerChange(this.value, oldValue);
  }

  /**
   Overrides the method from formField to be able to add validation after the user has changed the value.

   @private
   */
  _onInputChange(event) {
    // stops the current event
    event.stopPropagation();

    // we only do this on user interaction
    this._validateInputValue();

    // we force the sync of the value,invalid and disabled properties
    this.value = this.value;
    this.invalid = this.invalid;
    this.disabled = this.disabled;

    // we always trigger a change since it came from user interaction
    this.trigger('change');
  }

  /**
   Handles focus event.

   @ignore
   */
  _onFocus() {
    this.classList.add('is-focused');
    this._elements.input.classList.add('is-focused');
    this._elements.liveregion.removeAttribute('role');
    this._elements.liveregion.removeAttribute('aria-hidden');
  }

  /**
   Handles blur event.

   @ignore
   */
  _onBlur() {
    this.classList.remove('is-focused');
    this._elements.input.classList.remove('is-focused');

    // clear liveregion
    this._elements.liveregion.setAttribute('role', 'presentation');
    this._elements.liveregion.setAttribute('aria-hidden', true);
    this._clearLiveRegion();
  }

  /** @ignore */
  _clearLiveRegion() {
    const liveregion = this._elements.liveregion;
    if (liveregion.firstChild) {
      liveregion.removeChild(liveregion.firstChild);
    }
  }

  /** @ignore */
  _updateLiveRegion(value) {
    let textNode;

    clearTimeout(clearLiveRegionTimeout);
    this._clearLiveRegion();

    if (value && value !== '') {
      textNode = document.createTextNode(value);
      window.requestAnimationFrame(() => {
        this._elements.liveregion.appendChild(textNode);

        clearLiveRegionTimeout = window.setTimeout(() => {
          this._clearLiveRegion();
        }, LIVEREGION_TIMEOUT_DELAY);
      });
    }
  }

  /** @ignore */
  static get observedAttributes() {
    return super.observedAttributes.concat([
      'min',
      'max',
      'step',
      'placeholder'
    ]);
  }

  /** @ignore */
  render() {
    super.render();

    this.classList.add(CLASSNAME);

    // Default reflected attributes
    if (!this._step) {
      this.step = 1;
    }

    // a11y
    this.setAttribute('role', 'group');

    if (this._elements.input.type === 'text') {
      this._elements.input.setAttribute('role', 'spinbutton');
    }

    const frag = document.createDocumentFragment();

    const templateHandleNames = ['presentation', 'input'];

    // Render main template
    frag.appendChild(this._elements.input);
    frag.appendChild(this._elements.presentation);

    while (this.firstChild) {
      const child = this.firstChild;
      if (child.nodeType === Node.TEXT_NODE ||
        child.nodeType === Node.ELEMENT_NODE && templateHandleNames.indexOf(child.getAttribute('handle')) === -1) {
        // Add non-template elements to the frag
        frag.appendChild(child);
      } else {
        // Remove anything else
        this.removeChild(child);
      }
    }

    this.appendChild(frag);
  }
});

export default NumberInput;