Establish application infrastructure

This commit is contained in:
2025-02-18 16:39:36 -06:00
commit cd9a0ecc18
21 changed files with 6928 additions and 0 deletions
+215
View File
@@ -0,0 +1,215 @@
// Top-level application container
export default (Toolkit,_package)=>class App extends Toolkit.Component {
// Instance fields
#components; // Registered Toolkit.Components
#locale; // Current display text dictionary
#locales; // Registered display text dictionaries
#title; // Application title
///////////////////////// Initialization Methods //////////////////////////
static {
_package.App = {
localize: (a,c)=>a.#localize(c),
onCreate: (a,c)=>a.#onCreate(c),
onDelete: (a,c)=>a.#onDelete(c)
};
}
constructor(overrides) {
super(null, _package.override(overrides, { className: "tk-app" }));
this.#components = new Set();
this.#locale = null;
this.#locales = new Map();
this.#title = null;
}
/////////////////////////////// Properties ////////////////////////////////
// Display text dictionary
get locale() { return this.#locale?.get("id"); }
set locale(value) {
// Unset the locale
if (value == null) {
if (this.#locale == null)
return;
this.#locale = null;
}
// Change to a different locale
else {
value = value == null ? null : String(value);
if (this.#locale?.get("id") == value)
return; // Same locale specified
if (!this.#locales.has(value))
throw new RangeError("No locale with ID " + value);
this.#locale = this.#locales.get(value);
}
// Update all display text
this.#localize();
}
// Display title
get title() { return this.#title; }
set title(value) {
if (value != null && !(value instanceof RegExp))
value = String(value);
this.#title = value;
this.#onLocalize();
}
///////////////////////////// Public Methods //////////////////////////////
// Register translation text
addLocale(data) {
// Error checking
if (!(data instanceof Object))
throw new TypeError("Data must be an object.");
// Working variables
let locale = new Map();
let objects = [ [null,data] ];
// Process all objects
while (objects.length != 0) {
let object = objects.shift();
let prefix = object[0] ? object[0] + "." : "";
// Process all members of the object
for (let entry of Object.entries(object[1])) {
let key = prefix + entry[0];
let value = entry[1];
// Add the new object to he queue
if (value instanceof Object) {
objects.push([ key, entry[1] ]);
continue;
}
// Error checking
if (typeof(value) != "string")
throw new TypeError("Non-string value encountered: "+key);
// Register the localization value
locale.set(key, new RegExp(value));
}
}
// Validate "id"
let id = locale.get("id");
if (id == null)
throw new Error("Locale does not contain \"id\" member.");
// Register the locale
this.#locales.set(id.source, locale);
return id;
}
// Resolve the value of some display text
translate(text, comp = null) {
// Error checking
if (comp != null) {
if (!(comp instanceof Toolkit.Component))
throw new TypeError("Component must be a Toolkit.Component.");
if (comp != this && comp.app != this)
throw new RangeError("Compoment must belong to this App.");
}
// Nothing to resolve
if (text == null)
return null;
// Input validation
if (!(text instanceof RegExp))
text = String(text);
// Working variables
let locale = this.#locale;
let ret = [ text ];
// Process all substitutions
for (let x = 0; x < ret.length; x++) {
let part = ret[x];
// Do not perform substitutions
if (!(part instanceof RegExp))
continue;
// Working variables
part = part.source;
// Locate the close of the innermost substitution
let close = part.indexOf("}}");
if (close == -1) {
if (part.indexOf("{{") != -1)
throw new Error("Found {{ without matching }}.");
ret[x] = part;
continue;
}
// Locate the opening of the innermost substitution
let open = part.substring(0, close).lastIndexOf("{{");
if (open == -1)
throw new Error("Found }} without matching {{.");
// Working variables
let after = part.substring(close + 2);
let before = part.substring(0, open);
let key = part.substring(open + 2, close).trim();
let value = comp?.getSubstitution(key) ?? locale?.get(key) ??
"{{\u00d7" + key.toUpperCase() + "\u00d7}}";
let within = value instanceof RegExp ? value.source : value;
// Compose the replacement text
part = before + within + after;
if (value instanceof RegExp)
ret[x--] = new RegExp(part);
else ret[x] = part;
}
return ret.join("");
}
///////////////////////////// Event Handlers //////////////////////////////
// Component created
#onCreate(comp) {
this.#components.add(comp);
}
// Component deleted
#onDelete(comp) {
this.#components.delete(comp);
}
// Configure display text
#onLocalize() {
document.title = this.translate(this.#title, this) ?? "";
}
///////////////////////////// Package Methods /////////////////////////////
// Update the display text for one or all components
#localize(comp = null) {
for (comp of (comp == null ? this.#components : [comp]))
_package.Component.onLocalize(comp);
}
};
+315
View File
@@ -0,0 +1,315 @@
// 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);
}
};
+280
View File
@@ -0,0 +1,280 @@
// Model state manager for checkboxes or radio buttons
export default (Toolkit,_package)=>class Group {
// Instance fields
#_app; // Managed Toolkit.App
#_byComponent; // Mapping of values keyed by component
#_byValue; // Mapping of component sets keyed by value
#_checked; // Set of checked values
#_eventListeners; // Active event listeners
#_type; // Group type, either "checkbox" or "radio"
///////////////////////// Initialization Methods //////////////////////////
static {
_package.Group = {
onAction: (g,c)=>g.#_onAction(c)
};
}
constructor() {
this.#_app = null;
this.#_byComponent = new Map();
this.#_byValue = new Map();
this.#_checked = new Set();
this.#_eventListeners = null;
this.#_type = null;
}
/////////////////////////////// Properties ////////////////////////////////
// Number of components in the group
get size() { return this.#_byComponent.size; }
// Array of checked values or singular radio value (null if none)
get value() {
let ret = [... this.#_checked];
return this.#_type == "checkbox" ? ret : ret[0] ?? null;
}
// Specify the current checkbox values or radio value
set value(value) {
// Error checking
if (this.#_type == null)
throw new Error("There are no components in the group.");
// Update the radio value
if (this.#_type == "radio") {
if (value === null)
this.set(this.value, false);
this.set(value, true);
return;
}
// Update the checkbox values
let checked = new Set(Array.isArray(value) ? value : [ value ]);
for (value of this.#_byValue.keys())
this.set(value, checked.has(value));
}
///////////////////////////// Public Methods //////////////////////////////
// Component iterator
[Symbol.iterator]() { return this.components(); }
// Add a component to the group
add(ctrl, value) {
let size = this.#_byComponent.size;
// Error checking
if (this.#_byComponent.has(ctrl))
throw new Error("Control is already part of this group.");
if (!(ctrl instanceof Toolkit.Component))
throw new Error("Control must be a Toolkit.Component.");
if (this.#_isOtherGroup(ctrl))
throw new Error("Control is already part of another group.");
if (size != 0 && ctrl.app != this.#_app) {
throw new Error("All controls in the group must belong " +
"to the same Toolkit.App.");
}
// Determine the group type of the item being added
let type = null;
if (ctrl instanceof Toolkit.MenuItem) {
if (ctrl.type == "checkbox")
type = "checkbox";
else if (ctrl.type == "radio")
type = "radio";
}
// Error checking
if (type == null) {
throw new Error("Control must be of a checkbox or " +
"radio button or variety.");
}
if (size != 0 && type != this.#_type) {
throw new Error("All controls in the group must be of the same " +
"variety, either checkbox or radio button.");
}
// First component in the group
if (size == 0) {
this.#_app = ctrl.app;
this.#_type = type;
}
// Register the component
this.#_byComponent.set(ctrl, value);
if (ctrl instanceof Toolkit.MenuItem)
_package.MenuItem.setGroup(ctrl, this);
// Register the value, add the component to the value's list
if (!this.#_byValue.has(value))
this.#_byValue.set(value, new Set());
this.#_byValue.get(value).add(ctrl);
if (ctrl instanceof Toolkit.MenuItem)
_package.MenuItem.setChecked(ctrl, this.#_checked.has(value));
}
// 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);
}
// Register the listener
let listeners = this.#_eventListeners.get(type);
let inner = e=>{
e[Toolkit.group ] = this;
e[Toolkit.target] = Toolkit.component(e.target);
listener(e);
};
listeners.push(listener);
listeners.inner.set(listener, inner);
}
// Component iterator
*components() {
let ret = [... this.#_byComponent.keys()];
for (let ctrl of ret)
yield ctrl;
}
// Determine whether a model value is currently checked
is(value) {
return this.#_checked.has(value);
}
// Remove a component from the group
remove(ctrl) {
// Error checking
if (!this.#_byComponent.has(ctrl))
return;
// Working variables
let value = this.#_byComponent.get(ctrl);
let components = this.#_byValue .get(value);
// Unregister the component
this.#_byComponent.delete(ctrl);
// No components remain
if (this.#_byComponent.size == 0) {
this.#_app = null;
this.#_type = null;
}
// Un-register the value
components.delete(ctrl);
if (components.size == 0) {
this.#_checked.delete(value);
this.#_byValue.delete(value);
}
// Detach the component from the group
if (ctrl instanceof MenuItem)
_package.MenuItem.setGroup(ctrl, null);
}
// Specify whether a model value is currently checked
set(value, checked) {
// Error checking
if (!this.#_byValue.has(value))
return;
// Checked state is not changing
checked = !!checked;
if (this.#_checked.has(value) == checked)
return;
// Un-check the previous radio value
if (this.#_type == "radio" && this.#_checked.size == 1) {
let checked = [... this.#_checked][0];
if (checked != value)
this.set(checked, false);
}
// Update components
for (let ctrl of this.#_byValue.get(value)) {
if (ctrl instanceof Toolkit.MenuItem)
_package.MenuItem.setChecked(ctrl, checked);
}
// Update model
this.#_checked[checked ? "add" : "delete"](value);
}
// Value iterator
*values() {
if (this.#_byComponent.size == 0)
return;
let ret = this.values;
if (this.#_type != "checkbox")
ret = [ ret ];
for (let value of ret)
yield value;
}
///////////////////////////// Package Methods /////////////////////////////
// Control was activated by the user
#_onAction(ctrl) {
this.set(this.#_byComponent.get(ctrl),
this.type == "radio" ? true : !this.#_checked.has(ctrl));
if (!this.#_eventListeners.has("action"))
return;
let listeners = this.#_eventListeners.get("action");
for (let listener of listeners) {
listener = listeners.inner.get(listener);
let e = new Event("group", { bubbles: true, cancelable: true });
Object.defineProperties(e, { target: { value: ctrl.element } });
listener(e);
}
}
///////////////////////////// Private Methods /////////////////////////////
// Generate a custom event object
#_emit(type, ctrl, properties) {
let e = new Event(type, { bubbles: true, cancelable: true });
Object.defineProperties(e, { target: { value: ctrl.element } });
Object.assign(e, properties);
return e;
}
// Determine whether a component belongs to another Group
#_isOtherGroup(ctrl) {
let group = null;
if (ctrl instanceof Toolkit.MenuItem)
group = ctrl.group;
return group != null && group != this;
}
};
+299
View File
@@ -0,0 +1,299 @@
// Window menu bar
export default (Toolkit,_package)=>class MenuBar extends Toolkit.Component {
// Instance fields
#_ariaLabel; // Accessible label
///////////////////////// Initialization Methods //////////////////////////
static {
_package.MenuBar = {
activate : (c,i,f,x)=>c.#_activate(i,f,x),
children : c=>this.#_children(c),
onLocalize: c=>c.#onLocalize()
};
}
constructor(app, overrides) {
super(app, _package.override(overrides, {
ariaOrientation: "horizontal",
className : "menu-bar",
role : "menubar",
style : {
display : "flex",
flexWrap: "wrap"
}
}));
// Configure instance fields
this.#_ariaLabel = null;
// Configure event listeners
this.addEventListener("focusout", e=>this.#_onBlur (e));
this.addEventListener("keydown" , e=>this.#_onKeyDown(e));
}
/////////////////////////////// Properties ////////////////////////////////
// Accessible label
get ariaLabel() { return this.#_ariaLabel; }
set ariaLabel(value) {
if (value != null && !(value instanceof RegExp))
value = String(value);
this.#_ariaLabel = value;
this.#onLocalize();
}
///////////////////////////// Event Handlers //////////////////////////////
// Focus out
#_onBlur(e) {
if (this.element.contains(e.relatedTarget))
return;
}
// Key pressed
#_onKeyDown(e) {
let item = Toolkit.component(e.originalTarget);
// Processing by key
switch (e.key) {
case " ":
case "Enter":
case "Pointer":
this.#_activate(item, e.key == "Enter", e.key != " ");
break;
case "ArrowDown":
// Expand the sub-menu and focus its first item
if (item.parent instanceof Toolkit.MenuBar) {
item.expanded = true;
this.#_focusBookend(item, false);
}
// Focus the next available sibling
else this.#_focusCycle(item, false);
break;
case "ArrowUp":
// Focus the previous available sibling
if (!(item.parent instanceof Toolkit.MenuBar))
this.#_focusCycle(item, true);
break;
case "ArrowRight":
// Focus the next available sibling
if (item.parent instanceof Toolkit.MenuBar)
this.#_focusCycle(item, false);
// Expand the sub-menu and focus its first item
else if (item.children.length != 0) {
item.expanded = true;
this.#_focusBookend(item, false);
}
// Focus the next top-level sibling's first sub-item
else {
while (!(item.parent instanceof Toolkit.MenuBar))
item = item.parent;
let expanded = item.expanded;
let next = this.#_focusCycle(item, false);
if (!(
expanded &&
next != null &&
next != item &&
next.children.length != 0
)) break;
next.expanded = true;
this.#_focusBookend(next, false);
}
break;
case "ArrowLeft":
// Focus the previous available sibling
if (item.parent instanceof Toolkit.MenuBar)
this.#_focusCycle(item, true);
// Focus the previous top-level sibling's first sub-item
else if (item.parent.parent instanceof Toolkit.MenuBar) {
while (!(item.parent instanceof Toolkit.MenuBar))
item = item.parent;
let expanded = item;
let next = this.#_focusCycle(item, true);
if (!(expanded && next != null && next != item))
break;
next.expanded = true;
this.#_focusBookend(next, false);
}
// Collapse the sub-menu
else item.parent.element.focus();
break;
case "End": // Focus the last sibling menu item
this.#_focusBookend(item.parent, true);
break;
case "Escape":
// Collapse the sub-menu
if (item.expanded) {
item.expanded = false;
}
// Collapse the current menu and focus on the menu item
else if (!(item.parent instanceof Toolkit.MenuBar)) {
item.parent.expanded = false;
item.parent.element.focus();
}
// Restore focus to the previous element
else this.#_restoreFocus();
break;
case "Home": // Focus the first sibling menu item
this.#_focusBookend(item.parent, false);
break;
default: { // Focus the next item that starts with the typed key
let key = e.key.toLowerCase();
if (key.length != 1)
return; // Allow the event to bubble
this.#_focusCycle(item, false, key);
}
}
// Event has been handled
Toolkit.consume(e);
}
// Configure display text
#onLocalize() {
this.element.ariaLabel =
this.app.translate(this.#_ariaLabel, this) ?? "";
}
///////////////////////////// Public Methods //////////////////////////////
// Add a menu item
add(comp) {
if (!(comp instanceof Toolkit.MenuItem))
throw new TypeError("Component must be a Toolkit.MenuItem.");
super.add(comp);
comp.element.tabIndex =
_package.MenuBar.children(this).length == 1 ? 0 : -1;
}
///////////////////////////// Private Methods /////////////////////////////
// Activate a menu item
#_activate(item, focus, close) {
// Error checking
if (item.disabled)
return;
//switch (item.constructor) {
// case Toolkit.CheckBoxMenuItem : return;
// case Toolkit.RadioButtonMenuItem: return;
//}
// Item does not have a sub-menu
if (_package.MenuBar.children(item).length == 0) {
_package.MenuItem.activate(item, true);
if (close || item.type == "button")
this.#_restoreFocus();
return;
}
// Collapse any other open sub-menu
let prev = item.parent.children.find(c=>c.expanded);
if (prev != null && prev != item)
prev.expanded = false;
// Expand the sub-menu
item.expanded = true;
if (focus)
this.#_focusBookend(item, false);
}
// Select eligible menu items
static #_children(menu) {
return menu == null ? [] : menu.children.filter(c=>c.visible);
}
// Collapse other sub-menus and expand a given sub-menu
#_expand(item) {
let other = item.parent.children.find(c=>c.expanded && c != item);
if (other != null)
other.expanded = false;
if (item.children.length != 0)
other.expanded = true;
}
// Move focus to the first or last menu item
#_focusBookend(menu, end) {
let children = _package.MenuBar.children(menu);
if (children.length == 0)
return null;
let item = children[end ? children.length - 1 : 0];
item.element.focus();
return item;
}
// Move focus to the next sibling of a menu item
#_focusCycle(item, reverse, key = null) {
let children = _package.MenuBar.children(item.parent).filter(c=>
(key == null || _package.MenuItem.startsWith(c, key)));
// No sibling menu items are eligible
if (children.length == 0)
return null;
// Working variables
let index = children.indexOf(item);
let step = children.length + (reverse ? -1 : 1);
// Find the next eligible sibling in the list
let sibling = children[(index + step) % children.length];
sibling.element.focus();
return sibling;
}
// Retrieve the root-level menu bar containing a menu item
#_menuBar(item) {
while (!(item instanceof Toolkit.MenuBar))
item = item.parent;
return item;
}
// Restore focus to the previous component
#_restoreFocus() {
let item = _package.MenuBar.children(this).find(c=>c.expanded)
if (item != null)
item.expanded = false;
}
};
+552
View File
@@ -0,0 +1,552 @@
// Sub-menu container
let Menu = (Toolkit,_package)=>class Menu extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, overrides) {
super(app, _package.override(overrides, {
className : "menu",
role : "menu",
visibility: true,
visible : false,
style : {
display : "flex",
flexDirection: "column",
position : "absolute"
}
}));
// Configure event handlers
this.addEventListener("focusout" , e=>this.#_onFocusOut(e));
this.addEventListener("pointerdown", e=>Toolkit.consume (e));
}
///////////////////////////// Event Handlers //////////////////////////////
// Focus lost
#_onFocusOut(e) {
this.parent?.element?.dispatchEvent(
new FocusEvent("focusout", { relatedTarget: e.relatedTarget }));
}
};
// Menu separator
let Separator = (Toolkit,_package)=>class Separator extends Toolkit.Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(app, overrides) {
super(app, _package.override(overrides, {
ariaOrientation: "horizontal",
className : "menu-separator",
role : "separator"
}));
}
/////////////////////////////// Properties ////////////////////////////////
get type() { return "separator"; }
};
// Menu item
export default (Toolkit,_package)=>class MenuItem extends Toolkit.Component {
// Inner classes
static #_Menu = Menu (Toolkit, _package);
static #_Separator = Separator(Toolkit, _package);
// Instance fields
#_client; // Interior content element
#_columns; // Content elements
#_drag; // Click and drag context
#_group; // Containing Toolkit.Group
#_icon; // Icon image URL
#_menu; // Pop-up menu element
#_resizer; // Column sizing listener
#_start; // Character that the display text starts with
#_text; // Display text
#_value; // Radio button value
///////////////////////// Initialization Methods //////////////////////////
static {
_package.MenuItem = {
activate : (c,f)=>c.#_activate(f),
menu : c=>c.#_menu,
onLocalize: c=>c.#_onLocalize(),
setChecked: (c,v)=>c.#_setChecked(v),
setGroup : (c,g)=>c.#_group=g,
startsWith: (c,k)=>c.#_startsWith(k)
};
}
constructor(app, overrides) {
overrides = _package.override(overrides, {
className: "menu-item"
});
let underrides = _package.underride(overrides, {
group: null,
text : null,
type : "button"
});
super(app, overrides);
// Configure instance fields
this.disabled = overrides.disabled;
this.#_drag = null;
this.#_icon = null;
this.#_menu = null;
this.#_start = null;
this.#_text = null;
// Configure event handlers
this.addEventListener("focusout" , e=>this.#_onFocusOut (e));
this.addEventListener("pointerdown", e=>this.#_onPointerDown(e));
this.addEventListener("pointermove", e=>this.#_onPointerMove(e));
this.addEventListener("pointerup" , e=>this.#_onPointerUp (e));
// Configure contents
this.#_client = document.createElement("div");
Object.assign(this.#_client.style, {
display: "grid"
});
this.#_columns = [
document.createElement("div"), // Icon
document.createElement("div"), // Text
document.createElement("div") // Shortcut
];
this.element.append(this.#_client);
for (let column of this.#_columns)
this.#_client.append(column);
// Configure properties
this.group = underrides.group;
this.text = underrides.text;
this.type = underrides.type;
}
/////////////////////////////// Properties ////////////////////////////////
// Check box or radio button checked state
get checked() { return this.element.ariaChecked == "true"; }
set checked(value) {
if (this.#_group == null)
this.#_setChecked(!!value);
}
// Element is inoperable
get disabled() { return this.element.ariaDisabled == "true"; }
set disabled(value) {
value = Boolean(value);
if (value == this.disabled)
return;
if (value)
this.element.ariaDisabled = "true";
else this.element.removeAttribute("aria-disabled");
}
// Sub-menu is visible
get expanded() { return this.element.ariaExpanded == "true"; }
set expanded(value) {
// Cannot be expanded
if (this.children.length == 0)
return;
// Input validation
value = Boolean(value);
if (value == this.expanded)
return;
// Expand or collapse self
this.element.ariaExpanded = String(value);
this.element.classList[value ? "add" : "remove"]("active");
this.#_menu.visible = value;
// Position the sub-menu element
if (value) {
let bounds = this.element.getBoundingClientRect();
Object.assign(this.#_menu.element.style,
this.parent instanceof Toolkit.MenuBar ?
{
left: bounds.left + "px",
top : bounds.bottom + "px"
} : {
left: bounds.right + "px",
top : bounds.top + "px"
}
);
}
// Collapse any expanded sub-menu
else {
let item = this.children.find(c=>c.expanded);
if (item != null)
item.expanded = false;
}
}
// Containing Toolkit.Group
get group() { return this.#_group; }
set group(value) {}
// Icon image URL
get icon() { return this.#_icon; }
set icon(value) {
this.#_icon = value ? String(value) : null;
this.#_refresh();
}
// Display text
get text() { return this.#_text; }
set text(value) {
if (value != null && !(value instanceof RegExp))
value = String(value);
this.#_text = value;
this.#_onLocalize();
}
// Menu item type
get type() {
switch (this.element.role) {
case "menuitem" : return "button";
case "menuitemcheckbox": return "checkbox";
case "menuitemradio" : return "radio";
}
return null;
}
set type(value) {
// Cannot change type if there is a sub-menu
if (this.children.length != 0)
throw new Error("Cannot change type while a sub-menu exists.");
// Error checking
value = value == null ? null : String(value);
let type = this.type;
if (type != null && value == type)
return;
// Input validation
switch (String(value)) {
case "button" : value = "menuitem" ; break;
case "checkbox": value = "menuitemcheckbox"; break;
case "radio" : value = "menuitemradio" ; break;
default:
if (type != null)
return;
value = "menuitem";
}
// Update the component
this.element.role = value;
this.#_refresh();
}
// Radio button value
get value() { return this.#_value; }
set value(value) { this.#_value = value; }
// HTML element visibility
get visible() { return super.visible; }
set visible(value) {
value = !!value;
if (value == super.visible)
return;
super.visible = value;
// TODO: Refresh siblings and parent
}
///////////////////////////// Event Handlers //////////////////////////////
// Component added to parent
#_onAdd() {
if (this.#_menu != null)
this.element.parent.append(this.#_menu.element);
}
// Focus lost
#_onFocusOut(e) {
if (
this.expanded &&
this.element != e.relatedTarget &&
!this.#_menu.element.contains(e.relatedTarget)
) this.expanded = false;
}
// Configure display text
#_onLocalize() {
let text = this.app.translate(this.#_text, this) ?? "";
this.#_columns[1].innerText = text;
this.#_start = text.length == 0 ? null : text[0].toLowerCase();
}
// Pointer pressed
#_onPointerDown(e) {
Toolkit.consume(e);
// Acquire focus
this.element.focus();
// Error checking
if (this.disabled || e.button != 0 || this.#_drag != null)
return;
// Activate a sub-menu
if (this.children.length != 0) {
if (!this.expanded)
this.#_activate();
else this.expanded = false;
return;
}
// Initiate a button response on a sub-menu item
this.element.setPointerCapture(e.pointerId);
this.element.classList.add("active");
this.#_drag = e.pointerId;
}
// Pointer moved
#_onPointerMove(e) {
Toolkit.consume(e);
// Style the menu item like a button on drag
if (this.#_drag != null) {
this.element.classList
[this.#_contains(e) ? "add" : "remove"]("active");
}
// Expand the sub-menu if another top-level sub-menu is expanded
if (
this.parent instanceof Toolkit.MenuBar &&
this.children.length != 0
) {
let item = this.parent.children.find(c=>c.expanded);
if (item != null && !this.expanded) {
this.expanded = true;
this.element.focus();
}
}
}
// Pointer released
#_onPointerUp(e) {
Toolkit.consume(e);
// Error checking
if (e.button != 0 || this.#_drag != e.pointerId)
return;
// Terminate the button response
this.element.releasePointerCapture(e.pointerId);
this.element.classList.remove("active");
this.#_drag = null;
// Activate the menu item
if (this.#_contains(e))
this.#_activate();
}
// Column resized
#_onResizeColumn() {
let widths = this.#_columns.map(c=>0);
for (let item of _package.MenuBar.children(this))
for (let x = 0; x < widths.length; x++) {
let column = item.#_columns[x];
column.style.removeProperty("min-width");
widths[x] = Math.max(widths[x],
column.getBoundingClientRect().width);
}
for (let item of _package.MenuBar.children(this))
for (let x = 0; x < widths.length; x++) {
if (x == 1)
continue; // Text
item.#_columns[x].style.minWidth = widths[x] + "px";
}
}
///////////////////////////// Public Methods //////////////////////////////
// Add a menu item
add(comp) {
// Error checking
if (!(comp instanceof Toolkit.MenuItem))
throw new TypeError("Component must be a Toolkit.MenuItem.");
// Associate the menu item with self
super.add(comp, false);
// The menu sub-component does not exist
if (this.#_menu == null) {
this.id = this.id ?? Toolkit.id();
this.#_menu = new this.constructor.#_Menu(this.app,
{ ariaLabelledBy: this.id });
_package.Component.setParent(this.#_menu, this);
if (this.parent != null)
this.element.after(this.#_menu.element);
Object.assign(this.element, {
ariaExpanded: "false",
ariaHasPopup: "menu"
});
this.#_resizer ??=
new ResizeObserver(()=>this.#_onResizeColumn());
}
// Add the component to the menu sub-component
comp.element.tabIndex = -1;
this.#_menu.element.append(comp.element);
this.#_resizer.observe(comp.element);
_package.Component.onAdd(comp);
// Refresh all sub-menu items
let children = _package.MenuBar.children(this);
let icon = this.#_needsIcon (children);
let shortcut = this.#_needsShortcut(children);
for (let item of children)
item.#_refresh(icon, shortcut);
}
// Add a separator between menu items
addSeparator(overrides = {}) {
let item =
new this.constructor.#_Separator(this.app, overrides);
this.#_menu.add(item);
}
///////////////////////////// Package Methods /////////////////////////////
// Reconfigure contents
#_refresh(needsIcon = null, needsShortcut = null) {
let client = this.#_client.style;
let icon = this.#_columns[0].style;
let shortcut = this.#_columns[2].style;
let hasIcon = true;
// Input validation
if (needsIcon == null || needsShortcut == null) {
let children = _package.MenuBar.children(this.parent);
needsIcon ??= this.#_needsIcon (children);
needsShortcut ??= this.#_needsShortcut(children);
}
// Regular menu item
if (this.type == "button") {
if (this.#_icon != null) {
icon.backgroundImage = "url(" + this.#_icon + ")";
} else {
icon.removeProperty("background-image");
hasIcon = false;
}
}
// Check box or radio button menu item
else icon.removeProperty("background-image");
// Configure layout
let template = ["auto"];
if (needsIcon || hasIcon) {
template.unshift("max-content");
icon.removeProperty("display");
} else icon.display = "none";
if (needsShortcut && false) { // TODO: Implement shortcut column
template.push("max-content");
shortcut.removeProperty("display");
} else shortcut.display = "none";
client.gridTemplateColumns = template.join(" ");
}
// Modify the checked state
#_setChecked(value) {
if (this.type != "button")
this.element.ariaChecked = value ? "true" : "false";
}
// Determine whether the translated display text starts with a given string
#_startsWith(pattern) {
return this.#_start == pattern;
}
///////////////////////////// Private Methods /////////////////////////////
// Simulate activation
#_activate(fromMenuBar = false) {
// Reroute activation handling to the containing Toolkit.MenuBar
if (!fromMenuBar) {
let bar = this.#_menuBar();
if (bar != null) {
_package.MenuBar.activate(bar, this, false, true);
return;
}
}
// Handling by menu item type
if (this.#_group != null)
_package.Group.onAction(this.#_group, this);
else if (this.type == "checkbox")
this.checked = !this.checked;
else if (this.type == "radio")
this.checked = true;
// Emit an action event
this.element.dispatchEvent(new Event("action"));
}
// Determine whether the pointer is within the element's boundary
#_contains(e) {
let bounds = this.element.getBoundingClientRect();
return (
e.clientX >= bounds.left &&
e.clientX < bounds.right &&
e.clientY >= bounds.top &&
e.clientY < bounds.bottom
);
}
// Resolve the containing Toolkit.MenuBar
#_menuBar() {
let item = this.parent;
while (item != null && !(item instanceof Toolkit.MenuBar))
item = item.parent;
return item;
}
// Determine whether sub-menu items need to show the icon column
#_needsIcon(children = null) {
return ((children ?? _package.MenuBar.children(this))
.some(c=>c.type != "button" || c.icon != null));
}
// Determine whether sub-menu items need to show the shortcut column
#_needsShortcut(children = null) {
return false;
//return ((children ?? _package.MenuBar.children(this))
// .any(c=>c.children.length != 0));
}
};
+133
View File
@@ -0,0 +1,133 @@
import App from /**/"./App.js";
import Component from /**/"./Component.js";
import Group from /**/"./Group.js";
import MenuBar from /**/"./MenuBar.js";
import MenuItem from /**/"./MenuItem.js";
// Pseudo environment context
let _package = {};
// GUI widget toolkit root
class Toolkit {
//////////////////////////////// Constants ////////////////////////////////
// Event keys
static group = Symbol(); // Events emitted by Toolkit.Group
static target = Symbol(); // Event target as a Toolkit.Component
///////////////////////// Initialization Methods //////////////////////////
Toolkit() { throw new Error("Cannot be instantiated."); }
static {
// Environment members
Object.assign(_package, {
componentKey: Symbol("Toolkit component"),
darkQuery : window.matchMedia("(prefers-color-scheme:dark)"),
nextId : 0n,
override : this.#override,
underride : this.#underride
});
// Register package classes with the Toolkit namespace
_package.register = ()=>{
this.Component = Component(this, _package);
this.App = App (this, _package);
this.Group = Group (this, _package);
this.MenuBar = MenuBar (this, _package);
this.MenuItem = MenuItem (this, _package);
Object.freeze(this);
Object.seal (this);
};
}
///////////////////////////// Static Methods //////////////////////////////
// Resolve the Toolkit.Component for an HTML element
static component(element) {
return element[_package.componentKey] ?? null;
}
// Terminate an event
static consume(event) {
event.preventDefault();
event.stopPropagation();
}
// Generate a unique element ID
static id() {
return "tk-" + _package.nextId++;
}
// Determine whether the user dark mode preference is active
static isDark() {
return _package.darkQuery.matches;
}
// Determine whether an element is fully visible
static isVisible(element, cache = null) {
cache ??= new Map();
for (let e = element; e instanceof Element; e = e.parentNode) {
let style;
if (!cache.has(e))
cache.set(e, style = getComputedStyle(e));
else style = cache.get(e);
if (style.display == "none" || style.visibility == "hidden")
return false;
}
return true;
}
// Generate a list of focusable descendant elements
static listFocusable(element) {
let cache = new Map();
return Array.from(element.querySelectorAll(
"*:is(a[href],area,button,details,input,textarea,select," +
"[tabindex='0']):not([disabled])"
)).filter(e=>this.isVisible(e, cache));
}
static stylesheet(url) {
let style = document.createElement("link");
style.rel = "stylesheet";
style.href = url;
return style;
}
///////////////////////////// Package Methods /////////////////////////////
// Process overrides for Toolkit.Component initialization
static #override(fromCaller, fromSelf) {
fromCaller = Object.assign({}, fromCaller ?? {});
fromSelf = Object.assign({}, fromSelf ?? {});
fromSelf.style = Object.assign(
fromSelf.style ?? {}, fromCaller.style ?? {});
delete fromCaller.style;
Object.assign(fromSelf, fromCaller);
return fromSelf;
}
// Extract override properties for later processing
static #underride(overrides, underrides) {
let ret = {};
for (let entry of Object.entries(underrides)) {
ret[entry[0]] = overrides[entry[0]] ?? underrides[entry[1]];
delete overrides[entry[0]];
}
return ret;
}
}
_package.register();
export default Toolkit;