diff --git a/plugins/field-grid-dropdown/src/grid.ts b/plugins/field-grid-dropdown/src/grid.ts index c99f8d0a4..3266c6113 100644 --- a/plugins/field-grid-dropdown/src/grid.ts +++ b/plugins/field-grid-dropdown/src/grid.ts @@ -6,10 +6,12 @@ import { browserEvents, + common, FieldDropdown, ImageProperties, MenuOption, utils, + WorkspaceSvg, } from 'blockly/core'; import {GridItem} from './grid_item'; @@ -171,24 +173,24 @@ export class Grid { switch (e.key) { case 'ArrowUp': - this.moveFocus(-1 * this.columns, true); + this.moveFocus(-1, true, false); break; case 'ArrowDown': - this.moveFocus(this.columns, true); + this.moveFocus(1, true, false); break; case 'ArrowLeft': - this.moveFocus(-1 * (this.rtl ? -1 : 1), true); + this.moveFocus(-1 * (this.rtl ? -1 : 1), true, true); break; case 'ArrowRight': - this.moveFocus(1 * (this.rtl ? -1 : 1), true); + this.moveFocus(1 * (this.rtl ? -1 : 1), true, true); break; case 'PageUp': case 'Home': - this.moveFocus(0, false); + this.moveFocus(0, false, true); break; case 'PageDown': case 'End': - this.moveFocus(this.items.length - 1, false); + this.moveFocus(this.items.length - 1, false, true); break; case 'Enter': case 'Space': @@ -223,7 +225,7 @@ export class Grid { const targetId = gridItem.id; const targetIndex = this.itemIndices.get(targetId); if (targetIndex === undefined) return; - this.moveFocus(targetIndex, false); + this.moveFocus(targetIndex, false, true); } /** @@ -236,7 +238,7 @@ export class Grid { const selected = item.getValue() === value; item.setSelected(selected); if (selected) { - this.moveFocus(index, false); + this.moveFocus(index, false, true); } } } @@ -244,21 +246,41 @@ export class Grid { /** * Moves browser focus to the grid item at the given index. * - * @param index The index of the item to focus. + * @param movementIndex The absolute or directionally relative 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. + * @param horizontal True for a left/right move. False for an up/down move. + * Vertical moves wrap to the next/previous column at column edges, but + * do not wrap around the ends of the grid. */ - private moveFocus(index: number, relative: boolean) { - let targetIndex = index; + private moveFocus( + movementIndex: number, + relative: boolean, + horizontal: boolean, + ) { + let targetIndex = movementIndex; if (relative) { const focusedItem = this.getFocusedItem(); if (!focusedItem) return; - targetIndex += this.indexOfItem(focusedItem); + const currentIndex = this.indexOfItem(focusedItem); + + if (horizontal) { + targetIndex += currentIndex; + } else { + targetIndex = this.getVerticalTargetIndex(currentIndex, movementIndex); + } } const targetItem = this.itemAtIndex(targetIndex); - if (!targetItem) return; + if (!targetItem) { + const workspace = common.getMainWorkspace(); + if (workspace instanceof WorkspaceSvg) { + workspace.getAudioManager().playErrorBeep(); + } + return; + } targetItem.focus(); utils.aria.setState( @@ -268,6 +290,44 @@ export class Grid { ); } + /** + * Returns the index to focus after a vertical move from the given index. + * + * Prefers moving within the same column. At a column edge, wraps to the + * first/last item of the adjacent column. Returns an out-of-bounds index + * when movement is blocked at the start or end of the grid. + * + * @param currentIndex The currently focused item index. + * @param direction 1 to move down, -1 to move up. + * @returns The target item index. + */ + private getVerticalTargetIndex( + currentIndex: number, + direction: number, + ): number { + // First try moving to the vertically adjacent item in the same column. + const column = currentIndex % this.columns; + const row = Math.floor(currentIndex / this.columns); + const targetIndex = (row + direction) * this.columns + column; + + if (this.itemAtIndex(targetIndex)) { + return targetIndex; + } + + // We are past this column's edge, so wrap to the adjacent column. + const targetColumn = column + direction; + if (targetColumn < 0 || targetColumn >= this.columns) { + return targetIndex; + } + + const columnItems = this.items.filter( + (item, i) => i % this.columns === targetColumn, + ); + const item = + direction > 0 ? columnItems[0] : columnItems[columnItems.length - 1]; + return this.indexOfItem(item); + } + /** * Returns the index of the given item within the grid. * diff --git a/plugins/field-grid-dropdown/src/index.ts b/plugins/field-grid-dropdown/src/index.ts index b35adc058..f0c881045 100644 --- a/plugins/field-grid-dropdown/src/index.ts +++ b/plugins/field-grid-dropdown/src/index.ts @@ -45,8 +45,10 @@ export class FieldGridDropdown extends Blockly.FieldDropdown { private borderColour?: string; - /** Object representing the grid of choices show in the dropdown. */ - private grid?: Grid; + /** Object representing the grid of choices shown in the dropdown. */ + private grid: Grid | null = null; + + protected override ariaTypeName = Blockly.Msg['ARIA_TYPE_FIELD_GRID']; /** * Class for an grid dropdown field. @@ -133,11 +135,17 @@ export class FieldGridDropdown extends Blockly.FieldDropdown { override recomputeAriaContext(): boolean { const shouldCustomize = super.recomputeAriaContext(); if (!shouldCustomize) return false; + const focusableElement = this.getFocusableElement(); Blockly.utils.aria.setState( - this.getFocusableElement(), + focusableElement, Blockly.utils.aria.State.HASPOPUP, 'grid', ); + Blockly.utils.aria.setState( + focusableElement, + Blockly.utils.aria.State.EXPANDED, + !!this.grid, + ); return true; } @@ -180,6 +188,17 @@ export class FieldGridDropdown extends Blockly.FieldDropdown { if (selectedValue) { this.grid.setSelectedValue(selectedValue); } + this.recomputeAriaContext(); + } + + /** + * Disposes of events and DOM-references belonging to the dropdown editor. + */ + protected override dropdownDispose_() { + // Keep aria-expanded accurate on later recomputes. + this.grid = null; + super.dropdownDispose_(); + this.recomputeAriaContext(); } /**