import Widget from "ui/core/widget";
import Constraint from "util/constraint";
import ConstraintSpec from "util/constraint-def";
/**
* Class representing an Dropmenu widget.
* @class
* @implements {Widget}
*/
class Dropmenu extends Widget {
/**
* @constructor
* @param {object} container - DOM container for the widget.
* @param {object} [o] - Options.
* @param {object} [o.menuItems=[]] - The items to populate the menu with.
* @param {string} [o.backgroundColor="#282828"] - The background color.
* @param {string} [o.fontColor="#ccc"] - The font color.
* @param {string} [o.fontSize="12px"] - The font size.
* @param {string} [o.fontFamily="Arial"] - The font family.
* @param {string} [o.menuItemFontSize="12px"] - The font size for items in the opened drop-down menu.
* @param {string} [o.menuItemFontFamily="Arial"] - The font family for items in the opened drop-down menu.
* @param {string} [o.selectedItemBackgroundColor="#f40"] - The background cover for the selected (hovered) item in the opened drop-down menu.
* @param {string} [o.selectedItemFontColor="#fff"] - The font color for the selected (hovered) item in the opened drop-down menu.
* @param {number} [o.menuItemHorizontalPadding=10] - Extra horizontal padding to add to each menu item.
* @param {number} [o.menuItemVerticalPadding=5] - Extra vertical padding to add to each menu item.
*/
constructor(container, o) {
super(container, o);
return this;
}
/* ==============================================================================================
* PUBLIC API
*/
/**
* Returns the currently selected menu item index.
* @public @override
* @returns {number} - Index of the item currently selected.
*/
getVal() {
return this.state.selectedItemIdx;
}
/**
* Returns the string representing the currently selected item.
* @public
* @returns {string} - The string representing the selected item.
*/
getSelectedItem() {
return this.state.menuItems[this.state.selectedItemIdx];
}
/**
* Sets the currently selected menu item.
* Same as setVal(), but will not cause an observer callback trigger.
* @public @override
* @param {number} itemIdx - Index of the item to be selected.
*/
setInternalVal(itemIdx) {
this.setInternalState({ selectedItemIdx: itemIdx });
}
/**
* Sets the currently selected menu item.
* Same as setInternalVal(), but will cause an observer callback trigger.
* @public @override
* @param {number} itemIdx - Index of the item to be selected.
*/
setVal(itemIdx) {
this.setState({ selectedItemIdx: itemIdx });
}
/**
* Sets the selected menu item by index.
* Same as setInternalSelectionIdx(), but will cause an observer callback trigger.
* @param {number} itemIdx - Index of the item to be selected.
*/
setSelectionIdx(itemIdx) {
this.setState({ selectedItemIdx: itemIdx });
}
/**
* Sets the selected menu item by index.
* Same as setSelectionIdx(), but will not cause an observer callback trigger.
* @param {number} itemIdx - Index of the item to be selected.
*/
setInternalSelectionIdx(itemIdx) {
this.setInternalState({ selectedItemIdx: itemIdx });
}
/**
* Sets the selected menu item according to a string argument specifying which item to select.
* If the argument is not one of the menu items, the selection will not change.
* Same as setInternalSelectedItem(), but will cause and observer callback trigger.
* @param {string} item - The item to select
* @returns {number} - Index of the item selected.
*/
setSelectedItem(item) {
let idx = this.state.menuItems.findIndex(menuItem => item === menuItem);
if (idx !== -1) {
this.setVal(idx);
} else {
idx = this.state.selectedItemIdx;
}
return idx;
}
/**
* Sets the selected menu item according to a string argument specifying which item to select.
* If the argument is not one of the menu items, the selection will not change.
* Same as setSelectedItem(), but will not cause and observer callback trigger.
* @param {string} item - The item to select
* @returns {number} - Index of the item selected.
*/
setInternalSelectedItem(item) {
let idx = this.state.menuItems.findIndex(menuItem => item === menuItem);
if (idx !== -1) {
this.setVal(idx);
} else {
idx = this.state.selectedItemIdx;
}
return idx;
}
/**
* Sets the list of available menu items.
* @public @override
* @param {array} menuItems - Array of menu items to use.
*/
setMenuItems(menuItems) {
this.setState({ menuItems: menuItems});
}
/* ==============================================================================================
* INITIALIZATION METHODS
*/
/**
* Initializes the options.
* @private @override
*/
_initOptions(o) {
// set the defaults
this.o = {
menuItems: [],
backgroundColor: "#282828",
fontColor: "#ccc",
fontSize: "12px",
fontFamily: "Arial",
menuItemFontSize: "12px",
menuItemFontFamily: "Arial",
menuItemVerticalPadding: 5,
menuItemHorizontalPadding: 10,
selectedItemBackgroundColor: "#f40",
selectedItemFontColor: "#fff",
mouseSensitivity: 1.2
};
// override defaults with provided options
super._initOptions(o);
}
/**
* Initializes state constraints.
* @private @override
*/
_initStateConstraints() {
const _this = this;
this.stateConstraints = new ConstraintSpec({
menuItems: [new Constraint()],
selectedItemIdx: new Constraint(),
hasFocus: new Constraint()
});
}
/**
* Initializes the state.
* @private @override
*/
_initState() {
this.state = {
menuItems: this.o.menuItems,
selectedItemIdx: 0,
hasFocus: false
};
}
/**
* Initializes the svg elements.
* @private @override
*/
_initSvgEls() {
const _this = this;
/* The following components are used:
* Panels are the background
* Text is where the text lives
* Overlays are transparent and are used to listen to mouse events
*/
this.svgEls = {
menuTogglePanel: document.createElementNS(_this.SVG_NS, "rect"),
menuToggleText: document.createElementNS(_this.SVG_NS, "text"),
menuToggleOverlay: document.createElementNS(_this.SVG_NS, "rect"),
menuBodyCanvasContainer: document.createElement("div"),
menuBodyCanvas: document.createElementNS(_this.SVG_NS, "svg"),
menuBodyPanel: document.createElementNS(_this.SVG_NS, "rect"),
menuItemPanels: [],
menuItemTextboxes: [],
menuItemOverlays: []
};
this.svg.appendChild(this.svgEls.menuTogglePanel);
this.svg.appendChild(this.svgEls.menuToggleText);
this.svg.appendChild(this.svgEls.menuToggleOverlay);
this.svgEls.menuToggleText.setAttribute("alignment-baseline", "middle");
// menu body (the part that is hidden unless toggled)
this.svgEls.menuBodyCanvasContainer.style.position = "relative";
this.container.appendChild(this.svgEls.menuBodyCanvasContainer);
this.svgEls.menuBodyCanvas = document.createElementNS(_this.SVG_NS, "svg");
this.svgEls.menuBodyCanvasContainer.appendChild(this.svgEls.menuBodyCanvas);
this.svgEls.menuBodyCanvas.style.position = "absolute";
this.svgEls.menuBodyCanvas.style.transform = "translateY(-5px)";
this.svgEls.menuBodyCanvas.appendChild(this.svgEls.menuBodyPanel);
this._update();
}
/**
* Initializes mouse and touch event handlers.
* @private @override
*/
_initHandlers() {
const _this = this;
this.handlers = {
touch: function touch(ev) {
ev.preventDefault();
ev.stopPropagation();
_this.handlers.focus(ev);
},
focus: function focus(ev) {
ev.preventDefault();
ev.stopPropagation();
_this.setInternalState({ hasFocus: true });
_this.svgEls.menuToggleOverlay.removeEventListener("mousedown", _this.handlers.touch);
_this.svgEls.menuToggleOverlay.removeEventListener("touchstart", _this.handlers.touch);
_this.svgEls.menuToggleOverlay.addEventListener("mousedown", _this.handlers.blur);
_this.svgEls.menuToggleOverlay.addEventListener("touchstart", _this.handlers.blur);
document.body.addEventListener("mousedown", _this.handlers.blur);
document.body.addEventListener("touchstart", _this.handlers.blur);
},
blur: function blur(ev) {
ev.preventDefault();
ev.stopPropagation();
_this.setInternalState({ hasFocus: false });
_this.svgEls.menuToggleOverlay.removeEventListener("mousedown", _this.handlers.blur);
_this.svgEls.menuToggleOverlay.removeEventListener("touchstart", _this.handlers.blur);
_this.svgEls.menuToggleOverlay.addEventListener("mousedown", _this.handlers.touch);
_this.svgEls.menuToggleOverlay.addEventListener("touchstart", _this.handlers.touch);
document.body.removeEventListener("mousedown", _this.handlers.blur);
document.body.removeEventListener("touchstart", _this.handlers.blur);
},
mouseOverItem: function mouseOverItem(ev) {
ev.preventDefault();
ev.stopPropagation();
let targetOverlay = ev.target;
_this._mouseOverItem(targetOverlay);
targetOverlay.addEventListener("mouseleave", _this.handlers.mouseLeaveItem);
targetOverlay.addEventListener("mouseup", (ev) => {
_this.handlers.select(ev);
_this.handlers.blur(ev);
});
targetOverlay.addEventListener("touchend", (ev) => {
_this.handlers.select(ev);
_this.handlers.blur(ev);
});
document.body.removeEventListener("mousedown", _this.handlers.blur);
document.body.removeEventListener("touchstart", _this.handlers.blur);
},
mouseLeaveItem: function mouseLeaveItem(ev) {
ev.preventDefault();
ev.stopPropagation();
let targetOverlay = ev.target;
_this._mouseLeaveItem(ev.target, false);
targetOverlay.removeEventListener("mouseleave", _this.handlers.hoverOut);
document.body.addEventListener("mousedown", _this.handlers.blur);
document.body.addEventListener("touchstart", _this.handlers.blur);
},
select: function select(ev) {
ev.preventDefault();
ev.stopPropagation();
_this._selectItem(ev.target);
}
};
this.svgEls.menuToggleOverlay.addEventListener("mousedown", this.handlers.touch);
this.svgEls.menuToggleOverlay.addEventListener("touchstart", this.handlers.touch);
}
/**
* Updates (redraws) components based on state.
* @private @override
*/
_update() {
const _this = this;
_this._updateEls();
for (let i = 0; i < _this.state.menuItems.length; ++i) {
_this.svgEls.menuItemTextboxes[i].textContent = _this.state.menuItems[i];
}
// Set attributes for the toggle area
this.svgEls.menuTogglePanel.setAttribute("fill", _this.o.backgroundColor);
this.svgEls.menuTogglePanel.setAttribute("width", _this._getWidth());
this.svgEls.menuTogglePanel.setAttribute("height", _this._getHeight());
this.svgEls.menuToggleText.setAttribute("width", _this._getWidth());
this.svgEls.menuToggleText.setAttribute("height", _this._getHeight());
this.svgEls.menuToggleText.setAttribute("x", 10);
this.svgEls.menuToggleText.setAttribute("y", _this._getHeight() / 2);
this.svgEls.menuToggleText.setAttribute("fill", _this.o.fontColor);
this.svgEls.menuToggleOverlay.setAttribute("fill", "transparent");
this.svgEls.menuToggleOverlay.setAttribute("width", _this._getWidth());
this.svgEls.menuToggleOverlay.setAttribute("height", _this._getHeight());
this.svgEls.menuToggleText.textContent = _this.state.menuItems[_this.state.selectedItemIdx];
// Set attributes for the menu body
if (this.state.hasFocus) {
this.svgEls.menuBodyCanvas.style.display = "inline-block";
// reappend the svg canvas for the menu body so that it appears on top of other elements
this.svgEls.menuBodyCanvasContainer.removeChild(this.svgEls.menuBodyCanvas);
this.svgEls.menuBodyCanvasContainer.appendChild(this.svgEls.menuBodyCanvas);
let menuItemDims = _this._calcMenuItemDims();
let menuDims = {
height: menuItemDims.height * _this.state.menuItems.length,
width: menuItemDims.width
};
this.svgEls.menuBodyCanvas.setAttribute("width", menuDims.width);
this.svgEls.menuBodyCanvas.setAttribute("height", menuDims.height);
this.svgEls.menuBodyCanvas.style.left = 0;
this.svgEls.menuBodyPanel.setAttribute("width", menuDims.width);
this.svgEls.menuBodyPanel.setAttribute("height", menuDims.height);
this.svgEls.menuBodyPanel.setAttribute("x", 0);
this.svgEls.menuBodyPanel.setAttribute("y", 0);
this.svgEls.menuBodyPanel.setAttribute("fill", this.o.backgroundColor);
for (let i = 0; i < this.state.menuItems.length; ++i) {
let curPanel = this.svgEls.menuItemPanels[i];
let curTextbox = this.svgEls.menuItemTextboxes[i];
let curOverlay = this.svgEls.menuItemOverlays[i];
curPanel.setAttribute("x", 0);
curPanel.setAttribute("y", i * menuItemDims.height);
curPanel.setAttribute("width", menuItemDims.width);
curPanel.setAttribute("height", menuItemDims.height);
curPanel.setAttribute("fill", "transparent");
curTextbox.setAttribute("alignment-baseline", "middle");
curTextbox.setAttribute("fill", _this.o.fontColor);
curTextbox.setAttribute("x", 10);
curTextbox.setAttribute("y", ((i + 1) * menuItemDims.height) - (menuItemDims.height / 2));
curOverlay.setAttribute("x", 0);
curOverlay.setAttribute("y", i * menuItemDims.height);
curOverlay.setAttribute("width", menuItemDims.width);
curOverlay.setAttribute("height", menuItemDims.height);
curOverlay.setAttribute("fill", "transparent");
}
} else {
this.svgEls.menuBodyCanvas.style.display = "none";
}
}
/**
* Updates elements to match SVG representation with the state.
* @private
*/
_updateEls() {
const _this = this;
for (let i = this.svgEls.menuItemTextboxes.length; i < this.state.menuItems.length; ++i) {
_this._addSvgMenuItem();
}
for (let i = this.state.menuItems.length; i > this.svgEls.menuItemTextboxes.length ; --i) {
_this._removeSvgMenuItem();
}
}
/* ==============================================================================================
* INTERNAL FUNCTIONALITY METHODS
*/
/**
* Handles mouse over event for menu item.
* @private
* @param {SvgElement} targetOverlay - The overlay of the item being hovered.
*/
_mouseOverItem(targetOverlay) {
const _this = this;
let idx = _this.svgEls.menuItemOverlays.findIndex(overlay => overlay === targetOverlay);
if (idx !== -1) {
let targetPanel = _this.svgEls.menuItemPanels[idx];
let targetTextbox = _this.svgEls.menuItemTextboxes[idx];
targetPanel.setAttribute("fill", _this.o.selectedItemBackgroundColor);
targetTextbox.setAttribute("fill", _this.o.selectedItemFontColor);
}
}
/**
* Handles mouse leave event for menu item.
* @private
* @param {SvgElement} targetOverlay - The overlay of the target item.
*/
_mouseLeaveItem(targetOverlay) {
const _this = this;
let idx = _this.svgEls.menuItemOverlays.findIndex(overlay => overlay === targetOverlay);
if (idx !== -1) {
let targetPanel = _this.svgEls.menuItemPanels[idx];
let targetTextbox = _this.svgEls.menuItemTextboxes[idx];
targetPanel.setAttribute("fill", "transparent");
targetTextbox.setAttribute("fill", _this.o.fontColor);
}
}
/**
* Adds svg elements representing a menu item.
* @private
*/
_addSvgMenuItem() {
const _this = this;
let newItemText = document.createElementNS(this.SVG_NS, "text");
let newItemPanel = document.createElementNS(this.SVG_NS, "rect");
let newItemOverlay = document.createElementNS(this.SVG_NS, "rect");
this.svgEls.menuItemTextboxes.push(newItemText);
this.svgEls.menuItemPanels.push(newItemPanel);
this.svgEls.menuItemOverlays.push(newItemOverlay);
this.svgEls.menuBodyCanvas.appendChild(newItemPanel);
this.svgEls.menuBodyCanvas.appendChild(newItemText);
this.svgEls.menuBodyCanvas.appendChild(newItemOverlay);
newItemOverlay.addEventListener("mouseenter", (ev) => { _this.handlers.mouseOverItem(ev); });
}
/**
* Removes svg elements representing a menu item.
* @private
*/
_removeSvgMenuItem() {
let targetItemTexbox = this.svgEls.menuItemTextboxes.pop();
let targetItemPanel = this.svgEls.menuItemPanels.pop();
let targetItemOverlay = this.svgEls.menuItemPanels.pop();
this.svgEls.menuBodyCanvas.removeChild(targetItemTexbox);
this.svgEls.menuBodyCanvas.removeChild(targetItemPanel);
this.svgEls.menuBodyCanvas.removeChild(targetItemOverlay);
targetItemTexbox = null;
targetItemPanel = null;
targetItemOverlay = null;
}
/**
* Calculate the height of each menu item.
* @private
* @returns {number} - Height in px.
*/
_calcMenuItemDims() {
let maxHeight = 0;
let maxWidth = 0;
this.svgEls.menuItemTextboxes.forEach(item => {
let bbox = item.getBoundingClientRect();
maxHeight = maxHeight > bbox.height ? maxHeight : bbox.height;
maxWidth = maxWidth > bbox.width ? maxWidth : bbox.width;
});
maxWidth = Math.max(maxWidth, this._getWidth());
// add some extra padding
maxHeight += this.o.menuItemVerticalPadding;
maxWidth += this.o.menuItemHorizontalPadding;
return { width: maxWidth, height: maxHeight };
}
/**
* Marks a menu element as selected.
* @private
* @param {SvgElement} targetOverlay
*/
_selectItem(targetOverlay) {
const _this = this;
let idx = _this.svgEls.menuItemOverlays.findIndex(overlay => overlay === targetOverlay);
if (idx !== -1) {
_this.setState({ selectedItemIdx: idx });
}
}
}
export default Dropmenu;