mirror of
https://github.com/Alfresco/alfresco-content-app.git
synced 2026-09-09 18:02:54 +00:00
[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:
+76
-2
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+9
-1
@@ -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';
|
||||
|
||||
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+7
-3
@@ -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 } {
|
||||
|
||||
+12
-8
@@ -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' });
|
||||
});
|
||||
}));
|
||||
});
|
||||
|
||||
+20
-8
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+42
-4
@@ -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
-2
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user