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