From ca9b883d4a86f26dc5caf414e07b83fcaade83f2 Mon Sep 17 00:00:00 2001 From: William Martin Date: Mon, 19 May 2025 00:40:12 -0400 Subject: [PATCH] fix a11y grid directives --- .../src/a11y/a11y-cell.directive.ts | 17 +-- .../src/a11y/a11y-grid.directive.ts | 141 +++++++++--------- .../components/src/a11y/a11y-row.directive.ts | 30 ++-- .../src/item/item-group.component.ts | 3 + 4 files changed, 96 insertions(+), 95 deletions(-) diff --git a/libs/components/src/a11y/a11y-cell.directive.ts b/libs/components/src/a11y/a11y-cell.directive.ts index c9a8fdda255..0ca82b15fc2 100644 --- a/libs/components/src/a11y/a11y-cell.directive.ts +++ b/libs/components/src/a11y/a11y-cell.directive.ts @@ -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("button, a"); } if (!focusTarget) { diff --git a/libs/components/src/a11y/a11y-grid.directive.ts b/libs/components/src/a11y/a11y-grid.directive.ts index ef7ba68b65c..b91cbe0b191 100644 --- a/libs/components/src/a11y/a11y-grid.directive.ts +++ b/libs/components/src/a11y/a11y-grid.directive.ts @@ -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; - /** 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 = 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(); } } diff --git a/libs/components/src/a11y/a11y-row.directive.ts b/libs/components/src/a11y/a11y-row.directive.ts index 7e0431d17e2..0d2babee735 100644 --- a/libs/components/src/a11y/a11y-row.directive.ts +++ b/libs/components/src/a11y/a11y-row.directive.ts @@ -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; - - @ContentChildren(A11yCellDirective) - private contentCells: QueryList; - - ngAfterViewInit(): void { - this.cells = [...this.viewCells, ...this.contentCells]; - } + cells: Signal = computed(() => [ + ...this.viewCells(), + ...this.contentCells(), + ]); } diff --git a/libs/components/src/item/item-group.component.ts b/libs/components/src/item/item-group.component.ts index 2a9a8275cc6..20d8bbef7cc 100644 --- a/libs/components/src/item/item-group.component.ts +++ b/libs/components/src/item/item-group.component.ts @@ -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 {}