Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -270,4 +270,36 @@ describe('DataTableHeaderComponent', () => {
const rightGroupStyle = await harness.getTransformStyle('right');
expect(rightGroupStyle).toBe('width: 200px;');
});

it('should not reorder a column when the pointer is not moved', async () => {
const columns = toInternalColumn([
{ prop: 'col1', name: 'Column 1', width: 100, draggable: true },
{ prop: 'col2', name: 'Column 2', width: 200, draggable: true }
]);
componentRef.setInput('columns', columns);
componentRef.setInput('reorderable', true);
await fixture.whenStable();

const firstCell = fixture.nativeElement.querySelector('datatable-header-cell') as HTMLElement;
const reorderSpy = vi.fn();
componentRef.instance.reorder.subscribe(reorderSpy);
const elementsFromPointSpy = vi
.spyOn(document, 'elementsFromPoint')
.mockReturnValue([firstCell]);

const dragEvent = {
initialX: 50,
initialY: 0,
currentX: 50,
currentY: 0,
element: firstCell,
model: columns[0]
};
componentRef.instance.onDragStart(dragEvent);
componentRef.instance.onDragEnd(dragEvent);

expect(reorderSpy).not.toHaveBeenCalled();
expect(firstCell.style.transform).toBe('');
elementsFromPointSpy.mockRestore();
});
});
136 changes: 79 additions & 57 deletions projects/ngx-datatable/src/lib/components/header/header.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,28 @@ import { NgStyle } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
DOCUMENT,
ElementRef,
inject,
TemplateRef,
input,
computed,
output
output,
signal,
TemplateRef,
viewChildren
} from '@angular/core';

