diff --git a/.gitattributes b/.gitattributes index 3db5655..a7a4c68 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/app/App.js b/app/App.js index bcad90c..8386767 100644 --- a/app/App.js +++ b/app/App.js @@ -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); + } + }; diff --git a/app/_boot.js b/app/_boot.js index 36810ae..23f8ff1 100644 --- a/app/_boot.js +++ b/app/_boot.js @@ -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(); diff --git a/app/locale/en-US.js b/app/locale/en-US.js new file mode 100644 index 0000000..b1e3d72 --- /dev/null +++ b/app/locale/en-US.js @@ -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" + } + } +} diff --git a/app/theme/base.css b/app/theme/base.css new file mode 100644 index 0000000..7e1f96d --- /dev/null +++ b/app/theme/base.css @@ -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; +} diff --git a/app/theme/dark.css b/app/theme/dark.css new file mode 100644 index 0000000..9b1d3e1 --- /dev/null +++ b/app/theme/dark.css @@ -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; +} diff --git a/app/theme/light.css b/app/theme/light.css new file mode 100644 index 0000000..3d260ff --- /dev/null +++ b/app/theme/light.css @@ -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; +} diff --git a/app/theme/virtual.css b/app/theme/virtual.css new file mode 100644 index 0000000..a417736 --- /dev/null +++ b/app/theme/virtual.css @@ -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"); } diff --git a/app/toolkit/Application.js b/app/toolkit/Application.js new file mode 100644 index 0000000..1743665 --- /dev/null +++ b/app/toolkit/Application.js @@ -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; + } + +}; diff --git a/app/toolkit/Button.js b/app/toolkit/Button.js new file mode 100644 index 0000000..1541751 --- /dev/null +++ b/app/toolkit/Button.js @@ -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); + } + +}; diff --git a/app/toolkit/Component.js b/app/toolkit/Component.js new file mode 100644 index 0000000..5973fac --- /dev/null +++ b/app/toolkit/Component.js @@ -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); + } + +}; diff --git a/app/toolkit/Menu.js b/app/toolkit/Menu.js new file mode 100644 index 0000000..3da9e42 --- /dev/null +++ b/app/toolkit/Menu.js @@ -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); + } + +}; diff --git a/app/toolkit/MenuBar.js b/app/toolkit/MenuBar.js new file mode 100644 index 0000000..f95f35f --- /dev/null +++ b/app/toolkit/MenuBar.js @@ -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(); + } + +}; diff --git a/app/toolkit/MenuItem.js b/app/toolkit/MenuItem.js new file mode 100644 index 0000000..9ee7db4 --- /dev/null +++ b/app/toolkit/MenuItem.js @@ -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); + } + +}; diff --git a/app/toolkit/Panel.js b/app/toolkit/Panel.js new file mode 100644 index 0000000..8f845d4 --- /dev/null +++ b/app/toolkit/Panel.js @@ -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); + } + +}; diff --git a/app/toolkit/Toolkit.js b/app/toolkit/Toolkit.js new file mode 100644 index 0000000..bf00ef4 --- /dev/null +++ b/app/toolkit/Toolkit.js @@ -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();