553 lines
16 KiB
JavaScript
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));
|
|
}
|
|
|
|
};
|