474 lines
15 KiB
JavaScript
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 };
|