diff --git a/README.md b/README.md index d56303d..e9ddf93 100644 --- a/README.md +++ b/README.md @@ -553,6 +553,28 @@ Here we listen for `scroll` on the `div` and call `container.update()` in case t In order not to kill the performance, because the `scroll` event is called many many times, you may want to **throttle** it to only call `update` every 16ms or so. +### Does the `dtsSelectItem` directive need to be a direct child of the `dts-select-container`? + +As of version `4.1.0`, an injection token is used to pass the `SelectContainerComponent` parent to the directive. You can use this in any component nested within the `dts-select-container`. + +```ts +import { DTS_SELECT_CONTAINER } from 'ngx-drag-to-select'; + +@Component({...}) +export class TaskListComponent { + constructor( + @Inject(DTS_SELECT_CONTAINER) @Optional() + public container: SelectContainerComponent + ) {} +} +``` + +You can find an example of this in the [drag and drop example](https://github.com/d3lm/ngx-drag-to-select/blob/master/src/app/dragndrop). + +### Why is my `dtsSelectItem` directive not selecting when I click on it? + +If you are using the `dtsSelectItem` within a nested component then your mousedown/up events might being captured by another directive or component in your code. For example if you are using this libary with Angular CDK's DragDropModule, the mouse events are captured by the `cdkDrag` directive, [see here](https://github.com/angular/components/pull/19674). + ## Want to contribute? If you want to file a bug, contribute some code, or improve our documentation, read up on our [contributing guidelines](CONTRIBUTING.md) and [code of conduct](CODE_OF_CONDUCT.md), and check out [open issues](/issues). diff --git a/cypress/integration/drag-and-drop.spec.ts b/cypress/integration/drag-and-drop.spec.ts new file mode 100644 index 0000000..0121358 --- /dev/null +++ b/cypress/integration/drag-and-drop.spec.ts @@ -0,0 +1,68 @@ +import { DEFAULT_CONFIG } from '../../projects/ngx-drag-to-select/src/lib/config'; + +import { getDoingList, getDragAndDropExample, getTodoList } from '../support/utils'; + +const SELECTED_CLASS = DEFAULT_CONFIG.selectedClass; + +describe('Drag And Drop', () => { + beforeEach(() => { + cy.visit('/'); + }); + + describe('Select on Drag', () => { + it('should start new selection', () => { + getDragAndDropExample().within(() => { + getTodoList() + .dispatch('mousedown', 'topLeft', { button: 0 }) + .getSelectItem(2) + .dispatch('mousemove') + .dispatch('mouseup'); + + cy.get('.selected').should('have.length', 3); + }); + }); + + it('should drag to new list', () => { + getDragAndDropExample().within(() => { + getTodoList() + // select first 3 items in list + .dispatch('mousedown', 'topLeft', { button: 0 }) + .getSelectItem(2) + .dispatch('mousemove') + .dispatch('mouseup') + // click on second item + .getSelectItem(1) + .dispatch('mousedown', { button: 0 }) + // drag to SelectItem in other list + .getSelectItem(5) + .wait(16) + .dispatch('mousemove', { force: true }) + .dispatch('mousemove', { force: true }) + .dispatch('mouseup'); + + getDoingList().within(() => { + cy.get('app-task').should('have.length', 5); + }); + }); + }); + + it('should reorder within list', () => { + getDragAndDropExample().within(() => { + getTodoList() + .dispatch('mousedown', 'topRight', { button: 0 }) + .getSelectItem(1) + .dispatch('mousemove') + .dispatch('mouseup') + .getSelectItem(0) + .dispatch('mousedown', 'bottom', { button: 0 }) + .getSelectItem(4) + .dispatch('mousemove', { force: true }) + .dispatch('mousemove', { force: true }) + .dispatch('mouseup'); + + cy.get('app-task').eq(0).should('contain', 'Open Ticket #1'); + cy.get('app-task').eq(1).should('contain', 'Open Ticket #2'); + }); + }); + }); +}); diff --git a/cypress/integration/dragging.spec.ts b/cypress/integration/dragging.spec.ts index 7a1e220..c2a5603 100644 --- a/cypress/integration/dragging.spec.ts +++ b/cypress/integration/dragging.spec.ts @@ -1,7 +1,9 @@ import { DEFAULT_CONFIG } from '../../projects/ngx-drag-to-select/src/lib/config'; import { + disableDragOverItems, disableSelection, + disableSelectOnClick, disableSelectOnDrag, enableSelectMode, getDesktopExample, @@ -91,6 +93,82 @@ describe('Dragging', () => { .dispatch('mouseup'); }); }); + + describe('selection with dragOverItems set to false', () => { + it('should not start selection over items', () => { + disableDragOverItems().then(() => { + getDesktopExample().within(() => { + cy.getSelectItem(0) + .dispatch('mousedown', { button: 0 }) + .getSelectItem(6, 'end') + .dispatch('mousemove') + .shouldSelect([1]) + .getSelectBox() + .then(shouldBeInvisible) + .get('@end') + .dispatch('mouseup'); + }); + }); + }); + + it('should start selection in element inbetween SelectContainer and SelectItem', () => { + disableDragOverItems().then(() => { + getDesktopExample().within(() => { + cy.get('mat-grid-list') + .as('end') + .scrollIntoView() + .wait(16) + .trigger('mousedown', 210, 50, { button: 0 }) + .wait(16) + .getSelectItem(6) + .dispatch('mousemove') + .shouldSelect([2, 3, 6, 7]) + .getSelectBox() + .then(shouldBeVisible) + .get('@end') + .dispatch('mouseup'); + }); + }); + }); + }); + + describe('selection with selectOnClick set to false', () => { + it('should not start selection over items', () => { + disableSelectOnClick().then(() => { + getDesktopExample().within(() => { + cy.getSelectItem(0) + .dispatch('mousedown', { button: 0 }) + .getSelectItem(6, 'end') + .dispatch('mousemove') + .shouldSelect([]) + .getSelectBox() + .then(shouldBeInvisible) + .get('@end') + .dispatch('mouseup'); + }); + }); + }); + + it('should start selection in element inbetween SelectContainer and SelectItem', () => { + disableSelectOnClick().then(() => { + getDesktopExample().within(() => { + cy.get('mat-grid-list') + .as('end') + .scrollIntoView() + .wait(16) + .trigger('mousedown', 210, 50, { button: 0 }) + .wait(16) + .getSelectItem(6) + .dispatch('mousemove') + .shouldSelect([2, 3, 6, 7]) + .getSelectBox() + .then(shouldBeVisible) + .get('@end') + .dispatch('mouseup'); + }); + }); + }); + }); }); describe('Keyboard Events', () => { diff --git a/cypress/support/utils.ts b/cypress/support/utils.ts index e046644..444b35a 100644 --- a/cypress/support/utils.ts +++ b/cypress/support/utils.ts @@ -28,6 +28,10 @@ export const getMobileExample = () => { return cy.get('[data-cy="mobile"]'); }; +export const getDragAndDropExample = () => { + return cy.get('[data-cy="drag-and-drop"]'); +}; + export const getSelectCount = () => { return cy.get('[data-cy="select-count"]'); }; @@ -48,10 +52,26 @@ export const getClearButton = () => { return cy.get('[data-cy="clearSelection"]'); }; +export const getTodoList = () => { + return cy.get('[data-cy="todo-list"]'); +}; + +export const getDoingList = () => { + return cy.get('[data-cy="doing-list"]'); +}; + +export const getDoneList = () => { + return cy.get('[data-cy="done-list"]'); +}; + export const disableSelectOnDrag = () => { return cy.get('[data-cy="selectOnDrag"]').click(); }; +export const disableDragOverItems = () => { + return cy.get('[data-cy="dragOverItems"]').click(); +}; + export const disableSelectOnClick = () => { return cy.get('[data-cy="selectOnClick"]').click(); }; diff --git a/projects/ngx-drag-to-select/src/lib/models.ts b/projects/ngx-drag-to-select/src/lib/models.ts index 4122c2f..5ebf228 100644 --- a/projects/ngx-drag-to-select/src/lib/models.ts +++ b/projects/ngx-drag-to-select/src/lib/models.ts @@ -62,3 +62,11 @@ export enum Action { Delete, None, } + +export interface SelectContainer { + selectedItems: T[]; + register(item: T): void; + unregister(item: T): void; +} + +export type ComponentType = new (...args: any[]) => T; diff --git a/projects/ngx-drag-to-select/src/lib/select-container.component.ts b/projects/ngx-drag-to-select/src/lib/select-container.component.ts index 9b4c764..8725d3a 100644 --- a/projects/ngx-drag-to-select/src/lib/select-container.component.ts +++ b/projects/ngx-drag-to-select/src/lib/select-container.component.ts @@ -8,13 +8,10 @@ import { Renderer2, ViewChild, NgZone, - ContentChildren, - QueryList, HostBinding, AfterViewInit, PLATFORM_ID, Inject, - AfterContentInit, } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; @@ -32,7 +29,6 @@ import { share, withLatestFrom, distinctUntilChanged, - observeOn, startWith, concatMapTo, first, @@ -41,7 +37,7 @@ import { import { SelectItemDirective, SELECT_ITEM_INSTANCE } from './select-item.directive'; import { ShortcutService } from './shortcut.service'; -import { createSelectBox, whenSelectBoxVisible, distinctKeyEvents } from './operators'; +import { createSelectBox, whenSelectBoxVisible } from './operators'; import { Action, @@ -52,6 +48,7 @@ import { UpdateActions, PredicateFn, BoundingBox, + SelectContainer, } from './models'; import { AUDIT_TIME, NO_SELECT_CLASS } from './constants'; @@ -67,6 +64,7 @@ import { hasMinimumSize, } from './utils'; import { KeyboardEventsService } from './keyboard-events.service'; +import { DTS_SELECT_CONTAINER } from './tokens'; @Component({ selector: 'dts-select-container', @@ -81,8 +79,9 @@ import { KeyboardEventsService } from './keyboard-events.service'; > `, styleUrls: ['./select-container.component.scss'], + providers: [{ provide: DTS_SELECT_CONTAINER, useExisting: SelectContainerComponent }], }) -export class SelectContainerComponent implements AfterViewInit, OnDestroy, AfterContentInit { +export class SelectContainerComponent implements AfterViewInit, OnDestroy, SelectContainer { host: SelectContainerHost; selectBoxStyles$: Observable>; selectBoxClasses$: Observable<{ [key: string]: boolean }>; @@ -90,9 +89,6 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After @ViewChild('selectBox', { static: true }) private $selectBox: ElementRef; - @ContentChildren(SelectItemDirective, { descendants: true }) - private $selectableItems: QueryList; - @Input() selectedItems: any; @Input() selectOnDrag = true; @Input() disabled = false; @@ -122,6 +118,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After private _selectedItems$ = new BehaviorSubject>([]); private _selectableItems: Array = []; + private _selectableItemsNative: Array = []; private updateItems$ = new Subject(); private destroy$ = new Subject(); @@ -130,6 +127,8 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After private _newRangeStart = false; private _lastRangeSelection: Map = new Map(); + private _registry: Set = new Set(); + constructor( @Inject(PLATFORM_ID) private platformId: Record, private shortcuts: ShortcutService, @@ -163,7 +162,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After const mousedown$ = fromEvent(this.host, 'mousedown').pipe( filter((event) => event.button === 0), // only emit left mouse filter(() => !this.disabled), - filter((event) => this.selectOnClick || event.target === this.host), + filter((event) => this.selectOnClick || this._isClickOutsideSelectableItem(event.target)), tap((event) => this._onMouseDown(event)), share() ); @@ -172,7 +171,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After filter((event) => !this.shortcuts.disableSelection(event)), filter(() => !this.selectMode), filter(() => !this.disableDrag), - filter((event) => this.dragOverItems || event.target === this.host), + filter((event) => this.dragOverItems || this._isClickOutsideSelectableItem(event.target)), switchMap(() => mousemove$.pipe(takeUntil(mouseup$))), share() ); @@ -263,12 +262,13 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After } } - ngAfterContentInit() { - this._selectableItems = this.$selectableItems.toArray(); + updateSelectableItems() { + this._selectableItems = Array.from(this._registry); + this._selectableItemsNative = this._selectableItems.map((directive) => directive.nativeElememnt); } selectAll() { - this.$selectableItems.forEach((item) => { + this._selectableItems.forEach((item) => { this._selectItem(item); }); } @@ -286,14 +286,25 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After } clearSelection() { - this.$selectableItems.forEach((item) => { + this._selectableItems.forEach((item) => { this._deselectItem(item); }); } + register(item: SelectItemDirective) { + this._registry.add(item); + this.updateSelectableItems(); + } + + unregister(item: SelectItemDirective) { + this._registry.delete(item); + this.updateSelectableItems(); + this._removeItem(item, this._selectedItems$.value); + } + update() { this._calculateBoundingClientRect(); - this.$selectableItems.forEach((item) => item.calculateBoundingClientRect()); + this._selectableItems.forEach((item) => item.calculateBoundingClientRect()); } ngOnDestroy() { @@ -344,22 +355,6 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After break; } }); - - // Update the container as well as all selectable items if the list has changed - this.$selectableItems.changes - .pipe(withLatestFrom(this._selectedItems$), observeOn(asyncScheduler), takeUntil(this.destroy$)) - .subscribe(([items, selectedItems]: [QueryList, any[]]) => { - const newList = items.toArray(); - this._selectableItems = newList; - const newValues = newList.map((item) => item.value); - const removedItems = selectedItems.filter((item) => !newValues.includes(item)); - - if (removedItems.length) { - removedItems.forEach((item) => this._removeItem(item, selectedItems)); - } - - this.update(); - }); } private _observeBoundingRectChanges() { @@ -456,7 +451,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After return; } - this.$selectableItems.forEach((item, index) => { + this._selectableItems.forEach((item, index) => { const itemRect = item.getBoundingClientRect(); const withinBoundingBox = inBoundingBox(mousePoint, itemRect); @@ -519,7 +514,7 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After private _selectItems(event: Event) { const selectionBox = calculateBoundingClientRect(this.$selectBox.nativeElement); - this.$selectableItems.forEach((item, index) => { + this._selectableItems.forEach((item, index) => { if (this._isExtendedSelection(event)) { this._extendedSelectionMode(selectionBox, item, event); } else { @@ -680,4 +675,13 @@ export class SelectContainerComponent implements AfterViewInit, OnDestroy, After return null; } + + private _isClickOutsideSelectableItem(element: EventTarget): boolean { + if (!(element instanceof HTMLElement)) return false; + + if (element === this.host) return true; + if (this._selectableItemsNative.includes(element)) return false; + + return this._isClickOutsideSelectableItem(element.parentElement); + } } diff --git a/projects/ngx-drag-to-select/src/lib/select-container.spec.ts b/projects/ngx-drag-to-select/src/lib/select-container.spec.ts index 1761811..d60b407 100644 --- a/projects/ngx-drag-to-select/src/lib/select-container.spec.ts +++ b/projects/ngx-drag-to-select/src/lib/select-container.spec.ts @@ -4,7 +4,8 @@ import { By } from '@angular/platform-browser'; import { DragToSelectModule } from './drag-to-select.module'; import { SelectContainerComponent } from './select-container.component'; import { SelectItemDirective } from './select-item.directive'; -import { BehaviorSubject } from 'rxjs'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; function triggerDomEvent( eventType: string, @@ -94,14 +95,16 @@ describe('SelectContainerComponent', () => { }); it('should update its selection when selectable items change', (done) => { + const done$ = new Subject(); selectContainerInstance.selectItems((item: SelectItemValue) => item.id === 1 || item.id === 2); - selectContainerInstance.itemDeselected.subscribe((item: SelectItemValue) => { + selectContainerInstance.itemDeselected.pipe(takeUntil(done$)).subscribe((item: SelectItemValue) => { expect(item).toEqual({ id: 1 }); }); selectContainerInstance.select.subscribe((items) => { expect(items).toEqual([{ id: 2 }]); + done$.next(); done(); }); diff --git a/projects/ngx-drag-to-select/src/lib/select-item.directive.ts b/projects/ngx-drag-to-select/src/lib/select-item.directive.ts index 87eafec..e00e694 100644 --- a/projects/ngx-drag-to-select/src/lib/select-item.directive.ts +++ b/projects/ngx-drag-to-select/src/lib/select-item.directive.ts @@ -10,10 +10,13 @@ import { Renderer2, OnInit, HostBinding, + Optional, + SkipSelf, + OnDestroy, } from '@angular/core'; -import { DragToSelectConfig, BoundingBox } from './models'; -import { CONFIG } from './tokens'; +import { DragToSelectConfig, BoundingBox, SelectContainer } from './models'; +import { CONFIG, DTS_SELECT_CONTAINER } from './tokens'; import { calculateBoundingClientRect } from './utils'; export const SELECT_ITEM_INSTANCE = Symbol(); @@ -22,7 +25,7 @@ export const SELECT_ITEM_INSTANCE = Symbol(); selector: '[dtsSelectItem]', exportAs: 'dtsSelectItem', }) -export class SelectItemDirective implements OnInit, DoCheck { +export class SelectItemDirective implements OnInit, DoCheck, OnDestroy { private _boundingClientRect: BoundingBox | undefined; selected = false; @@ -46,18 +49,24 @@ export class SelectItemDirective implements OnInit, DoCheck { constructor( @Inject(CONFIG) private config: DragToSelectConfig, @Inject(PLATFORM_ID) private platformId: Record, + @Inject(DTS_SELECT_CONTAINER) @Optional() @SkipSelf() public container: SelectContainer, private host: ElementRef, private renderer: Renderer2 ) {} ngOnInit() { this.nativeElememnt[SELECT_ITEM_INSTANCE] = this; + this.container.register(this); } ngDoCheck() { this.applySelectedClass(); } + ngOnDestroy() { + this.container.unregister(this); + } + toggleRangeStart() { this.rangeStart = !this.rangeStart; } diff --git a/projects/ngx-drag-to-select/src/lib/tokens.ts b/projects/ngx-drag-to-select/src/lib/tokens.ts index 6e3122b..87d3856 100644 --- a/projects/ngx-drag-to-select/src/lib/tokens.ts +++ b/projects/ngx-drag-to-select/src/lib/tokens.ts @@ -1,5 +1,6 @@ import { InjectionToken } from '@angular/core'; -import { DragToSelectConfig } from './models'; +import { DragToSelectConfig, SelectContainer, ComponentType } from './models'; export const CONFIG = new InjectionToken('DRAG_TO_SELECT_CONFIG'); export const USER_CONFIG = new InjectionToken('USER_CONFIG'); +export const DTS_SELECT_CONTAINER = new InjectionToken>('SelectContainerComponent'); diff --git a/projects/ngx-drag-to-select/src/public_api.ts b/projects/ngx-drag-to-select/src/public_api.ts index e1d010e..aab3305 100644 --- a/projects/ngx-drag-to-select/src/public_api.ts +++ b/projects/ngx-drag-to-select/src/public_api.ts @@ -5,3 +5,4 @@ export * from './lib/drag-to-select.module'; export * from './lib/select-container.component'; export * from './lib/select-item.directive'; +export { DTS_SELECT_CONTAINER } from './lib/tokens'; diff --git a/src/app/app.component.html b/src/app/app.component.html index 8929b47..2993545 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -52,9 +52,10 @@

