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