[ADF-2990] Datatable - row navigation (#5198)

* datatable row component

* add to module

* implement datatable row component

* use FocusKeyManager for arrows navigation

* tests

* prevent screen reader to announce undefined value

* prevent from bubbling up

* fix unit test

* fix row locator

* fix locator reference

* fix row reference locators

* fix locator xpath
This commit is contained in:
Cilibiu Bogdan
2019-10-30 09:39:43 +02:00
committed by Denys Vuika
parent a150e74366
commit af6bd0892c
17 changed files with 320 additions and 45 deletions

View File

@@ -0,0 +1,99 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DataTableRowComponent } from './datatable-row.component';
import { DataRow } from '../../data/data-row.model';
import { TestBed, ComponentFixture } from '@angular/core/testing';
describe('DataTableRowComponent', () => {
let fixture: ComponentFixture<DataTableRowComponent>;
let component: DataTableRowComponent;
const row: DataRow = {
isSelected: false,
hasValue: jasmine.createSpy('hasValue'),
getValue: () => {}
};
beforeEach(() => {
TestBed.configureTestingModule({
declarations: [DataTableRowComponent]
});
fixture = TestBed.createComponent(DataTableRowComponent);
component = fixture.componentInstance;
});
it('should add select class when row is selected', () => {
row.isSelected = true;
component.row = row;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.classList.contains('adf-is-selected')).toBe(true);
});
it('should not have select class when row is not selected', () => {
row.isSelected = false;
component.row = row;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.classList.contains('adf-is-selected'))
.not.toBe(true);
});
it('should set aria selected to true when row is selected', () => {
row.isSelected = true;
component.row = row;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('aria-selected')).toBe('true');
});
it('should set aria selected to false when row is not selected', () => {
row.isSelected = false;
component.row = row;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('aria-selected')).toBe('false');
});
it('should set aria label', () => {
spyOn(row, 'getValue').and.returnValue('some-name');
component.row = row;
fixture.detectChanges();
expect(fixture.debugElement.nativeElement.getAttribute('aria-label')).toBe('some-name');
});
it('should focus element', () => {
expect(document.activeElement.classList.contains('adf-datatable-row')).toBe(false);
component.focus();
expect(document.activeElement.classList.contains('adf-datatable-row')).toBe(true);
});
it('should emit keyboard space event', () => {
spyOn(component.select, 'emit');
const event = new KeyboardEvent('keydown', {
key: ' ',
code: 'Space'
});
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(component.select.emit).toHaveBeenCalledWith(event);
});
});

View File

