as-menu.js

import API from '../api/ecosystem-base.js';
import {Component_A} from './basic/as-component.js';
import {View_A} from './basic/as-view.js';
import {ErrorCode, ExceptionIdMap} from './../exception/exceptionDesc.js';

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

/**
 * @description Properties accepted by the TComponents.Menu_A component, defining its appearance, behavior, and lifecycle hooks.
 * @typedef {object} TComponents.MenuProps
 * @prop {object} [options] Additional options for the menu component.
 * Items in this object include:
 * - **responsive** (boolean, default: false): Whether the menu should be responsive.
 * @prop {Function} [onCreated] Lifecycle hook invoked after component instantiation.
 * @prop {Function} [onMounted] Lifecycle hook invoked after component is attached to the DOM.
 * @prop {string} [position] CSS `position` property.
 * @prop {number} [width] Width of the menu container.
 * @prop {number} [height] Height of the menu container.
 * @prop {number} [top] Top offset in pixels.
 * @prop {number} [left] Left offset in pixels.
 * @prop {number} [borderRadius] Border radius in pixels.
 * @prop {number} [rotation] Rotation in degrees.
 * @prop {number} [zIndex] CSS `z-index`.
 * @prop {string} [border] CSS border shorthand.
 * @prop {string} [color] CSS text color.
 * @prop {string} [backgroundColor] CSS background color.
 * @prop {object} [font] Font configuration.
 * 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} [size] Preset size key for the component.
 * @prop {string} [styleTemplate] Key of a style template to apply.
 * @prop {boolean} [useTitle] Whether to reserve space for the title.
 * @prop {string} [title] Title displayed for the menu.
 * @prop {number} [activeViewIndex] Zero-based index of the currently active view.
 * @prop {Function|string} [onChange] Callback triggered when the active view changes.
 * @prop {boolean} [useViewIcon] Whether to display an icon alongside each view label.
 * @prop {TComponents.ViewProps[]} [views] Array of view objects to populate the menu.
 * @prop {string} [defaultState] Default state of the component.
 * @memberof TComponents
 */

/**
 * @description A menu component for displaying a list of views.
 * This is an **abstract class**, currently used for  {@link TComponents.Hamburger} and {@link TComponents.Tab}.
 * Although examples are provided for its use, it is recommended that
 * users inherit from this class to implement concrete implementations.
 * @class TComponents.Menu_A
 * @extends TComponents.Component_A
 * @memberof TComponents
 * @param {HTMLElement} parent - HTML element that is going to be the parent of the component
 * @param {TComponents.MenuProps} [props] - Properties accepted by the Menu_A component.
 * @example
 * const menu = new TComponents.Menu_A(document.body, {
 *   title: 'My Menu',
 *   views: [
 *     { name: 'View 1', content: 'id-1' },
 *     { name: 'View 2', content: 'id-2' },
 *   ],
 * });
 * await menu.render();
 */
export class Menu_A extends Component_A {
  constructor(parent, props) {
    super(parent, props);

    this.viewId = new Map();
    this.requireMarkup = [];

    /** @type {TComponents.MenuProps} */
    this._props;

    this._views = [];

    /** @type {TComponents.View_A[]} */
    this._children = [];

    this.initPropsDep('views');
  }

  /**
   * @deprecated This getter will be removed in future versions.
   * Use the instance field `this.views` directly instead. Review {@link TComponents.Menu_A#_initViews} for more details.
   * @description The views array of view-type components.
   * This interface is about to be deprecated; please review {@link TComponents.Menu_A#_initViews} before using it.
   * @member {any[]} TComponents.Menu_A#views
   * @instance
   */
  get views() {
    const views = [];
    if (this._props.views.length > 0) {
      for (let i = 0; i < this._props.views.length; i++) {
        const view = this._props.views[i];
        const tmpView = Object.assign({}, view);

        if (typeof tmpView.content == 'string') {
          tmpView.content = document.createElement('div');
          tmpView.content.id = view.content;
          tmpView.content.classList.add('t-component__container');
          // Manually set the view id.
          tmpView.id = view.content;
        } else {
          tmpView.id = this._processContent(tmpView.content);
        }

        tmpView.name = Component_A.tParse(view.name);

        views.push(tmpView);
      }
    }

    return views;
  }

