1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-08 04:33:38 +00:00

fix a11y grid directives

This commit is contained in:
William Martin
2025-05-19 00:40:12 -04:00
parent fcaf5e63c5
commit ca9b883d4a
4 changed files with 96 additions and 95 deletions

View File

@@ -1,6 +1,4 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { ContentChild, Directive, ElementRef, HostBinding } from "@angular/core";
import { Directive, ElementRef, HostBinding, contentChild } from "@angular/core";
import { FocusableElement } from "../shared/focusable-element";
@@ -11,17 +9,16 @@ import { FocusableElement } from "../shared/focusable-element";
})
export class A11yCellDirective implements FocusableElement {
@HostBinding("attr.role")
role: "gridcell" | null;
role?: "gridcell" | null;
@ContentChild(FocusableElement)
private focusableChild: FocusableElement;
private focusableChild = contentChild(FocusableElement);
getFocusTarget() {
let focusTarget: HTMLElement;
if (this.focusableChild) {
focusTarget = this.focusableChild.getFocusTarget();
let focusTarget: HTMLElement | undefined | null;
if (this.focusableChild()) {
focusTarget = this.focusableChild()!.getFocusTarget();
} else {
focusTarget = this.elementRef.nativeElement.querySelector("button, a");
focusTarget = this.elementRef.nativeElement.querySelector<HTMLElement>("button, a");
}
if (!focusTarget) {

View File

@@ -1,13 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CdkVirtualScrollViewport } from "@angular/cdk/scrolling";
import {
AfterViewInit,
ContentChildren,
Directive,
HostBinding,
HostListener,
Input,
QueryList,
Signal,
computed,
contentChildren,
effect,
inject,
input,
signal,
} from "@angular/core";
import type { A11yCellDirective } from "./a11y-cell.directive";
@@ -17,23 +19,49 @@ import { A11yRowDirective } from "./a11y-row.directive";
selector: "bitA11yGrid",
standalone: true,
})
export class A11yGridDirective implements AfterViewInit {
export class A11yGridDirective {
private viewPort = inject(CdkVirtualScrollViewport, { optional: true });
@HostBinding("attr.role")
role = "grid";
@ContentChildren(A11yRowDirective)
rows: QueryList<A11yRowDirective>;
/** The number of pages to navigate on `PageUp` and `PageDown` */
@Input() pageSize = 5;
pageSize = input(5);
private grid: A11yCellDirective[][];
private rows = contentChildren(A11yRowDirective);
private grid: Signal<A11yCellDirective[][]> = computed(() =>
this.rows().map((row) => [...row.cells()]),
);
private get numRows(): number {
return this.viewPort ? this.viewPort.getDataLength() : this.rows().length;
}
/** The row that currently has focus */
private activeRow = 0;
private activeRow = signal(0);
private renderedRow = computed(() => this.convertRealRowToRenderedRow(this.activeRow()));
/** The cell that currently has focus */
private activeCol = 0;
private activeCol = signal(0);
private focusTarget = computed(() =>
this.grid()?.[this.renderedRow()]?.[this.activeCol()]?.getFocusTarget(),
);
constructor() {
effect(() => {
this.rows().forEach((row) => (row.role = "row"));
this.grid()
.flat()
.forEach((cell) => {
cell.role = "gridcell";
if (cell.getFocusTarget() !== this.focusTarget()) {
cell.getFocusTarget().tabIndex = -1;
}
});
});
}
@HostListener("keydown", ["$event"])
onKeyDown(event: KeyboardEvent) {
@@ -51,16 +79,16 @@ export class A11yGridDirective implements AfterViewInit {
this.updateCellFocusByDelta(0, -1);
break;
case "Home":
this.updateCellFocusByDelta(-this.activeRow, -this.activeCol);
this.updateCellFocusByDelta(-this.activeRow(), 0);
break;
case "End":
this.updateCellFocusByDelta(this.grid.length, this.grid[this.grid.length - 1].length);
this.updateCellFocusByDelta(this.numRows, 0);
break;
case "PageUp":
this.updateCellFocusByDelta(-this.pageSize, 0);
this.updateCellFocusByDelta(-this.pageSize(), 0);
break;
case "PageDown":
this.updateCellFocusByDelta(this.pageSize, 0);
this.updateCellFocusByDelta(this.pageSize(), 0);
break;
default:
return;
@@ -70,80 +98,59 @@ export class A11yGridDirective implements AfterViewInit {
event.preventDefault();
}
ngAfterViewInit(): void {
this.initializeGrid();
}
private initializeGrid(): void {
try {
this.grid = this.rows.map((listItem) => {
listItem.role = "row";
return [...listItem.cells];
});
this.grid.flat().forEach((cell) => {
cell.role = "gridcell";
cell.getFocusTarget().tabIndex = -1;
});
this.getActiveCellContent().tabIndex = 0;
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
// eslint-disable-next-line no-console
console.error("Unable to initialize grid");
private convertRealRowToRenderedRow(row: number) {
const range = this.viewPort
? this.viewPort.getRenderedRange()
: { start: 0, end: this.numRows };
if (row >= range.start && row < range.end) {
return row - range.start; // Convert real row index to rendered row index
}
}
/** Get the focusable content of the active cell */
private getActiveCellContent(): HTMLElement {
return this.grid[this.activeRow][this.activeCol].getFocusTarget();
return row;
}
/** Move focus via a delta against the currently active gridcell */
private updateCellFocusByDelta(rowDelta: number, colDelta: number) {
const prevActive = this.getActiveCellContent();
let nextCol = this.activeCol() + colDelta;
let nextRow = this.activeRow() + rowDelta;
this.activeCol += colDelta;
this.activeRow += rowDelta;
const getNumColumns = (r: number) => this.grid()[this.convertRealRowToRenderedRow(r)].length;
// Row upper bound
if (this.activeRow >= this.grid.length) {
this.activeRow = this.grid.length - 1;
if (nextRow >= this.numRows) {
nextRow = this.grid().length - 1;
}
// Row lower bound
if (this.activeRow < 0) {
this.activeRow = 0;
if (nextRow < 0) {
nextRow = 0;
}
// Column upper bound
if (this.activeCol >= this.grid[this.activeRow].length) {
if (this.activeRow < this.grid.length - 1) {
if (nextCol >= getNumColumns(nextRow)) {
if (nextRow < this.numRows - 1) {
// Wrap to next row on right arrow
this.activeCol = 0;
this.activeRow += 1;
nextCol = 0;
nextRow += 1;
} else {
this.activeCol = this.grid[this.activeRow].length - 1;
nextCol = getNumColumns(nextRow) - 1;
}
}
// Column lower bound
if (this.activeCol < 0) {
if (this.activeRow > 0) {
if (nextCol < 0) {
if (nextRow > 0) {
// Wrap to prev row on left arrow
this.activeRow -= 1;
this.activeCol = this.grid[this.activeRow].length - 1;
nextRow -= 1;
nextCol = getNumColumns(nextRow) - 1;
} else {
this.activeCol = 0;
nextCol = 0;
}
}
const nextActive = this.getActiveCellContent();
nextActive.tabIndex = 0;
nextActive.focus();
this.activeCol.set(nextCol);
this.activeRow.set(nextRow);
if (nextActive !== prevActive) {
prevActive.tabIndex = -1;
}
this.focusTarget().tabIndex = 0;
this.focusTarget().focus();
}
}

View File

@@ -1,12 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
AfterViewInit,
ContentChildren,
Directive,
HostBinding,
QueryList,
ViewChildren,
Signal,
computed,
contentChildren,
viewChildren,
} from "@angular/core";
import { A11yCellDirective } from "./a11y-cell.directive";
@@ -15,19 +13,15 @@ import { A11yCellDirective } from "./a11y-cell.directive";
selector: "bitA11yRow",
standalone: true,
})
export class A11yRowDirective implements AfterViewInit {
export class A11yRowDirective {
@HostBinding("attr.role")
role: "row" | null;
role?: "row" | null;
cells: A11yCellDirective[];
private viewCells = viewChildren(A11yCellDirective);
private contentCells = contentChildren(A11yCellDirective);
@ViewChildren(A11yCellDirective)
private viewCells: QueryList<A11yCellDirective>;
@ContentChildren(A11yCellDirective)
private contentCells: QueryList<A11yCellDirective>;
ngAfterViewInit(): void {
this.cells = [...this.viewCells, ...this.contentCells];
}
cells: Signal<A11yCellDirective[]> = computed(() => [
...this.viewCells(),
...this.contentCells(),
]);
}

View File

@@ -1,5 +1,7 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { A11yGridDirective } from "../a11y/a11y-grid.directive";
@Component({
selector: "bit-item-group",
standalone: true,
@@ -9,5 +11,6 @@ import { ChangeDetectionStrategy, Component } from "@angular/core";
host: {
class: "tw-block",
},
hostDirectives: [A11yGridDirective],
})
export class ItemGroupComponent {}