Skip to content

Commit

Permalink
feat(datagrid): range selection with shift key
Browse files Browse the repository at this point in the history
Signed-off-by: Ivan Donchev <idonchev@vmware.com>
  • Loading branch information
Jinnie committed Jul 7, 2022
1 parent 2c0df7b commit f0ed558
Show file tree
Hide file tree
Showing 6 changed files with 210 additions and 8 deletions.
5 changes: 3 additions & 2 deletions projects/angular/clarity.api.md
Expand Up @@ -1124,7 +1124,7 @@ export class ClrDatagrid<T = any> implements AfterContentInit, AfterViewInit, On
// Warning: (ae-forgotten-export) The symbol "Page" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "ColumnsService" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "KeyNavigationGridController" needs to be exported by the entry point index.d.ts
constructor(organizer: DatagridRenderOrganizer, items: Items<T>, expandableRows: ExpandableRowsCount, selection: Selection_2<T>, rowActionService: RowActionService, stateProvider: StateProvider<T>, displayMode: DisplayModeService, renderer: Renderer2, detailService: DetailService, datagridId: string, el: ElementRef, page: Page, commonStrings: ClrCommonStringsService, columnsService: ColumnsService, keyNavigation: KeyNavigationGridController);
constructor(organizer: DatagridRenderOrganizer, items: Items<T>, expandableRows: ExpandableRowsCount, selection: Selection_2<T>, rowActionService: RowActionService, stateProvider: StateProvider<T>, displayMode: DisplayModeService, renderer: Renderer2, detailService: DetailService, datagridId: string, document: any, el: ElementRef, page: Page, commonStrings: ClrCommonStringsService, columnsService: ColumnsService, keyNavigation: KeyNavigationGridController, zone: NgZone);
get allSelected(): boolean;
set allSelected(_value: boolean);
// (undocumented)
Expand Down Expand Up @@ -1704,11 +1704,12 @@ export class ClrDatagridPlaceholder<T = any> {

// @public (undocumented)
export class ClrDatagridRow<T = any> implements AfterContentInit, AfterViewInit {
constructor(selection: Selection_2<T>, rowActionService: RowActionService, globalExpandable: ExpandableRowsCount, expand: DatagridIfExpandService, detailService: DetailService, displayMode: DisplayModeService, vcr: ViewContainerRef, renderer: Renderer2, el: ElementRef, commonStrings: ClrCommonStringsService);
constructor(selection: Selection_2<T>, rowActionService: RowActionService, globalExpandable: ExpandableRowsCount, expand: DatagridIfExpandService, detailService: DetailService, displayMode: DisplayModeService, vcr: ViewContainerRef, renderer: Renderer2, el: ElementRef, commonStrings: ClrCommonStringsService, items: Items, document: any);
// (undocumented)
_calculatedCells: ViewContainerRef;
// (undocumented)
checkboxId: string;
clearRanges(event: MouseEvent): void;
set clrDgDetailCloseLabel(label: string);
// (undocumented)
get clrDgDetailCloseLabel(): string;
Expand Down
4 changes: 2 additions & 2 deletions projects/angular/src/data/datagrid/datagrid-row.html
Expand Up @@ -2,7 +2,7 @@
We need to wrap the #rowContent in label element if we are in rowSelectionMode.
Clicking of that wrapper label will equate to clicking on the whole row, which triggers the checkbox to toggle.
-->
<label class="datagrid-row-clickable" *ngIf="selection.rowSelectionMode">
<label class="datagrid-row-clickable" *ngIf="selection.rowSelectionMode" (mousedown)="clearRanges($event)">
<clr-expandable-animation [clrExpandTrigger]="expandAnimationTrigger" *ngIf="expand.expandable">
<ng-template [ngTemplateOutlet]="rowContent"></ng-template>
</clr-expandable-animation>
Expand Down Expand Up @@ -53,7 +53,7 @@
[attr.aria-label]="clrDgRowAriaLabel"
/>
<!-- Usage of class clr-col-null here prevents clr-col-* classes from being added when a datagrid is wrapped inside clrForm -->
<label [for]="checkboxId" class="clr-control-label clr-col-null">
<label [for]="checkboxId" class="clr-control-label clr-col-null" (click)="clearRanges($event)">
<span class="clr-sr-only">{{commonStrings.keys.select}}</span>
</label>
</div>
Expand Down
58 changes: 57 additions & 1 deletion projects/angular/src/data/datagrid/datagrid-row.ts
Expand Up @@ -4,13 +4,15 @@
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { DOCUMENT } from '@angular/common';
import {
AfterContentInit,
AfterViewInit,
Component,
ContentChildren,
ElementRef,
EventEmitter,
Inject,
Injector,
Input,
Output,
Expand All @@ -33,6 +35,7 @@ import { SelectionType } from './enums/selection-type';
import { DetailService } from './providers/detail.service';
import { DisplayModeService } from './providers/display-mode.service';
import { ExpandableRowsCount } from './providers/global-expandable-rows';
import { Items } from './providers/items';
import { RowActionService } from './providers/row-action-service';
import { Selection } from './providers/selection';
import { WrappedRow } from './wrapped-row';
Expand Down Expand Up @@ -74,6 +77,25 @@ export class ClrDatagridRow<T = any> implements AfterContentInit, AfterViewInit

public expandAnimationTrigger = false;

/**
* The default behavior in Chrome and Firefox for shift-clicking on a label is to perform text-selection.
* This prevents our intended range-selection, because this text-selection overrides our shift-click event.
* We need to clear the stored selection range when shift-clicking. This will override the mostly unused shift-click
* selection browser functionality, which is inconsistently implemented in browsers anyway.
*/
clearRanges(event: MouseEvent) {
if (event.shiftKey) {
this.document.getSelection().removeAllRanges();
// Firefox is too persistent about its text-selection behaviour. So we need to add a preventDefault();
// We should not try to enforce this on the other browsers, though, because their toggle cycle does not get canceled by
// the preventDefault() and they toggle the checkbox second time, effectively retrurning it to not-selected.
if (window.navigator.userAgent.indexOf('Firefox') !== -1) {
event.preventDefault();
this.toggle(true);
}
}
}

constructor(
public selection: Selection<T>,
public rowActionService: RowActionService,
Expand All @@ -84,7 +106,9 @@ export class ClrDatagridRow<T = any> implements AfterContentInit, AfterViewInit
private vcr: ViewContainerRef,
private renderer: Renderer2,
private el: ElementRef,
public commonStrings: ClrCommonStringsService
public commonStrings: ClrCommonStringsService,
private items: Items,
@Inject(DOCUMENT) private document: any
) {
nbRow++;
this.id = 'clr-dg-row' + nbRow;
Expand Down Expand Up @@ -127,6 +151,11 @@ export class ClrDatagridRow<T = any> implements AfterContentInit, AfterViewInit
if (this.selection.selectionType === SelectionType.None) {
this._selected = value as boolean;
} else {
if (value && this.selection.selectionType === SelectionType.Multi) {
this.rangeSelect();
} else {
this.selection.rangeStart = null;
}
this.selection.setSelected(this.item, value as boolean);
}
}
Expand Down Expand Up @@ -250,6 +279,33 @@ export class ClrDatagridRow<T = any> implements AfterContentInit, AfterViewInit
);
}

private rangeSelect() {
const items = this.items.displayed;
if (!items) {
return;
}
const startIx = items.indexOf(this.selection.rangeStart);
if (
this.selection.rangeStart &&
this.selection.current.includes(this.selection.rangeStart) &&
this.selection.shiftPressed &&
startIx !== -1
) {
const endIx = items.indexOf(this.item);
// Using Set to remove duplicates
const newSelection = new Set(
this.selection.current.concat(items.slice(Math.min(startIx, endIx), Math.max(startIx, endIx) + 1))
);
this.selection.clearSelection();
this.selection.current.push(...newSelection);
} else {
// page number has changed or
// no Shift was pressed or
// rangeStart not yet set
this.selection.rangeStart = this.item;
}
}

private subscriptions: Subscription[] = [];

ngOnDestroy() {
Expand Down
117 changes: 116 additions & 1 deletion projects/angular/src/data/datagrid/datagrid.spec.ts
Expand Up @@ -131,6 +131,24 @@ class MultiSelectionTest {
@Input() selected: any[] = [];
}

@Component({
template: `
<clr-datagrid [(clrDgSelected)]="selected">
<clr-dg-column>First</clr-dg-column>
<clr-dg-column>Second</clr-dg-column>
<clr-dg-row *clrDgItems="let item of items" [clrDgItem]="item">
<clr-dg-cell>{{ item }}</clr-dg-cell>
<clr-dg-cell>{{ item * item }}</clr-dg-cell>
</clr-dg-row>
</clr-datagrid>
`,
})
class MultiSelectionSimpleTest {
items: any[] = [1, 2, 3, 4, 5, 6, 7];
selected: any[] = [];
}

@Component({
template: `
<clr-datagrid [(clrDgSingleSelected)]="selected" clrDgSingleSelectionAriaLabel="Select row from Datagrid">
Expand Down Expand Up @@ -988,7 +1006,7 @@ export default function (): void {
});
});

describe('Multi selection', function () {
describe('Multi selection (OnPush)', function () {
let context: TestContext<ClrDatagrid<number>, OnPushTest>;
let selection: Selection<number>;

Expand All @@ -1011,6 +1029,103 @@ export default function (): void {
});
});

describe('Multi selection', function () {
let context: TestContext<ClrDatagrid<number>, MultiSelectionSimpleTest>;
let selection: Selection<number>;

beforeEach(function () {
context = this.create(ClrDatagrid, MultiSelectionSimpleTest, [Selection]);
selection = context.getClarityProvider(Selection) as Selection<number>;
});

describe('Range selection', function () {
let checkboxes: HTMLElement[];

beforeEach(function () {
checkboxes = Array.from(context.clarityElement.querySelectorAll('input[type=checkbox]')) as HTMLElement[];
});

it('updates selection independently without Shift key', function () {
// We use .clr-control-label because the datagrid implementation hides the
// real checkbox and only leaves visible the label area.
expect(checkboxes.length).toEqual(8); // "select all" option in header included
checkboxes[1].click();
expect(selection.current).toEqual([1]);
checkboxes[3].click();
expect(selection.current).toEqual([1, 3]);
});

it('updates selection range when Shift key pressed', function () {
checkboxes[1].click();
expect(selection.current).toEqual([1]);
expect(selection.shiftPressed).toBeFalse();
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
expect(selection.shiftPressed).toBeTrue();
checkboxes[3].click();
document.body.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift' }));
expect(selection.shiftPressed).toBeFalse();
expect(selection.current).toEqual([1, 2, 3]);
});

it('updates selection range in reverse order', function () {
checkboxes[3].click();
expect(selection.current).toEqual([3]);
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
checkboxes[1].click();
document.body.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift' }));
// We don't really care about the order, do we?
expect(selection.current.every(x => [1, 2, 3].includes(x))).toBeTrue();
});

it('can update ranges in a sequence, without releasing Shift', function () {
// first range
checkboxes[1].click();
expect(selection.current).toEqual([1]);
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
checkboxes[3].click();
expect(selection.current).toEqual([1, 2, 3]);
// second range
checkboxes[5].click();
expect(selection.current).toEqual([1, 2, 3, 4, 5]);
checkboxes[7].click();
document.body.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift' }));
// final state
expect(selection.current.every(x => [1, 2, 3, 4, 5, 6, 7].includes(x))).toBeTrue();
});
it('can update ranges in a sequence, releasing Shift', function () {
// first range
checkboxes[1].click();
expect(selection.current).toEqual([1]);
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
checkboxes[3].click();
document.body.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift' }));
expect(selection.current).toEqual([1, 2, 3]);
// second range
checkboxes[5].click();
expect(selection.current).toEqual([1, 2, 3, 5]);
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
checkboxes[7].click();
document.body.dispatchEvent(new KeyboardEvent('keyup', { key: 'Shift' }));
expect(selection.current.includes(4)).toBeFalse();
// final state
expect(selection.current.every(x => [1, 2, 3, 5, 6, 7].includes(x))).toBeTrue();
});
it('can deselect items inside a range', fakeAsync(function () {
// define range
selection.current.push(1, 2, 3, 4, 5, 6, 7);
context.detectChanges();
tick();
// // deselect inside range
checkboxes[2].click();
checkboxes[4].click();
checkboxes[6].click();
context.detectChanges();
tick();
expect(selection.current).toEqual([1, 3, 5, 7]);
}));
});
});

describe('Chocolate', function () {
describe('clrDgItems', function () {
it("doesn't taunt with chocolate on actionable rows", function () {
Expand Down
25 changes: 23 additions & 2 deletions projects/angular/src/data/datagrid/datagrid.ts
Expand Up @@ -4,6 +4,7 @@
* The full license information can be found in LICENSE in the root directory of this project.
*/

import { DOCUMENT } from '@angular/common';
import {
AfterContentInit,
AfterViewInit,
Expand All @@ -14,14 +15,15 @@ import {
EventEmitter,
Inject,
Input,
NgZone,
OnDestroy,
Output,
QueryList,
Renderer2,
ViewChild,
ViewContainerRef,
} from '@angular/core';
import { combineLatest, Subscription } from 'rxjs';
import { combineLatest, fromEvent, Subscription } from 'rxjs';

import { ClrCommonStringsService } from '../../utils/i18n/common-strings.service';
import { UNIQUE_ID, UNIQUE_ID_PROVIDER } from '../../utils/id-generator/id-generator.service';
Expand Down Expand Up @@ -87,11 +89,13 @@ export class ClrDatagrid<T = any> implements AfterContentInit, AfterViewInit, On
private renderer: Renderer2,
public detailService: DetailService,
@Inject(UNIQUE_ID) datagridId: string,
@Inject(DOCUMENT) private document: any,
private el: ElementRef,
private page: Page,
public commonStrings: ClrCommonStringsService,
private columnsService: ColumnsService,
private keyNavigation: KeyNavigationGridController
private keyNavigation: KeyNavigationGridController,
private zone: NgZone
) {
this.selectAllId = 'clr-dg-select-all-' + datagridId;
this.detailService.id = datagridId;
Expand Down Expand Up @@ -326,6 +330,23 @@ export class ClrDatagrid<T = any> implements AfterContentInit, AfterViewInit, On
}
})
);

// We need to preserve shift state, so it can be used on selection change, regardless of the input event
// that triggered the change. This helps us to easily resolve the k/b only case together with the mouse selection case.
this.zone.runOutsideAngular(() => {
this._subscriptions.push(
fromEvent(this.document.body, 'keydown').subscribe((event: KeyboardEvent) => {
if (event.key === 'Shift') {
this.selection.shiftPressed = true;
}
}),
fromEvent(this.document.body, 'keyup').subscribe((event: KeyboardEvent) => {
if (event.key === 'Shift') {
this.selection.shiftPressed = false;
}
})
);
});
}

/**
Expand Down
9 changes: 9 additions & 0 deletions projects/angular/src/data/datagrid/providers/selection.ts
Expand Up @@ -239,6 +239,15 @@ export class Selection<T = any> {
this.updateCurrent(value, true);
}

/**
* Last selection, for use in range selection.
*/
public rangeStart: T;
/**
* Shift key state, for use in range selection.
*/
public shiftPressed = false;

private valueCollector: Subject<T[]> = new Subject<T[]>();
public updateCurrent(value: T[], emit: boolean) {
this._current = value;
Expand Down

0 comments on commit f0ed558

Please sign in to comment.