[ACS-10219] a11y Keyboard focus is lost when dismissing popup with Esc (#4906)

* [ACS-10219] a11y Keyboard focus is lost when dismissing popup with Esc

* [ACS-10219] cr fixes

* [link-adf:dev-mmaliarchuk/ACS-10219-a11y-keyboard-focus-is-lost-when-dismissing-popup][affected:*][ci:force]

* empty commit

* [ACS-10219] move hostListeners under constructor
This commit is contained in:
Mykyta Maliarchuk
2025-11-27 09:10:34 +01:00
committed by GitHub
parent e98e5b3511
commit ef7542ec0c
8 changed files with 190 additions and 29 deletions
@@ -22,10 +22,84 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
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: `
<div class="custom-selector">
<button #menuTrigger="matMenuTrigger" [matMenuTriggerFor]="menu" data-automation-id="trigger-button">Open Menu</button>
<mat-menu #menu="matMenu" class="aca-context-menu" acaContextMenuOutsideEvent>
<button mat-menu-item data-automation-id="menu-item">Item 1</button>
</mat-menu>
<button data-automation-id="outside-button">Outside Button</button>
</div>
`
})
class TestComponent {}
describe('OutsideEventDirective', () => {
it('should be defined', () => {
expect(OutsideEventDirective).toBeDefined();
let fixture: ComponentFixture<TestComponent>;
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();
});
});
@@ -22,7 +22,7 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
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<void> = 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<HTMLElement>(this.focusTargetSelector)?.focus();
}
private findAncestor(el: Element): boolean {
const className = 'aca-context-menu';
@@ -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);
@@ -22,7 +22,7 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
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<HTMLElement>
) {}
@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 } {
@@ -22,16 +22,18 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
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<MatDialogRef<any>>;
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' });
});
}));
});
@@ -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<HTMLElement>(focusedElementSelector)?.closest<HTMLElement>('adf-datatable-row').focus();
}
}
}
@@ -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<SavedSearchesListUiComponent>;
@@ -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');
@@ -22,7 +22,18 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
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<HTMLElement>
) {
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<HTMLElement>('.adf-context-menu');
if (contextMenu) {
this.hostElement.nativeElement.querySelector<HTMLElement>('.adf-context-menu-source')?.focus();
}
}
onShowRowActionsMenu(event: DataCellEvent): void {
event.value.actions = this.menuOptions;
}