> + [dtsSelectItem]="document" + [dtsDisabled]="disableEvenItems && document.disabled" + *ngFor="let document of documents" + > {{ document.name }} @@ -72,12 +73,25 @@

Meta Information

+
+

+ Drag & Drop Demo + SOURCE +

+ + + +
+

- Mobile Demo SOURCE + Mobile Demo + SOURCE

-
+
+ +
diff --git a/src/app/app.module.ts b/src/app/app.module.ts index a7bcc84..2f00890 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -9,6 +9,8 @@ import { MatChipsModule } from '@angular/material/chips'; import { MatTabsModule } from '@angular/material/tabs'; import { MatIconModule } from '@angular/material/icon'; import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { DragDropModule } from '@angular/cdk/drag-drop'; import { BrowserModule } from '@angular/platform-browser'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; @@ -19,6 +21,9 @@ import { AppComponent } from './app.component'; import { FooterComponent } from './footer/footer.component'; import { HeaderComponent } from './header/header.component'; import { PhoneComponent } from './phone/phone.component'; +import { DragAndDropComponent } from './drag-and-drop/drag-and-drop.component'; +import { TaskComponent } from './drag-and-drop/task/task.component'; +import { TaskListComponent } from './drag-and-drop/task-list/task-list.component'; import { KeyComponent, @@ -36,6 +41,8 @@ const MATERIAL_MODULES = [ MatTabsModule, MatIconModule, MatButtonModule, + MatCardModule, + DragDropModule, ]; @NgModule({ @@ -49,6 +56,9 @@ const MATERIAL_MODULES = [ FooterComponent, HeaderComponent, PhoneComponent, + DragAndDropComponent, + TaskComponent, + TaskListComponent, ], imports: [ BrowserModule.withServerTransition({ appId: 'demo-app' }), diff --git a/src/app/drag-and-drop/drag-and-drop.component.html b/src/app/drag-and-drop/drag-and-drop.component.html new file mode 100644 index 0000000..494cd58 --- /dev/null +++ b/src/app/drag-and-drop/drag-and-drop.component.html @@ -0,0 +1,13 @@ + +
+ + + +
+
diff --git a/src/app/drag-and-drop/drag-and-drop.component.scss b/src/app/drag-and-drop/drag-and-drop.component.scss new file mode 100644 index 0000000..4497a6f --- /dev/null +++ b/src/app/drag-and-drop/drag-and-drop.component.scss @@ -0,0 +1,20 @@ +.list-wrapper { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 1rem; + min-height: 400px; +} + +.cdk-drag-preview { + box-sizing: border-box; + border-radius: 4px; + box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2), 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12); +} + +.cdk-drag-placeholder { + opacity: 0; +} + +.cdk-drag-animating { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} diff --git a/src/app/drag-and-drop/drag-and-drop.component.ts b/src/app/drag-and-drop/drag-and-drop.component.ts new file mode 100644 index 0000000..9d81538 --- /dev/null +++ b/src/app/drag-and-drop/drag-and-drop.component.ts @@ -0,0 +1,13 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-drag-and-drop', + templateUrl: './drag-and-drop.component.html', + styleUrls: ['./drag-and-drop.component.scss'], +}) +export class DragAndDropComponent { + todo = ['Next Task', 'Very Important Item', 'Open Ticket #1', 'Open Ticket #2', 'Not Important Task']; + doing = ['High Priority Task', 'In Progress Item']; + done = ['Completed Item']; + selectedItems: any[] = []; +} diff --git a/src/app/drag-and-drop/task-list/task-list.component.html b/src/app/drag-and-drop/task-list/task-list.component.html new file mode 100644 index 0000000..6a726fe --- /dev/null +++ b/src/app/drag-and-drop/task-list/task-list.component.html @@ -0,0 +1,20 @@ +

{{title}}

+ +
+ +
+ {{ selected }} +
+
+ {{ selected }} +
+
+
\ No newline at end of file diff --git a/src/app/drag-and-drop/task-list/task-list.component.scss b/src/app/drag-and-drop/task-list/task-list.component.scss new file mode 100644 index 0000000..4691402 --- /dev/null +++ b/src/app/drag-and-drop/task-list/task-list.component.scss @@ -0,0 +1,72 @@ +:host { + background-color: #ebecf0; + border-radius: 3px; + box-sizing: border-box; + display: flex; + flex-direction: column; + max-height: 100%; + position: relative; + white-space: normal; +} + +.list-header { + padding: 0.5rem; +} + +.list-cards { + margin: 0 4px; + padding: 0 4px; + display: grid; + grid-template-columns: 1fr; + grid-auto-rows: min-content; + gap: 0.4rem; + height: -webkit-fill-available; +} + +.list-cards.cdk-drop-list-dragging app-task:not(.cdk-drag-placeholder) { + transition: transform 250ms cubic-bezier(0, 0, 0.2, 1); +} + +:host(.item-dragging) { + app-task.selected:not(.cdk-drag-placeholder) { + display: none; + } +} + +.drag-placeholder-container { + display: flex; + flex-flow: column; + gap: 0.5rem; + min-height: 53px; + height: min-content; + + mat-card { + opacity: 0.5; + } +} + +.drag-preview-container { + gap: 0.5rem; + position: relative; + + mat-card { + position: absolute; + width: 200px; + top: -10px; + left: -25px; + } + + mat-card:nth-child(2) { + left: -40px; + top: -10px; + transform: rotate(-1deg); + z-index: -1; + } + + mat-card:nth-child(3) { + top: -5px; + left: -20px; + transform: rotate(1deg); + z-index: -2; + } +} diff --git a/src/app/drag-and-drop/task-list/task-list.component.ts b/src/app/drag-and-drop/task-list/task-list.component.ts new file mode 100644 index 0000000..f3eadda --- /dev/null +++ b/src/app/drag-and-drop/task-list/task-list.component.ts @@ -0,0 +1,81 @@ +import { CdkDragStart, CdkDragDrop } from '@angular/cdk/drag-drop'; +import { Component, ElementRef, HostBinding, Inject, Input, Optional, SkipSelf } from '@angular/core'; +import { DTS_SELECT_CONTAINER } from 'projects/ngx-drag-to-select/src/lib/tokens'; +import { SelectContainerComponent } from 'projects/ngx-drag-to-select/src/public_api'; + +@Component({ + selector: 'app-task-list', + styleUrls: ['./task-list.component.scss'], + templateUrl: './task-list.component.html', +}) +export class TaskListComponent { + @Input() title = 'Tasks'; + @Input() tasks = []; + + @HostBinding('class.item-dragging') + dragging = false; + + // CdkDragModule only supports dragging a single dom element + private selectDomRefs: { parent: Node; node: Node }[] = []; + + constructor( + @Inject(DTS_SELECT_CONTAINER) @Optional() public container: SelectContainerComponent, + private element: ElementRef + ) {} + + dragStarted(ev: CdkDragStart, index: number): void { + this.dragging = !!ev.source._dragRef; + this.container.selectItems((item) => { + return item === this.tasks[index]; + }); + + /** + * We remove the selected elements from the DOM, because the + * CdkDragDropModule includes them when reordering even if they. + * are hidden. + **/ + this.selectDomRefs = []; + this.element.nativeElement.querySelectorAll('.selected').forEach((node: Node) => { + this.selectDomRefs.push({ parent: node.parentNode, node }); + node.parentNode.removeChild(node); + }); + } + + dragEnded(): void { + /** + * Add the DOM elements back in because the Angular refs are still + * bound to them. + **/ + this.selectDomRefs.forEach(({ parent, node }) => { + parent.appendChild(node); + }); + this.dragging = null; + } + + dropped(event: CdkDragDrop): void { + this.dragging = null; + const indices = this.container.selectedItems.map((it) => event.previousContainer.data.findIndex((i) => it === i)); + indices.sort().reverse(); + indices.forEach((idx) => { + this.tasks.splice(idx, 1); + }); + + setTimeout(() => this.container.clearSelection()); + } + + drop(event: CdkDragDrop) { + const spliceIntoIndex = event.currentIndex; + this.tasks.splice(spliceIntoIndex, 0, ...this.container.selectedItems); + + /** + * Caution! + * When drop event is within the same drop-list the bounding box of + * drag events might not update to there new positions. Use the + * `update` method from the `SelectContainerComponent` to force the + * bounding boxes to be recalculated. + * This is put behind `setTimeout` because we have to wait until the next + * frame before angular change detection has updated the dom. + * */ + setTimeout(() => this.container.update()); + } +} diff --git a/src/app/drag-and-drop/task/task.component.html b/src/app/drag-and-drop/task/task.component.html new file mode 100644 index 0000000..1a52761 --- /dev/null +++ b/src/app/drag-and-drop/task/task.component.html @@ -0,0 +1,5 @@ + + {{ item }} + \ No newline at end of file diff --git a/src/app/drag-and-drop/task/task.component.scss b/src/app/drag-and-drop/task/task.component.scss new file mode 100644 index 0000000..2b3cb9e --- /dev/null +++ b/src/app/drag-and-drop/task/task.component.scss @@ -0,0 +1,13 @@ +:host { + display: block; + + &.selected { + mat-card { + border: 1px solid #2196f3; + } + } +} + +mat-card { + border: 1px solid transparent; +} diff --git a/src/app/drag-and-drop/task/task.component.ts b/src/app/drag-and-drop/task/task.component.ts new file mode 100644 index 0000000..b2279c9 --- /dev/null +++ b/src/app/drag-and-drop/task/task.component.ts @@ -0,0 +1,18 @@ +import { Component, Inject, Input, Optional, SkipSelf } from '@angular/core'; +import { SelectContainerComponent } from 'projects/ngx-drag-to-select/src/public_api'; +import { DTS_SELECT_CONTAINER } from 'projects/ngx-drag-to-select/src/lib/tokens'; + +@Component({ + selector: 'app-task', + styleUrls: ['./task.component.scss'], + templateUrl: './task.component.html', +}) +export class TaskComponent { + @Input() item = ''; + constructor(@Inject(DTS_SELECT_CONTAINER) @Optional() public container: SelectContainerComponent) {} + + select() { + this.container.clearSelection(); + this.container.selectItems((it) => it === this.item); + } +}