as-radio.js

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

/**
 * @typedef TComponents.RadioProps
 * @prop {object} [options] Additional options for the radio component.
 * Items in this object include:
 * - **responsive** (boolean, default: false): Whether the radio group 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 radio 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} [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} [color] Text color of the component.
 * @prop {object} [font] Font settings for the radio labels.
 * 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`.
 * @prop {string} [optionItems] Radio options in the format `text1|value1;text2|value2;text3|value3`.
 * @prop {object} [optionConfig] Configuration for dynamic radio 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.
 * @prop {string} [defaultState] Initial state of the component.
 * @prop {string} [text] Current selected option value, corresponds to the `value` field in `optionItems`.
 * @prop {number} [selectedIndex] Index of the initially selected option, `-1` means no option is selected.
 * @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} [dataStruct] Data structure associated with the component.
 * @memberof TComponents
 */

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

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

    /**
     * @instance
     * @private
     * @type {TComponents.RadioProps}
     */
    this._props;
    this.beforOnChangeFuncList = [];
    // this.initPropsDep(['optionItems']);
  }

  /**
   * @description Returns the default values of class properties (excluding parent properties).
   * @member TComponents.Radio#defaultProps
   * @method
   * @protected
   * @returns {TComponents.RadioProps}
   */
  defaultProps() {
    return {
      options: {
        responsive: false,
      },
      tips: '',
      // life cycle
      onCreated: '',
      onMounted: '',
      onDispose: '',
      onChange: '',
      // X/Y/W/H/B/R
      position: 'static',
      width: 320,
      height: 32,
      top: 0,
      left: 0,
      rotation: 0,
      zIndex: 0,
      // Function
      functionality: {
        types: [],
        params: [
          {type: '', variablePath: '', isHidden: false},
          {type: 'num', variablePath: '', isHidden: false},
        ],
      },
      // layout
      // layout: {
      //   isHorizontal: true,
      //   itemWidth: 0,
      // },
      // font color
      color: '#000000',
      // font style
      font: {
        fontSize: 12,
        fontFamily: 'Segoe UI',
        style: {
          fontStyle: 'normal',
          fontWeight: 'normal',
          textDecoration: 'none',
        },
      },
      // Data
      optionItems: `text1|value1;text2|value2;text3|value3`,
      optionConfig: {
        mode: 'fixed', //fixed, sync,initialize
        type: '',
        isHidden: false,
        variablePath: '',
      },
      defaultState: 'show_enable',
      // sync property
      text: '',
      selectedIndex: -1,
      inputVar: {
        type: Component_A.INPUTVAR_TYPE.ANY,
        func: Component_A.INPUTVAR_FUNC.CUSTOM,
        value: '0',
        isHidden: false,
      },
      dataStruct: '',
    };
  }

  /**
   * @description Initializes the radio component.
   * @member TComponents.Radio#defaultProps
   * @method
   * @async
   * @returns {Promise<void>}
   */
  async onInit() {
    //if user set selectedIndex but not set text, set text to the value of selectedIndex
    // so the searchedIndex can take effect at the first time, and override the text property
    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.Radio#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 Returns an object containing the components mapped to their identifiers.
   * As one of the methods of component lifecycle,
   * **we do not recommend that users call this function manually.**
   * @member TComponents.Radio#mapComponents
   * @method
   * @returns {object} An object mapping identifiers to components.
   */
  mapComponents() {
    return {};
  }

  /**
   * Maps the internal components.
   * @returns {Object} The mapped components
   */
  async _mapComponents() {
    // Special case, the `this.child` within the lifecycle is only initialized once during construction and will not be dynamically added or removed afterward.
    // Here, we will use external properties to control the dynamic addition and removal of `child`. Therefore, to ensure that it is assigned each time, `this.child` must be set to null once.
    // This code is reflected in the `initChildrenComponents` function of `Component_A`.
    const child = [];
    for (let i = 0; i < this.optionItems.length; i++) {
      let radio = new FP_Radio_A();
      radio._desc = this.optionItems[i].text;
      radio.props = this._props;
      radio.font = this._props.font;
      radio.color = this._props.color;
      radio.checked = this.optionItems[i].value == this._props.text ? true : false;
      radio.onclick = this._onChange.bind(this, i);
      radio.enabled = this.enabled;
      child.push(radio);
    }
    return child;
  }

  /**
   * @description Maps the internal components.
   * @member TComponents.Radio#groupComponents
   * @method
   * @protected
   * @returns {array} The mapped components
   */
  groupComponents() {
    // Special case, the `this.child` within the lifecycle is only initialized once during construction and will not be dynamically added or removed afterward.
    // Here, we will use external properties to control the dynamic addition and removal of `child`. Therefore, to ensure that it is assigned each time, `this.child` must be set to null once.
    // This code is reflected in the `initChildrenComponents` function of `Component_A`.
    const child = [];
    for (let i = 0; i < this.optionItems.length; i++) {
      let radio = new FP_Radio_A();
      radio._desc = this.optionItems[i].text;
      radio.props = this._props;
      radio.font = this._props.font;
      radio.color = this._props.color;
      radio.checked = this.optionItems[i].value == this._props.text ? true : false;
      radio.onclick = this._onChange.bind(this, i);
      radio.enabled = this.enabled;
      child.push(radio);
    }
    return child;
  }

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

      const radioContainer = this.find('.fp-components-radio-group');
      if (radioContainer) {
        // radioContainer.style.gap = `10px`;
        radioContainer.classList.add('horizontal-layout');
      }

      this.child = this.groupComponents();
      for (let i = 0; i < this.child.length; i++) {
        this.child[i].attachToElement(radioContainer);
      }

      this._updateChildStatus();

      this._addTips();
      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.FailedToRenderComponent,
        `Failed to render Radio component with id ${this.compId}`,
        e,
      );
      throw new Error(ErrorCode.FailedToRunOnRender, {cause: e});
    }
  }

  /**
   * @description Generates the markup for the radio component.
   * @member TComponents.Radio#markup
   * @method
   * @returns {string} HTML markup string
   */
  markup() {
    return /*html*/ `
    <div class="tc-radio">
      <div class="fp-components-radio-group"></div>
    </div>
    `;
  }

  /**
   * @description Callback function which is called when the button is pressed, it triggers any function registered with {@link TComponents.Radio#onChange|onChange}.
   * @member TComponents.Radio~_onChange
   * @method
   * @private
   * @async
   * @param {Number} index - The index of the clicked radio component.
   * @returns {Promise<void>}
   */
  async _onChange(index) {
    if (this.enabled) {
      const beforeValue = this.value;
      try {
        const value = this.optionItems[index].value;
        await this.syncInputData(value);
        this.value = value;
      } catch (e) {
        this.value = beforeValue;
        Component_A.popupEventError(e, 'syncInputData', logModule);
        return;
      }
      try {
        var fn = Component_A.genFuncTemplate(this._props.onChange, this);
        fn && (await fn(index));
      } catch (e) {
        Component_A.popupEventError(e, 'onChange', logModule);
      }
    }
  }

  /**
   * @description Updates the checked status of child radio components based on the current text property.
   * @member TComponents.Radio~_updateChildStatus
   * @method
   * @private
   */
  _updateChildStatus() {
    const optionItems = this.optionItems;
    for (let i = 0; i < optionItems.length; i++) {
      this.child[i].checked = optionItems[i].value.toLowerCase() === this._props.text.toLowerCase();
    }
  }

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

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

  /**
   * This attribute is used to set the text of the Radio 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.Radio#setText
   * @method
   * @param {string} text
   * @example
   * const radio = new TComponents.Radio(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   * });
   *
   * // Render the component.
   * radio.render();
   *
   * radio.setText(0);
   */
  async setText(text) {
    const optionItems = this.optionItems;
    const current = optionItems.find((item) => item.value === text);
    if (current) {
      await this.syncInputData(current.value);
      this.value = current.value;
    }
  }

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

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

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

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

  /**
   * @returns {Function|undefined}
   */
  get onChange() {
    try {
      var fn = Component_A.genFuncTemplate(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 radio 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.Radio#onChange
   * @param {Function} t
   * @instance
   * @example
   * const radio = new TComponents.Radio(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
   *   selectedIndex: 0,
   * });
   *
   * await radio.render();
   *
   * // Example 1: Using a string as the handler:
   * radio.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 radio object
   * radio.onChange = () => { console.log('state changed', radio.selectedIndex); };
   * @example
   * // Example 3: Using a common function as the handler:
   * radio.onChange = async function() {
   *   console.log('state changed', this.selectedIndex);
   * };
   */
  set onChange(t) {
    this.setProps({onChange: t});
  }

  /**
   * @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.Radio#optionItems
   * @instance
   * @param {string} itemsString - The formatted string of option items.
   * @example
   * const radio = new TComponents.Radio(document.body, {
   *   position: 'absolute',
   *   zIndex: 1000,
   *   optionItems: 'Option 1|value1;Option 2|value2;Option 3|value3',
   *   selectedIndex: 0,
   * });
   *
   * // Render the component
   * await radio.render();
   *
   * radio.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 optionConfig is not in fixed mode, can not set optionItems',
      });
    }
  }
}

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

.tc-radio .fp-components-radio-group {
  width: 100%;
  height: 100%;
  min-width: 0px;
  min-height: 0px;
  padding: 0px;
  margin: 0px;
  overflow: hidden;
}
.tc-radio .fp-components-radio-container{
  display: flex;
  flex-direction: row;
  align-items: center;
  margin-right: 10px;
}
.tc-radio .fp-components-radio-group > *:last-child {
  margin-right: 0px;
}
.tc-radio .fp-components-radio-group .fp-components-radio-disabled{
  cursor: not-allowed !important;
  border-color:var(--fp-color-BLACK-OPACITY-30);
}
.tc-radio .fp-components-radio-group .fp-components-radio:hover{
  opacity:0.7;
}

.horizontal-layout {
  display: flex;
  flex-direction: row;
  overflow: hidden;
  flex-wrap: wrap;
  align-content: space-around;
  justify-content: space-between;
  align-items: center;
}
.vertical-layout {
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
`);