mirror of
https://github.com/Alfresco/alfresco-ng2-components.git
synced 2026-09-09 18:03:21 +00:00
[ACS-10304] KN: personal files: Column resizing available via mouse but not accessible via keyboard (#11525)
This commit is contained in:
@@ -76,6 +76,7 @@
|
||||
#resizableElement="adf-resizable"
|
||||
[coverPadding]="10"
|
||||
(resizing)="onResizing($event, columnIndex)"
|
||||
(keyboardResizing)="onResizing($event, columnIndex)"
|
||||
(resizeStart)="resizingColumnIndex = columnIndex"
|
||||
(resizeEnd)="onResizingEnd()"
|
||||
[attr.data-automation-id]="'auto_header_content_id_' + col.key"
|
||||
@@ -134,18 +135,22 @@
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="isResizingEnabled && col.resizable && !lastColumn"
|
||||
[ngClass]="hoveredHeaderColumnIndex === columnIndex && !isResizing || resizingColumnIndex === columnIndex ? 'adf-datatable__resize-handle-visible' : 'adf-datatable__resize-handle-hidden'"
|
||||
adf-resize-handle
|
||||
tabindex="0"
|
||||
role="button"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keyup.enter)="$event.stopPropagation()"
|
||||
class="adf-datatable__resize-handle"
|
||||
[resizableContainer]="resizableElement">
|
||||
<div class="adf-datatable__resize-handle--divider"></div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="isResizingEnabled && col.resizable && !lastColumn"
|
||||
[ngClass]="hoveredHeaderColumnIndex === columnIndex && !isResizing || resizingColumnIndex === columnIndex ? 'adf-datatable__resize-handle-visible' : 'adf-datatable__resize-handle-hidden'"
|
||||
adf-resize-handle
|
||||
tabindex="0"
|
||||
role="slider"
|
||||
[attr.aria-valuenow]="col.width ?? 100"
|
||||
[attr.aria-label]="'ADF-DATATABLE.ACCESSIBILITY.RESIZE_COLUMN' | translate: { column: col.title | translate }"
|
||||
[attr.aria-valuemin]="100"
|
||||
[attr.aria-valuemax]="500"
|
||||
(click)="$event.stopPropagation()"
|
||||
(keydown)="$event.stopPropagation()"
|
||||
class="adf-datatable__resize-handle"
|
||||
[resizableContainer]="resizableElement">
|
||||
<div class="adf-datatable__resize-handle--divider"></div>
|
||||
</div>
|
||||
<div class="adf-drop-header-cell-placeholder" *cdkDragPlaceholder></div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
@@ -27,13 +27,21 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width-1 !default;
|
||||
|
||||
&__resize-handle {
|
||||
padding: 0 2px;
|
||||
position: relative;
|
||||
|
||||
&-visible {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&-hidden {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible,
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&--divider {
|
||||
@@ -44,6 +52,17 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width-1 !default;
|
||||
&:hover {
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
opacity: 1;
|
||||
outline: 2px solid var(--theme-accent-color-a200);
|
||||
outline-offset: -1px;
|
||||
|
||||
.adf-datatable__resize-handle--divider {
|
||||
border-color: var(--theme-accent-color-a200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__cursor--pointer {
|
||||
|
||||
@@ -158,4 +158,12 @@ describe('ResizableDirective', () => {
|
||||
|
||||
expect(directive.resizing.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, bottom: 0, right: 130, width: 130 } });
|
||||
});
|
||||
|
||||
it('should emit keyboardResizing event when resizeByKeyboard', () => {
|
||||
spyOn(directive.keyboardResizing, 'emit');
|
||||
const step = 20;
|
||||
directive.resizeByKeyboard(step);
|
||||
|
||||
expect(directive.keyboardResizing.emit).toHaveBeenCalledWith({ rectangle: { top: 0, left: 0, bottom: 0, right: step, width: step } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,11 @@ export class ResizableDirective implements OnInit, OnDestroy {
|
||||
*/
|
||||
@Output() resizeEnd = new EventEmitter<ResizeEvent>();
|
||||
|
||||
/**
|
||||
* Emitted when keyboard resize is triggered.
|
||||
*/
|
||||
@Output() keyboardResizing = new EventEmitter<ResizeEvent>();
|
||||
|
||||
/**
|
||||
* This is to cover sum of the left and right padding between resize handler and its parent.
|
||||
*/
|
||||
@@ -174,6 +179,14 @@ export class ResizableDirective implements OnInit, OnDestroy {
|
||||
this.unsubscribeMouseUp?.();
|
||||
}
|
||||
|
||||
resizeByKeyboard(delta: number): void {
|
||||
const currentRect = this.getElementRect(this.element);
|
||||
const rectangle = this.getNewBoundingRectangle(currentRect, delta);
|
||||
this.zone.run(() => {
|
||||
this.keyboardResizing.emit({ rectangle });
|
||||
});
|
||||
}
|
||||
|
||||
private getNewBoundingRectangle({ top, bottom, left, right }: BoundingRectangle, clientX: number): BoundingRectangle {
|
||||
const updatedRight = Math.round(right + clientX);
|
||||
|
||||
|
||||
@@ -15,42 +15,151 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ElementRef, NgZone, Renderer2 } from '@angular/core';
|
||||
import { ResizeHandleDirective } from './resize-handle.directive';
|
||||
import { ResizableDirective } from './resizable.directive';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
describe('ResizeHandleDirective', () => {
|
||||
let ngZone: NgZone;
|
||||
let renderer: Renderer2;
|
||||
let element: ElementRef;
|
||||
let directive: ResizeHandleDirective;
|
||||
|
||||
const rendererMock = {
|
||||
listen: jasmine.createSpy('listen')
|
||||
};
|
||||
|
||||
const elementRefMock = {
|
||||
nativeElement: { dispatchEvent: () => {} }
|
||||
};
|
||||
let renderer: jasmine.SpyObj<Renderer2>;
|
||||
let element: ElementRef;
|
||||
let ngZone: NgZone;
|
||||
let resizableContainer: jasmine.SpyObj<ResizableDirective>;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [ResizeHandleDirective],
|
||||
providers: [
|
||||
{ provide: Renderer2, useValue: rendererMock },
|
||||
{ provide: ElementRef, useValue: elementRefMock }
|
||||
]
|
||||
renderer = jasmine.createSpyObj('Renderer2', ['listen']);
|
||||
element = { nativeElement: document.createElement('div') };
|
||||
ngZone = { runOutsideAngular: (fn: () => void) => fn() } as NgZone;
|
||||
|
||||
resizableContainer = jasmine.createSpyObj('ResizableDirective', ['resizeByKeyboard'], {
|
||||
mousedown: new Subject(),
|
||||
mouseup: new Subject(),
|
||||
mousemove: new Subject()
|
||||
});
|
||||
|
||||
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();
|
||||
directive.resizableContainer = resizableContainer;
|
||||
});
|
||||
|
||||
it('should attach mousedown event on resizable element', () => {
|
||||
expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousedown', jasmine.any(Function));
|
||||
describe('ngOnInit', () => {
|
||||
it('should attach mousedown event listener on element', () => {
|
||||
renderer.listen.and.returnValue(() => {});
|
||||
|
||||
directive.ngOnInit();
|
||||
|
||||
expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousedown', jasmine.any(Function));
|
||||
});
|
||||
|
||||
it('should run mousedown listener outside Angular zone', () => {
|
||||
const runOutsideAngularSpy = spyOn(ngZone, 'runOutsideAngular').and.callThrough();
|
||||
renderer.listen.and.returnValue(() => {});
|
||||
|
||||
directive.ngOnInit();
|
||||
|
||||
expect(runOutsideAngularSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ngOnDestroy', () => {
|
||||
it('should unsubscribe from mousedown listener', () => {
|
||||
const unlistenMouseDown = jasmine.createSpy('unlistenMouseDown');
|
||||
renderer.listen.and.returnValue(unlistenMouseDown);
|
||||
|
||||
directive.ngOnInit();
|
||||
directive.ngOnDestroy();
|
||||
|
||||
expect(unlistenMouseDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mouse events', () => {
|
||||
let mousedownCallback: (event: MouseEvent) => void;
|
||||
|
||||
beforeEach(() => {
|
||||
renderer.listen.and.callFake((_target: any, eventName: string, callback: (event: MouseEvent) => void) => {
|
||||
if (eventName === 'mousedown') {
|
||||
mousedownCallback = callback;
|
||||
}
|
||||
return () => {};
|
||||
});
|
||||
directive.ngOnInit();
|
||||
});
|
||||
|
||||
it('should emit mousedown event to resizable container with resize flag', () => {
|
||||
spyOn(resizableContainer.mousedown, 'next');
|
||||
const mouseEvent = new MouseEvent('mousedown', { cancelable: true });
|
||||
|
||||
mousedownCallback(mouseEvent);
|
||||
|
||||
expect(resizableContainer.mousedown.next).toHaveBeenCalledWith(jasmine.objectContaining({ resize: true }));
|
||||
});
|
||||
|
||||
it('should prevent default on cancelable mousedown event', () => {
|
||||
const mouseEvent = new MouseEvent('mousedown', { cancelable: true });
|
||||
spyOn(mouseEvent, 'preventDefault');
|
||||
|
||||
mousedownCallback(mouseEvent);
|
||||
|
||||
expect(mouseEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not prevent default on non-cancelable mousedown event', () => {
|
||||
const mouseEvent = new MouseEvent('mousedown', { cancelable: false });
|
||||
spyOn(mouseEvent, 'preventDefault');
|
||||
|
||||
mousedownCallback(mouseEvent);
|
||||
|
||||
expect(mouseEvent.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should attach mousemove and mouseup listeners on mousedown', () => {
|
||||
const mouseEvent = new MouseEvent('mousedown', { cancelable: true });
|
||||
|
||||
mousedownCallback(mouseEvent);
|
||||
|
||||
expect(renderer.listen).toHaveBeenCalledWith(element.nativeElement, 'mousemove', jasmine.any(Function));
|
||||
expect(renderer.listen).toHaveBeenCalledWith('document', 'mouseup', jasmine.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyboard resizing', () => {
|
||||
it('should call resizeByKeyboard with positive delta on ArrowRight', () => {
|
||||
const event = new KeyboardEvent('keydown', { key: 'ArrowRight' });
|
||||
spyOn(event, 'preventDefault');
|
||||
spyOn(event, 'stopPropagation');
|
||||
|
||||
directive.onKeydown(event);
|
||||
|
||||
expect(resizableContainer.resizeByKeyboard).toHaveBeenCalledWith(20);
|
||||
expect(event.preventDefault).toHaveBeenCalled();
|
||||
expect(event.stopPropagation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use larger step with Shift+ArrowRight', () => {
|
||||
const event = new KeyboardEvent('keydown', { key: 'ArrowRight', shiftKey: true });
|
||||
|
||||
directive.onKeydown(event);
|
||||
|
||||
expect(resizableContainer.resizeByKeyboard).toHaveBeenCalledWith(60);
|
||||
});
|
||||
|
||||
it('should call resizeByKeyboard with negative delta on Shift+ArrowLeft', () => {
|
||||
const event = new KeyboardEvent('keydown', { key: 'ArrowLeft', shiftKey: true });
|
||||
|
||||
directive.onKeydown(event);
|
||||
|
||||
expect(resizableContainer.resizeByKeyboard).toHaveBeenCalledWith(-40);
|
||||
});
|
||||
|
||||
it('should not call resizeByKeyboard for unrelated keys', () => {
|
||||
const event = new KeyboardEvent('keydown', { key: 'Enter' });
|
||||
spyOn(event, 'preventDefault');
|
||||
|
||||
directive.onKeydown(event);
|
||||
|
||||
expect(resizableContainer.resizeByKeyboard).not.toHaveBeenCalled();
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import { ResizableDirective } from './resizable.directive';
|
||||
import { Directive, ElementRef, Input, NgZone, OnDestroy, OnInit, Renderer2 } from '@angular/core';
|
||||
import { Directive, ElementRef, HostListener, Input, NgZone, OnDestroy, OnInit, Renderer2 } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[adf-resize-handle]'
|
||||
@@ -32,6 +32,35 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
|
||||
private unlistenMouseUp?: () => void;
|
||||
constructor(private readonly renderer: Renderer2, private readonly element: ElementRef, private readonly zone: NgZone) {}
|
||||
|
||||
@HostListener('keydown', ['$event'])
|
||||
onKeydown(event: KeyboardEvent): void {
|
||||
let delta: number | null = null;
|
||||
const shiftDelta = 40;
|
||||
if (event.shiftKey) {
|
||||
delta += shiftDelta;
|
||||
}
|
||||
const rightStepBaseValue = 20;
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
case 'ArrowUp':
|
||||
delta += rightStepBaseValue;
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowDown':
|
||||
delta = -delta;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
if (delta !== null) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.resizableContainer.resizeByKeyboard(delta);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.unlistenMouseDown = this.renderer.listen(this.element.nativeElement, 'mousedown', (mouseDownEvent: MouseEvent) => {
|
||||
@@ -66,6 +95,7 @@ export class ResizeHandleDirective implements OnInit, OnDestroy {
|
||||
|
||||
private onMouseup(event: MouseEvent): void {
|
||||
this.unlistenMouseMove?.();
|
||||
this.unlistenMouseMove = undefined;
|
||||
this.unlistenMouseUp();
|
||||
this.resizableContainer.mouseup.next(event);
|
||||
}
|
||||
|
||||
@@ -406,7 +406,9 @@
|
||||
"ICON_DISABLED": "Disabled",
|
||||
"ROW_OPTION_BUTTON": "Actions",
|
||||
"DRAG": "Drag button",
|
||||
"EMPTY_HEADER": "Empty header"
|
||||
"EMPTY_HEADER": "Empty header",
|
||||
"RESIZE_COLUMN": "Resize {{ column }} column. Use arrow keys to adjust width, Shift for larger steps.",
|
||||
"COLUMN_WIDTH_CHANGED": "{{ column }} column width: {{ width }} pixels"
|
||||
},
|
||||
"FILE_TYPE": {
|
||||
"DOCUMENT": "Document",
|
||||
|
||||
Reference in New Issue
Block a user