import { DatatableDraggableDirective } from '../../directives/datatable-draggable.directive';
import { OrderableDirective } from '../../directives/orderable.directive';
import {
DatatableDraggableDirective,
DragEvent
} from '../../directives/datatable-draggable.directive';
import { ScrollbarHelper } from '../../services/scrollbar-helper.service';
import {
ColumnResizeEventInternal,
InnerSortEvent,
ReorderEventInternal,
SortableTableColumnInternal,
TableColumnInternal,
TargetChangedEvent
TableColumnInternal
} from '../../types/internal.types';
import {
Row,
Expand All @@ -34,17 +39,14 @@ import { DataTableHeaderCellComponent } from './header-cell.component';

@Component({
selector: 'datatable-header',
imports: [OrderableDirective, NgStyle, DataTableHeaderCellComponent, DatatableDraggableDirective],
imports: [NgStyle, DataTableHeaderCellComponent, DatatableDraggableDirective],
template: `
@let _columnGroupWidths = this._columnGroupWidths();
<div
role="row"
orderable
class="datatable-header-inner"
[class.horizontal-overflow]="innerWidth() < _columnGroupWidths.total"
[style.width.px]="_columnGroupWidths.total"
(reorder)="onColumnReordered($event)"
(targetChanged)="onTargetChanged($event)"
>
@for (colGroup of _columnsByPin(); track colGroup.type) {
@if (colGroup.columns.length) {
Expand All @@ -59,9 +61,9 @@ import { DataTableHeaderCellComponent } from './header-cell.component';
dragStartDelay="500"
[datatableDraggable]="reorderable() && column.draggable"
[dragModel]="column"
[isTarget]="column.isTarget"
[isTarget]="targetColumn() === column"
[targetMarkerTemplate]="targetMarkerTemplate()"
[targetMarkerContext]="column.targetMarkerContext"
[targetMarkerContext]="targetMarkerContext()"
[column]="column"
[showResizeHandle]="lastColumnId() !== column.$$id && column.resizeable"
[sortType]="sortType()"
Expand All @@ -73,6 +75,9 @@ import { DataTableHeaderCellComponent } from './header-cell.component';
[allRowsSelected]="allRowsSelected()"
[enableClearingSortState]="enableClearingSortState()"
[ariaHeaderCheckboxMessage]="ariaHeaderCheckboxMessage()"
(dragStart)="onDragStart($event)"
(dragMove)="onDragMove($event)"
(dragEnd)="onDragEnd($event)"
(resize)="onColumnResized($event)"
(resizing)="onColumnResizing($event)"
(sort)="onSort($event)"
Expand All @@ -94,7 +99,9 @@ import { DataTableHeaderCellComponent } from './header-cell.component';
}
})
export class DataTableHeaderComponent {
private scrollbarHelper = inject(ScrollbarHelper);
private readonly document = inject(DOCUMENT);
private readonly scrollbarHelper = inject(ScrollbarHelper);
private readonly headerCells = viewChildren(DataTableHeaderCellComponent, { read: ElementRef });

readonly lastColumnId = computed(() => this.columns().at(-1)?.$$id);

Expand All @@ -113,7 +120,6 @@ export class DataTableHeaderComponent {
readonly reorderable = input<boolean>();
readonly verticalScrollVisible = input(false);
readonly ariaHeaderCheckboxMessage = input.required<string>();

readonly headerHeight = input.required<'auto' | number>();
readonly columns = input.required<TableColumnInternal[]>();

Expand All @@ -127,21 +133,16 @@ export class DataTableHeaderComponent {
column: TableColumnInternal;
}>();

readonly _columnsByPin = computed(() => {
return columnsByPinArr(this.columns());
});
readonly _columnsByPin = computed(() => columnsByPinArr(this.columns()));
readonly _columnGroupWidths = computed(() => {
const colsByPin = columnsByPin(this.columns());
return columnGroupWidths(colsByPin, this.columns());
});
readonly _styleByGroup = computed(() => {
return {
left: this.calcStylesByGroup('left'),
center: this.calcStylesByGroup('center'),
right: this.calcStylesByGroup('right')
};
});

readonly _styleByGroup = computed(() => ({
left: this.calcStylesByGroup('left'),
center: this.calcStylesByGroup('center'),
right: this.calcStylesByGroup('right')
}));
readonly headerWidth = computed(() => {
if (this.scrollbarH()) {
const width = this.verticalScrollVisible()
Expand All @@ -152,6 +153,15 @@ export class DataTableHeaderComponent {

return '100%';
});
readonly columnGroups = computed(() => this._columnsByPin());
private readonly renderedColumns = computed(() =>
this.columnGroups().flatMap(group => group.columns)
);

private dragInitialIndex?: number;
private dragTargetIndex?: number;
readonly targetColumn = signal<TableColumnInternal | undefined>(undefined);
readonly targetMarkerContext = signal<{ class: string } | undefined>(undefined);

onColumnResized({ width, column }: { width: number; column: TableColumnInternal }): void {
this.resize.emit(this.makeResizeEvent(width, column));
Expand All @@ -177,44 +187,59 @@ export class DataTableHeaderComponent {
};
}

onColumnReordered(event: ReorderEventInternal): void {
const column = this.getColumn(event.newValue);
column.isTarget = false;
column.targetMarkerContext = undefined;
this.reorder.emit(event);
onDragStart({ model }: DragEvent): void {
this.dragInitialIndex = model ? this.renderedColumns().indexOf(model) : undefined;
}

onTargetChanged({ prevIndex, newIndex, initialIndex }: TargetChangedEvent): void {
if (prevIndex || prevIndex === 0) {
const oldColumn = this.getColumn(prevIndex);
oldColumn.isTarget = false;
oldColumn.targetMarkerContext = undefined;
}
if (newIndex || newIndex === 0) {
const newColumn = this.getColumn(newIndex);
newColumn.isTarget = true;
onDragMove(event: DragEvent): void {
const targetIndex = this.getDragTargetIndex(event);
if (targetIndex !== this.dragTargetIndex) {
if (this.dragTargetIndex !== undefined) {
this.targetColumn.set(undefined);
this.targetMarkerContext.set(undefined);
}

if (initialIndex !== newIndex) {
newColumn.targetMarkerContext = {
class: 'targetMarker '.concat(initialIndex > newIndex ? 'dragFromRight' : 'dragFromLeft')
};
if (targetIndex !== undefined && this.dragInitialIndex !== undefined) {
this.targetColumn.set(this.renderedColumns()[targetIndex]);
if (this.dragInitialIndex !== targetIndex) {
this.targetMarkerContext.set({
class: `targetMarker ${this.dragInitialIndex > targetIndex ? 'dragFromRight' : 'dragFromLeft'}`
});
}
}
this.dragTargetIndex = targetIndex;
}

event.element.style.transform = `translateX(${event.currentX - event.initialX}px)`;
Comment thread
fh1ch marked this conversation as resolved.
}

getColumn(index: number): any {
const _columnsByPin = this._columnsByPin();
const leftColumnCount = _columnsByPin[0].columns.length;
if (index < leftColumnCount) {
return _columnsByPin[0].columns[index];
onDragEnd(event: DragEvent): void {
event.element.style.transform = '';

const targetIndex = this.getDragTargetIndex(event);
if (this.dragTargetIndex !== undefined) {
this.targetColumn.set(undefined);
this.targetMarkerContext.set(undefined);
}

const centerColumnCount = _columnsByPin[1].columns.length;
if (index < leftColumnCount + centerColumnCount) {
return _columnsByPin[1].columns[index - leftColumnCount];
if (event.model && this.dragInitialIndex !== undefined && targetIndex !== undefined) {
this.reorder.emit({
prevValue: this.dragInitialIndex,
newValue: targetIndex,
column: event.model
});
}

return _columnsByPin[2].columns[index - leftColumnCount - centerColumnCount];
this.dragInitialIndex = undefined;
this.dragTargetIndex = undefined;
}

private getDragTargetIndex({ currentX, currentY, element }: DragEvent): number | undefined {
const elementsAtPoint = this.document.elementsFromPoint(currentX, currentY);
const index = this.headerCells().findIndex(
cell => cell.nativeElement !== element && elementsAtPoint.includes(cell.nativeElement)
);
return index === -1 ? undefined : index;
}

onSort({ column, prevValue, newValue }: InnerSortEvent): void {
Expand Down Expand Up @@ -264,9 +289,6 @@ export class DataTableHeaderComponent {

calcStylesByGroup(group: 'center' | 'right' | 'left'): NgStyle['ngStyle'] {
const widths = this._columnGroupWidths();

return {
width: `${widths[group]}px`
};
return { width: `${widths[group]}px` };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface DragEvent {
initialY: number;
currentX: number;
currentY: number;
element: HTMLElement;
model?: TableColumnInternal;
}

@Directive({
Expand All @@ -38,13 +40,15 @@ export class DatatableDraggableDirective implements OnDestroy {
readonly dragStartDelay = input(0, { transform: numberAttribute });
readonly enabled = input(true, { transform: booleanAttribute, alias: 'datatableDraggable' });
readonly dragMove = output<DragEvent>();
readonly dragEnd = output<void>();
readonly dragStart = output<void>();
readonly dragEnd = output<DragEvent>();
readonly dragStart = output<DragEvent>();

private timeoutId?: number;
private touchId?: number;
private readonly startX = signal<number | undefined>(undefined);
private readonly startY = signal<number | undefined>(undefined);
private currentX?: number;
private currentY?: number;
protected readonly isLongPressing = computed(
() => this.dragStartDelay() !== 0 && this.isDragging()
);
Expand Down Expand Up @@ -130,20 +134,20 @@ export class DatatableDraggableDirective implements OnDestroy {
private starting(clientX: number, clientY: number): void {
this.startX.set(clientX);
this.startY.set(clientY);
this.dragStart.emit();
this.currentX = clientX;
this.currentY = clientY;
this.dragStart.emit(this.dragEvent());
}

private moving(clientX: number, clientY: number): void {
this.dragMove.emit({
initialX: this.startX()!,
initialY: this.startY()!,
currentX: clientX,
currentY: clientY
});
this.currentX = clientX;
this.currentY = clientY;
this.dragMove.emit(this.dragEvent());
}

private ending = (): void => {
const dragged = this.isDragging();
const dragEvent = dragged ? this.dragEvent() : undefined;
this.document.removeEventListener('mousemove', this.mousemove);
this.document.removeEventListener('touchmove', this.touchmove);
this.document.removeEventListener('mouseup', this.ending);
Expand All @@ -156,10 +160,21 @@ export class DatatableDraggableDirective implements OnDestroy {
// In that case, we don't want to emit dragEnd.
if (dragged) {
this.setDragging(false);
this.dragEnd.emit();
this.dragEnd.emit(dragEvent!);
}
};

private dragEvent(): DragEvent {
return {
initialX: this.startX()!,
initialY: this.startY()!,
currentX: this.currentX!,
currentY: this.currentY!,
element: this.element,
model: this.dragModel()
};
}

private setDragging(dragging: boolean): void {
const model = this.dragModel();
if (model) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { DraggableHarness } from './testing/draggable.harness';
[datatableDraggable]="enabled()"
[dragStartDelay]="dragStartDelay()"
(dragStart)="dragStart()"
(dragEnd)="dragEnd()"
(dragEnd)="dragEnd($event)"
(dragMove)="dragMove($event)"
></div>
`
Expand All @@ -26,7 +26,7 @@ class TestFixtureComponent {

dragStart(): void {}

dragEnd(): void {}
dragEnd(event: DragEvent): void {}

dragMove(event: DragEvent): void {}
}
Expand All @@ -36,7 +36,7 @@ describe('DraggableDirective', () => {
let component: TestFixtureComponent;
let harness: DraggableHarness;
let dragStartSpy: Mock<() => void>;
let dragEndSpy: Mock<() => void>;
let dragEndSpy: Mock<(event: DragEvent) => void>;
let dragMoveSpy: Mock<(event: DragEvent) => void>;

beforeEach(async () => {
Expand Down Expand Up @@ -70,6 +70,19 @@ describe('DraggableDirective', () => {
vi.useRealTimers();
});

it('should provide the initial position when dragging ends without moving', async () => {
vi.useFakeTimers();
await harness.mouseDown(25);
vi.advanceTimersByTime(0);
await fixture.whenStable();
await harness.mouseUp();

expect(dragEndSpy).toHaveBeenCalledWith(
expect.objectContaining({ currentX: 25, initialX: 25 })
);
vi.useRealTimers();
});

it('should fire touch drag events', async () => {
vi.useFakeTimers();
await harness.touchStart(0);
Expand Down
Loading
Loading