Introducing toolkit and color themes
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
"use strict";
|
||||
|
||||
// Root element and localization manager for a Toolkit application
|
||||
Toolkit.Application = class Application extends Toolkit.Panel {
|
||||
|
||||
// Object constructor
|
||||
constructor() {
|
||||
super(null);
|
||||
|
||||
// Configure instance fields
|
||||
this.application = this;
|
||||
this.components = [];
|
||||
this.locale = null;
|
||||
this.locales = { first: null };
|
||||
|
||||
// Configure element
|
||||
this.element.setAttribute("application", "");
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Public Methods //////////////////////////////
|
||||
|
||||
// Add a component for localization management
|
||||
addComponent(component) {
|
||||
if (this.components.indexOf(component) != -1)
|
||||
return;
|
||||
this.components.push(component);
|
||||
component.localize();
|
||||
}
|
||||
|
||||
// Register a locale with the application
|
||||
addLocale(source) {
|
||||
let loc = null;
|
||||
|
||||
// Process the locale object from the source
|
||||
try { loc = new Function("return (" + source + ");")(); }
|
||||
catch(e) { console.log(e); }
|
||||
|
||||
// Error checking
|
||||
if (
|
||||
!loc || typeof loc != "object" ||
|
||||
!("key" in loc) || !("name" in loc)
|
||||
) return null;
|
||||
|
||||
// Register the locale
|
||||
if (this.locales.first == null)
|
||||
this.locales.first = loc;
|
||||
this.locales[loc.key] = loc;
|
||||
return loc.key;
|
||||
}
|
||||
|
||||
// Produce a list of all registered locale keys
|
||||
listLocales() {
|
||||
return Object.values(this.locales);
|
||||
}
|
||||
|
||||
// Remove a compnent from being localized
|
||||
removeComponent(component) {
|
||||
let index = this.components.indexOf(component);
|
||||
if (index == -1)
|
||||
return false;
|
||||
this.components.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Specify which localized strings to use for application controls
|
||||
setLocale(lang) {
|
||||
|
||||
// Error checking
|
||||
if (this.locales.first == null)
|
||||
return null;
|
||||
|
||||
// Working variables
|
||||
lang = lang.toLowerCase();
|
||||
let parts = lang.split("-");
|
||||
let best = null;
|
||||
|
||||
// Check all locales
|
||||
for (let loc of Object.values(this.locales)) {
|
||||
let key = loc.key.toLowerCase();
|
||||
|
||||
// The language is an exact match
|
||||
if (key == lang) {
|
||||
best = loc;
|
||||
break;
|
||||
}
|
||||
|
||||
// The language matches, but the region may not
|
||||
if (best == null && key.split("-")[0] == parts[0])
|
||||
best = loc;
|
||||
}
|
||||
|
||||
// The language did not match: use the first locale that was registered
|
||||
if (best == null)
|
||||
best = this.locales.first;
|
||||
|
||||
// Select the locale
|
||||
this.locale = best;
|
||||
return best.key;
|
||||
}
|
||||
|
||||
// Localize text for a component
|
||||
translate(text, properties) {
|
||||
properties = !properties ? {} :
|
||||
properties instanceof Toolkit.Component ? properties.properties :
|
||||
properties;
|
||||
|
||||
// Process all characters from the input
|
||||
let sub = { text: "", parent: null };
|
||||
for (let x = 0; x < text.length; x++) {
|
||||
let c = text[x];
|
||||
let last = x == text.length - 1;
|
||||
|
||||
// Left curly brace
|
||||
if (c == '{') {
|
||||
|
||||
// Literal left curly brace
|
||||
if (!last && text[x + 1] == '{') {
|
||||
sub.text += c;
|
||||
x++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Open a substring
|
||||
sub = { text: "", parent: sub };
|
||||
continue;
|
||||
}
|
||||
|
||||
// Right curly brace
|
||||
if (c == '}') {
|
||||
|
||||
// Literal right curly brace
|
||||
if (!last && text[x + 1] == '}') {
|
||||
sub.text += c;
|
||||
x++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Close a sub (if there are any to close)
|
||||
if (sub.parent != null) {
|
||||
|
||||
// Text comes from component property
|
||||
if (sub.text in properties) {
|
||||
sub.parent.text += properties[sub.text];
|
||||
sub = sub.parent;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Text comes from locale
|
||||
let value = this.fromLocale(sub.text, true);
|
||||
if (value !== null) {
|
||||
text = value + text.substring(x + 1);
|
||||
x = -1;
|
||||
sub = sub.parent;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Take the text as-is
|
||||
sub.parent.text += "{" + sub.text + "}";
|
||||
sub = sub.parent;
|
||||
continue;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Append the character to the sub's text
|
||||
sub.text += c;
|
||||
}
|
||||
|
||||
// Close any remaining subs (should never happen)
|
||||
for (; sub.parent != null; sub = sub.parent)
|
||||
sub.parent.text += sub.text;
|
||||
return sub.text;
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Private Methods /////////////////////////////
|
||||
|
||||
// Retrieve the text for a key in the locale
|
||||
fromLocale(key) {
|
||||
let locale = this.locale || {};
|
||||
for (let part of key.split(".")) {
|
||||
if (!(part in locale))
|
||||
return null;
|
||||
locale = locale[part];
|
||||
}
|
||||
return typeof locale == "string" ? locale : null;
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
"use strict";
|
||||
|
||||
// Clickable button
|
||||
Toolkit.Button = class Button extends Toolkit.Component {
|
||||
|
||||
// Object constructor
|
||||
constructor(application, options) {
|
||||
super(application, "div");
|
||||
options = options || {};
|
||||
|
||||
// Configure instance fields
|
||||
this.blurListeners = [];
|
||||
this.clickListeners = [];
|
||||
this.enabled = "enabled" in options ? options.enabled : true;
|
||||
this.focusListeners = [];
|
||||
this.pressed = "pressed" in options ? options.pressed : false;
|
||||
this.tabStop = true;
|
||||
this.text = options.text || "";
|
||||
this.toggleable = "toggleable"in options?options.toggleable:false;
|
||||
|
||||
// Configure element
|
||||
this.element.type = "button";
|
||||
this.element.setAttribute("role", "button");
|
||||
this.element.style.cursor = "default";
|
||||
this.element.style.position = "relative";
|
||||
this.element.style.userSelect = "none";
|
||||
this.element.addEventListener("blur" , e=>this.onblur (e));
|
||||
this.element.addEventListener("focus" , e=>this.onfocus (e));
|
||||
this.element.addEventListener("keydown" , e=>this.onkeydown (e));
|
||||
this.element.addEventListener("pointerdown", e=>this.onpointerdown(e));
|
||||
this.element.addEventListener("pointermove", e=>this.onpointermove(e));
|
||||
this.element.addEventListener("pointerup" , e=>this.onpointerup (e));
|
||||
|
||||
// Configure properties
|
||||
this.setEnabled (this.enabled );
|
||||
this.setPressed (this.pressed );
|
||||
this.setTabStop (this.tabStop );
|
||||
this.setText (this.text );
|
||||
this.setToggleable(this.toggleable);
|
||||
application.addComponent(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Public Methods //////////////////////////////
|
||||
|
||||
// Add a callback for blur events
|
||||
addBlurListener(listener) {
|
||||
if (this.blurListeners.indexOf(listener) == -1)
|
||||
this.blurListeners.push(listener);
|
||||
}
|
||||
|
||||
// Add a callback for click events
|
||||
addClickListener(listener) {
|
||||
if (this.clickListeners.indexOf(listener) == -1)
|
||||
this.clickListeners.push(listener);
|
||||
}
|
||||
|
||||
// Add a callback for focus events
|
||||
addFocusListener(listener) {
|
||||
if (this.focusListeners.indexOf(listener) == -1)
|
||||
this.focusListeners.push(listener);
|
||||
}
|
||||
|
||||
// Determine whether the component participates in the tab sequence
|
||||
getTabStop() {
|
||||
return this.tabStop;
|
||||
}
|
||||
|
||||
// Retrieve the control's text
|
||||
getText() {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
// Determine whether the control is enabled
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
// Determine the toggle button's active state
|
||||
isPressed() {
|
||||
return this.pressed;
|
||||
}
|
||||
|
||||
// Determine whether the button is a toggle button
|
||||
isToggleable() {
|
||||
return this.toggleable;
|
||||
}
|
||||
|
||||
// Specify whether the control is enabled
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled = !!enabled;
|
||||
if (enabled)
|
||||
this.element.removeAttribute("disabled");
|
||||
else this.element.setAttribute("disabled", "");
|
||||
}
|
||||
|
||||
// Specify whether the component participates in the regular tab sequence
|
||||
setTabStop(tabStop) {
|
||||
this.tabStop = tabStop = !!tabStop;
|
||||
this.element.setAttribute("tabindex", tabStop ? "0" : "-1");
|
||||
}
|
||||
|
||||
// Specify the toggle button's active state
|
||||
setPressed(pressed) {
|
||||
this.pressed = pressed = !!pressed;
|
||||
if (this.toggleable)
|
||||
this.element.setAttribute("aria-pressed", pressed);
|
||||
}
|
||||
|
||||
// Specify the control's text
|
||||
setText(text) {
|
||||
this.text = text || "";
|
||||
this.localize();
|
||||
}
|
||||
|
||||
// Specify whether the button is a toggle button
|
||||
setToggleable(toggleable) {
|
||||
this.toggleable = toggleable = !!toggleable;
|
||||
if (toggleable)
|
||||
this.element.setAttribute("aria-pressed", this.pressed);
|
||||
else this.element.removeAttribute("aria-pressed");
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Private Methods /////////////////////////////
|
||||
|
||||
// Actions when the button is activated
|
||||
activate(e) {
|
||||
if (this.toggleable)
|
||||
this.setPressed(!this.pressed);
|
||||
for (let listener of this.clickListeners)
|
||||
listener(e, this);
|
||||
}
|
||||
|
||||
// Update display text with localized strings
|
||||
localize() {
|
||||
let text = this.text;
|
||||
if (this.application)
|
||||
text = this.application.translate(text, this);
|
||||
this.element.innerText = text;
|
||||
this.element.setAttribute("aria-label", text);
|
||||
}
|
||||
|
||||
// Blur event handler
|
||||
onblur(e) {
|
||||
for (let listener of this.blurListeners)
|
||||
listener(e, this);
|
||||
this.focusChanged(
|
||||
this, e.relatedTarget ? e.relatedTarget.component : null);
|
||||
}
|
||||
|
||||
// Focus event handler
|
||||
onfocus(e) {
|
||||
for (let listener of this.focusListeners)
|
||||
listener(e, this);
|
||||
this.focusChanged(
|
||||
e.relatedTarget ? e.relatedTarget.component : null, this);
|
||||
}
|
||||
|
||||
// Key press event handler
|
||||
onkeydown(e) {
|
||||
|
||||
// Error checking
|
||||
if (e.key != " " && e.key != "Enter")
|
||||
return;
|
||||
|
||||
// Configure event
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// The button was activated
|
||||
this.activate(e);
|
||||
}
|
||||
|
||||
// Pointer down event handler
|
||||
onpointerdown(e) {
|
||||
|
||||
// Error checking
|
||||
if (e.button != 0 || this.element.hasPointerCapture(e.pointerId))
|
||||
return;
|
||||
|
||||
// Configure event
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Configure element
|
||||
this.element.focus();
|
||||
this.element.setPointerCapture(e.pointerId);
|
||||
this.element.setAttribute("active", "");
|
||||
}
|
||||
|
||||
// Pointer move event handler
|
||||
onpointermove(e) {
|
||||
|
||||
// Error checking
|
||||
if (!this.element.hasPointerCapture(e.pointerId))
|
||||
return;
|
||||
|
||||
// Configure event
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Working variables
|
||||
let bounds = this.element.getBoundingClientRect();
|
||||
let active =
|
||||
e.x >= bounds.x && e.x < bounds.x + bounds.width &&
|
||||
e.y >= bounds.y && e.y < bounds.y + bounds.height
|
||||
;
|
||||
|
||||
// Configure event
|
||||
if (active)
|
||||
this.element.setAttribute("active", "");
|
||||
else this.element.removeAttribute("active");
|
||||
}
|
||||
|
||||
// Pointer up event handler
|
||||
onpointerup(e) {
|
||||
|
||||
// Error checking
|
||||
if (!this.element.hasPointerCapture(e.pointerId))
|
||||
return;
|
||||
|
||||
// Configure event
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Working variables
|
||||
let active = this.element.hasAttribute("active");
|
||||
|
||||
// Configure element
|
||||
this.element.releasePointerCapture(e.pointerId);
|
||||
this.element.removeAttribute("active");
|
||||
|
||||
// The pointer was released without activating the button
|
||||
if (!active)
|
||||
return;
|
||||
|
||||
// The button was activated
|
||||
this.activate(e);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
"use strict";
|
||||
|
||||
// Base features for all components
|
||||
Toolkit.Component = class Component {
|
||||
|
||||
// Object constructor
|
||||
constructor(application, tagname) {
|
||||
|
||||
// Configure instance fields
|
||||
this.application = application;
|
||||
this.containers = [ this ];
|
||||
this.element = document.createElement(tagname);
|
||||
this.id = this.element.id = Toolkit.id();
|
||||
this.parent = null;
|
||||
this.properties = {};
|
||||
this.resizeListeners = [];
|
||||
|
||||
// Configure component
|
||||
this.element.component = this;
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Public Methods //////////////////////////////
|
||||
|
||||
// Add a callback for resize events
|
||||
addResizeListener(listener) {
|
||||
if (this.resizeListeners.indexOf(listener) != -1)
|
||||
return;
|
||||
if (this.resizeListeners.length == 0)
|
||||
new ResizeObserver(()=>this.onresize()).observe(this.element);
|
||||
this.resizeListeners.push(listener);
|
||||
}
|
||||
|
||||
// Remove the component from its parent
|
||||
remove() {
|
||||
this.parent && this.parent.remove(this);
|
||||
}
|
||||
|
||||
// Retrieve the bounding box of the element
|
||||
getBounds() {
|
||||
return this.element.getBoundingClientRect();
|
||||
}
|
||||
|
||||
// Specify the height of the element
|
||||
setHeight(height) {
|
||||
if (height === null)
|
||||
this.element.style.removeProperty("height");
|
||||
else this.element.style.height = height;
|
||||
}
|
||||
|
||||
// Specify the width of the element
|
||||
setWidth(width) {
|
||||
if (width === null)
|
||||
this.element.style.removeProperty("width");
|
||||
else this.element.style.width = width;
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Package Methods /////////////////////////////
|
||||
|
||||
// Determine whether this component contains another
|
||||
contains(comp) {
|
||||
if (comp == null)
|
||||
return false;
|
||||
if (comp instanceof Toolkit.Component)
|
||||
comp = comp.element;
|
||||
for (let cont of this.containers)
|
||||
if ((cont instanceof Toolkit.Component ? cont.element : cont)
|
||||
.contains(comp)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Notify of a change to component focus
|
||||
focusChanged(from, to) {
|
||||
if (this.parent != null)
|
||||
this.parent.focusChanged(from, to);
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Private Methods /////////////////////////////
|
||||
|
||||
// Resize event handler
|
||||
onresize() {
|
||||
let bounds = this.getBounds();
|
||||
for (let listener of this.resizeListeners)
|
||||
listener(bounds, this);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
"use strict";
|
||||
|
||||
// Selection within a MenuBar
|
||||
Toolkit.Menu = class Menu extends Toolkit.MenuItem {
|
||||
|
||||
// Object constructor
|
||||
constructor(parent, options) {
|
||||
super(parent, options);
|
||||
|
||||
// Configure instance fields
|
||||
this.items = [];
|
||||
this.parent = parent;
|
||||
|
||||
// Configure menu element
|
||||
this.menu = document.createElement("div");
|
||||
this.menu.id = Toolkit.id();
|
||||
this.menu.style.display = "none";
|
||||
this.menu.style.flexDirection = "column";
|
||||
this.menu.style.position = "absolute";
|
||||
this.menu.setAttribute("role", "menu");
|
||||
this.menu.setAttribute("aria-labelledby", this.id);
|
||||
this.containers.push(this.menu);
|
||||
|
||||
// Configure element
|
||||
this.element.setAttribute("aria-expanded", "false");
|
||||
this.element.setAttribute("aria-haspopup", "menu");
|
||||
this.element.addEventListener("pointerdown", e=>this.onpointerdown(e));
|
||||
this.element.addEventListener("pointermove", e=>this.onpointermove(e));
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Public Methods //////////////////////////////
|
||||
|
||||
// Create a MenuItem and associate it with the application and component
|
||||
newMenuItem(options, index) {
|
||||
let item = new Toolkit.MenuItem(this, options);
|
||||
|
||||
// Determine the ordinal position of the element within the container
|
||||
index = !(typeof index == "number") ? this.items.length :
|
||||
Math.floor(Math.min(Math.max(0, index), this.items.length));
|
||||
|
||||
// Add the item to the menu
|
||||
let ref = this.items[index];
|
||||
this.menu.insertBefore(item.element, ref ? ref.element : null);
|
||||
this.items.splice(index, 0, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Specify whether the menu is enabled
|
||||
setEnabled(enabled) {
|
||||
super.setEnabled(enabled);
|
||||
if (!this.enabled && this.parent.expanded == this)
|
||||
this.setExpanded(false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Package Methods /////////////////////////////
|
||||
|
||||
// The menu item was activated
|
||||
activate(deeper) {
|
||||
if (!this.enabled)
|
||||
return;
|
||||
this.setExpanded(true);
|
||||
if (deeper && this.items.length > 0)
|
||||
this.items[0].element.focus();
|
||||
}
|
||||
|
||||
// Notify of a change to component focus
|
||||
focusChanged(from, to) {
|
||||
if (!this.contains(to)) {
|
||||
let expanded = this.parent.expanded;
|
||||
this.setExpanded(false);
|
||||
this.parent.expanded = expanded == this ? null : expanded;
|
||||
}
|
||||
super.focusChanged(from, to);
|
||||
}
|
||||
|
||||
// Show or hide the pop-up menu
|
||||
setExpanded(expanded) {
|
||||
|
||||
// Setting expanded to false
|
||||
if (!expanded) {
|
||||
|
||||
// Hide the pop-up menu
|
||||
this.element.setAttribute("aria-expanded", "false");
|
||||
this.menu.style.display = "none";
|
||||
this.parent.expanded = null;
|
||||
|
||||
// Close any expanded submenus
|
||||
if (this.expanded != null)
|
||||
this.expanded.setExpanded(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide the existing submenu of the parent
|
||||
if (this.parent.expanded != null && this.parent.expanded != this)
|
||||
this.parent.expanded.setExpanded(false);
|
||||
this.parent.expanded = this;
|
||||
|
||||
// Configure element
|
||||
this.element.setAttribute("aria-expanded", "true");
|
||||
|
||||
// Configure pop-up menu
|
||||
let barBounds = this.menuBar.element.getBoundingClientRect();
|
||||
let bounds = this.element.getBoundingClientRect();
|
||||
this.menu.style.display = "flex";
|
||||
this.menu.style.left = (bounds.x - barBounds.x) + "px";
|
||||
this.menu.style.top = (bounds.y + bounds.height - barBounds.y) + "px";
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Private Methods /////////////////////////////
|
||||
|
||||
// Key press event handler
|
||||
onkeydown(e) {
|
||||
|
||||
// Processing by key
|
||||
switch (e.key) {
|
||||
|
||||
// Open the menu and select its first item (if any)
|
||||
case " ":
|
||||
case "ArrowDown":
|
||||
case "Enter":
|
||||
this.activate(true);
|
||||
break;
|
||||
|
||||
// Conditional
|
||||
case "ArrowLeft":
|
||||
|
||||
// Move to the previous item in the menu bar
|
||||
if (this.parent == this.menuBar) {
|
||||
let menu = this.parent.menus[
|
||||
(this.parent.menus.indexOf(this) +
|
||||
this.parent.menus.length - 1) %
|
||||
this.parent.menus.length
|
||||
];
|
||||
if (menu != this && this.parent.expanded != null)
|
||||
menu.activate(true);
|
||||
else menu.element.focus();
|
||||
}
|
||||
|
||||
// Close the menu and return to the parent menu
|
||||
else {
|
||||
this.setExpanded(false);
|
||||
this.parent.element.focus();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
// Conditional
|
||||
case "ArrowRight":
|
||||
|
||||
// Move to the next item in the menu bar
|
||||
if (this.parent == this.menuBar) {
|
||||
let menu = this.parent.menus[
|
||||
(this.parent.menus.indexOf(this) + 1) %
|
||||
this.parent.menus.length
|
||||
];
|
||||
if (menu != this) {
|
||||
if (this.parent.expanded != null) {
|
||||
menu.activate(true);
|
||||
} else menu.element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Open the menu and select its first item (if any)
|
||||
else this.activate(true);
|
||||
|
||||
break;
|
||||
|
||||
// Open the menu and select the last item (if any)
|
||||
case "ArrowUp":
|
||||
this.activate(false);
|
||||
if (this.items.length > 0)
|
||||
this.items[this.items.length - 1].element.focus();
|
||||
break;
|
||||
|
||||
// Conditional
|
||||
case "End":
|
||||
|
||||
// Select the Last menu in the menu bar
|
||||
if (this.parent == this.menuBar) {
|
||||
let menu = this.parent.menus[this.parent.menus.length - 1];
|
||||
if (menu != this) {
|
||||
if (this.menuBar.expanded != null)
|
||||
menu.activate(true);
|
||||
else menu.element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Select the last item in the parent menu
|
||||
else {
|
||||
let item = this.parent.items[this.parent.items.length - 1];
|
||||
if (item != this) {
|
||||
this.setExpanded(false);
|
||||
item.element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
// Return focus to the original element
|
||||
case "Escape":
|
||||
this.menuBar.expanded.setExpanded(false);
|
||||
break;
|
||||
|
||||
// Conditional
|
||||
case "Home":
|
||||
|
||||
// Select the first menu in the menu bar
|
||||
if (this.parent == this.menuBar) {
|
||||
let menu = this.parent.menus[0];
|
||||
if (menu != this) {
|
||||
if (this.menuBar.expanded != null)
|
||||
menu.activate(true);
|
||||
else menu.element.focus();
|
||||
}
|
||||
}
|
||||
|
||||
// Select the last item in the parent menu
|
||||
else {
|
||||
let item = this.parent.items[0];
|
||||
if (item != this) {
|
||||
this.setExpanded(false);
|
||||
item.element.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
default: return;
|
||||
}
|
||||
|
||||
// Configure event
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Pointer down event handler
|
||||
onpointerdown(e) {
|
||||
|
||||
// Error checking
|
||||
if (e.button != 0)
|
||||
return;
|
||||
|
||||
// Configure event
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Activate the menu
|
||||
this.element.focus();
|
||||
this.activate(false);
|
||||
}
|
||||
|
||||
// Pointer move event handler
|
||||
onpointermove(e) {
|
||||
|
||||
// Error checking
|
||||
if (
|
||||
this.parent != this.menuBar ||
|
||||
this.parent.expanded == null ||
|
||||
this.parent.expanded == this
|
||||
) return;
|
||||
|
||||
// Activate the menu
|
||||
this.parent.expanded.setExpanded(false);
|
||||
this.parent.expanded = this;
|
||||
this.element.focus();
|
||||
this.setExpanded(true);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
"use strict";
|
||||
|
||||
// Main application menu bar
|
||||
Toolkit.MenuBar = class MenuBar extends Toolkit.Component {
|
||||
|
||||
// Object constructor
|
||||
constructor(application, options) {
|
||||
super(application, "div");
|
||||
|
||||
// Configure instance fields
|
||||
this.expanded = null;
|
||||
this.lastFocus = null;
|
||||
this.menuBar = this;
|
||||
this.menus = [];
|
||||
this.name = options.name || "";
|
||||
|
||||
// Configure element
|
||||
this.element.style.display = "flex";
|
||||
this.element.style.position = "relative";
|
||||
this.element.style.zIndex = "1";
|
||||
this.element.setAttribute("role", "menubar");
|
||||
|
||||
// Configure properties
|
||||
this.setName(this.name);
|
||||
application.addComponent(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Public Methods //////////////////////////////
|
||||
|
||||
// Create a Menu and associate it with the application and component
|
||||
newMenu(options, index) {
|
||||
let menu = new Toolkit.Menu(this, options);
|
||||
|
||||
// Determine the ordinal position of the element within the container
|
||||
index = !(typeof index == "number") ? this.menus.length :
|
||||
Math.floor(Math.min(Math.max(0, index), this.menus.length));
|
||||
|
||||
// Add the menu to the menu bar
|
||||
let ref = this.menus[index];
|
||||
this.element.insertBefore(menu.element, ref ? ref.element : null);
|
||||
this.menus.splice(index, 0, menu);
|
||||
menu.element.insertAdjacentElement("afterend", menu.menu);
|
||||
|
||||
// Ensure only the first menu is focusable
|
||||
for (let x = 0; x < this.menus.length; x++)
|
||||
this.menus[x].element.setAttribute("tabindex", x==0 ? "0" : "-1");
|
||||
|
||||
return menu;
|
||||
}
|
||||
|
||||
// Specify the menu's accessible name
|
||||
setName(name) {
|
||||
this.name = name || "";
|
||||
this.localize();
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Package Methods /////////////////////////////
|
||||
|
||||
// Notify of a change to component focus
|
||||
focusChanged(from, to) {
|
||||
|
||||
// Configure tabstop on the first menu
|
||||
if (this.menus.length > 0)
|
||||
this.menus[0].element.setAttribute("tabindex",
|
||||
this.contains(to) ? "-1" : "0");
|
||||
|
||||
// Retain a reference to the previously focused element
|
||||
if (!this.contains(from)) {
|
||||
if (from == null)
|
||||
from = document.body;
|
||||
if ("component" in from)
|
||||
from = from.component;
|
||||
this.lastFocus = from;
|
||||
}
|
||||
|
||||
super.focusChanged(from, to);
|
||||
}
|
||||
|
||||
// Update display text with localized strings
|
||||
localize() {
|
||||
let text = this.name;
|
||||
if (this.application)
|
||||
text = this.application.translate(text, this);
|
||||
this.element.setAttribute("aria-label", text);
|
||||
}
|
||||
|
||||
// Return focus to where it was before the menu was activated
|
||||
restoreFocus() {
|
||||
let elm = this.lastFocus;
|
||||
if (elm == null)
|
||||
elm = document.body;
|
||||
elm.focus();
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
"use strict";
|
||||
|
||||
// Selection within a Menu
|
||||
Toolkit.MenuItem = class MenuItem extends Toolkit.Component {
|
||||
|
||||
// Object constructor
|
||||
constructor(parent, options) {
|
||||
super(parent && parent.application, "div");
|
||||
|
||||
// Configure instance fields
|
||||
this.clickListeners = [];
|
||||
this.enabled = "enabled" in options ? !!options.enabled : true;
|
||||
this.icon = options.icon || null;
|
||||
this.menuBar = parent.menuBar;
|
||||
this.parent = parent;
|
||||
this.text = options.text || "";
|
||||
this.shortcut = options.shortcut || null;
|
||||
|
||||
// Configure base element
|
||||
this.element.style.display = "flex";
|
||||
this.element.setAttribute("role", "menuitem");
|
||||
this.element.setAttribute("tabindex", "-1");
|
||||
this.element.addEventListener("blur" , e=>this.onblur (e));
|
||||
this.element.addEventListener("focus" , e=>this.onfocus (e));
|
||||
this.element.addEventListener("keydown", e=>this.onkeydown(e));
|
||||
if (this.parent != this.menuBar)
|
||||
this.element.addEventListener("pointerup", e=>this.onpointerup(e));
|
||||
|
||||
// Configure display text element
|
||||
this.textElement = document.createElement("div");
|
||||
this.textElement.style.cursor = "default";
|
||||
this.textElement.style.flexGrow = "1";
|
||||
this.textElement.style.userSelect = "none";
|
||||
this.element.appendChild(this.textElement);
|
||||
|
||||
// Configure properties
|
||||
this.setEnabled(this.enabled);
|
||||
this.setText (this.text);
|
||||
this.application.addComponent(this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Public Methods //////////////////////////////
|
||||
|
||||
// Add a callback for click events
|
||||
addClickListener(listener) {
|
||||
if (this.clickListeners.indexOf(listener) == -1)
|
||||
this.clickListeners.push(listener);
|
||||
}
|
||||
|
||||
// Retrieve the item's display text
|
||||
getText() {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
// Determine whether the item is enabled
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
// Specify whether the item is enabled
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled = !!enabled;
|
||||
if (enabled)
|
||||
this.element.removeAttribute("disabled");
|
||||
else this.element.setAttribute("disabled", "");
|
||||
}
|
||||
|
||||
// Specify the item's display text
|
||||
setText(text) {
|
||||
this.text = text || "";
|
||||
this.localize();
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Package Methods /////////////////////////////
|
||||
|
||||
// Update display text with localized strings
|
||||
localize() {
|
||||
let text = this.text;
|
||||
if (this.application)
|
||||
text = this.application.translate(text, this);
|
||||
this.element.setAttribute("aria-label", text);
|
||||
this.textElement.innerText = text;
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Private Methods /////////////////////////////
|
||||
|
||||
// The menu item was activated
|
||||
activate(e) {
|
||||
if (!this.enabled)
|
||||
return;
|
||||
this.menuBar.restoreFocus();
|
||||
for (let listener of this.clickListeners)
|
||||
listener(e, this);
|
||||
this.menuBar.expanded.setExpanded(false);
|
||||
}
|
||||
|
||||
// Focus lost event handler
|
||||
onblur(e) {
|
||||
this.focusChanged(
|
||||
this, e.relatedTarget ? e.relatedTarget.component : null);
|
||||
}
|
||||
|
||||
// Focus gained event handler
|
||||
onfocus(e) {
|
||||
this.focusChanged(
|
||||
e.relatedTarget ? e.relatedTarget.component : null, this);
|
||||
}
|
||||
|
||||
// Key press event handler
|
||||
onkeydown(e) {
|
||||
|
||||
// Processing by key
|
||||
switch (e.key) {
|
||||
|
||||
// Activate the item
|
||||
case " ":
|
||||
case "Enter":
|
||||
this.activate(e);
|
||||
break;
|
||||
|
||||
// Select the next item
|
||||
case "ArrowDown":
|
||||
this.parent.items[
|
||||
(this.parent.items.indexOf(this) + 1) %
|
||||
this.parent.items.length
|
||||
].element.focus();
|
||||
break;
|
||||
|
||||
// Conditional
|
||||
case "ArrowLeft":
|
||||
|
||||
// Move to the previous menu in the menu bar
|
||||
if (this.parent.parent == this.menuBar) {
|
||||
let menu = this.menuBar.menus[
|
||||
(this.menuBar.menus.indexOf(this.parent) +
|
||||
this.menuBar.menus.length - 1) %
|
||||
this.menuBar.menus.length
|
||||
];
|
||||
if (menu != this.parent)
|
||||
menu.activate(true);
|
||||
}
|
||||
|
||||
// Close the containing submenu
|
||||
else {
|
||||
this.parent.setExpanded(false);
|
||||
this.parent.parent.element.focus();
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
// Move to the next menu in the menu bar
|
||||
case "ArrowRight":
|
||||
let menu = this.menuBar.menus[
|
||||
(this.menuBar.menus.indexOf(this.menuBar.expanded) + 1) %
|
||||
this.menuBar.menus.length
|
||||
];
|
||||
if (this.menuBar.expanded != menu)
|
||||
menu.activate(true);
|
||||
break;
|
||||
|
||||
// Select the previous item
|
||||
case "ArrowUp":
|
||||
this.parent.items[
|
||||
(this.parent.items.indexOf(this) +
|
||||
this.parent.items.length - 1) %
|
||||
this.parent.items.length
|
||||
].element.focus();
|
||||
break;
|
||||
|
||||
// Select the last item in the menu
|
||||
case "End":
|
||||
this.parent.items[this.parent.items.length-1].element.focus();
|
||||
break;
|
||||
|
||||
// Return focus to the original element
|
||||
case "Escape":
|
||||
this.menuBar.expanded.setExpanded(false);
|
||||
break;
|
||||
|
||||
// Select the first item in the menu
|
||||
case "Home":
|
||||
this.parent.items[0].element.focus();
|
||||
break;
|
||||
|
||||
default: return;
|
||||
}
|
||||
|
||||
// Configure element
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
// Pointer up event handler
|
||||
onpointerup(e) {
|
||||
|
||||
// Error checking
|
||||
if (e.button != 0 || document.activeElement != this.element)
|
||||
return;
|
||||
|
||||
// Configure event
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Activate the menu item
|
||||
this.activate(e);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,188 @@
|
||||
"use strict";
|
||||
|
||||
// Box that can contain other components
|
||||
Toolkit.Panel = class Panel extends Toolkit.Component {
|
||||
|
||||
// Object constructor
|
||||
constructor(application) {
|
||||
super(application, "div");
|
||||
|
||||
// Configure instance fields
|
||||
this.application = application;
|
||||
this.children = [];
|
||||
this.crossAlign = "start";
|
||||
this.direction = "row";
|
||||
this.edge = "left";
|
||||
this.hGap = "0";
|
||||
this.layout = "split";
|
||||
this.mainAlign = "start";
|
||||
this.sizeable = false;
|
||||
this.vGap = "0";
|
||||
this.wrap = false;
|
||||
|
||||
// Configure element
|
||||
this.element.style.minHeight = "0";
|
||||
this.element.style.minWidth = "0";
|
||||
|
||||
// Configure layout
|
||||
this.setSplitLayout("left", false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Public Methods //////////////////////////////
|
||||
|
||||
// Add a component as a child of this container
|
||||
add(component, index) {
|
||||
|
||||
// Determine the ordinal position of the element within the container
|
||||
index = !(typeof index == "number") ? this.children.length :
|
||||
Math.floor(Math.min(Math.max(0, index), this.children.length));
|
||||
|
||||
// Add the component to the container
|
||||
component.parent = this;
|
||||
this.children.splice(index, 0, component);
|
||||
this.arrange();
|
||||
}
|
||||
|
||||
// Create a Button and associate it with the application
|
||||
newButton(options) {
|
||||
return new Toolkit.Button(this.application, options);
|
||||
}
|
||||
|
||||
// Create a MenuBar and associate it with the application
|
||||
newMenuBar(options) {
|
||||
return new Toolkit.MenuBar(this.application, options);
|
||||
}
|
||||
|
||||
// Create a Panel and associate it with the application
|
||||
newPanel(options) {
|
||||
return new Toolkit.Panel(this.application, options);
|
||||
}
|
||||
|
||||
// Remove a component from the container
|
||||
remove(component) {
|
||||
|
||||
// Locate the component in the children
|
||||
let index = this.children.indexOf(component);
|
||||
if (index == -1)
|
||||
return;
|
||||
|
||||
// Remove the component
|
||||
component.parent = null;
|
||||
component.element.remove();
|
||||
this.children.splice(index, 1);
|
||||
this.arrange();
|
||||
}
|
||||
|
||||
// Configure the element with a flex layout
|
||||
setFlexLayout(direction, mainAlign, crossAlign, gap, wrap) {
|
||||
|
||||
// Configure instance fields
|
||||
this.layout = "flex";
|
||||
this.crossAlign = crossAlign;
|
||||
this.direction = direction;
|
||||
this.mainAlign = mainAlign;
|
||||
this.wrap = wrap;
|
||||
|
||||
// Working variables
|
||||
switch (crossAlign) {
|
||||
case "end" : crossAlign = "flex-end" ;
|
||||
case "start": crossAlign = "flex-start";
|
||||
}
|
||||
switch (mainAlign) {
|
||||
case "end" : mainAlign = "flex-end" ;
|
||||
case "start": mainAlign = "flex-start";
|
||||
}
|
||||
|
||||
// Configure element
|
||||
this.element.style.alignItems = crossAlign;
|
||||
this.element.style.display = "flex";
|
||||
this.element.style.flexDirection = direction;
|
||||
this.element.style.justifyContent = mainAlign;
|
||||
if (direction == "column") {
|
||||
this.hGap = gap;
|
||||
this.element.style.rowGap = gap;
|
||||
}
|
||||
if (direction == "row") {
|
||||
this.vGap = gap;
|
||||
this.element.style.columnGap = gap;
|
||||
}
|
||||
if (wrap)
|
||||
this.element.style.flexWrap = "wrap";
|
||||
else this.element.style.removeProperty("flex-wrap");
|
||||
|
||||
// Manage components
|
||||
this.arrange();
|
||||
}
|
||||
|
||||
// Configure the element with a split layout
|
||||
setSplitLayout(edge, sizeable) {
|
||||
|
||||
// Configure instance fields
|
||||
this.layout = "split";
|
||||
this.edge = edge;
|
||||
this.sizeable = sizeable;
|
||||
|
||||
// Working variables
|
||||
let rows = null, cols = null;
|
||||
let tracks = [ "max-content", "auto" ];
|
||||
if (sizeable)
|
||||
tracks.splice(1, 0, "max-content");
|
||||
|
||||
// Processing by edge
|
||||
switch (edge) {
|
||||
case "bottom": tracks.reverse(); // Fallthrough
|
||||
case "top" : rows = tracks.join(" "); break;
|
||||
case "right" : tracks.reverse(); // Fallthrough
|
||||
case "left" : cols = tracks.join(" ");
|
||||
}
|
||||
|
||||
// Configure element
|
||||
this.element.style.display = "grid";
|
||||
if (cols != null)
|
||||
this.element.style.gridTemplateColumns = cols;
|
||||
else this.element.style.removeProperty("grid-template-columns");
|
||||
if (rows != null)
|
||||
this.element.style.gridTemplateRows = rows;
|
||||
else this.element.style.removeProperty("grid-template-rows");
|
||||
|
||||
// Manage components
|
||||
this.arrange();
|
||||
}
|
||||
|
||||
|
||||
|
||||
///////////////////////////// Private Methods /////////////////////////////
|
||||
|
||||
// Configure the panel's DOM elements
|
||||
arrange() {
|
||||
let components = [];
|
||||
|
||||
// Remove all children from the DOM
|
||||
for (let comp of this.children)
|
||||
comp.element.remove();
|
||||
|
||||
// Split layout
|
||||
if (this.layout == "split") {
|
||||
components.push(this.children[0]);
|
||||
components.push(this.children[1]);
|
||||
if (this.sizeable)
|
||||
components.splice(1, 0, this.splitter);
|
||||
if (this.edge == "bottom" || this.edge == "right")
|
||||
components.reverse();
|
||||
}
|
||||
|
||||
// Flex and grid layouts
|
||||
else {
|
||||
for (let comp of this.children)
|
||||
components.push(comp);
|
||||
}
|
||||
|
||||
// Add the resulting elements to the DOM
|
||||
for (let comp of components)
|
||||
if (comp)
|
||||
this.element.appendChild(comp.element);
|
||||
}
|
||||
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
"use strict";
|
||||
|
||||
// Widget toolkit manager
|
||||
(globalThis.Toolkit = class Toolkit {
|
||||
|
||||
// Static initializer
|
||||
static initializer() {
|
||||
|
||||
// Static fields
|
||||
Toolkit.lastId = 0;
|
||||
}
|
||||
|
||||
// Produce a unique element ID
|
||||
static id() {
|
||||
return "i" + (Toolkit.lastId++);
|
||||
}
|
||||
|
||||
}).initializer();
|
||||
Reference in New Issue
Block a user