[ACS-10273] a11y: Saved Search - fix focus management on dialog open and return to trigger on close (#5101)

* [ACS-10273] a11y: Saved Search - fix focus management on dialog open and return to trigger on close

* [ACS-10273] cr fixes
This commit is contained in:
Mykyta Maliarchuk
2026-03-18 16:24:14 +01:00
committed by GitHub
parent 73fd3085b8
commit d1b7b051de
6 changed files with 91 additions and 23 deletions
@@ -14,6 +14,7 @@
<mat-dialog-actions align="end"> <mat-dialog-actions align="end">
<button mat-button <button mat-button
mat-dialog-close mat-dialog-close
adf-auto-focus
id="aca-save-search-delete-dialog-cancel-button">{{ 'CANCEL' | titlecase | translate }}</button> id="aca-save-search-delete-dialog-cancel-button">{{ 'CANCEL' | titlecase | translate }}</button>
<button mat-flat-button <button mat-flat-button
id="aca-save-search-delete-dialog-submit-button" id="aca-save-search-delete-dialog-submit-button"
@@ -23,7 +23,7 @@
*/ */
import { Component, Inject, ViewEncapsulation } from '@angular/core'; import { Component, Inject, ViewEncapsulation } from '@angular/core';
import { SavedSearch } from '@alfresco/adf-content-services'; import { AutoFocusDirective, SavedSearch } from '@alfresco/adf-content-services';
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { take } from 'rxjs/operators'; import { take } from 'rxjs/operators';
import { NotificationService } from '@alfresco/adf-core'; import { NotificationService } from '@alfresco/adf-core';
@@ -34,7 +34,7 @@ import { MatButtonModule } from '@angular/material/button';
import { SavedSearchesContextService } from '../../../../../services/saved-searches-context.service'; import { SavedSearchesContextService } from '../../../../../services/saved-searches-context.service';
@Component({ @Component({
imports: [TranslatePipe, TitleCasePipe, MatIconModule, MatButtonModule, MatDialogModule], imports: [TranslatePipe, TitleCasePipe, MatIconModule, MatButtonModule, MatDialogModule, AutoFocusDirective],
selector: 'aca-saved-search-delete-dialog', selector: 'aca-saved-search-delete-dialog',
templateUrl: './saved-search-delete-dialog.component.html', templateUrl: './saved-search-delete-dialog.component.html',
styleUrls: ['./saved-search-delete-dialog.component.scss'], styleUrls: ['./saved-search-delete-dialog.component.scss'],
@@ -53,12 +53,72 @@ describe('NodeTemplateService', () => {
it('should open edit save search dialog with proper params', fakeAsync(() => { it('should open edit save search dialog with proper params', fakeAsync(() => {
savedSearchesListUiService.openEditSavedSearch(mockedSearch); savedSearchesListUiService.openEditSavedSearch(mockedSearch);
expect(dialog.open).toHaveBeenCalledWith(SavedSearchEditDialogComponent, { data: mockedSearch, width: '600px' }); expect(dialog.open).toHaveBeenCalledWith(SavedSearchEditDialogComponent, {
data: mockedSearch,
width: '600px',
restoreFocus: false
});
})); }));
it('should open delete save search dialog with proper params', fakeAsync(() => { it('should open delete save search dialog with proper params', fakeAsync(() => {
savedSearchesListUiService.confirmDeleteSavedSearch(mockedSearch); savedSearchesListUiService.confirmDeleteSavedSearch(mockedSearch);
expect(dialog.open).toHaveBeenCalledWith(SavedSearchDeleteDialogComponent, { data: mockedSearch, minWidth: '500px' }); expect(dialog.open).toHaveBeenCalledWith(SavedSearchDeleteDialogComponent, {
data: mockedSearch,
minWidth: '500px',
restoreFocus: false
});
})); }));
describe('focusAfterClose', () => {
let mockRow: jasmine.SpyObj<HTMLElement>;
let mockButton: jasmine.SpyObj<HTMLElement>;
let mockCell: jasmine.SpyObj<HTMLElement>;
beforeEach(() => {
mockButton = jasmine.createSpyObj<HTMLElement>('button', ['focus']);
mockRow = jasmine.createSpyObj<HTMLElement>('adf-datatable-row', ['focus', 'querySelector']);
mockRow.querySelector.and.returnValue(mockButton);
mockCell = jasmine.createSpyObj<HTMLElement>('cell', ['closest', 'getAttribute']);
mockCell.getAttribute.and.returnValue(mockedSearch.name);
mockCell.closest.and.returnValue(mockRow);
spyOn(document, 'querySelectorAll').and.returnValue([mockCell] as unknown as NodeListOf<HTMLElement>);
});
it('should focus the actions button when edit dialog closes from actions button', () => {
savedSearchesListUiService.openEditSavedSearch(mockedSearch, false);
expect(mockRow.focus).toHaveBeenCalled();
expect(mockButton.focus).toHaveBeenCalled();
});
it('should focus row when edit dialog closes from context menu', () => {
savedSearchesListUiService.openEditSavedSearch(mockedSearch, true);
expect(mockRow.focus).toHaveBeenCalled();
expect(mockButton.focus).not.toHaveBeenCalled();
});
it('should focus the row before the actions button', () => {
const callOrder: string[] = [];
mockRow.focus.and.callFake(() => callOrder.push('row'));
mockButton.focus.and.callFake(() => callOrder.push('button'));
savedSearchesListUiService.openEditSavedSearch(mockedSearch, false);
expect(callOrder).toEqual(['row', 'button']);
});
it('should focus the actions button when delete dialog closes from actions button', () => {
savedSearchesListUiService.confirmDeleteSavedSearch(mockedSearch, false);
expect(mockRow.focus).toHaveBeenCalled();
expect(mockButton.focus).toHaveBeenCalled();
});
it('should focus row when delete dialog closes from context menu', () => {
savedSearchesListUiService.confirmDeleteSavedSearch(mockedSearch, true);
expect(mockRow.focus).toHaveBeenCalled();
expect(mockButton.focus).not.toHaveBeenCalled();
});
});
}); });
@@ -32,29 +32,36 @@ import { SavedSearchEditDialogComponent } from '../dialog/edit/saved-search-edit
export class SavedSearchesListUiService { export class SavedSearchesListUiService {
private readonly dialog = inject(MatDialog); private readonly dialog = inject(MatDialog);
openEditSavedSearch(savedSearch: SavedSearch): void { openEditSavedSearch(savedSearch: SavedSearch, fromContextMenu = false): void {
this.dialog this.dialog
.open(SavedSearchEditDialogComponent, { .open(SavedSearchEditDialogComponent, {
data: savedSearch, data: savedSearch,
width: '600px' width: '600px',
restoreFocus: false
}) })
.afterClosed() .afterClosed()
.subscribe(() => this.focusAfterClose(`.adf-datatable-cell--${savedSearch.name}`)); .subscribe(() => this.focusAfterClose(savedSearch.name, fromContextMenu));
} }
confirmDeleteSavedSearch(savedSearch: SavedSearch): void { confirmDeleteSavedSearch(savedSearch: SavedSearch, fromContextMenu = false): void {
this.dialog this.dialog
.open(SavedSearchDeleteDialogComponent, { .open(SavedSearchDeleteDialogComponent, {
data: savedSearch, data: savedSearch,
minWidth: '500px' minWidth: '500px',
restoreFocus: false
}) })
.afterClosed() .afterClosed()
.subscribe(() => this.focusAfterClose(`.adf-datatable-cell--${savedSearch.name}`)); .subscribe(() => this.focusAfterClose(savedSearch.name, fromContextMenu));
} }
private focusAfterClose(focusedElementSelector: string): void { private focusAfterClose(name: string, fromContextMenu: boolean): void {
if (focusedElementSelector) { const row = Array.from(document.querySelectorAll<HTMLElement>('.adf-datatable-cell'))
document.querySelector<HTMLElement>(focusedElementSelector)?.closest<HTMLElement>('adf-datatable-row').focus(); .find((el) => el.getAttribute('data-automation-id') === name)
?.closest<HTMLElement>('adf-datatable-row');
row?.focus();
if (!fromContextMenu) {
row?.querySelector<HTMLElement>('.adf-datatable-actions-menu button')?.focus();
} }
} }
} }
@@ -214,7 +214,7 @@ describe('SavedSearchesListUiComponent ', () => {
}); });
it('should call openEditSavedSearch when selected edit option', () => { it('should call openEditSavedSearch when selected edit option', () => {
expect(savedSearchesListUiService.openEditSavedSearch).toHaveBeenCalledWith(editAction.data); expect(savedSearchesListUiService.openEditSavedSearch).toHaveBeenCalledWith(editAction.data, true);
}); });
}); });
@@ -227,7 +227,7 @@ describe('SavedSearchesListUiComponent ', () => {
}); });
it('should call confirmDeleteSavedSearch when selected delete option', () => { it('should call confirmDeleteSavedSearch when selected delete option', () => {
expect(savedSearchesListUiService.confirmDeleteSavedSearch).toHaveBeenCalledWith(deleteAction.data); expect(savedSearchesListUiService.confirmDeleteSavedSearch).toHaveBeenCalledWith(deleteAction.data, true);
}); });
}); });
@@ -110,7 +110,7 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft
ngAfterContentInit() { ngAfterContentInit() {
this.createDatatableSchema(); this.createDatatableSchema();
this.contextMenuAction$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((action) => this.executeMenuOption(action.key, action.data)); this.contextMenuAction$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((action) => this.executeMenuOption(action.key, action.data, true));
} }
@HostListener('document:keydown.escape') @HostListener('document:keydown.escape')
@@ -129,13 +129,13 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft
this.savedSearchOrderChanged.next(event); this.savedSearchOrderChanged.next(event);
} }
executeMenuOption(optionKey: string, savedSearchData: SavedSearch): void { executeMenuOption(optionKey: string, savedSearchData: SavedSearch, fromContextMenu = false): void {
switch (optionKey) { switch (optionKey) {
case this.editSavedSearchOptionKey: case this.editSavedSearchOptionKey:
this.openEditSavedSearchDialog(savedSearchData); this.openEditSavedSearchDialog(savedSearchData, fromContextMenu);
break; break;
case this.deleteSavedSearchOptionKey: case this.deleteSavedSearchOptionKey:
this.openDeleteSavedSearchDialog(savedSearchData); this.openDeleteSavedSearchDialog(savedSearchData, fromContextMenu);
break; break;
case this.copyToClipboardUrlOptionKey: case this.copyToClipboardUrlOptionKey:
this.copyToClipboard(savedSearchData); this.copyToClipboard(savedSearchData);
@@ -146,12 +146,12 @@ export class SavedSearchesListUiComponent extends DataTableSchema implements Aft
} }
} }
openEditSavedSearchDialog(savedSearch: SavedSearch): void { openEditSavedSearchDialog(savedSearch: SavedSearch, fromContextMenu = false): void {
this.savedSearchesListUiService.openEditSavedSearch(savedSearch); this.savedSearchesListUiService.openEditSavedSearch(savedSearch, fromContextMenu);
} }
openDeleteSavedSearchDialog(savedSearch: SavedSearch): void { openDeleteSavedSearchDialog(savedSearch: SavedSearch, fromContextMenu = false): void {
this.savedSearchesListUiService.confirmDeleteSavedSearch(savedSearch); this.savedSearchesListUiService.confirmDeleteSavedSearch(savedSearch, fromContextMenu);
} }
copyToClipboard(savedSearch: SavedSearch): void { copyToClipboard(savedSearch: SavedSearch): void {