I want to allow the users to select the area in angular table like excel sheets. Something like in the below image
I have achieved multi selection rows on click.
Stackblitz | multi selection rows
I am not sure how to proceed.
I found some similar plugins but not sure how to achieve that in Angular
Any help , suggestion is appreciated
You can create similar plugin for Angular with the help of custom directive.
Ng-run Example
It supports:
Click - to select cell
Ctrl + Click - to toggle cell
Shift + Click to select range
Click and Drag to select range
rowSpan and colSpan behavior
range-selection.directive.ts
import { Directive, ElementRef, Input, NgZone, OnDestroy, OnInit } from '@angular/core';
import { fromEvent, pipe, Subject } from 'rxjs';
import { filter, map, switchMap, takeUntil, tap } from 'rxjs/operators';
@Directive({
selector: 'table[range-selection]',
})
export class RangeSelectionDirective implements OnDestroy, OnInit {
@Input() selectionClass = 'state--selected';
selectedRange = new Set<HTMLTableCellElement>();
private readonly table: HTMLTableElement;
private startCell: HTMLTableCellElement = null;
private cellIndices = new Map<HTMLTableCellElement, { row: number; column: number }>();
private selecting: boolean;
private destroy$ = new Subject<void>();
constructor(private zone: NgZone, {nativeElement}: ElementRef<HTMLTableElement>) {
this.table = nativeElement;
}
ngOnInit() {
this.zone.runOutsideAngular(() => this.initListeners());
}
private initListeners() {
const withCell = pipe(
map((event: MouseEvent) => ({event, cell: (event.target as HTMLElement).closest<HTMLTableCellElement>('th,td')})),
filter(({cell}) => !!cell),
);
const mouseDown$ = fromEvent<MouseEvent>(this.table, 'mousedown')
.pipe(
filter(event => event.button === 0),
withCell,
tap(this.startSelection)
);
const mouseOver$ = fromEvent<MouseEvent>(this.table, 'mouseover');
const mouseUp$ = fromEvent(document, 'mouseup').pipe(
tap(() => this.selecting = false)
);
this.handleOutsideClick();
mouseDown$.pipe(
switchMap(() => mouseOver$.pipe(takeUntil(mouseUp$))),
takeUntil(this.destroy$),
withCell
).subscribe(this.select);
}
private handleOutsideClick() {
fromEvent(document, 'mouseup').pipe(
takeUntil(this.destroy$)
).subscribe((event: any) => {
if (!this.selecting && !this.table.contains(event.target as HTMLElement)) {
this.clearCells();
}
});
}
private startSelection = ({cell, event}: { event: MouseEvent, cell: HTMLTableCellElement }) => {
this.updateCellIndices();
if (!event.ctrlKey && !event.shiftKey) {
this.clearCells();
}
if (event.shiftKey) {
this.select({cell});
}
this.selecting = true;
this.startCell = cell;
if (!event.shiftKey) {
if (this.selectedRange.has(cell)) {
this.selectedRange.delete(cell);
} else {
this.selectedRange.add(cell);
}
cell.classList.toggle(this.selectionClass);
}
};
private select = ({cell}: { cell: HTMLTableCellElement }) => {
this.clearCells();
this.getCellsBetween(this.startCell, cell).forEach(item => {
this.selectedRange.add(item);
item.classList.add(this.selectionClass);
});
};
private clearCells() {
Array.from(this.selectedRange).forEach(cell => {
cell.classList.remove(this.selectionClass);
});
this.selectedRange.clear();
}
private getCellsBetween(start: HTMLTableCellElement, end: HTMLTableCellElement) {
const startCoords = this.cellIndices.get(start);
const endCoords = this.cellIndices.get(end);
const boundaries = {
top: Math.min(startCoords.row, endCoords.row),
right: Math.max(startCoords.column + start.colSpan - 1, endCoords.column + end.colSpan - 1),
bottom: Math.max(startCoords.row + start.rowSpan - 1, endCoords.row + end.rowSpan - 1),
left: Math.min(startCoords.column, endCoords.column),
};
const cells = [];
iterateCells(this.table, (cell) => {
const { column, row } = this.cellIndices.get(cell);
if (column >= boundaries.left && column <= boundaries.right &&
row >= boundaries.top && row <= boundaries.bottom) {
cells.push(cell);
}
});
return cells;
}
private updateCellIndices() {
this.cellIndices.clear();
const matrix = [];
iterateCells(this.table, (cell, y, x) => {
for (; matrix[y] && matrix[y][x]; x++) {}
for (let spanX = x; spanX < x + cell.colSpan; spanX++) {
for (let spanY = y; spanY < y + cell.rowSpan; spanY++) {
(matrix[spanY] = matrix[spanY] || [])[spanX] = 1;
}
}
this.cellIndices.set(cell, {row: y, column: x});
});
}
ngOnDestroy() {
this.destroy$.next();
}
}
function iterateCells(table: HTMLTableElement, callback: (cell: HTMLTableCellElement, y: number, x: number) => void): void {
for (let y = 0; y < table.rows.length; y++) {
for (let x = 0; x < table.rows[y].cells.length; x++) {
callback(table.rows[y].cells[x], y, x);
}
}
}
Your Forked Stackblitz
But it's not so complex do it stackblitz
If our tds are like
<ng-container matColumnDef="position">
<th mat-header-cell *matHeaderCellDef> No. </th>
<td mat-cell #cell *matCellDef="let element;let i=index"
(click)="select($event,cell)"
<!--use isSelected(i,0) for the first column
isSelected(i,1) for the second column
...
-->
[ngClass]="{'selected':isSelected(i,0)}"
> {{element.position}} </td>
</ng-container>
We get the cells using viewChildren, and declare an array with the selection
selection: number[]=[];
@ViewChildren("cell", { read: ElementRef }) cells: QueryList<ElementRef>;
In cells we has all the "td" order from top to bottom and from left to rigth
The function select take account if you are pressed the shifh key or the control key
select(event: MouseEvent, cell: any) {
//search the cell "clicked"
const cellClick = this.cells.find(x => x.nativeElement == event.target);
//get the index of this cells
let indexSelected = -1;
this.cells.forEach((x, i) => {
if (x == cellClick) indexSelected = i;
});
if (event.ctrlKey) { //if ctrl pressed
if (this.selection.indexOf(indexSelected)>=0) //if its yet selected
this.selection=this.selection.filter(x=>x!=indexSelected);
else //if it's not selected
this.selection.push(indexSelected)
} else {
if (event.shiftKey) { //if the key shift is pressed
if (this.selection.length) //if there any more selected
{
//calculate the row and col of fisrt element we selected
let rowFrom=this.selection[0]%this.dataSource.data.length;
let colFrom=Math.floor(this.selection[0]/this.dataSource.data.length)
//idem from the index selected
let rowTo=indexSelected%this.dataSource.data.length;
let colTo=Math.floor(indexSelected/this.dataSource.data.length)
//interchange if from is greater than to
if (rowFrom>rowTo)
[rowFrom, rowTo] = [rowTo, rowFrom]
if (colFrom>colTo)
[colFrom, colTo] = [colTo, colFrom]
//clean the array
this.selection=[]
//we run througth all the td to check if we need push or not
this.cells.forEach((x,index)=>{
const row=index%this.dataSource.data.length;
const col=Math.floor(index/this.dataSource.data.length)
if (row>=rowFrom && row<=rowTo && col>=colFrom && col<=colTo)
this.selection.push(index)
})
}
else //if there're anything selected and the shit is pressed
this.selection = [indexSelected]
} else { //if no key shit nor key ctrl
this.selection = [indexSelected]
}
}
}
And a function isSelected give as posibility to change the class
isSelected(row,column)
{
const index=column*this.dataSource.data.length+row
return this.selection.indexOf(index)>=0
}
with a .css
table {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
td {
outline: none!important
}
.selected
{
border:1px solid red;
}
Update
In the example you need shift & ctrl
. But we can has a checkbox or a select to "change the way of selection" -use [(ngModel)]- and replace the conditions event.shiftKey and event.ctrlKey.
If you allways select a range you can use two variables "fromIndex" and "toIndex",
fromIndex:number=-1
toIndex:number=-1
select(event: MouseEvent, cell: any){
....
..get the indexSelected ...
if (this.fromIndex==-1){ //If nothing select
this.fromIndex=indexSelected
this.toIndex=-1
}
else{
this.toIndex=indexSelected;
}
if (this.toIndex>=0 && this.fromIndex>=0)
{
...the code to select range
}
}
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