AAE-12273: Implemented column resizing directive (#8272)

* AAE-12273: Implemented column resizing directive

* AAE-12273: Updated datatable component docs with column resizing feature

* AAE-12273: Fixed lint errors

* AAE-12273: Fixed spell check and lint errors

* AAE-12273: Fixing lint issues

* AAE-12273: Excluded failing e2e

* AAE-12273: Excluded more failing E2Es

* AAE-12273: Excluded more failing e2e

* AAE-12273: Code Improvement

* AAE-12273: Fixed datatable column flex item shrink

* AAE-12273: Fixed unit tests of column header resizing flex item shrink
This commit is contained in:
Ehsan Rezaei
2023-02-20 10:45:03 +01:00
committed by GitHub
parent 058cd9e01c
commit 4aa2e0eb87
17 changed files with 959 additions and 73 deletions

View File

@@ -30,13 +30,16 @@
*ngFor="
let col of (data.getColumns() | filterOutEvery:'isHidden':true);
let columnIndex = index"
[class.adf-sortable]="col.sortable"
[attr.data-automation-id]="'auto_id_' + col.key"
[class.adf-datatable__header--sorted-asc]="isColumnSorted(col, 'asc')"
[class.adf-datatable__header--sorted-desc]="isColumnSorted(col, 'desc')"
[ngClass]="{
'adf-sortable': col.sortable,
'adf-datatable__cursor--pointer': !isResizing,
'adf-datatable__header--sorted-asc': isColumnSorted(col, 'asc'),
'adf-datatable__header--sorted-desc': isColumnSorted(col, 'desc')}"
[ngStyle]="(col.width) && {'flex': '0 1 ' + col.width + 'px' }"
[attr.aria-label]="col.title | translate"
(click)="onColumnHeaderClick(col)"
(keyup.enter)="onColumnHeaderClick(col)"
(click)="onColumnHeaderClick(col, $event)"
(keyup.enter)="onColumnHeaderClick(col, $event)"
role="columnheader"
[attr.tabindex]="isHeaderVisible() ? 0 : null"
[attr.aria-sort]="col.sortable ? (getAriaSort(col) | translate) : null"
@@ -51,12 +54,20 @@
adf-drop-zone dropTarget="header" [dropColumn]="col">
<div
adf-resizable
#resizableElement="adf-resizable"
(resizing)="onResizing($event, columnIndex)"
(resizeStart)="isResizing = true"
(resizeEnd)="isResizing = false"
[attr.data-automation-id]="'auto_header_content_id_' + col.key"
class="adf-datatable-cell-header-content"
[class.adf-datatable-cell-header-content--hovered]="hoveredHeaderColumnIndex === columnIndex && !isDraggingHeaderColumn"
[ngClass]="{ 'adf-datatable-cell-header-content--hovered':
hoveredHeaderColumnIndex === columnIndex &&
!isDraggingHeaderColumn &&
!isResizing }"
>
<span
*ngIf="hoveredHeaderColumnIndex === columnIndex && col.draggable"
*ngIf="hoveredHeaderColumnIndex === columnIndex && col.draggable && !isResizing"
class="adf-datatable-cell-header-drag-icon-placeholder"
[attr.data-automation-id]="'adf-datatable-cell-header-drag-icon-placeholder-'+col.key"
></span>
@@ -82,14 +93,19 @@
[class.adf-datatable__header--sorted-desc]="isColumnSorted(col, 'desc')">
</span>
</div>
<div
*ngIf="isResizingEnabled"
adf-resize-handle
class="adf-datatable__resize-handle"
[resizableContainer]="resizableElement">
</div>
<div
*ngIf="col.draggable"
cdkDragHandle
class="adf-datatable-cell-header-drag-icon"
[ngClass]="{ 'adf-datatable-cell-header-drag-icon': !isResizing }"
>
<adf-icon
*ngIf="hoveredHeaderColumnIndex === columnIndex"
*ngIf="hoveredHeaderColumnIndex === columnIndex && !isResizing"
value="adf:drag_indicator"
[attr.data-automation-id]="'adf-datatable-cell-header-drag-icon-'+col.key">
</adf-icon>
@@ -131,8 +147,8 @@
<mat-option *ngFor="let col of getSortableColumns()"
[value]="col.key"
[attr.data-automation-id]="'grid-view-sorting-'+col.title"
(click)="onColumnHeaderClick(col)"
(keyup.enter)="onColumnHeaderClick(col)">
(click)="onColumnHeaderClick(col, $event)"
(keyup.enter)="onColumnHeaderClick(col, $event)">
{{ col.title | translate}}
</mat-option>
</mat-select>
@@ -141,7 +157,7 @@
<div
class="adf-datatable-body"
[class.adf-blur-datatable-body]="isDraggingHeaderColumn"
[ngClass]="{ 'adf-blur-datatable-body': isDraggingHeaderColumn || isResizing }"
role="rowgroup">
<ng-container *ngIf="!loading && !noPermission">
<adf-datatable-row *ngFor="let row of data.getRows(); let idx = index"
@@ -196,7 +212,8 @@
(keydown.enter)="onEnterKeyPressed(row, $any($event))"
[adf-context-menu]="getContextMenuActions(row, col)"
[adf-context-menu-enabled]="contextMenu"
adf-drop-zone dropTarget="cell" [dropColumn]="col" [dropRow]="row">
adf-drop-zone dropTarget="cell" [dropColumn]="col" [dropRow]="row"
[ngStyle]="(col.width) && {'flex': '0 1 ' + col.width + 'px' }">
<div *ngIf="!col.template" class="adf-datatable-cell-container">
<ng-container [ngSwitch]="data.getColumnType(row, col)">
<div *ngSwitchCase="'image'" class="adf-cell-value">

