Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Angular Mat table and shift click selection

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?

like image 625
Ian Young Avatar asked Oct 29 '25 03:10

Ian Young


1 Answers

Here is my solution: >STACKBLITZ<

  • Material Checkbox Table (docs)
  • github reference

CODE SAVE IN CASE STACKBLITZ IS OFFLINE

  • html
<table [shiftClickSource]="dataSource"
       [shiftClickSelectModel]="selection"
       [shiftClickSourceId]="['position']"
       mat-table [dataSource]="dataSource" class="mat-elevation-z8" matSort>

....
  • ts (directive)
    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();
      }

    }
like image 80
Andre Elrico Avatar answered Oct 31 '25 16:10

Andre Elrico