as-input.js

import {Component_A} from './basic/as-component.js';
import {FP_Input_A} from './fp-ext/fp-input-ext.js';
import {ErrorCode} from './../exception/exceptionDesc.js';

/**
 * @ignore
 */
const logModule = 'as-input';

/**
 * @typedef TComponents.InputProps
 * @prop {object} [options] Additional options for the input component.
 * Items in this object include:
 * - **responsive** (boolean, default: false): Whether the input should be responsive.
 * @prop {string} [tips] Tooltip text for the component.
 * @prop {Function} [onCreated] Function to be called when the component is created.
 * @prop {Function} [onMounted] Function to be called when the component is mounted.
 * @prop {string} [position] CSS position property.
 * @prop {number} [width] Width of the component.
 * @prop {number} [height] Height of the component.
 * @prop {number} [top] Top position of the component.
 * @prop {number} [left] Left position of the component.
 * @prop {number} [borderRadius] Border radius of the component.
 * @prop {number} [rotation] Rotation angle of the component.
 * @prop {number} [zIndex] Z-index of the component.
 * @prop {string} [border] Border style of the input field.
 * @prop {string} [color] Text color of the input field.
 * @prop {string} [backgroundColor] Background color of the input field.
 * @prop {string} [size] Size style template of the component.
 * @prop {object} [font] Font settings for the input text.
 * This object controls text appearance:
 * - **fontSize** (number, default: 12): Font size in pixels.
 * - **fontFamily** (string, default: 'Segoe UI'): Font family name.
 * @prop {boolean} [readOnly] Set to true to use the input field only for displaying values.
 * @prop {boolean} [useBorder] Whether to draw a border around the value.
 * @prop {object} [regex] Regular expression configuration for validation.
 * This object defines validation behavior:
 * - **label** (string, default: 'no regex'): Description of the regex rule.
 * - **value** (string): Regex pattern used to validate the input.
 * @prop {string} [text] Initial value of the input field.
 * @prop {object} [inputVar] Configuration object for input variable binding.
 * This object configures the bound input variable:
 * - **type** (string): Input variable type. Default is `Component_A.INPUTVAR_TYPE.ANY`.
 * - **func** (string): Binding behavior. Default is `Component_A.INPUTVAR_FUNC.CUSTOM`.
 * - **value** (string): Initial bound value.
 * - **isHidden** (boolean, default: false): Whether the variable is hidden.
 * @prop {Function} [onChange] Function to be called when the input value changes.
 * @prop {string} [keyboardHelperDesc] Description text shown in the keyboard helper.
 * @prop {string} [defaultState] Default state of the component.
 * @prop {string} [dataStruct] Data structure associated with the component.
 * @memberof TComponents
 */

/**
 * @description Input field
 * This class focuses on the specific properties of the Input component.
 * Since it inherits from Accessor_A, all basic properties (e.g., height, width) are available but documented in the Accessor_A part.
 * @class TComponents.Input
 * @extends TComponents.Component_A
 * @memberof TComponents
 * @param {HTMLElement} parent - DOM element in which this component is to be inserted
 * @param {TComponents.InputProps} [props] - Input field properties (InputProps)
 * @example
 * const inputField = new TComponents.Input(document.body, {
 *   position: 'absolute',
 *   zIndex: 1000,
 *   text: 'Input field'
 * });
 *
 * // Render the component.
 * inputField.render();
 */
export class Input extends Component_A {
  constructor(parent, props = {}) {
    super(parent, props);

    /**
     * @instance
     * @private
     * @type {TComponents.InputProps}
     */
    this._props;

    /*
     * If bound to web data, `this._bindData` will have the format: { type: 'webdata', key: 'xxx' }.
     * If bound to digital signal data, `this._bindData` will have the format: { type: 'digitalsignal', key: 'xxxx' }.
     * If bound to rapid data, `this._bindData` will have the format: { type: 'rapiddata', dataType: 'xxx', module: 'xxx', name: 'xxx', task: 'xxx' }.
     */
    this._bindData = null;
    this._inputField = new FP_Input_A();
  }

