|
| 1 | +import * as Blockly from 'blockly/core'; |
| 2 | +import * as aria from './aria'; |
| 3 | + |
| 4 | +export function computeBlockAriaLabel(block: Blockly.BlockSvg): string { |
| 5 | + // Guess the block's aria label based on its field labels. |
| 6 | + if (block.isShadow()) { |
| 7 | + // TODO: Shadows may have more than one field. |
| 8 | + // Shadow blocks are best represented directly by their field since they |
| 9 | + // effectively operate like a field does for keyboard navigation purposes. |
| 10 | + const field = Array.from(block.getFields())[0]; |
| 11 | + return aria.getState(field.getFocusableElement(), aria.State.LABEL) ?? 'Unknown?'; |
| 12 | + } |
| 13 | + |
| 14 | + const fieldLabels = []; |
| 15 | + for (const field of block.getFields()) { |
| 16 | + if (field instanceof Blockly.FieldLabel) { |
| 17 | + fieldLabels.push(field.getText()); |
| 18 | + } |
| 19 | + } |
| 20 | + return fieldLabels.join(' '); |
| 21 | +}; |
| 22 | + |
| 23 | +function collectSiblingBlocks(block: Blockly.BlockSvg, surroundParent: Blockly.BlockSvg | null): Blockly.BlockSvg[] { |
| 24 | + // NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The |
| 25 | + // returned list needs to be relatively stable for consistency block indexes |
| 26 | + // read out to users via screen readers. |
| 27 | + if (surroundParent) { |
| 28 | + // Start from the first sibling and iterate in navigation order. |
| 29 | + const firstSibling: Blockly.BlockSvg = surroundParent.getChildren(false)[0]; |
| 30 | + const siblings: Blockly.BlockSvg[] = [firstSibling]; |
| 31 | + let nextSibling: Blockly.BlockSvg | null = firstSibling; |
| 32 | + while (nextSibling = nextSibling.getNextBlock()) { |
| 33 | + siblings.push(nextSibling); |
| 34 | + } |
| 35 | + return siblings; |
| 36 | + } else { |
| 37 | + // For top-level blocks, simply return those from the workspace. |
| 38 | + return block.workspace.getTopBlocks(false); |
| 39 | + } |
| 40 | +} |
| 41 | + |
| 42 | +function computeLevelInWorkspace(block: Blockly.BlockSvg): number { |
| 43 | + const surroundParent = block.getSurroundParent(); |
| 44 | + return surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 0; |
| 45 | +}; |
| 46 | + |
| 47 | +// TODO: Do this efficiently (probably centrally). |
| 48 | +export function recomputeAriaTreeItemDetailsRecursively(block: Blockly.BlockSvg) { |
| 49 | + const elem = block.getFocusableElement(); |
| 50 | + const connection = (block as any).currentConnectionCandidate; |
| 51 | + let childPosition: number; |
| 52 | + let parentsChildCount: number; |
| 53 | + let hierarchyDepth: number; |
| 54 | + if (connection) { |
| 55 | + // If the block is being inserted into a new location, the position is hypothetical. |
| 56 | + // TODO: Figure out how to deal with output connections. |
| 57 | + let surroundParent: Blockly.BlockSvg | null; |
| 58 | + let siblingBlocks: Blockly.BlockSvg[]; |
| 59 | + if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { |
| 60 | + surroundParent = connection.sourceBlock_; |
| 61 | + siblingBlocks = collectSiblingBlocks(block, surroundParent); |
| 62 | + // The block is being added as a child since it's input. |
| 63 | + // TODO: Figure out how to compute the correct position. |
| 64 | + childPosition = 1; |
| 65 | + } else { |
| 66 | + surroundParent = connection.sourceBlock_.getSurroundParent(); |
| 67 | + siblingBlocks = collectSiblingBlocks(block, surroundParent); |
| 68 | + // The block is being added after the connected block. |
| 69 | + childPosition = siblingBlocks.indexOf(connection.sourceBlock_) + 2; |
| 70 | + } |
| 71 | + parentsChildCount = siblingBlocks.length + 1; |
| 72 | + hierarchyDepth = surroundParent ? computeLevelInWorkspace(surroundParent) + 1 : 1; |
| 73 | + } else { |
| 74 | + const surroundParent = block.getSurroundParent(); |
| 75 | + const siblingBlocks = collectSiblingBlocks(block, surroundParent); |
| 76 | + childPosition = siblingBlocks.indexOf(block) + 1; |
| 77 | + parentsChildCount = siblingBlocks.length; |
| 78 | + hierarchyDepth = computeLevelInWorkspace(block) + 1; |
| 79 | + } |
| 80 | + aria.setState(elem, aria.State.POSINSET, childPosition); |
| 81 | + aria.setState(elem, aria.State.SETSIZE, parentsChildCount); |
| 82 | + aria.setState(elem, aria.State.LEVEL, hierarchyDepth); |
| 83 | + block.getChildren(false).forEach((child) => recomputeAriaTreeItemDetailsRecursively(child)); |
| 84 | +} |
| 85 | + |
| 86 | +export function announceDynamicAriaStateForBlock(block: Blockly.BlockSvg, isMoving: boolean, isCanceled: boolean, newLoc?: Blockly.utils.Coordinate) { |
| 87 | + const connection = (block as any).currentConnectionCandidate; |
| 88 | + if (isCanceled) { |
| 89 | + aria.announceDynamicAriaState('Canceled movement'); |
| 90 | + return; |
| 91 | + } |
| 92 | + if (!isMoving) return; |
| 93 | + if (connection) { |
| 94 | + // TODO: Figure out general detachment. |
| 95 | + // TODO: Figure out how to deal with output connections. |
| 96 | + let surroundParent: Blockly.BlockSvg | null = connection.sourceBlock_; |
| 97 | + const announcementContext = []; |
| 98 | + announcementContext.push('Moving'); // TODO: Specialize for inserting? |
| 99 | + // NB: Old code here doesn't seem to handle parents correctly. |
| 100 | + if (connection.type === Blockly.ConnectionType.INPUT_VALUE) { |
| 101 | + announcementContext.push('to', 'input'); |
| 102 | + } else { |
| 103 | + announcementContext.push('to', 'child'); |
| 104 | + } |
| 105 | + if (surroundParent) { |
| 106 | + announcementContext.push('of', computeBlockAriaLabel(surroundParent)); |
| 107 | + } |
| 108 | + |
| 109 | + // If the block is currently being moved, announce the new block label so that the user understands where it is now. |
| 110 | + // TODO: Figure out how much recomputeAriaTreeItemDetailsRecursively needs to anticipate position if it won't be reannounced, and how much of that context should be included in the liveannouncement. |
| 111 | + aria.announceDynamicAriaState(announcementContext.join(' ')); |
| 112 | + } else if (newLoc) { |
| 113 | + // The block is being freely dragged. |
| 114 | + aria.announceDynamicAriaState(`Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`); |
| 115 | + } |
| 116 | +} |
0 commit comments