[ACS-10304] KN: personal files: Column resizing available via mouse but not accessible via keyboard (#11525)

This commit is contained in:
Dominik Iwanek
2026-01-21 12:49:47 +01:00
committed by GitHub
parent d838d93abc
commit 934765f2f6
7 changed files with 226 additions and 40 deletions
@@ -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);
}
+3 -1
View File
@@ -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",