Introducing toolkit and color themes

This commit is contained in:
Guy Perfect 2021-08-23 18:56:36 -05:00
parent 0927a42107
commit dd066e0bbb
16 changed files with 1641 additions and 14 deletions

17
.gitattributes vendored
View File

@ -1,13 +1,10 @@
* text=auto
*.c text diff=c
*.css text diff=css
*.h text diff=c
*.html text diff=html
*.java text diff=java
*.js text diff=js
*.sample text
*.txt text
*.c text eol=lf diff=c
*.css text eol=lf diff=css
*.h text eol=lf diff=c
*.html text eol=lf diff=html
*.java text eol=lf diff=java
*.js text eol=lf diff=js
*.txt text eol=lf
*.class binary
*.dll binary

View File

@ -1,5 +1,108 @@
"use strict";
// Top-level state and UI manager
globalThis.App = class App {
// Object constructor
constructor() {
// Configure themes
Bundle.get("app/theme/base.css").style();
this.themes = {
dark : Bundle.get("app/theme/dark.css" ).style(false),
light : Bundle.get("app/theme/light.css" ).style(true ),
virtual: Bundle.get("app/theme/virtual.css").style(false)
};
this.theme = this.themes["light"];
// Produce toolkit instance
this.gui = new Toolkit.Application();
document.body.appendChild(this.gui.element);
window.addEventListener("resize", ()=>{
this.gui.element.style.height = window.innerHeight + "px";
this.gui.element.style.width = window.innerWidth + "px";
});
// Configure locales
this.gui.addLocale(Bundle.get("app/locale/en-US.js").toString());
this.gui.setLocale(navigator.language);
// Configure GUI
this.gui.setSplitLayout("top", false);
// Menu bar
this.mainMenu = this.gui.newMenuBar({ name: "{menu._}" });
this.gui.add(this.mainMenu);
// File menu
let menu = this.mainMenu.newMenu({ text: "{menu.file._}"});
let item = menu.newMenuItem({ text: "{menu.file.loadROM}"});
item.addClickListener(()=>this.loadROM());
// Theme menu
menu = this.mainMenu.newMenu({ text: "{menu.theme._}"});
item = menu.newMenuItem({ text: "{menu.theme.light}"});
item.addClickListener(()=>this.setTheme("light"));
item = menu.newMenuItem({ text: "{menu.theme.dark}"});
item.addClickListener(()=>this.setTheme("dark"));
item = menu.newMenuItem({ text: "{menu.theme.virtual}"});
item.addClickListener(()=>this.setTheme("virtual"));
}
///////////////////////////// Private Methods /////////////////////////////
// Prompt the user to select a ROM file
loadROM() {
let file = document.createElement("input");
file.type = "file";
file.addEventListener("input", ()=>this.setROM(file.files[0]));
file.click();
}
// Specify a ROM file
async setROM(file) {
// No file is specified (perhaps the user canceled)
if (file == null)
return;
// Check the file's size
if (
file.size < 1024 ||
file.size > 0x1000000 ||
(file.size - 1 & file.size) != 0
) {
alert(this.gui.translate("{app.romNotVB}"));
return;
}
// Load the file data into a byte buffer
let filename = file.name;
try { file = new Uint8Array(await file.arrayBuffer()); }
catch {
alert(this.gui.translate("{app.readFileError}"));
return;
}
// Testing output pending further features
alert(this.gui.translate("{app.romLoaded}", {
filename: filename,
size : file.length + " byte" + (file.length == 1 ? "" : "s")
}));
}
// Specify the current color theme
setTheme(key) {
let theme = this.themes[key];
if (theme == this.theme)
return;
let old = this.theme;
this.theme = theme;
theme.setEnabled(true);
old.setEnabled(false);
}
};

View File

