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;
}
}