Skip to content

Commit e35406b

Browse files
authored
feat!: Improve accessibility of the grid field. (#2488)
* feat!: Improve accessibility of the grid field. * chore: Fix lint problems. * chore: Clarify comments. * refactor: Limit imports. * refactor: Include `field` in CSS class names. * refactor: Move grid item population into its own function. * refactor: Use null instead of undefined for selection callback.
1 parent 7476290 commit e35406b

3 files changed

Lines changed: 547 additions & 49 deletions

File tree

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

0 commit comments

Comments
 (0)