  /**
   * @deprecated
   */
  get useTitle() {
    return this._props.useTitle;
  }

  /**
   * @deprecated The 'useTitle' interface is no longer supported by 'TComponent.Menu_A'.
   * Components that inherit from it will need to override the 'useTitle' interface themselves.
   * @description Set the useTitle property.
   * @member {boolean} TComponents.Menu_A#useTitle
   * @instance
   * @param {boolean} b - The new value for the useTitle property.
   * @example
   * const menu = new TComponents.Menu_A(document.body, { useTitle: true });
   * // Update the property dynamically
   * menu.useTitle = false;
   */
  set useTitle(b) {
    this.setProps({useTitle: b});
  }

  /**
   * @deprecated
   */
  get title() {
    return this._props.title;
  }

  /**
   * @deprecated The 'title' interface is no longer supported by 'TComponent.Menu_A'.
   * Components that inherit from it will need to override the 'title' interface themselves.
   * @description The title property of menu-type component.
   * @member {string} TComponents.Menu_A#title
   * @instance
   * @param {string} s - The title property.
   * @example
   * const menu = new TComponents.Menu_A(document.body, { title: 'My Menu' });
   * // Update the title dynamically
   * menu.title = 'Updated Menu Title';
   */
  set title(s) {
    this.setProps({title: s});
  }

  /**
   * @description Returns the default values of class properties (excluding parent properties).
   * @member TComponents.Menu_A#defaultProps
   * @method
   * @protected
   * @returns {TComponents.MenuProps}
   */
  defaultProps() {
    return {
      options: {
        responsive: false,
      },
      onCreated: '',
      onMounted: '',
      onDispose: '',
      position: 'static',
      width: 200,
      height: 200,
      top: 0,
      left: 0,
      borderRadius: 4,
      rotation: 0,
      zIndex: 0,
      border: '1px solid #dbdbdb',
      color: '(0,0,0,1)',
      backgroundColor: '(245,245,245,1)',
      font: {
        fontSize: 12,
        fontFamily: 'Segoe UI',
        style: {
          fontStyle: 'normal',
          fontWeight: 'normal',
          textDecoration: 'none',
        },
      },
      size: '',
      styleTemplate: '',
      useTitle: true,
      title: '',
      activeViewIndex: 0,
      onChange: '',
      useViewIcon: true,
      views: [
        {
          name: Component_A.tParse('Item 0'),
          content: `View_${API.generateUUID()}`,
          id: null,
          icon: 'abb-icon abb-icon-abb_robot-tool_32',
          children: [],
        },
      ],
      defaultState: 'show_enable',
    };
  }

  /**
   * @description Initializes the component.
   * @member TComponents.Menu_A#onInit
   * @method
   * @returns {void}
   * @throws {ErrorCode} - If an error occurs during initialization.
   */
  onInit() {
    try {
      this.views = [];
      if (this._props.onChange) this.on('change', this._props.onChange);
      if (this._props.views.length > 0) {
        this._props.views.forEach((view) => {
          view['id'] = this._processContent(view.content);
          this.views.push(view);
        });
      }
    } catch (e) {
      Logger.e(
        logModule,
        ErrorCode.FailedToInitComponent,
        `Error happens on onInit of menu component ${this.compId}.`,
        e,
      );
      throw ErrorCode.FailedToInitComponent;
    }
  }

  /**
   * @description Maps components to their identifiers.
   * @member TComponents.Menu_A#mapComponents
   * @method
   * @returns {object}
   */
  mapComponents() {
    const obj = {};

    this.views.forEach(({content}) => {
      if (Component_A.isTComponent(content)) {
        obj[content.compId] = content;
      }
    });
    return obj;
  }

  /**
   * @description Render the component.
   * @member TComponents.Menu_A#onRender
   * @method
   * @returns {void}
   * @throws {Error} - If an error occurs during rendering.
   */
  onRender() {
    if (this._props.onChange) {
      this.cleanEvent('change');
      this.on('change', this._props.onChange);
    }
    this.viewId.clear();
    this.views.forEach(({name, content, image, active, id}) => {
      const dom = this._getDom(content, id, name);

      id = {};
      this.viewId.set(id, name);
    });

    this.container.classList.add('tc-container');
  }

