pvbemu/app/toolkit/MenuBar.js

749 lines
22 KiB
JavaScript

import { Component } from /**/"./Component.js";
let Toolkit;
///////////////////////////////////////////////////////////////////////////////
// Menu //
///////////////////////////////////////////////////////////////////////////////
// Pop-up menu container, child of MenuItem
class Menu extends Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(gui, options) {
super(gui, options, {
className : "tk tk-menu",
role : "menu",
tagName : "div",
visibility: true,
visible : false,
style : {
position: "absolute",
}
});
// Trap pointer events
this.addEventListener("pointerdown", e=>{
e.stopPropagation();
e.preventDefault();
});
}
///////////////////////////// Package Methods /////////////////////////////
// Replacement behavior for parent.add()
addedHook(parent) {
this.setAttribute("aria-labelledby", parent.id);
}
};
///////////////////////////////////////////////////////////////////////////////
// MenuSeparator //
///////////////////////////////////////////////////////////////////////////////
// Separator between groups of menu items
class MenuSeparator extends Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(gui, options) {
super(gui, options, {
className: "tk tk-menu-separator",
role : "separator",
tagName : "div"
});
}
};
///////////////////////////////////////////////////////////////////////////////
// MenuItem //
///////////////////////////////////////////////////////////////////////////////
// Individual menu selection
class MenuItem extends Component {
///////////////////////// Initialization Methods //////////////////////////
constructor(gui, options) {
super(gui, options, {
className: "tk tk-menu-item",
focusable: true,
tabStop : false,
tagName : "div"
});
options = options || {};
// Configure instance fields
this.isEnabled = null;
this.isExpanded = false;
this.menu = null;
this.menuBar = null;
this.text = null;
this.type = null;
// Configure element
this.contents = document.createElement("div");
this.append(this.contents);
this.eicon = document.createElement("div");
this.eicon.className = "tk tk-icon";
this.contents.append(this.eicon);
this.etext = document.createElement("div");
this.etext.className = "tk tk-text";
this.contents.append(this.etext);
// Configure event handlers
this.addEventListener("blur" , e=>this.onBlur (e));
this.addEventListener("keydown" , e=>this.onKeyDown (e));
this.addEventListener("pointerdown", e=>this.onPointerDown(e));
this.addEventListener("pointermove", e=>this.onPointerMove(e));
this.addEventListener("pointerup" , e=>this.onPointerUp (e));
// Configure widget
this.gui.localize(this);
this.setEnabled("enabled" in options ? !!options.enabled : true);
this.setId (Toolkit.id());
this.setText (options.text);
this.setType (options.type, options.checked);
}
///////////////////////////// Event Handlers //////////////////////////////
// Focus lost
onBlur(e) {
// An item in a different menu is receiving focus
if (this.menu != null) {
if (
!this .contains(e.relatedTarget) &&
!this.menu.contains(e.relatedTarget)
) this.setExpanded(false);
}
// Item is an action
else if (e.component == this)
this.element.classList.remove("active");
// Simulate a bubbling event sequence
if (this.parent)
this.parent.onBlur(e);
}
// Key press
onKeyDown(e) {
// Processing by key
switch (e.key) {
case "ArrowDown":
// Error checking
if (!this.parent)
break;
// Top-level: open the menu and focus its first item
if (this.parent == this.menuBar) {
if (this.menu == null)
return;
this.setExpanded(true);
this.listItems()[0].focus();
}
// Sub-menu: cycle to the next sibling
else {
let items = this.parent.listItems();
items[(items.indexOf(this) + 1) % items.length].focus();
}
break;
case "ArrowLeft":
// Error checking
if (!this.parent)
break;
// Sub-menu: close and focus parent
if (
this.parent != this.menuBar &&
this.parent.parent != this.menuBar
) {
this.parent.setExpanded(false);
this.parent.focus();
}
// Top-level: cycle to previous sibling
else {
let menu = this.parent == this.menuBar ?
this : this.parent;
let items = this.menuBar.listItems();
let prev = items[(items.indexOf(menu) +
items.length - 1) % items.length];
if (menu.isExpanded)
prev.setExpanded(true);
prev.focus();
}
break;
case "ArrowRight":
// Error checking
if (!this.parent)
break;
// Sub-menu: open the menu and focus its first item
if (this.menu != null && this.parent != this.menuBar) {
this.setExpanded(true);
(this.listItems()[0] || this).focus();
}
// Top level: cycle to next sibling
else {
let menu = this;
while (menu.parent != this.menuBar)
menu = menu.parent;
let expanded = this.menuBar.expandedMenu() != null;
let items = this.menuBar.listItems();
let next = items[(items.indexOf(menu) + 1) % items.length];
next.focus();
if (expanded)
next.setExpanded(true);
}
break;
case "ArrowUp":
// Error checking
if (!this.parent)
break;
// Top-level: open the menu and focus its last item
if (this.parent == this.menuBar) {
if (this.menu == null)
return;
this.setExpanded(true);
let items = this.listItems();
(items[items.length - 1] || this).focus();
}
// Sub-menu: cycle to previous sibling
else {
let items = this.parent.listItems();
items[(items.indexOf(this) +
items.length - 1) % items.length].focus();
}
break;
case "End":
{
// Error checking
if (!this.parent)
break;
// Focus last sibling
let expanded = this.isExpanded &&
this.parent == this.menuBar;
let items = this.parent.listItems();
let last = items[items.length - 1] || this;
last.focus();
if (expanded)
last.setExpanded(true);
}
break;
case "Enter":
case " ":
// Do nothing
if (!this.isEnabled)
break;
// Action item: activate the menu item
if (this.menu == null)
this.activate(this.type == "check" && e.key == " ");
// Sub-menu: open the menu and focus its first item
else {
this.setExpanded(true);
let items = this.listItems();
if (items[0])
items[0].focus();
}
break;
case "Escape":
// Error checking
if (!this.parent)
break;
// Top-level (not specified by WAI-ARIA)
if (this.parent == this.menuBar) {
if (this.isExpanded)
this.setExpanded(false);
else this.menuBar.exit();
}
// Sub-menu: close and focus parent
else {
this.parent.setExpanded(false);
this.parent.focus();
}
break;
case "Home":
{
// Error checking
if (!this.parent)
break;
// Focus first sibling
let expanded = this.isExpanded &&
this.parent == this.menuBar;
let first = this.parent.listItems()[0] || this;
first.focus();
if (expanded)
first.setExpanded(true);
}
break;
// Do not handle the event
default: return;
}
// The event was handled
e.stopPropagation();
e.preventDefault();
}
// Pointer press
onPointerDown(e) {
this.focus();
// Error checking
if (
!this.isEnabled ||
this.element.hasPointerCapture(e.pointerId) ||
e.button != 0
) return;
// Configure event
if (this.menu == null)
this.element.setPointerCapture(e.pointerId);
e.stopPropagation();
e.preventDefault();
// Configure component
if (this.menu != null)
this.setExpanded(!this.isExpanded);
else this.element.classList.add("active");
}
// Pointer move
onPointerMove(e) {
// Hovering over a menu when a sibling menu is already open
let expanded = this.parent && this.parent.expandedMenu();
if (this.menu != null && expanded != null && expanded != this) {
// Configure component
this.setExpanded(true);
this.focus();
// Configure event
e.stopPropagation();
e.preventDefault();
return;
}
// Not dragging
if (!this.element.hasPointerCapture(e.pointerId))
return;
// Configure event
e.stopPropagation();
e.preventDefault();
// Not an action item
if (this.menu != null)
return;
// Check if the cursor is within the bounds of the component
this.element.classList[
Toolkit.isInside(this.element, e) ? "add" : "remove"]("active");
}
// Pointer release
onPointerUp(e) {
// Error checking
if (
!this.isEnabled ||
e.button != 0 ||
(this.parent && this.parent.hasFocus() ?
this.menu != null :
!this.element.hasPointerCapture(e.pointerId)
)
) return;
// Configure event
this.element.releasePointerCapture(e.pointerId);
e.stopPropagation();
e.preventDefault();
// Item is an action
let bounds = this.getBounds();
if (this.menu == null && Toolkit.isInside(this.element, e))
this.activate();
}
///////////////////////////// Public Methods //////////////////////////////
// Invoke an action command
activate(noExit) {
if (this.menu != null)
return;
if (this.type == "check")
this.setChecked(!this.isChecked);
if (!noExit)
this.menuBar.exit();
this.element.dispatchEvent(Toolkit.event("action", this));
}
// Add a separator between groups of menu items
addSeparator(options) {
let sep = new Toolkit.MenuSeparator(this, options);
this.add(sep);
return sep;
}
// Produce a list of child items
listItems(invisible) {
return this.children.filter(c=>
c instanceof Toolkit.MenuItem &&
(invisible || c.isVisible())
);
}
// Specify whether the menu item is checked
setChecked(checked) {
if (this.type != "check")
return;
this.isChecked = !!checked;
this.setAttribute("aria-checked", this.isChecked);
}
// Specify whether the menu item can be activated
setEnabled(enabled) {
this.isEnabled = enabled = !!enabled;
this.setAttribute("aria-disabled", enabled ? null : "true");
if (!enabled)
this.setExpanded(false);
}
// Specify whether the sub-menu is open
setExpanded(expanded) {
// State is not changing
expanded = !!expanded;
if (this.menu == null || expanded === this.isExpanded)
return;
// Position the sub-menu
if (expanded) {
let bndGUI = this.gui .getBounds();
let bndMenu = this.menu.getBounds();
let bndThis = this .getBounds();
let bndParent = !this.parent ? bndThis : (
this.parent == this.menuBar ? this.parent : this.parent.menu
).getBounds();
this.menu.element.style.left = Math.max(0,
Math.min(
(this.parent && this.parent == this.menuBar ?
bndThis.left : bndThis.right) - bndParent.left,
bndGUI.right - bndMenu.width
)
) + "px";
this.menu.element.style.top = Math.max(0,
Math.min(
(this.parent && this.parent == this.menuBar ?
bndThis.bottom : bndThis.top) - bndParent.top,
bndGUI.bottom - bndMenu.height
)
) + "px";
}
// Close all open sub-menus
else for (let child of this.listItems())
child.setExpanded(false);
// Configure component
this.isExpanded = expanded;
this.setAttribute("aria-expanded", expanded);
this.menu.setVisible(expanded);
if (expanded)
this.element.classList.add("active");
else this.element.classList.remove("active");
}
// Specify the widget's display text
setText(text) {
this.text = (text || "").toString().trim();
this.translate();
}
// Specify what kind of menu item this is
setType(type, arg) {
this.type = type = (type || "").toString().trim() || "normal";
switch (type) {
case "check":
this.setAttribute("role", "menuitemcheckbox");
this.setChecked(arg);
break;
default: // normal
this.setAttribute("role", "menuitem");
this.setAttribute("aria-checked", null);
break;
}
if (this.parent && "checkIcons" in this.parent)
this.parent.checkIcons();
}
///////////////////////////// Package Methods /////////////////////////////
// Replacement behavior for add()
addHook(component) {
// Convert to sub-menu
if (this.menu == null) {
this.menu = new Toolkit.Menu(this);
this.after(this.menu);
this.setAttribute("aria-haspopup", "menu");
this.setAttribute("aria-expanded", "false");
if (this.parent && "checkIcons" in this.parent)
this.parent.checkIcons();
}
// Add the child component
component.menuBar = this.menuBar;
this.menu.append(component);
if (component instanceof Toolkit.MenuItem && component.menu != null)
this.menu.append(component.menu);
// Configure icon mode
this.checkIcons();
}
// Check whether any child menu items contain icons
checkIcons() {
if (this.menu == null)
return;
if (this.children.filter(c=>
c instanceof Toolkit.MenuItem &&
c.menu == null &&
c.type != "normal"
).length != 0)
this.menu.element.classList.add("icons");
else this.menu.element.classList.remove("icons");
}
// Replacement behavior for remove()
removeHook(component) {
// Remove the child component
component.element.remove();
if (component instanceof Toolkit.MenuItem && component.menu != null)
component.menu.element.remove();
// Convert to action item
if (this.children.length == 0) {
this.menu.element.remove();
this.menu = null;
this.setAttribute("aria-haspopup", null);
this.setAttribute("aria-expanded", "false");
if (this.parent && "checkIcons" in this.parent)
this.parent.checkIcons();
}
}
// Regenerate localized display text
translate() {
super.translate();
if (!("contents" in this))
return;
this.etext.innerText = this.gui.translate(this.text, this);
}
///////////////////////////// Private Methods /////////////////////////////
// Retrieve the currently expanded sub-menu, if any
expandedMenu() {
return this.children.filter(c=>c.isExpanded)[0] || null;
}
};
///////////////////////////////////////////////////////////////////////////////
// MenuBar //
///////////////////////////////////////////////////////////////////////////////
// Application menu bar
class MenuBar extends Component {
static Component = Component;
///////////////////////// Initialization Methods //////////////////////////
constructor(gui, options) {
super(gui, options, {
className: "tk tk-menu-bar",
focusable: false,
tagName : "div",
tabStop : true,
role : "menubar",
style : {
position: "relative",
zIndex : "1"
}
});
// Configure instance fields
this.focusTarget = null;
this.menuBar = this;
// Configure event handlers
this.addEventListener("blur" , e=>this.onBlur (e), true);
this.addEventListener("focus" , e=>this.onFocus (e), true);
this.addEventListener("keydown", e=>this.onKeyDown(e), true);
// Configure widget
this.gui.localize(this);
}
///////////////////////////// Event Handlers //////////////////////////////
// Focus lost
onBlur(e) {
if (this.contains(e.relatedTarget))
return;
let items = this.listItems();
if (items[0])
items[0].setFocusable(true, true);
let menu = this.expandedMenu();
if (menu != null)
menu.setExpanded(false);
}
// Focus gained
onFocus(e) {
if (this.contains(e.relatedTarget))
return;
let items = this.listItems();
if (items[0])
items[0].setFocusable(true, false);
this.focusTarget = e.relatedTarget;
}
// Key pressed
onKeyDown(e) {
if (e.key != "Tab")
return;
e.stopPropagation();
e.preventDefault();
this.exit();
}
///////////////////////////// Public Methods //////////////////////////////
// Produce a list of child items
listItems(invisible) {
return this.children.filter(c=>
c instanceof Toolkit.MenuItem &&
(invisible || c.isVisible())
);
}
///////////////////////////// Package Methods /////////////////////////////
// Replacement behavior for add()
addHook(component) {
component.menuBar = this.menuBar;
this.append(component);
if (component instanceof Toolkit.MenuItem && component.menu != null)
this.append(component.menu);
let items = this.listItems();
if (items[0])
items[0].setFocusable(true, true);
}
// Return control to the application
exit() {
this.onBlur({ relatedTarget: null });
if (this.focusTarget)
this.focusTarget.focus();
else document.activeElement.blur();
}
// Replacement behavior for remove()
removeHook(component) {
component.element.remove();
if (component instanceof Toolkit.MenuItem && component.menu != null)
component.menu.element.remove();
let items = this.listItems();
if (items[0])
items[0].setFocusable(true, true);
}
// Update the global Toolkit object
static setToolkit(toolkit) {
Toolkit = toolkit;
}
///////////////////////////// Private Methods /////////////////////////////
// Retrieve the currently expanded menu, if any
expandedMenu() {
return this.children.filter(c=>c.isExpanded)[0] || null;
}
};
export { Menu, MenuBar, MenuItem, MenuSeparator };