/**
* An abstract class for fields that have a single trigger which opens a "picker" popup below
* the field, e.g. a combobox menu list or a date picker. It provides a base implementation
* for toggling the picker's visibility when the trigger is clicked, as well as keyboard navigation
* and some basic events. Sizing and alignment of the picker can be controlled via the
* {@link #matchFieldWidth} and {@link #pickerAlign}/{@link #pickerOffset} config properties
* respectively.
*
* You would not normally use this class directly, but instead use it as the parent class
* for a specific picker field implementation. Subclasses must implement the {@link #createPicker}
* method to create a picker component appropriate for the field.
*/
Ext.define('Ext.form.field.Picker', {
extend: 'Ext.form.field.Text',
alias: 'widget.pickerfield',
alternateClassName: 'Ext.form.Picker',
requires: ['Ext.util.KeyNav'],
config: {
triggers: {
picker: {
handler: 'onTriggerClick',
scope: 'this',
focusOnMousedown: true
}
}
},
renderConfig: {
/**
* @cfg {Boolean} editable
* False to prevent the user from typing text directly into the field; the field can only
* have its value set via selecting a value from the picker. In this state, the picker
* can also be opened by clicking directly on the input field itself.
*/
editable: true
},
keyMap: {
scope: 'this',
DOWN: 'onDownArrow',
ESC: 'onEsc'
},
keyMapTarget: 'inputEl',
/**
* @property {Boolean} isPickerField
* `true` in this class to identify an object as an instantiated Picker Field,
* or subclass thereof.
*/
isPickerField: true,
/**
* @cfg {Boolean} matchFieldWidth
* Whether the picker dropdown's width should be explicitly set to match the width of the field.
* Defaults to true.
*/
matchFieldWidth: true,
/**
* @cfg {String} pickerAlign
* The {@link Ext.util.Positionable#alignTo alignment position} with which to align the picker.
* Defaults to "tl-bl?"
*/
pickerAlign: 'tl-bl?',
/**
* @cfg {Number[]} pickerOffset
* An offset [x,y] to use in addition to the {@link #pickerAlign} when positioning the picker.
* Defaults to undefined.
*/
/**
* @cfg {String} [openCls='x-pickerfield-open']
* A class to be added to the field's {@link #bodyEl} element when the picker is opened.
*/
openCls: Ext.baseCSSPrefix + 'pickerfield-open',
/**
* @property {Boolean} isExpanded
* True if the picker is currently expanded, false if not.
*/
isExpanded: false,
/**
* @cfg {String} triggerCls
* An additional CSS class used to style the trigger button. The trigger will always
* get the class 'x-form-trigger' and triggerCls will be appended if specified.
*/
/**
* @event expand
* Fires when the field's picker is expanded.
* @param {Ext.form.field.Picker} field This field instance
*/
/**
* @event collapse
* Fires when the field's picker is collapsed.
* @param {Ext.form.field.Picker} field This field instance
*/
/**
* @event select
* Fires when a value is selected via the picker.
* @param {Ext.form.field.Picker} field This field instance
* @param {Object} value The value that was selected. The exact type of this value
* is dependent on the individual field and picker implementations.
*/
applyTriggers: function(triggers) {
var me = this,
picker = triggers.picker;
if (!picker.cls) {
picker.cls = me.triggerCls;
}
return me.callParent([triggers]);
},
getSubTplData: function(fieldData) {
var me = this,
data, ariaAttr;
data = me.callParent([fieldData]);
if (!me.ariaStaticRoles[me.ariaRole]) {
ariaAttr = data.ariaElAttributes;
if (ariaAttr) {
ariaAttr['aria-haspopup'] = true;
// Picker fields start as collapsed
ariaAttr['aria-expanded'] = false;
}
}
return data;
},
initEvents: function() {
this.callParent();
// Disable native browser autocomplete
if (Ext.isGecko) {
this.inputEl.dom.setAttribute('autocomplete', 'off');
}
},
updateEditable: function(editable, oldEditable) {
var me = this;
// Non-editable allows opening the picker by clicking the field
if (!editable) {
me.inputEl.on('click', me.onInputElClick, me);
}
else {
me.inputEl.un('click', me.onInputElClick, me);
}
me.callParent([editable, oldEditable]);
},
/**
* @private
*/
onEsc: function(e) {
if (Ext.isIE) {
// Stop the esc key from "restoring" the previous value in IE
// For example, type "foo". Highlight all the text, hit backspace.
// Hit esc, "foo" will be restored. This behaviour doesn't occur
// in any other browsers
e.preventDefault();
}
if (this.isExpanded) {
this.collapse();
e.stopEvent();
}
},
onDownArrow: function(e) {
var me = this;
if ((e.time - me.lastDownArrow) > 150) {
delete me.lastDownArrow;
}
if (!me.isExpanded) {
// Do not let the down arrow event propagate into the picker
e.stopEvent();
// Don't call expand() directly as there may be additional processing involved before
// expanding, e.g. in the case of a ComboBox query.
me.onTriggerClick(me, me.getPickerTrigger(), e);
me.lastDownArrow = e.time;
}
else if (!e.stopped && (e.time - me.lastDownArrow) < 150) {
delete me.lastDownArrow;
}
},
/**
* Expands this field's picker dropdown.
*/
expand: function() {
var me = this,
bodyEl, picker, doc;
if (me.rendered && !me.isExpanded && !me.destroyed) {
bodyEl = me.bodyEl;
picker = me.getPicker();
doc = Ext.getDoc();
picker.setMaxHeight(picker.initialConfig.maxHeight);
if (me.matchFieldWidth) {
picker.setWidth(me.bodyEl.getWidth());
}
// Show the picker and set isExpanded flag. alignPicker only works if isExpanded.
picker.show();
me.isExpanded = true;
me.alignPicker();
bodyEl.addCls(me.openCls);
if (!me.ariaStaticRoles[me.ariaRole]) {
if (!me.ariaEl.dom.hasAttribute('aria-owns')) {
me.ariaEl.dom.setAttribute(
'aria-owns', picker.listEl ? picker.listEl.id : picker.el.id
);
}
me.ariaEl.dom.setAttribute('aria-expanded', true);
}
// Collapse on touch outside this component tree.
// Because touch platforms do not focus document.body on touch
// so no focusleave would occur to trigger a collapse.
me.touchListeners = doc.on({
// Do not translate on non-touch platforms.
// mousedown will blur the field.
translate: false,
touchstart: me.collapseIf,
scope: me,
delegated: false,
destroyable: true
});
// Scrolling of anything which causes this field to move should collapse
me.scrollListeners = Ext.on({
scroll: me.onGlobalScroll,
scope: me,
destroyable: true
});
// Buffer is used to allow any layouts to complete before we align
Ext.on('resize', me.alignPicker, me, { buffer: 1 });
me.fireEvent('expand', me);
me.onExpand();
}
},
onExpand: Ext.emptyFn,
/**
* Aligns the picker to the input element
* @protected
*/
alignPicker: function() {
var me = this,
picker;
if (me.rendered && !me.destroyed) {
picker = me.getPicker();
if (picker.isVisible() && picker.isFloating()) {
me.doAlign();
}
}
},
/**
* Performs the alignment on the picker using the class defaults
* @private
*/
doAlign: function() {
var me = this,
picker = me.picker,
aboveSfx = '-above',
newPos,
isAbove;
// Align to the trigger wrap because the border isn't always on the input element, which
// can cause the offset to be off
picker.el.alignTo(me.triggerWrap, me.pickerAlign, me.pickerOffset);
// We used *element* alignTo to bypass the automatic reposition on scroll which
// Floating#alignTo does. So we must sync the Component state.
newPos = picker.floatParent
? picker.getOffsetsTo(picker.floatParent.getTargetEl())
: picker.getXY();
picker.x = newPos[0];
picker.y = newPos[1];
// add the {openCls}-above class if the picker was aligned above
// the field due to hitting the bottom of the viewport
isAbove = picker.el.getY() < me.inputEl.getY();
me.bodyEl[isAbove ? 'addCls' : 'removeCls'](me.openCls + aboveSfx);
picker[isAbove ? 'addCls' : 'removeCls'](picker.baseCls + aboveSfx);
},
/**
* Collapses this field's picker dropdown.
*/
collapse: function() {
var me = this,
openCls = me.openCls,
aboveSfx = '-above',
picker;
if (me.isExpanded && !me.destroyed && !me.destroying) {
picker = me.picker;
// hide the picker and set isExpanded flag
picker.hide();
me.isExpanded = false;
// remove the openCls
me.bodyEl.removeCls([openCls, openCls + aboveSfx]);
picker.el.removeCls(picker.baseCls + aboveSfx);
if (!me.ariaStaticRoles[me.ariaRole]) {
me.ariaEl.dom.setAttribute('aria-expanded', false);
}
// remove event listeners
me.touchListeners.destroy();
me.scrollListeners.destroy();
Ext.un('resize', me.alignPicker, me);
me.fireEvent('collapse', me);
me.onCollapse();
}
},
onCollapse: Ext.emptyFn,
/**
* @private
* Runs on touchstart of doc to check to see if we should collapse the picker.
*/
collapseIf: function(e) {
var me = this;
// If what was mousedowned on is outside of this Field, and is not focusable, then collapse.
// If it is focusable, this Field will blur and collapse anyway.
if (!me.destroyed && !e.within(me.bodyEl, false, true) && !me.owns(e.target) &&
!Ext.fly(e.target).isFocusable()) {
me.collapse();
}
},
/**
* Returns a reference to the picker component for this field, creating it if necessary by
* calling {@link #createPicker}.
* @return {Ext.Component} The picker component
*/
getPicker: function() {
var me = this,
picker = me.picker;
if (!picker) {
me.creatingPicker = true;
me.picker = picker = me.createPicker();
// For upward component searches.
picker.ownerCmp = me;
delete me.creatingPicker;
}
return me.picker;
},
// When focus leaves the picker component, if it's to outside of this
// Component's hierarchy
onFocusLeave: function(e) {
this.collapse();
this.callParent([e]);
},
/**
* @private
* The CQ interface. Allow drilling down into the picker when it exists.
* Important for determining whether an event took place in the bounds of some
* higher level containing component. See AbstractComponent#owns
*/
getRefItems: function() {
var result = [];
if (this.picker) {
result[0] = this.picker;
}
return result;
},
getPickerTrigger: function() {
return this.triggers && this.triggers.picker;
},
/**
* @method
* Creates and returns the component to be used as this field's picker.
* Must be implemented by subclasses of Picker.
*/
createPicker: Ext.emptyFn,
onInputElClick: function(e) {
this.onTriggerClick(this, this.getPickerTrigger(), e);
},
/**
* Handles the trigger click; by default toggles between expanding and collapsing
* the picker component.
* @protected
* @param {Ext.form.field.Picker} field This field instance.
* @param {Ext.form.trigger.Trigger} trigger This field's picker trigger.
* @param {Ext.event.Event} e The event that generated this call.
*/
onTriggerClick: function(field, trigger, e) {
var me = this;
if (!me.readOnly && !me.disabled) {
if (me.isExpanded) {
me.collapse();
}
else {
me.expand();
}
}
},
doDestroy: function() {
var me = this,
picker = me.picker;
Ext.un('resize', me.alignPicker, me);
Ext.destroy(me.keyNav, picker);
if (picker) {
me.picker = picker.pickerField = null;
}
me.callParent();
},
privates: {
onGlobalScroll: function(scroller) {
var scrollEl = scroller.getElement();
// Collapse if the scroll is anywhere but inside the picker
if (!this.picker.owns(scrollEl) && scrollEl.isAncestor(this.el)) {
this.collapse();
}
}
}
});