  /**
   * @description Generate the markup for the component.
   * @member TComponents.Menu_A#markup
   * @method
   * @returns {string} HTML markup
   */
  markup() {
    return /*html*/ `
    ${this.views.filter(({id}) => id !== null).reduce((html, {id}) => html + `<div id="${id}"></div>`, '')}
    `;
  }

  /**
   * @deprecated Handle this change function in different menu-type component separately.
   * @description Callback function triggered when the active view changes.
   * @member TComponents.Menu_A~cbOnChange
   * @method
   * @private
   * @param {*} oldView - The previous view object.
   * @param {*} newView - The new view object.
   * @returns {void}
   */
  cbOnChange(oldView, newView) {
    this.trigger('change', this.viewId.get(oldView), this.viewId.get(newView));
  }

  /**
   * @description Add a new view to the menu.
   * @member TComponents.Menu_A#addView
   * @method
   * @protected
   * @param {TComponents.ViewProps} newView View object.
   * @returns {void}
   */
  addView(newView) {
    const tmpView = this.processView(newView);
    let propsView = Object.assign(
      {
        name: '',
        content: null,
        icon: '',
        children: [],
      },
      newView,
      {content: tmpView.content},
    );

    this.pushView(tmpView);
    this._props.views.push(propsView);
  }

  /**
   * @description Removes a view from the menu by its view object.
   * This will remove the view from both internal view list and props.views.
   * @member TComponents.Menu_A#removeView
   * @method
   * @protected
   * @param {object} view - The view object to remove.
   * @returns {void}
   */
  removeView(view) {
    if (!view || !view.id) return;

    const index = this._views.findIndex((v) => v.id === view.id);
    if (!this.isValidIndex(index)) return;

    this._views.splice(index, 1);
    this._props.views.splice(index, 1);
  }

  /**
   * @description Shows the specified view by updating its visibility state
   * and restoring its associated DOM element display.
   * @member TComponents.Menu_A#showView
   * @method
   * @protected
   * @param {object} view - The view object to show.
   * @returns {void}
   */
  showView(view) {
    if (!view) return;

    view.isHidden = false;

    const el = view.content;
    if (Component_A._isHTMLElement(el)) {
      el.style.display = '';
    }
  }
  /**
   * @description Hides the specified view by updating its visibility state
   * and setting its associated DOM element display to none.
   * @member TComponents.Menu_A#hideView
   * @method
   * @protected
   * @param {object} view - The view object to hide.
   * @returns {void}
   */
  hideView(view) {
    if (!view) return;

    view.isHidden = true;

    const el = view.content;
    if (Component_A._isHTMLElement(el)) {
      el.style.display = 'none';
    }
  }

  /**
   * @description Determines the type of menu component currently set to active.
   * Checks if the provided view index is valid and sets it as active if it is.
   * @member TComponents.Menu_A#checkActiveViewIndex
   * @method
   * @protected
   * @param {number} t - The new active view index.
   * @returns {null | number} Returns null if the provided index is the same as the
   * current active index or invalid, otherwise returns the new active view index.
   * @example
   * const menu = new TComponents.Menu_A(document.body,{});
   * const newIndex = menu.checkActiveViewIndex(1);
   */
  checkActiveViewIndex(t) {
    if (this._props.activeViewIndex === t) {
      return null;
    }

    const viewLen = this._props.views.length;
    if (t >= viewLen || t < 0) {
      Logger.w(logModule, `Invalid active view index: ${t}. Valid range is 0 to ${viewLen - 1}.`);
      return null;
    }

    return t;
  }

  /**
   * @description Checks whether the given index is a valid view index.
   * @member TComponents.Menu_A#isValidIndex
   * @method
   * @protected
   * @param {number} index - The index to validate.
   * @returns {boolean} True if the index is valid, otherwise false.
   */
  isValidIndex(index) {
    return Number.isInteger(index) && index >= 0 && index < this._views.length;
  }

