diff --git a/lib/core/src/lib/datatable/components/datatable/datatable.component.html b/lib/core/src/lib/datatable/components/datatable/datatable.component.html index 0903483996..0a495a703a 100644 --- a/lib/core/src/lib/datatable/components/datatable/datatable.component.html +++ b/lib/core/src/lib/datatable/components/datatable/datatable.component.html @@ -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 @@ /> -
-
-
+
+
+
diff --git a/lib/core/src/lib/datatable/components/datatable/datatable.component.scss b/lib/core/src/lib/datatable/components/datatable/datatable.component.scss index 585a16d565..37aa44d062 100644 --- a/lib/core/src/lib/datatable/components/datatable/datatable.component.scss +++ b/lib/core/src/lib/datatable/components/datatable/datatable.component.scss @@ -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 { diff --git a/lib/core/src/lib/datatable/directives/resizable/resizable.directive.spec.ts b/lib/core/src/lib/datatable/directives/resizable/resizable.directive.spec.ts index dfbc2f334b..f0bb7d910a 100644 --- a/lib/core/src/lib/datatable/directives/resizable/resizable.directive.spec.ts +++ b/lib/core/src/lib/datatable/directives/resizable/resizable.directive.spec.ts @@ -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 } }); + }); }); diff --git a/lib/core/src/lib/datatable/directives/resizable/resizable.directive.ts b/lib/core/src/lib/datatable/directives/resizable/resizable.directive.ts index 6bc2e370ce..712e7b41e0 100644 --- a/lib/core/src/lib/datatable/directives/resizable/resizable.directive.ts +++ b/lib/core/src/lib/datatable/directives/resizable/resizable.directive.ts @@ -41,6 +41,11 @@ export class ResizableDirective implements OnInit, OnDestroy { */ @Output() resizeEnd = new EventEmitter(); + /** + * Emitted when keyboard resize is triggered. + */ + @Output() keyboardResizing = new EventEmitter(); + /** * 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); diff --git a/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.spec.ts b/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.spec.ts index 576b545ae4..16122149f0 100644 --- a/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.spec.ts +++ b/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.spec.ts @@ -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; + let element: ElementRef; + let ngZone: NgZone; + let resizableContainer: jasmine.SpyObj; 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(); + }); }); }); diff --git a/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.ts b/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.ts index 0613154b3c..a827a05851 100644 --- a/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.ts +++ b/lib/core/src/lib/datatable/directives/resizable/resize-handle.directive.ts @@ -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); } diff --git a/lib/core/src/lib/i18n/en.json b/lib/core/src/lib/i18n/en.json index 98bbed25a8..5c1ad1a1ad 100644 --- a/lib/core/src/lib/i18n/en.json +++ b/lib/core/src/lib/i18n/en.json @@ -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",