as-select.js

import {Component_A} from './basic/as-component.js';
import {FP_Select_A} from './fp-ext/fp-select-ext.js';
import {formatOptionsString, generateOptionsString, initDynamicOptions} from './utils/utils.js';
import {ErrorCode} from '../exception/exceptionDesc.js';

/**
 * @typedef TComponents.SelectProps
 * @prop {object} [options] Additional options for the select component.
 * Items in this object include:
 * - **responsive** (boolean, default: false): Whether the select 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 {Function} [onChange] Callback function that is called when the selected option changes.
 * @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 {object} [functionality] Configuration object for component functionality.
 * This object contains settings to control the component's behavior:
 * - **types** (Array<string>): A list of functionality types enabled for the component.
 * - **params** (Array<object>): Additional parameters, each containing `type`, `variablePath`, `isHidden`.
 * @prop {string} [size] Size style template for the component.
 * @prop {string} [labelPos] Position of the label relative to the select component.
 * @prop {string} [placeHolder] Placeholder text shown when no option is selected.
 * @prop {string} [color] Font color of the selected text.
 * @prop {object} [font] Font configuration for the selected text.
 * This object controls text appearance:
 * - **fontSize** (number, default: 12): Font size in pixels.
 * - **fontFamily** (string, default: 'Segoe UI'): Font family name.
 * - **style** (object): Font style configuration, containing `fontStyle`, `fontWeight`, `textDecoration`.
 * - **textAlign** (string, default: 'left'): Text alignment of the text.
 * @prop {string} [border] CSS border style of the select element.
 * @prop {any} [value] <span style="color:red; font-weight: bold;"> Invalid attribute.</span>
 * @prop {string} [optionItems] Options list in the format `text1|value1;\\ntext2|value2;\\ntext3|value3`.
 * @prop {object} [optionConfig] Configuration for dynamic options.
 * This object controls how options are provided:
 * - **mode** (string, default: 'fixed'): Options mode, e.g. 'fixed', 'sync', 'initialize'.
 * - **type** (string): Type of the options data source.
 * - **isHidden** (boolean, default: false): Whether this option configuration is hidden.
 * - **variablePath** (string): Variable path used when options are dynamic.
 * - **sync** (boolean, default: true): Whether to synchronize the options with the data source.
 * @prop {string} [text] Current selected option value, corresponds to the `value` field in `optionItems`.
 * @prop {number} [selectedIndex] Index of the initially selected option.
 * @prop {object} [inputVar] Input variable binding configuration.
 * This object configures the bound input variable:
 * - **type** (string): Binding variable type. Default is `Component_A.INPUTVAR_TYPE.ANY`.
 * - **func** (string): Binding mode. Default is `Component_A.INPUTVAR_FUNC.CUSTOM`.
 * - **value** (string): Initial value or variable path used for binding.
 * - **isHidden** (boolean, default: false): Whether this binding is hidden in variable selectors.
 * @prop {string} [defaultState] Initial state of the component.
 * @prop {string} [dataStruct] Data structure associated with the component.
 * @memberof TComponents
 */

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

/**
 * @description This component allows users to choose from a list of options. Additional callbacks can be added with the {@link TComponents.Select#onChange|onChange} method.
 * This class focuses on the specific properties of the Select 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.Select
 * @extends TComponents.Component_A
 * @memberof TComponents
 * @param {HTMLElement} parent - HTML element that is going to be the parent of the component
 * @param {TComponents.SelectProps} [props]
 * @property {TComponents.SelectProps} props
 * @example
 * const selectBox = new TComponents.Select(document.body, {
 *   position: 'absolute',
 *   zIndex: 1000,
 *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
 *   selectedIndex: 0,
 * });
 *
 * // Render the component.
 * selectBox.render();
 */
export class Select extends Component_A {
  constructor(parent, props = {}) {
    super(parent, props);

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

    this._select = new FP_Select_A();
    // this.initPropsDep(['optionItems']);
  }

  /**
   * @description Returns the default values of class properties (excluding parent properties).
   * @member TComponents.Select#defaultProps
   * @method
   * @protected
   * @returns {TComponents.SelectProps}
   */
  defaultProps() {
    return {
      options: {
        responsive: false,
      },
      tips: '',
      // life cycle
      onCreated: '',
      onMounted: '',
      onDispose: '',

      onChange: '',

      // ⭐ W/H/X/Y/B/R/Z: Component required attributes.
      position: 'static',
      width: 100,
      height: 32,
      top: 0,
      left: 0,
      borderRadius: 4,
      rotation: 0,
      zIndex: 0,
      // Function
      functionality: {
        types: [],
        params: [
          {type: '', variablePath: '', isHidden: false},
          {type: 'num', variablePath: '', isHidden: false},
        ],
      },
      // Style template
      size: 'small',
      // Font
      // label: 'label',
      labelPos: 'top',
      placeHolder: 'select a value',
      color: '#000000',
      // font style
      font: {
        fontSize: 12,
        fontFamily: 'Segoe UI',
        style: {
          fontStyle: 'normal',
          fontWeight: 'normal',
          textDecoration: 'none',
        },
        textAlign: 'left',
      },

      //  Border
      border: '1px solid #dbdbdb',
      // Data
      value: '', // Invalid attribute
      optionItems: `text1|value1;\ntext2|value2;\ntext3|value3`,
      optionConfig: {
        mode: 'fixed', //fixed, sync,initialize
        type: '',
        isHidden: false,
        variablePath: '',
        sync: true,
      },
      text: '',
      selectedIndex: 0,
      inputVar: {
        type: Component_A.INPUTVAR_TYPE.ANY,
        func: Component_A.INPUTVAR_FUNC.CUSTOM,
        value: '0',
        isHidden: false,
      },
      defaultState: 'show_enable',
      dataStruct: '',
    };
  }