  /**
   * @description Returns the default values of class properties (excluding parent properties).
   * @member TComponents.Input#defaultProps
   * @method
   * @protected
   * @returns {TComponents.InputProps}
   */
  defaultProps() {
    return {
      options: {
        responsive: false,
      },
      tips: '',
      // life cycle
      onCreated: '',
      onMounted: '',
      onDispose: '',
      // basic properties: X/Y/W/H/B/R/Z
      position: 'static',
      width: 100,
      height: 32,
      top: 0,
      left: 0,
      borderRadius: 4,
      rotation: 0,
      zIndex: 0,
      // border
      border: '1px solid #dbdbdb',
      // background
      color: 'black',
      backgroundColor: 'rgba(255,255,255,1)',
      // size template
      size: '',
      // font
      font: {
        fontSize: 12,
        fontFamily: 'Segoe UI',
      },
      // Special properties.
      readOnly: false,
      useBorder: true,
      regex: {
        label: 'no regex',
        value: '',
      },
      // Input variable binding properties.
      text: 'Input here',
      inputVar: {
        type: Component_A.INPUTVAR_TYPE.ANY, // Component_A.INPUTVAR_TYPE
        func: Component_A.INPUTVAR_FUNC.CUSTOM, // Component_A.INPUTVAR_FUNC
        value: 'Input here', // string
        isHidden: false,
      },
      onChange: '',
      // Special properties.
      keyboardHelperDesc: 'No description',
      defaultState: 'show_enable',
      dataStruct: '',
    };
  }

  /**
   * @description Initializes the component and sets up click event handling.
   * @member TComponents.Input#onInit
   * @method
   * @async
   * @returns {Promise<void>}
   */
  async onInit() {}

  /**
   * @description there are something need to do after render once
   * @member TComponents.Input#afterRenderOnce
   * @method
   * @protected
   * @returns {void}
   */
  afterRenderOnce() {
    if (this._props.inputVar.func == 'sync') {
      this._bindData = Component_A.getBindData(this._props.inputVar.value, this);
    }
  }

  /**
   * @description Renders the component on the screen and applies the necessary styles and event listeners.
   * @member TComponents.Input#onRender
   * @method
   * @throws {Error} Throws an error if rendering fails.
   * @returns {void}
   */
  onRender() {
    try {
      this.removeAllEventListeners();

      this._inputField.attachToElement(this.find('.tc-input'));

      this._props.readOnly || (this._inputField.onchange = this._cbOnChange.bind(this));

      // Set input default style.
      this._inputField.border = this._props.border;

      const fpCoreEl = this.container.querySelector('.fp-components-input');
      if (Component_A._isHTMLElement(fpCoreEl)) {
        fpCoreEl.style.border = this._props.useBorder ? this._props.border : 'none';
        fpCoreEl.style.borderRadius = `${this._props.borderRadius}px`;
        fpCoreEl.style.backgroundColor = `${this._props.backgroundColor}`;
        fpCoreEl.style.color = `${this._props.color}`;

        // Set the behavior of the input component.
        if (this._props.readOnly) {
          fpCoreEl.onclick = null;
        }
      }

      const pElem = this.container.querySelector('p');
      if (Component_A._isHTMLElement(pElem)) {
        pElem.style.fontSize = `${this._props.font.fontSize}px`;
        pElem.style.fontFamily = `${this._props.font.fontFamily}`;
      }

      this._inputField.text = this._props.text;

      // Avoid new RegExp('')!
      if (typeof this._props.regex.value == 'string' && this._props.regex.value !== '')
        this._inputField.regex = new RegExp(this._props.regex.value);

      if (Object.prototype.toString.call(this._props.regex.value) === '[object RegExp]') {
        this._inputField.regex = this._props.regex.value;
      }

      this._inputField.label = Component_A.tParse(this._props.keyboardHelperDesc);
      this._addTips();
      Component_A.resolveBindingExpression(this._props.text, this, 'textX');
    } catch (e) {
      // Runtime errors: write specific content to the log, throw error code
      Logger.e(
        logModule,
        ErrorCode.FailedToRunOnRender,
        `Error happens on onRender of Input component ${this.compId}.`,
        e,
      );
      throw new Error(ErrorCode.FailedToRunOnRender, {cause: e});
    }
  }

  /**
   * @description Returns the HTML markup for the component.
   * @member TComponents.Input#markup
   * @method
   * @returns {string} The HTML markup for the component.
   */
  markup() {
    return /*html*/ `<div class="tc-input"></div>`;
  }

  /**
   * @description Callback function which is called when the button is pressed, it trigger any function registered with {@link onChange() onChange}
   * @member TComponents.Input~_cbOnChange
   * @method
   * @private
   * @param {any} value
   * @async
   * @returns {void}
   */
  async _cbOnChange(value) {
    const oldValue = this._props.text;
    try {
      await this.syncInputData(value);
      this.commitProps({text: value});
      this._inputField.text = value;
    } catch (e) {
      this._inputField.text = oldValue;
      Component_A.popupEventError(e, 'syncInputData', logModule);
      return;
    }
    try {
      var fn = Component_A.genFuncTemplate(this._props.onChange, this);
      fn && (await fn(value));
    } catch (e) {
      Component_A.popupEventError(e, 'onChange', logModule);
    }
  }

  /**
   * @returns {Function}
   */
  get validator() {
    return this._inputField.validator;
  }

