@studiometa/js-toolkit
Version:
A set of useful little bits of JavaScript to boost your project! 🚀
478 lines (477 loc) • 13.4 kB
JavaScript
import {
getComponentElements,
getEventTarget,
addToQueue,
addInstance,
deleteInstance,
addToRegistry,
getInstances,
findClosestInstance
} from "./utils.js";
import {
ChildrenManager,
RefsManager,
ServicesManager,
EventsManager,
OptionsManager
} from "./managers/index.js";
import { getInstanceFromElement } from "../helpers/getInstanceFromElement.js";
import { noop, isDev, isFunction, isArray } from "../utils/index.js";
let id = 0;
class Base {
/**
* This is a Base instance.
*/
static $isBase = true;
/**
* Config.
* @link https://js-toolkit.studiometa.dev/api/configuration.html
*/
static config = {
name: "Base",
emits: [
// hook events
"before-mounted",
"mounted",
"after-mounted",
"updated",
"before-destroyed",
"destroyed",
"after-destroyed",
"terminated",
// default services' events
"ticked",
"scrolled",
"resized",
"moved",
"loaded",
"keyed"
]
};
/**
* The instance id.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#id
*/
$id;
/**
* The root element.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#el
*/
$el;
/**
* The state of the component.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#isMounted
*/
$isMounted = false;
/**
* Is the component currently mounting itself and its children?
* @private
*/
__isMounting = false;
/**
* Store the event handlers.
* @private
*/
__eventHandlers = /* @__PURE__ */ new Map();
/**
* Get the root instance of the app.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#root
*/
get $root() {
if (!this.$parent) {
return this;
}
let parent = this.$parent;
let root = this.$parent;
while (parent) {
if (!parent.$parent) {
root = parent;
}
parent = parent.$parent;
}
return root;
}
/**
* The parent instance if the current instance is registered as a child component in a parent component.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#parent
*/
get $parent() {
const parents = /* @__PURE__ */ new Set();
for (const instance of getInstances()) {
if (!instance.$config.components) continue;
if (Object.values(instance.$config.components).includes(this.constructor) && instance.$el.contains(this.$el)) {
parents.add(instance);
}
}
const closest = findClosestInstance(this.$el, parents);
return closest ?? null;
}
/**
* Merge configuration with the parents' configurations.
* @private
*/
get __config() {
let proto = Object.getPrototypeOf(this);
let { config } = proto.constructor;
while (proto.constructor.config && proto.constructor.$isBase) {
config = { ...proto.constructor.config, ...config };
if (proto.constructor.config.options) {
config.options = { ...proto.constructor.config.options, ...config.options };
}
if (proto.constructor.config.emits && config.emits) {
config.emits = [...proto.constructor.config.emits, ...config.emits];
}
proto = Object.getPrototypeOf(proto);
}
config.options = config.options ?? {};
config.refs = config.refs ?? [];
config.components = config.components ?? {};
return config;
}
/**
* The normalized config of the component.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#config
*/
get $config() {
return this.__config;
}
__services;
/**
* Services.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#services
*/
get $services() {
return this.__services;
}
__refs;
/**
* Refs.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#refs
*/
get $refs() {
return this.__refs.props;
}
__options;
/**
* Options.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#options
*/
get $options() {
return this.__options.props;
}
__children;
/**
* Children.
* @link https://js-toolkit.studiometa.dev/api/instance-properties.html#children
*/
get $children() {
return this.__children.props;
}
__events;
/**
* Small helper to log stuff.
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#log-content
*/
get $log() {
return this.$options.log ? window.console.log.bind(window, `[${this.$id}]`) : noop;
}
/**
* Small helper to make warning easier.
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#warn-content
*/
get $warn() {
return this.$options.log ? window.console.warn.bind(window, `[${this.$id}]`) : noop;
}
/**
* Small helper to debug information.
* @internal
*/
get __debug() {
return isDev && this.$options.debug ? window.console.log.bind(window, `[debug] [${this.$id}]`) : noop;
}
/**
* Get manager constructors.
* @internal
*/
get __managers() {
return {
ChildrenManager,
EventsManager,
OptionsManager,
RefsManager,
ServicesManager
};
}
/**
* Call an instance method and emit corresponding events.
* @internal
*/
__callMethod(method, ...args) {
if (isDev) {
this.__debug("callMethod", method, ...args);
}
this.$emit(method, ...args);
if (!isFunction(this[method])) {
return null;
}
if (isDev) {
this.__debug(method, this, ...args);
}
return this[method].call(this, ...args);
}
/**
* Test if the given event has been bound to the instance.
*
* @param {string} event The event's name.
* @return {boolean} Wether the given event has been bound or not.
* @internal
*/
__hasEvent(event) {
const eventHandlers = this.__eventHandlers.get(event);
return eventHandlers && eventHandlers.size > 0;
}
/**
* Class constructor where all the magic takes place.
*
* @param {HTMLElement} element The component's root element dd.
* @link https://js-toolkit.studiometa.dev/api/instantiation.html
*/
constructor(element) {
if (!element) {
if (isDev) {
throw new Error("The root element must be defined.");
}
return;
}
const { $config } = this;
if ($config.name === "Base") {
if (isDev) {
throw new Error("The `config.name` property is required.");
}
return;
}
addToRegistry($config.name, this.constructor);
this.$id = `${$config.name}-${id}`;
id += 1;
this.$el = element;
this.$el.__base__ ??= /* @__PURE__ */ new Map();
this.$el.__base__.set($config.name, this);
for (const service of ["Options", "Services", "Events", "Refs", "Children"]) {
this[`__${service.toLowerCase()}`] = new this.__managers[`${service}Manager`](this);
}
this.$on("before-mounted", () => {
addInstance(this);
});
this.$on("after-destroyed", () => {
deleteInstance(this);
});
if (isDev) {
this.__debug("constructor", this);
}
}
/**
* Trigger the `mounted` callback.
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#mount
*/
async $mount() {
if (this.$isMounted || this.__isMounting) {
return this;
}
this.__isMounting = true;
this.$emit("before-mounted");
if (isDev) {
this.__debug("$mount");
}
await Promise.all([
addToQueue(() => this.__children.registerAll()),
addToQueue(() => this.__refs.registerAll()),
addToQueue(() => this.__events.bindRootElement()),
addToQueue(() => this.__services.enableAll()),
addToQueue(() => this.__children.mountAll())
]);
await addToQueue(() => {
this.$isMounted = true;
this.__callMethod("mounted");
});
this.__isMounting = false;
this.$emit("after-mounted");
return this;
}
/**
* Update the instance children.
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#update
*/
async $update() {
if (isDev) {
this.__debug("$update");
}
await Promise.all([
// Undo
addToQueue(() => this.__refs.unregisterAll()),
addToQueue(() => this.__services.disableAll()),
// Redo
addToQueue(() => this.__children.registerAll()),
addToQueue(() => this.__refs.registerAll()),
addToQueue(() => this.__services.enableAll()),
// Update
addToQueue(() => this.__children.updateAll())
]);
await addToQueue(() => this.__callMethod("updated"));
return this;
}
/**
* Trigger the `destroyed` callback.
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#destroy
*/
async $destroy() {
if (!this.$isMounted) {
return this;
}
if (isDev) {
this.__debug("$destroy");
}
this.$emit("before-destroyed");
this.$isMounted = false;
await Promise.all([
addToQueue(() => this.__events.unbindRootElement()),
addToQueue(() => this.__refs.unregisterAll()),
addToQueue(() => this.__services.disableAll()),
addToQueue(() => this.__children.destroyAll())
]);
await addToQueue(() => this.__callMethod("destroyed"));
this.$emit("after-destroyed");
return this;
}
/**
* Terminate a child instance when it is not needed anymore.
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#terminate
*/
async $terminate() {
if (isDev) {
this.__debug("$terminate");
}
await Promise.all([
// First, destroy the component.
addToQueue(() => this.$destroy()),
// Execute the `terminated` hook if it exists
addToQueue(() => this.__callMethod("terminated")),
// Delete instance
addToQueue(() => this.$el.__base__.set(this.$config.name, "terminated"))
]);
}
/**
* Add an emitted event.
*
* @param {string} event The event name.
* @return {void}
* @internal
*/
__addEmits(event) {
const ctor = this.constructor;
if (isArray(ctor.config.emits)) {
ctor.config.emits.push(event);
} else {
ctor.config.emits = [event];
}
}
/**
* Remove an emitted event.
*
* @param {string} event The event name.
* @return {void}
* @internal
*/
__removeEmits(event) {
const ctor = this.constructor;
const index = ctor.config.emits.indexOf(event);
ctor.config.emits.splice(index, 1);
}
/**
* Bind a listener function to an event.
*
* @param {string} event
* Name of the event.
* @param {EventListenerOrEventListenerObject} listener
* Function to be called.
* @param {boolean|AddEventListenerOptions} [options]
* Options for the `removeEventListener` method.
* @return {() => void}
* A function to unbind the listener.
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#on-event-callback-options
*/
$on(event, listener, options) {
if (isDev) {
this.__debug("$on", event, listener, options);
}
let set = this.__eventHandlers.get(event);
if (!set) {
set = /* @__PURE__ */ new Set();
this.__eventHandlers.set(event, set);
}
set.add(listener);
const target = getEventTarget(this, event, this.__config);
target?.addEventListener(event, listener, options);
if (isDev) {
if (!target) {
console.warn(
`[${this.$id}]`,
`The "${event}" event is missing from the configuration and is not a native`,
`event for the root element of type \`${this.$el.constructor.name}\`.`
);
}
}
return () => {
this.$off(event, listener, options);
};
}
/**
* Unbind a listener function from an event.
*
* @param {string} event
* Name of the event.
* @param {EventListenerOrEventListenerObject} listener
* Function to be removed.
* @param {boolean|EventListenerOptions} [options]
* Options for the `removeEventListener` method.
* @return {void}
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#off-event-callback-options
*/
$off(event, listener, options) {
if (isDev) {
this.__debug("$off", event, listener);
}
this.__eventHandlers.get(event)?.delete(listener);
const target = getEventTarget(this, event, this.__config);
target?.removeEventListener(event, listener, options);
}
/**
* Emits an event.
*
* @param {string} event Name of the event.
* @param {any[]} args The arguments to apply to the functions bound to this event.
* @return {void}
* @link https://js-toolkit.studiometa.dev/api/instance-methods.html#emit-event-args
*/
$emit(event, ...args) {
if (isDev) {
this.__debug("$emit", event, args);
}
this.$el.dispatchEvent(
event instanceof Event ? event : new CustomEvent(event, { detail: args })
);
}
/**
* Register and mount all instances of the component.
* @link https://js-toolkit.studiometa.dev/api/static-methods.html#register-nameorselector-string
*/
static $register(nameOrSelector) {
addToRegistry(nameOrSelector ?? this.config.name, this);
return getComponentElements(nameOrSelector ?? this.config.name).map(
async (el) => getInstanceFromElement(el, this) ?? new this(el).$mount()
);
}
}
export {
Base
};
//# sourceMappingURL=Base.js.map