Skip to content

Commit 2fb9b03

Browse files
committed
feat!: Improve accessibility of the grid field.
1 parent 7476290 commit 2fb9b03

File tree

3 files changed

+535
-49
lines changed

3 files changed

+535
-49
lines changed
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as Blockly from 'blockly/core';
8+
import {GridItem} from './grid_item';
9+
10+
/**
11+
* Class for managing a group of items displayed in a grid.
12+
*/
13+
export class Grid {
14+
/** Mapping from grid item ID to grid item. */
15+
private itemIndices = new Map<string, number>();
16+
17+
/** List of items displayed in this grid. */
18+
private items = new Array<GridItem>();
19+
20+
/** Root DOM element of this grid. */
21+
private root: HTMLDivElement;
22+
23+
/** Identifier for keydown handler to be unregistered in dispose(). */
24+
private keyDownHandler: Blockly.browserEvents.Data | null = null;
25+
26+
/** Identifier for pointermove handler to be unregistered in dispose(). */
27+
private pointerMoveHandler: Blockly.browserEvents.Data | null = null;
28+
29+
/** Function to be called when an item in this grid is selected. */
30+
private selectionCallback?: (selectedItem: GridItem) => void;
31+
32+
/**
33+
* Creates a new Grid instance.
34+
*
35+
* @param container The parent element of this grid in the DOM.
36+
* @param options A list of MenuOption objects representing the items to be
37+
* shown in this grid.
38+
* @param columns The number of columns to display items in.
39+
* @param rtl True if this grid is being shown in a right-to-left environment.
40+
* @param selectionCallback Function to be called when an item in the grid is
41+
* selected.
42+
*/
43+
constructor(
44+
container: HTMLElement,
45+
options: Blockly.MenuOption[],
46+
private readonly columns: number,
47+
private readonly rtl: boolean,
48+
selectionCallback: (selectedItem: GridItem) => void,
49+
) {
50+
this.selectionCallback = selectionCallback;
51+
52+
this.root = document.createElement('div');
53+
this.root.className = 'blocklyGrid';
54+
this.root.tabIndex = 0;
55+
Blockly.utils.aria.setRole(this.root, Blockly.utils.aria.Role.GRID);
56+
container.appendChild(this.root);
57+
58+
let row = document.createElement('div');
59+
for (const [index, item] of options.entries()) {
60+
if (index % this.columns === 0) {
61+
row = document.createElement('div');
62+
row.className = 'blocklyGridRow';
63+
Blockly.utils.aria.setRole(row, Blockly.utils.aria.Role.ROW);
64+
this.root.appendChild(row);
65+
}
66+
67+
const [label, value] = item;
68+
const content = (() => {
69+
if (typeof label === 'object') {
70+
// Convert ImageProperties to an HTMLImageElement.
71+
const image = new Image(label['width'], label['height']);
72+
image.src = label['src'];
73+
image.alt = label['alt'] || '';
74+
return image;
75+
}
76+
return label;
77+
})();
78+
79+
const gridItem = new GridItem(
80+
row,
81+
content,
82+
value,
83+
(selectedItem: GridItem) => {
84+
this.setSelectedValue(selectedItem.getValue());
85+
this.selectionCallback?.(selectedItem);
86+
},
87+
);
88+
this.itemIndices.set(gridItem.getId(), this.itemIndices.size);
89+
this.items.push(gridItem);
90+
}
91+
92+
this.keyDownHandler = Blockly.browserEvents.conditionalBind(
93+
this.root,
94+
'keydown',
95+
this,
96+
this.onKeyDown,
97+
);
98+
99+
this.pointerMoveHandler = Blockly.browserEvents.conditionalBind(
100+
this.root,
101+
'pointermove',
102+
this,
103+
this.onPointerMove,
104+
true,
105+
);
106+
107+
if (columns >= 1) {
108+
this.columns = columns;
109+
this.root.style.setProperty('--grid-columns', `${this.columns}`);
110+
} else {
111+
throw new Error(`Number of columns must be >= 1; got ${columns}`);
112+
}
113+
}
114+
115+
/**
116+
* Disposes of this grid.
117+
*/
118+
dispose() {
119+
this.selectionCallback = undefined;
120+
for (const item of this.items) {
121+
item.dispose();
122+
}
123+
this.itemIndices.clear();
124+
this.items.length = 0;
125+
if (this.keyDownHandler) {
126+
Blockly.browserEvents.unbind(this.keyDownHandler);
127+
this.keyDownHandler = null;
128+
}
129+
130+
if (this.pointerMoveHandler) {
131+
Blockly.browserEvents.unbind(this.pointerMoveHandler);
132+
this.pointerMoveHandler = null;
133+
}
134+
this.root.remove();
135+
}
136+
137+
/**
138+
* Handles a keydown event in the grid, generally by moving focus.
139+
*
140+
* @param e The keydown event to handle.
141+
*/
142+
private onKeyDown(e: KeyboardEvent) {
143+
if (
144+
!this.items.length ||
145+
e.shiftKey ||
146+
e.ctrlKey ||
147+
e.metaKey ||
148+
e.altKey
149+
) {
150+
return;
151+
}
152+
153+
switch (e.key) {
154+
case 'ArrowUp':
155+
this.moveFocus(-1 * this.columns, true);
156+
break;
157+
case 'ArrowDown':
158+
this.moveFocus(this.columns, true);
159+
break;
160+
case 'ArrowLeft':
161+
this.moveFocus(-1 * (this.rtl ? -1 : 1), true);
162+
break;
163+
case 'ArrowRight':
164+
this.moveFocus(1 * (this.rtl ? -1 : 1), true);
165+
break;
166+
case 'PageUp':
167+
case 'Home':
168+
this.moveFocus(0, false);
169+
break;
170+
case 'PageDown':
171+
case 'End':
172+
this.moveFocus(this.items.length - 1, false);
173+
break;
174+
default:
175+
// Not a key the grid is interested in.
176+
return;
177+
}
178+
// The grid used this key, don't let it have secondary effects.
179+
e.preventDefault();
180+
e.stopPropagation();
181+
}
182+
183+
/**
184+
* Handles a pointermove event in the grid by focusing the hovered item.
185+
*
186+
* @param e The pointermove event to handle.
187+
*/
188+
private onPointerMove(e: PointerEvent) {
189+
// Don't highlight grid items on "pointermove" if the pointer didn't
190+
// actually move (but the content under it did due to e.g. scrolling into
191+
// view), or if the target isn't an Element, which should never happen, but
192+
// TS needs to be reassured of that.
193+
if (!(e.movementX || e.movementY) || !(e.target instanceof Element)) return;
194+
195+
const gridItem = e.target.closest('.blocklyGridItem');
196+
if (!gridItem) return;
197+
198+
const targetId = gridItem.id;
199+
const targetIndex = this.itemIndices.get(targetId);
200+
if (targetIndex === undefined) return;
201+
this.moveFocus(targetIndex, false);
202+
}
203+
204+
/**
205+
* Selects the item with the given value in the grid.
206+
*
207+
* @param value The value of the grid item to select.
208+
*/
209+
setSelectedValue(value: string) {
210+
for (const [index, item] of this.items.entries()) {
211+
const selected = item.getValue() === value;
212+
item.setSelected(selected);
213+
if (selected) {
214+
this.moveFocus(index, false);
215+
}
216+
}
217+
}
218+
219+
/**
220+
* Moves browser focus to the grid item at the given index.
221+
*
222+
* @param index The index of the item to focus.
223+
* @param relative True to interpret the index as relative to the currently
224+
* focused item, false to move focus to it as an absolute value.
225+
*/
226+
private moveFocus(index: number, relative: boolean) {
227+
let targetIndex = index;
228+
229+
if (relative) {
230+
const focusedItem = this.getFocusedItem();
231+
if (!focusedItem) return;
232+
targetIndex += this.indexOfItem(focusedItem);
233+
}
234+
235+
const targetItem = this.itemAtIndex(targetIndex);
236+
if (!targetItem) return;
237+
238+
targetItem.focus();
239+
Blockly.utils.aria.setState(
240+
this.root,
241+
Blockly.utils.aria.State.ACTIVEDESCENDANT,
242+
targetItem.getId(),
243+
);
244+
}
245+
246+
/**
247+
* Returns the index of the given item within the grid.
248+
*
249+
* @returns The index of the given item within the grid.
250+
*/
251+
private indexOfItem(item: GridItem): number {
252+
return this.itemIndices.get(item.getId()) ?? -1;
253+
}
254+
255+
/**
256+
* Returns the GridItem object at the given index in the grid.
257+
*
258+
* @returns The GridItem at the given index.
259+
*/
260+
private itemAtIndex(index: number): GridItem | undefined {
261+
return this.items[index];
262+
}
263+
264+
/**
265+
* Returns the currently focused grid item, if any.
266+
*
267+
* @returns The focused grid item, or undefined if no item is focused.
268+
*/
269+
private getFocusedItem(): GridItem | undefined {
270+
const element =
271+
this.root.querySelector('.blocklyGridItem:focus') ??
272+
this.root.querySelector('.blocklyGridItem');
273+
if (!element || !element.id) return undefined;
274+
275+
const index = this.itemIndices.get(element.id);
276+
if (index === undefined) return undefined;
277+
278+
return this.itemAtIndex(index);
279+
}
280+
}

0 commit comments

Comments
 (0)