You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

468 lines
16 KiB
JavaScript

define([
"dojo/_base/declare", // declare
"dojo/_base/Deferred",
"dojo/dom", // dom.isDescendant
"dojo/dom-attr", // domAttr.set
"dojo/dom-class", // domClass.add domClass.contains domClass.remove
"dojo/dom-geometry", // domGeometry.marginBox domGeometry.position
"dojo/dom-style", // domStyle.set
"dojo/has", // has("touch")
"dojo/keys", // keys.DOWN_ARROW keys.ENTER keys.ESCAPE
"dojo/_base/lang", // lang.hitch lang.isFunction
"dojo/on",
"dojo/touch",
"./registry", // registry.byNode()
"./focus",
"./popup",
"./_FocusMixin"
], function(declare, Deferred, dom, domAttr, domClass, domGeometry, domStyle, has, keys, lang, on, touch,
registry, focus, popup, _FocusMixin){
// module:
// dijit/_HasDropDown
return declare("dijit._HasDropDown", _FocusMixin, {
// summary:
// Mixin for widgets that need drop down ability.
// _buttonNode: [protected] DomNode
// The button/icon/node to click to display the drop down.
// Can be set via a data-dojo-attach-point assignment.
// If missing, then either focusNode or domNode (if focusNode is also missing) will be used.
_buttonNode: null,
// _arrowWrapperNode: [protected] DomNode
// Will set CSS class dijitUpArrow, dijitDownArrow, dijitRightArrow etc. on this node depending
// on where the drop down is set to be positioned.
// Can be set via a data-dojo-attach-point assignment.
// If missing, then _buttonNode will be used.
_arrowWrapperNode: null,
// _popupStateNode: [protected] DomNode
// The node to set the aria-expanded class on.
// Also sets popupActive class but that will be removed in 2.0.
// Can be set via a data-dojo-attach-point assignment.
// If missing, then focusNode or _buttonNode (if focusNode is missing) will be used.
_popupStateNode: null,
// _aroundNode: [protected] DomNode
// The node to display the popup around.
// Can be set via a data-dojo-attach-point assignment.
// If missing, then domNode will be used.
_aroundNode: null,
// dropDown: [protected] Widget
// The widget to display as a popup. This widget *must* be
// defined before the startup function is called.
dropDown: null,
// autoWidth: [protected] Boolean
// Set to true to make the drop down at least as wide as this
// widget. Set to false if the drop down should just be its
// default width.
autoWidth: true,
// forceWidth: [protected] Boolean
// Set to true to make the drop down exactly as wide as this
// widget. Overrides autoWidth.
forceWidth: false,
// maxHeight: [protected] Integer
// The max height for our dropdown.
// Any dropdown taller than this will have scrollbars.
// Set to 0 for no max height, or -1 to limit height to available space in viewport
maxHeight: -1,
// dropDownPosition: [const] String[]
// This variable 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
//
// The list is positions is tried, in order, until a position is found where the drop down fits
// within the viewport.
//
dropDownPosition: ["below", "above"],
// _stopClickEvents: Boolean
// When set to false, the click events will not be stopped, in
// case you want to use them in your subclass
_stopClickEvents: true,
_onDropDownMouseDown: function(/*Event*/ e){
// summary:
// Callback when the user mousedown/touchstart on the arrow icon.
if(this.disabled || this.readOnly){
return;
}
// Prevent default to stop things like text selection, but don't stop propagation, so that:
// 1. TimeTextBox etc. can focus the <input> on mousedown
// 2. dropDownButtonActive class applied by _CssStateMixin (on button depress)
// 3. user defined onMouseDown handler fires
//
// Also, don't call preventDefault() on MSPointerDown event (on IE10) because that prevents the button
// from getting focus, and then the focus manager doesn't know what's going on (#17262)
if(e.type != "MSPointerDown" && e.type != "pointerdown"){
e.preventDefault();
}
this.own(on.once(this.ownerDocument, touch.release, lang.hitch(this, "_onDropDownMouseUp")));
this.toggleDropDown();
},
_onDropDownMouseUp: function(/*Event?*/ e){
// summary:
// 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.
var dropDown = this.dropDown, overMenu = false;
if(e && this._opened){
// This code 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 = domGeometry.position(this._buttonNode, true);
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;
while(t && !overMenu){
if(domClass.contains(t, "dijitPopup")){
overMenu = true;
}else{
t = t.parentNode;
}
}
if(overMenu){
t = e.target;
if(dropDown.onItemClick){
var menuItem;
while(t && !(menuItem = registry.byNode(t))){
t = t.parentNode;
}
if(menuItem && menuItem.onClick && menuItem.getParent){
menuItem.getParent().onItemClick(menuItem, e);
}
}
return;
}
}
}
if(this._opened){
// Focus the dropdown widget unless it's a menu (in which case autoFocus is set to false).
// Even if it's a menu, we need to focus it 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.
if(dropDown.focus && (dropDown.autoFocus !== false || (e.type == "mouseup" && !this.hovering))){
// Do it on a delay so that we don't steal back focus from the dropdown.
this._focusDropDownTimer = this.defer(function(){
dropDown.focus();
delete this._focusDropDownTimer;
});
}
}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("focus");
}
}
},
_onDropDownClick: function(/*Event*/ e){
// The drop down was already opened on mousedown/keydown; just need to stop the event
if(this._stopClickEvents){
e.stopPropagation();
e.preventDefault();
}
},
buildRendering: function(){
this.inherited(arguments);
this._buttonNode = this._buttonNode || this.focusNode || this.domNode;
this._popupStateNode = this._popupStateNode || this.focusNode || this._buttonNode;
// Add a class to the "dijitDownArrowButton" 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",
"above": "Up",
"below": "Down",
"left": "Left",
"right": "Right"
}[this.dropDownPosition[0]] || this.dropDownPosition[0] || "Down";
domClass.add(this._arrowWrapperNode || this._buttonNode, "dijit" + defaultPos + "ArrowButton");
},
postCreate: function(){
// summary:
// set up nodes and connect our mouse and keyboard events
this.inherited(arguments);
var keyboardEventNode = this.focusNode || this.domNode;
this.own(
on(this._buttonNode, touch.press, lang.hitch(this, "_onDropDownMouseDown")),
on(this._buttonNode, "click", lang.hitch(this, "_onDropDownClick")),
on(keyboardEventNode, "keydown", lang.hitch(this, "_onKey")),
on(keyboardEventNode, "keyup", lang.hitch(this, "_onKeyUp"))
);
},
destroy: function(){
// If dropdown is open, close it, to avoid leaving dijit/focus 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.destroyRecursive();
}
delete this.dropDown;
}
this.inherited(arguments);
},
_onKey: function(/*Event*/ e){
// summary:
// Callback when the user presses a key while focused on the button node
if(this.disabled || this.readOnly){
return;
}
var d = this.dropDown, target = e.target;
if(d && this._opened && d.handleKey){
if(d.handleKey(e) === false){
/* false return code means that the drop down handled the key */
e.stopPropagation();
e.preventDefault();
return;
}
}
if(d && 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 _KeyNavMixin has active searching 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._toggleOnKeyUp = true;
e.stopPropagation();
e.preventDefault();
}
},
_onKeyUp: function(){
if(this._toggleOnKeyUp){
delete this._toggleOnKeyUp;
this.toggleDropDown();
var d = this.dropDown; // drop down may not exist until toggleDropDown() call
if(d && d.focus){
this.defer(lang.hitch(d, "focus"), 1);
}
}
},
_onBlur: function(){
// summary:
// Called magically 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);
this.inherited(arguments);
},
isLoaded: function(){
// summary:
// Returns true if the dropdown exists and it's data is loaded. This can
// be overridden in order to force a call to loadDropDown().
// tags:
// protected
return true;
},
loadDropDown: function(/*Function*/ loadCallback){
// summary:
// 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 calls
// the given callback.
// tags:
// protected
// TODO: for 2.0, change API to return a Deferred, instead of calling loadCallback?
loadCallback();
},
loadAndOpenDropDown: function(){
// summary:
// 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: Deferred
// Deferred for the drop down widget that
// fires when drop down is created and loaded
// tags:
// protected
var d = new Deferred(),
afterLoad = lang.hitch(this, function(){
this.openDropDown();
d.resolve(this.dropDown);
});
if(!this.isLoaded()){
this.loadDropDown(afterLoad);
}else{
afterLoad();
}
return d;
},
toggleDropDown: function(){
// summary:
// Callback when the user presses the down arrow button or presses
// the down arrow key to open/close the drop down.
// Toggle the drop-down widget; if it is up, close it, if not, open it
// tags:
// protected
if(this.disabled || this.readOnly){
return;
}
if(!this._opened){
this.loadAndOpenDropDown();
}else{
this.closeDropDown(true); // refocus button to avoid hiding node w/focus
}
},
openDropDown: function(){
// summary:
// Opens the dropdown for this widget. To be called only when this.dropDown
// has been created and is ready to display (ie, it's data is loaded).
// returns:
// return value of dijit/popup.open()
// tags:
// protected
var dropDown = this.dropDown,
ddNode = dropDown.domNode,
aroundNode = this._aroundNode || this.domNode,
self = this;
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(){
domAttr.set(self._popupStateNode, "popupActive", false);
domClass.remove(self._popupStateNode, "dijitHasDropDownOpen");
self._set("_opened", false); // use set() because _CssStateMixin is watching
}
});
// Set width of drop down if necessary, so that dropdown width + width of scrollbar (from popup wrapper)
// matches width of aroundNode
if(this.forceWidth || (this.autoWidth && aroundNode.offsetWidth > dropDown._popupWrapper.offsetWidth)){
var widthAdjust = aroundNode.offsetWidth - dropDown._popupWrapper.offsetWidth;
var resizeArgs = {
w: dropDown.domNode.offsetWidth + widthAdjust
};
if(lang.isFunction(dropDown.resize)){
dropDown.resize(resizeArgs);
}else{
domGeometry.setMarginBox(ddNode, resizeArgs);
}
// 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";
}
}
domAttr.set(this._popupStateNode, "popupActive", "true");
domClass.add(this._popupStateNode, "dijitHasDropDownOpen");
this._set("_opened", true); // use set() because _CssStateMixin is watching
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(ddNode.getAttribute("role") !== "presentation" && !ddNode.getAttribute("aria-labelledby")){
ddNode.setAttribute("aria-labelledby", this.id);
}
return retVal;
},
closeDropDown: function(/*Boolean*/ focus){
// summary:
// Closes the drop down on this widget
// focus:
// If true, refocuses the button widget
// tags:
// protected
if(this._focusDropDownTimer){
this._focusDropDownTimer.remove();
delete this._focusDropDownTimer;
}
if(this._opened){
this._popupStateNode.setAttribute("aria-expanded", "false");
if(focus && this.focus){
this.focus();
}
popup.close(this.dropDown);
this._opened = false;
}
}
});
});