  /**
   * @description Appends a child component to a specific view by its index.
   * The child component will be appended to the content of the view at the specified index.
   * If a child with the same name already exists, it will be removed first.
   * @member TComponents.Menu_A#appendChild
   * @method
   * @protected
   * @param {number} index - The index of the view to append the child to.
   * @param {object} instance - The child component to append.
   * @param {string} name - The name of the child component.
   * @throws {Error} - Throws an error if the index is out of range or invalid.
   * @returns {void}
   * @example
   * const menu = new TComponents.Menu_A(document.body, {views:[{name: 'view-1', content: 'id-xxx'}]});
   * const childComponent = new TComponents.Button_A(null, {});
   * // Append to the first view
   * menu.appendChild(0, childComponent, 'childName');
   */
  appendChild(index, instance, name) {
    try {
      if (typeof index != 'number' || index >= this._children.length || index < 0) return;
      const vinstance = this._children[index];
      vinstance.appendChild(instance, name);
    } catch (e) {
      // Runtime errors: write specific content to the log, throw error code
      Logger.e(
        logModule,
        ErrorCode.FailedToAttachToElement,
        `Error happens on appendChild of menu component ${this.compId}.`,
        e,
      );
      throw new Error(ErrorCode.FailedToRunOnRender, {cause: e});
    }
  }

  /**
   * @description Removes a child component from a specific view by its index.
   * The child component is identified by the provided name or component ID.
   * @member TComponents.Menu_A#removeChild
   * @method
   * @protected
   * @param {number} index - The index of the view from where the child the removed.
   * @param {string|object} t - The name or component instance of the child to be removed.
   * @throws {Error} - Throws an error if the index is out of range or invalid.
   * @returns {void}
   * @example
   * const menu = new TComponents.Menu_A(document.body, {});
   * // Remove a child component from the first view by name
   * menu.removeChild(0, 'childName');
   */
  removeChild(index, t) {
    try {
      if (typeof index != 'number' || index >= this._children.length || index < 0) return;
      const vinstance = this._children[index];
      vinstance.removeChild(t);
    } catch (e) {
      Logger.e(logModule, ErrorCode.FailedToRemoveChildElement, `Failed to remove child component:`, e);
      throw new Error(ErrorCode.FailedToRemoveChildElement, {cause: e});
    }
  }

  /**
   * @description Get the DOM element for the given content.
   * @member TComponents.Menu_A#getDom
   * @method
   * @protected
   * @param {TComponents.Component_A | HTMLElement | string} content - The content of the view.
   * @param {string} id - The identifier of the view.
   * @param {string} name - The name of the view.
   * @returns {HTMLElement} The DOM element.
   */
  getDom(content, id, name) {
    return this._getDom(content, id, name);
  }

  /**
   * @member TComponents.Menu_A~_getDom
   * @method
   * @private
   * @param {TComponents.Component_A | HTMLElement | string} content
   * @param {string} id
   * @param {string} name
   * @returns {HTMLElement}
   */
  _getDom(content, id, name) {
    let dom;
    if (Component_A.isTComponent(content)) {
      if (id) content.attachToElement(this.find(`#${id}`));
      dom = content.parent;
    } else {
      dom = content;
    }
    return dom;
  }

  /**
   * @description Get a menu bar item DOM element by index.
   * @member TComponents.Menu_A#getMenuBarItem
   * @method
   * @protected
   * @param {string} style - CSS selector used to find the menu bar container.
   * @param {number} index - Index of the menu bar item (0-based).
   * @returns {HTMLElement|null} The menu bar item element if found, otherwise null.
   */
  getMenuBarItem(style, index) {
    const menuBar = this.find(style);
    if (menuBar && Component_A._isHTMLElement(menuBar)) {
      const node = menuBar.children[index];

      if (node) {
        return node;
      }
    }

    return null;
  }

  /**
   * @description Finds a view object by id, content, or name.
   * Lookup priority:
   * 1. `id`
   * 2. `content`
   * 3. `name`
   * @member TComponents.Menu_A#findView
   * @method
   * @protected
   * @param {object} options
   * @param {string} [options.id] - The view id.
   * @param {string|HTMLElement} [options.content] - The view content id or content object.
   * @param {string} [options.name] - The view display name.
   * @returns {object|undefined} The matched view, if found.
   */
  findView({id = null, content = null, name = null} = {}) {
    if (id != null) {
      return this._views.find((v) => v.id === id);
    }

    if (content != null) {
      return this._views.find((v) => {
        if (typeof content === 'string') {
          return v.content && v.content.id === content;
        }
        return v.content === content;
      });
    }

    if (name != null) {
      return this._views.find((v) => v.name === Component_A.tParse(name));
    }

    return undefined;
  }

