pvbemu/web/toolkit/Component.js

474 lines
15 KiB
JavaScript

let register = Toolkit => Toolkit.Component =
// Base class from which all toolkit components are derived
class Component {
//////////////////////////////// Constants ////////////////////////////////
// Non-attributes
static NON_ATTRIBUTES = new Set([
"checked", "disabled", "doNotFocus", "group", "hover", "max", "min",
"name", "orientation", "overflowX", "overflowY", "tag", "text",
"value", "view", "visibility", "visible"
]);
///////////////////////// Initialization Methods //////////////////////////
constructor(app, options = {}) {
// Configure element
this.element = document.createElement(options.tag || "div");
this.element.component = this;
for (let entry of Object.entries(options)) {
if (
Toolkit.Component.NON_ATTRIBUTES.has(entry[0]) ||
entry[0] == "type" && options.tag != "input"
) continue;
if (entry[0] == "style" && entry[1] instanceof Object)
Object.assign(this.element.style, entry[1]);
else this.element.setAttribute(entry[0], entry[1]);
}
// Configure instance fields
this._isLocalized = false;
this.app = app;
this.display = options.style && options.style.display;
this.style = this.element.style;
this.text = null;
this.visibility = !!options.visibility;
this.visible = !("visible" in options) || options.visible;
}
///////////////////////////// Public Methods //////////////////////////////
// Add a child component
add(comp) {
// Error checking
if (
!(comp instanceof Toolkit.Component) ||
comp instanceof Toolkit.App ||
comp.app != (this.app || this)
) return false;
// No components have been added yet
if (!this.children)
this.children = [];
// The child already has a parent: remove it
if (comp.parent) {
comp.parent.children.splice(
comp.parent.children.indexOf(comp), 1);
}
// Add the component to self
this.children.push(comp);
this.append(comp.element);
comp.parent = this;
return true;
}
// Register an event listener on the element
addEventListener(type, listener) {
// No event listeners have been registered yet
if (!this.listeners)
this.listeners = new Map();
if (!this.listeners.has(type))
this.listeners.set(type, []);
// The listener has already been registered for this event
let listeners = this.listeners.get(type);
if (listeners.indexOf(listener) != -1)
return listener;
// Resize events are implemented by a ResizeObserver
if (type == "resize") {
if (!this.resizeObserver) {
this.resizeObserver = new ResizeObserver(()=>
this.element.dispatchEvent(new Event("resize")));
this.resizeObserver.observe(this.element);
}
}
// Visibility events are implemented by an IntersectionObserver
else if (type == "visibility") {
if (!this.visibilityObserver) {
this.visibilityObserver = new IntersectionObserver(
()=>this.element.dispatchEvent(Object.assign(
new Event("visibility"),
{ visible: this.isVisible() }
)),
{ root: document.body }
);
this.visibilityObserver.observe(this.element);
}
}
// Register the listener with the element
listeners.push(listener);
this.element.addEventListener(type, listener);
return listener;
}
// Component cannot be interacted with
get disabled() { return this.element.hasAttribute("disabled"); }
set disabled(disabled) { this.setDisabled(disabled); }
// Move focus into the component
focus() {
this.element.focus({ preventScroll: true });
}
// Specify whether the component is localized
get isLocalized() { return this._isLocalized; }
set isLocalized(isLocalized) {
if (isLocalized == this._isLocalized)
return;
this._isLocalized = isLocalized;
(this instanceof Toolkit.App ? this : this.app)
.localize(this, isLocalized);
}
// Determine whether an element is actually visible
isVisible(element = this.element) {
if (!document.body.contains(element))
return false;
for (; element instanceof Element; element = element.parentNode) {
let style = getComputedStyle(element);
if (style.display == "none" || style.visibility == "hidden")
return false;
}
return true;
}
// Produce an ordered list of registered event listeners for an event type
listEventListeners(type) {
return this.listeners && this.listeners.has(type) &&
this.listeners.get(type).list.slice() || [];
}
// Remove a child component
remove(comp) {
if (comp.parent != this || !this.children)
return false;
let index = this.children.indexOf(comp);
if (index == -1)
return false;
this.children.splice(index, 1);
comp.element.remove();
comp.parent = null;
return true;
}
// Unregister an event listener from the element
removeEventListener(type, listener) {
// Not listening to events of the specified type
if (!this.listeners || !this.listeners.has(type))
return listener;
// Listener is not registered
let listeners = this.listeners.get(type);
let index = listeners.indexOf(listener);
if (index == -1)
return listener;
// Unregister the listener
this.element.removeEventListener(listener);
listeners.splice(index, 1);
// Delete the ResizeObserver
if (
type == "resize" &&
listeners.list.length == 0 &&
this.resizeObserver
) {
this.resizeObserver.disconnect();
delete this.resizeObserver;
}
// Delete the IntersectionObserver
else if (
type == "visibility" &&
listeners.list.length == 0 &&
this.visibilityObserver
) {
this.visibilityObserver.disconnect();
delete this.visibilityObserver;
}
return listener;
}
// Specify accessible name
setLabel(text, localize) {
// Label is another component
if (
text instanceof Toolkit.Component ||
text instanceof HTMLElement
) {
this.element.setAttribute("aria-labelledby",
(text.element || text).id);
this.setString("label", null, false);
}
// Label is the given text
else {
this.element.removeAttribute("aria-labelledby");
this.setString("label", text, localize);
}
}
// Specify role description text
setRoleDescription(text, localize) {
this.setString("roleDescription", text, localize);
}
// Specify inner text
setText(text, localize) {
this.setString("text", text, localize);
}
// Specify tooltip text
setTitle(text, localize) {
this.setString("title", text, localize);
}
// Specify substitution text
substitute(key, text = null, recurse = false) {
if (text === null) {
if (this.substitutions.has(key))
this.substitutions.delete(key);
} else this.substitutions.set(key, [ text, recurse ]);
if (this.localize instanceof Function)
this.localize();
}
// Determine whether the element wants to be visible
get visible() {
let style = this.element.style;
return style.display != "none" && style.visibility != "hidden";
}
// Specify whether the element is visible
set visible(visible) {
visible = !!visible;
// Visibility is not changing
if (visible == this.visible)
return;
let comps = [ this ].concat(
Array.from(this.element.querySelectorAll("*"))
.map(c=>c.component)
).filter(c=>
c instanceof Toolkit.Component &&
c.listeners &&
c.listeners.has("visibility")
)
;
let prevs = comps.map(c=>c.isVisible());
// Allow the component to be shown
if (visible) {
if (!this.visibility) {
if (this.display)
this.element.style.display = this.display;
else this.element.style.removeProperty("display");
} else this.element.style.removeProperty("visibility");
}
// Prevent the component from being shown
else {
this.element.style.setProperty(
this.visibility ? "visibility" : "display",
this.visibility ? "hidden" : "none"
);
}
for (let x = 0; x < comps.length; x++) {
let comp = comps[x];
visible = comp.isVisible();
if (visible == prevs[x])
continue;
comp.element.dispatchEvent(Object.assign(
new Event("visibility"),
{ visible: visible }
));
}
}
///////////////////////////// Package Methods /////////////////////////////
// Add a child component to the primary client region of this component
append(element) {
this.element.append(element instanceof Toolkit.Component ?
element.element : element);
}
// Determine whether a component or element is a child of this component
contains(child) {
return this.element.contains(child instanceof Toolkit.Component ?
child.element : child);
}
// Generate a list of focusable descendant elements
getFocusable(element = this.element) {
let cache;
return Array.from(element.querySelectorAll(
"*:is(a[href],area,button,details,input,textarea,select," +
"[tabindex='0']):not([disabled])"
)).filter(e=>{
for (; e instanceof Element; e = e.parentNode) {
let style =
(cache || (cache = new Map())).get(e) ||
cache.set(e, getComputedStyle(e)).get(e)
;
if (style.display == "none" || style.visibility == "hidden")
return false;
}
return true;
});
}
// Specify the inner text of the primary client region of this component
get innerText() { return this.element.textContent; }
set innerText(text) { this.element.innerText = text; }
// Determine whether an element is focusable
isFocusable(element = this.element) {
return element.matches(
":is(a[href],area,button,details,input,textarea,select," +
"[tabindex='0'],[tabindex='-1']):not([disabled])"
);
}
// Determine whether a pointer event is within the element
isWithin(e, element = this.element) {
let bounds = element.getBoundingClientRect();
return (
e.clientX >= bounds.left && e.clientX < bounds.right &&
e.clientY >= bounds.top && e.clientY < bounds.bottom
);
}
// Common processing for localizing the accessible name
localizeLabel(element = this.element) {
// There is no label or the label is another element
if (!this.label || element.hasAttribute("aria-labelledby")) {
element.removeAttribute("aria-label");
return;
}
// Localize the label
let text = this.label;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("aria-label", text);
}
// Common processing for localizing the accessible role description
localizeRoleDescription(element = this.element) {
// There is no role description
if (!this.roleDescription) {
element.removeAttribute("aria-roledescription");
return;
}
// Localize the role description
let text = this.roleDescription;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("aria-roledescription", text);
}
// Common processing for localizing inner text
localizeText(element = this.element) {
// There is no title
if (!this.text) {
element.innerText = "";
return;
}
// Localize the text
let text = this.text;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.innerText = text;
}
// Common processing for localizing the tooltip text
localizeTitle(element = this.element) {
// There is no title
if (!this.title) {
element.removeAttribute("title");
return;
}
// Localize the title
let text = this.title;
text = !text[1] ? text[0] :
this.app.localize(text[0], this.substitutions);
element.setAttribute("title", text);
}
// Common handler for configuring whether the component is disabled
setDisabled(disabled, element = this.element) {
element[disabled ? "setAttribute" : "removeAttribute"]
("disabled", "");
element.setAttribute("aria-disabled", disabled ? "true" : "false");
}
// Specify display text
setString(key, value, localize = true) {
// There is no method to update the display text
if (!(this.localize instanceof Function))
return;
// Working variables
let app = this instanceof Toolkit.App ? this : this.app;
// Remove the string
if (value === null) {
if (app && this[key] != null && this[key][1])
app.localize(this, false);
this[key] = null;
}
// Set or replace the string
else {
if (app && localize && (this[key] == null || !this[key][1]))
app.localize(this, true);
this[key] = [ value, localize ];
}
// Update the display text
this.localize();
}
// Retrieve the substitutions map
get substitutions() {
if (!this._substitutions)
this._substitutions = new Map();
return this._substitutions;
}
};
export { register };