// Sub-menu container let Menu = (Toolkit,_package)=>class Menu extends Toolkit.Component { ///////////////////////// Initialization Methods ////////////////////////// constructor(app, overrides) { super(app, _package.override(overrides, { className : "menu", role : "menu", visibility: true, visible : false, style : { display : "flex", flexDirection: "column", position : "absolute" } })); // Configure event handlers this.addEventListener("focusout" , e=>this.#_onFocusOut(e)); this.addEventListener("pointerdown", e=>Toolkit.consume (e)); } ///////////////////////////// Event Handlers ////////////////////////////// // Focus lost #_onFocusOut(e) { this.parent?.element?.dispatchEvent( new FocusEvent("focusout", { relatedTarget: e.relatedTarget })); } }; // Menu separator let Separator = (Toolkit,_package)=>class Separator extends Toolkit.Component { ///////////////////////// Initialization Methods ////////////////////////// constructor(app, overrides) { super(app, _package.override(overrides, { ariaOrientation: "horizontal", className : "menu-separator", role : "separator" })); } /////////////////////////////// Properties //////////////////////////////// get type() { return "separator"; } }; // Menu item export default (Toolkit,_package)=>class MenuItem extends Toolkit.Component { // Inner classes static #_Menu = Menu (Toolkit, _package); static #_Separator = Separator(Toolkit, _package); // Instance fields #_client; // Interior content element #_columns; // Content elements #_drag; // Click and drag context #_group; // Containing Toolkit.Group #_icon; // Icon image URL #_menu; // Pop-up menu element #_resizer; // Column sizing listener #_start; // Character that the display text starts with #_text; // Display text #_value; // Radio button value ///////////////////////// Initialization Methods ////////////////////////// static { _package.MenuItem = { activate : (c,f)=>c.#_activate(f), menu : c=>c.#_menu, onLocalize: c=>c.#_onLocalize(), setChecked: (c,v)=>c.#_setChecked(v), setGroup : (c,g)=>c.#_group=g, startsWith: (c,k)=>c.#_startsWith(k) }; } constructor(app, overrides) { overrides = _package.override(overrides, { className: "menu-item" }); let underrides = _package.underride(overrides, { group: null, text : null, type : "button" }); super(app, overrides); // Configure instance fields this.disabled = overrides.disabled; this.#_drag = null; this.#_icon = null; this.#_menu = null; this.#_start = null; this.#_text = null; // Configure event handlers this.addEventListener("focusout" , e=>this.#_onFocusOut (e)); this.addEventListener("pointerdown", e=>this.#_onPointerDown(e)); this.addEventListener("pointermove", e=>this.#_onPointerMove(e)); this.addEventListener("pointerup" , e=>this.#_onPointerUp (e)); // Configure contents this.#_client = document.createElement("div"); Object.assign(this.#_client.style, { display: "grid" }); this.#_columns = [ document.createElement("div"), // Icon document.createElement("div"), // Text document.createElement("div") // Shortcut ]; this.element.append(this.#_client); for (let column of this.#_columns) this.#_client.append(column); // Configure properties this.group = underrides.group; this.text = underrides.text; this.type = underrides.type; } /////////////////////////////// Properties //////////////////////////////// // Check box or radio button checked state get checked() { return this.element.ariaChecked == "true"; } set checked(value) { if (this.#_group == null) this.#_setChecked(!!value); } // Element is inoperable get disabled() { return this.element.ariaDisabled == "true"; } set disabled(value) { value = Boolean(value); if (value == this.disabled) return; if (value) this.element.ariaDisabled = "true"; else this.element.removeAttribute("aria-disabled"); } // Sub-menu is visible get expanded() { return this.element.ariaExpanded == "true"; } set expanded(value) { // Cannot be expanded if (this.children.length == 0) return; // Input validation value = Boolean(value); if (value == this.expanded) return; // Expand or collapse self this.element.ariaExpanded = String(value); this.element.classList[value ? "add" : "remove"]("active"); this.#_menu.visible = value; // Position the sub-menu element if (value) { let bounds = this.element.getBoundingClientRect(); Object.assign(this.#_menu.element.style, this.parent instanceof Toolkit.MenuBar ? { left: bounds.left + "px", top : bounds.bottom + "px" } : { left: bounds.right + "px", top : bounds.top + "px" } ); } // Collapse any expanded sub-menu else { let item = this.children.find(c=>c.expanded); if (item != null) item.expanded = false; } } // Containing Toolkit.Group get group() { return this.#_group; } set group(value) {} // Icon image URL get icon() { return this.#_icon; } set icon(value) { this.#_icon = value ? String(value) : null; this.#_refresh(); } // Display text get text() { return this.#_text; } set text(value) { if (value != null && !(value instanceof RegExp)) value = String(value); this.#_text = value; this.#_onLocalize(); } // Menu item type get type() { switch (this.element.role) { case "menuitem" : return "button"; case "menuitemcheckbox": return "checkbox"; case "menuitemradio" : return "radio"; } return null; } set type(value) { // Cannot change type if there is a sub-menu if (this.children.length != 0) throw new Error("Cannot change type while a sub-menu exists."); // Error checking value = value == null ? null : String(value); let type = this.type; if (type != null && value == type) return; // Input validation switch (String(value)) { case "button" : value = "menuitem" ; break; case "checkbox": value = "menuitemcheckbox"; break; case "radio" : value = "menuitemradio" ; break; default: if (type != null) return; value = "menuitem"; } // Update the component this.element.role = value; this.#_refresh(); } // Radio button value get value() { return this.#_value; } set value(value) { this.#_value = value; } // HTML element visibility get visible() { return super.visible; } set visible(value) { value = !!value; if (value == super.visible) return; super.visible = value; // TODO: Refresh siblings and parent } ///////////////////////////// Event Handlers ////////////////////////////// // Component added to parent #_onAdd() { if (this.#_menu != null) this.element.parent.append(this.#_menu.element); } // Focus lost #_onFocusOut(e) { if ( this.expanded && this.element != e.relatedTarget && !this.#_menu.element.contains(e.relatedTarget) ) this.expanded = false; } // Configure display text #_onLocalize() { let text = this.app.translate(this.#_text, this) ?? ""; this.#_columns[1].innerText = text; this.#_start = text.length == 0 ? null : text[0].toLowerCase(); } // Pointer pressed #_onPointerDown(e) { Toolkit.consume(e); // Acquire focus this.element.focus(); // Error checking if (this.disabled || e.button != 0 || this.#_drag != null) return; // Activate a sub-menu if (this.children.length != 0) { if (!this.expanded) this.#_activate(); else this.expanded = false; return; } // Initiate a button response on a sub-menu item this.element.setPointerCapture(e.pointerId); this.element.classList.add("active"); this.#_drag = e.pointerId; } // Pointer moved #_onPointerMove(e) { Toolkit.consume(e); // Style the menu item like a button on drag if (this.#_drag != null) { this.element.classList [this.#_contains(e) ? "add" : "remove"]("active"); } // Expand the sub-menu if another top-level sub-menu is expanded if ( this.parent instanceof Toolkit.MenuBar && this.children.length != 0 ) { let item = this.parent.children.find(c=>c.expanded); if (item != null && !this.expanded) { this.expanded = true; this.element.focus(); } } } // Pointer released #_onPointerUp(e) { Toolkit.consume(e); // Error checking if (e.button != 0 || this.#_drag != e.pointerId) return; // Terminate the button response this.element.releasePointerCapture(e.pointerId); this.element.classList.remove("active"); this.#_drag = null; // Activate the menu item if (this.#_contains(e)) this.#_activate(); } // Column resized #_onResizeColumn() { let widths = this.#_columns.map(c=>0); for (let item of _package.MenuBar.children(this)) for (let x = 0; x < widths.length; x++) { let column = item.#_columns[x]; column.style.removeProperty("min-width"); widths[x] = Math.max(widths[x], column.getBoundingClientRect().width); } for (let item of _package.MenuBar.children(this)) for (let x = 0; x < widths.length; x++) { if (x == 1) continue; // Text item.#_columns[x].style.minWidth = widths[x] + "px"; } } ///////////////////////////// Public Methods ////////////////////////////// // Add a menu item add(comp) { // Error checking if (!(comp instanceof Toolkit.MenuItem)) throw new TypeError("Component must be a Toolkit.MenuItem."); // Associate the menu item with self super.add(comp, false); // The menu sub-component does not exist if (this.#_menu == null) { this.id = this.id ?? Toolkit.id(); this.#_menu = new this.constructor.#_Menu(this.app, { ariaLabelledBy: this.id }); _package.Component.setParent(this.#_menu, this); if (this.parent != null) this.element.after(this.#_menu.element); Object.assign(this.element, { ariaExpanded: "false", ariaHasPopup: "menu" }); this.#_resizer ??= new ResizeObserver(()=>this.#_onResizeColumn()); } // Add the component to the menu sub-component comp.element.tabIndex = -1; this.#_menu.element.append(comp.element); this.#_resizer.observe(comp.element); _package.Component.onAdd(comp); // Refresh all sub-menu items let children = _package.MenuBar.children(this); let icon = this.#_needsIcon (children); let shortcut = this.#_needsShortcut(children); for (let item of children) item.#_refresh(icon, shortcut); } // Add a separator between menu items addSeparator(overrides = {}) { let item = new this.constructor.#_Separator(this.app, overrides); this.#_menu.add(item); } ///////////////////////////// Package Methods ///////////////////////////// // Reconfigure contents #_refresh(needsIcon = null, needsShortcut = null) { let client = this.#_client.style; let icon = this.#_columns[0].style; let shortcut = this.#_columns[2].style; let hasIcon = true; // Input validation if (needsIcon == null || needsShortcut == null) { let children = _package.MenuBar.children(this.parent); needsIcon ??= this.#_needsIcon (children); needsShortcut ??= this.#_needsShortcut(children); } // Regular menu item if (this.type == "button") { if (this.#_icon != null) { icon.backgroundImage = "url(" + this.#_icon + ")"; } else { icon.removeProperty("background-image"); hasIcon = false; } } // Check box or radio button menu item else icon.removeProperty("background-image"); // Configure layout let template = ["auto"]; if (needsIcon || hasIcon) { template.unshift("max-content"); icon.removeProperty("display"); } else icon.display = "none"; if (needsShortcut && false) { // TODO: Implement shortcut column template.push("max-content"); shortcut.removeProperty("display"); } else shortcut.display = "none"; client.gridTemplateColumns = template.join(" "); } // Modify the checked state #_setChecked(value) { if (this.type != "button") this.element.ariaChecked = value ? "true" : "false"; } // Determine whether the translated display text starts with a given string #_startsWith(pattern) { return this.#_start == pattern; } ///////////////////////////// Private Methods ///////////////////////////// // Simulate activation #_activate(fromMenuBar = false) { // Reroute activation handling to the containing Toolkit.MenuBar if (!fromMenuBar) { let bar = this.#_menuBar(); if (bar != null) { _package.MenuBar.activate(bar, this, false, true); return; } } // Handling by menu item type if (this.#_group != null) _package.Group.onAction(this.#_group, this); else if (this.type == "checkbox") this.checked = !this.checked; else if (this.type == "radio") this.checked = true; // Emit an action event this.element.dispatchEvent(new Event("action")); } // Determine whether the pointer is within the element's boundary #_contains(e) { let bounds = this.element.getBoundingClientRect(); return ( e.clientX >= bounds.left && e.clientX < bounds.right && e.clientY >= bounds.top && e.clientY < bounds.bottom ); } // Resolve the containing Toolkit.MenuBar #_menuBar() { let item = this.parent; while (item != null && !(item instanceof Toolkit.MenuBar)) item = item.parent; return item; } // Determine whether sub-menu items need to show the icon column #_needsIcon(children = null) { return ((children ?? _package.MenuBar.children(this)) .some(c=>c.type != "button" || c.icon != null)); } // Determine whether sub-menu items need to show the shortcut column #_needsShortcut(children = null) { return false; //return ((children ?? _package.MenuBar.children(this)) // .any(c=>c.children.length != 0)); } };