shrooms-vb-web/toolkit/Component.js

316 lines
9.5 KiB
JavaScript

// Discrete UI widget
export default (Toolkit,_package)=>class Component {
// Instance fields
#_app; // Containing app
#_children; // Child components
#_element; // Managed HTML element
#_eventListeners; // Active event listeners
#_parent; // Containing component
#_substitutions; // Subtituted text entries
#_visibility; // Control visible property with CSS visibility
#_visible; // CSS display value to restore visibility
///////////////////////// Initialization Methods //////////////////////////
static {
_package.Component = {
onAdd : c=>c.#onAdd(),
onLocalize: (c,l)=>c.#onLocalize(l),
setParent : (c,p)=>c.#_parent=p
};
}
constructor(app, overrides) {
// Error checking
if (
!(app instanceof Toolkit.App) &&
!(this instanceof Toolkit.App)
) throw new TypeError("Must supply a Toolkit.App.");
// Working variables
overrides = Object.assign({}, overrides ?? {});
let tagName = overrides.tagName ?? "div";
// Instance fields
this.#_app = app;
this.#_children = null;
this.#_element = document.createElement(tagName);
this.#_parent = null;
this.#_substitutions = null;
this.visibility = overrides.visibility;
// Register the element with the Toolkit environment
this.element[_package.componentKey] = this;
// Apply overrides
Object.assign(this.#_element.style, overrides.style ?? {});
for (let entry of Object.entries(overrides)) {
let key = entry[0];
let value = entry[1];
switch (key) {
// Properties that are handled in other ways
case "style":
case "tagName":
case "visibility":
break;
// Properties of the component
case "enabled":
case "focusable":
case "id":
case "visible":
this[key] = value;
break;
// Properties with special handling
case "ariaLabelledBy":
if (value != null)
this.#_element.setAttribute("aria-labelledby", value);
else this.#_element.removeAttribute("aria-labelledby");
break;
// Properties of the element
default:
this.#_element[key] = value;
}
}
// Register the component with the app
if (app != null)
_package.App.onCreate(app, this);
}
/////////////////////////////// Properties ////////////////////////////////
// Containing Toolkit.App
get app() { return this.#_app; }
// Child components
get children() { return (this.#_children ?? []).slice(); }
// HTML class list
get classList() { return this.#_element.classList; }
// HTML element
get element() { return this.#_element; }
// HTML element ID
get id() { return this.#_element.id || null; }
set id(value) { this.#_element.id = String(value ?? ""); }
// Containing Toolkit.Component
get parent() { return this.#_parent; }
// HTML element style declaration state
get style() { return this.#_element.style; }
// Visibility control
get visibility() { return this.#_visibility; }
set visibility(value) {
value = !!value;
// Property is not changing
if (value == this.#_visibility)
return;
// Update the visibility mode
let visible = this.visible;
this.#_visibility = value;
this.visible = visible;
}
// HTML element visibility
get visible() { return this.#_visible == null; }
set visible(value) {
value = !!value;
// Property is not changing
if (value == (this.#_visible == null))
return;
// Show the element
if (value) {
if (this.#_visibility)
this.#_element.style.removeProperty("visibility");
else if (this.#_visible == "")
this.#_element.style.removeProperty("display");
else this.#_element.style.display = this.#_visible;
this.#_visible = null;
}
// Hide the element
else {
this.#_visible = this.#_element.style.display;
if (this.#_visibility)
this.#_element.style.visibility = "hidden";
else this.#_element.style.display = "none";
}
}
///////////////////////////// Event Handlers //////////////////////////////
// Component added to parent, shold be overridden as needed
#onAdd() {}
// Configure display text, should be overridden as needed
#onLocalize() {}
///////////////////////////// Public Methods //////////////////////////////
// Add a child component
add(comp) {
// Error checking
if (!(comp instanceof Toolkit.Component))
throw new TypeError("Component must be a Toolkit.Component.");
if (comp.app != this && comp.app != this.#_app)
throw new RangeError("Component must belong to the same App.");
// TODO: Disassociate the component from its current parent
// Associate the component
(this.#_children ??= []).push(comp);
comp.#_parent = this;
if (arguments[1] === false)
return; // Undocumented: prevent element management
this.#_element.append(comp.element);
comp.#onAdd();
}
// Register an event listener
addEventListener(type, listener) {
// Input validation
type = String(type);
if (!(listener instanceof Function))
throw new TypeError("listener must be a function.");
// The event listener is already registered
if (this.#_eventListeners?.get(type)?.includes(listener))
return;
// Establish a set for the listener type
this.#_eventListeners ??= new Map();
if (!this.#_eventListeners.has(type)) {
let listeners = [];
listeners.inner = new Map();
this.#_eventListeners.set(type, listeners);
// Dark events implemented via MediaQueryList
if (type == "dark") {
listeners.handler =
e=>{ this.#_emit("dark", { isDark: e.matches }); };
_package.darkQuery
.addEventListener("change", listeners.handler);
}
// Resize events implemented via ResizeObserver
else if (type == "resize") {
listeners.handler = new ResizeObserver(()=>{
this.#_emit("resize",
{ bounds: this.#_element.getBoundingClientRect() });
});
listeners.handler.observe(this.#_element);
}
// Visibility events implemented via IntersectionObserver
else if (type == "visibility") {
listeners.handler = new ResizeObserver(()=>{
this.#_emit("visibility",
{ visible: Toolkit.isVisible(this.#_element) });
});
listeners.handler.observe(this.#_element);
}
}
// Register the listener
let listeners = this.#_eventListeners.get(type);
let inner = e=>{ e[Toolkit.target] = this; listener(e); }
listeners.push(listener);
listeners.inner.set(listener, inner);
this.#_element.addEventListener(type, inner);
}
// Destroy a component and all of its application references
delete() {
// TODO: Remove from parent
this.#_element.remove();
let app = this.#_app;
if (app != null) {
this.#_app = null;
_package.App.onDelete(app, this);
}
}
// Retrieve the value for a substitution
getSubstitution(key) {
return key == null ? null :
this.#_substitutions?.get(String(key)) ?? null;
}
// Determine whether the element is fully visible
isVisible() {
return Toolkit.isVisible(this.#_element);
}
// Generate a list of focusable descendant elements
listFocusable() {
return Toolkit.listFocusable(this.#_element);
}
// Register or remove a substitution
setSubstitution(key, value) {
// Error checking
if (key == null)
throw new TypeError("Key cannot be null.");
// Input validation
key = String(key);
if (!(value instanceof RegExp))
value = String(value);
// Remove an association
if (value == null) {
if (this.#_substitutions?.has(key)) {
this.#_substitutions.delete(key);
if (this.#_substitutions.length == 0)
this.#_substitutions = null;
}
return;
}
// Register an association
(this.#_substitutions ??= new Map()).set(key, value);
// Update any display text
this.#onLocalize();
}
///////////////////////////// Private Methods /////////////////////////////
// Generate a custom event object
#_emit(type, properties) {
let e = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(e, { target: { value: this.#_element } });
Object.assign(e, properties);
this.#_element.dispatchEvent(e);
}
};