  /**
   * @description Sets a callback function to validate the input value. This function accepts the current input value (a string)
   * and returns a boolean (`true` if the input is valid, `false` if it is invalid).
   * The validation function will be triggered whenever the user types in the input field.
   * Note that if the input value is programmatically set (e.g., via the `text` attribute),
   * this validation will not be applied.
   * @member {Function} TComponents.Input#validator
   * @instance
   * @param {Function} func - A validation function that takes the current input value (string) as an argument
   *                           and returns a boolean indicating whether the input is valid (`true`) or not (`false`).
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * inputField.validator = function () {
   *   // customize your validator logic here
   *   return true;
   * };
   */
  set validator(func) {
    try {
      var fn = Component_A.genFuncTemplateWithPopup(func, this);
    } catch (e) {
      return;
    }
    if (fn) this._inputField.validator = func;
  }

  /**
   * @returns {string}
   */
  get text() {
    return this._props.text;
  }

  /**
   * This attribute represents the text content of the input field.
   * It can be read to get the current content. Setting this attribute will programmatically
   * will trigger the onchange callback function
   * synchronize the data in controller.
   * @member {string} TComponents.Input#text
   * @instance
   * @param {string} text
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * inputField.text = 'New input text';
   */
  set text(text) {
    if (this._props.text === text) {
      return;
    }
    (async () => {
      try {
        await this.syncInputData(text);
        this.commitProps({text: text});
        this._inputField.text = text;
      } catch (e) {
        Component_A.popupEventError(e, 'syncInputData', logModule);
        return;
      }

      try {
        var fn = Component_A.genFuncTemplate(this._props.onChange, this);
        fn && (await fn(text));
      } catch (e) {
        Component_A.popupEventError(e, 'text setter', logModule);
        return;
      }
    })();
  }

  /**
   * This attribute represents the text content of the input field.
   * It can be read to get the current content. Setting this attribute will programmatically
   * will not trigger the onchange callback function
   * but synchronize the data in controller.
   * @member {string} TComponents.Input#setText
   * @method
   * @param {string} text
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * inputField.setText('New input text');
   */
  async setText(text) {
    await this.syncInputData(text);
    this.commitProps({text: text});
    this._inputField.text = text;
  }

  /**
   * @returns {string}
   */
  get textX() {
    return this._props.text;
  }

  /**
   * This attribute represents the text content of the input field.
   * The update is applied silently without triggering change events and synchronization with the controller,
   * @member {string} TComponents.Input#textX
   * @instance
   * @param {string} text
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * inputField.textX = 'New input text';
   */
  set textX(text) {
    this.commitProps({text: text});
    this._inputField.text = text;
  }

  /**
   * @returns {string}
   */
  get value() {
    return this._inputField.text;
  }

  /**
   * @description Sets the value of the input field. This operation is asynchronous and ensures
   * the input field's data is synchronized before triggering the 'change' event.
   * @member {string} TComponents.Input#value
   * @instance
   * @param {string} t - The new value to be set in the input field.
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * inputField.value = 'New input value';
   */
  set value(t) {
    // (async () => {
    //   await this.syncInputData(t);
    //   this.trigger('change', t);
    // })();
    this.text = t;
  }

  /**
   * @returns {string}
   */
  get valueX() {
    return this.value;
  }

  /**
   * @description Sets the value of the input field silently without triggering the 'change' event
   * and without synchronizing with the controller. This behaves similarly to {@link TComponents.Input#textX}.
   * @member {string} TComponents.Input#valueX
   * @instance
   * @param {string} t - The new value to be set in the input field.
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * // Update value silently (no change event triggered)
   * inputField.valueX = 'Silent value';
   */
  set valueX(t) {
    this.textX = t;
  }

  /**
   * @returns {string}
   */
  get keyboardHelperDesc() {
    return this._props.keyboardHelperDesc;
  }

  /**
   * @description Descriptive label string that will be visible below the editor field
   * on the keyboard when the input field is being edited.
   * The value that the use is editing should be described and input limitations are preferably provided.
   * @member {string} TComponents.Input#keyboardHelperDesc
   * @instance
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * inputField.keyboardHelperDesc = 'keyboard Helper Desc';
   */
  set keyboardHelperDesc(text) {
    this.setProps({
      keyboardHelperDesc: text,
    });
  }

  /**
   * @returns {any}
   */
  get regex() {
    return this._props.regex.value;
  }

  /**
   * @description Regular expression object
   * Standard JavaScript regular expression object used for validating and allowing the input.
   * Default value is null.
   * It can be used in combination with the validator argument.
   * Note that if the text of the input field is set programmatically using the text attribute, this input limitation does not apply.
   * @member {any} TComponents.Input#regex
   * @instance
   * @param {any} regexp
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * // Only allow input of floating-point numbers or integers.
   * inputField.regex = /^-?[0-9]+(\.[0-9]+)?$/;
   */
  set regex(regexp) {
    this.setProps({
      regex: {
        value: regexp,
      },
    });
  }

