LP1816480 Angular grid ARIA improvements user/berick/lp1816480-ang-grid-aria
authorBill Erickson <berickxx@gmail.com>
Tue, 5 Mar 2019 22:07:21 +0000 (17:07 -0500)
committerBill Erickson <berickxx@gmail.com>
Tue, 5 Mar 2019 22:26:52 +0000 (17:26 -0500)
Various navigation and "role" improvements to the Angular grid:

* Apply "grid", "row", "columnheader", and "gridcell" role attributes.
* Page-Down goes to next page
* Page-Up goes to previous page
* Shift-UpArrow extends selection one row up (spanning pages).
* Shift-DownArrow extends selection one row down (spanning pages).
* Shift-Arrow controls support reverse navigation for back-tracking to
  de-select certain rows.
** E.g. shift-up 3 rows then shift-down 1 will leave 2 rows selected.
* Control-A now selects all rows in the page.
** For consistency with the select-all checkbox, only rows in the
   current page are selected.
** Note we could add an option to extend the selection to all rows,
   but it would require pre-fetching all of the data, simimar to
   how grid printing pre-fetches.

Signed-off-by: Bill Erickson <berickxx@gmail.com>
Open-ILS/src/eg2/src/app/share/grid/grid-body.component.html
Open-ILS/src/eg2/src/app/share/grid/grid-body.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-header.component.html
Open-ILS/src/eg2/src/app/share/grid/grid-header.component.ts
Open-ILS/src/eg2/src/app/share/grid/grid-toolbar.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.component.html
Open-ILS/src/eg2/src/app/share/grid/grid.ts

index 8d495aa..a9f35aa 100644 (file)
@@ -2,7 +2,7 @@
   tabindex=1 so the grid body can capture keyboard events.
 -->
 <div class="eg-grid-body" tabindex="1" (keydown)="onGridKeyDown($event)">
-  <div class="eg-grid-row eg-grid-body-row {{context.rowClassCallback(row)}}"
+  <div role="row" class="eg-grid-row eg-grid-body-row {{context.rowClassCallback(row)}}"
     [ngClass]="{'selected': context.rowSelector.contains(context.getRowIndex(row))}"
     *ngFor="let row of context.dataSource.getPageOfRows(context.pager); let idx = index">
 
@@ -27,7 +27,8 @@
         </ng-container>
       </ng-container>
     </div>
-    <div class="eg-grid-cell eg-grid-body-cell" [ngStyle]="{flex:col.flex}"
+    <div role="gridcell" class="eg-grid-cell eg-grid-body-cell"
+      [ngStyle]="{flex:col.flex}"
       [ngClass]="{'eg-grid-cell-overflow': context.overflowCells}"
       (dblclick)="onRowDblClick(row)"
       (click)="onRowClick($event, row, idx)"