@ -34,8 +34,8 @@ globalThis.Bundle = class BundledFile {
await Bundle.files[name].run();
}
// Resolve a URL for a script source file
static script(name) {
// Resolve a URL for a source file
static source(name) {
return debug ? name : Bundle.files[name].toDataURL();
}
@ -79,7 +79,7 @@ globalThis.Bundle = class BundledFile {
}
// Running in debug mode
return new Promise((resolve,reject)=>{
await new Promise((resolve,reject)=>{
let script = document.createElement("script");
document.head.appendChild(script);
script.addEventListener("load", ()=>resolve());
@ -88,6 +88,22 @@ globalThis.Bundle = class BundledFile {
}
// Register the file as a CSS stylesheet
style(enabled) {
let link = document.createElement("link");
link.href = debug ? this.name : this.toDataURL();
link.rel = "stylesheet";
link.type = "text/css";
link.setEnabled = enabled=>{
if (enabled)
link.removeAttribute("disabled");
else link.setAttribute("disabled", null);
};
link.setEnabled(enabled === undefined || !!enabled);
document.head.appendChild(link);
return link;
}
// Produce a blob from the file data
toBlob() {
return new Blob(this.data, { type: this.mime });
@ -268,7 +284,15 @@ for (let file of manifest) {
// Program startup
let run = async function() {
Bundle.run("app/App.js");
await Bundle.run("app/App.js");
await Bundle.run("app/toolkit/Toolkit.js");
await Bundle.run("app/toolkit/Component.js");
await Bundle.run("app/toolkit/Panel.js");
await Bundle.run("app/toolkit/Application.js");
await Bundle.run("app/toolkit/Button.js");
await Bundle.run("app/toolkit/MenuBar.js");
await Bundle.run("app/toolkit/MenuItem.js");
await Bundle.run("app/toolkit/Menu.js");
new App();
};
run();

22
app/locale/en-US.js Normal file
View File

@ -0,0 +1,22 @@
{
key : "en-US",
name: "English (United States)",
app : {
romLoaded : "Successfully loaded file \"{filename}\" ({size})",
romNotVB : "The selected file is not a Virtual Boy ROM.",
readFileError: "Unable to read the selected file."
},
menu: {
_ : "Main application menu",
file: {
_ : "File",
loadROM: "Load ROM..."
},
theme: {
_ : "Theme",
dark : "Dark",
light : "Light",
virtual: "Virtual"
}
}
}

118
app/theme/base.css Normal file
View File

@ -0,0 +1,118 @@
/* Common styles for all themes */
:root {
color : var(--text);
font-family: Arial, sans-serif;
font-size : 16px;
}
body {
background: var(--background);
margin : 0;
overflow : hidden;
}
*:focus {
box-shadow:
0 0 0 1px var(--background),
0 0 0 3px var(--focus-ring),
0 0 0 4px var(--background);
outline : none;
z-index : 1;
}
/********************************** Button ***********************************/
[role="button"] {
align-items : center;
background : var(--button);
border-color :
var(--border-light)
var(--border-dark )
var(--border-dark )
var(--border-light)
;
border-radius : 3px;
border-style : solid;
border-width : 1px;
display : flex;
justify-content: center;
padding : 4px;
}
[role="button"][aria-pressed="true"]:not([active]) {
background : var(--button-pressed);
border-color:
var(--border-dark )
var(--border-light)
var(--border-light)
var(--border-dark )
;
padding: 5px 3px 3px 5px;
}
[role="button"]:not([active]):hover {
background: var(--button-hover);
}
[role="button"][active]:not([aria-pressed="true"]) {
background : var(--button-pressed);
border-color:
var(--border-dark )
var(--border-light)
var(--border-light)
var(--border-dark )
;
padding: 5px 3px 3px 5px;
}
[role="button"][aria-pressed="true"][active] {
background : var(--button-pressed);
border-color:
var(--border-dark )
var(--border-light)
var(--border-light)
var(--border-dark )
;
padding: 6px 2px 2px 6px;
}
/********************************** MenuBar **********************************/
[role="menubar"] {
border-bottom: 1px solid var(--border-weak);
column-gap : 4px;
padding : 4px;
}
[role="menubar"] > [role="menuitem"] {
border-radius: 3px;
padding : 4px;
}
[role="menubar"] > * [role="menuitem"] {
border-radius: 3px;
padding : 4px;
}
[role="menubar"] [role="menuitem"]:hover,
[role="menubar"] [role="menuitem"]:focus,
[role="menubar"] [role="menuitem"][aria-expanded="true"] {
background: var(--button-hover);
}
[role="menubar"] [role="menu"] {
background : var(--background);
border-radius: 4px;
box-shadow :
0 0 0 1px var(--border-strong),
4px 4px 5px var(--shadow)
;
min-height : 16px;
min-width : 16px;
padding : 4px;
}

13
app/theme/dark.css Normal file
View File

@ -0,0 +1,13 @@
:root {
--background : #222222;
--border-dark : #555555;
--border-light : #999999;
--border-weak : #666666;
--border-strong : #999999;
--button : #444444;
--button-hover : #4c4c4c;
--button-pressed: #555555;
--focus-ring : #0099ff;
--shadow : #00000080;
--text : #cccccc;
}

13
app/theme/light.css Normal file
View File

@ -0,0 +1,13 @@
:root {
--background : #ffffff;
--border-dark : #666666;
--border-light : #aaaaaa;
--border-weak : #aaaaaa;
--border-strong : #666666;
--button : #cccccc;
--button-hover : #d5d5d5;
--button-pressed: #dddddd;
--focus-ring : #0099ff;
--shadow : #00000055;
--text : #000000;
}

15
app/theme/virtual.css Normal file
View File

@ -0,0 +1,15 @@
:root {
--background : #000000;
--border-dark : #550000;
--border-light : #aa0000;
--border-weak : #550000;
--border-strong : #aa0000;
--button : #000000;
--button-hover : #550000;
--button-pressed: #aa0000;
--focus-ring : #ff0000;
--shadow : #ff000080;
--text : #ff0000;
}
body { filter: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxmaWx0ZXIgaWQ9InYiPjxmZUNvbG9yTWF0cml4IGluPSJTb3VyY2VHcmFwaGljIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMSAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMSAwIiAvPjwvZmlsdGVyPjwvc3ZnPg==#v"); }

192
app/toolkit/Application.js Normal file
View File

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

244
app/toolkit/Button.js Normal file
View File

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

92
app/toolkit/Component.js Normal file
View File

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

275
app/toolkit/Menu.js Normal file
View File

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

99
app/toolkit/MenuBar.js Normal file
View File

@ -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();
}
};

214
app/toolkit/MenuItem.js Normal file
View File

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

188
app/toolkit/Panel.js Normal file
View File

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

18
app/toolkit/Toolkit.js Normal file
View File

@ -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();