[ACS-10280] Keyboard Navigation: Search: Unnecessary stops in the results table using the Tab key: (#11529)

* [ACS-10280]: removes rows being focusable by default

* [ACS-10280]: non fucusable chips by default

* [ACS-10280]: datatable UTs

* [ACS-10280]: updates focus management for datatable

* [ACS-10280]: improves tabfocus for hidden action buttons
This commit is contained in:
Anton Ramanovich
2026-01-22 07:18:33 +01:00
committed by GitHub
parent 5076862e07
commit 506aeecdc2
6 changed files with 72 additions and 96 deletions
@@ -90,19 +90,21 @@ describe('DataTableRowComponent', () => {
expect(fixture.debugElement.nativeElement.getAttribute('aria-label')).toBe('some-name'); expect(fixture.debugElement.nativeElement.getAttribute('aria-label')).toBe('some-name');
}); });
it('should set tabindex as focusable when row is not disabled', () => { it('should set tabindex as non focusable by default (disabled propery is not passed)', () => {
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('tabindex')).toBeNull();
});
it('should not set tabindex when row is disabled', () => {
component.disabled = false;
fixture.detectChanges(); fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('tabindex')).toBe('0'); expect(fixture.debugElement.nativeElement.getAttribute('tabindex')).toBe('0');
}); });
it('should not set tabindex when row is disabled', () => {
component.disabled = true;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('tabindex')).toBe(null);
});
it('should focus element', () => { it('should focus element', () => {
expect(document.activeElement.classList.contains('adf-datatable-row')).toBe(false); expect(document.activeElement.classList.contains('adf-datatable-row')).toBe(false);
component.disabled = false;
fixture.detectChanges();
component.focus(); component.focus();
expect(document.activeElement.classList.contains('adf-datatable-row')).toBe(true); expect(document.activeElement.classList.contains('adf-datatable-row')).toBe(true);
@@ -25,14 +25,13 @@ import { DataRow } from '../../data/data-row.model';
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,
host: { host: {
class: 'adf-datatable-row', class: 'adf-datatable-row',
tabindex: '0',
role: 'row' role: 'row'
} }
}) })
export class DataTableRowComponent implements FocusableOption { export class DataTableRowComponent implements FocusableOption {
@Input() row: DataRow; @Input() row: DataRow;
@Input() disabled = false; @Input() disabled = true;
@Output() @Output()
select: EventEmitter<any> = new EventEmitter<any>(); select: EventEmitter<any> = new EventEmitter<any>();
@@ -12,7 +12,6 @@
cdkDropListOrientation="horizontal" cdkDropListOrientation="horizontal"
[cdkDropListSortPredicate]="filterDisabledColumns" [cdkDropListSortPredicate]="filterDisabledColumns"
data-automation-id="datatable-row-header" data-automation-id="datatable-row-header"
[disabled]="!isHeaderVisible()"
class="adf-datatable-row" class="adf-datatable-row"
role="row"> role="row">
@@ -58,7 +57,7 @@
(click)="onColumnHeaderClick(col, $event)" (click)="onColumnHeaderClick(col, $event)"
(keyup.enter)="onColumnHeaderClick(col, $event)" (keyup.enter)="onColumnHeaderClick(col, $event)"
role="columnheader" role="columnheader"
[attr.tabindex]="isHeaderVisible() ? 0 : null" [attr.tabindex]="col.sortable ? 0 : null"
[attr.aria-sort]="col.sortable ? (getAriaSort(col) | translate) : null" [attr.aria-sort]="col.sortable ? (getAriaSort(col) | translate) : null"
cdkDrag cdkDrag
cdkDragLockAxis="x" cdkDragLockAxis="x"
@@ -214,7 +213,7 @@
[class.adf-datatable-row__dragging]="isDraggingRow" [class.adf-datatable-row__dragging]="isDraggingRow"
[attr.data-automation-id]="'datatable-row-' + idx" [attr.data-automation-id]="'datatable-row-' + idx"
(contextmenu)="markRowAsContextMenuSource(row)" (contextmenu)="markRowAsContextMenuSource(row)"
[disabled]="!(data.allowFocusOnRows ?? true)" [disabled]="!(data?.allowFocusOnRows ?? true)"
> >
<!-- Drag button --> <!-- Drag button -->
<div *ngIf="enableDragRows" <div *ngIf="enableDragRows"
@@ -251,7 +250,7 @@
(click)="onCheckboxLabelClick(row, $event)" (click)="onCheckboxLabelClick(row, $event)"
[for]="'select-file-' + idx" [for]="'select-file-' + idx"
class="adf-datatable-cell adf-datatable-checkbox adf-datatable-checkbox-single" class="adf-datatable-cell adf-datatable-checkbox adf-datatable-checkbox-single"
tabindex="0"> [attr.tabindex]="null">
<mat-checkbox <mat-checkbox
[id]="'select-file-' + idx" [id]="'select-file-' + idx"
[disabled]="!row?.isSelectable" [disabled]="!row?.isSelectable"
@@ -278,7 +277,7 @@
[attr.aria-selected]="row.isSelected" [attr.aria-selected]="row.isSelected"
[attr.aria-label]="col.title ? (col.title | translate) : null" [attr.aria-label]="col.title ? (col.title | translate) : null"
(click)="onRowClick(row, $event)" (click)="onRowClick(row, $event)"
[attr.tabindex]="col.focus ? 0 : null" [attr.tabindex]="null"
(keydown.enter)="onEnterKeyPressed(row, $any($event))" (keydown.enter)="onEnterKeyPressed(row, $any($event))"
[adf-context-menu]="getContextMenuActions(row, col)" [adf-context-menu]="getContextMenuActions(row, col)"
[adf-context-menu-enabled]="contextMenu" [adf-context-menu-enabled]="contextMenu"
@@ -327,7 +326,7 @@
<div <div
*ngSwitchCase="'date'" *ngSwitchCase="'date'"
class="adf-cell-value adf-cell-date" class="adf-cell-value adf-cell-date"
[attr.tabindex]="data.getValue(row, col, resolverFn)? 0 : -1"
[attr.data-automation-id]="'date_' + (data.getValue(row, col, resolverFn) | adfLocalizedDate: 'medium') "> [attr.data-automation-id]="'date_' + (data.getValue(row, col, resolverFn) | adfLocalizedDate: 'medium') ">
<adf-date-cell class="adf-datatable-center-date-column-ie" <adf-date-cell class="adf-datatable-center-date-column-ie"
[data]="data" [data]="data"
@@ -338,7 +337,7 @@
[dateConfig]="col.dateConfig" /> [dateConfig]="col.dateConfig" />
</div> </div>
<div *ngSwitchCase="'location'" [attr.tabindex]="data.getValue(row, col, resolverFn)? 0 : -1" class="adf-cell-value" <div *ngSwitchCase="'location'" class="adf-cell-value"
[attr.data-automation-id]="'location' + data.getValue(row, col, resolverFn)"> [attr.data-automation-id]="'location' + data.getValue(row, col, resolverFn)">
<adf-location-cell <adf-location-cell
[data]="data" [data]="data"
@@ -347,7 +346,7 @@
[resolverFn]="resolverFn" [resolverFn]="resolverFn"
[tooltip]="getCellTooltip(row, col)" /> [tooltip]="getCellTooltip(row, col)" />
</div> </div>
<div *ngSwitchCase="'fileSize'" [attr.tabindex]="data.getValue(row, col, resolverFn)? 0 : -1" class="adf-cell-value" <div *ngSwitchCase="'fileSize'" class="adf-cell-value"
[attr.data-automation-id]="'fileSize_' + data.getValue(row, col, resolverFn)"> [attr.data-automation-id]="'fileSize_' + data.getValue(row, col, resolverFn)">
<adf-filesize-cell class="adf-datatable-center-size-column-ie" <adf-filesize-cell class="adf-datatable-center-size-column-ie"
[data]="data" [data]="data"
@@ -356,7 +355,7 @@
[resolverFn]="resolverFn" [resolverFn]="resolverFn"
[tooltip]="getCellTooltip(row, col)" /> [tooltip]="getCellTooltip(row, col)" />
</div> </div>
<div *ngSwitchCase="'text'" [attr.tabindex]="data.getValue(row, col, resolverFn)? 0 : -1" class="adf-cell-value" <div *ngSwitchCase="'text'" class="adf-cell-value"
[attr.data-automation-id]="'text_' + data.getValue(row, col, resolverFn)"> [attr.data-automation-id]="'text_' + data.getValue(row, col, resolverFn)">
<adf-datatable-cell <adf-datatable-cell
[copyContent]="col.copyContent" [copyContent]="col.copyContent"
@@ -366,7 +365,7 @@
[resolverFn]="resolverFn" [resolverFn]="resolverFn"
[tooltip]="getCellTooltip(row, col)" /> [tooltip]="getCellTooltip(row, col)" />
</div> </div>
<div *ngSwitchCase="'boolean'" [attr.tabindex]="data.getValue(row, col, resolverFn)? 0 : -1" class="adf-cell-value" <div *ngSwitchCase="'boolean'" class="adf-cell-value"
[attr.data-automation-id]="'boolean_' + data.getValue(row, col, resolverFn)"> [attr.data-automation-id]="'boolean_' + data.getValue(row, col, resolverFn)">
<adf-boolean-cell <adf-boolean-cell
[data]="data" [data]="data"
@@ -375,7 +374,7 @@
[resolverFn]="resolverFn" [resolverFn]="resolverFn"
[tooltip]="getCellTooltip(row, col)" /> [tooltip]="getCellTooltip(row, col)" />
</div> </div>
<div *ngSwitchCase="'json'" [attr.tabindex]="data.getValue(row, col, resolverFn)? 0 : -1" class="adf-cell-value"> <div *ngSwitchCase="'json'" class="adf-cell-value">
<adf-json-cell <adf-json-cell
[editable]="col.editable" [editable]="col.editable"
[data]="data" [data]="data"
@@ -385,7 +384,6 @@
</div> </div>
<div *ngSwitchCase="'amount'" <div *ngSwitchCase="'amount'"
class="adf-cell-value" class="adf-cell-value"
[attr.tabindex]="data.getValue(row, col, resolverFn)? 0 : -1"
[attr.data-automation-id]="'amount_' + data.getValue(row, col, resolverFn)"> [attr.data-automation-id]="'amount_' + data.getValue(row, col, resolverFn)">
<adf-amount-cell <adf-amount-cell
[data]="data" [data]="data"
@@ -396,7 +394,6 @@
</div> </div>
<div *ngSwitchCase="'number'" <div *ngSwitchCase="'number'"
class="adf-cell-value" class="adf-cell-value"
[attr.tabindex]="data.getValue(row, col, resolverFn)? 0 : -1"
[attr.data-automation-id]="'number_' + data.getValue(row, col, resolverFn)"> [attr.data-automation-id]="'number_' + data.getValue(row, col, resolverFn)">
<adf-number-cell <adf-number-cell
[data]="data" [data]="data"
@@ -411,7 +408,7 @@
</ng-container> </ng-container>
</div> </div>
<div *ngIf="col.template" class="adf-datatable-cell-container"> <div *ngIf="col.template" class="adf-datatable-cell-container">
<div class="adf-cell-value" [attr.tabindex]="col.focus ? 0 : null"> <div class="adf-cell-value">
<ng-container <ng-container
[ngTemplateOutlet]="col.template" [ngTemplateOutlet]="col.template"
[ngTemplateOutletContext]="{ $implicit: { data: data, row: row, col: col }, value: data.getValue(row, col, resolverFn) }" /> [ngTemplateOutletContext]="{ $implicit: { data: data, row: row, col: col }, value: data.getValue(row, col, resolverFn) }" />
@@ -425,7 +422,6 @@
((actions && actionsPosition === 'right') || ((actions && actionsPosition === 'right') ||
(mainActionTemplate && showMainDatatableActions))" (mainActionTemplate && showMainDatatableActions))"
role="gridcell" role="gridcell"
tabindex="0"
class="adf-datatable-cell adf-datatable__actions-cell adf-datatable-center-actions-column-ie adf-datatable-actions-menu"> class="adf-datatable-cell adf-datatable__actions-cell adf-datatable-center-actions-column-ie adf-datatable-actions-menu">
<ng-container *ngIf="(actions && actionsPosition === 'right')"> <ng-container *ngIf="(actions && actionsPosition === 'right')">
@@ -342,7 +342,15 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width-1 !default;
} }
} }
.adf-datatable-row .adf-datatable-actions-menu { .adf-datatable-row {
&:focus-within,
&:hover {
.adf-datatable-actions-menu .adf-datatable-hide-actions-without-hover {
visibility: visible;
}
}
.adf-datatable-actions-menu {
margin-left: auto; margin-left: auto;
justify-content: end; justify-content: end;
padding-right: 15px; padding-right: 15px;
@@ -361,6 +369,7 @@ $data-table-cell-min-width-file-size: $data-table-cell-min-width-1 !default;
padding-right: 0; padding-right: 0;
} }
} }
}
.adf-datatable-checkbox { .adf-datatable-checkbox {
display: flex; display: flex;
@@ -36,6 +36,7 @@ import { HarnessLoader } from '@angular/cdk/testing';
import { ConfigurableFocusTrapFactory } from '@angular/cdk/a11y'; import { ConfigurableFocusTrapFactory } from '@angular/cdk/a11y';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { DataTableAdapter } from '@alfresco/adf-core';
@Component({ @Component({
selector: 'adf-custom-column-template-component', selector: 'adf-custom-column-template-component',
@@ -1712,52 +1713,14 @@ describe('Accessibility', () => {
expect(document.activeElement?.getAttribute('data-automation-id')).toBe('datatable-row-0'); expect(document.activeElement?.getAttribute('data-automation-id')).toBe('datatable-row-0');
}); });
});
it('should select header row when `showHeader` is `Always`', () => { describe('Row cells focus management', () => {
const testFocus = (sortable: boolean, selector: string, expectedTabindex: string | null) => {
dataTable.showHeader = ShowHeaderMode.Always; dataTable.showHeader = ShowHeaderMode.Always;
dataTable.ngOnChanges({
rows: new SimpleChange(null, dataRows, false)
});
fixture.detectChanges();
dataTable.ngAfterViewInit();
const rowElement = testingUtils.getAllByCSS('.adf-datatable-body .adf-datatable-row')[0];
testingUtils.setDebugElement(rowElement);
testingUtils.clickByCSS('.adf-datatable-cell');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement?.getAttribute('data-automation-id')).toBe('datatable-row-header');
});
it('should not select header row when `showHeader` is `Never`', () => {
dataTable.showHeader = ShowHeaderMode.Never;
dataTable.ngOnChanges({
rows: new SimpleChange(null, dataRows, false)
});
fixture.detectChanges();
dataTable.ngAfterViewInit();
const rowElement = testingUtils.getAllByCSS('.adf-datatable-body .adf-datatable-row')[0];
testingUtils.setDebugElement(rowElement);
testingUtils.clickByCSS('.adf-datatable-cell');
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement?.getAttribute('data-automation-id')).toBe('datatable-row-1');
});
});
describe('DataTable row focus management', () => {
const testFocus = (focus: boolean, selector: string, expectedTabindex: string | null) => {
dataTable.showHeader = ShowHeaderMode.Never;
const dataRows = [{ name: 'name1' }]; const dataRows = [{ name: 'name1' }];
dataTable.data = new ObjectDataTableAdapter([], [new ObjectDataColumn({ key: 'name', template: columnCustomTemplate, focus })]); dataTable.data = new ObjectDataTableAdapter([], [new ObjectDataColumn({ key: 'name', template: columnCustomTemplate, sortable })]);
dataTable.ngOnChanges({ dataTable.ngOnChanges({
rows: new SimpleChange(null, dataRows, false) rows: new SimpleChange(null, dataRows, false)
@@ -1770,23 +1733,24 @@ describe('Accessibility', () => {
expect(element?.nativeElement.getAttribute('tabindex')).toEqual(expectedTabindex); expect(element?.nativeElement.getAttribute('tabindex')).toEqual(expectedTabindex);
}; };
const cellaValSelector = '.adf-datatable-row[data-automation-id="datatable-row-0"] .adf-cell-value'; const headerCellSelector = '.adf-datatable-cell-header';
const cellWrapperSelector = '.adf-datatable-cell';
it('should remove cell focus when [focus] is set to false', () => { it('should remove header cell focus when cell is not sortable', () => {
testFocus(false, cellaValSelector, null); testFocus(false, headerCellSelector, null);
}); });
it('should allow element focus when [focus] is set to true', () => { it('should allow header cell focus when cell is sortable', () => {
testFocus(true, cellaValSelector, '0'); testFocus(true, headerCellSelector, '0');
}); });
it('should remove col focus when [focus] is set to false', () => { it('should set tabindex equal to null on body cell by default (sortable = false)', () => {
testFocus(false, cellWrapperSelector, null); const bodyCellSelector = '.adf-datatable-cell';
testFocus(false, bodyCellSelector, null);
}); });
it('should allow col focus when [focus] is set to true', () => { it('should set tabindex equal to null on body cell by default (sortable = true)', () => {
testFocus(true, cellWrapperSelector, '0'); const bodyCellSelector = '.adf-datatable-cell';
testFocus(true, bodyCellSelector, null);
}); });
}); });
@@ -1802,12 +1766,10 @@ describe('Accessibility', () => {
this.allowFocusOnRows = allow; this.allowFocusOnRows = allow;
} }
} }
const testAllowFocusOnRows = (allowFocus: boolean, expectedTabindex: string | null) => { const testAllowFocusOnRows = (expectedTabindex: string | null, adapter: DataTableAdapter) => {
const fakeDataRows = [new FakeDataRow(), new FakeDataRow()]; const fakeDataRows = [new FakeDataRow(), new FakeDataRow()];
const adapter = new ShareAdapterMock([], []);
adapter.setRows(fakeDataRows); adapter.setRows(fakeDataRows);
adapter.setAllowFocusOnTableRows(allowFocus);
dataTable.data = adapter; dataTable.data = adapter;
fixture.detectChanges(); fixture.detectChanges();
const rowElements = testingUtils.getAllByCSS('.adf-datatable-body adf-datatable-row'); const rowElements = testingUtils.getAllByCSS('.adf-datatable-body adf-datatable-row');
@@ -1816,11 +1778,20 @@ describe('Accessibility', () => {
}; };
it('should set tabindex to null (disabled === true) on datatable-body rows when allowFocusOnRows is set to false in ShareDatatable adapter', () => { it('should set tabindex to null (disabled === true) on datatable-body rows when allowFocusOnRows is set to false in ShareDatatable adapter', () => {
testAllowFocusOnRows(false, null); const adapter = new ShareAdapterMock([], []);
adapter.setAllowFocusOnTableRows(false);
testAllowFocusOnRows(null, adapter);
}); });
it('should set tabindex to 0 (disabled === false) on datatable-body rows when allowFocusOnRows is set to true in ShareDatatable adapter (default case)', () => { it('should set tabindex to 0 (disabled === false) on datatable-body rows when allowFocusOnRows is not set explicitly in ShareDatatable adapter and falls back to default value ', () => {
testAllowFocusOnRows(true, '0'); const adapter = new ShareAdapterMock([], []);
adapter.setAllowFocusOnTableRows(true);
testAllowFocusOnRows('0', adapter);
});
it('should set tabindex to 0 (disabled === false) by default on datatable-body rows when allowFocusOnRows is not defined in Datatable adapter (fallback case)', () => {
const adapter = new ObjectDataTableAdapter([], []);
testAllowFocusOnRows('0', adapter);
}); });
}); });
@@ -14,7 +14,6 @@
[style.border-radius]="roundUpChips ? '20px' : '10px'" [style.border-radius]="roundUpChips ? '20px' : '10px'"
[style.font-weight]="'bold'" [style.font-weight]="'bold'"
role="listitem" role="listitem"
tabIndex="0"
[attr.aria-label]="chip.name" [attr.aria-label]="chip.name"
(removed)="removedChip.emit(chip.id)"> (removed)="removedChip.emit(chip.id)">
<span id="adf-dynamic-chip-list-chip-name-{{ idx }}">{{ chip.name }}</span> <span id="adf-dynamic-chip-list-chip-name-{{ idx }}">{{ chip.name }}</span>