Introducing toolkit and color themes
This commit is contained in:
parent
0927a42107
commit
dd066e0bbb
|
@ -1,13 +1,10 @@
|
||||||
* text=auto
|
*.c text eol=lf diff=c
|
||||||
|
*.css text eol=lf diff=css
|
||||||
*.c text diff=c
|
*.h text eol=lf diff=c
|
||||||
*.css text diff=css
|
*.html text eol=lf diff=html
|
||||||
*.h text diff=c
|
*.java text eol=lf diff=java
|
||||||
*.html text diff=html
|
*.js text eol=lf diff=js
|
||||||
*.java text diff=java
|
*.txt text eol=lf
|
||||||
*.js text diff=js
|
|
||||||
*.sample text
|
|
||||||
*.txt text
|
|
||||||
|
|
||||||
*.class binary
|
*.class binary
|
||||||
*.dll binary
|
*.dll binary
|
||||||
|
|
103
app/App.js
103
app/App.js
|
@ -1,5 +1,108 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
// Top-level state and UI manager
|
||||||
globalThis.App = class App {
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
32
app/_boot.js
32
app/_boot.js
|
@ -34,8 +34,8 @@ globalThis.Bundle = class BundledFile {
|
||||||
await Bundle.files[name].run();
|
await Bundle.files[name].run();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve a URL for a script source file
|
// Resolve a URL for a source file
|
||||||
static script(name) {
|
static source(name) {
|
||||||
return debug ? name : Bundle.files[name].toDataURL();
|
return debug ? name : Bundle.files[name].toDataURL();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ globalThis.Bundle = class BundledFile {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Running in debug mode
|
// Running in debug mode
|
||||||
return new Promise((resolve,reject)=>{
|
await new Promise((resolve,reject)=>{
|
||||||
let script = document.createElement("script");
|
let script = document.createElement("script");
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
script.addEventListener("load", ()=>resolve());
|
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
|
// Produce a blob from the file data
|
||||||
toBlob() {
|
toBlob() {
|
||||||
return new Blob(this.data, { type: this.mime });
|
return new Blob(this.data, { type: this.mime });
|
||||||
|
@ -268,7 +284,15 @@ for (let file of manifest) {
|
||||||
|
|
||||||
// Program startup
|
// Program startup
|
||||||
let run = async function() {
|
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();
|
new App();
|
||||||
};
|
};
|
||||||
run();
|
run();
|
||||||
|
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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"); }
|
|
@ -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();
|
Loading…
Reference in New Issue