From 74d30f95b04315c8097a46389f51aa9c974c7fa3 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 20 May 2025 21:04:46 -0400 Subject: [PATCH] different approach --- .../src/a11y/a11y-grid.directive.ts | 175 ++++++++---------- 1 file changed, 81 insertions(+), 94 deletions(-) diff --git a/libs/components/src/a11y/a11y-grid.directive.ts b/libs/components/src/a11y/a11y-grid.directive.ts index 5b50335d46e..9d7ff1911aa 100644 --- a/libs/components/src/a11y/a11y-grid.directive.ts +++ b/libs/components/src/a11y/a11y-grid.directive.ts @@ -11,6 +11,8 @@ import { input, signal, } from "@angular/core"; +import { toObservable } from "@angular/core/rxjs-interop"; +import { firstValueFrom, skip, filter, take } from "rxjs"; import type { A11yCellDirective } from "./a11y-cell.directive"; import { A11yRowDirective } from "./a11y-row.directive"; @@ -36,6 +38,7 @@ export class A11yGridDirective { pageSize = input(5); private rows = contentChildren(A11yRowDirective); + private rows$ = toObservable(this.rows); private grid: Signal = computed(() => this.rows().map((row) => [...row.cells()]), @@ -45,63 +48,117 @@ export class A11yGridDirective { return this.viewPort ? this.viewPort.getDataLength() : this.rows().length; } - /** The row that currently has focus */ private activeRow = signal(0); - private renderedRow = computed(() => this.convertRealRowToViewportRow(this.activeRow())); - - /** The cell that currently has focus */ private activeCol = signal(0); + private renderedRow = computed(() => this.convertRealRowToViewportRow(this.activeRow())); + private numColumns = computed(() => this.grid()[this.renderedRow()].length); + private focusTarget = computed(() => this.grid()?.[this.renderedRow()]?.[this.activeCol()]?.getFocusTarget(), ); constructor() { + // Consolidated effect for row/cell roles and tabIndex management (no focus here!) 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; - } - }); + const focusTarget = this.focusTarget(); + const rows = this.rows(); + const grid = this.grid(); + + // Set row roles + rows.forEach((row) => (row.role = "row")); + + // Set cell roles and tabIndex + grid.flat().forEach((cell) => { + cell.role = "gridcell"; + if (cell.getFocusTarget() !== focusTarget) { + cell.getFocusTarget().tabIndex = -1; + } + }); }); } + private async updateRow(delta: number) { + const prev = this.activeRow(); + let nextRow = prev + delta; + + // Clamp to bounds + nextRow = Math.max(0, Math.min(nextRow, this.numRows - 1)); + + // If the row is not rendered, scroll and wait for it to render before updating + if (this.viewPort) { + const { start, end } = this.viewPort.getRenderedRange(); + if (nextRow < start || nextRow >= end) { + this.viewPort.scrollToIndex(nextRow); + // Wait until the row is rendered and the cell exists + await firstValueFrom( + this.rows$.pipe( + skip(1), + filter((rows) => { + const renderedIdx = this.convertRealRowToViewportRow(nextRow); + return !!rows[renderedIdx]; + }), + take(1), + ), + ); + } + } + + // Only set activeRow after ensuring the row is rendered + this.activeRow.set(nextRow); + + this.focusIfPossible(); + } + + private updateCol(delta: number) { + this.activeCol.update((prev) => { + let nextCol = prev + delta; + const cols = this.numColumns(); + nextCol = Math.max(0, Math.min(nextCol, cols - 1)); + return nextCol; + }); + + this.focusIfPossible(); + } + + private focusIfPossible() { + const focusTarget = this.focusTarget(); + if (focusTarget && document.body.contains(focusTarget)) { + focusTarget.tabIndex = 0; + focusTarget.focus(); + } + } + @HostListener("keydown", ["$event"]) - onKeyDown(event: KeyboardEvent) { + async onKeyDown(event: KeyboardEvent) { switch (event.code) { case "ArrowUp": - this.updateActiveCell(-1, 0); + await this.updateRow(-1); break; case "ArrowRight": - this.updateActiveCell(0, 1); + this.updateCol(1); break; case "ArrowDown": - this.updateActiveCell(1, 0); + await this.updateRow(1); break; case "ArrowLeft": - this.updateActiveCell(0, -1); + this.updateCol(-1); break; case "Home": - this.updateActiveCell(-this.activeRow(), 0); + await this.updateRow(-this.activeRow()); break; case "End": - this.updateActiveCell(this.numRows, 0); + await this.updateRow(this.numRows); break; case "PageUp": - this.updateActiveCell(-this.pageSize(), 0); + await this.updateRow(-this.pageSize()); break; case "PageDown": - this.updateActiveCell(this.pageSize(), 0); + await this.updateRow(this.pageSize()); break; default: return; } - - /** Prevent default scrolling behavior */ event.preventDefault(); } @@ -113,80 +170,10 @@ export class A11yGridDirective { private convertRealRowToViewportRow(row: number): number { if (this.viewPort) { const { start } = this.viewPort.getRenderedRange(); - // TODO, removing this console log makes things break - // eslint-disable-next-line no-console - console.log(`row: ${row}, start: ${start}, gridLength: ${this.grid().length}`); if (row >= start) { return row - start; } } - return row; } - - /** Get the number of columns for a particular row */ - private getNumColumns(row: number) { - return this.grid()[this.convertRealRowToViewportRow(row)].length; - } - - private scrollRowIntoViewport(row: number) { - if (!this.viewPort) { - return; - } - - const { start, end } = this.viewPort.getRenderedRange(); - - if (row >= start && row <= end) { - return; - } - - this.viewPort.scrollToIndex(row); - } - - /** Move focus via a delta against the currently active gridcell */ - private updateActiveCell(rowDelta: number, colDelta: number) { - let nextCol = this.activeCol() + colDelta; - let nextRow = this.activeRow() + rowDelta; - - // Row upper bound - if (nextRow >= this.numRows) { - nextRow = this.grid().length - 1; - } - - // Row lower bound - if (nextRow < 0) { - nextRow = 0; - } - - // The row must exist in the viewport before we can query its columns - this.scrollRowIntoViewport(nextRow); - - // Column upper bound - if (nextCol >= this.getNumColumns(nextRow)) { - if (nextRow < this.numRows - 1) { - // Wrap to next row on right arrow - nextCol = 0; - nextRow += 1; - } else { - nextCol = this.getNumColumns(nextRow) - 1; - } - } - - // Column lower bound - if (nextCol < 0) { - if (nextRow > 0) { - // Wrap to prev row on left arrow - nextRow -= 1; - nextCol = this.getNumColumns(nextRow) - 1; - } else { - nextCol = 0; - } - } - - this.activeCol.set(nextCol); - this.activeRow.set(nextRow); - - this.focusTarget().tabIndex = 0; - this.focusTarget().focus(); - } }