316 lines
9.5 KiB
JavaScript
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);
|
||
|
}
|
||
|
|
||
|
};
|