From f753f9f59bb6246177c9736690371ce3a3b415ba Mon Sep 17 00:00:00 2001 From: Guy Perfect Date: Thu, 26 Aug 2021 19:23:18 +0000 Subject: [PATCH] Adding desktop/window components --- app/App.js | 28 ++- app/_boot.js | 1 + app/locale/en-US.js | 5 + app/theme/base.css | 118 ------------ app/theme/dark.css | 23 ++- app/theme/kiosk.css | 188 ++++++++++++++++++ app/theme/light.css | 21 +- app/theme/virtual.css | 25 +-- app/toolkit/Application.js | 29 ++- app/toolkit/Button.js | 221 ++++++++++----------- app/toolkit/Component.js | 84 ++++++-- app/toolkit/Menu.js | 211 ++++++++------------ app/toolkit/MenuBar.js | 96 ++++++---- app/toolkit/MenuItem.js | 230 ++++++++++++++++------ app/toolkit/Panel.js | 265 +++++++++++++++---------- app/toolkit/Window.js | 382 +++++++++++++++++++++++++++++++++++++ makefile | 2 +- 17 files changed, 1297 insertions(+), 632 deletions(-) delete mode 100644 app/theme/base.css create mode 100644 app/theme/kiosk.css create mode 100644 app/toolkit/Window.js diff --git a/app/App.js b/app/App.js index 8386767..4d6fe1e 100644 --- a/app/App.js +++ b/app/App.js @@ -7,7 +7,7 @@ globalThis.App = class App { constructor() { // Configure themes - Bundle.get("app/theme/base.css").style(); + Bundle.get("app/theme/kiosk.css").style(); this.themes = { dark : Bundle.get("app/theme/dark.css" ).style(false), light : Bundle.get("app/theme/light.css" ).style(true ), @@ -16,23 +16,24 @@ globalThis.App = class App { this.theme = this.themes["light"]; // Produce toolkit instance - this.gui = new Toolkit.Application(); + this.gui = new Toolkit.Application({ + layout: "grid", + rows : "max-content auto" + }); 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"; + this.gui.setSize(window.innerWidth+"px", window.innerHeight+"px"); }); + window.dispatchEvent(new Event("resize")); // 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); + this.gui.addPropagationListener(e=>this.mainMenu.restoreFocus()); // File menu let menu = this.mainMenu.newMenu({ text: "{menu.file._}"}); @@ -47,6 +48,19 @@ globalThis.App = class App { item.addClickListener(()=>this.setTheme("dark")); item = menu.newMenuItem({ text: "{menu.theme.virtual}"}); item.addClickListener(()=>this.setTheme("virtual")); + + // Desktop pane + let desktop = this.gui.newPanel({ layout: "desktop" }); + desktop.setRole("group"); + desktop.element.setAttribute("desktop", ""); + this.gui.add(desktop); + + let wnd = this.gui.newWindow({ + title: "{memory._}" + }); + desktop.add(wnd); + wnd.setLocation ( 20, 10); + wnd.setClientSize(384, 224); } diff --git a/app/_boot.js b/app/_boot.js index 23f8ff1..72ea4f9 100644 --- a/app/_boot.js +++ b/app/_boot.js @@ -293,6 +293,7 @@ let run = async function() { await Bundle.run("app/toolkit/MenuBar.js"); await Bundle.run("app/toolkit/MenuItem.js"); await Bundle.run("app/toolkit/Menu.js"); + await Bundle.run("app/toolkit/Window.js"); new App(); }; run(); diff --git a/app/locale/en-US.js b/app/locale/en-US.js index b1e3d72..370f128 100644 --- a/app/locale/en-US.js +++ b/app/locale/en-US.js @@ -2,10 +2,15 @@ key : "en-US", name: "English (United States)", app : { + close : "Close", + console : "Console", romLoaded : "Successfully loaded file \"{filename}\" ({size})", romNotVB : "The selected file is not a Virtual Boy ROM.", readFileError: "Unable to read the selected file." }, + memory: { + _: "Memory" + }, menu: { _ : "Main application menu", file: { diff --git a/app/theme/base.css b/app/theme/base.css deleted file mode 100644 index 7e1f96d..0000000 --- a/app/theme/base.css +++ /dev/null @@ -1,118 +0,0 @@ -/* 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 index 9b1d3e1..5fa27e9 100644 --- a/app/theme/dark.css +++ b/app/theme/dark.css @@ -1,13 +1,12 @@ :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; -} + --control : #222222; + --control-focus : #444444; + --control-shadow : #999999; + --desktop : #111111; + --text : #cccccc; + --window-blur : #555555; + --window-blur-text : #cccccc; + --window-close-text: #cccccc; + --window-focus : #007ACC; + --window-focus-text: #ffffff; +} \ No newline at end of file diff --git a/app/theme/kiosk.css b/app/theme/kiosk.css new file mode 100644 index 0000000..555cb67 --- /dev/null +++ b/app/theme/kiosk.css @@ -0,0 +1,188 @@ +/* Common styles for all themes */ + +:root { + color : var(--text); + font-family: Arial, sans-serif; + font-size : 12px; +} + +body { + margin : 0; + overflow: hidden; +} + +*:focus { + outline: none; +} + +[desktop] { + background: var(--desktop); +} + + + +/********************************** Button ***********************************/ + +[role="button"] { + box-shadow: + 0 0 0 1px var(--control-shadow), + 1px 1px 0 1px var(--control-shadow) + ; + margin : 1px; + padding : 3px; +} + +[role="button"]:focus { + background: var(--control-focus); +} + +[role="button"][active] { + box-shadow: 0 0 0 1px var(--control-shadow); + margin : 2px 0 0 2px; +} + + + +/********************************** MenuBar **********************************/ + +[role="menubar"] { + background : var(--control); + border-bottom: 1px solid var(--text); + padding : 2px 3px 3px 2px; +} + +[role="menubar"] [role="menuitem"] { + margin : 1px; + padding: 3px; +} + +[role="menubar"] [role="menuitem"]:focus { + background: var(--control-focus); +} + +[role="menubar"] [role="menuitem"]:not([active],[aria-expanded="true"]):focus, +[role="menubar"] [role="menuitem"]:not([active],[aria-expanded="true"]):hover { + box-shadow: + 0 0 0 1px var(--control-shadow), + 1px 1px 0 1px var(--control-shadow) + ; +} + +[role="menubar"] [role="menuitem"][active], +[role="menubar"] [role="menuitem"][aria-expanded="true"] { + box-shadow: 0 0 0 1px var(--control-shadow); + margin : 2px 0 0 2px; +} + +[role="menubar"] [role="menu"] { + background: var(--control); + box-shadow: + 0 0 0 1px var(--text), + 1px 1px 0 1px var(--text) + ; + min-height: 16px; + min-width : 16px; + padding : 2px 3px 3px 2px; +} + + + +/********************************** Window ***********************************/ + +[role="dialog"] { + /*background: #cc0000;*/ +} + +[role="dialog"] [name="body"] { + background: var(--control); + box-shadow: + 0 0 0 1px var(--control), + 0 0 0 2px var(--text), + 1px 1px 0 2px var(--text) + ; + row-gap : 3px; +} + +[role="dialog"] [name="title-bar"] { +} + +[role="dialog"][focus="true"] [name="title-bar"] { + box-shadow: + 0 0 0 1px var(--window-focus), + 0 1px 0 1px var(--control-shadow) + ; + background : var(--window-focus); +} + +[role="dialog"][focus="false"] [name="title-bar"] { + box-shadow: + 0 0 0 1px var(--window-blur), + 0 1px 0 1px var(--control-shadow) + ; + background: var(--window-blur); +} + +[role="dialog"] [name="title-icon"], +[role="dialog"] [name="title-close-box"] { + align-self : stretch; + min-width : calc(1em + 4px); + width : calc(1em + 4px); +} + +[role="dialog"] [name="title"] { + color : var(--window-focus-text); + font-weight : bold; + overflow : hidden; + padding : 2px; + text-align : center; + text-overflow: ellipsis; + white-space : nowrap; +} + +[role="dialog"][focus="false"] [name="title"] { + color : var(--window-blur-text); +} + +[role="dialog"] [name="title-close-box"] { + align-items : center; + display : flex; + justify-content: flex-start; +} + +[role="dialog"] [name="title-close"] { + align-items : center; + background : var(--control); + box-shadow : + 0 0 0 1px var(--control), + 0 0 0 2px var(--control-shadow) + ; + display : flex; + height : 11px; + justify-content: center; + overflow : hidden; + padding : 0; + width : 11px; +} + +[role="dialog"] [name="title-close"]:focus { + background: var(--control-focus); +} + +[role="dialog"] [name="title-close"][active] { + box-shadow : + -1px -1px 0 1px var(--control), + -1px -1px 0 2px var(--control-shadow) + ; + margin: 2px 0 0 2px; +} + +[role="dialog"] [name="title-close"]:after { + color : var(--window-close-text); + content : '\00d7'; + font-size : 12px; + line-height: 1em; +} + +[role="dialog"] [name="client"] { + background: var(--control); +} diff --git a/app/theme/light.css b/app/theme/light.css index 3d260ff..58a4cd8 100644 --- a/app/theme/light.css +++ b/app/theme/light.css @@ -1,13 +1,12 @@ :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; + --control : #eeeeee; + --control-focus : #cccccc; + --control-shadow : #999999; + --desktop : #cccccc; + --text : #000000; + --window-blur : #cccccc; + --window-blur-text : #444444; + --window-close-text: #444444; + --window-focus : #80ccff; + --window-focus-text: #000000; } diff --git a/app/theme/virtual.css b/app/theme/virtual.css index a417736..ffbc469 100644 --- a/app/theme/virtual.css +++ b/app/theme/virtual.css @@ -1,15 +1,16 @@ :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; + --control : #000000; + --control-focus : #550000; + --control-shadow : #aa0000; + --desktop : #000000; + --text : #ff0000; + --window-blur : #000000; + --window-blur-text : #aa0000; + --window-close-text: #ff0000; + --window-focus : #550000; + --window-focus-text: #ff0000; } -body { filter: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxmaWx0ZXIgaWQ9InYiPjxmZUNvbG9yTWF0cml4IGluPSJTb3VyY2VHcmFwaGljIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMSAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMSAwIiAvPjwvZmlsdGVyPjwvc3ZnPg==#v"); } +[filter="true"] { + filter: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjxmaWx0ZXIgaWQ9InYiPjxmZUNvbG9yTWF0cml4IGluPSJTb3VyY2VHcmFwaGljIiB0eXBlPSJtYXRyaXgiIHZhbHVlcz0iMSAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMCAwIDAgMSAwIiAvPjwvZmlsdGVyPjwvc3ZnPg==#v"); +} diff --git a/app/toolkit/Application.js b/app/toolkit/Application.js index 1743665..2f3448f 100644 --- a/app/toolkit/Application.js +++ b/app/toolkit/Application.js @@ -4,17 +4,20 @@ Toolkit.Application = class Application extends Toolkit.Panel { // Object constructor - constructor() { - super(null); + constructor(options) { + super(null, options); // Configure instance fields - this.application = this; - this.components = []; - this.locale = null; - this.locales = { first: null }; + this.application = this; + this.components = []; + this.locale = null; + this.locales = { first: null }; + this.propagationListeners = []; // Configure element this.element.setAttribute("application", ""); + this.element.addEventListener("mousedown" , e=>this.onpropagation(e)); + this.element.addEventListener("pointerdown", e=>this.onpropagation(e)); } @@ -50,6 +53,12 @@ Toolkit.Application = class Application extends Toolkit.Panel { return loc.key; } + // Add a callback for propagation events + addPropagationListener(listener) { + if (this.propagationListeners.indexOf(listener) == -1) + this.propagationListeners.push(listener); + } + // Produce a list of all registered locale keys listLocales() { return Object.values(this.locales); @@ -189,4 +198,12 @@ Toolkit.Application = class Application extends Toolkit.Panel { return typeof locale == "string" ? locale : null; } + // A pointer or mouse down even has propagated + onpropagation(e) { + e.preventDefault(); + e.stopPropagation(); + for (let listener of this.propagationListeners) + listener(e, this); + } + }; diff --git a/app/toolkit/Button.js b/app/toolkit/Button.js index 1541751..8d24fff 100644 --- a/app/toolkit/Button.js +++ b/app/toolkit/Button.js @@ -1,192 +1,184 @@ "use strict"; -// Clickable button +// Push button Toolkit.Button = class Button extends Toolkit.Component { // Object constructor constructor(application, options) { - super(application, "div"); + super(application, "div", options); 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; + this.enabled = "enabled" in options ? !!options.enabled : true; + this.focusable = "focusable" in options?!!options.focusable:true; + this.name = options.name || ""; + this.text = options.text || ""; + this.toolTip = options.toolTip || ""; // Configure element - this.element.type = "button"; this.element.setAttribute("role", "button"); + this.element.setAttribute("tabindex", "0"); 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); + this.setEnabled (this.enabled ); + this.setFocusable(this.focusable); + this.setName (this.name ); + this.setText (this.text ); + this.setToolTip (this.toolTip ); + this.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); + // Request focus on the appropriate element + focus() { + this.element.focus(); } - // Determine whether the component participates in the tab sequence - getTabStop() { - return this.tabStop; + // Retrieve the button's accessible name + getName() { + return this.name; } - // Retrieve the control's text + // Retrieve the button's display text getText() { return this.text; } - // Determine whether the control is enabled + // Retrieve the button's tool tip text + getToolTip() { + return this.toolTip; + } + + // Determine whether the button is enabled isEnabled() { return this.enabled; } - // Determine the toggle button's active state - isPressed() { - return this.pressed; + // Determine whether the button is focusable + isFocusable() { + return this.focusable; } - // Determine whether the button is a toggle button - isToggleable() { - return this.toggleable; - } - - // Specify whether the control is enabled + // Specify whether the button is enabled setEnabled(enabled) { this.enabled = enabled = !!enabled; - if (enabled) - this.element.removeAttribute("disabled"); - else this.element.setAttribute("disabled", ""); + this.element.setAttribute("aria-disabled", !enabled); } - // Specify whether the component participates in the regular tab sequence - setTabStop(tabStop) { - this.tabStop = tabStop = !!tabStop; - this.element.setAttribute("tabindex", tabStop ? "0" : "-1"); + // Specify whether the button can receive focus + setFocusable(focusable) { + this.focusable = focusable = !!focusable; + if (focusable) + this.element.setAttribute("tabindex", "0"); + else this.element.removeAttribute("tabindex"); } - // Specify the toggle button's active state - setPressed(pressed) { - this.pressed = pressed = !!pressed; - if (this.toggleable) - this.element.setAttribute("aria-pressed", pressed); + // Specify the button's accessible name + setName(name) { + this.name = name || ""; + this.localize(); } - // Specify the control's text + // Specify the button's display 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"); + // Specify the button's tool tip text + setToolTip(toolTip) { + this.toolTip = toolTip || ""; + this.localize(); + } + + + + ///////////////////////////// Package Methods ///////////////////////////// + + // Update display text with localized strings + localize() { + let name = this.name || this.text; + let text = this.text; + let toolTip = this.toolTip; + if (this.application) { + name = this.application.translate(name, this); + text = this.application.translate(text, this); + if (toolTip) + toolTip = this.application.translate(toolTip, this); + } + this.element.setAttribute("aria-label", name); + this.element.innerText = text; + if (toolTip) + this.element.setAttribute("title", toolTip); + else this.element.removeAttribute("title"); } ///////////////////////////// Private Methods ///////////////////////////// - // Actions when the button is activated + // The button was activated activate(e) { - if (this.toggleable) - this.setPressed(!this.pressed); + if (!this.enabled) + return; 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 + // Key down event handler onkeydown(e) { // Error checking - if (e.key != " " && e.key != "Enter") + if (!this.enabled) return; + // Processing by key + switch (e.key) { + case " ": + case "Enter": + this.activate(e); + break; + default: 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 focus + if (this.enabled) + this.focus(); + else return; + + // Error checking + if (e.button != 0 || this.element.hasPointerCapture(e.captureId)) + return; + // Configure element - this.element.focus(); this.element.setPointerCapture(e.pointerId); this.element.setAttribute("active", ""); } @@ -194,22 +186,22 @@ Toolkit.Button = class Button extends Toolkit.Component { // Pointer move event handler onpointermove(e) { - // Error checking - if (!this.element.hasPointerCapture(e.pointerId)) - return; - // Configure event e.preventDefault(); e.stopPropagation(); + // Error checking + if (!this.element.hasPointerCapture(e)) + return; + // Working variables - let bounds = this.element.getBoundingClientRect(); + let bounds = this.getBounds(); let active = e.x >= bounds.x && e.x < bounds.x + bounds.width && e.y >= bounds.y && e.y < bounds.y + bounds.height ; - // Configure event + // Configure element if (active) this.element.setAttribute("active", ""); else this.element.removeAttribute("active"); @@ -218,26 +210,21 @@ Toolkit.Button = class Button extends Toolkit.Component { // 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"); + // Error checking + if (!this.element.hasPointerCapture(e.pointerId)) + return; // Configure element this.element.releasePointerCapture(e.pointerId); - this.element.removeAttribute("active"); - // The pointer was released without activating the button - if (!active) + // Activate the menu item if it is active + if (!this.element.hasAttribute("active")) return; - - // The button was activated + this.element.removeAttribute("active"); this.activate(e); } diff --git a/app/toolkit/Component.js b/app/toolkit/Component.js index 5973fac..d04826e 100644 --- a/app/toolkit/Component.js +++ b/app/toolkit/Component.js @@ -4,19 +4,24 @@ Toolkit.Component = class Component { // Object constructor - constructor(application, tagname) { + constructor(application, tagname, options) { + options = options || {}; // Configure instance fields this.application = application; this.containers = [ this ]; + this.display = null; this.element = document.createElement(tagname); this.id = this.element.id = Toolkit.id(); this.parent = null; this.properties = {}; this.resizeListeners = []; + this.resizeObserver = null; + this.visible = "visible" in options ? !!options.visible : true; // Configure component this.element.component = this; + this.setVisible(this.visible); } @@ -27,8 +32,10 @@ Toolkit.Component = class Component { addResizeListener(listener) { if (this.resizeListeners.indexOf(listener) != -1) return; - if (this.resizeListeners.length == 0) - new ResizeObserver(()=>this.onresize()).observe(this.element); + if (this.resizeObserver == null) { + this.resizeObserver = new ResizeObserver(()=>this.onresized()); + this.resizeObserver.observe(this.element); + } this.resizeListeners.push(listener); } @@ -42,18 +49,77 @@ Toolkit.Component = class Component { return this.element.getBoundingClientRect(); } + // Retrieve the display CSS property of the visible element + getDisplay() { + return this.display; + } + + // Determine whether the component is visible + isVisible() { + for (let comp = this; comp != null; comp = comp.parent) + if (!comp.visible) + return false; + return true; + } + + // Specify the display CSS property of the visible element + setDisplay(display) { + this.display = display || null; + this.setVisible(this.visible); + } + // Specify the height of the element setHeight(height) { if (height === null) this.element.style.removeProperty("height"); - else this.element.style.height = height; + else this.element.style.height = + typeof height == "number" ? height + "px" : height + } + + // Specify the horizontal position of the component + setLeft(left) { + if (left === null) + this.element.style.removeProperty("left"); + else this.element.style.left = + typeof left == "number" ? left + "px" : left ; + } + + // Specify the absolute position of the component + setLocation(left, top) { + this.setLeft(left); + this.setTop (top ); + } + + // Specify both the width and the height of the component + setSize(width, height) { + this.setHeight(height); + this.setWidth (width ); + } + + // Specify the vertical position of the component + setTop(top) { + if (top === null) + this.element.style.removeProperty("top"); + else this.element.style.top = + typeof top == "number" ? top + "px" : top ; + } + + // Specify whether the component is visible + setVisible(visible) { + this.visible = visible = !!visible; + if (visible) { + if (this.display == null) + this.element.style.removeProperty("display"); + else this.element.style.display = this.display; + } else this.element.style.display = "none"; } // Specify the width of the element setWidth(width) { if (width === null) this.element.style.removeProperty("width"); - else this.element.style.width = width; + else this.element.style.width = + typeof width == "number" ? width + "px" : width ; } @@ -72,18 +138,12 @@ Toolkit.Component = class Component { 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() { + onresized() { 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 index 3da9e42..bd5647f 100644 --- a/app/toolkit/Menu.js +++ b/app/toolkit/Menu.js @@ -4,46 +4,45 @@ Toolkit.Menu = class Menu extends Toolkit.MenuItem { // Object constructor - constructor(parent, options) { - super(parent, options); - - // Configure instance fields - this.items = []; - this.parent = parent; + constructor(application, options) { + super(application, options); // 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.menu = this.add(this.application.newPanel({ + layout : "flex", + alignCross: "stretch", + direction : "column", + visible : false + })); + this.menu.element.style.position = "absolute"; + this.menu.element.setAttribute("role", "menu"); + this.menu.element.setAttribute("aria-labelledby", this.id); this.containers.push(this.menu); + this.children = this.menu.children; // 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 + newMenu(options, index) { + let menu = this.menu.add(new Toolkit.Menu( + this.application, options), index); + menu.child(); + menu.element.insertAdjacentElement("afterend", menu.menu.element); + return menu; + } + // 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); + let item = this.menu.add(new Toolkit.MenuItem( + this.application, options), index); + item.child(); return item; } @@ -63,18 +62,8 @@ Toolkit.Menu = class Menu extends Toolkit.MenuItem { 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); + if (deeper && this.children.length > 0) + this.children[0].focus(); } // Show or hide the pop-up menu @@ -85,7 +74,7 @@ Toolkit.Menu = class Menu extends Toolkit.MenuItem { // Hide the pop-up menu this.element.setAttribute("aria-expanded", "false"); - this.menu.style.display = "none"; + this.menu.setVisible(false); this.parent.expanded = null; // Close any expanded submenus @@ -106,9 +95,11 @@ Toolkit.Menu = class Menu extends Toolkit.MenuItem { // 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"; + this.menu.setVisible(true); + this.menu.setLocation( + (bounds.x - barBounds.x) + "px", + (bounds.y + bounds.height - barBounds.y) + "px" + ); } @@ -117,120 +108,60 @@ Toolkit.Menu = class Menu extends Toolkit.MenuItem { // Key press event handler onkeydown(e) { + let index; // 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; + // Delegate to the MenuItem handler for these keys + case " " : + case "ArrowLeft": + case "End" : + case "Enter" : + case "Escape" : + case "Home" : + return super.onkeydown(e); // Conditional - case "ArrowLeft": + case "ArrowDown": - // 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(); - } + // Open the menu and select the first item (if any) + if (this.parent == this.menuBar) + this.activate(true); - // Close the menu and return to the parent menu - else { - this.setExpanded(false); - this.parent.element.focus(); - } + // Delegate to the MenuItem handler + else return super.onkeydown(e); 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 the first item (if any) + if (this.parent != this.menuBar) + this.activate(true); - // Open the menu and select its first item (if any) - else this.activate(true); + // Delegate to the MenuItem handler + else return super.onkeydown(e); break; - // Open the menu and select the last item (if any) + // Conditional 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 + // Open the menu and select the last item (if any) 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(); - } + this.activate(false); + index = this.previousChild(0); + if (index != -1) + this.children[index].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(); - } - } + // Delegate to the MenuItem handler + else return super.onkeydown(e); 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; } @@ -242,22 +173,26 @@ Toolkit.Menu = class Menu extends Toolkit.MenuItem { // Pointer down event handler onpointerdown(e) { - // Error checking - if (e.button != 0) - return; - // Configure event e.preventDefault(); e.stopPropagation(); + // Error checking + if (!this.enabled || e.button != 0) + return; + // Activate the menu - this.element.focus(); + this.focus(); this.activate(false); } // Pointer move event handler onpointermove(e) { + // Configure event + e.preventDefault(); + e.stopPropagation(); + // Error checking if ( this.parent != this.menuBar || @@ -268,8 +203,14 @@ Toolkit.Menu = class Menu extends Toolkit.MenuItem { // Activate the menu this.parent.expanded.setExpanded(false); this.parent.expanded = this; - this.element.focus(); + this.focus(); this.setExpanded(true); } + // Pointer up event handler (prevent superclass behavior) + onpointerup(e) { + e.preventDefault(); + e.stopPropagation(); + } + }; diff --git a/app/toolkit/MenuBar.js b/app/toolkit/MenuBar.js index f95f35f..662ceb1 100644 --- a/app/toolkit/MenuBar.js +++ b/app/toolkit/MenuBar.js @@ -1,24 +1,31 @@ "use strict"; // Main application menu bar -Toolkit.MenuBar = class MenuBar extends Toolkit.Component { +Toolkit.MenuBar = class MenuBar extends Toolkit.Panel { // Object constructor constructor(application, options) { - super(application, "div"); + super(application, options); // 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"); + this.setLayout("flex", { + direction: "row", + wrap : "false" + }); + this.setOverflow("visible", "visible"); + this.element.addEventListener( + "blur" , e=>this.onblur (e), { capture: true }); + this.element.addEventListener( + "focus", e=>this.onfocus(e), { capture: true }); // Configure properties this.setName(this.name); @@ -29,27 +36,40 @@ Toolkit.MenuBar = class MenuBar extends Toolkit.Component { ///////////////////////////// Public Methods ////////////////////////////// + // Add a component as a child of this container + add(component, index) { + super.add(component, index); + component.child(); + return component; + } + // 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); + // Create and add a new menu + let menu = this.add(new Toolkit.Menu(this.application,options), index); + menu.element.insertAdjacentElement("afterend", menu.menu.element); // 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"); + for (let x = 0; x < this.children.length; x++) + this.children[x].element + .setAttribute("tabindex", x == 0 ? "0" : "-1"); return menu; } + // Return focus to where it was before the menu was activated + restoreFocus() { + if (!this.contains(document.activeElement)) + return; + let elm = this.lastFocus; + if (elm == null) + elm = document.body; + elm.focus(); + if (this.contains(document.activeElement)) + document.activeElement.blur(); + } + // Specify the menu's accessible name setName(name) { this.name = name || ""; @@ -60,24 +80,32 @@ Toolkit.MenuBar = class MenuBar extends Toolkit.Component { ///////////////////////////// Package Methods ///////////////////////////// - // Notify of a change to component focus - focusChanged(from, to) { + // Blur event capture + onblur(e) { + if (this.contains(e.relatedTarget)) + return; + if (this.children.length > 0) + this.children[0].element.setAttribute("tabindex", "0"); + if (this.expanded != null) + this.expanded.setExpanded(false); + } + + // Focus event capture + onfocus(e) { // Configure tabstop on the first menu - if (this.menus.length > 0) - this.menus[0].element.setAttribute("tabindex", - this.contains(to) ? "-1" : "0"); + if (this.children.length > 0) + this.children[0].element.setAttribute("tabindex", "-1"); // 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); + if (this.contains(e.relatedTarget)) + return; + let from = e.relatedTarget; + if (from == null) + from = document.body; + if ("component" in from) + from = from.component; + this.lastFocus = from; } // Update display text with localized strings @@ -88,12 +116,4 @@ Toolkit.MenuBar = class MenuBar extends Toolkit.Component { 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 index 9ee7db4..33b42c3 100644 --- a/app/toolkit/MenuItem.js +++ b/app/toolkit/MenuItem.js @@ -1,37 +1,37 @@ "use strict"; // Selection within a Menu -Toolkit.MenuItem = class MenuItem extends Toolkit.Component { +Toolkit.MenuItem = class MenuItem extends Toolkit.Panel { // Object constructor - constructor(parent, options) { - super(parent && parent.application, "div"); + constructor(application, options) { + super(application, options); // 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.setLayout("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)); + 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 display text element this.textElement = document.createElement("div"); + this.textElement.id = Toolkit.id(); this.textElement.style.cursor = "default"; this.textElement.style.flexGrow = "1"; this.textElement.style.userSelect = "none"; + this.textElement.setAttribute("name", "text"); this.element.appendChild(this.textElement); + this.element.setAttribute("aria-labelledby", this.textElement.id); // Configure properties this.setEnabled(this.enabled); @@ -49,6 +49,11 @@ Toolkit.MenuItem = class MenuItem extends Toolkit.Component { this.clickListeners.push(listener); } + // Request focus on the appropriate element + focus() { + this.element.focus(); + } + // Retrieve the item's display text getText() { return this.text; @@ -77,12 +82,24 @@ Toolkit.MenuItem = class MenuItem extends Toolkit.Component { ///////////////////////////// Package Methods ///////////////////////////// + // Configure this component to be a child of its parent + child() { + this.menuBar = this.parent; + while (!(this.menuBar instanceof Toolkit.MenuBar)) + this.menuBar = this.menuBar.parent; + this.menuItem = this instanceof Toolkit.Menu ? this : this.parent; + while (!(this.menuItem instanceof Toolkit.Menu)) + this.menuItem = this.menuItem.parent; + this.menuTop = this instanceof Toolkit.Menu ? this : this.parent; + while (this.menuTop.parent != this.menuBar) + this.menuTop = this.menuTop.parent; + } + // 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; } @@ -97,23 +114,11 @@ Toolkit.MenuItem = class MenuItem extends Toolkit.Component { 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) { + let index; // Processing by key switch (e.key) { @@ -126,88 +131,189 @@ Toolkit.MenuItem = class MenuItem extends Toolkit.Component { // Select the next item case "ArrowDown": - this.parent.items[ - (this.parent.items.indexOf(this) + 1) % - this.parent.items.length - ].element.focus(); + index = this.parent.nextChild( + this.parent.children.indexOf(this)); + if (index != -1) + this.parent.children[index].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); + if (this.menuItem.parent == this.menuBar) { + index = this.menuBar.previousChild( + this.menuBar.children.indexOf(this.menuItem)); + if (index != -1) { + let menu = this.menuBar.children[index]; + if (menu != this.menuTop) { + if (this.menuBar.expanded != null) + menu.activate(true); + else menu.focus(); + } + } } // Close the containing submenu else { - this.parent.setExpanded(false); - this.parent.parent.element.focus(); + this.menuItem.setExpanded(false); + this.menuItem.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); + index = this.menuBar.nextChild( + this.menuBar.children.indexOf(this.menuTop)); + if (index != -1) { + let menu = this.menuBar.children[index]; + if (menu != this.menuTop) { + if (this.menuBar.expanded != null) + menu.activate(true); + else menu.focus(); + } + } 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(); + index = this.parent.previousChild( + this.parent.children.indexOf(this)); + if (index != -1) + this.parent.children[index].focus(); break; - // Select the last item in the menu + // Conditional case "End": - this.parent.items[this.parent.items.length-1].element.focus(); + + // Select the last menu in the menu bar + if (this.parent == this.menuBar) { + index = this.menuBar.previousChild( + this.menuBar.children.length); + if (index != -1) { + let menu = this.menuBar.children[index]; + if (menu != this.menuTop) { + if (this.menuBar.expanded != null) + menu.activate(true); + else menu.focus(); + } + } + } + + // Select the last item in the menu + else { + index = this.menuItem.previousChild( + this.menuItem.children.length); + if (index != -1) + this.menuItem.children[index].focus(); + } + break; // Return focus to the original element case "Escape": - this.menuBar.expanded.setExpanded(false); + if (this.menuBar.expanded != null) + this.menuBar.expanded.setExpanded(false); + this.menuBar.restoreFocus(); break; - // Select the first item in the menu + // Conditional case "Home": - this.parent.items[0].element.focus(); + + // Select the first menu in the menu bar + if (this.parent == this.menuBar) { + index = this.menuBar.nextChild(-1); + if (index != -1) { + let menu = this.menuBar.children[index]; + if (menu != this.menuTop) { + if (this.menuBar.expanded != null) + menu.activate(true); + else menu.focus(); + } + } + } + + // Select the last item in the menu + else { + index = this.menuItem.nextChild(-1); + if (index != -1) + this.menuItem.children[index].focus(); + } + break; default: return; } - // Configure element + // Configure event e.preventDefault(); e.stopPropagation(); } - // Pointer up event handler - onpointerup(e) { - - // Error checking - if (e.button != 0 || document.activeElement != this.element) - return; + // Pointer down event handler + onpointerdown(e) { // Configure event e.preventDefault(); e.stopPropagation(); - // Activate the menu item + // Configure focus + if (this.enabled) + this.focus(); + else return; + + // Error checking + if (e.button != 0 || this.element.hasPointerCapture(e.pointerId)) + return; + + // Configure element + this.element.setPointerCapture(e.pointerId); + this.element.setAttribute("active", ""); + } + + // Pointer move event handler + onpointermove(e) { + + // Configure event + e.preventDefault(); + e.stopPropagation(); + + // Error checking + if (!this.element.hasPointerCapture(e.pointerid)) + return; + + // Working variables + let bounds = this.getBounds(); + let active = + e.x >= bounds.x && e.x < bounds.x + bounds.width && + e.y >= bounds.y && e.y < bounds.y + bounds.height + ; + + // Configure element + if (active) + this.element.setAttribute("active", ""); + else this.element.removeAttribute("active"); + } + + // Pointer up event handler + onpointerup(e) { + + // Configure event + e.preventDefault(); + e.stopPropagation(); + + // Error checking + if (!this.element.hasPointerCapture(e.pointerId)) + return; + + // Configure element + this.element.releasePointerCapture(e.pointerId); + + // Activate the menu item if it is active + if (!this.element.hasAttribute("active")) + return; + this.element.removeAttribute("active"); this.activate(e); } diff --git a/app/toolkit/Panel.js b/app/toolkit/Panel.js index 8f845d4..41c93de 100644 --- a/app/toolkit/Panel.js +++ b/app/toolkit/Panel.js @@ -4,28 +4,31 @@ Toolkit.Panel = class Panel extends Toolkit.Component { // Object constructor - constructor(application) { - super(application, "div"); + constructor(application, options) { + super(application, "div", options); // Configure instance fields + options = options || {}; + this.alignCross = "start"; + this.alignMain = "start"; this.application = application; this.children = []; - this.crossAlign = "start"; + this.columns = null; this.direction = "row"; - this.edge = "left"; - this.hGap = "0"; - this.layout = "split"; - this.mainAlign = "start"; - this.sizeable = false; - this.vGap = "0"; + this.layout = null; + this.overflowX = options.overflowX || "hidden"; + this.overflowY = options.overflowY || "hidden"; + this.rows = null; this.wrap = false; // Configure element this.element.style.minHeight = "0"; this.element.style.minWidth = "0"; + this.setOverflow(this.overflowX, this.overflowY); // Configure layout - this.setSplitLayout("left", false); + options = options || {}; + this.setLayout(options.layout || null, options); } @@ -40,9 +43,13 @@ Toolkit.Panel = class Panel extends Toolkit.Component { Math.floor(Math.min(Math.max(0, index), this.children.length)); // Add the component to the container + let ref = this.children[index] || null; component.parent = this; + this.element.insertBefore(component.element, + ref == null ? null : ref.element); this.children.splice(index, 0, component); - this.arrange(); + + return component; } // Create a Button and associate it with the application @@ -60,6 +67,33 @@ Toolkit.Panel = class Panel extends Toolkit.Component { return new Toolkit.Panel(this.application, options); } + // Create a Window and associate it with the application + newWindow(options) { + return new Toolkit.Window(this.application, options); + } + + // Determine the index of the next visible child + nextChild(index) { + for (let x = 0; x <= this.children.length; x++) { + index = (index + 1) % this.children.length; + let comp = this.children[index]; + if (comp.isVisible()) + return index; + } + return -1; + } + + // Determine the index of the previous visible child + previousChild(index) { + for (let x = 0; x <= this.children.length; x++) { + index = (index + this.children.length - 1) % this.children.length; + let comp = this.children[index]; + if (comp.isVisible()) + return index; + } + return -1; + } + // Remove a component from the container remove(component) { @@ -72,117 +106,146 @@ Toolkit.Panel = class Panel extends Toolkit.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 the element's layout + setLayout(layout, options) { // Configure instance fields - this.layout = "flex"; - this.crossAlign = crossAlign; - this.direction = direction; - this.mainAlign = mainAlign; - this.wrap = wrap; + this.layout = layout; - // 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"; + // Processing by layout + switch (layout) { + case "block" : this.setBlockLayout (options); break; + case "desktop": this.setDesktopLayout(options); break; + case "flex" : this.setFlexLayout (options); break; + case "grid" : this.setGridLayout (options); break; + default : this.setNullLayout (options); break; } - // 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 the panel's overflow scrolling behavior + setOverflow(x, y) { + this.overflowX = x || "hidden"; + this.overflowY = y || "hidden"; + this.element.style.overflow = this.overflowX + " " + this.overflowY; + } - // 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(); + // Specify the semantic role of the panel + setRole(role) { + if (!role) + this.element.removeAttribute("role"); + else this.element.setAttribute("role", "" + role); } ///////////////////////////// Private Methods ///////////////////////////// - // Configure the panel's DOM elements - arrange() { - let components = []; + // Resize event handler + onresize(desktop) { - // Remove all children from the DOM - for (let comp of this.children) - comp.element.remove(); + // Error checking + if (this.layout != "desktop") + return; - // 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(); + // Ensure all child windows are visible in the viewport + for (let wnd of this.children) { + let bounds = wnd.getBounds(); + wnd.contain( + desktop, + bounds, + bounds.x - desktop.x, + bounds.y - desktop.y + ); } - // 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); + // Configure a block layout + setBlockLayout(options) { + + // Configure instance fields + this.layout = "block"; + + // Configure element + this.setDisplay("block"); + } + + // Configure a desktop layout + setDesktopLayout(options) { + + // Configure instance fields + this.layout = "desktop"; + + // Configure element + this.setDisplay("block"); + this.element.style.position = "relative"; + if (this.resizeObserver == null) + this.addResizeListener(b=>this.onresize(b)); + } + + // Configure a flex layout + setFlexLayout(options) { + + // Configure instance fields + this.alignCross = options.alignCross || "start"; + this.alignMain = options.alignMain || "start"; + this.direction = options.direction || this.direction; + this.layout = "flex"; + this.wrap = !!options.wrap; + + // Working variables + let alignCross = this.alignCross; + let alignMain = this.alignMain; + if (alignCross == "start" || alignCross == "end") + alignCross = "flex-" + alignCross; + if (alignMain == "start" || alignMain == "end") + alignMain = "flex-" + alignMain; + + // Configure element + this.setDisplay("flex"); + this.element.style.alignItems = alignCross; + this.element.style.flexDirection = this.direction; + this.element.style.justifyContent = alignMain; + this.element.style.flexWrap = this.wrap ? "wrap" : "nowrap"; + } + + // Configure a grid layout + setGridLayout(options) { + + // Configure instance fields + this.columns = options.columns || null; + this.layout = "grid"; + this.rows = options.rows || null; + + // Configure element + this.setDisplay("grid"); + if (this.columns == null) + this.element.style.removeProperty("grid-template-columns"); + else this.element.style.gridTemplateColumns = this.columns; + if (this.rows == null) + this.element.style.removeProperty("grid-template-rows"); + else this.element.style.gridTemplateRows = this.rows; + } + + // Configure a null layout + setNullLayout(options) { + + // Configure instance fields + this.layout = null; + + // Configure element + this.setDisplay(null); + this.element.style.removeProperty("align-items" ); + this.element.style.removeProperty("flex-wrap" ); + this.element.style.removeProperty("grid-template-columns"); + this.element.style.removeProperty("grid-template-rows" ); + this.element.style.removeProperty("justify-content" ); + this.element.style.removeProperty("flex-direction" ); + this.element.style.removeProperty("overflow-x" ); + this.element.style.removeProperty("overflow-y" ); } }; diff --git a/app/toolkit/Window.js b/app/toolkit/Window.js new file mode 100644 index 0000000..d85ced1 --- /dev/null +++ b/app/toolkit/Window.js @@ -0,0 +1,382 @@ +"use strict"; + +// Movable, sizeable child window +Toolkit.Window = class Window extends Toolkit.Panel { + + // Object constructor + constructor(application, options) { + super(application, options); + options = options || {}; + + // Configure instance fields + this.dragBounds = null; + this.dragCursor = { x: 0, y: 0 }; + this.dragEdge = null; + this.lastFocus = this.element; + this.title = options.title || ""; + + // Configure element + this.setLayout("flex", { + alignCross: "stretch", + direction : "column", + overflowX : "visible", + overflowY : "visible" + }); + this.setRole("dialog"); + this.element.style.position = "absolute"; + this.element.setAttribute("aria-modal", "false"); + this.element.setAttribute("focus" , "false"); + this.element.setAttribute("tabindex" , "-1" ); + this.element.addEventListener( + "blur" , e=>this.onblur (e), { capture: true }); + this.element.addEventListener( + "focus", e=>this.onfocus(e), { capture: true }); + 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 body container + this.body = this.add(this.newPanel({ + layout : "flex", + alignCross: "stretch", + direction : "column", + overflowX : "visible", + overflowY : "visible" + })); + this.body.element.style.flexGrow = "1"; + this.body.element.style.margin = "3px"; + this.body.element.setAttribute("name", "body"); + + // Configure title bar + this.titleBar = this.body.add(this.newPanel({ + layout : "flex", + alignCross: "center", + direction : "row", + overflowX : "visible", + overflowY : "visible" + })); + this.titleBar.element.setAttribute("name", "title-bar"); + + // Configure title icon element + this.titleIcon = this.titleBar.add(this.newPanel({})); + this.titleIcon.element.setAttribute("name", "title-icon"); + this.titleIcon.element.style.removeProperty("min-width"); + + // Configure title text element + this.titleElement = this.titleBar.add(this.newPanel({})); + this.titleElement.element.setAttribute("name", "title"); + this.titleElement.element.style.cursor = "default"; + this.titleElement.element.style.flexGrow = "1"; + this.titleElement.element.style.userSelect = "none"; + this.element.setAttribute("aria-labelledby", this.titleElement.id); + + // Configure title close element + this.titleCloseBox = this.titleBar.add(this.newPanel({})); + this.titleCloseBox.element.setAttribute("name", "title-close-box"); + this.titleCloseBox.element.style.removeProperty("min-width"); + this.titleClose = this.titleCloseBox.add(this.newButton({ + focusable: false, + name : "{app.close}", + toolTip : "{app.close}" + })); + this.titleClose.element.setAttribute("name", "title-close"); + + // Configure client area + this.client = this.body.add(this.newPanel({})); + this.client.element.style.flexGrow = "1"; + this.client.element.setAttribute("name", "client"); + this.client.element.addEventListener( + "pointerdown", e=>this.onclientdown(e)); + + // Configure properties + this.setTitle(this.title); + application.addComponent(this); + } + + + + ///////////////////////////// Public Methods ////////////////////////////// + + // Specify the size of the client rectangle in pixels + setClientSize(width, height) { + let bounds = this.getBounds(); + let client = this.client.getBounds(); + this.setSize( + width + bounds.width - client.width, + height + bounds.height - client.height + ); + } + + // Specify the window's title text + setTitle(title) { + this.title = title || ""; + this.localize(); + } + + + + ///////////////////////////// Package Methods ///////////////////////////// + + // Request focus on the appropriate element + focus() { + if (this.lastFocus != this) + this.lastFocus.focus(); + else this.element.focus(); + } + + + + ///////////////////////////// Private Methods ///////////////////////////// + + // Position the window using a tentative location in the desktop + contain(desktop, bounds, x, y, client) { + bounds = bounds || this.getBounds(); + client = client || this.client.getBounds(); + + // Restrict window position + x = Math.min(x, desktop.width - 16); + x = Math.max(x, -bounds.width + 16); + y = Math.min(y, desktop.height - (client.y - bounds.y)); + y = Math.max(y, 0); + + // Configure element + this.setLocation(x, y); + } + + // Detect where in the window the pointer is + edge(e, bounds) { + bounds = bounds || this.getBounds(); + let x = e.x - bounds.x; + let y = e.y - bounds.y; + if (y < 3) { + if (x < 8) return "nw"; + if (x < bounds.width - 8) return "n" ; + return "ne"; + } + if (y >= bounds.height - 3) { + if (x < 8) return "sw"; + if (x < bounds.width - 8) return "s" ; + return "se"; + } + if (x < 3) { + if (y < 8) return "nw"; + if (y < bounds.height - 8) return "w" ; + return "sw"; + } + if (x >= bounds.width - 3) { + if (y < 8) return "ne"; + if (y < bounds.height - 8) return "e" ; + return "se"; + } + return null; + } + + // Update display text with localized strings + localize() { + let title = this.title; + if (this.application) + title = this.application.translate(title, this); + this.titleElement.element.innerText = title; + } + + // Focus lost event capture + onblur(e) { + if (!this.contains(e.relatedTarget)) + this.element.setAttribute("focus", "false"); + } + + // Client pointer down event handler + onclientdown(e) { + e.preventDefault(); + e.stopPropagation(); + this.focus(); + } + + // Focus gained event capture + onfocus(e) { + + // Configure element + this.element.setAttribute("focus", "true"); + + // Working variables + let target = e.target; + + // Delegate focus to the most recently focused component + if (target == this.element) + target = this.lastFocus; + + // The component is not visible: focus on self instead + if ("component" in target && !target.component.isVisible()) + target = this.element; + + // Configure instance fields + this.lastFocus = target; + + // Transfer focus to the correct component + if (target != e.target) + target.focus(); + } + + // Key down event handler + onkeydown(e) { + + // Processing by key + switch (e.key) { + default: return; + } + + // Configure event + e.preventDefault(); + e.stopPropagation(); + } + + // Pointer down event handler + onpointerdown(e) { + + // Configure event + e.preventDefault(); + e.stopPropagation(); + + // Configure element + this.focus(); + + // Error checking + if ( + e.button != 0 || + this.element.hasPointerCapture(e.pointerId) + ) return; + + // Configure instance fields + this.dragBounds = this.getBounds(); + this.dragEdge = this.edge(e, this.dragBounds); + this.dragCursor.x = e.x; + this.dragCursor.y = e.y; + + // Configure element + this.element.setPointerCapture(e.pointerId); + } + + // Pointer move event handler + onpointermove(e) { + + // Configure event + e.preventDefault(); + e.stopPropagation(); + + // Not dragging: set the cursor based on pointer location + if (!this.element.hasPointerCapture(e.pointerId)) { + let region = this.edge(e); + if (region == null) + this.element.style.removeProperty("cursor"); + else this.element.style.cursor = region + "-resize"; + return; + } + + // Working variables + let rX = e.x - this.dragCursor.x; + let rY = e.y - this.dragCursor.y; + let bounds = this.getBounds(); + let desktop = this.parent.getBounds(); + let client = this.client.getBounds(); + let minHeight = bounds.height - client.height; + + // Move the window + if (this.dragEdge == null) { + this.contain( + desktop, + bounds, + this.dragBounds.x - desktop.x + rX, + this.dragBounds.y - desktop.y + rY, + client + ); + return; + } + + // Resizing on the north edge + if (this.dragEdge.startsWith("n")) { + let maxTop = desktop.height - client.y + bounds.y; + let top = this.dragBounds.y - desktop.y + rY; + let height = this.dragBounds.height - rY; + + // Restrict window bounds + if (top > maxTop) { + height -= maxTop - top; + top = maxTop; + } + if (top < 0) { + height += top; + top = 0; + } + if (height < minHeight) { + top -= minHeight - height; + height = minHeight; + } + + // Configure element + this.setTop (top ); + this.setHeight(height); + } + + // Resizing on the west edge + if (this.dragEdge.endsWith("w")) { + let maxLeft = desktop.width - 16; + let left = this.dragBounds.x - desktop.x + rX; + let width = this.dragBounds.width - rX; + + // Restrict window bounds + if (left > maxLeft) { + width -= maxLeft - left; + left = maxLeft; + } + if (width < 64) { + left -= 64 - width; + width = 64; + } + + // Configure element + this.setLeft (left ); + this.setWidth(width); + } + + // Resizing on the east edge + if (this.dragEdge.endsWith("e")) { + let width = this.dragBounds.width + rX; + + // Restrict window bounds + width = Math.max(64, width); + width = Math.max(width, -this.dragBounds.x + 16); + + // Configure element + this.setWidth(width); + } + + // Resizing on the south edge + if (this.dragEdge.startsWith("s")) { + let height = this.dragBounds.height + rY; + + // Restrict window bounds + height = Math.max(minHeight, height); + + // Configure element + this.setHeight(height); + } + + } + + // Pointer up event handler + onpointerup(e) { + + // Configure event + e.preventDefault(); + e.stopPropagation(); + + // Error checking + if (!this.element.hasPointerCapture(e.pointerId)) + return; + + // Configure element + this.element.releasePointerCapture(e.pointerId); + } + +}; diff --git a/makefile b/makefile index c685c9c..51f1122 100644 --- a/makefile +++ b/makefile @@ -1,7 +1,7 @@ .PHONY: help help: @echo - @echo "Virtual Boy Emulator - August 22, 2021" + @echo "Virtual Boy Emulator - August 26, 2021" @echo @echo "Target build environment is any Debian with the following packages:" @echo " emscripten"