/** @module delite/HasDropDown */
define([
"dcl/dcl",
"dojo/Deferred",
"dojo/dom-class", // domClass.add domClass.contains domClass.remove
"dojo/when",
"./keys", // keys.DOWN_ARROW keys.ENTER keys.ESCAPE
"./place",
"./popup",
"./Widget",
"./activationTracker", // for delite-deactivated event
"dpointer/events" // so can just monitor for "pointerdown"
], function (dcl, Deferred, domClass, when, keys, place, popup, Widget) {
/**
* Dispatched before popup widget is shown.
* @example
* document.addEventListener("delite-before-show", function (evt) {
* console.log("about to show popup", evt.child);
* });
* @event module:delite/HasDropDown#delite-before-show
* @property {Element} child - reference to popup
*/
/**
* Dispatched after popup widget is shown.
* @example
* document.addEventListener("delite-after-show", function (evt) {
* console.log("just displayed popup", evt.child);
* });
* @event module:delite/HasDropDown#delite-after-show
* @property {Element} child - reference to popup
*/
/**
* Dispatched before popup widget is hidden.
* @example
* document.addEventListener("delite-before-hide", function (evt) {
* console.log("about to hide popup", evt.child);
* });
* @event module:delite/HasDropDown#delite-before-hide
* @property {Element} child - reference to popup
*/
/**
* Dispatched after popup widget is hidden.
* @example
* document.addEventListener("delite-after-hide", function (evt) {
* console.log("just hid popup", evt.child);
* });
* @event module:delite/HasDropDown#delite-after-hide
* @property {Element} child - reference to popup
*/
/**
* Base class for widgets that need drop down ability.
* @mixin module:delite/HasDropDown
* @augments module:delite/Widget
*/
return dcl(Widget, /** @lends module:delite/HasDropDown# */ {
/**
* The button/icon/node to click to display the drop down.
* Can be set in a template via a `attach-point` assignment.
* If missing, then either `this.focusNode` or `this.domNode` (if `focusNode` is also missing) will be used.
* @member {Element}
* @protected
*/
buttonNode: null,
/**
* Will set CSS class `d-up-arrow-button`, `d-down-arrow-button`, `d-right-arrow-button` etc. on this node
* depending on where the drop down is set to be positioned.
* Can be set in a template via a `attach-point` assignment.
* If missing, then `this.buttonNode` will be used.
* @member {Element}
* @protected
*/
arrowWrapperNode: null,
/**
* The node to set the `aria-expanded` class on.
* Can be set in a template via a `attach-point` assignment.
* If missing, then `this.focusNode` or `this.buttonNode` (if `focusNode` is missing) will be used.
* @member {Element}
* @protected
*/
popupStateNode: null,
/**
* The node to display the popup around.
* Can be set in a template via a `attach-point` assignment.
* If missing, then `this.domNode` will be used.
* @member {Element}
* @protected
*/
aroundNode: null,
/**
* The widget to display as a popup. Applications/subwidgets should *either*:
*
* 1. define this property
* 2. override `loadDropDown()` to return a dropdown widget or Promise for one
* 3. setup a listener for the delite-display-load event that [asynchronously] resolves the event's
* `loadDeferred` Promise to the dropdown
* @member {Element}
*/
dropDown: null,
/**
* If true, make the drop down at least as wide as this widget.
* If false, leave the drop down at its default width.
* Has no effect when `dropDownPosition = ["center"]`.
* @member {boolean}
* @default true
*/
autoWidth: true,
/**
* If true, make the drop down exactly as wide as this widget. Overrides `autoWidth`.
* Has no effect when `dropDownPosition = ["center"]`.
* @member {boolean}
* @default false
*/
forceWidth: false,
/**
* The maximum height for our dropdown.
* Any dropdown taller than this will have a scroll bar.
* Set to 0 for no max height, or -1 to limit height to available space in viewport.
* @member {number}
* @default -1
*/
maxHeight: -1,
/**
* Controls the position of the drop down.
* It's an array of strings with the following values:
*
* - before: places drop down to the left of the target node/widget, or to the right in
* the case of RTL scripts like Hebrew and Arabic
* - after: places drop down to the right of the target node/widget, or to the left in
* the case of RTL scripts like Hebrew and Arabic
* - above: drop down goes above target node
* - below: drop down goes below target node
* - center: drop down is centered on the screen, like a dialog; when used, this should be
* the only choice in the array
*
* The positions are tried, in order, until a position is found where the drop down fits
* within the viewport.
*
* @member {string[]}
* @default ["below", "above"]
*/
dropDownPosition: ["below", "above"],
/**
* Whether or not the drop down is open.
* @member {boolean}
* @readonly
*/
opened: false,
/**
* Callback when the user mousedown/touchstart on the arrow icon.
* @private
*/
_dropDownPointerDownHandler: function () {
if (this.disabled || this.readOnly) {
return;
}
// In the past we would call e.preventDefault() to stop things like text selection,
// but it doesn't work on IE10 (or IE11?) since it prevents the button from getting focus
// (see #17262), so not doing it at all for now.
//
// Also, don't stop propagation, so that:
// 1. TimeTextBox etc. can focus the <input> on mousedown
// 2. dropDownButtonActive class applied by CssState (on button depress)
// 3. user defined onMouseDown handler fires
this._docHandler = this.on("pointerup", this._dropDownPointerUpHandler.bind(this), this.ownerDocument.body);
this.toggleDropDown();
},
/**
* Callback on mouseup/touchend after mousedown/touchstart on the arrow icon.
* Note that this function is called regardless of what node the event occurred on (but only after
* a mousedown/touchstart on the arrow).
*
* If the drop down is a simple menu and the cursor is over the menu, we execute it, otherwise,
* we focus our drop down widget. If the event is missing, then we are not a mouseup event.
*
* This is useful for the common mouse movement pattern with native browser `<select>` nodes:
*
* 1. mouse down on the select node (probably on the arrow)
* 2. move mouse to a menu item while holding down the mouse button
* 3. mouse up; this selects the menu item as though the user had clicked it
*
* @param {Event} [e]
* @private
*/
_dropDownPointerUpHandler: function (e) {
/* jshint maxcomplexity:14 */
if (this._docHandler) {
this._docHandler.remove();
this._docHandler = null;
}
// If mousedown on the button, then dropdown opened, then user moved mouse over a menu item
// in the drop down, and released the mouse.
if (this._currentDropDown) {
// This if() statement deals with the corner-case when the drop down covers the original widget,
// because it's so large. In that case mouse-up shouldn't select a value from the menu.
// Find out if our target is somewhere in our dropdown widget,
// but not over our buttonNode (the clickable node)
var c = place.position(this.buttonNode);
if (!(e.pageX >= c.x && e.pageX <= c.x + c.w) || !(e.pageY >= c.y && e.pageY <= c.y + c.h)) {
var t = e.target, overMenu;
while (t && !overMenu) {
if (domClass.contains(t, "d-popup")) {
overMenu = true;
break;
} else {
t = t.parentNode;
}
}
if (overMenu) {
if (this._currentDropDown.handleSlideClick) {
var menuItem = this.getEnclosingWidget(e.target);
menuItem.handleSlideClick(menuItem, e);
}
return;
}
}
}
if (this._openDropDownPromise) {
// Focus the drop down once it opens, unless it's a menu.
// !this.hovering condition checks if this is a fake mouse event caused by the user typing
// SPACE/ENTER while using JAWS. Jaws converts the SPACE/ENTER key into mousedown/mouseup events.
// If this.hovering is false then it's presumably actually a keyboard event.
this._focusDropDownOnOpen(!this.hovering);
} else {
// The drop down arrow icon probably can't receive focus, but widget itself should get focus.
// defer() needed to make it work on IE (test DateTextBox)
if (this.focus) {
this.defer(this.focus);
}
}
},
/**
* Helper function to focus the dropdown when it finishes loading and opening.
* Exception: doesn't focus the dropdown when `dropDown.focusOnOpen === false a menu`, unless it
* was opened via the keyboard. `dropDown.focusOnOpen` is meant to be set for menus.
* @param {boolean} keyboard - True if the user opened the dropdown via the keyboard
*/
_focusDropDownOnOpen: function (keyboard) {
// Wait until the dropdown appears (if it hasn't appeared already), and then
// focus it, unless it's a menu (in which case focusOnOpen is set to false).
// Even if it's a menu, we need to focus it when it's opened by the keyboard.
this._openDropDownPromise.then(function (ret) {
var dropDown = ret.dropDown;
if (dropDown.focus && (keyboard || dropDown.focusOnOpen !== false)) {
this._focusDropDownTimer = this.defer(function () {
dropDown.focus();
delete this._focusDropDownTimer;
});
}
}.bind(this));
},
postRender: function () {
this.buttonNode = this.buttonNode || this.focusNode || this;
this.popupStateNode = this.popupStateNode || this.focusNode || this.buttonNode;
this.setAttribute("aria-haspopup", "true");
// basic listeners
this.on("pointerdown", this._dropDownPointerDownHandler.bind(this), this.buttonNode);
this.on("keydown", this._dropDownKeyDownHandler.bind(this), this.focusNode || this);
this.on("keyup", this._dropDownKeyUpHandler.bind(this), this.focusNode || this);
// set this.hovering when mouse is over widget so we can differentiate real mouse clicks from synthetic
// mouse clicks generated from JAWS upon keyboard events
this.on("pointerenter", function () {
this.hovering = true;
}.bind(this));
this.on("pointerleave", function () {
this.hovering = false;
}.bind(this));
// Avoid phantom click on android [and maybe iOS] where touching the button opens a centered dialog, but
// then there's a phantom click event on the dialog itself, possibly closing it.
// Happens in deliteful/tests/functional/ComboBox-prog.html on a phone (portrait mode), when you click
// towards the right side of the second ComboBox.
this.on("touchstart", function (evt) {
// Note: need to be careful not to call evt.preventDefault() indiscriminately because that would
// prevent [non-disabled] <input> etc. controls from getting focus.
if (this.dropDownPosition[0] === "center") {
evt.preventDefault();
}
}.bind(this), this.buttonNode);
// Stop click events and workaround problem on iOS where a blur event occurs ~300ms after
// the focus event, causing the dropdown to open then immediately close.
// Workaround iOS problem where clicking a Menu can focus an <input> (or click a button) behind it.
// Need to be careful though that you can still focus <input>'s and click <button>'s in a TooltipDialog.
// Also, be careful not to break (native) scrolling of dropdown like ComboBox's options list.
this.on("touchend", function (evt) {
evt.preventDefault();
}, this.buttonNode);
this.on("click", function (evt) {
evt.preventDefault();
evt.stopPropagation();
}, this.buttonNode);
this.on("delite-deactivated", this._deactivatedHandler.bind(this));
// trigger initial setting of d-down-arrow class
this.notifyCurrentValue("dropDownPosition");
},
refreshRendering: function (props) {
if ("dropDownPosition" in props) {
// Add a "d-down-arrow" type class to buttonNode so theme can set direction of arrow
// based on where drop down will normally appear
var defaultPos = {
"after": this.isLeftToRight() ? "right" : "left",
"before": this.isLeftToRight() ? "left" : "right"
}[this.dropDownPosition[0]] || this.dropDownPosition[0] || "down";
this.setClassComponent("arrowDirectionIcon", "d-" + defaultPos + "-arrow",
this.arrowWrapperNode || this.buttonNode);
}
},
destroy: function () {
// If dropdown is open, close it, to avoid leaving delite/activationTracker in a strange state.
// Put focus back on me to avoid the focused node getting destroyed, which flummoxes IE.
if (this.opened) {
this.closeDropDown(true);
}
if (this.dropDown) {
// Destroy the drop down, unless it's already been destroyed. This can happen because
// the drop down is a direct child of <body> even though it's logically my child.
if (!this.dropDown._destroyed) {
this.dropDown.destroy();
}
delete this.dropDown;
}
},
/**
* Callback when the user presses a key while focused on the button node.
* @param {Event} e
* @private
*/
_dropDownKeyDownHandler: function (e) {
/* jshint maxcomplexity:14 */
if (this.disabled || this.readOnly) {
return;
}
var dropDown = this._currentDropDown, target = e.target;
if (dropDown && this.opened) {
if (dropDown.emit("keydown", e) === false) {
/* false return code means that the drop down handled the key */
e.stopPropagation();
e.preventDefault();
return;
}
}
if (dropDown && this.opened && e.keyCode === keys.ESCAPE) {
this.closeDropDown();
e.stopPropagation();
e.preventDefault();
} else if (!this.opened &&
(e.keyCode === keys.DOWN_ARROW ||
// ignore unmodified SPACE if KeyNav has search in progress
((e.keyCode === keys.ENTER || (e.keyCode === keys.SPACE &&
(!this._searchTimer || (e.ctrlKey || e.altKey || e.metaKey)))) &&
//ignore enter and space if the event is for a text input
((target.tagName || "").toLowerCase() !== "input" ||
(target.type && target.type.toLowerCase() !== "text"))))) {
// Toggle the drop down, but wait until keyup so that the drop down doesn't
// get a stray keyup event, or in the case of key-repeat (because user held
// down key for too long), stray keydown events.
this._openOnKeyUp = true;
e.stopPropagation();
e.preventDefault();
}
},
/**
* Callback when the user releases a key while focused on the button node.
* @param {Event} e
* @private
*/
_dropDownKeyUpHandler: function () {
if (this._openOnKeyUp) {
delete this._openOnKeyUp;
this.openDropDown();
this._focusDropDownOnOpen(true);
}
},
_deactivatedHandler: function () {
// Called when focus has shifted away from this widget and it's dropdown
// Close dropdown but don't focus my <input>. User may have focused somewhere else (ex: clicked another
// input), and even if they just clicked a blank area of the screen, focusing my <input> will unwantedly
// popup the keyboard on mobile.
this.closeDropDown(false);
},
/**
* Creates/loads the drop down.
* Returns a Promise for the dropdown, or if loaded synchronously, the dropdown itself.
*
* Applications must either:
*
* 1. set the `dropDown` property to point to the dropdown (as an initialisation parameter)
* 2. override this method to create custom drop downs on the fly, returning a reference or promise
* for the dropdown
* 3. listen for a "delite-display-load" event, and then [asynchronously] resolve the event's `evt.loadDeferred`
* Promise property with an Object like `{child: dropDown}`
*
* With option (2) or (3) the application is responsible for destroying the dropdown.
*
* @returns {Element|Promise} Element or Promise for the dropdown
* @protected
* @fires module:delite/DisplayContainer#delite-display-load
*/
loadDropDown: function () {
if (this.dropDown) {
return this.dropDown;
} else {
// tell app controller we are going to show the dropdown; it must return a pointer to the dropdown
var def = new Deferred();
this.emit("delite-display-load", {
loadDeferred: def
});
return def.then(function (value) { return value.child; });
}
},
/**
* Toggle the drop-down widget; if it is open, close it, if not, open it.
* Called when the user presses the down arrow button or presses
* the down arrow key to open/close the drop down.
* @protected
*/
toggleDropDown: function () {
if (this.disabled || this.readOnly) {
return;
}
if (!this.opened) {
return this.openDropDown();
} else {
return this.closeDropDown(true); // refocus button to avoid hiding node w/focus
}
},
/**
* Creates the drop down if it doesn't exist, loads the data
* if there's an href and it hasn't been loaded yet, and
* then opens the drop down. This is basically a callback when the
* user presses the down arrow button to open the drop down.
* @returns {Promise} Promise for the drop down widget that fires when drop down is created and loaded.
* @protected
* @fires module:delite/HasDropDown#delite-before-show
* @fires module:delite/HasDropDown#delite-after-show
*/
openDropDown: function () {
return this._openDropDownPromise ||
(this._openDropDownPromise = when(this.loadDropDown()).then(function (dropDown) {
this._currentDropDown = dropDown;
var aroundNode = this.aroundNode || this,
self = this;
this.emit("delite-before-show", {
child: dropDown,
cancelable: false
});
// Generate id for anchor if it's not already specified
if (!this.id) {
this.id = "HasDropDown_" + this.widgetId;
}
dropDown._originalStyle = dropDown.style.cssText;
var retVal = popup.open({
parent: this,
popup: dropDown,
around: aroundNode,
orient: this.dropDownPosition,
maxHeight: this.maxHeight,
onExecute: function () {
self.closeDropDown(true);
},
onCancel: function () {
self.closeDropDown(true);
},
onClose: function () {
domClass.remove(self.popupStateNode, "d-drop-down-open");
this.opened = false;
}
});
// Set width of drop down if necessary, so that dropdown width + width of scrollbar (from popup wrapper)
// matches width of aroundNode. Don't do anything for when dropDownPosition=["center"] though,
// in which case popup.open() doesn't return a value.
if (retVal && (this.forceWidth ||
(this.autoWidth && aroundNode.offsetWidth > dropDown._popupWrapper.offsetWidth))) {
var widthAdjust = aroundNode.offsetWidth - dropDown._popupWrapper.offsetWidth;
dropDown._popupWrapper.style.width = aroundNode.offsetWidth + "px";
// Workaround apparent iOS bug where width: inherit on dropdown apparently not working.
dropDown.style.width = aroundNode.offsetWidth + "px";
// If dropdown is right-aligned then compensate for width change by changing horizontal position
if (retVal.corner[1] === "R") {
dropDown._popupWrapper.style.left =
(dropDown._popupWrapper.style.left.replace("px", "") - widthAdjust) + "px";
}
}
domClass.add(this.popupStateNode, "d-drop-down-open");
this.opened = true;
this.popupStateNode.setAttribute("aria-expanded", "true");
this.popupStateNode.setAttribute("aria-owns", dropDown.id);
// Set aria-labelledby on dropdown if it's not already set to something more meaningful
if (dropDown.getAttribute("role") !== "presentation" && !dropDown.getAttribute("aria-labelledby")) {
dropDown.setAttribute("aria-labelledby", this.id);
}
this.emit("delite-after-show", {
child: dropDown,
cancelable: false
});
return {
dropDown: dropDown,
position: retVal
};
}.bind(this)));
},
/**
* Closes the drop down on this widget.
* @param {boolean} focus - If true, refocus this widget.
* @protected
* @fires module:delite/HasDropDown#delite-before-hide
* @fires module:delite/HasDropDown#delite-after-hide
*/
closeDropDown: function (focus) {
if (this._openDropDownPromise) {
if (!this._openDropDownPromise.isFulfilled()) {
this._openDropDownPromise.cancel();
}
delete this._openDropDownPromise;
}
if (this._focusDropDownTimer) {
this._focusDropDownTimer.remove();
delete this._focusDropDownTimer;
}
if (this.opened) {
this.popupStateNode.setAttribute("aria-expanded", "false");
if (focus && this.focus) {
this.focus();
}
this.emit("delite-before-hide", {
child: this._currentDropDown,
cancelable: false
});
popup.close(this._currentDropDown);
this.opened = false;
this._currentDropDown.style.cssText = this._currentDropDown._originalStyle;
this.emit("delite-after-hide", {
child: this._currentDropDown,
cancelable: false
});
}
delete this._currentDropDown;
}
});
});