From 5423101f8ac95670842b1c7b7defeeda1b10bffd Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:57:05 -0400 Subject: [PATCH 1/2] feat(workspace-search): Add screen reader support --- package-lock.json | 1 + packages/blockly/core/utils/aria.ts | 2 + packages/blockly/msg/json/en.json | 9 +- packages/blockly/msg/json/qqq.json | 9 +- packages/blockly/msg/messages.js | 25 ++++- packages/plugins/workspace-search/README.md | 2 +- .../plugins/workspace-search/package.json | 1 + .../workspace-search/src/workspace_search.ts | 63 ++++++++++-- .../test/workspace_search_test.mocha.js | 98 ++++++++++++++++--- 9 files changed, 186 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index aa8e3a55155..95f3ba294a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41379,6 +41379,7 @@ "devDependencies": { "@blockly/dev-scripts": "^13.1.0", "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", "jsdom": "^30.0.1", "jsdom-global": "3.0.2", "sinon": "^22.1.0", diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index b4e962cf021..c5a870dc6da 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -95,6 +95,8 @@ export enum Role { REGION = 'region', /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/row_role. */ ROW = 'row', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/search_role. */ + SEARCH = 'search', /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/separator_role. */ SEPARATOR = 'separator', /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role. */ diff --git a/packages/blockly/msg/json/en.json b/packages/blockly/msg/json/en.json index 7dd6e57236c..1fe29609ba6 100644 --- a/packages/blockly/msg/json/en.json +++ b/packages/blockly/msg/json/en.json @@ -664,5 +664,12 @@ "FIELD_MULTILINEINPUT_NEW_LINE": "New line", "ZOOM_TO_FIT_ARIA_LABEL": "Zoom to fit", "MINIMAP_ARIA_LABEL": "Workspace minimap. Use the arrow keys to pan the workspace.", - "ARIA_LABEL_TRASH_EMPTY": "Trash, currently empty" + "ARIA_LABEL_TRASH_EMPTY": "Trash, currently empty", + "WORKSPACE_SEARCH_PLACEHOLDER": "Search", + "WORKSPACE_SEARCH_INPUT_LABEL": "Search workspace. Press Enter for the next match, Shift+Enter for the previous match. Press Escape to close search and focus the current match.", + "WORKSPACE_SEARCH_FIND_NEXT": "Find next", + "WORKSPACE_SEARCH_FIND_PREVIOUS": "Find previous", + "WORKSPACE_SEARCH_CLOSE": "Close search bar", + "WORKSPACE_SEARCH_NO_MATCHES": "No matching blocks", + "WORKSPACE_SEARCH_MATCH": "Match %1 of %2: %3" } diff --git a/packages/blockly/msg/json/qqq.json b/packages/blockly/msg/json/qqq.json index c86ac7f2255..320dba23d35 100644 --- a/packages/blockly/msg/json/qqq.json +++ b/packages/blockly/msg/json/qqq.json @@ -669,5 +669,12 @@ "FIELD_MULTILINEINPUT_NEW_LINE": "Label for the hint shown in the multiline input field editor indicating the key to insert a new line. Keep this message brief.", "ZOOM_TO_FIT_ARIA_LABEL": "ARIA label for the zoom-to-fit button that zooms the workspace to fit all blocks.", "MINIMAP_ARIA_LABEL": "ARIA label for the workspace minimap with instructions on keyboard use.", - "ARIA_LABEL_TRASH_EMPTY": "ARIA label for the trashcan when it contains no blocks and cannot be interacted with." + "ARIA_LABEL_TRASH_EMPTY": "ARIA label for the trashcan when it contains no blocks and cannot be interacted with.", + "WORKSPACE_SEARCH_PLACEHOLDER": "Placeholder text shown in the workspace search input.", + "WORKSPACE_SEARCH_INPUT_LABEL": "ARIA label for the workspace search text input, including keyboard usage.", + "WORKSPACE_SEARCH_FIND_NEXT": "ARIA label for the workspace search button that selects the next matching block.", + "WORKSPACE_SEARCH_FIND_PREVIOUS": "ARIA label for the workspace search button that selects the previous matching block.", + "WORKSPACE_SEARCH_CLOSE": "ARIA label for the button that closes the workspace search bar.", + "WORKSPACE_SEARCH_NO_MATCHES": "ARIA live region message announced when workspace search finds no matching blocks.", + "WORKSPACE_SEARCH_MATCH": "ARIA live region message announcing the currently highlighted workspace search match. \n\nParameters:\n* %1 - 1-based index of the current match\n* %2 - total number of matches\n* %3 - accessible label of the current block \n\nExamples:\n* 'Match 1 of 3: print, hello'" } diff --git a/packages/blockly/msg/messages.js b/packages/blockly/msg/messages.js index a20a720df38..068eda42a32 100644 --- a/packages/blockly/msg/messages.js +++ b/packages/blockly/msg/messages.js @@ -2510,4 +2510,27 @@ Blockly.Msg.ZOOM_TO_FIT_ARIA_LABEL = 'Zoom to fit'; Blockly.Msg.MINIMAP_ARIA_LABEL = 'Workspace minimap. Use the arrow keys to pan the workspace.'; /** @type {string} */ /// ARIA label for the trashcan when it contains no blocks and cannot be interacted with. -Blockly.Msg.ARIA_LABEL_TRASH_EMPTY = 'Trash, currently empty'; \ No newline at end of file +Blockly.Msg.ARIA_LABEL_TRASH_EMPTY = 'Trash, currently empty'; +/** @type {string} */ +/// Placeholder text shown in the workspace search input. +Blockly.Msg.WORKSPACE_SEARCH_PLACEHOLDER = 'Search'; +/** @type {string} */ +/// ARIA label for the workspace search text input, including keyboard usage. +Blockly.Msg.WORKSPACE_SEARCH_INPUT_LABEL = 'Search workspace. Press Enter for the next match, Shift+Enter for the previous match. Press Escape to close search and focus the current match.'; +/** @type {string} */ +/// ARIA label for the workspace search button that selects the next matching block. +Blockly.Msg.WORKSPACE_SEARCH_FIND_NEXT = 'Find next'; +/** @type {string} */ +/// ARIA label for the workspace search button that selects the previous matching block. +Blockly.Msg.WORKSPACE_SEARCH_FIND_PREVIOUS = 'Find previous'; +/** @type {string} */ +/// ARIA label for the button that closes the workspace search bar. +Blockly.Msg.WORKSPACE_SEARCH_CLOSE = 'Close search bar'; +/** @type {string} */ +/// ARIA live region message announced when workspace search finds no matching blocks. +Blockly.Msg.WORKSPACE_SEARCH_NO_MATCHES = 'No matching blocks'; +/** @type {string} */ +/// ARIA live region message announcing the currently highlighted workspace search match. +/// \n\nParameters:\n* %1 - 1-based index of the current match\n* %2 - total number of matches\n* %3 - accessible label of the current block +/// \n\nExamples:\n* "Match 1 of 3: print, hello" +Blockly.Msg.WORKSPACE_SEARCH_MATCH = 'Match %1 of %2: %3'; \ No newline at end of file diff --git a/packages/plugins/workspace-search/README.md b/packages/plugins/workspace-search/README.md index c1df72e32a5..e08a2ac8370 100644 --- a/packages/plugins/workspace-search/README.md +++ b/packages/plugins/workspace-search/README.md @@ -28,7 +28,7 @@ workspaceSearch.init(); ``` -To open workspace search use either command + f or control + f. To close the search bar hit escape or the 'x' in the top right corner. +To open workspace search use either command + f or control + f. To close the search bar, press Escape or click the 'x' in the top right corner. Escape focuses the current matching block, or the workspace if there is no match. ## API diff --git a/packages/plugins/workspace-search/package.json b/packages/plugins/workspace-search/package.json index 603b44ee7aa..57449344688 100644 --- a/packages/plugins/workspace-search/package.json +++ b/packages/plugins/workspace-search/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@blockly/dev-scripts": "^13.1.0", "@blockly/dev-tools": "^13.1.0", + "chai": "^6.2.2", "jsdom": "^30.0.1", "jsdom-global": "3.0.2", "sinon": "^22.1.0", diff --git a/packages/plugins/workspace-search/src/workspace_search.ts b/packages/plugins/workspace-search/src/workspace_search.ts index 81268317ae9..07661c3c31d 100644 --- a/packages/plugins/workspace-search/src/workspace_search.ts +++ b/packages/plugins/workspace-search/src/workspace_search.ts @@ -35,7 +35,7 @@ export class WorkspaceSearch implements Blockly.IPositionable { /** * The placeholder text for the search bar input. */ - private textInputPlaceholder = 'Search'; + private textInputPlaceholder = Blockly.Msg['WORKSPACE_SEARCH_PLACEHOLDER']; /** * A list of blocks that came up in the search. @@ -148,6 +148,7 @@ export class WorkspaceSearch implements Blockly.IPositionable { this.htmlDiv = document.createElement('div'); Blockly.utils.dom.addClass(this.htmlDiv, 'blockly-ws-search'); + Blockly.utils.aria.setRole(this.htmlDiv, Blockly.utils.aria.Role.SEARCH); const searchContainer = document.createElement('div'); Blockly.utils.dom.addClass(searchContainer, 'blockly-ws-search-container'); @@ -239,8 +240,14 @@ export class WorkspaceSearch implements Blockly.IPositionable { */ protected createTextInput(): HTMLInputElement { const textInput = document.createElement('input'); - textInput.type = 'text'; + textInput.type = 'search'; + textInput.autocomplete = 'off'; textInput.setAttribute('placeholder', this.textInputPlaceholder); + Blockly.utils.aria.setState( + textInput, + Blockly.utils.aria.State.LABEL, + Blockly.Msg['WORKSPACE_SEARCH_INPUT_LABEL'], + ); return textInput; } @@ -250,7 +257,10 @@ export class WorkspaceSearch implements Blockly.IPositionable { * @returns The next button. */ protected createNextBtn(): HTMLButtonElement { - return this.createBtn('blockly-ws-search-next-btn', 'Find next'); + return this.createBtn( + 'blockly-ws-search-next-btn', + Blockly.Msg['WORKSPACE_SEARCH_FIND_NEXT'], + ); } /** @@ -259,7 +269,10 @@ export class WorkspaceSearch implements Blockly.IPositionable { * @returns The previous button. */ protected createPreviousBtn(): HTMLButtonElement { - return this.createBtn('blockly-ws-search-previous-btn', 'Find previous'); + return this.createBtn( + 'blockly-ws-search-previous-btn', + Blockly.Msg['WORKSPACE_SEARCH_FIND_PREVIOUS'], + ); } /** @@ -268,7 +281,10 @@ export class WorkspaceSearch implements Blockly.IPositionable { * @returns A button for closing the search bar. */ protected createCloseBtn(): HTMLButtonElement { - return this.createBtn('blockly-ws-search-close-btn', 'Close search bar'); + return this.createBtn( + 'blockly-ws-search-close-btn', + Blockly.Msg['WORKSPACE_SEARCH_CLOSE'], + ); } /** @@ -283,7 +299,7 @@ export class WorkspaceSearch implements Blockly.IPositionable { const btn = document.createElement('button'); Blockly.utils.dom.addClass(btn, className); btn.type = 'button'; - btn.setAttribute('aria-label', text); + Blockly.utils.aria.setState(btn, Blockly.utils.aria.State.LABEL, text); return btn; } @@ -447,6 +463,7 @@ export class WorkspaceSearch implements Blockly.IPositionable { this.highlightCurrentSelection(currentBlock); this.workspace.centerOnBlock(currentBlock.id, false); this.lastHighlighted = currentBlock; + this.announceCurrentMatch(); } /** @@ -511,6 +528,9 @@ export class WorkspaceSearch implements Blockly.IPositionable { currentIdx = currentIdx > -1 ? currentIdx : 0; } this.setCurrentBlock(currentIdx); + if (this.searchText && !this.blocks.length) { + this.announceNoMatches(); + } } /** @@ -600,6 +620,37 @@ export class WorkspaceSearch implements Blockly.IPositionable { this.blocks = []; } + /** + * Announces the currently highlighted match to screen readers. + * + * Focus stays in the search input while browsing results, so the highlighted + * block is announced via the live region rather than by moving focus. + */ + private announceCurrentMatch() { + const currentBlock = this.blocks[this.currentBlockIndex]; + if (!currentBlock) { + return; + } + Blockly.utils.aria.announceDynamicAriaState( + Blockly.Msg['WORKSPACE_SEARCH_MATCH'] + .replace('%1', String(this.currentBlockIndex + 1)) + .replace('%2', String(this.blocks.length)) + .replace( + '%3', + currentBlock.getAriaLabel(Blockly.utils.aria.Verbosity.TERSE), + ), + ); + } + + /** + * Announces that the current search term matched no blocks. + */ + private announceNoMatches() { + Blockly.utils.aria.announceDynamicAriaState( + Blockly.Msg['WORKSPACE_SEARCH_NO_MATCHES'], + ); + } + /** * Adds "current selection" highlight to the provided block. * Highlights the provided block as the "current selection". diff --git a/packages/plugins/workspace-search/test/workspace_search_test.mocha.js b/packages/plugins/workspace-search/test/workspace_search_test.mocha.js index ed534c05c8d..a932347702a 100644 --- a/packages/plugins/workspace-search/test/workspace_search_test.mocha.js +++ b/packages/plugins/workspace-search/test/workspace_search_test.mocha.js @@ -9,8 +9,8 @@ * @author kozbial@google.com (Monica Kozbial) */ -const assert = require('assert'); const Blockly = require('blockly'); +const {assert} = require('chai'); const sinon = require('sinon'); const {WorkspaceSearch} = require('../src/index'); @@ -63,6 +63,24 @@ suite('WorkspaceSearch', function () { } } + suiteSetup(function () { + Blockly.defineBlocksWithJsonArray([ + { + type: 'alpha_block', + message0: 'alpha', + }, + { + type: 'beta_block', + message0: 'beta', + }, + ]); + }); + + suiteTeardown(function () { + delete Blockly.Blocks['alpha_block']; + delete Blockly.Blocks['beta_block']; + }); + setup(function () { this.jsdomCleanup = require('jsdom-global')( '
', @@ -119,6 +137,22 @@ suite('WorkspaceSearch', function () { assert.equal(previousBtn.type, 'button'); assert.equal(closeBtn.type, 'button'); }); + + test('Buttons have accessible names', function () { + this.workspaceSearch.init(); + const nextBtn = document.querySelector( + 'button.blockly-ws-search-next-btn', + ); + const previousBtn = document.querySelector( + 'button.blockly-ws-search-previous-btn', + ); + const closeBtn = document.querySelector( + 'button.blockly-ws-search-close-btn', + ); + assert.equal(nextBtn.getAttribute('aria-label'), 'Find next'); + assert.equal(previousBtn.getAttribute('aria-label'), 'Find previous'); + assert.equal(closeBtn.getAttribute('aria-label'), 'Close search bar'); + }); }); suite('dispose()', function () { @@ -466,19 +500,6 @@ suite('WorkspaceSearch', function () { }); suite('focus', function () { - suiteSetup(function () { - Blockly.defineBlocksWithJsonArray([ - { - type: 'alpha_block', - message0: 'alpha', - }, - { - type: 'beta_block', - message0: 'beta', - }, - ]); - }); - setup(function () { this.alphaBlock = this.workspace.newBlock('alpha_block'); this.betaBlock = this.workspace.newBlock('beta_block'); @@ -530,4 +551,53 @@ suite('WorkspaceSearch', function () { assertFocusedNodeType('beta_block'); }); }); + + suite('screen reader support', function () { + setup(function () { + this.alphaBlock = this.workspace.newBlock('alpha_block'); + this.betaBlock = this.workspace.newBlock('beta_block'); + this.workspaceSearch.init(); + this.liveRegion = document.getElementById('blocklyAriaAnnounce'); + }); + + test('search container is a search landmark', function () { + const search = document.querySelector('div.blockly-ws-search'); + assert.equal(search.getAttribute('role'), 'search'); + }); + + test('search input aria-label describes keyboard usage', function () { + const input = document.querySelector('.blockly-ws-search-input input'); + const label = input.getAttribute('aria-label'); + assert.include(label, 'Enter'); + assert.include(label, 'Escape'); + }); + + test('matching blocks are announced with the current match', function () { + this.workspaceSearch.searchAndHighlight('a'); + this.clock.tick(11); + + assert.include(this.liveRegion.textContent, 'Match 1 of 2'); + assert.include(this.liveRegion.textContent, 'alpha'); + + this.workspaceSearch.next(); + this.clock.tick(11); + + assert.include(this.liveRegion.textContent, 'Match 2 of 2'); + assert.include(this.liveRegion.textContent, 'beta'); + }); + + test('no matches are announced', function () { + this.workspaceSearch.searchAndHighlight('c'); + this.clock.tick(11); + + assert.include(this.liveRegion.textContent, 'No matching blocks'); + }); + + test('empty search does not announce matches', function () { + this.workspaceSearch.searchAndHighlight(''); + this.clock.tick(11); + + assert.equal(this.liveRegion.textContent, ''); + }); + }); }); From 1f9a8a35c7e19b0be808a6bdedf6c65b9ddc5727 Mon Sep 17 00:00:00 2001 From: Mike Harvey <43474485+mikeharv@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:08:40 -0400 Subject: [PATCH 2/2] fix: restore search text when reopening --- .../plugins/workspace-search/src/workspace_search.ts | 9 +++++---- .../test/workspace_search_test.mocha.js | 11 +++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/plugins/workspace-search/src/workspace_search.ts b/packages/plugins/workspace-search/src/workspace_search.ts index 07661c3c31d..a6076e2645b 100644 --- a/packages/plugins/workspace-search/src/workspace_search.ts +++ b/packages/plugins/workspace-search/src/workspace_search.ts @@ -471,11 +471,12 @@ export class WorkspaceSearch implements Blockly.IPositionable { */ open() { this.setVisible(true); - this.inputElement?.focus(); - this.inputElement?.select(); - if (this.searchText) { - this.searchAndHighlight(this.searchText); + if (this.inputElement) { + this.inputElement.value = this.searchText; + this.inputElement.focus(); + this.inputElement.select(); } + this.searchAndHighlight(this.searchText); } /** diff --git a/packages/plugins/workspace-search/test/workspace_search_test.mocha.js b/packages/plugins/workspace-search/test/workspace_search_test.mocha.js index a932347702a..baad6c16635 100644 --- a/packages/plugins/workspace-search/test/workspace_search_test.mocha.js +++ b/packages/plugins/workspace-search/test/workspace_search_test.mocha.js @@ -593,11 +593,14 @@ suite('WorkspaceSearch', function () { assert.include(this.liveRegion.textContent, 'No matching blocks'); }); - test('empty search does not announce matches', function () { - this.workspaceSearch.searchAndHighlight(''); - this.clock.tick(11); + test('reopen restores the query and highlights matches', function () { + this.workspaceSearch.searchAndHighlight('alpha'); + this.workspaceSearch.close(); + this.workspaceSearch.open(); - assert.equal(this.liveRegion.textContent, ''); + const input = document.querySelector('.blockly-ws-search-input input'); + assert.equal(input.value, 'alpha'); + assert.isTrue(isBlockHighlighted(this.alphaBlock)); }); }); });