import {HTTP_METHOD} from '@mirlo/services/requests';
import ComponentStateBinderHandler from './state';
import {getService} from './app';
/**
* The active component.
* @private
*/
let active_component = null;
/**
* Class representing a Base Component node.
* See {@tutorial Components}.
* @tutorial components
*/
class Component extends HTMLElement {
/**
* The root state.
* @type {(Object|Proxy)}
* @private
*/
#root_state = {};
/**
* The queue of the state binds changes.
* @type {Array}
* @private
*/
#queue_state_changes = [];
/**
* The rAF handler for state binds changes.
* @type {Number}
* @private
*/
#queue_state_raf = 0;
/**
* The Shadow Root of the node
* @type {NodeList}
* @private
*/
#sdom = null;
/**
* The store of fetchData.
* @type Object
* @private
*/
#netdata = {};
/**
* The object for mirlo purpuoses.
* @type {Object}
* @property {Object} options - The component options.
*/
mirlo = {
options: {},
_events: null,
_fetch_data: null,
_state_binds: null,
_is_unsafe: false,
_external_rel_styles: null,
_skip_queue_state_raf: false,
};
/**
* Create a Component node.
* @hideconstructor
*/
constructor() {
super();
active_component = this;
this.onSetup();
active_component = null;
[
'_events',
'_fetch_data',
'_state_binds',
'_is_unsafe',
'_external_rel_styles',
'_skip_queue_state_raf',
].forEach(item => Object.freeze(this.mirlo[item]));
if (!this.mirlo._is_unsafe) {
this.#sdom = this.attachShadow({mode: 'closed'});
}
this.renderTemplate();
}
/**
* Invoked when the component node is attached to the page.
* @private
*/
connectedCallback() {
this.onWillStart().then(() => window.requestAnimationFrame(() => {
this.mirlo._skip_queue_state_raf = true;
this.onStart(...arguments);
this.mirlo._skip_queue_state_raf = false;
}), this.root);
}
/**
* Invoked when the component node is dettached from the page.
* @private
*/
disconnectedCallback() {
this.onRemove(...arguments);
}
/**
* Invoked when the component node change the attributes.
* @private
*/
attributeChangedCallback() {
this.onAttributeChanged(...arguments);
}
/**
* Invoked when the component is allocated. Use this method to call 'Hook Functions' and configure the component.
*/
onSetup() {
// Override me
}
/**
* Invoked when the component is attached to the page. Used to call promises and wait for them before full initialization.
* @returns {Promise}
*/
onWillStart() {
if (this.mirlo._state_binds) {
const state_handler = Object.assign({}, ComponentStateBinderHandler, {
_component_obj: this,
});
this.mirlo.state = new Proxy(this.#root_state, state_handler);
} else {
this.mirlo.state = this.#root_state;
}
return this.#fetchData();
}
/**
* Invoked when 'onWillStart' promise finish. Here you can manipulate the component node.
*/
onStart() {
// Assign Events
if (this.mirlo._events) {
Object.entries(this.mirlo._events).forEach(([selector, event_def]) => {
const {mode, events} = event_def;
let dom_targets;
if (selector) {
if (mode === 'id') {
dom_targets = [this.queryId(selector)];
} else {
dom_targets = this.queryAll(selector);
}
} else {
dom_targets = [this];
}
Object.entries(events).forEach(([ename, callback]) => {
const callback_bind = callback.bind(this);
for (const dom_target of dom_targets) {
dom_target.addEventListener(ename, callback_bind);
}
});
});
}
}
/**
* Invoked when the component is removed from the page.
*/
onRemove() {
// Override me
}
/**
* Invoked when the component changes an attribute.
* @param {string} name - The attribute name.
* @param {string} old_value - The old value.
* @param {string} new_value - The new value.
*/
onAttributeChanged(name, old_value, new_value) {
this.mirlo.options[name] = new_value;
}
/**
* Invoked when the component state change.
* @param {string} prop - The property name.
* @param {any} old_value - The old value.
* @param {any} new_value - The new value.
*/
onStateChanged(prop, old_value, new_value) {
if (
old_value !== new_value &&
Object.hasOwn(this.mirlo._state_binds, prop)
) {
const bind = this.mirlo._state_binds[prop];
if (bind) {
let targets;
if (bind.id) {
targets = [this.queryId(bind.id)];
} else if (bind.selector) {
targets = [this.query(bind.selector)];
} else if (bind.selectorAll) {
targets = Array.from(this.queryAll(bind.selectorAll));
} else {
targets = [this];
}
this.#queue_state_changes.push(
...targets.map(target => {
return [target, bind.attribute, new_value];
}),
);
if (this.#queue_state_raf === 0 && this.#queue_state_changes.length) {
if (this.mirlo._skip_queue_state_raf) {
this.#queueStateFlush();
} else {
this.#queue_state_raf = window.requestAnimationFrame(
this.#queueStateFlush.bind(this), this.root
);
}
}
}
}
}
/**
* Fetch data from the configured endpoints.
* @returns {Promise}
* @private
*/
async #fetchData() {
if (this.mirlo._fetch_data) {
const fetch_data_entries = Object.entries(this.mirlo._fetch_data);
const requests = getService('requests');
const prom_res = await Promise.all(
fetch_data_entries.map(([key, value]) =>
requests
.queryJSON(
value.endpoint,
value.data,
value.method ?? HTTP_METHOD.POST,
value.cache_name,
)
.then(result => {
this.#netdata[key] = result;
Object.freeze(this.#netdata[key]);
return result;
}),
),
);
return prom_res;
}
}
/**
* Process the queue of the state binds changes.
* @private
*/
#queueStateFlush() {
this.#queue_state_changes.forEach(item =>
this.constructor.updateStateBind(...item),
);
this.#queue_state_changes = [];
this.#queue_state_raf = 0;
}
/**
* Render the associated template.
* A template is created using the node 'template' with an 'id' like "template-mirlo-<component name>".
*/
renderTemplate() {
const template = document.getElementById(
`template-${this.tagName.toLowerCase()}`,
);
if (template) {
const tmpl_node = template.content.cloneNode(true);
if (this.mirlo._external_rel_styles) {
this.mirlo._external_rel_styles.forEach(href => {
const dom_el_link = document.createElement('link');
dom_el_link.setAttribute('type', 'text/css');
dom_el_link.setAttribute('rel', 'stylesheet');
dom_el_link.setAttribute('href', href);
tmpl_node.prepend(dom_el_link);
});
}
this.root.appendChild(tmpl_node);
}
}
/**
* Update the node with the state bind change.
* @param {HTMLElement} node - The node.
* @param {string} attr - The attribute name.
* @param {string} value - The value.
*/
static updateStateBind(node, attr, value) {
if (!attr) {
node.textContent = value;
} else if (attr === 'html') {
node.innerHTML = value;
} else {
node.setAttribute(attr, value);
}
}
/**
* Gets the active allocated component.
* @returns {Component}
* @throws Will throw an error if the method is called outside allocation time.
*/
static getActiveComponent() {
if (!active_component) {
throw new Error(
'No active component. Hook functions must be used in the constructor.',
);
}
return active_component;
}
/**
* Configure component events.
* @param {Object} event_defs - The event definition.
* @throws Will throw an error if the method is called outside allocation time.
*/
static useEvents(event_defs) {
const comp = this.getActiveComponent();
comp.mirlo._events = event_defs;
}
/**
* Configure component fetch data.
* @param {Object} fetch_defs - The fetch data definition.
* @throws Will throw an error if the method is called outside allocation time.
*/
static useFetchData(fetch_defs) {
const comp = this.getActiveComponent();
comp.mirlo._fetch_data = fetch_defs;
}
/**
* Configure component state binds.
* @param {Object} state_defs - The state bind definition.
* @throws Will throw an error if the method is called outside allocation time.
*/
static useStateBinds(state_defs) {
const comp = this.getActiveComponent();
comp.mirlo._state_binds = state_defs;
}
/**
* Add external styles. Only useful if not uses {@link disableShadow}
* @param {...string} rel_hrefs - The href of the stylesheet link.
* @throws Will throw an error if the method is called outside allocation time.
*/
static useStyles(...rel_hrefs) {
const comp = this.getActiveComponent();
comp.mirlo._external_rel_styles = rel_hrefs;
}
/**
* Disable Shadow Root.
* Not recommended if html templates are used.
* @throws Will throw an error if the method is called outside allocation time.
*/
static disableShadow() {
const comp = this.getActiveComponent();
comp.mirlo._is_unsafe = true;
}
/**
* Get the root node of the component
* @type {ShadowRoot|HTMLElement}
*/
get root() {
return this.#sdom || this;
}
/**
* Get Fetch Data results.
* @param {string} ref_name = The fetch data reference name.
* @type {Object}
*/
getFetchData(ref_name) {
return this.#netdata[ref_name];
}
/**
* Query all nodes using an CSS selector.
* @param {string} selector - The CSS selector.
* @returns {NodeList}
*/
queryAll(selector) {
return this.root.querySelectorAll(selector);
}
/**
* Query a node using an CSS selector.
* @param {string} selector - The CSS selector.
* @returns {HTMLElement}
*/
query(selector) {
return this.root.querySelector(selector);
}
/**
* Query a node using its id.
* @param {string} el_id - The id of the node.
* @returns {HTMLElement}
*/
queryId(el_id) {
if (this.mirlo._is_unsafe) {
return document.getElementById(el_id);
}
return this.root.getElementById(el_id);
}
}
export default Component;