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