shrooms-vb-web/toolkit/MenuItem.js

553 lines
16 KiB
JavaScript

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