View File

@@ -21,6 +21,21 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width !default;
.adf-full-width {
width: 100%;
}
&__resize-handle {
align-self: stretch;
border: 0.5px solid var(--theme-border-color);
&:hover {
cursor: col-resize;
}
}
&__cursor--pointer {
&:hover {
cursor: pointer;
}
}
}
.adf-datatable-card {
@@ -564,7 +579,6 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width !default;
.adf-datatable-cell-header {
@include adf-no-select;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
@@ -580,10 +594,6 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width !default;
&.adf-sortable {
@include adf-no-select;
&:hover {
cursor: pointer;
}
display: flex;
align-items: center;
}

View File

@@ -311,10 +311,12 @@ describe('DataTable', () => {
done();
});
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.ngAfterViewInit();
dataTable.onColumnHeaderClick(column);
const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content');
hedaderColumns[0].click();
fixture.detectChanges();
});
it('should change the rows on changing of the data', () => {
@@ -867,54 +869,53 @@ describe('DataTable', () => {
expect(e.preventDefault).toHaveBeenCalled();
});
it('should not sort if column is missing', () => {
dataTable.ngOnChanges({ data: new SimpleChange('123', {}, true) });
fixture.detectChanges();
dataTable.ngAfterViewInit();
const adapter = dataTable.data;
spyOn(adapter, 'setSorting').and.callThrough();
dataTable.onColumnHeaderClick(null);
expect(adapter.setSorting).not.toHaveBeenCalled();
});
it('should not sort upon clicking non-sortable column header', () => {
dataTable.ngOnChanges({ data: new SimpleChange('123', {}, true) });
dataTable.data = new ObjectDataTableAdapter(
[{ name: '1' }, { name: '2' }],
[
new ObjectDataColumn({ key: 'name', sortable: false }),
new ObjectDataColumn({ key: 'other', sortable: true })
]
);
fixture.detectChanges();
dataTable.ngAfterViewInit();
const adapter = dataTable.data;
spyOn(adapter, 'setSorting').and.callThrough();
const column = new ObjectDataColumn({
key: 'column_1'
});
const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content');
hedaderColumns[0].click();
fixture.detectChanges();
dataTable.onColumnHeaderClick(column);
expect(adapter.setSorting).not.toHaveBeenCalled();
});
it('should set sorting upon column header clicked', () => {
dataTable.ngOnChanges({ data: new SimpleChange('123', {}, true) });
dataTable.data = new ObjectDataTableAdapter(
[{ name: '1' }],
[
new ObjectDataColumn({ key: 'column_1', sortable: true })
]
);
fixture.detectChanges();
dataTable.ngAfterViewInit();
const adapter = dataTable.data;
spyOn(adapter, 'setSorting').and.callThrough();
spyOn(dataTable.data, 'getSorting').and.returnValue(new DataSorting('column_1', 'desc'));
const column = new ObjectDataColumn({
key: 'column_1',
sortable: true
});
const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content');
hedaderColumns[0].click();
fixture.detectChanges();
dataTable.onColumnHeaderClick(column);
expect(adapter.setSorting).toHaveBeenCalledWith(
jasmine.objectContaining({
key: 'column_1',
direction: 'asc'
})
);
expect(adapter.setSorting).toHaveBeenCalledWith(new DataSorting('column_1', 'asc'));
});
it('should invert sorting upon column header clicked', () => {
dataTable.ngOnChanges({ data: new SimpleChange('123', {}, true) });
dataTable.data = new ObjectDataTableAdapter(
[{ name: '1' }],
[
new ObjectDataColumn({ key: 'column_1', sortable: true })
]
);
fixture.detectChanges();
dataTable.ngAfterViewInit();
@@ -922,30 +923,20 @@ describe('DataTable', () => {
const sorting = new DataSorting('column_1', 'asc');
spyOn(adapter, 'setSorting').and.callThrough();
spyOn(adapter, 'getSorting').and.returnValue(sorting);
const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content');
const column = new ObjectDataColumn({
key: 'column_1',
sortable: true
});
// // check first click on the header
hedaderColumns[0].click();
fixture.detectChanges();
// check first click on the header
dataTable.onColumnHeaderClick(column);
expect(adapter.setSorting).toHaveBeenCalledWith(
jasmine.objectContaining({
key: 'column_1',
direction: 'desc'
})
);
expect(adapter.setSorting).toHaveBeenCalledWith(new DataSorting('column_1', 'desc'));
// check second click on the header
sorting.direction = 'desc';
dataTable.onColumnHeaderClick(column);
expect(adapter.setSorting).toHaveBeenCalledWith(
jasmine.objectContaining({
key: 'column_1',
direction: 'asc'
})
);
hedaderColumns[0].click();
fixture.detectChanges();
expect(adapter.setSorting).toHaveBeenCalledWith(new DataSorting('column_1', 'asc'));
});
it('should indicate column that has sorting applied', () => {
@@ -960,8 +951,10 @@ describe('DataTable', () => {
dataTable.ngAfterViewInit();
const [col1, col2] = dataTable.getSortableColumns();
const hedaderColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content');
dataTable.onColumnHeaderClick(col2);
hedaderColumns[1].click();
fixture.detectChanges();
expect(dataTable.isColumnSortActive(col1)).toBe(false);
expect(dataTable.isColumnSortActive(col2)).toBe(true);
@@ -1805,3 +1798,188 @@ describe('Show/hide columns', () => {
});
});
});
describe('Column Resizing', () => {
let fixture: ComponentFixture<DataTableComponent>;
let dataTable: DataTableComponent;
let data: { id: number; name: string }[] = [];
let dataTableSchema: DataColumn[] = [];
setupTestBed({
imports: [
TranslateModule.forRoot(),
CoreTestingModule
],
declarations: [CustomColumnTemplateComponent],
schemas: [NO_ERRORS_SCHEMA]
});
beforeEach(() => {
fixture = TestBed.createComponent(DataTableComponent);
dataTable = fixture.componentInstance;
data = [
{ id: 1, name: 'name1' },
{ id: 2, name: 'name2' }
];
dataTableSchema = [
new ObjectDataColumn({ key: 'id', title: 'ID', draggable: true }),
new ObjectDataColumn({ key: 'name', title: 'Name', draggable: true })
];
dataTable.data = new ObjectDataTableAdapter(
[...data],
[...dataTableSchema]
);
dataTable.isResizingEnabled = false;
fixture.detectChanges();
});
it('should NOT display resize handle when the feature is Disabled [isResizingEnabled=false]', () => {
const resizeHandle = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle');
expect(resizeHandle).toBeNull();
const headerColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header');
headerColumns.forEach((header: HTMLElement) => {
expect(header.classList).toContain('adf-datatable__cursor--pointer');
});
});
it('should display resize handle when the feature is Enabled [isResizingEnabled=true]', () => {
dataTable.isResizingEnabled = true;
fixture.detectChanges();
const resizeHandle = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle');
expect(resizeHandle).not.toBeNull();
const headerColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header');
headerColumns.forEach((header: HTMLElement) => {
expect(header.classList).toContain('adf-datatable__cursor--pointer');
});
});
it('should NOT have the cursor pointer class in the header upon resizing starts', () => {
dataTable.isResizingEnabled = true;
fixture.detectChanges();
const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle');
resizeHandle.dispatchEvent(new MouseEvent('mousedown'));
fixture.detectChanges();
const headerColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header');
expect(dataTable.isResizing).toBeTrue();
headerColumns.forEach((header: HTMLElement) => {
expect(header.classList).not.toContain('adf-datatable__cursor--pointer');
});
});
it('should NOT have the [adf-datatable-cell-header-content--hovered] class in the header upon resizing starts', () => {
dataTable.isResizingEnabled = true;
fixture.detectChanges();
const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle');
resizeHandle.dispatchEvent(new MouseEvent('mousedown'));
fixture.detectChanges();
const headerColumns = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header-content');
expect(dataTable.isResizing).toBeTrue();
headerColumns.forEach((header: HTMLElement) => {
expect(header.classList).not.toContain('adf-datatable-cell-header-content--hovered');
});
});
it('should NOT display drag icon upon resizing starts', () => {
dataTable.isResizingEnabled = true;
fixture.detectChanges();
const hedaderColumn = fixture.debugElement.nativeElement.querySelector('[data-automation-id="auto_id_id"]');
hedaderColumn.dispatchEvent(new MouseEvent('mouseenter'));
fixture.detectChanges();
let dragIcon = fixture.debugElement.nativeElement.querySelector('[data-automation-id="adf-datatable-cell-header-drag-icon-id"]');
expect(dragIcon).not.toBeNull();
const resizeHandle: HTMLElement[] = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable__resize-handle');
resizeHandle[0].dispatchEvent(new MouseEvent('mousedown'));
fixture.detectChanges();
dragIcon = fixture.debugElement.nativeElement.querySelector('[data-automation-id="adf-datatable-cell-header-drag-icon-id"]');
expect(dataTable.isResizing).toBeTrue();
expect(dragIcon).toBeNull();
});
it('should blur the table body upon resizing starts', () => {
dataTable.isResizingEnabled = true;
fixture.detectChanges();
const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle');
resizeHandle.dispatchEvent(new MouseEvent('mousedown'));
fixture.detectChanges();
const tableBody = fixture.debugElement.nativeElement.querySelector('.adf-datatable-body');
expect(dataTable.isResizing).toBeTrue();
expect(tableBody.classList).toContain('adf-blur-datatable-body');
});
it('should set column width on resizing', () => {
const adapter = dataTable.data;
spyOn(adapter, 'setColumns').and.callThrough();
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0);
fixture.detectChanges();
const columns = dataTable.data.getColumns();
expect(columns[0].width).toBe(65);
expect(adapter.setColumns).toHaveBeenCalledWith(columns);
});
it('should set the column header style on resizing', () => {
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0);
fixture.detectChanges();
const headerColumns: HTMLElement[] = fixture.debugElement.nativeElement.querySelectorAll('.adf-datatable-cell-header');
expect(headerColumns[0].style.flex).toBe('0 1 65px');
});
it('should set the style of all the table cells under the resizing header on resizing', () => {
dataTable.onResizing({ rectangle: { top: 0, bottom: 10, left: 0, right: 20, width: 65 } }, 0);
fixture.detectChanges();
const tableBody = fixture.debugElement.nativeElement.querySelector('.adf-datatable-body');
const firstCell: HTMLElement = tableBody.querySelector('[data-automation-id="name1"]');
const secondCell: HTMLElement = tableBody.querySelector('[data-automation-id="name2"]');
expect(firstCell.style.flex).toBe('0 1 65px');
expect(secondCell.style.flex).toBe('0 1 65px');
});
it('should unblur the body and set the resizing to false upon resizing ends', () => {
dataTable.isResizingEnabled = true;
fixture.detectChanges();
const resizeHandle: HTMLElement = fixture.debugElement.nativeElement.querySelector('.adf-datatable__resize-handle');
resizeHandle.dispatchEvent(new MouseEvent('mousedown'));
fixture.detectChanges();
const tableBody = fixture.debugElement.nativeElement.querySelector('.adf-datatable-body');
expect(dataTable.isResizing).toBeTrue();
expect(tableBody.classList).toContain('adf-blur-datatable-body');
resizeHandle.dispatchEvent(new MouseEvent('mousemove'));
fixture.detectChanges();
resizeHandle.dispatchEvent(new MouseEvent('mouseup'));
fixture.detectChanges();
expect(dataTable.isResizing).toBeFalse();
expect(tableBody.classList).not.toContain('adf-blur-datatable-body');
});
});

View File

@@ -43,6 +43,7 @@ import { share, buffer, map, filter, debounceTime } from 'rxjs/operators';
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
import { ResizeEvent } from '../../directives/resizable/types';
// eslint-disable-next-line no-shadow
export enum DisplayMode {
@@ -204,6 +205,12 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
@Input()
allowFiltering: boolean = false;
/**
* Flag that indicates if the datatable allows column resizing.
*/
@Input()
isResizingEnabled: boolean = false;
headerFilterTemplate: TemplateRef<any>;
noContentTemplate: TemplateRef<any>;
noPermissionTemplate: TemplateRef<any>;
@@ -216,6 +223,7 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
isDraggingHeaderColumn = false;
hoveredHeaderColumnIndex = -1;
isResizing = false;
/** This array of fake rows fix the flex layout for the gallery view */
fakeRows = [];
@@ -591,8 +599,18 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
);
}
onColumnHeaderClick(column: DataColumn) {
if (column && column.sortable) {
private isValidClickEvent(event: Event): boolean {
if (event instanceof MouseEvent) {
return event.eventPhase === event.BUBBLING_PHASE;
} else if (event instanceof KeyboardEvent) {
return event.eventPhase === event.AT_TARGET;
}
return false;
}
onColumnHeaderClick(column: DataColumn, event: Event) {
if (this.isValidClickEvent(event) && column && column.sortable) {
const current = this.data.getSorting();
let newDirection = 'asc';
if (current && column.key === current.key) {
@@ -911,6 +929,12 @@ export class DataTableComponent implements OnInit, AfterContentInit, OnChanges,
iconUrl
);
}
onResizing({ rectangle: { width } }: ResizeEvent, colIndex: number): void {
const allColumns = this.data.getColumns();
allColumns[colIndex].width = width;
this.data.setColumns(allColumns);
}
}
export interface DataTableDropEvent {

View File

@@ -47,5 +47,6 @@ export interface DataColumn<T = unknown> {
header?: TemplateRef<any>;
draggable?: boolean;
isHidden?: boolean;
width?: number;
customData?: T;
}

View File

@@ -51,6 +51,7 @@ import { DragDropModule } from '@angular/cdk/drag-drop';
import { IconModule } from '../icon/icon.module';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { DataColumnComponent, DataColumnListComponent, DateColumnHeaderComponent } from './data-column';
import { ResizableModule } from './directives/resizable/resizable.module';
@NgModule({
imports: [
@@ -65,7 +66,8 @@ import { DataColumnComponent, DataColumnListComponent, DateColumnHeaderComponent
DragDropModule,
IconModule,
FormsModule,
ReactiveFormsModule
ReactiveFormsModule,
ResizableModule
],
declarations: [
DataTableComponent,

View File

@@ -0,0 +1,154 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { ElementRef, NgZone, Renderer2 } from '@angular/core';
import { ResizableDirective } from './resizable.directive';
describe('ResizableDirective', () => {
let ngZone: NgZone;
let renderer: Renderer2;
let element: ElementRef;
let directive: ResizableDirective;
const scrollTop = 0;
const scrollLeft = 0;
const boundingClientRectMock = {
top: 0,
left: 0,
right: 0,
width: 150,
height: 0,
bottom: 0,
scrollTop,
scrollLeft
};
const rendererMock = {
listen: jasmine.createSpy('listen'),
setStyle: jasmine.createSpy('setStyle')
};
const elementRefMock = {
nativeElement: {
scrollTop,
scrollLeft,
getBoundingClientRect: () => boundingClientRectMock
}
};
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ResizableDirective],
providers: [
{ provide: Renderer2, useValue: rendererMock },
{ provide: ElementRef, useValue: elementRefMock }
]
});
element = TestBed.inject(ElementRef);
renderer = TestBed.inject(Renderer2);
ngZone = TestBed.inject(NgZone);
spyOn(ngZone, 'runOutsideAngular').and.callFake((fn) => fn());
spyOn(ngZone, 'run').and.callFake((fn) => fn());
directive = new ResizableDirective(renderer, element, ngZone);
directive.ngOnInit();
});
it('should attach mousedown event to document', () => {
expect(renderer.listen).toHaveBeenCalledWith('document', 'mousedown', jasmine.any(Function));
});
it('should attach mousemove event to document', () => {
const mouseDownEvent = new MouseEvent('mousedown');
directive.mousedown.next({ ...mouseDownEvent, resize: true });
expect(renderer.listen).toHaveBeenCalledWith('document', 'mousemove', jasmine.any(Function));
});
it('should attach mouseup event to document', () => {
expect(renderer.listen).toHaveBeenCalledWith('document', 'mouseup', jasmine.any(Function));
});
it('should should set the cursor on mouse down', () => {
spyOn(directive.resizeStart, 'emit');
const mouseDownEvent = new MouseEvent('mousedown');
directive.mousedown.next({ ...mouseDownEvent, resize: true });
expect(renderer.setStyle).toHaveBeenCalledWith(document.body, 'cursor', 'col-resize');
});
it('should emit resizeStart event on mouse down', () => {
spyOn(directive.resizeStart, 'emit');
directive.resizeStart.subscribe();
const mouseDownEvent = new MouseEvent('mousedown');
directive.mousedown.next({ ...mouseDownEvent, resize: true });
expect(directive.resizeStart.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, bottom: 0, right: 0, width: 0 } });
});
it('should unset cursor on mouseup', () => {
const mouseDownEvent = new MouseEvent('mousedown');
const mouseUpEvent = new MouseEvent('mouseup');
directive.mousedown.next({ ...mouseDownEvent, resize: true });
directive.mouseup.next(mouseUpEvent);
expect(renderer.setStyle).toHaveBeenCalledWith(document.body, 'cursor', '');
});
it('should emit resizeEnd on mouseup', () => {
spyOn(directive.resizeEnd, 'emit');
directive.resizeEnd.subscribe();
const mouseDownEvent = new MouseEvent('mousedown');
const mouseUpEvent = new MouseEvent('mouseup');
directive.mousedown.next({ ...mouseDownEvent, resize: true });
directive.mouseup.next(mouseUpEvent);
expect(directive.resizeEnd.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, right: 0, width: 150, height: 0, bottom: 0, scrollTop: 0, scrollLeft: 0 } });
});
it('should emit resizing on mousemove', () => {
spyOn(directive.resizing, 'emit');
directive.resizing.subscribe();
const mouseDownEvent = new MouseEvent('mousedown');
const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 120 });
directive.mousedown.next({ ...mouseDownEvent, resize: true });
directive.mousemove.next(mouseMoveEvent);
expect(directive.resizing.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, bottom: 0, right: 120, width: 120 } });
});
it('should NOT emit resizing on mousemove when movement goes under the minimum allowed size [100]', () => {
spyOn(directive.resizing, 'emit');
directive.resizing.subscribe();
const mouseDownEvent = new MouseEvent('mousedown');
const mouseMoveEvent = new MouseEvent('mousemove', { clientX: 99 });
directive.mousedown.next({ ...mouseDownEvent, resize: true });
directive.mousemove.next(mouseMoveEvent);
expect(directive.resizing.emit).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,257 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Subject, Observable, Observer, merge } from 'rxjs';
import { BoundingRectangle, ResizeEvent, IResizeMouseEvent, ICoordinateX } from './types';
import { map, tap, take, share, filter, pairwise, mergeMap, takeUntil } from 'rxjs/operators';
import { OnInit, Output, NgZone, OnDestroy, Directive, Renderer2, ElementRef, EventEmitter } from '@angular/core';
@Directive({
selector: '[adf-resizable]',
exportAs: 'adf-resizable'
})
export class ResizableDirective implements OnInit, OnDestroy {
/**
* Emitted when the mouse is pressed and a resize event is about to begin.
*/
@Output() resizeStart = new EventEmitter<ResizeEvent>();
/**
* Emitted when the mouse is dragged after a resize event has started.
*/
@Output() resizing = new EventEmitter<ResizeEvent>();
/**
* Emitted when the mouse is released after a resize event.
*/
@Output() resizeEnd = new EventEmitter<ResizeEvent>();
mouseup = new Subject<IResizeMouseEvent>();
mousedown = new Subject<IResizeMouseEvent>();
mousemove = new Subject<IResizeMouseEvent>();
private pointerDown: Observable<IResizeMouseEvent>;
private pointerMove: Observable<IResizeMouseEvent>;
private pointerUp: Observable<IResizeMouseEvent>;
private startingRect: BoundingRectangle;
private currentRect: BoundingRectangle;
private unlistenMouseDown: () => void;
private unlistenMouseMove: () => void;
private unlistenMouseUp: () => void;
private destroy$ = new Subject<void>();
private static MINIMUM_COLUMN_SIZE = 100;
constructor(
private readonly renderer: Renderer2,
private readonly element: ElementRef<HTMLElement>,
private readonly zone: NgZone
) {
this.pointerDown = new Observable(
(observer: Observer<IResizeMouseEvent>) => {
zone.runOutsideAngular(() => {
this.unlistenMouseDown = renderer.listen(
'document',
'mousedown',
(event: MouseEvent) => {
observer.next(event);
}
);
});
}
).pipe(share());
this.pointerMove = new Observable(
(observer: Observer<IResizeMouseEvent>) => {
zone.runOutsideAngular(() => {
this.unlistenMouseMove = renderer.listen(
'document',
'mousemove',
(event: MouseEvent) => {
observer.next(event);
}
);
});
}
).pipe(share());
this.pointerUp = new Observable(
(observer: Observer<IResizeMouseEvent>) => {
zone.runOutsideAngular(() => {
this.unlistenMouseUp = renderer.listen(
'document',
'mouseup',
(event: MouseEvent) => {
observer.next(event);
}
);
});
}
).pipe(share());
}
ngOnInit(): void {
const mousedown$: Observable<IResizeMouseEvent> = merge(this.pointerDown, this.mousedown);
const mousemove$: Observable<IResizeMouseEvent> = merge(this.pointerMove, this.mousemove)
.pipe(
tap((event) => this.preventDefaultEvent(event)),
share()
);
const mouseup$: Observable<IResizeMouseEvent> = merge(this.pointerUp, this.mouseup);
const mousedrag: Observable<IResizeMouseEvent | ICoordinateX> = mousedown$
.pipe(
mergeMap(({ clientX = 0 }) => merge(
mousemove$.pipe(take(1)).pipe(map((coords) => [, coords])),
mousemove$.pipe(pairwise())
)
.pipe(
map(([previousCoords = {}, newCoords = {}]) =>
[
{ clientX: previousCoords.clientX - clientX },
{ clientX: newCoords.clientX - clientX }
]
)
)
.pipe(
filter(([previousCoords = {}, newCoords = {}]) =>
Math.ceil(previousCoords.clientX) !== Math.ceil(newCoords.clientX))
)
.pipe(
map(([, newCoords]) =>
({
clientX: Math.round(newCoords.clientX)
}))
)
.pipe(takeUntil(merge(mouseup$, mousedown$)))
)
)
.pipe(filter(() => !!this.currentRect));
mousedrag
.pipe(
map(({ clientX }) => this.getNewBoundingRectangle(this.startingRect, clientX))
)
.pipe(
filter(this.minimumAllowedSize)
)
.subscribe((rectangle: BoundingRectangle) => {
if (this.resizing.observers.length > 0) {
this.zone.run(() => {
this.resizing.emit({ rectangle });
});
}
this.currentRect = rectangle;
});
mousedown$
.pipe(
map(({ resize = false }) => resize),
filter((resize) => resize),
takeUntil(this.destroy$)
)
.subscribe(() => {
const startingRect: BoundingRectangle = this.getElementRect(this.element);
this.startingRect = startingRect;
this.currentRect = startingRect;
this.renderer.setStyle(document.body, 'cursor', 'col-resize');
if (this.resizeStart.observers.length > 0) {
this.zone.run(() => {
this.resizeStart.emit({
rectangle: this.getNewBoundingRectangle(this.startingRect, 0)
});
});
}
});
mouseup$.pipe(takeUntil(this.destroy$)).subscribe(() => {
if (this.currentRect) {
this.renderer.setStyle(document.body, 'cursor', '');
if (this.resizeEnd.observers.length > 0) {
this.zone.run(() => {
this.resizeEnd.emit({ rectangle: this.currentRect });
});
}
this.startingRect = null;
this.currentRect = null;
}
});
}
ngOnDestroy(): void {
this.mousedown.complete();
this.mousemove.complete();
this.mouseup.complete();
this.unlistenMouseDown && this.unlistenMouseDown();
this.unlistenMouseMove && this.unlistenMouseMove();
this.unlistenMouseUp && this.unlistenMouseUp();
this.destroy$.next();
}
private preventDefaultEvent(event: MouseEvent): void {
if ((this.currentRect || this.startingRect) && event.cancelable) {
event.preventDefault();
}
}
private getNewBoundingRectangle({ top, bottom, left, right }: BoundingRectangle, clientX: number): BoundingRectangle {
const updatedRight = right += clientX;
return {
top,
left,
bottom,
right: updatedRight,
width: updatedRight - left
};
}
private getElementRect({ nativeElement }: ElementRef): BoundingRectangle {
const { height = 0, width = 0, top = 0, bottom = 0, right = 0, left = 0 }: BoundingRectangle = nativeElement.getBoundingClientRect();
return {
top,
left,
right,
width,
height,
bottom,
scrollTop: nativeElement.scrollTop,
scrollLeft: nativeElement.scrollLeft
};
}
private minimumAllowedSize({ width = 0 }: BoundingRectangle): boolean {
return width > ResizableDirective.MINIMUM_COLUMN_SIZE;
}
}

View File

@@ -0,0 +1,26 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { NgModule } from '@angular/core';
import { ResizableDirective } from './resizable.directive';
import { ResizeHandleDirective } from './resize-handle.directive';
@NgModule({
declarations: [ResizableDirective, ResizeHandleDirective],
exports: [ResizableDirective, ResizeHandleDirective]
})
export class ResizableModule {}

View File

@@ -0,0 +1,56 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TestBed } from '@angular/core/testing';
import { ElementRef, NgZone, Renderer2 } from '@angular/core';
import { ResizeHandleDirective } from './resize-handle.directive';
describe('ResizeHandleDirective', () => {
let ngZone: NgZone;
let renderer: Renderer2;
let element: ElementRef;
let directive: ResizeHandleDirective;
const rendererMock = {
listen: jasmine.createSpy('listen')
};
const elementRefMock = {
nativeElement: { dispatchEvent: () => { } }
};
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [ResizeHandleDirective],
providers: [
{ provide: Renderer2, useValue: rendererMock },
{ provide: ElementRef, useValue: elementRefMock }
]
});
element = TestBed.inject(ElementRef);
renderer = TestBed.inject(Renderer2);
ngZone = TestBed.inject(NgZone);
spyOn(ngZone, 'runOutsideAngular').and.callFake((fn) => fn());
directive = new ResizeHandleDirective(renderer, element, ngZone);
directive.ngOnInit();
});
it('should attach mousedown event on resizable element', () => {
expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousedown', jasmine.any(Function));
});
});

