Skip to content

Commit 075860e

Browse files
authored
Merge pull request #4 from tildeio/accessibility
Add accessible keyboard navigation
2 parents 26cd404 + bbe4166 commit 075860e

File tree

11 files changed

+843
-101
lines changed

11 files changed

+843
-101
lines changed

src/css-classes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export const CLASSES = {
3838
rowheader: 'epg_rowheader' as const,
3939
/** All gridcell, columnheader, and rowheader elements */
4040
cell: 'epg_cell' as const,
41+
/** The columnheader element containing the grid title, if one is provided. */
42+
title: 'epg_title' as const,
43+
/** The heading element containing the grid title, if one exists. */
44+
caption: 'epg_caption' as const,
4145
/** All gridcell and columnheader elements related to a Package */
4246
package: 'epg_package' as const,
4347
/** All gridcell and rowheader elements related to a Perk */

src/custom-element.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { CLASSES } from './css-classes';
22
import { fetchData, PerkGridFetchError } from './fetch-data';
3-
import { render } from './render';
3+
import { render, ResizeEvent } from './render';
44
import { DisplayOption } from './render/types';
55
import { PerkGridTypeError } from './types/utils';
6-
import { createElement } from './utils/rendering';
6+
import { createElement } from './utils/dom';
77

88
/**
99
* The following dataset properties can be set on the `<perk-grid>` element by
@@ -58,6 +58,8 @@ export interface PerkGridDataSet {
5858
display?: DisplayOption;
5959

6060
/**
61+
* Set attribute `data-min-width-perk`.
62+
*
6163
* The minimum width (in pixels) for displaying the Perk header column when
6264
* the grid is displayed with multiple columns.
6365
*
@@ -66,9 +68,11 @@ export interface PerkGridDataSet {
6668
* NOTE: If the `display` option is set to "list", this option will not be
6769
* used.
6870
*/
69-
minWidthPerk?: number | undefined;
71+
minWidthPerk?: number;
7072

7173
/**
74+
* Set attribute `data-min-width-package`.
75+
*
7276
* The minimum width (in pixels) for displaying the Package columns when
7377
* the grid is displayed with multiple columns.
7478
*
@@ -77,7 +81,18 @@ export interface PerkGridDataSet {
7781
* NOTE: If the `display` option is set to "list", this option will not be
7882
* used.
7983
*/
80-
minWidthPackage?: number | undefined;
84+
minWidthPackage?: number;
85+
86+
/**
87+
* Set attribute `data-allow-keyboard-navigation`.
88+
*
89+
* If true, enables keyboard navigation following WAI-ARIA Authoring Practices.
90+
*
91+
* Defaults to `true`.
92+
*
93+
* @link https://www.w3.org/TR/wai-aria-practices-1.1/examples/grid/dataGrids.html
94+
*/
95+
allowKeyboardNavigation?: boolean;
8196
}
8297

8398
/**
@@ -101,7 +116,7 @@ export class PerkGridError extends Error {
101116
* });
102117
* ```
103118
*/
104-
export type ConnectingEvent = CustomEvent<Record<string, never>>;
119+
export type ConnectingEvent = CustomEvent<never>;
105120

106121
/**
107122
* Fired when the `<perk-grid>` element loading state is displayed and just
@@ -117,7 +132,7 @@ export type ConnectingEvent = CustomEvent<Record<string, never>>;
117132
* });
118133
* ```
119134
*/
120-
export type LoadingEvent = CustomEvent<Record<string, never>>;
135+
export type LoadingEvent = CustomEvent<never>;
121136

122137
/**
123138
* Fired _if_ there is an error while the `<perk-grid>` element is loading or
@@ -134,7 +149,7 @@ export type LoadingEvent = CustomEvent<Record<string, never>>;
134149
* });
135150
* ```
136151
*/
137-
export type ErrorEvent = CustomEvent<{ detail: unknown }>;
152+
export type ErrorEvent = CustomEvent<unknown>;
138153

139154
/**
140155
* Fired when the `<perk-grid>` element has completed loading and rendering the
@@ -150,7 +165,7 @@ export type ErrorEvent = CustomEvent<{ detail: unknown }>;
150165
* });
151166
* ```
152167
*/
153-
export type ReadyEvent = CustomEvent<Record<string, never>>;
168+
export type ReadyEvent = CustomEvent<never>;
154169