  /**
   * @description Initializes the component.
   * @member TComponents.Select#onInit
   * @method
   * @async
   * @returns {Promise<void>}
   */
  async onInit() {
    this._select._onselection = this._cbOnChange.bind(this);

    // Option values are always strings (from formatOptionsString). Normalize text
    // to string so strict === comparisons don't fail when the prop was saved as a number.
    if (this._props.text !== '' && typeof this._props.text !== 'string') {
      this.commitProps({text: String(this._props.text)});
    }

    if (this._props.selectedIndex > -1 && this._props.text == '') {
      const targetItem = this.optionItems[this._props.selectedIndex];
      if (targetItem) {
        this.commitProps({text: targetItem.value});
      }
    }
  }

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

    initDynamicOptions(this._props.optionConfig, (data) => {
      this.setProps({
        optionItems: generateOptionsString(data),
      });
    });
  }

  /**
   * @description Renders the select component.
   * @member TComponents.Select#onRender
   * @async
   * @method
   * @throws {Error} Throws an error if rendering fails.
   * @returns {Promise<void>}
   */
  async onRender() {
    try {
      this.removeAllEventListeners();

      this._select.props = this._props;
      this._select.enabled = this.enabled;
      this._select.items = this.optionItems;
      const selectContainer = this.find('.tc-select');
      if (selectContainer) this._select.attachToElement(selectContainer);

      // this.addEventListener(this._select._root, 'change', this._onChange.bind(this));

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

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

  /**
   * @returns {boolean}
   */
  get highlight() {
    return this._select._highlight;
  }

  /**
   * @description Sets whether the select component is highlighted.
   * @member {boolean} TComponents.Select#highlight
   * @instance
   * @param {boolean} value - True to highlight, false otherwise.
   * @example
   * const selectBox = new TComponents.Select(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
   *   selectedIndex: 0,
   * });
   *
   * // Render the component.
   * selectBox.render();
   *
   * selectBox.highlight = true;
   */
  set highlight(value) {
    this._select._highlight = value ? true : false;
  }

  /**
   * @description Handles the change event for the select element.
   * @member TComponents.Select~_cbOnChange
   * @method
   * @private
   * @async
   * @param {object} option - The clicked item.
   * @param {number} index - The index of the clicked item.
   * @returns {Promise<void>}
   */
  async _cbOnChange(option, index) {
    if (this.enabled) {
      try {
        await this.syncInputData(option.value);
        this.value = option.value;
      } catch (e) {
        Component_A.popupEventError(e, 'syncInputData', logModule);
        return;
      }
      try {
        var fn = Component_A.genFuncTemplate(this._props.onChange, this);
        fn && (await fn());
      } catch (e) {
        Component_A.popupEventError(e, 'onChange', logModule);
      }
    }
  }

  /**
   * @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.
   * 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 select 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.Select#onChange
   * @instance
   * @param {Function} t
   * @example
   * const selectBox = new TComponents.Select(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
   *   selectedIndex: 0,
   * });
   *
   * // Render the component.
   * selectBox.render();
   *
   * // Example 1: Using a string as the handler:
   * selectBox.onChange = "console.log('state changed', this.selectedIndex);";
   * @example
   * // Example 2: Using a arrow function as the handler:
   * // Note that the `this` context will not refer to the selectBox object
   * selectBox.onChange = () => { console.log('state changed', selectBox.selectedIndex); };
   * @example
   * // Example 3: Using a common function as the handler:
   * selectBox.onChange = async function() {
   *   console.log('state changed', this.selectedIndex);
   * };
   */
  set onChange(t) {
    this.setProps({onChange: t});
  }

  /**
   * @description Updates the child component's status.
   * This method is called whenever a property that affects the child component is changed.
   * It ensures that the child component reflects the current state of the parent component.
   * @member TComponents.Select~_updateChildStatus
   * @method
   * @private
   * @returns {void}
   */
  _updateChildStatus() {
    this._select.props = this._props;
    this._select.rebuild();
  }

  /**
   * @description Sets the text to enable selected option.
   * @member {string} TComponents.Select#text
   * @instance
   * @param {string} t - The text of the option to be selected.
   * @example
   * const selectBox = new TComponents.Select(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
   *   selectedIndex: 0,
   * });
   *
   * // Render the component.
   * selectBox.render();
   *
   * selectBox.text = 'value2';
   */
  set text(t) {
    const optionItems = this.optionItems;
    const current = optionItems.find((item) => item.text === t);
    if (current) {
      this.commitProps({text: current.value});
      this._select.text = current.value;
    }
  }

  /**
   * @returns {string}
   */
  get text() {
    const optionItems = this.optionItems;
    const current = optionItems.find((item) => item.value === this._props.text);
    if (current) return current.text;
    return '';
  }

  /**
   * This attribute is used to set the text of the Select component.
   * When you set this attribute, the component will attempt to synchronize the new text value with any bound variables or data sources.
   * @member {string} TComponents.Select#setText
   * @method
   * @param {string} text
   * @example
   * const selectBox = new TComponents.Select(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   * });
   *
   * // Render the component.
   * selectBox.render();
   *
   * selectBox.setText(0);
   */
  async setText(text) {
    await this.syncInputData(text);
    this.value = text;
  }

  /**
   * @returns {string}
   *
   */
  get value() {
    const optionItems = this.optionItems;
    const current = optionItems.find((item) => item.value === this._props.text);
    if (current) return current.value;
    return '';
  }

  /**
   * @description Sets the value to enable selected option.
   * @member {string} TComponents.Select#value
   * @instance
   * @param {string} v - The value of the option to be selected.
   * @example
   * const selectBox = new TComponents.Select(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
   *   selectedIndex: 0,
   * });
   *
   * // Render the component.
   * selectBox.render();
   *
   * selectBox.value = 'value2';
   */
  set value(v) {
    const optionItems = this.optionItems;
    const current = optionItems.find((item) => item.value === v);
    if (current) {
      this.commitProps({text: current.value});
      this._select.text = v;
    }
  }

  /**
   * @returns {number}
   */
  get selectedIndex() {
    const optionItems = this.optionItems;
    return optionItems.findIndex((item) => item.value === this._props.text);
  }

  /**
   * @description Sets the index of the currently selected option.
   * @member TComponents.Select#selectedIndex
   * @instance
   * @param {number} index - The index of the selected option.
   * @example
   * const selectBox = new TComponents.Select(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
   *   selectedIndex: 0,
   * });
   *
   * // Render the component.
   * selectBox.render();
   *
   * selectBox.selectedIndex = 1;
   */
  set selectedIndex(index) {
    const targetItem = this.optionItems[index];
    if (targetItem) {
      this.value = targetItem.value;
    } else {
      throw new Error(ErrorCode.FailedToUpdateComponentWithSetter, {
        cause: `Index out of range: ${index}`,
      });
    }
  }

  /**
   * @returns {object[]}
   */
  get optionItems() {
    const data = formatOptionsString(this._props.optionItems);
    for (let i = 0; i < data.length; i++) {
      data[i].text = Component_A.tParse(data[i].text.replace(/^\n+/, ''));
    }
    return data;
  }

  /**
   * @description Sets the option items from a formatted string.
   * @member {string} TComponents.Select#optionItems
   * @instance
   * @param {string} itemsString - The formatted string of option items.
   * @example
   * const selectBox = new TComponents.Select(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
   *   selectedIndex: 0,
   * });
   *
   * // Render the component.
   * selectBox.render();
   *
   * selectBox.optionItems = "Option 4|value4;Option 5|value5;Option 6|value6";
   */
  set optionItems(itemsString) {
    const optionsConfig = this._props.optionConfig;
    if (optionsConfig && optionsConfig.mode == 'fixed') {
      this.setProps({
        optionItems: itemsString,
      });
    } else {
      throw new Error(ErrorCode.FailedToUpdateComponentWithSetter, {
        cause: 'The optionsConfig is not in fixed mode, can not set optionItems',
      });
    }
  }
}

/**
 * @description Add css properties to the component
 * @member TComponents.Select.loadCssClassFromString
 * @method
 * @static
 * @param {string} css - The css string to be loaded into style tag
 * @returns {void}
 * @example
 * TComponents.Select.loadCssClassFromString(`
 * .tc-select {
 *   height: inherit;
 *  }`
 * );
 */
Select.loadCssClassFromString(/*css*/ `
.tc-select {
  height: inherit;
  width: 100%;
  min-width: 0px;
  min-height: 0px;
  padding: 0px;
  margin: 0px;
}

.tc-select .fp-components-select-container-disabled,
.tc-select .fp-components-select-container  {
  width: 100%;
  height: 100%;
  display: flex;
  align-content: center;
  justify-content: center;
  align-items: center;
  flex-direction: row;
  position: relative;
}

.tc-select .fp-components-select-container-disabled .custom-select-display {
  cursor:not-allowed !important;
  opacity:0.7;
}
.tc-select .fp-components-select-container:hover {
  opacity:0.7;
}

.tc-select .fp-components-select-container .custom-select-display {
  width: 100%;
  height: 100%;
  box-sizing: border-box;
  padding: 5px;
  cursor: pointer;
  background-color: white;
  display: flex;
  align-items: center;
  text-overflow: ellipsis;
}



.custom-select-option.selected,.custom-select-option:hover{
  background-color: #f0f0f0;
}

.custom-select-options:hover{
  display: block !important;
}

`);