Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: Improve accessibility of the grid field. #2488

Merged
merged 7 commits into from
Feb 13, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 282 additions & 0 deletions plugins/field-grid-dropdown/src/grid.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import * as Blockly from 'blockly/core';
import {GridItem} from './grid_item';

/**
* Class for managing a group of items displayed in a grid.
*/
export class Grid {
/** Mapping from grid item ID to grid item. */
private itemIndices = new Map<string, number>();

/** List of items displayed in this grid. */
private items = new Array<GridItem>();

/** Root DOM element of this grid. */
private root: HTMLDivElement;

/** Identifier for keydown handler to be unregistered in dispose(). */
private keyDownHandler: Blockly.browserEvents.Data | null = null;

/** Identifier for pointermove handler to be unregistered in dispose(). */
private pointerMoveHandler: Blockly.browserEvents.Data | null = null;

/** Function to be called when an item in this grid is selected. */
private selectionCallback?: (selectedItem: GridItem) => void;

/**
* Creates a new Grid instance.
*
* @param container The parent element of this grid in the DOM.
* @param options A list of MenuOption objects representing the items to be
* shown in this grid.
* @param columns The number of columns to display items in.
* @param rtl True if this grid is being shown in a right-to-left environment.
* @param selectionCallback Function to be called when an item in the grid is
* selected.
*/
constructor(
container: HTMLElement,
options: Blockly.MenuOption[],
private readonly columns: number,
private readonly rtl: boolean,
selectionCallback: (selectedItem: GridItem) => void,
) {
this.selectionCallback = selectionCallback;

this.root = document.createElement('div');
this.root.className = 'blocklyGrid';
this.root.tabIndex = 0;
Blockly.utils.aria.setRole(this.root, Blockly.utils.aria.Role.GRID);
container.appendChild(this.root);

let row = document.createElement('div');
for (const [index, item] of options.entries()) {
if (index % this.columns === 0) {
row = document.createElement('div');
row.className = 'blocklyGridRow';
Blockly.utils.aria.setRole(row, Blockly.utils.aria.Role.ROW);
this.root.appendChild(row);
}

const [label, value] = item;
const content = (() => {
if (typeof label === 'object') {
// Convert ImageProperties to an HTMLImageElement.
const image = new Image(label['width'], label['height']);
image.src = label['src'];
image.alt = label['alt'] || '';
return image;
}
return label;
})();

const gridItem = new GridItem(
row,
content,
value,
(selectedItem: GridItem) => {
this.setSelectedValue(selectedItem.getValue());
this.selectionCallback?.(selectedItem);
},
);
this.itemIndices.set(gridItem.getId(), this.itemIndices.size);
this.items.push(gridItem);
}

this.keyDownHandler = Blockly.browserEvents.conditionalBind(
this.root,
'keydown',
this,
this.onKeyDown,
);

this.pointerMoveHandler = Blockly.browserEvents.conditionalBind(
this.root,
'pointermove',
this,
this.onPointerMove,
true,
);

if (columns >= 1) {
this.columns = columns;
this.root.style.setProperty('--grid-columns', `${this.columns}`);
} else {
throw new Error(`Number of columns must be >= 1; got ${columns}`);
}
}

/**
* Disposes of this grid.
*/
dispose() {
this.selectionCallback = undefined;
for (const item of this.items) {
item.dispose();
}
this.itemIndices.clear();
this.items.length = 0;
if (this.keyDownHandler) {
Blockly.browserEvents.unbind(this.keyDownHandler);
this.keyDownHandler = null;
}

if (this.pointerMoveHandler) {
Blockly.browserEvents.unbind(this.pointerMoveHandler);
this.pointerMoveHandler = null;
}
this.root.remove();
}

/**
* Handles a keydown event in the grid, generally by moving focus.
*
* @param e The keydown event to handle.
*/
private onKeyDown(e: KeyboardEvent) {
if (
!this.items.length ||
e.shiftKey ||
e.ctrlKey ||
e.metaKey ||
e.altKey
) {
return;
}

switch (e.key) {
case 'ArrowUp':
this.moveFocus(-1 * this.columns, true);
break;
case 'ArrowDown':
this.moveFocus(this.columns, true);
break;
case 'ArrowLeft':
this.moveFocus(-1 * (this.rtl ? -1 : 1), true);
break;
case 'ArrowRight':
this.moveFocus(1 * (this.rtl ? -1 : 1), true);
break;
case 'PageUp':
case 'Home':
this.moveFocus(0, false);
break;
case 'PageDown':
case 'End':
this.moveFocus(this.items.length - 1, false);
break;
default:
// Not a key the grid is interested in.
return;
}
// The grid used this key, don't let it have secondary effects.
e.preventDefault();
e.stopPropagation();
}

/**
* Handles a pointermove event in the grid by focusing the hovered item.
*
* @param e The pointermove event to handle.
*/
private onPointerMove(e: PointerEvent) {
// Don't highlight grid items on "pointermove" if the pointer didn't
// actually move (but the content under it did due to e.g. scrolling into
// view), or if the target isn't an Element, which should never happen, but
// TS needs to be reassured of that.
if (!(e.movementX || e.movementY) || !(e.target instanceof Element)) return;

const gridItem = e.target.closest('.blocklyGridItem');
if (!gridItem) return;

const targetId = gridItem.id;
const targetIndex = this.itemIndices.get(targetId);
if (targetIndex === undefined) return;
this.moveFocus(targetIndex, false);
}

/**
* Selects the item with the given value in the grid.
*
* @param value The value of the grid item to select.
*/
setSelectedValue(value: string) {
for (const [index, item] of this.items.entries()) {
const selected = item.getValue() === value;
item.setSelected(selected);
if (selected) {
this.moveFocus(index, false);
}
}
}

/**
* Moves browser focus to the grid item at the given index.
*
* @param index The index of the item to focus.
* @param relative True to interpret the index as relative to the currently
* focused item, false to move focus to it as an absolute value.
*/
private moveFocus(index: number, relative: boolean) {
let targetIndex = index;

if (relative) {
const focusedItem = this.getFocusedItem();
if (!focusedItem) return;
targetIndex += this.indexOfItem(focusedItem);
}

const targetItem = this.itemAtIndex(targetIndex);
if (!targetItem) return;

targetItem.focus();
Blockly.utils.aria.setState(
this.root,
Blockly.utils.aria.State.ACTIVEDESCENDANT,
targetItem.getId(),
);
}

/**
* Returns the index of the given item within the grid.
*
* @param item The item to return the index of.
* @returns The index of the given item within the grid.
*/
private indexOfItem(item: GridItem): number {
return this.itemIndices.get(item.getId()) ?? -1;
}

/**
* Returns the GridItem object at the given index in the grid.
*
* @param index The index to retrieve the grid item at.
* @returns The GridItem at the given index.
*/
private itemAtIndex(index: number): GridItem | undefined {
return this.items[index];
}

/**
* Returns the currently focused grid item, if any.
*
* @returns The focused grid item, or undefined if no item is focused.
*/
private getFocusedItem(): GridItem | undefined {
const element =
this.root.querySelector('.blocklyGridItem:focus') ??
this.root.querySelector('.blocklyGridItem');
if (!element || !element.id) return undefined;

const index = this.itemIndices.get(element.id);
if (index === undefined) return undefined;

return this.itemAtIndex(index);
}
}
Loading
Loading