155170
/**
156171
* Fired when the `<perk-grid>` element is disconnected from the DOM.
@@ -165,7 +180,7 @@ export type ReadyEvent = CustomEvent<Record<string, never>>;
165180
* });
166181
* ```
167182
*/
168-
export type DisconnectedEvent = CustomEvent<Record<string, never>>;
183+
export type DisconnectedEvent = CustomEvent<never>;
169184

170185
/**
171186
* The `<perk-grid>` custom element displays a grid of sponsorship packages and
@@ -184,6 +199,7 @@ export class PerkGrid extends HTMLElement {
184199
* @fires {@link LoadingEvent}
185200
* @fires {@link ErrorEvent}
186201
* @fires {@link ReadyEvent}
202+
* @fires {@link ResizeEvent}
187203
*/
188204
async connectedCallback(): Promise<void> {
189205
this.dispatchEvent(new CustomEvent('connecting') as ConnectingEvent);
@@ -196,6 +212,7 @@ export class PerkGrid extends HTMLElement {
196212
display,
197213
minWidthPerk,
198214
minWidthPackage,
215+
allowKeyboardNavigation,
199216
} = this.dataset as Partial<PerkGridDataSet>;
200217

201218
const placeholder = createElement('div', CLASSES.loading, {
@@ -218,6 +235,7 @@ export class PerkGrid extends HTMLElement {
218235
display,
219236
minWidthPerk,
220237
minWidthPackage,
238+
allowKeyboardNavigation,
221239
});
222240
} catch (error: unknown) {
223241
this.dispatchEvent(
@@ -262,3 +280,5 @@ if (customElements.get('perk-grid')) {
262280
}
263281

264282
export * from './css-classes';
283+
// eslint-disable-next-line unicorn/prefer-export-from
284+
export { ResizeEvent };

src/index.css

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,19 @@
99
}
1010

1111
.epg_cell {
12-
border: 1px solid black;
12+
padding: 8px;
13+
}
14+
15+
/*
16+
* Ensure we always show the focus ring so that it's obvious something has
17+
* happened when the user clicks a cell and keyboard navigation is enabled.
18+
*/
19+
.epg_cell:focus {
20+
outline: rgb(0 95 204) auto 1px;
21+
}
22+
23+
.epg_title:empty {
24+
display: none;
1325
}
1426

1527
.epg_grid.epg_display-as-grid {
@@ -21,6 +33,10 @@
2133
);
2234
}
2335

36+
.epg_display-as-grid .epg_title:empty {
37+
display: unset;
38+
}
39+
2440
.epg_display-as-grid .epg_rowgroup,
2541
.epg_display-as-grid .epg_row {
2642
display: contents; /* Also undoes display:none on the body and footer */

src/render/enable-keyboard-nav.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import { CLASSES } from '../css-classes';
2+
import { assertExists } from '../types/utils';
3+
import { isVisible } from '../utils/dom';
4+
import { ResizeEvent } from './make-responsive';
5+
6+
type Cells = HTMLElement[][];
7+
8+
type Position = { row: number; column: number };
9+
10+
const DEFAULT_POSITION = { row: 0, column: 0 };
11+
12+
class GridFocusManager {
13+
private cells: Cells;
14+
15+
constructor(private grid: HTMLElement) {
16+
this.cells = setupCells(grid);
17+
this.setup();
18+
}
19+
20+
reset(): void {
21+
this.cells = setupCells(this.grid);
22+
this.currentPosition = DEFAULT_POSITION;
23+
}
24+
25+
private setup(): void {
26+
this.grid.addEventListener('keydown', this.onKeydown.bind(this));
27+
this.grid.addEventListener('click', this.onClick.bind(this));
28+
}
29+
30+
private currentPosition: Position = DEFAULT_POSITION;
31+
32+
private get row(): number {
33+
return this.currentPosition.row;
34+
}
35+
36+
private get lastRow(): number {
37+
return this.cells.length - 1;
38+
}
39+
40+
private get column(): number {
41+
return this.currentPosition.column;
42+
}
43+
44+
private get lastColumn(): number {
45+
// Assumes all rows have the same number of columns.
46+
return assertExists(this.cells[0]).length - 1;
47+
}
48+
49+
private onKeydown(event: KeyboardEvent): void {
50+
const displayedAsGrid = this.grid.classList.contains(CLASSES.displayAsGrid);
51+
52+
switch (event.key) {
53+
case 'ArrowRight':
54+
this.updatePosition({ column: this.column + 1 });
55+
break;
56+
57+
case 'ArrowLeft':
58+
this.updatePosition({ column: this.column - 1 });
59+
break;
60+
61+
case 'ArrowDown':
62+
if (displayedAsGrid) {
63+
this.updatePosition({ row: this.row + 1 });
64+
} else {
65+
this.updatePosition({ column: this.column + 1 });
66+
}
67+
break;
68+
69+
case 'ArrowUp':
70+
if (displayedAsGrid) {
71+
this.updatePosition({ row: this.row - 1 });
72+
} else {
73+
this.updatePosition({ column: this.column - 1 });
74+
}
75+
break;
76+
77+
case 'Home':
78+
this.updatePosition({
79+
column: 0,
80+
row: displayedAsGrid && event.ctrlKey ? 0 : this.row,
81+
});
82+
break;
83+
84+
case 'End':
85+
this.updatePosition({
86+
column: this.lastColumn,
87+
row: displayedAsGrid && event.ctrlKey ? this.lastRow : this.row,
88+
});
89+
break;
90+
91+
default:
92+
break;
93+
}
94+
}
95+
96+
private onClick(event: MouseEvent) {
97+
if (event.target instanceof HTMLElement) {
98+
const cell = event.target.closest(`div.${CLASSES.cell}`);
99+
if (cell instanceof HTMLElement) {
100+
this.updatePosition(this.getPosition(cell));
101+
}
102+
}
103+
}
104+
105+
/**
106+
* Focus new position.
107+
* Ensure only the latest position is focusable by keyboard.
108+
* Keep track of latest position.
109+
*/
110+
private updatePosition(updates: Partial<Position>) {
111+
const row =
112+
updates.row === undefined
113+
? this.row
114+
: Math.max(Math.min(updates.row, this.lastRow), 0);
115+
const column =
116+
updates.column === undefined
117+
? this.column
118+
: Math.max(Math.min(updates.column, this.lastColumn), 0);
119+
120+
this.currentCell.tabIndex = -1;
121+
this.currentPosition = { row, column };
122+
this.focusCurrent();
123+
}
124+
125+
private focusCurrent(): void {
126+
this.currentCell.focus();
127+
this.currentCell.tabIndex = 0;
128+
}
129+
130+
private get currentCell(): HTMLElement {
131+
return assertExists(this.getCell(this.row, this.column));
132+
}
133+
134+
private getCell(
135+
rowIndex: number,
136+
columnIndex: number
137+
): HTMLElement | undefined {
138+
const row = this.cells[rowIndex];
139+
return row && row[columnIndex];
140+
}
141+
142+
private getPosition(cell: HTMLElement): Position {
143+
// eslint-disable-next-line no-restricted-syntax
144+
for (const row of this.cells) {
145+
const index = row.indexOf(cell);
146+
if (index > -1) {
147+
return {
148+
column: index,
149+
row: this.cells.indexOf(row),
150+
};
151+
break;
152+
}
153+
}
154+
155+
throw new Error('Position not found for cell');
156+
}
157+
}
158+
159+
export default function enableKeyboardNav(
160+
grid: HTMLElement,
161+
isResponsive: boolean
162+
): void {
163+
const manager = new GridFocusManager(grid);
164+
165+
function maybeResetFocusManager(event: ResizeEvent) {
166+
if (!manager || event.detail.displayChanged) {
167+
manager.reset();
168+
}
169+
}
170+
171+
if (isResponsive) {
172+
grid.addEventListener('grid-resize', (event) => {
173+
maybeResetFocusManager(event as ResizeEvent);
174+
});
175+
}
176+
}
177+
178+
function setupCells(grid: HTMLElement) {
179+
const cells: Cells = [];
180+
[...grid.querySelectorAll(`div.${CLASSES.row}`)].forEach((row, rowIndex) => {
181+
cells.push(setupRowCells(row, rowIndex));
182+
});
183+
return cells;
184+
}
185+
186+
function setupRowCells(row: Element, rowIndex: number) {
187+
const rowCells = [
188+
...row.querySelectorAll(`div.${CLASSES.cell}`),
189+
] as HTMLElement[];
190+
191+
rowCells.forEach((cell) => {
192+
// eslint-disable-next-line no-param-reassign
193+
cell.tabIndex = -1;
194+
});
195+
196+
if (rowIndex === 0) {
197+
const firstCell = () => assertExists(rowCells[0]);
198+
if (!isVisible(firstCell())) {
199+
rowCells.shift();
200+
}
201+
firstCell().tabIndex = 0;
202+
}
203+
return rowCells;
204+
}

0 commit comments

Comments
 (0)