/**
* Implements row based navigation via keyboard.
*
* Must synchronize across grid sections.
*
* @example
* var store = Ext.create('Ext.data.Store', {
* fields : ['name', 'email', 'phone'],
* data : {
* items : [
* { name : 'Lisa', email : '[email protected]', phone : '555-111-1224' },
* { name : 'Bart', email : '[email protected]', phone : '555-222-1234' },
* { name : 'Homer', email : '[email protected]', phone : '555-222-1244' },
* { name : 'Marge', email : '[email protected]', phone : '555-222-1254' }
* ]
* },
* proxy : {
* type : 'memory',
* reader : {
* type : 'json',
* root : 'items'
* }
* }
* });
* Ext.create('Ext.grid.Panel', {
* title : 'Simpsons',
* store : store,
* width : 400,
* renderTo : Ext.getBody(),
* columns : [
* { text : 'Name', dataIndex : 'name' },
* { text : 'Email', dataIndex : 'email', flex : 1 },
* { text : 'Phone', dataIndex : 'phone' }
* ]
* });
*/
Ext.define('Ext.selection.RowModel', {
extend: 'Ext.selection.Model',
alias: 'selection.rowmodel',
requires: ['Ext.util.KeyNav'],
/**
* @private
* Number of pixels to scroll to the left/right when pressing
* left/right keys.
*/
deltaScroll: 5,
/**
* @cfg {Boolean} enableKeyNav
*
* Turns on/off keyboard navigation within the grid.
*/
enableKeyNav: true,
/**
* @cfg {Boolean} [ignoreRightMouseSelection=false]
* True to ignore selections that are made when using the right mouse button if there are
* records that are already selected. If no records are selected, selection will continue
* as normal
*/
ignoreRightMouseSelection: false,
isRowModel: true,
constructor: function() {
this.addEvents(
/**
* @event beforedeselect
* Fired before a record is deselected. If any listener returns false, the
* deselection is cancelled.
* @param {Ext.selection.RowModel} this
* @param {Ext.data.Model} record The deselected record
* @param {Number} index The row index deselected
*/
'beforedeselect',
/**
* @event beforeselect
* Fired before a record is selected. If any listener returns false, the
* selection is cancelled.
* @param {Ext.selection.RowModel} this
* @param {Ext.data.Model} record The selected record
* @param {Number} index The row index selected
*/
'beforeselect',
/**
* @event deselect
* Fired after a record is deselected
* @param {Ext.selection.RowModel} this
* @param {Ext.data.Model} record The deselected record
* @param {Number} index The row index deselected
*/
'deselect',
/**
* @event select
* Fired after a record is selected
* @param {Ext.selection.RowModel} this
* @param {Ext.data.Model} record The selected record
* @param {Number} index The row index selected
*/
'select'
);
this.views = [];
this.callParent(arguments);
},
bindComponent: function(view) {
var me = this;
view.on({
// Because we used to select on mousedown, contextmenu (right click) used to also select.
// Now we use click, for backward compatibility, we need to select on contextmenu too.
// Apps may assume that contextmenu selects: https://sencha.jira.com/browse/EXTJSIV-11297
itemcontextmenu: me.onRowClick,
itemclick: me.onRowClick,
scope: me
});
if (me.enableKeyNav) {
me.initKeyNav(view);
}
},
initKeyNav: function(view) {
var me = this;
if (!view.rendered) {
view.on('render', Ext.Function.bind(me.initKeyNav, me, [view], 0), me, {single: true});
return;
}
// view.el has tabIndex -1 to allow for
// keyboard events to be passed to it.
view.el.set({
tabIndex: -1
});
// Drive the KeyNav off the View's itemkeydown event so that beforeitemkeydown listeners may veto
me.keyNav = new Ext.util.KeyNav({
target: view,
ignoreInputFields: true,
eventName: 'itemkeydown',
processEvent: function(view, record, node, index, event) {
event.record = record;
event.recordIndex = index;
return event;
},
up: me.onKeyUp,
down: me.onKeyDown,
right: me.onKeyRight,
left: me.onKeyLeft,
pageDown: me.onKeyPageDown,
pageUp: me.onKeyPageUp,
home: me.onKeyHome,
end: me.onKeyEnd,
space: me.onKeySpace,
enter: me.onKeyEnter,
scope: me
});
},
onUpdate: function(record) {
var me = this,
view = me.view,
index;
if (view && me.isSelected(record)) {
index = view.indexOf(record);
view.onRowSelect(index);
if (record === me.lastFocused) {
view.onRowFocus(index, true);
}
}
},
// Returns the number of rows currently visible on the screen or
// false if there were no rows. This assumes that all rows are
// of the same height and the first view is accurate.
getRowsVisible: function() {
var rowsVisible = false,
view = this.views[0],
firstRow = view.all.first(),
rowHeight, gridViewHeight;
if (firstRow) {
rowHeight = firstRow.getHeight();
gridViewHeight = view.el.getHeight();
rowsVisible = Math.floor(gridViewHeight / rowHeight);
}
return rowsVisible;
},
// go to last visible record in grid.
onKeyEnd: function(e) {
var me = this,
view = me.views[0];
if (view.bufferedRenderer) {
// If rendering is buffered, we cannot just increment the row - the row may not be there
// We have to ask the BufferedRenderer to navigate to the target.
// And that may involve asynchronous I/O, so must postprocess in a callback.
view.bufferedRenderer.scrollTo(me.store.getCount() - 1, false, function(newIdx, newRecord) {
me.afterKeyNavigate(e, newRecord)
});
} else {
me.afterKeyNavigate(e, view.getRecord(view.all.getCount() - 1))
}
},
// go to first visible record in grid.
onKeyHome: function(e) {
var me = this,
view = me.views[0];
if (view.bufferedRenderer) {
// If rendering is buffered, we cannot just increment the row - the row may not be there
// We have to ask the BufferedRenderer to navigate to the target.
// And that may involve asynchronous I/O, so must postprocess in a callback.
view.bufferedRenderer.scrollTo(0, false, function(newIdx, newRecord) {
me.afterKeyNavigate(e, newRecord)
});
} else {
me.afterKeyNavigate(e, view.getRecord(0));
}
},
// Go one page up from the lastFocused record in the grid.
onKeyPageUp: function(e) {
var me = this,
view = me.views[0],
rowsVisible = me.getRowsVisible(),
newIdx,
newRecord;
if (rowsVisible) {
// If rendering is buffered, we cannot just increment the row - the row may not be there
// We have to ask the BufferedRenderer to navigate to the target.
// And that may involve asynchronous I/O, so must postprocess in a callback.
if (view.bufferedRenderer) {
newIdx = Math.max(e.recordIndex - rowsVisible, 0);
(me.lastKeyEvent || (me.lastKeyEvent = new Ext.EventObjectImpl())).setEvent(e.browserEvent);
view.bufferedRenderer.scrollTo(newIdx, false, me.afterBufferedScrollTo, me);
} else {
newRecord = view.walkRecs(e.record, -rowsVisible);
me.afterKeyNavigate(e, newRecord);
}
}
},
// Go one page down from the lastFocused record in the grid.
onKeyPageDown: function(e) {
var me = this,
view = me.views[0],
rowsVisible = me.getRowsVisible(),
newIdx,
newRecord;
if (rowsVisible) {
// If rendering is buffered, we cannot just increment the row - the row may not be there
// We have to ask the BufferedRenderer to navigate to the target.
// And that may involve asynchronous I/O, so must postprocess in a callback.
if (view.bufferedRenderer) {
newIdx = Math.min(e.recordIndex + rowsVisible, me.store.getCount() - 1);
(me.lastKeyEvent || (me.lastKeyEvent = new Ext.EventObjectImpl())).setEvent(e.browserEvent);
view.bufferedRenderer.scrollTo(newIdx, false, me.afterBufferedScrollTo, me);
} else {
newRecord = view.walkRecs(e.record, rowsVisible);
me.afterKeyNavigate(e, newRecord);
}
}
},
// Select/Deselect based on pressing Spacebar.
onKeySpace: function(e) {
var record = this.lastFocused;
if (record) {
this.afterKeyNavigate(e, record);
}
},
onKeyEnter: Ext.emptyFn,
// Navigate one record up. This could be a selection or
// could be simply focusing a record for discontiguous
// selection. Provides bounds checking.
onKeyUp: function(e) {
var newRecord = this.views[0].walkRecs(e.record, -1);
if (newRecord) {
this.afterKeyNavigate(e, newRecord);
}
},
// Navigate one record down. This could be a selection or
// could be simply focusing a record for discontiguous
// selection. Provides bounds checking.
onKeyDown: function(e) {
// If we are in the middle of an animated node expand, jump to next sibling.
// The first child record is in a temp animation DIV and will be removed, so will blur.
var newRecord = e.record.isExpandingOrCollapsing ? null : this.views[0].walkRecs(e.record, 1);
if (newRecord) {
this.afterKeyNavigate(e, newRecord);
}
},
afterBufferedScrollTo: function(newIdx, newRecord) {
this.afterKeyNavigate(this.lastKeyEvent, newRecord)
},
scrollByDeltaX: function(delta) {
var view = this.views[0],
section = view.up(),
hScroll = section.horizontalScroller;
if (hScroll) {
hScroll.scrollByDeltaX(delta);
}
},
onKeyLeft: function(e) {
this.scrollByDeltaX(-this.deltaScroll);
},
onKeyRight: function(e) {
this.scrollByDeltaX(this.deltaScroll);
},
// Select the record with the event included so that
// we can take into account ctrlKey, shiftKey, etc
onRowClick: function(view, record, item, index, e) {
var me = this;
// Record index will be -1 if the clicked record is a metadata record and not selectable
if (index !== -1) {
if (!me.allowRightMouseSelection(e)) {
return;
}
me.processSelection(view, record, item, index, e);
}
},
// May be overridden by a subclass to process a click in different ways
processSelection: function(view, record, item, index, e) {
this.selectWithEvent(record, e);
},
/**
* Checks whether a selection should proceed based on the ignoreRightMouseSelection
* option.
* @private
* @param {Ext.EventObject} e The event
* @return {Boolean} False if the selection should not proceed
*/
allowRightMouseSelection: function(e) {
var disallow = this.ignoreRightMouseSelection && e.button !== 0;
if (disallow) {
disallow = this.hasSelection();
}
return !disallow;
},
// Allow the GridView to update the UI by
// adding/removing a CSS class from the row.
onSelectChange: function(record, isSelected, suppressEvent, commitFn) {
var me = this,
views = me.views,
viewsLn = views.length,
rowIdx = views[0].indexOf(record),
eventName = isSelected ? 'select' : 'deselect',
i = 0;
if ((suppressEvent || me.fireEvent('before' + eventName, me, record, rowIdx)) !== false &&
commitFn() !== false) {
for (; i < viewsLn; i++) {
if (isSelected) {
views[i].onRowSelect(rowIdx, suppressEvent);
} else {
views[i].onRowDeselect(rowIdx, suppressEvent);
}
}
if (!suppressEvent) {
me.fireEvent(eventName, me, record, rowIdx);
}
}
},
// Provide indication of what row was last focused via
// the gridview.
onLastFocusChanged: function(oldFocused, newFocused, supressFocus) {
var views = this.views,
viewsLn = views.length,
rowIdx,
i = 0;
if (oldFocused) {
rowIdx = views[0].indexOf(oldFocused);
if (rowIdx != -1) {
for (; i < viewsLn; i++) {
views[i].onRowFocus(rowIdx, false, true);
}
}
}
if (newFocused) {
rowIdx = views[0].indexOf(newFocused);
if (rowIdx != -1) {
for (i = 0; i < viewsLn; i++) {
views[i].onRowFocus(rowIdx, true, supressFocus);
}
}
}
this.callParent(arguments);
},
onEditorTab: function(editingPlugin, e) {
var me = this,
view = editingPlugin.context.view,
record = editingPlugin.getActiveRecord(),
header = editingPlugin.getActiveColumn(),
position = view.getPosition(record, header),
direction = e.shiftKey ? 'left' : 'right',
lastPos;
// We want to continue looping while:
// 1) We have a valid position
// 2) There is no editor at that position
// 3) There is an editor, but editing has been cancelled (veto event)
do {
lastPos = position;
position = view.walkCells(position, direction, e, me.preventWrap);
if (lastPos && lastPos.isEqual(position)) {
// If we end up with the same result twice, it means that we weren't able to progress
// via walkCells, for example if the remaining records are non-record rows, so gracefully
// fall out here.
return;
}
} while (position && (!position.columnHeader.getEditor(record) || !editingPlugin.startEditByPosition(position)));
},
/**
* Returns position of the first selected cell in the selection in the format {row: row, column: column}
*/
getCurrentPosition: function() {
var firstSelection = this.selected.getAt(0);
if (firstSelection) {
return new Ext.grid.CellContext(this.view).setPosition(this.store.indexOf(firstSelection), 0);
}
},
selectByPosition: function(position) {
var context = new Ext.grid.CellContext(this.view);
context.setPosition(position.row, position.column);
this.select(context.record);
},
/**
* Selects the record immediately following the currently selected record.
* @param {Boolean} [keepExisting] True to retain existing selections
* @param {Boolean} [suppressEvent] Set to false to not fire a select event
* @return {Boolean} `true` if there is a next record, else `false`
*/
selectNext: function(keepExisting, suppressEvent) {
var me = this,
store = me.store,
selection = me.getSelection(),
record = selection[selection.length - 1],
index = me.views[0].indexOf(record) + 1,
success;
if (index === store.getCount() || index === 0) {
success = false;
} else {
me.doSelect(index, keepExisting, suppressEvent);
success = true;
}
return success;
},
/**
* Selects the record that precedes the currently selected record.
* @param {Boolean} [keepExisting] True to retain existing selections
* @param {Boolean} [suppressEvent] Set to false to not fire a select event
* @return {Boolean} `true` if there is a previous record, else `false`
*/
selectPrevious: function(keepExisting, suppressEvent) {
var me = this,
selection = me.getSelection(),
record = selection[0],
index = me.views[0].indexOf(record) - 1,
success;
if (index < 0) {
success = false;
} else {
me.doSelect(index, keepExisting, suppressEvent);
success = true;
}
return success;
},
isRowSelected: function(record) {
return this.isSelected(record);
},
isCellSelected: function(view, record, columnHeader) {
return this.isSelected(record);
}
});