@@ -0,0 +1,75 @@
/*!
* @license
* Copyright 2019 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Component,
ViewEncapsulation,
ElementRef,
Input,
HostBinding,
HostListener,
Output,
EventEmitter
} from '@angular/core';
import { FocusableOption } from '@angular/cdk/a11y';
import { DataRow } from '../../data/data-row.model';
@Component({
selector: 'adf-datatable-row',
template: `<ng-content></ng-content>`,
encapsulation: ViewEncapsulation.None,
host: {
class: 'adf-datatable-row',
tabindex: '0',
role: 'row'
}
})
export class DataTableRowComponent implements FocusableOption {
@Input() row: DataRow;
@Output()
select: EventEmitter<any> = new EventEmitter<any>();
@HostBinding('class.adf-is-selected')
get isSelected(): boolean {
return this.row.isSelected;
}
@HostBinding('attr.aria-selected')
get isAriaSelected(): boolean {
return this.row.isSelected;
}
@HostBinding('attr.aria-label')
get ariaLabel(): boolean {
return this.row.getValue('name') || '';
}
@HostListener('keydown.space', ['$event'])
onKeyDown(event: KeyboardEvent) {
if ((event.target as Element).tagName === this.element.nativeElement.tagName) {
event.preventDefault();
this.select.emit(event);
}
}
constructor(private element: ElementRef) {}
focus() {
this.element.nativeElement.focus();
}
}

View File

@@ -51,14 +51,15 @@
<div class="adf-datatable-body" role="rowgroup">
<ng-container *ngIf="!loading && !noPermission">
<div *ngFor="let row of data.getRows(); let idx = index"
class="adf-datatable-row"
role="row"
[class.adf-is-selected]="row.isSelected"
[adf-upload]="allowDropFiles && rowAllowsDrop(row)" [adf-upload-data]="row"
[ngStyle]="rowStyle"
[ngClass]="getRowStyle(row)"
(keyup)="onRowKeyUp(row, $event)">
<adf-datatable-row *ngFor="let row of data.getRows(); let idx = index"
[row]="row"
(select)="onEnterKeyPressed(row, $event)"
(keyup)="onRowKeyUp(row, $event)"
[adf-upload]="allowDropFiles && rowAllowsDrop(row)"
[adf-upload-data]="row"
[ngStyle]="rowStyle"
[ngClass]="getRowStyle(row)"
[attr.data-automation-id]="'datatable-row-' + idx">
<!-- Actions (left) -->
<div *ngIf="actions && actionsPosition === 'left'" role="gridcell" class="adf-datatable-cell">
<button mat-icon-button [matMenuTriggerFor]="menu"
@@ -90,7 +91,7 @@
</div>
<div *ngFor="let col of data.getColumns()"
role="gridcell"
class=" adf-datatable-cell adf-datatable-cell--{{col.type || 'text'}} {{col.cssClass}}"
class="adf-datatable-cell adf-datatable-cell--{{col.type || 'text'}} {{col.cssClass}}"
[attr.title]="col.title | translate"
[attr.data-automation-id]="getAutomationValue(row)"
[attr.aria-selected]="row.isSelected ? true : false"
@@ -191,7 +192,7 @@
</ng-container>
</div>
<div *ngIf="col.template" class="adf-datatable-cell-container">
<div class="adf-cell-value" tabindex="0">
<div class="adf-cell-value">
<ng-container
[ngTemplateOutlet]="col.template"
[ngTemplateOutletContext]="{ $implicit: { data: data, row: row, col: col }, value: data.getValue(row, col, resolverFn) }">
@@ -222,8 +223,7 @@
</button>
</mat-menu>
</div>
</div>
</adf-datatable-row>
<div *ngIf="isEmpty()"
role="row"
[class.adf-datatable-row]="display === 'list'"

View File

@@ -12,7 +12,7 @@
$data-table-cell-text-color: mat-color($foreground, text) !default;
$data-table-cell-link-color: mat-color($foreground, text) !default;
$data-table-cell-link-hover-color: mat-color($alfresco-ecm-blue, 500) !default;
$data-table-cell-outline: 1px solid mat-color($alfresco-ecm-blue, A200) !default;
$data-table-outline: 1px solid mat-color($alfresco-ecm-blue, A200) !default;
$data-table-divider-color: mat-color($foreground, text, 0.07) !default;
$data-table-hover-color: mat-color($background, 'hover') !default;
$data-table-selection-color: mat-color($background, 'selected-button') !default;
@@ -238,6 +238,11 @@
background-color: $data-table-hover-color;
}
&:focus {
outline-offset: -1px;
outline: $data-table-outline;
}
&.adf-is-selected, &.adf-is-selected:hover {
background-color: $data-table-selection-color;
}
@@ -416,7 +421,7 @@
&:focus {
outline-offset: -1px;
outline: $data-table-cell-outline;
outline: $data-table-outline;
}
}

View File

@@ -373,6 +373,7 @@ describe('DataTable', () => {
const rows = dataTable.data.getRows();
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.rowClick.subscribe(() => {
expect(rows[0].isSelected).toBeFalsy();
@@ -395,6 +396,7 @@ describe('DataTable', () => {
const rows = dataTable.data.getRows();
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.rowClick.subscribe(() => {
expect(rows[0].isSelected).toBeFalsy();
@@ -462,6 +464,7 @@ describe('DataTable', () => {
);
const rows = dataTable.data.getRows();
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.rowClick.subscribe(() => {
expect(rows[0].isSelected).toBeTruthy();
@@ -481,6 +484,7 @@ describe('DataTable', () => {
rows[0].isSelected = true;
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.rowClick.subscribe(() => {
expect(rows[0].isSelected).toBeFalsy();
done();
@@ -509,6 +513,7 @@ describe('DataTable', () => {
});
dataTable.selection.push(rows[0]);
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.rowClick.subscribe(() => {
expect(rows[0].isSelected).toBeTruthy();
expect(rows[1].isSelected).toBeTruthy();
@@ -584,6 +589,7 @@ describe('DataTable', () => {
it('should emit row click event', (done) => {
const row = <DataRow> {};
dataTable.data = new ObjectDataTableAdapter([], []);
dataTable.rowClick.subscribe((e) => {
expect(e.value).toBe(row);
@@ -591,13 +597,16 @@ describe('DataTable', () => {
});
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.onRowClick(row, null);
});
it('should emit double click if there are two single click in 250ms', (done) => {
const row = <DataRow> {};
dataTable.data = new ObjectDataTableAdapter([], []);
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.rowDblClick.subscribe(() => {
done();
@@ -614,7 +623,9 @@ describe('DataTable', () => {
it('should emit double click if there are more than two single click in 250ms', (done) => {
const row = <DataRow> {};
dataTable.data = new ObjectDataTableAdapter([], []);
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.rowDblClick.subscribe(() => {
done();
@@ -635,7 +646,9 @@ describe('DataTable', () => {
const row = <DataRow> {};
let clickCount = 0;
dataTable.data = new ObjectDataTableAdapter([], []);
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.rowClick.subscribe(() => {
clickCount += 1;
@@ -653,6 +666,7 @@ describe('DataTable', () => {
it('should emit row-click dom event', (done) => {
const row = <DataRow> {};
dataTable.data = new ObjectDataTableAdapter([], []);
fixture.nativeElement.addEventListener('row-click', (e) => {
expect(e.detail.value).toBe(row);
@@ -660,17 +674,20 @@ describe('DataTable', () => {
});
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.onRowClick(row, null);
});
it('should emit row-dblclick dom event', (done) => {
const row = <DataRow> {};
dataTable.data = new ObjectDataTableAdapter([], []);
fixture.nativeElement.addEventListener('row-dblclick', (e) => {
expect(e.detail.value).toBe(row);
done();
});
dataTable.ngOnChanges({});
fixture.detectChanges();
dataTable.onRowClick(row, null);
dataTable.onRowClick(row, null);
});
@@ -1177,4 +1194,64 @@ describe('Accesibility', () => {
expect(dataTable.getAriaSort(column)).toBe('ADF-DATATABLE.ACCESSIBILITY.SORT_DESCENDING');
});
});
it('should focus next row on ArrowDown event', () => {
const event = new KeyboardEvent('keyup', {
code: 'ArrowDown',
key: 'ArrowDown',
keyCode: 40
} as KeyboardEventInit );
const dataRows =
[ { name: 'test1'}, { name: 'test2' } ];
dataTable.data = new ObjectDataTableAdapter([],
[new ObjectDataColumn({ key: 'name' })]
);
dataTable.ngOnChanges({
rows: new SimpleChange(null, dataRows, false)
});
fixture.detectChanges();
dataTable.ngAfterViewInit();
const rowElement = document.querySelectorAll('.adf-datatable-body .adf-datatable-row')[0];
const rowCellElement = rowElement.querySelector('.adf-datatable-cell');
rowCellElement.dispatchEvent(new MouseEvent('click'));
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.getAttribute('data-automation-id')).toBe('datatable-row-1');
});
it('should focus previous row on ArrowUp event', () => {
const event = new KeyboardEvent('keyup', {
code: 'ArrowDown',
key: 'ArrowDown',
keyCode: 38
} as KeyboardEventInit );
const dataRows =
[ { name: 'test1'}, { name: 'test2' } ];
dataTable.data = new ObjectDataTableAdapter([],
[new ObjectDataColumn({ key: 'name' })]
);
dataTable.ngOnChanges({
rows: new SimpleChange(null, dataRows, false)
});
fixture.detectChanges();
dataTable.ngAfterViewInit();
const rowElement = document.querySelectorAll('.adf-datatable-body .adf-datatable-row')[1];
const rowCellElement = rowElement.querySelector('.adf-datatable-cell');
rowCellElement.dispatchEvent(new MouseEvent('click'));
fixture.debugElement.nativeElement.dispatchEvent(event);
expect(document.activeElement.getAttribute('data-automation-id')).toBe('datatable-row-0');
});
});

View File

@@ -16,9 +16,11 @@
*/
import {
ViewChildren, QueryList, HostListener,
AfterContentInit, Component, ContentChild, DoCheck, ElementRef, EventEmitter, Input,
IterableDiffers, OnChanges, Output, SimpleChange, SimpleChanges, TemplateRef, ViewEncapsulation, OnDestroy
} from '@angular/core';
import { FocusKeyManager } from '@angular/cdk/a11y';
import { MatCheckboxChange } from '@angular/material';
import { Subscription, Observable, Observer } from 'rxjs';
import { DataColumnListComponent } from '../../../data-column/data-column-list.component';
@@ -27,6 +29,7 @@ import { DataRowEvent } from '../../data/data-row-event.model';
import { DataRow } from '../../data/data-row.model';
import { DataSorting } from '../../data/data-sorting.model';
import { DataTableAdapter } from '../../data/datatable-adapter';
import { DataTableRowComponent } from './datatable-row.component';
import { ObjectDataRow } from '../../data/object-datarow.model';
import { ObjectDataTableAdapter } from '../../data/object-datatable-adapter';
@@ -48,6 +51,9 @@ export enum DisplayMode {
})
export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck, OnDestroy {
@ViewChildren(DataTableRowComponent)
rowsList: QueryList<DataTableRowComponent>;
@ContentChild(DataColumnListComponent)
columnList: DataColumnListComponent;
@@ -178,6 +184,7 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck,
/** This array of fake rows fix the flex layout for the gallery view */
fakeRows = [];
private keyManager: FocusKeyManager<DataTableRowComponent>;
private clickObserver: Observer<DataRowEvent>;
private click$: Observable<DataRowEvent>;
@@ -189,6 +196,11 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck,
private multiClickStreamSub: Subscription;
private dataRowsChanged: Subscription;
@HostListener('keyup', ['$event'])
onKeydown(event: KeyboardEvent): void {
this.keyManager.onKeydown(event);
}
constructor(private elementRef: ElementRef,
differs: IterableDiffers) {
if (differs) {
@@ -210,6 +222,10 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck,
this.setTableSchema();
}
ngAfterViewInit() {
this.keyManager = new FocusKeyManager(this.rowsList).withWrap();
}
ngOnChanges(changes: SimpleChanges) {
this.initAndSubscribeClickStream();
if (this.isPropertyChanged(changes['data'])) {
@@ -390,6 +406,7 @@ export class DataTableComponent implements AfterContentInit, OnChanges, DoCheck,
}
if (row) {
this.keyManager.setActiveItem(this.data.getRows().indexOf(row));
const dataRowEvent = new DataRowEvent(row, mouseEvent, this);
this.clickObserver.next(dataRowEvent);
}

View File

@@ -22,11 +22,11 @@ import { AlfrescoApiService } from '../../../services/alfresco-api.service';
@Component({
selector: 'adf-filesize-cell',
template: `
<ng-container>
<ng-container *ngIf="(value$ | async | adfFileSize) as fileSize">
<span
[title]="tooltip"
[attr.aria-label]="value$ | async | adfFileSize"
>{{ value$ | async | adfFileSize }}</span
[attr.aria-label]="fileSize"
>{{ fileSize }}</span
>
</ng-container>
`,