index 15aa2b7..9c9b190 100644 (file)
@@ -20,23 +20,45 @@ export class GridBodyComponent implements OnInit {
     onGridKeyDown(evt: KeyboardEvent) {
         switch (evt.key) {
             case 'ArrowUp':
-                this.context.selectPreviousRow();
+                if (evt.shiftKey) {
+                    // Extend selection up one row
+                    this.context.selectMultiRowsPrevious();
+                } else {
+                    this.context.selectPreviousRow();
+                }
                 evt.stopPropagation();
                 break;
             case 'ArrowDown':
-                this.context.selectNextRow();
+                if (evt.shiftKey) {
+                    // Extend selection down one row
+                    this.context.selectMultiRowsNext();
+                } else {
+                    this.context.selectNextRow();
+                }
                 evt.stopPropagation();
                 break;
             case 'ArrowLeft':
+            case 'PageUp':
                 this.context.toPrevPage()
                 .then(ok => this.context.selectFirstRow(), err => {});
                 evt.stopPropagation();
                 break;
             case 'ArrowRight':
+            case 'PageDown':
                 this.context.toNextPage()
                 .then(ok => this.context.selectFirstRow(), err => {});
                 evt.stopPropagation();
                 break;
+            case 'a':
+                // control-a means select all visible rows.
+                // For consistency, select all rows in the current page only.
+                if (evt.ctrlKey) {
+                    this.context.rowSelector.clear();
+                    this.context.selectRowsInPage();
+                    evt.preventDefault();
+                }
+                break;
+
             case 'Enter':
                 if (this.context.lastSelectedIndex) {
                     this.grid.onRowActivate.emit(
index 0662f54..01d0657 100644 (file)
@@ -1,26 +1,31 @@
 
-<div class="eg-grid-row eg-grid-header-row">
+<div row="row" class="eg-grid-row eg-grid-header-row">
   <ng-container *ngIf="!context.disableSelect">
-    <div class="eg-grid-cell eg-grid-header-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
-      <input type='checkbox' (click)="handleBatchSelect($event)">
+    <div role="columnheader"
+      class="eg-grid-cell eg-grid-header-cell eg-grid-checkbox-cell eg-grid-cell-skinny">
+      <input type='checkbox' (click)="handleBatchSelect($event)"
+        [(ngModel)]="batchRowCheckbox">
     </div>
   </ng-container>
-  <div class="eg-grid-cell eg-grid-header-cell eg-grid-number-cell eg-grid-cell-skinny">
+  <div role="columnheader"
+    class="eg-grid-cell eg-grid-header-cell eg-grid-number-cell eg-grid-cell-skinny">
     <span i18n="number|Row Number Header">#</span>
   </div>
-  <div *ngIf="context.rowFlairIsEnabled" 
+  <div *ngIf="context.rowFlairIsEnabled"
+    role="columnheader"
     class="eg-grid-cell eg-grid-header-cell eg-grid-flair-cell">
     <span class="material-icons">notifications</span>
   </div>
-  <div *ngFor="let col of context.columnSet.displayColumns()" 
-    draggable="true" 
+  <div role="columnheader"
+    *ngFor="let col of context.columnSet.displayColumns()"
+    draggable="true"
     (dragstart)="dragColumn = col"
     (drop)="onColumnDrop(col)"
     (dragover)="onColumnDragEnter($event, col)"
     (dragleave)="onColumnDragLeave($event, col)"
     [ngClass]="{'dragover' : col.isDragTarget}"
     class="eg-grid-cell eg-grid-header-cell" [ngStyle]="{flex:col.flex}">
-    <a class="sortable label-with-material-icon" *ngIf="col.isSortable" 
+    <a class="sortable label-with-material-icon" *ngIf="col.isSortable"
       (click)="sortOneColumn(col)">
       <span class="eg-grid-header-cell-sort-label">{{col.label}}</span>
       <span class="material-icons eg-grid-header-cell-sort-arrow"
index 0010a45..591fc66 100644 (file)
@@ -13,9 +13,15 @@ export class GridHeaderComponent implements OnInit {
 
     dragColumn: GridColumn;
 
+    batchRowCheckbox: boolean;
+
     constructor() {}
 
-    ngOnInit() {}
+    ngOnInit() {
+        this.context.selectRowsInPageEmitter.subscribe(
+            () => this.batchRowCheckbox = true
+        );
+    }
 
     onColumnDragEnter($event: any, col: any) {
         if (this.dragColumn && this.dragColumn.name !== col.name) {
@@ -71,9 +77,7 @@ export class GridHeaderComponent implements OnInit {
     }
 
     selectAll() {
-        const rows = this.context.dataSource.getPageOfRows(this.context.pager);
-        const indexes = rows.map(r => this.context.getRowIndex(r));
-        this.context.rowSelector.select(indexes);
+        this.context.selectRowsInPage();
     }
 
     allRowsAreSelected(): boolean {
index 5eaa81f..3f5f445 100644 (file)
@@ -5,7 +5,7 @@
 
     <!-- buttons -->
     <div class="btn-grp" *ngIf="gridContext.toolbarButtons.length">
-      <button *ngFor="let btn of gridContext.toolbarButtons" 
+      <button *ngFor="let btn of gridContext.toolbarButtons"
         [disabled]="btn.disabled"
         class="btn btn-outline-dark mr-1" (click)="btn.action()">
         {{btn.label}}
     </div>
 
     <!-- checkboxes -->
-    <div class="form-check form-check-inline" 
+    <div class="form-check form-check-inline"
       *ngIf="gridContext.toolbarCheckboxes.length">
       <ng-container *ngFor="let cb of gridContext.toolbarCheckboxes">
         <label class="form-check-label">
-          <input class="form-check-input" type="checkbox" 
+          <input class="form-check-input" type="checkbox"
             (click)="cb.onChange($event.target.checked)"/>
             {{cb.label}}
         </label>
   <!-- push everything else to the right -->
   <div class="flex-1"></div>
 
+  <div class="font-sm font-italic d-flex flex-column-reverse mr-2">
+    {{gridContext.rowSelector.selected().length}} selected
+  </div>
   <div ngbDropdown class="mr-1" placement="bottom-right">
     <button ngbDropdownToggle [disabled]="!gridContext.toolbarActions.length"
         class="btn btn-outline-dark no-dropdown-caret">
-      <span title="Actions For Selected Rows" i18n-title 
+      <span title="Actions For Selected Rows" i18n-title
         class="material-icons mat-icon-in-button">playlist_add_check</span>
     </button>
     <div class="dropdown-menu" ngbDropdownMenu>
     </div>
   </div>
 
-  <button [disabled]="gridContext.pager.isFirstPage()" type="button" 
+  <button [disabled]="gridContext.pager.isFirstPage()" type="button"
     class="btn btn-outline-dark mr-1" (click)="gridContext.pager.toFirst()">
-    <span title="First Page" i18n-title 
+    <span title="First Page" i18n-title
         class="material-icons mat-icon-in-button">first_page</span>
   </button>
-  <button [disabled]="gridContext.pager.isFirstPage()" type="button" 
+  <button [disabled]="gridContext.pager.isFirstPage()" type="button"
     class="btn btn-outline-dark mr-1" (click)="gridContext.pager.decrement()">
-    <span title="Previous Page" i18n-title 
+    <span title="Previous Page" i18n-title
         class="material-icons mat-icon-in-button">keyboard_arrow_left</span>
   </button>
-  <button [disabled]="gridContext.pager.isLastPage()" type="button" 
+  <button [disabled]="gridContext.pager.isLastPage()" type="button"
     class="btn btn-outline-dark mr-1" (click)="gridContext.pager.increment()">
-    <span title="Next Page" i18n-title 
+    <span title="Next Page" i18n-title
         class="material-icons mat-icon-in-button">keyboard_arrow_right</span>
   </button>
 
   <!--
   Hiding jump-to-last since there's no analog in the angularjs grid and
   it has limited value since the size of the data set is often unknown.
-  <button [disabled]="!gridContext.pager.resultCount || gridContext.pager.isLastPage()" 
+  <button [disabled]="!gridContext.pager.resultCount || gridContext.pager.isLastPage()"
     type="button" class="btn btn-outline-dark mr-1" (click)="gridContext.pager.toLast()">
-    <span title="First Page" i18n-title 
+    <span title="First Page" i18n-title
         class="material-icons mat-icon-in-button">last_page</span>
   </button>
   -->
@@ -76,7 +79,7 @@
       </span>
     </button>
     <div class="dropdown-menu" ngbDropdownMenu>
-      <a class="dropdown-item" 
+      <a class="dropdown-item"
         *ngFor="let count of [5, 10, 25, 50, 100]"
         (click)="gridContext.pager.setLimit(count)">
         <span class="ml-2">{{count}}</span>
     </div>
   </div>
 
-  <button type="button" 
-    class="btn btn-outline-dark mr-1" 
+  <button type="button"
+    class="btn btn-outline-dark mr-1"
     (click)="gridContext.overflowCells=!gridContext.overflowCells">
     <span *ngIf="!gridContext.overflowCells"
-      title="Expand Cells Vertically" i18n-title 
+      title="Expand Cells Vertically" i18n-title
       class="material-icons mat-icon-in-button">expand_more</span>
     <span *ngIf="gridContext.overflowCells"
-      title="Collaps Cells Vertically" i18n-title 
+      title="Collaps Cells Vertically" i18n-title
       class="material-icons mat-icon-in-button">expand_less</span>
   </button>
 
   </eg-grid-column-config>
   <div ngbDropdown placement="bottom-right">
     <button ngbDropdownToggle class="btn btn-outline-dark no-dropdown-caret">
-      <span title="Show Grid Options" i18n-title 
+      <span title="Show Grid Options" i18n-title
         class="material-icons mat-icon-in-button">settings</span>
     </button>
     <div class="dropdown-menu" ngbDropdownMenu>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="columnConfDialog.open({size:'lg'})">
         <span class="material-icons">build</span>
         <span class="ml-2" i18n>Manage Columns</span>
       </a>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="colWidthConfig.isVisible = !colWidthConfig.isVisible">
         <span class="material-icons">compare_arrows</span>
         <span class="ml-2" i18n>Manage Column Widths</span>
       </a>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="saveGridConfig()">
         <span class="material-icons">save</span>
         <span class="ml-2" i18n>Save Grid Settings</span>
       </a>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="gridContext.columnSet.reset()">
         <span class="material-icons">restore</span>
         <span class="ml-2" i18n>Reset Columns</span>
       </a>
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="generateCsvExportUrl($event)"
         [download]="csvExportFileName"
         [href]="csvExportUrl">
 
       <div class="dropdown-divider"></div>
 
-      <a class="dropdown-item label-with-material-icon" 
+      <a class="dropdown-item label-with-material-icon"
         (click)="col.visible=!col.visible" *ngFor="let col of gridContext.columnSet.columns">
         <span *ngIf="col.visible" class="badge badge-success">&#x2713;</span>
         <span *ngIf="!col.visible" class="badge badge-warning">&#x2717;</span>
index a98e17a..77ea0e6 100644 (file)
@@ -1,8 +1,8 @@
 
-<div class="eg-grid">
+<div class="eg-grid" role="grid">
 
   <eg-grid-toolbar
-    [gridContext]="context" 
+    [gridContext]="context"
     [gridPrinter]="gridPrinter"
     [colWidthConfig]="colWidthConfig">
   </eg-grid-toolbar>
@@ -11,7 +11,7 @@
 
   <eg-grid-column-width #colWidthConfig [gridContext]="context">
   </eg-grid-column-width>
-  
+
   <eg-grid-print #gridPrinter [gridContext]="context">
   </eg-grid-print>
 
index dcffc95..5475277 100644 (file)
@@ -1,7 +1,7 @@
 /**
  * Collection of grid related classses and interfaces.
  */
-import {TemplateRef} from '@angular/core';
+import {TemplateRef, EventEmitter} from '@angular/core';
 import {Observable, Subscription} from 'rxjs';
 import {IdlService, IdlObject} from '@eg/core/idl.service';
 import {OrgService} from '@eg/core/org.service';
@@ -438,6 +438,10 @@ export class GridContext {
     defaultHiddenFields: string[];
     overflowCells: boolean;
 
+    // Allow calling code to know when the select-all-rows-in-page
+    // action has occurred.
+    selectRowsInPageEmitter: EventEmitter<void>;
+
     // Services injected by our grid component
     idl: IdlService;
     org: OrgService;
@@ -463,6 +467,7 @@ export class GridContext {
     }
 
     init() {
+        this.selectRowsInPageEmitter = new EventEmitter<void>();
         this.columnSet = new GridColumnSet(this.idl, this.idlClass);
         this.columnSet.isSortable = this.isSortable === true;
         this.columnSet.isMultiSortable = this.isMultiSortable === true;
@@ -706,6 +711,12 @@ export class GridContext {
         this.lastSelectedIndex = index;
     }
 
+    selectMultipleRows(indexes: any[]) {
+        this.rowSelector.clear();
+        this.rowSelector.select(indexes);
+        this.lastSelectedIndex = indexes[indexes.length - 1];
+    }
+
     // selects or deselects an item, without affecting the others.
     // returns true if the item is selected; false if de-selected.
     toggleSelectOneRow(index: any) {
@@ -745,12 +756,84 @@ export class GridContext {
         }
     }
 
+    // shift-up-arrow
+    // Select the previous row in addition to any currently selected row.
+    // However, if the previous row is already selected, assume the user
+    // has reversed direction and now wants to de-select the last selected row.
+    selectMultiRowsPrevious() {
+        if (!this.lastSelectedIndex) { return; }
+        const pos = this.getRowPosition(this.lastSelectedIndex);
+        const selectedIndexes = this.rowSelector.selected();
+
+        const promise = // load the previous page of data if needed
+            (pos === this.pager.offset) ? this.toPrevPage() : Promise.resolve();
+
+        promise.then(
+            ok => {
+                const row = this.dataSource.data[pos - 1];
+                const newIndex = this.getRowIndex(row);
+                if (selectedIndexes.filter(i => i === newIndex).length > 0) {
+                    // Prev row is already selected.  User is reversing direction.
+                    this.rowSelector.deselect(this.lastSelectedIndex);
+                    this.lastSelectedIndex = newIndex;
+                } else {
+                    this.selectMultipleRows(selectedIndexes.concat(newIndex));
+                }
+            },
+            err => {}
+        );
+    }
+
+    // shift-down-arrow
+    // Select the next row in addition to any currently selected row.
+    // However, if the next row is already selected, assume the user
+    // has reversed direction and wants to de-select the last selected row.
+    selectMultiRowsNext() {
+        if (!this.lastSelectedIndex) { return; }
+        const pos = this.getRowPosition(this.lastSelectedIndex);
+        const selectedIndexes = this.rowSelector.selected();
+
+        const promise = // load the next page of data if needed
+            (pos === (this.pager.offset + this.pager.limit - 1)) ?
+            this.toNextPage() : Promise.resolve();
+
+        promise.then(
+            ok => {
+                const row = this.dataSource.data[pos + 1];
+                const newIndex = this.getRowIndex(row);
+                if (selectedIndexes.filter(i => i === newIndex).length > 0) {
+                    // Next row is already selected.  User is reversing direction.
+                    this.rowSelector.deselect(this.lastSelectedIndex);
+                    this.lastSelectedIndex = newIndex;
+                } else {
+                    this.selectMultipleRows(selectedIndexes.concat(newIndex));
+                }
+            },
+            err => {}
+        );
+    }
+
+    getFirstRowInPage(): any {
+        return this.dataSource.data[this.pager.offset];
+    }
+
+    getLastRowInPage(): any {
+        return this.dataSource.data[this.pager.offset + this.pager.limit - 1];
+    }
+
     selectFirstRow() {
-        this.selectRowByPos(this.pager.offset);
+        this.selectOneRow(this.getRowIndex(this.getFirstRowInPage()));
     }
 
     selectLastRow() {
-        this.selectRowByPos(this.pager.offset + this.pager.limit - 1);
+        this.selectOneRow(this.getRowIndex(this.getLastRowInPage()));
+    }
+
+    selectRowsInPage() {
+        const rows = this.dataSource.getPageOfRows(this.pager);
+        const indexes = rows.map(r => this.getRowIndex(r));
+        this.rowSelector.select(indexes);
+        this.selectRowsInPageEmitter.emit();
     }
 
     toPrevPage(): Promise<any> {