LP1816480 Angular grid ARIA improvements
authorBill Erickson <berickxx@gmail.com>
Tue, 5 Mar 2019 22:07:21 +0000 (17:07 -0500)
committerBill Erickson <berickxx@gmail.com>
Fri, 24 May 2019 18:00:24 +0000 (14:00 -0400)
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>
Signed-off-by: Jane Sandberg <sandbej@linnbenton.edu>
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 98a6fbf..96811a3 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 5dd307f..af223fe 100644 (file)
@@ -19,7 +19,7 @@
         <label class="form-check-label">
           <input class="form-check-input" type="checkbox"
             [(ngModel)]="cb.isChecked"
-            (click)="cb.onChange.emit($event.target.checked)"/>
+            (click)="cb.onChange($event.target.checked)"/>
             {{cb.label}}
         </label>
       </ng-container>
@@ -29,6 +29,9 @@
   <!-- 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">
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 7c30438..fbc0208 100644 (file)
@@ -442,6 +442,10 @@ export class GridContext {
     overflowCells: boolean;
     showLinkSelectors: 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;
@@ -467,6 +471,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;
@@ -731,6 +736,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) {
@@ -770,12 +781,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> {