/**
* A Column subclass which renders a checkbox in each column cell which toggles the truthiness
* of the associated data field on click.
*
* Example usage:
*
* @example
* var store = Ext.create('Ext.data.Store', {
* fields: ['name', 'email', 'phone', 'active'],
* data: [
* { name: 'Lisa', email: 'lisa@simpsons.com', phone: '555-111-1224', active: true },
* { name: 'Bart', email: 'bart@simpsons.com', phone: '555-222-1234', active: true },
* { name: 'Homer', email: 'homer@simpsons.com', phone: '555-222-1244', active: false },
* { name: 'Marge', email: 'marge@simpsons.com', phone: '555-222-1254', active: true }
* ]
* });
*
* Ext.create('Ext.grid.Panel', {
* title: 'Simpsons',
* height: 200,
* width: 400,
* renderTo: Ext.getBody(),
* store: store,
* columns: [
* { text: 'Name', dataIndex: 'name' },
* { text: 'Email', dataIndex: 'email', flex: 1 },
* { text: 'Phone', dataIndex: 'phone' },
* { xtype: 'checkcolumn', text: 'Active', dataIndex: 'active' }
* ]
* });
*
* The check column can be at any index in the columns array.
*/
Ext.define('Ext.grid.column.Check', {
extend: 'Ext.grid.column.Column',
alternateClassName: ['Ext.ux.CheckColumn', 'Ext.grid.column.CheckColumn'],
alias: 'widget.checkcolumn',
/**
* @property {Boolean} isCheckColumn
* `true` in this class to identify an object as an instantiated Check column,
* or subclass thereof.
*/
isCheckColumn: true,
config: {
/**
* @cfg {Boolean} [headerCheckbox=false]
* Configure as `true` to display a checkbox below the header text.
*
* Clicking the checkbox will check/uncheck all records.
*/
headerCheckbox: false
},
/**
* @cfg
* @hide
* Overridden from base class. Must center to line up with editor.
*/
align: 'center',
/**
* @cfg {String} [triggerEvent=click]
* The mouse event which triggers the toggle of a single cell.
*/
triggerEvent: 'click',
/**
* @cfg {Boolean} invert
* Use `true` to display a check when the value is `false` instead of when the value
* is `true`.
*/
invert: false,
/**
* @cfg {String} tooltip
* The tooltip text to show upon hover of a unchecked cell.
*/
/**
* @cfg {String} checkedTooltip
* The tooltip text to show upon hover of an checked cell.
*/
ignoreExport: true,
/**
* @cfg {Boolean} [stopSelection=true]
* Prevent grid selection upon mousedown.
*/
stopSelection: true,
/**
* @private
*/
headerCheckedCls: Ext.baseCSSPrefix + 'grid-hd-checker-on',
/**
* @private
* The CSS class used to style and select the header checkbox.
*/
headerCheckboxCls: Ext.baseCSSPrefix + 'column-header-checkbox',
checkboxCls: Ext.baseCSSPrefix + 'grid-checkcolumn',
checkboxCheckedCls: Ext.baseCSSPrefix + 'grid-checkcolumn-checked',
innerCls: Ext.baseCSSPrefix + 'grid-checkcolumn-cell-inner',
clickTargetName: 'el',
defaultFilterType: 'boolean',
checkboxAriaRole: 'checkbox',
/**
* @event beforecheckchange
* Fires when the UI requests a change of check status.
* The change may be vetoed by returning `false` from a listener.
* @param {Ext.grid.column.Check} this CheckColumn.
* @param {Number} rowIndex The row index.
* @param {Boolean} checked `true` if the box is to be checked.
* @param {Ext.data.Model} record The record to be updated.
* @param {Ext.event.Event} e The underlying event which caused the check change.
* @param {Ext.grid.CellContext} e.position A {@link Ext.grid.CellContext CellContext} object
* containing all contextual information about where the event was triggered.
*/
/**
* @event checkchange
* Fires when the UI has successfully changed the checked state of a row.
* @param {Ext.grid.column.Check} this CheckColumn.
* @param {Number} rowIndex The row index.
* @param {Boolean} checked `true` if the box is now checked.
* @param {Ext.data.Model} record The record which was updated.
* @param {Ext.event.Event} e The underlying event which caused the check change.
* @param {Ext.grid.CellContext} e.position A {@link Ext.grid.CellContext CellContext} object
*/
/**
* @event beforeheadercheckchange
* Fires when the header is clicked and before the mass check/uncheck takes place.
* The change may be vetoed by returning `false` from a listener.
* @param {Ext.grid.column.Check} this CheckColumn.
* @param {Boolean} checked `true` if all boxes are to be checked.
* @param {Ext.event.Event} e The underlying event which caused the check change.
*/
/**
* @event headercheckchange
* Fires after the header is clicked and a mass check/uncheck operation has been completed.
* @param {Ext.grid.column.Check} this CheckColumn.
* @param {Boolean} checked `true` if all boxes are now checked.
* @param {Ext.event.Event} e The underlying event which caused the check change.
*/
constructor: function(config) {
this.scope = this;
this.callParent([config]);
},
afterComponentLayout: function() {
var me = this;
me.callParent(arguments);
if (me.useAriaElements && me.headerCheckbox) {
me.updateHeaderAriaDescription(me.areAllChecked());
}
// Only do this once
if (!me.storeListeners) {
// Ensure initial rendered state is correct.
// This will update the header state on the next animation frame
// after all rows have been rendered.
me.updateHeaderState();
// We need to listen to data changed. This includes add and remove as well as reload.
// We cannot rely on the renderer or updater to kick off an updateHeaderState call
// because buffered rendering may mean that the UI does not process the entire dataset.
me.storeListeners = me.getView().dataSource.on({
datachanged: me.onDataChanged,
scope: me,
destroyable: true
});
}
},
onRemoved: function() {
this.callParent(arguments);
this.storeListeners = Ext.destroy(this.storeListeners);
},
onDataChanged: function(store, records) {
// If any records are added or removed, we need up to date the header state.
this.updateHeaderState();
},
updateHeaderCheckbox: function(headerCheckbox) {
var me = this,
cls = Ext.baseCSSPrefix + 'column-header-checkbox';
if (headerCheckbox) {
me.addCls(cls);
// So that SPACE/ENTER does not sort, but routes to the checkbox
me.sortable = false;
if (me.useAriaElements) {
me.updateHeaderAriaDescription(me.areAllChecked());
}
}
else {
me.removeCls(cls);
if (me.useAriaElements && me.ariaEl.dom) {
me.ariaEl.dom.removeAttribute('aria-describedby');
}
}
// Keep the header checkbox up to date
me.updateHeaderState();
},
/**
* @private
* Process and refire events routed from the GridView's processEvent method.
*/
processEvent: function(type, view, cell, recordIndex, cellIndex, e, record, row) {
var me = this,
key = type === 'keydown' && e.getKey(),
isClick = type === me.triggerEvent,
disabled = me.disabled,
ret,
checked;
// Flag event to tell SelectionModel not to process it.
e.stopSelection = !key && me.stopSelection;
if (!disabled && (isClick || (key === e.ENTER || key === e.SPACE))) {
checked = !me.isRecordChecked(record);
// Allow apps to hook beforecheckchange
if (me.fireEvent('beforecheckchange', me, recordIndex, checked, record, e) !== false) {
me.setRecordCheck(record, recordIndex, checked, cell, e);
// Do not allow focus to follow from this mousedown unless the grid
// is already in actionable mode
if (isClick && !view.actionableMode) {
e.preventDefault();
}
if (me.hasListeners.checkchange) {
me.fireEvent('checkchange', me, recordIndex, checked, record, e);
}
}
}
else {
ret = me.callParent(arguments);
}
return ret;
},
onTitleElClick: function(e, t, sortOnClick) {
var me = this;
// Toggle if no text, or it's activated by SPACE key,
// or the click is on the checkbox element.
if (!me.disabled &&
(e.keyCode || !me.text || (Ext.fly(e.target).hasCls(me.headerCheckboxCls)))) {
me.toggleAll(e);
}
else {
return me.callParent([e, t, sortOnClick]);
}
},
toggleAll: function(e) {
var me = this,
view = me.getView(),
store = view.getStore(),
checked = !me.allChecked;
if (me.fireEvent('beforeheadercheckchange', me, checked, e) !== false) {
// Only create and maintain a CellContext if there are consumers
// in the form of event listeners. The event is a click on a
// column header and will have no position property.
if (me.hasListeners.checkchange || me.hasListeners.beforecheckchange) {
e.position = new Ext.grid.CellContext(view);
}
store.each(function(record, recordIndex) {
me.setRecordCheck(record, recordIndex, checked, view.getCell(record, me));
});
me.setHeaderStatus(checked, e);
me.fireEvent('headercheckchange', me, checked, e);
}
},
setHeaderStatus: function(checked, e) {
var me = this;
// Will fire initially due to allChecked being undefined and using !==
if (me.allChecked !== checked) {
me.allChecked = checked;
if (me.headerCheckbox) {
me[checked ? 'addCls' : 'removeCls'](me.headerCheckedCls);
if (me.useAriaElements) {
me.updateHeaderAriaDescription(checked);
}
}
}
},
updateHeaderState: function(e) {
var me = this;
if (!me.headerStateTimer) {
me.headerStateTimer = Ext.raf(me.doUpdateHeaderState, me);
}
},
doUpdateHeaderState: function(e) {
var me = this;
me.headerStateTimer = null;
// This is called on a timer, so ignore if it fires after destruction
if (!me.destroyed && me.headerCheckbox) {
me.setHeaderStatus(me.areAllChecked(), e);
}
},
/**
* Enables this CheckColumn.
*/
onEnable: function() {
this.callParent(arguments);
this._setDisabled(false);
},
/**
* Disables this CheckColumn.
*/
onDisable: function() {
this._setDisabled(true);
},
// Don't want to conflict with the Component method
_setDisabled: function(disabled) {
var me = this,
cls = me.disabledCls,
items;
items = me.up('tablepanel').el.select(me.getCellSelector());
if (disabled) {
items.addCls(cls);
}
else {
items.removeCls(cls);
}
},
defaultRenderer: function(value, cellValues) {
var me = this,
cls = me.checkboxCls,
tip = '',
ariaElAttributes = {},
ariaRenderConfigs = "",
ariaAttr,
ariaLabelledBy,
ariaDescribedBy,
spanElId;
if (me.invert) {
value = !value;
}
if (me.disabled) {
cellValues.tdCls += ' ' + me.disabledCls;
}
if (value) {
cls += ' ' + me.checkboxCheckedCls;
tip = me.checkedTooltip;
}
else {
tip = me.tooltip;
}
if (tip) {
cellValues.tdAttr += ' data-qtip="' + Ext.htmlEncode(tip) + '"';
}
spanElId = me.id + '-spanEl-' + cellValues.rowIndex + cellValues.columnIndex;
// User can change the state of checkbox (span element) by clicking anywhere in cell. Hence
// making span as an active descendant to cell to guide the screenreader while focusing.
cellValues.tdAttr += 'aria-activedescendant="' + spanElId + '"';
if (me.useAriaElements) {
// Selection column cannot have other aria attributes as it will
// hinder the row selection announcement which we have binded
// through aria-describedby attribute below
ariaElAttributes["aria-describedby"] = me.id + '-cell-description' +
(!value ? '-not' : '') + '-selected';
ariaElAttributes['aria-rowindex'] = Ext.Number.from(cellValues.rowIndex, 0) + 1;
}
else {
ariaLabelledBy = me.getAriaLabelEl(me.ariaLabelledBy);
ariaDescribedBy = me.getAriaLabelEl(me.ariaDescribedBy);
if (ariaLabelledBy) {
ariaElAttributes["aria-labelledby"] = ariaLabelledBy;
}
else if (me.ariaLabel) {
ariaElAttributes["aria-label"] = me.ariaLabel || me.text;
}
if (ariaDescribedBy) {
ariaElAttributes["aria-describedby"] = ariaDescribedBy;
}
// No need to set aria-checked here if cell is already rendered
// We are handling it on 'setRecordCheck' method
if (!this.getView().getCell(cellValues.record, cellValues.column)) {
ariaElAttributes['aria-checked'] = !!value;
}
ariaElAttributes = Ext.apply(ariaElAttributes, me.getAriaAttributes());
}
for (ariaAttr in ariaElAttributes) {
ariaRenderConfigs += ariaAttr + '="' + ariaElAttributes[ariaAttr] + '"';
}
// This will update the header state on the next animation frame
// after all rows have been rendered.
me.updateHeaderState();
return '<span id="' + spanElId + '"' +
'class="' + cls + '" role="' + me.checkboxAriaRole + '" ' +
ariaRenderConfigs +
(!me.ariaStaticRoles[me.checkboxAriaRole] ? ' tabIndex="0"' : '') +
'></span>';
},
isRecordChecked: function(record) {
var prop = this.property;
if (prop) {
return record[prop];
}
return record.get(this.dataIndex);
},
areAllChecked: function() {
var me = this,
store = me.getView().getStore(),
records, len, i;
if (!store.isBufferedStore && store.getCount() > 0) {
records = store.getData().items;
len = records.length;
for (i = 0; i < len; ++i) {
if (!me.isRecordChecked(records[i])) {
return false;
}
}
return true;
}
},
setRecordCheck: function(record, recordIndex, checked, cell, e) {
var me = this,
prop = me.property,
checkEl;
// Only proceed if we NEED to change
// eslint-disable-next-line eqeqeq
if ((prop ? record[prop] : record.get(me.dataIndex)) != checked) {
if (prop) {
record[prop] = checked;
me.updater(cell, checked);
}
else {
record.set(me.dataIndex, checked);
}
checkEl = cell.querySelector('.' + me.checkboxCls);
if (checkEl) {
// On click, screenreader is announcing the state inconsistently when
// checkbox is accessed using mouse/cursor. Defer will force reader to
// announce both the preceding and the concluding state of the checkbox.
if (e && e.type === 'click') {
Ext.defer(function() {
checkEl.setAttribute('aria-checked', checked);
}, 500);
}
else {
checkEl.setAttribute('aria-checked', checked);
}
}
}
},
updater: function(cell, value) {
var me = this,
tip;
if (me.invert) {
value = !value;
}
if (value) {
tip = me.checkedTooltip;
}
else {
tip = me.tooltip;
}
if (tip) {
cell.setAttribute('data-qtip', tip);
}
else {
cell.removeAttribute('data-qtip');
}
if (me.useAriaElements) {
me.updateCellAriaDescription(null, value, cell);
}
cell = Ext.fly(cell);
cell[me.disabled ? 'addCls' : 'removeCls'](me.disabledCls);
// eslint-disable-next-line max-len
Ext.fly(cell.down(me.getView().innerSelector, true).firstChild)[value ? 'addCls' : 'removeCls'](Ext.baseCSSPrefix + 'grid-checkcolumn-checked');
// This will update the header state on the next animation frame
// after all rows have been updated.
me.updateHeaderState();
},
/**
* @private
*/
updateHeaderAriaDescription: function(isSelected) {
var me = this;
if (me.useAriaElements && me.ariaEl.dom) {
me.ariaEl.dom.setAttribute('aria-describedby', me.id + '-header-description' +
(!isSelected ? '-not' : '') + '-selected');
}
},
/**
* @private
*/
updateCellAriaDescription: function(record, isSelected, cell) {
var me = this,
cellSpanEl, ariaRowIdx, rowDescribedNode, rowDescribedText;
if (me.useAriaElements) {
cell = cell || me.getView().getCell(record, me);
if (cell) {
cellSpanEl = cell.querySelector('.' + me.checkboxCls);
if (cellSpanEl) {
rowDescribedNode = me.id + '-cell-description' +
(!isSelected ? '-not' : '') + '-selected';
ariaRowIdx = cellSpanEl.getAttribute('aria-rowindex');
rowDescribedText = !isSelected ? me.rowSelectText : me.rowDeselectText;
Ext.fly(rowDescribedNode).setText(
rowDescribedText.replace('{rowIdx}', ariaRowIdx)
);
cellSpanEl.setAttribute('aria-describedby', rowDescribedNode);
}
}
}
},
doDestroy: function() {
Ext.unraf(this.headerStateTimer);
this.callParent();
},
privates: {
/**
* A method called by the render template to allow extra content after the header text.
* Needs to be a seperate element to carry this. Cannot be a :after pseudo element
* on one of the textual elements because we need to filter the click target to this
* element for header checkbox clicking.
* @private
*/
afterText: function(out, values) {
var me = this,
id = me.id;
out.push('<span role="presentation" class="', me.headerCheckboxCls, '"></span>');
if (me.useAriaElements) {
out.push(
'<span id="' + id + '-header-description-selected" class="' +
Ext.baseCSSPrefix + 'hidden-offsets">' + me.headerDeselectText + '</span>' +
'<span id="' + id + '-header-description-not-selected" class="' +
Ext.baseCSSPrefix + 'hidden-offsets">' + me.headerSelectText + '</span>' +
'<span id="' + id + '-cell-description-selected" class="' +
Ext.baseCSSPrefix + 'hidden-offsets">' + me.rowDeselectText +
'</span>' +
'<span id="' + id + '-cell-description-not-selected" class="' +
Ext.baseCSSPrefix + 'hidden-offsets">' + me.rowSelectText +
'</span>'
);
}
}
}
});