UNPKG

@qooxdoo/framework

Version:

The JS Framework for Coders

789 lines (673 loc) 20.6 kB
/* ************************************************************************ qooxdoo - the new era of web development http://qooxdoo.org Copyright: 2004-2008 1&1 Internet AG, Germany, http://www.1und1.de License: MIT: https://opensource.org/licenses/MIT See the LICENSE file in the project's top-level directory for details. Authors: * Sebastian Werner (wpbasti) * Fabian Jakobs (fjakobs) ************************************************************************ */ /** * The menu is a popup like control which supports buttons. It comes * with full keyboard navigation and an improved timeout based pointer * control behavior. * * This class is the container for all derived instances of * {@link qx.ui.menu.AbstractButton}. * * @childControl slidebar {qx.ui.menu.MenuSlideBar} shows a slidebar to easily navigate inside the menu (if too little space is left) */ qx.Class.define("qx.ui.menu.Menu", { extend: qx.ui.core.Widget, include: [qx.ui.core.MPlacement, qx.ui.core.MRemoteChildrenHandling], construct() { super(); // Use hard coded layout this._setLayout(new qx.ui.menu.Layout()); // Automatically add to application's root var root = this.getApplicationRoot(); root.add(this); // ARIA attrs const contentEl = this.getContentElement(); contentEl.setAttribute("role", "menu"); contentEl.setAttribute("id", "menu-" + this.toHashCode()); // Register pointer listeners this.addListener("pointerover", this._onPointerOver); this.addListener("pointerout", this._onPointerOut); // add resize listener this.addListener("resize", this._onResize, this); root.addListener("resize", this._onResize, this); this._blocker = new qx.ui.core.Blocker(root); // Initialize properties this.initVisibility(); this.initKeepFocus(); this.initKeepActive(); }, properties: { /* --------------------------------------------------------------------------- WIDGET PROPERTIES --------------------------------------------------------------------------- */ // overridden appearance: { refine: true, init: "menu" }, // overridden allowGrowX: { refine: true, init: false }, // overridden allowGrowY: { refine: true, init: false }, // overridden visibility: { refine: true, init: "excluded" }, // overridden keepFocus: { refine: true, init: true }, // overridden keepActive: { refine: true, init: true }, /* --------------------------------------------------------------------------- STYLE OPTIONS --------------------------------------------------------------------------- */ /** The spacing between each cell of the menu buttons */ spacingX: { check: "Integer", apply: "_applySpacingX", init: 0, themeable: true }, /** The spacing between each menu button */ spacingY: { check: "Integer", apply: "_applySpacingY", init: 0, themeable: true }, /** * Default icon column width if no icons are rendered. * This property is ignored as soon as an icon is present. */ iconColumnWidth: { check: "Integer", init: 0, themeable: true, apply: "_applyIconColumnWidth" }, /** Default arrow column width if no sub menus are rendered */ arrowColumnWidth: { check: "Integer", init: 0, themeable: true, apply: "_applyArrowColumnWidth" }, /** * Color of the blocker */ blockerColor: { check: "Color", init: null, nullable: true, apply: "_applyBlockerColor", themeable: true }, /** * Opacity of the blocker */ blockerOpacity: { check: "Number", init: 1, apply: "_applyBlockerOpacity", themeable: true }, /* --------------------------------------------------------------------------- FUNCTIONALITY PROPERTIES --------------------------------------------------------------------------- */ /** The currently selected button */ selectedButton: { check: "qx.ui.core.Widget", nullable: true, apply: "_applySelectedButton" }, /** The currently opened button (sub menu is visible) */ openedButton: { check: "qx.ui.core.Widget", nullable: true, apply: "_applyOpenedButton" }, /** Widget that opened the menu */ opener: { check: "qx.ui.core.Widget", nullable: true, apply: "_applyOpener" }, /* --------------------------------------------------------------------------- BEHAVIOR PROPERTIES --------------------------------------------------------------------------- */ /** Interval in ms after which sub menus should be opened */ openInterval: { check: "Integer", themeable: true, init: 250 }, /** Interval in ms after which sub menus should be closed */ closeInterval: { check: "Integer", themeable: true, init: 250 }, /** Blocks the background if value is <code>true<code> */ blockBackground: { check: "Boolean", themeable: true, init: false } }, /* ***************************************************************************** MEMBERS ***************************************************************************** */ members: { __scheduledOpen: null, __onAfterSlideBarAdd: null, /** @type {qx.ui.core.Blocker} blocker for background blocking */ _blocker: null, /* --------------------------------------------------------------------------- PUBLIC API --------------------------------------------------------------------------- */ /** * Opens the menu and configures the opener */ open() { if (this.getOpener() != null) { var isPlaced = this.placeToWidget(this.getOpener(), true); if (isPlaced) { this.__updateSlideBar(); this.show(); this._placementTarget = this.getOpener(); } else { this.warn( "Could not open menu instance because 'opener' widget is not visible" ); } } else { this.warn("The menu instance needs a configured 'opener' widget!"); } }, /** * Opens the menu at the pointer position * * @param e {qx.event.type.Pointer} Pointer event to align to */ openAtPointer(e) { this.placeToPointer(e); this.__updateSlideBar(); this.show(); this._placementTarget = { left: e.getDocumentLeft(), top: e.getDocumentTop() }; }, /** * Opens the menu in relation to the given point * * @param point {Map} Coordinate of any point with the keys <code>left</code> * and <code>top</code>. */ openAtPoint(point) { this.placeToPoint(point); this.__updateSlideBar(); this.show(); this._placementTarget = point; }, /** * Convenience method to add a separator to the menu */ addSeparator() { this.add(new qx.ui.menu.Separator()); }, /** * Returns the column sizes detected during the pre-layout phase * * @return {Array} List of all column widths */ getColumnSizes() { return this._getMenuLayout().getColumnSizes(); }, /** * Return all selectable menu items. * * @return {qx.ui.core.Widget[]} selectable widgets */ getSelectables() { var result = []; var children = this.getChildren(); for (var i = 0; i < children.length; i++) { if (children[i].isEnabled()) { result.push(children[i]); } } return result; }, /* --------------------------------------------------------------------------- PROPERTY APPLY ROUTINES --------------------------------------------------------------------------- */ // property apply _applyIconColumnWidth(value, old) { this._getMenuLayout().setIconColumnWidth(value); }, // property apply _applyArrowColumnWidth(value, old) { this._getMenuLayout().setArrowColumnWidth(value); }, // property apply _applySpacingX(value, old) { this._getMenuLayout().setColumnSpacing(value); }, // property apply _applySpacingY(value, old) { this._getMenuLayout().setSpacing(value); }, // overridden _applyVisibility(value, old) { super._applyVisibility(value, old); var mgr = qx.ui.menu.Manager.getInstance(); if (value === "visible") { // Register to manager (zIndex handling etc.) mgr.add(this); // Mark opened in parent menu var parentMenu = this.getParentMenu(); if (parentMenu) { parentMenu.setOpenedButton(this.getOpener()); } } else if (old === "visible") { // Deregister from manager (zIndex handling etc.) mgr.remove(this); // Unmark opened in parent menu var parentMenu = this.getParentMenu(); if (parentMenu && parentMenu.getOpenedButton() == this.getOpener()) { parentMenu.resetOpenedButton(); } // Clear properties this.resetOpenedButton(); this.resetSelectedButton(); } this.__updateBlockerVisibility(); }, /** * Updates the blocker's visibility */ __updateBlockerVisibility() { if (this.isVisible()) { if (this.getBlockBackground()) { var zIndex = this.getZIndex(); this._blocker.blockContent(zIndex - 1); } } else { if (this._blocker.isBlocked()) { this._blocker.unblock(); } } }, /** * Get the parent menu. Returns <code>null</code> if the menu doesn't have a * parent menu. * * @return {qx.ui.core.Widget|null} The parent menu. */ getParentMenu() { var widget = this.getOpener(); if (!widget || !(widget instanceof qx.ui.menu.AbstractButton)) { return null; } if (widget && widget.getContextMenu() === this) { return null; } while (widget && !(widget instanceof qx.ui.menu.Menu)) { widget = widget.getLayoutParent(); } return widget; }, // property apply _applySelectedButton(value, old) { if (old) { old.removeState("selected"); } if (value) { value.addState("selected"); } // ARIA attrs const opener = this.__getRootOpener(); const contentEl = opener ? opener.getContentElement() : this.getContentElement(); if (!contentEl) { return; } const valueContentEl = value ? value.getContentElement() : null; if (valueContentEl) { contentEl.setAttribute( "aria-activedescendant", valueContentEl.getAttribute("id") ); } else { contentEl.removeAttribute("aria-activedescendant"); } }, // property apply _applyOpenedButton(value, old) { if (old && old.getMenu()) { old.getMenu().exclude(); } if (value) { value.getMenu().open(); } }, // property apply _applyOpener(value, old) { // ARIA attrs const contentEl = this.getContentElement(); if (!contentEl) { return; } if (value && value.getContentElement()) { contentEl.setAttribute("aria-labelledby", ""); this.addAriaLabelledBy(value); } else { contentEl.removeAttribute("aria-labelledby"); } }, // property apply _applyBlockerColor(value, old) { this._blocker.setColor(value); }, // property apply _applyBlockerOpacity(value, old) { this._blocker.setOpacity(value); }, /* --------------------------------------------------------------------------- SCROLLING SUPPORT --------------------------------------------------------------------------- */ // overridden getChildrenContainer() { return this.getChildControl("slidebar", true) || this; }, // overridden _createChildControlImpl(id, hash) { var control; switch (id) { case "slidebar": var control = new qx.ui.menu.MenuSlideBar(); var layout = this._getLayout(); this._setLayout(new qx.ui.layout.Grow()); var slidebarLayout = control.getLayout(); control.setLayout(layout); slidebarLayout.dispose(); var children = qx.lang.Array.clone(this.getChildren()); for (var i = 0; i < children.length; i++) { control.add(children[i]); } this.removeListener("resize", this._onResize, this); control .getChildrenContainer() .addListener("resize", this._onResize, this); this._add(control); break; } return control || super._createChildControlImpl(id); }, /** * Get the menu layout manager * * @return {qx.ui.layout.Abstract} The menu layout manager */ _getMenuLayout() { if (this.hasChildControl("slidebar")) { return this.getChildControl("slidebar") .getChildrenContainer() .getLayout(); } else { return this._getLayout(); } }, /** * Get the menu bounds * * @return {Map} The menu bounds */ _getMenuBounds() { if (this.hasChildControl("slidebar")) { return this.getChildControl("slidebar") .getChildrenContainer() .getBounds(); } else { return this.getBounds(); } }, /** * Computes the size of the menu. This method is used by the * {@link qx.ui.core.MPlacement} mixin. * @return {Map} The menu bounds */ _computePlacementSize() { return this._getMenuBounds(); }, /** * Updates the visibility of the slidebar based on the menu's current size * and position. */ __updateSlideBar() { var menuBounds = this._getMenuBounds(); if (!menuBounds) { this.addListenerOnce("resize", this.__updateSlideBar, this); return; } var rootHeight = this.getLayoutParent().getBounds().height; var top = this.getLayoutProperties().top; var left = this.getLayoutProperties().left; // Adding the slidebar must be deferred because this call can happen // during the layout flush, which make it impossible to move existing // layout to the slidebar if (top < 0) { this._assertSlideBar(function () { this.setHeight(menuBounds.height + top); this.moveTo(left, 0); }); } else if (top + menuBounds.height > rootHeight) { this._assertSlideBar(function () { this.setHeight(rootHeight - top); }); } else { this.setHeight(null); } }, /** * Schedules the addition of the slidebar and calls the given callback * after the slidebar has been added. * * @param callback {Function} the callback to call * @return {var|undefined} The return value of the callback if the slidebar * already exists, or <code>undefined</code> if it doesn't */ _assertSlideBar(callback) { if (this.hasChildControl("slidebar")) { return callback.call(this); } this.__onAfterSlideBarAdd = callback; qx.ui.core.queue.Widget.add(this); }, // overridden syncWidget(jobs) { this.getChildControl("slidebar"); if (this.__onAfterSlideBarAdd) { this.__onAfterSlideBarAdd.call(this); delete this.__onAfterSlideBarAdd; } }, /* --------------------------------------------------------------------------- EVENT HANDLING --------------------------------------------------------------------------- */ /** * Gets called when a child is added. Sets ARIA attrs * @param {*} child */ _afterAddChild(child) { // Some childs, e.g. Seperators, are no meaningful menu items if (child instanceof qx.ui.menu.AbstractButton) { const contentEl = child.getContentElement(); contentEl.setAttribute("id", "menu-item-" + child.toHashCode()); contentEl.setAttribute("role", "menuitem"); } }, /** * Update position if the menu or the root is resized */ _onResize() { if (this.isVisible()) { var target = this._placementTarget; if (!target) { return; } else if (target instanceof qx.ui.core.Widget) { this.placeToWidget(target, true); } else if (target.top !== undefined) { this.placeToPoint(target); } else { throw new Error("Unknown target: " + target); } this.__updateSlideBar(); } }, /** * Event listener for pointerover event. * * @param e {qx.event.type.Pointer} pointerover event */ _onPointerOver(e) { // Cache manager var mgr = qx.ui.menu.Manager.getInstance(); // Be sure this menu is kept mgr.cancelClose(this); // Change selection var target = e.getTarget(); if (target.isEnabled() && target instanceof qx.ui.menu.AbstractButton) { // Select button directly this.setSelectedButton(target); var subMenu = target.getMenu && target.getMenu(); if (subMenu) { subMenu.setOpener(target); // Finally schedule for opening mgr.scheduleOpen(subMenu); // Remember scheduled menu for opening this.__scheduledOpen = subMenu; } else { var opened = this.getOpenedButton(); if (opened) { mgr.scheduleClose(opened.getMenu()); } if (this.__scheduledOpen) { mgr.cancelOpen(this.__scheduledOpen); this.__scheduledOpen = null; } } } else if (!this.getOpenedButton()) { // When no button is opened reset the selection // Otherwise keep it this.resetSelectedButton(); } }, /** * Event listener for pointerout event. * * @param e {qx.event.type.Pointer} pointerout event */ _onPointerOut(e) { // Cache manager var mgr = qx.ui.menu.Manager.getInstance(); // Detect whether the related target is out of the menu if (!qx.ui.core.Widget.contains(this, e.getRelatedTarget())) { // Update selected property // Force it to the open sub menu in cases where that is opened // Otherwise reset it. Menus which are left by the cursor should // not show any selection. var opened = this.getOpenedButton(); opened ? this.setSelectedButton(opened) : this.resetSelectedButton(); // Cancel a pending close request for the currently // opened sub menu if (opened) { mgr.cancelClose(opened.getMenu()); } // When leaving this menu to the outside, stop // all pending requests to open any other sub menu if (this.__scheduledOpen) { mgr.cancelOpen(this.__scheduledOpen); } } }, /* --------------------------------------------------------------------------- HELPER FUNCTIONS --------------------------------------------------------------------------- */ /** * Get the opener of the root/the first parent menu. * parent menu. * * @return {qx.ui.core.Widget|null} The opener. */ __getRootOpener() { let parentMenu = this.getParentMenu(); if (!parentMenu) { return this.getOpener(); } let opener; while (parentMenu) { opener = parentMenu.getOpener(); parentMenu = parentMenu.getParentMenu(); } return opener; } }, /* ***************************************************************************** DESTRUCTOR ***************************************************************************** */ destruct() { if (!qx.core.ObjectRegistry.inShutDown) { qx.ui.menu.Manager.getInstance().remove(this); } this.getApplicationRoot().removeListener("resize", this._onResize, this); this._placementTarget = null; this._disposeObjects("_blocker"); } });