diff --git a/projects/aca-content/src/lib/components/context-menu/context-menu-outside-event.directive.spec.ts b/projects/aca-content/src/lib/components/context-menu/context-menu-outside-event.directive.spec.ts index 839922b6d..04b180d5f 100644 --- a/projects/aca-content/src/lib/components/context-menu/context-menu-outside-event.directive.spec.ts +++ b/projects/aca-content/src/lib/components/context-menu/context-menu-outside-event.directive.spec.ts @@ -22,10 +22,84 @@ * from Hyland Software. If not, see . */ +import { Component, DebugElement } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { OutsideEventDirective } from './context-menu-outside-event.directive'; +import { MatMenuModule } from '@angular/material/menu'; +import { UnitTestingUtils } from '@alfresco/adf-core'; + +@Component({ + standalone: true, + imports: [OutsideEventDirective, MatMenuModule], + template: ` +
+ + + + + +
+ ` +}) +class TestComponent {} describe('OutsideEventDirective', () => { - it('should be defined', () => { - expect(OutsideEventDirective).toBeDefined(); + let fixture: ComponentFixture; + let directive: DebugElement; + let unitTestingUtils: UnitTestingUtils; + let directiveInstance: OutsideEventDirective; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [TestComponent, OutsideEventDirective, MatMenuModule] + }).compileComponents(); + + fixture = TestBed.createComponent(TestComponent); + unitTestingUtils = new UnitTestingUtils(fixture.debugElement); + directive = unitTestingUtils.getByDirective(OutsideEventDirective); + directiveInstance = directive.injector.get(OutsideEventDirective); + fixture.detectChanges(); + }); + + it('should emit clickOutside when clicking outside the context menu', () => { + spyOn(directiveInstance.clickOutside, 'next'); + + unitTestingUtils.clickByDataAutomationId('outside-button'); + fixture.detectChanges(); + + expect(directiveInstance.clickOutside.next).toHaveBeenCalled(); + }); + + it('should not emit clickOutside when clicking inside the context menu', () => { + unitTestingUtils.clickByDataAutomationId('trigger-button'); + fixture.detectChanges(); + + spyOn(directiveInstance.clickOutside, 'next'); + + unitTestingUtils.clickByDataAutomationId('menu-item'); + fixture.detectChanges(); + + expect(directiveInstance.clickOutside.next).not.toHaveBeenCalled(); + }); + + it('should focus focusTargetSelector element on escape key', () => { + expect(directiveInstance.focusTargetSelector).toBe('.adf-context-menu-source'); + + directiveInstance.focusTargetSelector = '.custom-selector'; + unitTestingUtils.clickByDataAutomationId('trigger-button'); + fixture.detectChanges(); + + const contextMenuSource: HTMLElement = unitTestingUtils.getByCSS(directiveInstance.focusTargetSelector).nativeElement; + spyOn(contextMenuSource, 'focus'); + + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true + }) + ); + + expect(contextMenuSource.focus).toHaveBeenCalled(); }); }); diff --git a/projects/aca-content/src/lib/components/context-menu/context-menu-outside-event.directive.ts b/projects/aca-content/src/lib/components/context-menu/context-menu-outside-event.directive.ts index ca2a28361..a6e47edf3 100644 --- a/projects/aca-content/src/lib/components/context-menu/context-menu-outside-event.directive.ts +++ b/projects/aca-content/src/lib/components/context-menu/context-menu-outside-event.directive.ts @@ -22,7 +22,7 @@ * from Hyland Software. If not, see . */ -import { DestroyRef, Directive, EventEmitter, inject, OnInit, Output } from '@angular/core'; +import { DestroyRef, Directive, EventEmitter, HostListener, inject, Input, OnInit, Output } from '@angular/core'; import { fromEvent } from 'rxjs'; import { filter } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -35,6 +35,9 @@ export class OutsideEventDirective implements OnInit { @Output() clickOutside: EventEmitter = new EventEmitter(); + @Input() + focusTargetSelector = '.adf-context-menu-source'; + private readonly destroyRef = inject(DestroyRef); ngOnInit() { @@ -46,6 +49,11 @@ export class OutsideEventDirective implements OnInit { .subscribe(() => this.clickOutside.next()); } + @HostListener('document:keydown.escape', ['$event']) + onEscapeKeydown() { + document.querySelector(this.focusTargetSelector)?.focus(); + } + private findAncestor(el: Element): boolean { const className = 'aca-context-menu'; diff --git a/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.spec.ts b/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.spec.ts index 67b493c57..d37264b4e 100644 --- a/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.spec.ts @@ -80,7 +80,8 @@ describe('SaveSearchDirective', () => { element.triggerEventHandler('click', event); const expectedConfig = { - data: { searchUrl: 'encodedQuery' } + data: { searchUrl: 'encodedQuery' }, + restoreFocus: true }; expect(dialog.open).toHaveBeenCalledWith(SaveSearchDialogComponent, expectedConfig); diff --git a/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.ts b/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.ts index 0487c77da..f25cd0d5e 100644 --- a/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.ts +++ b/projects/aca-content/src/lib/components/search/search-save/directive/save-search.directive.ts @@ -22,7 +22,7 @@ * from Hyland Software. If not, see . */ -import { Directive, HostListener, Input } from '@angular/core'; +import { Directive, ElementRef, HostListener, Input } from '@angular/core'; import { MatDialog } from '@angular/material/dialog'; import { SaveSearchDialogComponent } from '../dialog/save-search-dialog.component'; @@ -39,16 +39,20 @@ export class SaveSearchDirective { @Input() acaSaveSearchQuery: string; - constructor(private readonly dialogRef: MatDialog) {} + constructor( + private readonly dialogRef: MatDialog, + private readonly elementRef: ElementRef + ) {} @HostListener('click', ['$event']) onClick(event: MouseEvent) { event.preventDefault(); + this.elementRef.nativeElement.focus(); this.openDialog(); } private openDialog(): void { - this.dialogRef.open(SaveSearchDialogComponent, this.getDialogConfig()); + this.dialogRef.open(SaveSearchDialogComponent, { ...this.getDialogConfig(), restoreFocus: true }); } private getDialogConfig(): { data: SaveSearchDirectiveDialogData } { diff --git a/projects/aca-content/src/lib/components/search/search-save/list/saved-searches-list-ui.service.spec.ts b/projects/aca-content/src/lib/components/search/search-save/list/saved-searches-list-ui.service.spec.ts index 88a878a1e..337ec7ba7 100644 --- a/projects/aca-content/src/lib/components/search/search-save/list/saved-searches-list-ui.service.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-save/list/saved-searches-list-ui.service.spec.ts @@ -22,16 +22,18 @@ * from Hyland Software. If not, see . */ -import { TestBed } from '@angular/core/testing'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { fakeAsync, TestBed } from '@angular/core/testing'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { SavedSearchesListUiService } from './saved-searches-list-ui.service'; import { SavedSearch } from '@alfresco/adf-content-services'; import { SavedSearchEditDialogComponent } from '../dialog/edit/saved-search-edit-dialog.component'; import { SavedSearchDeleteDialogComponent } from '../dialog/delete/saved-search-delete-dialog.component'; +import { of } from 'rxjs'; describe('NodeTemplateService', () => { let dialog: MatDialog; let savedSearchesListUiService: SavedSearchesListUiService; + let dialogRefSpy: jasmine.SpyObj>; const mockedSearch: SavedSearch = { name: 'test', encodedUrl: 'test', order: 1 }; @@ -42,19 +44,21 @@ describe('NodeTemplateService', () => { dialog = TestBed.inject(MatDialog); savedSearchesListUiService = TestBed.inject(SavedSearchesListUiService); + + dialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['afterClosed']); + dialogRefSpy.afterClosed.and.returnValue(of(null)); + spyOn(dialog, 'open').and.returnValue(dialogRefSpy); }); - it('should open edit save search dialog with proper params', () => { - spyOn(dialog, 'open'); + it('should open edit save search dialog with proper params', fakeAsync(() => { savedSearchesListUiService.openEditSavedSearch(mockedSearch); expect(dialog.open).toHaveBeenCalledWith(SavedSearchEditDialogComponent, { data: mockedSearch, width: '600px' }); - }); + })); - it('should open delete save search dialog with proper params', () => { - spyOn(dialog, 'open'); + it('should open delete save search dialog with proper params', fakeAsync(() => { savedSearchesListUiService.confirmDeleteSavedSearch(mockedSearch); expect(dialog.open).toHaveBeenCalledWith(SavedSearchDeleteDialogComponent, { data: mockedSearch, minWidth: '500px' }); - }); + })); }); diff --git a/projects/aca-content/src/lib/components/search/search-save/list/saved-searches-list-ui.service.ts b/projects/aca-content/src/lib/components/search/search-save/list/saved-searches-list-ui.service.ts index dfe9545ec..d53e7cfbe 100644 --- a/projects/aca-content/src/lib/components/search/search-save/list/saved-searches-list-ui.service.ts +++ b/projects/aca-content/src/lib/components/search/search-save/list/saved-searches-list-ui.service.ts @@ -33,16 +33,28 @@ export class SavedSearchesListUiService { private readonly dialog = inject(MatDialog); openEditSavedSearch(savedSearch: SavedSearch): void { - this.dialog.open(SavedSearchEditDialogComponent, { - data: savedSearch, - width: '600px' - }); + this.dialog + .open(SavedSearchEditDialogComponent, { + data: savedSearch, + width: '600px' + }) + .afterClosed() + .subscribe(() => this.focusAfterClose(`.adf-datatable-cell--${savedSearch.name}`)); } confirmDeleteSavedSearch(savedSearch: SavedSearch): void { - this.dialog.open(SavedSearchDeleteDialogComponent, { - data: savedSearch, - minWidth: '500px' - }); + this.dialog + .open(SavedSearchDeleteDialogComponent, { + data: savedSearch, + minWidth: '500px' + }) + .afterClosed() + .subscribe(() => this.focusAfterClose(`.adf-datatable-cell--${savedSearch.name}`)); + } + + private focusAfterClose(focusedElementSelector: string): void { + if (focusedElementSelector) { + document.querySelector(focusedElementSelector)?.closest('adf-datatable-row').focus(); + } } } diff --git a/projects/aca-content/src/lib/components/search/search-save/list/ui-list/saved-searches-list.ui-component.spec.ts b/projects/aca-content/src/lib/components/search/search-save/list/ui-list/saved-searches-list.ui-component.spec.ts index 747328267..f7bdb67f7 100644 --- a/projects/aca-content/src/lib/components/search/search-save/list/ui-list/saved-searches-list.ui-component.spec.ts +++ b/projects/aca-content/src/lib/components/search/search-save/list/ui-list/saved-searches-list.ui-component.spec.ts @@ -23,14 +23,14 @@ */ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { DataCellEvent, DataTableComponent, NoopTranslateModule, NotificationService } from '@alfresco/adf-core'; +import { DataCellEvent, DataTableComponent, NoopTranslateModule, NotificationService, UnitTestingUtils } from '@alfresco/adf-core'; import { SavedSearchesListUiComponent } from './saved-searches-list.ui-component'; import { SavedSearchesListUiService } from '../saved-searches-list-ui.service'; -import { By } from '@angular/platform-browser'; import { SavedSearch } from '@alfresco/adf-content-services'; import { Subject } from 'rxjs'; import { Clipboard } from '@angular/cdk/clipboard'; import { Router } from '@angular/router'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; describe('SavedSearchesListUiComponent ', () => { let fixture: ComponentFixture; @@ -39,11 +39,12 @@ describe('SavedSearchesListUiComponent ', () => { let savedSearchesListUiService: SavedSearchesListUiService; let clipboard: Clipboard; let router: Router; + let unitTestingUtils: UnitTestingUtils; beforeEach(() => { TestBed.configureTestingModule({ imports: [NoopTranslateModule, SavedSearchesListUiComponent], - providers: [SavedSearchesListUiService] + providers: [SavedSearchesListUiService, provideNoopAnimations()] }); notificationService = TestBed.inject(NotificationService); @@ -53,6 +54,7 @@ describe('SavedSearchesListUiComponent ', () => { fixture = TestBed.createComponent(SavedSearchesListUiComponent); component = fixture.componentInstance; + unitTestingUtils = new UnitTestingUtils(fixture.debugElement); }); function getColumnDefinition(key: string, title: string) { @@ -73,7 +75,7 @@ describe('SavedSearchesListUiComponent ', () => { beforeEach(() => { fixture.detectChanges(); - dataTable = fixture.debugElement.query(By.directive(DataTableComponent)).componentInstance; + dataTable = unitTestingUtils.getByDirective(DataTableComponent).componentInstance; dataCellEvent = new DataCellEvent( { isSelected: true, @@ -159,6 +161,42 @@ describe('SavedSearchesListUiComponent ', () => { ]); }); + it('should focus .adf-context-menu-source element when escape key is pressed and context menu exists', () => { + component.savedSearches = [{ name: '1', order: 0, encodedUrl: '123' }] as SavedSearch[]; + + fixture.detectChanges(); + + dataTable.showRowContextMenu.emit(dataCellEvent); + fixture.detectChanges(); + + const tableCell = unitTestingUtils.getByCSS('adf-datatable-cell'); + + tableCell.nativeElement.dispatchEvent( + new MouseEvent('contextmenu', { + bubbles: true, + cancelable: true, + button: 2 + }) + ); + fixture.detectChanges(); + + const contextMenu = document.querySelector('.adf-context-menu'); + const contextMenuSource: HTMLElement = unitTestingUtils.getByCSS('.adf-context-menu-source').nativeElement; + + expect(contextMenu).toBeTruthy(); + spyOn(contextMenuSource, 'focus'); + + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + code: 'Escape', + bubbles: true + }) + ); + + expect(contextMenuSource.focus).toHaveBeenCalled(); + }); + describe('Context menu actions', () => { beforeEach(() => { spyOn(savedSearchesListUiService, 'openEditSavedSearch'); diff --git a/projects/aca-content/src/lib/components/search/search-save/list/ui-list/saved-searches-list.ui-component.ts b/projects/aca-content/src/lib/components/search/search-save/list/ui-list/saved-searches-list.ui-component.ts index 7ba811844..789ac05fa 100644 --- a/projects/aca-content/src/lib/components/search/search-save/list/ui-list/saved-searches-list.ui-component.ts +++ b/projects/aca-content/src/lib/components/search/search-save/list/ui-list/saved-searches-list.ui-component.ts @@ -22,7 +22,18 @@ * from Hyland Software. If not, see . */ -import { AfterContentInit, Component, DestroyRef, EventEmitter, inject, Input, Output, ViewEncapsulation } from '@angular/core'; +import { + AfterContentInit, + Component, + DestroyRef, + ElementRef, + EventEmitter, + HostListener, + inject, + Input, + Output, + ViewEncapsulation +} from '@angular/core'; import { AppConfigService, DataCellEvent, @@ -93,7 +104,8 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft constructor( protected appConfig: AppConfigService, private readonly clipboard: Clipboard, - private readonly router: Router + private readonly router: Router, + private readonly hostElement: ElementRef ) { super(appConfig, '', savedSearchesListSchema); } @@ -103,6 +115,14 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft this.contextMenuAction$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((action) => this.executeMenuOption(action.key, action.data)); } + @HostListener('document:keydown.escape', ['$event']) + onEscapeKeydown() { + const contextMenu = document.querySelector('.adf-context-menu'); + if (contextMenu) { + this.hostElement.nativeElement.querySelector('.adf-context-menu-source')?.focus(); + } + } + onShowRowActionsMenu(event: DataCellEvent): void { event.value.actions = this.menuOptions; }