I am attempting to implement shift click functionality on a sorted MatDataTable using angular and typescript.
The basic run down is that whenever a click event is registered on the table, the row that is selected is stored.
If a shift click is detected, the component will attempt to select between the last selected row, and the currently selected row (Just like shift click selection works in Windows).
The event handling code I have is as follows:
clickHandler(event, row, index) {
console.log('index clicked: ' + index);
if (event.ctrlKey) {
this.selectRows(row, index); // This records this.lastSelected
} else if (event.shiftKey) {
this.selectRowsFill(row, index);
} else {
this.selectElement(row, index); // As does this call.
}
}
// This function computes a start and end point for autoselection of
// rows between this.lastSelected, and the currently clicked row
selectRowsFill(row, index) {
const indexA = this.lastSelected;
const indexB = index;
if (indexA > indexB) {
// Descending order
this.selectRowsBetween(indexB, indexA);
} else {
// Ascending order
this.selectRowsBetween(indexA, indexB);
}
}
// And this performs the actual selection.
private selectRowsBetween(start, end) {
let currentIndex = 0;
this.dataSource.data.forEach(row => {
if (currentIndex >= start && currentIndex <= end) {
this.selection.select(row);
}
currentIndex++;
});
}
And the HTML:
<mat-row *matRowDef="let row; let i = index; columns: cols;" (click)="clickHandler($event, row, i)" [ngClass]="{'inDatabase' : isAdded(row), 'highlight': isSelectedAndAdded(row) || isSelected(row) }">
This code works fine, so long as the table is not sorted. As soon as I apply a sorting algorithm to the MatTableDataSource, it changes the order of the data, causing the selection to malfunction. It looks like the selection is based on the original (unsorted)order of the data in the MatTableDataSource, which makes sense.
So how do I get the shift click selection to work on the sorted data, rather than the unsorted data?
Here is my solution: >STACKBLITZ<
<table [shiftClickSource]="dataSource"
[shiftClickSelectModel]="selection"
[shiftClickSourceId]="['position']"
mat-table [dataSource]="dataSource" class="mat-elevation-z8" matSort>
....
import { Directive, Input, HostListener, OnInit, OnDestroy, ElementRef } from '@angular/core';
import {SelectionModel, SelectionChange} from '@angular/cdk/collections';
import { Subject, BehaviorSubject, Observable, merge, pipe, } from 'rxjs';
import { shareReplay, takeUntil, withLatestFrom, tap } from 'rxjs/operators';
import {MatTable, MatTableDataSource} from '@angular/material/table';
/**
* Directive that adds shift-click selection to your mat-table.
* It needs the datasource from the host, the selectionModel of the checkboxes
* and optionally an array of [ids] to find the correct rows in the
* (possibly sorted/ filtered) source array.
*/
@Directive({
selector: '[shiftClickSource]'
})
export class ShiftClickDirective implements OnInit, OnDestroy {
finishHim = new Subject<void>();
lastItem: any;
lastShiftSelection: any[];
alwaysTheRightSourceArray$: Observable<any>;
// the always right source array :-)
source: any[];
shiftHolding$ = new BehaviorSubject<boolean>(false);
//click that happens on tbody of mat-table
click$ = new Subject<void>();
// observable to record the last change of the checkbox selectionModel
recordSelectionChange$: Observable<SelectionChange<any>>;
@HostListener('document:keydown.shift', ['$event'])
shiftDown(_) {
this.shiftHolding$.next(true);
}
@HostListener('document:keyup.shift', ['$event'])
shiftUp(event: KeyboardEvent) {
this.shiftHolding$.next(false);
}
// datasource that is used on the Checkbox-Table
@Input('shiftClickSource') dataSource: MatTableDataSource<any>;
// Is optional, if id col is known this will improve performance.
@Input('shiftClickSourceId') idArr: string[];
@Input('shiftClickSelectModel') selection: SelectionModel<any>;
constructor(private host: ElementRef) {}
ngOnInit() {
// data can change order (sorting) and can be reduced (filtering)
this.alwaysTheRightSourceArray$ = this.dataSource.connect()
.pipe(
tap(_ => {
this.source = this.dataSource
.sortData(
this.dataSource.filteredData,
this.dataSource.sort
);
})
);
// lets record the last change of
this.recordSelectionChange$ = this.selection.changed.pipe(
shareReplay(1)
)
// clicks on tbody mean that we need to do something
this.host.nativeElement.childNodes[1].addEventListener("click", function() {
this.click$.next();
}.bind(this));
const reactOnClickOnTbody$ =
this.click$.pipe(
withLatestFrom(this.shiftHolding$.asObservable()),
withLatestFrom(this.recordSelectionChange$, (arr, c): [SelectionChange<any>, boolean ] => [c, arr[1]] ),
tap( arr => {
const v = arr[0];
const sh = arr[1];
const ans = [...v.added, ...v.removed][0];
if ( sh && this.lastItem ) {
this.onTbodyClicked(this.lastItem, ans);
} else {
this.lastItem = ans;
this.lastShiftSelection = undefined;
// console.log('clear');
}
}),
);
merge(
reactOnClickOnTbody$,
this.alwaysTheRightSourceArray$
).pipe(takeUntil(this.finishHim.asObservable()))
.subscribe();
}
// This function does all the real work.
onTbodyClicked(from: object, to: object) {
// console.log('onTbodyClickedRuns');
const fIdx = this.getIndexBasedOnData(from);
const tIdx = this.getIndexBasedOnData(to);
let ans;
let ans_last;
if( fIdx > tIdx ) {
ans = this.source.slice(tIdx, fIdx);
} else {
// console.log('seconds index case');
ans = this.source.slice(fIdx +1 , tIdx + 1);
}
if (this.lastShiftSelection) {
this.selection['_emitChanges']=false;
this.selection.deselect(...this.lastShiftSelection);
this.selection['_emitChanges']=true;
}
this.lastShiftSelection = [...ans];
if( fIdx > tIdx ) {
ans_last = ans.shift();
} else {
ans_last = ans.pop();
}
// console.log(this.lastShiftSelection);
const cond = ans.every(el => this.selection.isSelected(el)) && !this.selection.isSelected(ans_last)
if ( cond ) {
// console.log('deselect')
this.selection['_emitChanges']=false;
this.selection.deselect(...ans);
this.selection['_emitChanges']=true;
} else {
// console.log('select')
this.selection['_emitChanges']=false;
this.selection.select(...ans, ans_last);
this.selection['_emitChanges']=true;
}
}
// helper function
private getIndexBasedOnData(row, source = this.source): number {
let ind: number;
if (this.idArr) {
ind = source.findIndex( _d => this.idArr.every(id => _d[id] === row[id] ));
} else {
ind = source.findIndex( _d => Object.keys(row).every(k => _d[k] === row[k] ));
}
return ind;
}
ngOnDestroy() {
this.finishHim.next();
this.finishHim.complete();
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With