slickgrid
Version:
A lightning fast JavaScript grid/spreadsheet
456 lines (407 loc) • 18.1 kB
text/typescript
import type { CheckboxSelectorOption, Column, DOMEvent, SlickPlugin, SelectableOverrideCallback, OnHeaderClickEventArgs } from '../models/index.js';
import { BindingEventService as BindingEventService_, type SlickEventData, SlickEventHandler as SlickEventHandler_, Utils as Utils_ } from '../slick.core.js';
import type { SlickDataView } from '../slick.dataview.js';
import type { SlickGrid } from '../slick.grid.js';
// for (iife) load Slick methods from global Slick object, or use imports for (esm)
const BindingEventService = IIFE_ONLY ? Slick.BindingEventService : BindingEventService_;
const SlickEventHandler = IIFE_ONLY ? Slick.EventHandler : SlickEventHandler_;
const Utils = IIFE_ONLY ? Slick.Utils : Utils_;
export class SlickCheckboxSelectColumn<T = any> implements SlickPlugin {
// --
// public API
pluginName = 'CheckboxSelectColumn' as const;
// --
// protected props
protected _dataView!: SlickDataView<T>;
protected _grid!: SlickGrid;
protected _isUsingDataView = false;
protected _selectableOverride: SelectableOverrideCallback<T> | null = null;
protected _headerRowNode?: HTMLElement;
protected _selectAll_UID: number;
protected _handler = new SlickEventHandler();
protected _selectedRowsLookup: any = {};
protected _checkboxColumnCellIndex: number | null = null;
protected _options: CheckboxSelectorOption;
protected _defaults: CheckboxSelectorOption = {
columnId: '_checkbox_selector',
cssClass: undefined,
hideSelectAllCheckbox: false,
name: '',
toolTip: 'Select/Deselect All',
width: 30,
reorderable: false,
applySelectOnAllPages: false, // defaults to false, when that is enabled the "Select All" will be applied to all pages (when using Pagination)
hideInColumnTitleRow: false,
hideInFilterHeaderRow: true
};
protected _isSelectAllChecked = false;
protected _bindingEventService: BindingEventService_;
constructor(options?: Partial<CheckboxSelectorOption>) {
this._bindingEventService = new BindingEventService();
this._options = Utils.extend(true, {}, this._defaults, options);
this._selectAll_UID = this.createUID();
// user could override the checkbox icon logic from within the options or after instantiating the plugin
if (typeof this._options.selectableOverride === 'function') {
this.selectableOverride(this._options.selectableOverride);
}
}
init(grid: SlickGrid) {
this._grid = grid;
this._isUsingDataView = !Array.isArray(grid.getData());
if (this._isUsingDataView) {
this._dataView = grid.getData();
}
this._handler
.subscribe(this._grid.onSelectedRowsChanged, this.handleSelectedRowsChanged.bind(this))
.subscribe(this._grid.onClick, this.handleClick.bind(this))
.subscribe(this._grid.onKeyDown, this.handleKeyDown.bind(this))
// whenever columns changed, we need to rerender Select All checkbox
.subscribe(this._grid.onAfterSetColumns, () => this.renderSelectAllCheckbox(this._isSelectAllChecked));
if (this._isUsingDataView && this._dataView && this._options.applySelectOnAllPages) {
this._handler
.subscribe(this._dataView.onSelectedRowIdsChanged, this.handleDataViewSelectedIdsChanged.bind(this))
.subscribe(this._dataView.onPagingInfoChanged, this.handleDataViewSelectedIdsChanged.bind(this));
}
if (!this._options.hideInFilterHeaderRow) {
this.addCheckboxToFilterHeaderRow(grid);
}
if (!this._options.hideInColumnTitleRow) {
this._handler.subscribe(this._grid.onHeaderClick, this.handleHeaderClick.bind(this));
}
}
destroy() {
this._handler.unsubscribeAll();
this._bindingEventService.unbindAll();
}
getOptions() {
return this._options;
}
setOptions(options: Partial<CheckboxSelectorOption>) {
this._options = Utils.extend(true, {}, this._options, options);
if (this._options.hideSelectAllCheckbox) {
this.hideSelectAllFromColumnHeaderTitleRow();
this.hideSelectAllFromColumnHeaderFilterRow();
} else {
if (!this._options.hideInColumnTitleRow) {
this.renderSelectAllCheckbox(this._isSelectAllChecked);
this._handler.subscribe(this._grid.onHeaderClick, this.handleHeaderClick.bind(this));
} else {
this.hideSelectAllFromColumnHeaderTitleRow();
if (this._options.name) {
this._grid.updateColumnHeader(this._options.columnId || '', this._options.name, '');
}
}
if (!this._options.hideInFilterHeaderRow) {
const selectAllContainerElm = this._headerRowNode?.querySelector<HTMLSpanElement>('#filter-checkbox-selectall-container');
if (selectAllContainerElm) {
selectAllContainerElm.style.display = 'flex';
const selectAllInputElm = selectAllContainerElm.querySelector<HTMLInputElement>('input[type="checkbox"]');
if (selectAllInputElm) {
selectAllInputElm.checked = this._isSelectAllChecked;
}
}
} else {
this.hideSelectAllFromColumnHeaderFilterRow();
}
}
}
protected hideSelectAllFromColumnHeaderTitleRow() {
this._grid.updateColumnHeader(this._options.columnId || '', this._options.name || '', '');
}
protected hideSelectAllFromColumnHeaderFilterRow() {
const selectAllContainerElm = this._headerRowNode?.querySelector<HTMLSpanElement>('#filter-checkbox-selectall-container');
if (selectAllContainerElm) {
selectAllContainerElm.style.display = 'none';
}
}
protected handleSelectedRowsChanged() {
const selectedRows = this._grid.getSelectedRows();
const lookup: any = {};
let row = 0, i = 0, k = 0;
let disabledCount = 0;
if (typeof this._selectableOverride === 'function') {
for (k = 0; k < this._grid.getDataLength(); k++) {
// If we are allowed to select the row
const dataItem = this._grid.getDataItem(k);
if (!this.checkSelectableOverride(i, dataItem, this._grid)) {
disabledCount++;
}
}
}
const removeList: number[] = [];
for (i = 0; i < selectedRows.length; i++) {
row = selectedRows[i];
// If we are allowed to select the row
const rowItem = this._grid.getDataItem(row);
if (this.checkSelectableOverride(i, rowItem, this._grid)) {
lookup[row] = true;
if (lookup[row] !== this._selectedRowsLookup[row]) {
this._grid.invalidateRow(row);
delete this._selectedRowsLookup[row];
}
}
else {
removeList.push(row);
}
}
if (typeof this._selectedRowsLookup === 'object') {
Object.keys(this._selectedRowsLookup).forEach(selectedRow => {
if (selectedRow !== undefined) {
this._grid.invalidateRow(+selectedRow);
}
});
}
this._selectedRowsLookup = lookup;
this._grid.render();
this._isSelectAllChecked = (selectedRows?.length ?? 0) + disabledCount >= this._grid.getDataLength();
if (!this._isUsingDataView || !this._options.applySelectOnAllPages) {
if (!this._options.hideInColumnTitleRow && !this._options.hideSelectAllCheckbox) {
this.renderSelectAllCheckbox(this._isSelectAllChecked);
}
if (!this._options.hideInFilterHeaderRow) {
const selectAllElm = this._headerRowNode?.querySelector<HTMLInputElement>(`#header-filter-selector${this._selectAll_UID}`);
if (selectAllElm) {
selectAllElm.checked = this._isSelectAllChecked;
}
}
}
// Remove items that shouln't of been selected in the first place (Got here Ctrl + click)
if (removeList.length > 0) {
for (i = 0; i < removeList.length; i++) {
const remIdx = selectedRows.indexOf(removeList[i]);
selectedRows.splice(remIdx, 1);
}
this._grid.setSelectedRows(selectedRows, 'click.cleanup');
}
}
protected handleDataViewSelectedIdsChanged() {
const selectedIds = this._dataView.getAllSelectedFilteredIds();
const filteredItems = this._dataView.getFilteredItems();
let disabledCount = 0;
if (typeof this._selectableOverride === 'function' && selectedIds.length > 0) {
for (let k = 0; k < this._dataView.getItemCount(); k++) {
// If we are allowed to select the row
const dataItem: T = this._dataView.getItemByIdx(k);
const idProperty = this._dataView.getIdPropertyName();
const dataItemId = dataItem[idProperty as keyof T];
const foundItemIdx = filteredItems.findIndex(function (item) {
return item[idProperty as keyof T] === dataItemId;
});
if (foundItemIdx >= 0 && !this.checkSelectableOverride(k, dataItem, this._grid)) {
disabledCount++;
}
}
}
this._isSelectAllChecked = (selectedIds && selectedIds.length) + disabledCount >= filteredItems.length;
if (!this._options.hideInColumnTitleRow && !this._options.hideSelectAllCheckbox) {
this.renderSelectAllCheckbox(this._isSelectAllChecked);
}
if (!this._options.hideInFilterHeaderRow) {
const selectAllElm = this._headerRowNode?.querySelector<HTMLInputElement>(`#header-filter-selector${this._selectAll_UID}`);
if (selectAllElm) {
selectAllElm.checked = this._isSelectAllChecked;
}
}
}
protected handleKeyDown(e: SlickEventData, args: any) {
if (e.which === 32) {
if (this._grid.getColumns()[args.cell].id === this._options.columnId) {
// if editing, try to commit
if (!this._grid.getEditorLock().isActive() || this._grid.getEditorLock().commitCurrentEdit()) {
this.toggleRowSelection(args.row);
}
e.preventDefault();
e.stopImmediatePropagation();
}
}
}
protected handleClick(e: SlickEventData, args: { row: number; cell: number; }) {
// clicking on a row select checkbox
if (this._grid.getColumns()[args.cell].id === this._options.columnId && (e.target as HTMLInputElement).type === 'checkbox') {
// if editing, try to commit
if (this._grid.getEditorLock().isActive() && !this._grid.getEditorLock().commitCurrentEdit()) {
e.preventDefault();
e.stopImmediatePropagation();
return;
}
this.toggleRowSelection(args.row);
e.stopPropagation();
e.stopImmediatePropagation();
}
}
protected toggleRowSelection(row: number) {
const dataContext = this._grid.getDataItem(row);
if (!this.checkSelectableOverride(row, dataContext, this._grid)) {
return;
}
if (this._selectedRowsLookup[row]) {
const newSelectedRows = this._grid.getSelectedRows().filter((n) => n !== row);
this._grid.setSelectedRows(newSelectedRows, 'click.toggle');
} else {
this._grid.setSelectedRows(this._grid.getSelectedRows().concat(row), 'click.toggle');
}
this._grid.setActiveCell(row, this.getCheckboxColumnCellIndex());
}
selectRows(rowArray: number[]) {
const addRows: number[] = [];
for (let i = 0, l = rowArray.length; i < l; i++) {
if (!this._selectedRowsLookup[rowArray[i]]) {
addRows[addRows.length] = rowArray[i];
}
}
this._grid.setSelectedRows(this._grid.getSelectedRows().concat(addRows), 'SlickCheckboxSelectColumn.selectRows');
}
deSelectRows(rowArray: number[]) {
const removeRows: number[] = [];
for (let i = 0, l = rowArray.length; i < l; i++) {
if (this._selectedRowsLookup[rowArray[i]]) {
removeRows[removeRows.length] = rowArray[i];
}
}
this._grid.setSelectedRows(this._grid.getSelectedRows().filter((n) => removeRows.indexOf(n) < 0), 'SlickCheckboxSelectColumn.deSelectRows');
}
protected handleHeaderClick(e: DOMEvent<HTMLInputElement> | SlickEventData, args: OnHeaderClickEventArgs) {
if (args.column.id === this._options.columnId && (e.target as HTMLInputElement).type === 'checkbox') {
// if editing, try to commit
if (this._grid.getEditorLock().isActive() && !this._grid.getEditorLock().commitCurrentEdit()) {
e.preventDefault();
e.stopImmediatePropagation();
return;
}
let isAllSelected = (e.target as HTMLInputElement).checked;
const caller = isAllSelected ? 'click.selectAll' : 'click.unselectAll';
const rows: number[] = [];
if (isAllSelected) {
for (let i = 0; i < this._grid.getDataLength(); i++) {
// Get the row and check it's a selectable row before pushing it onto the stack
const rowItem = this._grid.getDataItem(i);
if (!rowItem.__group && !rowItem.__groupTotals && this.checkSelectableOverride(i, rowItem, this._grid)) {
rows.push(i);
}
}
isAllSelected = true;
}
if (this._isUsingDataView && this._dataView && this._options.applySelectOnAllPages) {
const ids: Array<number | string> = [];
const filteredItems = this._dataView.getFilteredItems();
for (let j = 0; j < filteredItems.length; j++) {
// Get the row and check it's a selectable ID (it could be in a different page) before pushing it onto the stack
const dataviewRowItem: T = filteredItems[j];
if (this.checkSelectableOverride(j, dataviewRowItem, this._grid)) {
ids.push(dataviewRowItem[this._dataView.getIdPropertyName() as keyof T] as number | string);
}
}
this._dataView.setSelectedIds(ids, { isRowBeingAdded: isAllSelected });
}
this._grid.setSelectedRows(rows, caller);
e.stopPropagation();
e.stopImmediatePropagation();
}
}
protected getCheckboxColumnCellIndex() {
if (this._checkboxColumnCellIndex === null) {
this._checkboxColumnCellIndex = 0;
const colArr = this._grid.getColumns();
for (let i = 0; i < colArr.length; i++) {
if (colArr[i].id === this._options.columnId) {
this._checkboxColumnCellIndex = i;
}
}
}
return this._checkboxColumnCellIndex;
}
/**
* use a DocumentFragment to return a fragment including an <input> then a <label> as siblings,
* the label is using `for` to link it to the input `id`
* @param {String} inputId - id to link the label
* @param {Boolean} [checked] - is the input checkbox checked? (defaults to false)
* @returns
*/
createCheckboxElement(inputId: string, checked = false) {
const fragmentElm = new DocumentFragment();
fragmentElm.appendChild(
Utils.createDomElement('input', { id: inputId, type: 'checkbox', checked, ariaChecked: String(checked) })
);
fragmentElm.appendChild(
Utils.createDomElement('label', { htmlFor: inputId })
);
return fragmentElm;
}
getColumnDefinition() {
return {
id: this._options.columnId,
reorderable: this._options.reorderable,
name: (this._options.hideSelectAllCheckbox || this._options.hideInColumnTitleRow)
? this._options.name || ''
: this.createCheckboxElement(`header-selector${this._selectAll_UID}`),
toolTip: (this._options.hideSelectAllCheckbox || this._options.hideInColumnTitleRow) ? '' : this._options.toolTip,
field: 'sel',
width: this._options.width,
resizable: false,
sortable: false,
cssClass: this._options.cssClass,
hideSelectAllCheckbox: this._options.hideSelectAllCheckbox,
formatter: this.checkboxSelectionFormatter.bind(this),
// exclude from all menus, defaults to true unless the option is provided differently by the user
excludeFromColumnPicker: this._options.excludeFromColumnPicker ?? true,
excludeFromGridMenu: this._options.excludeFromGridMenu ?? true,
excludeFromHeaderMenu: this._options.excludeFromHeaderMenu ?? true,
};
}
protected addCheckboxToFilterHeaderRow(grid: SlickGrid) {
this._handler.subscribe(grid.onHeaderRowCellRendered, (_e, args) => {
if (args.column.field === 'sel') {
Utils.emptyElement(args.node);
const spanElm = Utils.createDomElement('span', { id: 'filter-checkbox-selectall-container', ariaChecked: 'false' });
spanElm.appendChild(
Utils.createDomElement('input', { type: 'checkbox', id: `header-filter-selector${this._selectAll_UID}` })
);
spanElm.appendChild(
Utils.createDomElement('label', { htmlFor: `header-filter-selector${this._selectAll_UID}` })
);
args.node.appendChild(spanElm);
this._headerRowNode = args.node;
this._bindingEventService.bind(spanElm, 'click', ((e: DOMEvent<HTMLInputElement>) => this.handleHeaderClick(e, args)) as EventListener);
}
});
}
protected createUID() {
return Math.round(10000000 * Math.random());
}
protected checkboxSelectionFormatter(row: number, _cell: number, _val: any, _columnDef: Column, dataContext: any, grid: SlickGrid) {
if (dataContext && this.checkSelectableOverride(row, dataContext, grid)) {
const UID = this.createUID() + row;
return this.createCheckboxElement(`selector${UID}`, !!this._selectedRowsLookup[row]);
}
return null;
}
protected checkSelectableOverride(row: number, dataContext: any, grid: SlickGrid) {
if (typeof this._selectableOverride === 'function') {
return this._selectableOverride(row, dataContext, grid);
}
return true;
}
protected renderSelectAllCheckbox(isSelectAllChecked?: boolean) {
this._grid.updateColumnHeader(
this._options.columnId || '',
this.createCheckboxElement(`header-selector${this._selectAll_UID}`, !!isSelectAllChecked),
this._options.toolTip
);
}
/**
* Method that user can pass to override the default behavior or making every row a selectable row.
* In order word, user can choose which rows to be selectable or not by providing his own logic.
* @param overrideFn: override function callback
*/
selectableOverride(overrideFn: SelectableOverrideCallback<T>) {
this._selectableOverride = overrideFn;
}
}
// extend Slick namespace on window object when building as iife
if (IIFE_ONLY && window.Slick) {
Utils.extend(true, window, {
Slick: {
CheckboxSelectColumn: SlickCheckboxSelectColumn
}
});
}