  /**
   * @returns {boolean}
   */
  get useBorder() {
    return this._props.useBorder;
  }

  /**
   * @description Sets the border usage state.
   * @member {boolean} TComponents.Input#useBorder
   * @instance
   * @param {boolean} b - The new border usage state.
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * inputField.useBorder = false;
   */
  set useBorder(b) {
    this.setProps({useBorder: b});
  }

  /**
   * @returns {boolean}
   */
  get readOnly() {
    return this._props.readOnly;
  }

  /**
   * @description Sets the read-only state of the Input component
   * @member {boolean} TComponents.Input#readOnly
   * @instance
   * @param {boolean} t - The read-only state.
   * @example
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * inputField.readOnly = true;
   */
  set readOnly(t) {
    this.setProps({readOnly: t});
  }

  /**
   * @returns {Function|undefined}
   */
  get onChange() {
    try {
      var fn = Component_A.genFuncTemplateWithPopup(this._props.onChange, this);
    } catch (e) {
      return undefined;
    }
    if (typeof fn === 'function') return fn;
    else return undefined;
  }

  /**
   * @description Sets the `onChange` event handler for the input component.
   * The handler can either be a string representing a function to be executed or a function itself.
   * 1. If you are using an arrow function, like `()=>{}`,
   * the `this` property of the scope may not refer to the input object.
   * 2. If you are using string assignment to define code execution,
   * the string should contain `only the body of the code (executable statements)`,
   * not a complete function declaration. Therefore, including function keywords like function or async function is incorrect.
   * - Correct (Statements Only): `xx.onChange = "console.log('Action done.');"`
   * - Incorrect (Function Declaration): `xx.onChange = "function() { console.log('Action done.'); }"`
   * @member {Function} TComponents.Input#onChange
   * @instance
   * @param {string|Function} t - A string representing a function to be executed or a function itself to handle the `onChange` event.
   * @example
   * // Example 1: Using a string as the handler:
   * inputField.onChange = "console.log(this.text);"
   * @example
   * // Example 2: Using a function as the handler:
   * inputField.onChange = function () {
   *   console.log(this.text);
   * };
   * @example
   * // Example 3: Using an arrow function as the handler:
   * // Note that the `this` context will not refer to the inputField object
   * inputField.onChange = () => {
   *   console.log(inputField.text);
   * };
   */
  set onChange(t) {
    this.setProps({onChange: t});
  }

  /**
   * @returns {object}
   */
  get inputVar() {
    return this._props.inputVar;
  }

  /**
   * @description Sets `inputVar` property.
   * This property is used to set the `inputVar` configuration.
   * If the configuration is invalid or doesn't meet the expected conditions,
   * an exception will be triggered in the input component.
   * **Note:** Invalid `inputVar` configurations may cause the input component to throw an error.
   * @member {object} TComponents.Input#inputVar
   * @instance
   * @param {object} t - The new `inputVar` configuration object. It should contain a valid `type`, `func`, and `value` as per the component's requirements.
   * @example
   * // prepare a boolean variable 'b1' in module 'Module1' of task 'T_ROB1'
   * const inputField = new TComponents.Input(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   text: 'Input field'
   * });
   *
   * // Render the component.
   * inputField.render();
   *
   * // Update the inputVar configuration
   * inputField.text = '@@bool|T_ROB1.Module1|b1@@'
   * inputField.inputVar = {
   *  type: TComponents.Component_A.INPUTVAR_TYPE.BOOL,
   *  func: TComponents.Component_A.INPUTVAR_FUNC.SYNC,
   *  value: '@@bool|T_ROB1.Module1|b1@@'
   * };
   */
  set inputVar(t) {
    this.setProps({inputVar: t});
  }
}

/**
 * @description Add css properties to the component
 * @alias loadCssClassFromString
 * @member TComponents.Input.loadCssClassFromString
 * @method
 * @static
 * @param {string} css - The css string to be loaded into style tag
 * @returns {void}
 * @example
 * TComponents.Input.loadCssClassFromString(`
 *   .tc-input {
 *     background-color: 'red';
 *   }
 * `);
 */
Input.loadCssClassFromString(/*css*/ `
.tc-input {
  height: inherit;
  width: 100%;
  min-width: 0px;
  min-height: 0px;
  padding: 0px;
  margin: 0px;
}
 
.tc-input .fp-components-input-container {
  height: 100%;
  width: 100%;
  min-width: 0px;
  min-height: 0px;
  padding: 0px;
  margin: 0px;
  overflow: hidden;
}

.tc-input .fp-components-input-container .fp-components-input {
  height: 100%;
  width: 100%;
  min-height: 0px;
}

.tc-input .fp-components-input-disabled {
  cursor: not-allowed !important;
}

.tc-input .fp-components-input:hover {
  opacity:0.7;
}
`);