Skip to content

Commit

Permalink
feat(lib): add range selection behavior
Browse files Browse the repository at this point in the history
Fixes #62
  • Loading branch information
d3lm committed Oct 25, 2019
1 parent c47dee4 commit 13cbbd3
Show file tree
Hide file tree
Showing 10 changed files with 215 additions and 37 deletions.
24 changes: 13 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,16 +137,18 @@ This section gives you an overview of things you can customize and configure.

You can override the following variables:

| Variable | Type | Default | Description |
| ----------------------------- | ------- | -------------- | ---------------------------------------------------- |
| `$dts-primary` | Color | `#7ddafc` | Primary color |
| `$select-box-color` | Color | `$dts-primary` | Color of the selection rectangle |
| `$select-box-removing-color` | Color | `$dts-primary` | Color of the selection rectangle when removing items |
| `$select-box-border-size` | Unit | `2px` | Border size for the selection rectangle |
| `$selected-item-border` | Boolean | `true` | Whether the selected item should get a border |
| `$selected-item-border-color` | Color | `#d2d2d2` | Border color of the selected item |
| `$selected-item-border-size` | Unit | `1px` | Border size of the selected item |
| `$box-shadow` | Boolean | `true` | Whether the selected item should get a box shadow |
| Variable | Type | Default | Description |
| ----------------------------- | ------- | -------------- | --------------------------------------------------------- |
| `$dts-primary` | Color | `#7ddafc` | Primary color |
| `$select-box-color` | Color | `$dts-primary` | Color of the selection rectangle |
| `$select-box-removing-color` | Color | `$dts-primary` | Color of the selection rectangle when removing items |
| `$select-box-border-size` | Unit | `2px` | Border size for the selection rectangle |
| `$selected-item-border` | Boolean | `true` | Whether the selected item should get a border |
| `$selected-item-border-color` | Color | `#d2d2d2` | Border color of the selected item |
| `$selected-item-border-size` | Unit | `1px` | Border size of the selected item |
| `$box-shadow` | Boolean | `true` | Whether the selected item should get a box shadow |
| `$range-start-border` | Boolean | `true` | Whether the range start item is highlighted with a border |
| `$range-start-border-color` | Color | `#2196f3` | Border color of the range start item |

If you wish to override one of these variables, make sure to do that **before** you import the sass package.

Expand Down Expand Up @@ -177,7 +179,7 @@ Class that is added to an item when it's selected. The default class is `selecte
| ------------------- | ---------------- | --------------------------------------------------------------------------------- |
| disableSelection | `alt` | Disable selection mode to allow selecting text on the screen within the drag area |
| toggleSingleItem | `meta` | Add or remove single item to / from selection |
| addToSelection | `shift` | Add items to selection |
| addToSelection | `shift` | Range selection, Add items to selection |
| removeFromSelection | `shift` + `meta` | Remove items from selection |

You can override these options by passing a configuration object to `forRoot()`.
Expand Down
59 changes: 58 additions & 1 deletion cypress/integration/clicking.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DEFAULT_CONFIG } from '../../projects/ngx-drag-to-select/src/lib/config';
import { disableSelectOnDrag, enableSelectWithShortcut, getDesktopExample } from '../support/utils';
import { disableSelectOnDrag, enableSelectWithShortcut, getDesktopExample, toggleItem } from '../support/utils';

const SELECTED_CLASS = DEFAULT_CONFIG.selectedClass;

Expand All @@ -8,6 +8,63 @@ describe('Clicking', () => {
cy.visit('/');
});

describe('Range', () => {
it('should select items in a row if shift is pressed', () => {
getDesktopExample().within(() => {
cy.getSelectItem(0)
.dispatch('mousedown', { button: 0 })
.dispatch('mouseup')
.getSelectItem(6)
.dispatch('mousedown', { button: 0, shiftKey: true })
.dispatch('mouseup')
.shouldSelect([1, 2, 3, 4, 5, 6, 7])
.get(`.${SELECTED_CLASS}`)
.should('have.length', 7);
});
});

it('should reset range start when item is toggled', () => {
getDesktopExample().within(() => {
cy.getSelectItem(0)
.as('start')
.dispatch('mousedown', { button: 0 })
.dispatch('mouseup');

cy.get('@start').should('have.class', 'dts-range-start');

cy.getSelectItem(2)
.dispatch('mousedown', { button: 0, shiftKey: true })
.dispatch('mouseup')
.getSelectItem(6)
.as('end')
.then(toggleItem);

cy.get('@end').should('have.class', 'dts-range-start');
});
});

it('should reset range start to be the first item of selection', () => {
getDesktopExample().within(() => {
cy.getSelectItem(0)
.as('start')
.dispatch('mousedown', { button: 0 })
.dispatch('mouseup')
.getSelectItem(5)
.dispatch('mousedown', { button: 0, shiftKey: true })
.dispatch('mouseup');

cy.getSelectContainer()
.dispatch('mousedown', 'top', { button: 0 })
.getSelectItem(7)
.dispatch('mousemove')
.dispatch('mouseup');

cy.getSelectItem(0).should('not.have.class', 'dts-range-start');
cy.getSelectItem(2).should('have.class', 'dts-range-start');
});
});
});