View File

@@ -0,0 +1,96 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Subject } from 'rxjs';
import { ResizableDirective } from './resizable.directive';
import { Input, OnInit, Directive, Renderer2, ElementRef, OnDestroy, NgZone } from '@angular/core';
@Directive({
selector: '[adf-resize-handle]'
})
export class ResizeHandleDirective implements OnInit, OnDestroy {
/**
* Reference to ResizableDirective
*/
@Input() resizableContainer: ResizableDirective;
private unlistenMouseDown: () => void;
private unlistenMouseMove: () => void;
private unlistenMouseUp: () => void;
private destroy$ = new Subject<void>();
constructor(
private readonly renderer: Renderer2,
private readonly element: ElementRef,
private readonly zone: NgZone
) { }
ngOnInit(): void {
this.zone.runOutsideAngular(() => {
this.unlistenMouseDown = this.renderer.listen(
this.element.nativeElement,
'mousedown',
(mouseDownEvent: MouseEvent) => {
this.onMousedown(mouseDownEvent);
}
);
});
}
ngOnDestroy(): void {
this.unlistenMouseDown && this.unlistenMouseDown();
this.unlistenMouseMove && this.unlistenMouseMove();
this.unlistenMouseUp && this.unlistenMouseUp();
this.destroy$.next();
}
private onMousedown(event: MouseEvent): void {
if (event.cancelable) {
event.preventDefault();
}
this.unlistenMouseMove = this.renderer.listen(
this.element.nativeElement,
'mousemove',
(mouseMoveEvent: MouseEvent) => {
this.onMousemove(mouseMoveEvent);
}
);
this.unlistenMouseUp = this.renderer.listen(
this.element.nativeElement,
'mouseup',
(mouseUpEvent: MouseEvent) => {
this.onMouseup(mouseUpEvent);
}
);
this.resizableContainer.mousedown.next({ ...event, resize: true });
}
private onMouseup(event: MouseEvent): void {
this.unlistenMouseMove && this.unlistenMouseMove();
this.unlistenMouseUp();
this.resizableContainer.mouseup.next(event);
}
private onMousemove(event: MouseEvent): void {
this.resizableContainer.mousemove.next(event);
}
}

View File

@@ -0,0 +1,40 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface BoundingRectangle {
top: number;
left: number;
right: number;
bottom: number;
width?: number;
height?: number;
scrollTop?: number;
scrollLeft?: number;
[key: string]: number | undefined;
}
export interface ResizeEvent {
rectangle: BoundingRectangle;
}
export interface IResizeMouseEvent extends MouseEvent{
resize?: boolean;
}
export interface ICoordinateX {
clientX: number;
}