  /**
   * @description Processes a view definition into an internal view model.
   * This method normalizes the view content, generates its ID,
   * resolves dynamic properties, and initializes runtime flags.
   * @member TComponents.Menu_A#processView
   * @method
   * @protected
   * @param {TComponents.ViewProps} view - The raw view definition.
   * @returns {object} The processed internal view object.
   */
  processView(view) {
    const tmpView = Object.assign({}, view);

    if (typeof tmpView.content == 'string') {
      tmpView.content = document.createElement('div');
      tmpView.content.id = view.content;
      tmpView.content.classList.add('t-component__container');
    }

    // We need to note that `tmpView.id` here will be overridden later.
    // Because it's a `tmpView`, it doesn't affect `props.views`,
    // but later in the `onInit` method of the continuing class,
    // it's assigned a value by `fpcomponent`.
    // Secondly, this is injected into `new View_A`,
    // but this `.id` attribute is redundant and not used.
    tmpView.id = this._processContent(tmpView.content);
    tmpView.name = Component_A.tParse(view.name);

    tmpView.isHidden = false;

    return tmpView;
  }

  /**
   * @description Pushes a processed view into internal view and child lists.
   * Also creates a corresponding View_A instance.
   * @member TComponents.Menu_A#pushView
   * @method
   * @protected
   * @param {object} tmpView - The processed internal view object.
   * @returns {void}
   */
  pushView(tmpView) {
    this._views.push(tmpView);

    const props = Object.assign({}, tmpView);
    const vins = new View_A(null, props);
    this._children.push(vins);
  }

  /**
   * @description Build internal view models and child component instances from props.views.
   * This method processes each view's content, ensuring it is a valid HTMLElement or TComponents.Component_A instance.
   * It also initializes the view's ID and name properties.
   * If the content is a string, it attempts to find the corresponding DOM element by ID.
   * If the content is not a valid type, it throws an error.
   * **Due to issues with the view's getter constructor, it is recommended that inherited components use this method to initialize views.**
   * @member TComponents.Menu_A#initViews
   * @method
   * @protected
   * @returns {void}
   * @example
   * // Example usage in a derived class
   * class MyMenu extends Menu_A {
   *   constructor(parent, props) {
   *     super(parent, props);
   *     this._views = [];
   *   }
   *
   *   // Override the view getter and setter.
   *   get views() {
   *    return this._views;
   *   }
   *
   *   onInit(){
   *    this._initViews();
   *     ...
   *   }
   * }
   */
  initViews() {
    this._initViews();
  }

  /**
   * @member TComponents.Menu_A~_initViews
   * @method
   * @private
   * @returns {void}
   */
  _initViews() {
    this._views = [];
    this._children = [];
    if (this._props.views.length === 0) return;

    this._props.views.forEach((view) => {
      const tmpView = this.processView(view);
      this.pushView(tmpView);
    });
  }

  /**
   * @description Process the content of the view.
   * @member TComponents.Menu_A#processContent
   * @method
   * @protected
   * @param {any} content - The content of the view.
   * @returns {HTMLElement} The content of the view, which is of the type `HTMLElement`.
   */
  processContent(content) {
    return this._processContent(content);
  }

  /**
   * @member TComponents.Menu_A~_processContent
   * @method
   * @private
   * @returns {HTMLElement}
   */
  _processContent(content) {
    let id = null;

    if (Component_A.isTComponent(content)) {
      id = content.compId + '__container';
    } else if (typeof content === 'string') {
      const elementId = content;
      content = document.getElementById(`${elementId}`);

      if (!content) {
        throw new Error(`Could not find element with id: ${elementId} in the DOM.
        Try adding view as Element or Component_A instance to the Hamburger menu.`);
      }
    } else if (!Component_A._isHTMLElement(content)) {
      throw new Error(`Unexpected type of view content: type -- ${typeof content} --> ${content}}`);
    }
    return id;
  }
}