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