it('should select single item on mousedown', () => {
getDesktopExample().within(() => {
cy.getSelectItem(0)
Expand Down
4 changes: 2 additions & 2 deletions cypress/integration/dragging.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,8 @@ describe('Dragging', () => {
.dispatch('mousemove')
.dispatch('mouseup')
.shouldSelect([1, 2, 5, 6])
.getSelectItem(2)
.dispatch('mousedown', { button: 0, shiftKey: true })
.getSelectContainer()
.dispatch('mousedown', 'top', { button: 0, shiftKey: true })
.getSelectItem(7)
.as('end')
.dispatch('mousemove', { shiftKey: true })
Expand Down
7 changes: 7 additions & 0 deletions cypress/integration/styling-and-classes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ describe('Styling and Classes', () => {
.dispatch('mouseup')
.then($element => {
expect($element.css('box-shadow')).not.to.eq('none');
expect($element.css('border')).to.eq('1px solid rgb(33, 150, 243)');
});

cy.getSelectItem(1)
.dispatch('mousedown', { button: 0, shiftKey: true })
.dispatch('mouseup')
.then($element => {
expect($element.css('border')).to.eq('1px solid rgb(210, 210, 210)');
});
});
Expand Down
2 changes: 1 addition & 1 deletion projects/ngx-drag-to-select/src/lib/operators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,6 @@ export const whenSelectBoxVisible = (selectBox$: Observable<SelectBox<number>>)
export const distinctKeyEvents = () => (source: Observable<KeyboardEvent>) =>
source.pipe(
distinctUntilChanged((prev, curr) => {
return prev.keyCode === curr.keyCode;
return prev.code === curr.code;
})
);
101 changes: 88 additions & 13 deletions projects/ngx-drag-to-select/src/lib/select-container.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
HostBinding,
AfterViewInit,
PLATFORM_ID,
Inject
Inject,
AfterContentInit
} from '@angular/core';

import { isPlatformBrowser } from '@angular/common';
Expand All @@ -37,7 +38,7 @@ import {
first
} from 'rxjs/operators';

import { SelectItemDirective } from './select-item.directive';
import { SelectItemDirective, SELECT_ITEM_INSTANCE } from './select-item.directive';
import { ShortcutService } from './shortcut.service';

import { createSelectBox, whenSelectBoxVisible, distinctKeyEvents } from './operators';
Expand All @@ -49,7 +50,8 @@ import {
SelectContainerHost,
UpdateAction,
UpdateActions,
PredicateFn
PredicateFn,
BoundingBox
} from './models';

import { AUDIT_TIME, NO_SELECT_CLASS } from './constants';
Expand Down Expand Up @@ -82,7 +84,7 @@ import {
`,
styleUrls: ['./select-container.component.scss']
})
export class SelectContainerComponent implements AfterViewInit, OnDestroy {
export class SelectContainerComponent implements AfterViewInit, OnDestroy, AfterContentInit {
host: SelectContainerHost;
selectBoxStyles$: Observable<SelectBox<string>>;
selectBoxClasses$: Observable<{ [key: string]: boolean }>;
Expand Down Expand Up @@ -114,11 +116,15 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
private _tmpItems = new Map<SelectItemDirective, Action>();

private _selectedItems$ = new BehaviorSubject<Array<any>>([]);
private _selectableItems: Array<SelectItemDirective> = [];
private updateItems$ = new Subject<UpdateAction>();
private destroy$ = new Subject<void>();

private _lastRange: [number, number] = [-1, -1];
private _lastStartIndex: number | undefined = undefined;

constructor(
@Inject(PLATFORM_ID) private platformId,
@Inject(PLATFORM_ID) private platformId: Object,
private shortcuts: ShortcutService,
private hostElementRef: ElementRef,
private renderer: Renderer2,
Expand Down Expand Up @@ -182,7 +188,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
const hide$ = mouseup$.pipe(mapTo(0));
const opacity$ = merge(show$, hide$).pipe(distinctUntilChanged());

const selectBox$ = combineLatest(dragging$, opacity$, currentMousePosition$).pipe(
const selectBox$ = combineLatest([dragging$, opacity$, currentMousePosition$]).pipe(
createSelectBox(this.host),
share()
);
Expand Down Expand Up @@ -252,6 +258,10 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
}
}

ngAfterContentInit() {
this._selectableItems = this.$selectableItems.toArray();
}

selectAll() {
this.$selectableItems.forEach(item => {
this._selectItem(item);
Expand Down Expand Up @@ -290,7 +300,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
// Wrap select items in an observable for better efficiency as
// no intermediate arrays are created and we only need to process
// every item once.
return from(this.$selectableItems.toArray()).pipe(filter(item => predicate(item.value)));
return from(this._selectableItems).pipe(filter(item => predicate(item.value)));
}

private _initSelectedItemsChange() {
Expand Down Expand Up @@ -343,6 +353,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
)
.subscribe(([items, selectedItems]: [QueryList<SelectItemDirective>, any[]]) => {
const newList = items.toArray();
this._selectableItems = newList;
const removedItems = selectedItems.filter(item => !newList.includes(item.value));

if (removedItems.length) {
Expand Down Expand Up @@ -410,21 +421,61 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
this.renderer.addClass(document.body, NO_SELECT_CLASS);
}

if (this.shortcuts.removeFromSelection(event)) {
return;
}

const mousePoint = getMousePosition(event);
const [currentIndex, clickedItem] = this._getClosestSelectItem(event);

let [startIndex, endIndex] = this._lastRange;

if (!this.shortcuts.extendedSelectionShortcut(event) && currentIndex > -1) {
const lastRangeStart = this._selectableItems[this._lastStartIndex];

if (lastRangeStart) {
lastRangeStart.toggleRangeStart();
}

this._lastStartIndex = currentIndex;
clickedItem.toggleRangeStart();
}

if (!this.shortcuts.extendedSelectionShortcut(event) && currentIndex === -1) {
if (this._lastStartIndex >= 0) {
const lastStart = this._selectableItems[this._lastStartIndex];
lastStart.toggleRangeStart();
}

this._lastStartIndex = -1;
}

if (!this.shortcuts.extendedSelectionShortcut(event)) {
this._resetRange();
}

if (currentIndex > -1) {
startIndex = Math.min(this._lastStartIndex, currentIndex);
endIndex = Math.max(this._lastStartIndex, currentIndex);
this._lastRange = [startIndex, endIndex];
}

this.$selectableItems.forEach((item, index) => {
const itemRect = item.getBoundingClientRect();
const withinBoundingBox = inBoundingBox(mousePoint, itemRect);

if (this.shortcuts.extendedSelectionShortcut(event)) {
return;
}

const shouldAdd =
(withinBoundingBox &&
!this.shortcuts.toggleSingleItem(event) &&
!this.selectMode &&
!this.selectWithShortcut) ||
// captured by range and start != end
(this.shortcuts.extendedSelectionShortcut(event) &&
startIndex > -1 &&
endIndex > -1 &&
index >= startIndex &&
index <= endIndex &&
startIndex !== endIndex) ||
(withinBoundingBox && this.shortcuts.toggleSingleItem(event) && !item.selected) ||
(!withinBoundingBox && this.shortcuts.toggleSingleItem(event) && item.selected) ||
(withinBoundingBox && !item.selected && this.selectMode) ||
Expand All @@ -434,7 +485,9 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
(!withinBoundingBox &&
!this.shortcuts.toggleSingleItem(event) &&
!this.selectMode &&
!this.shortcuts.extendedSelectionShortcut(event) &&
!this.selectWithShortcut) ||
(this.shortcuts.extendedSelectionShortcut(event) && currentIndex > -1) ||
(!withinBoundingBox && this.shortcuts.toggleSingleItem(event) && !item.selected) ||
(withinBoundingBox && this.shortcuts.toggleSingleItem(event) && item.selected) ||
(!withinBoundingBox && !item.selected && this.selectMode) ||
Expand All @@ -451,11 +504,16 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
private _selectItems(event: Event) {
const selectionBox = calculateBoundingClientRect(this.$selectBox.nativeElement);

this.$selectableItems.forEach(item => {
this.$selectableItems.forEach((item, index) => {
if (this._isExtendedSelection(event)) {
this._extendedSelectionMode(selectionBox, item, event);
} else {
this._normalSelectionMode(selectionBox, item, event);

if (this._lastStartIndex < 0 && item.selected) {
item.toggleRangeStart();
this._lastStartIndex = index;
}
}
});
}
Expand All @@ -464,7 +522,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
return this.shortcuts.extendedSelectionShortcut(event) && this.selectOnDrag;
}

private _normalSelectionMode(selectBox, item: SelectItemDirective, event: Event) {
private _normalSelectionMode(selectBox: BoundingBox, item: SelectItemDirective, event: Event) {
const inSelection = boxIntersects(selectBox, item.getBoundingClientRect());

const shouldAdd = inSelection && !item.selected && !this.shortcuts.removeFromSelection(event);
Expand Down Expand Up @@ -568,4 +626,21 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy {
private _hasItem(item: SelectItemDirective, selectedItems: Array<any>) {
return selectedItems.includes(item.value);
}

private _getClosestSelectItem(event: Event): [number, SelectItemDirective] {
const target = (event.target as HTMLElement).closest('.dts-select-item');
let index = -1;
let targetItem = null;

if (target) {
targetItem = target[SELECT_ITEM_INSTANCE];
index = this._selectableItems.indexOf(targetItem);
}

return [index, targetItem];
}

private _resetRange() {
this._lastRange = [-1, -1];
}
}

0 comments on commit 13cbbd3

Please sign in to comment.