From 0a38566209ce743789b02129571f9689da6fc0d1 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 16 Apr 2025 12:12:24 -0700 Subject: [PATCH 01/12] feat: Allow resetting alert/prompt/confirm to defaults. --- core/dialog.ts | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/core/dialog.ts b/core/dialog.ts index 7e21129855c..61afe093157 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -6,24 +6,25 @@ // Former goog.module ID: Blockly.dialog -let alertImplementation = function ( - message: string, - opt_callback?: () => void, -) { +const defaultAlert = function (message: string, opt_callback?: () => void) { window.alert(message); if (opt_callback) { opt_callback(); } }; -let confirmImplementation = function ( +let alertImplementation = defaultAlert; + +const defaultConfirm = function ( message: string, callback: (result: boolean) => void, ) { callback(window.confirm(message)); }; -let promptImplementation = function ( +let confirmImplementation = defaultConfirm; + +const defaultPrompt = function ( message: string, defaultValue: string, callback: (result: string | null) => void, @@ -31,6 +32,8 @@ let promptImplementation = function ( callback(window.prompt(message, defaultValue)); }; +let promptImplementation = defaultPrompt; + /** * Wrapper to window.alert() that app developers may override via setAlert to * provide alternatives to the modal browser window. @@ -48,8 +51,10 @@ export function alert(message: string, opt_callback?: () => void) { * @param alertFunction The function to be run. * @see Blockly.dialog.alert */ -export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) { - alertImplementation = alertFunction; +export function setAlert( + alertFunction?: (message: string, callback?: () => void) => void, +) { + alertImplementation = alertFunction ?? defaultAlert; } /** @@ -59,14 +64,14 @@ export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) { * @param message The message to display to the user. * @param callback The callback for handling user response. */ -export function confirm(message: string, callback: (p1: boolean) => void) { +export function confirm(message: string, callback: (result: boolean) => void) { TEST_ONLY.confirmInternal(message, callback); } /** * Private version of confirm for stubbing in tests. */ -function confirmInternal(message: string, callback: (p1: boolean) => void) { +function confirmInternal(message: string, callback: (result: boolean) => void) { confirmImplementation(message, callback); } @@ -77,9 +82,12 @@ function confirmInternal(message: string, callback: (p1: boolean) => void) { * @see Blockly.dialog.confirm */ export function setConfirm( - confirmFunction: (p1: string, p2: (p1: boolean) => void) => void, + confirmFunction?: ( + message: string, + callback: (result: boolean) => void, + ) => void, ) { - confirmImplementation = confirmFunction; + confirmImplementation = confirmFunction ?? defaultConfirm; } /** @@ -95,7 +103,7 @@ export function setConfirm( export function prompt( message: string, defaultValue: string, - callback: (p1: string | null) => void, + callback: (result: string | null) => void, ) { promptImplementation(message, defaultValue, callback); } @@ -107,13 +115,13 @@ export function prompt( * @see Blockly.dialog.prompt */ export function setPrompt( - promptFunction: ( - p1: string, - p2: string, - p3: (p1: string | null) => void, + promptFunction?: ( + message: string, + defaultValue: string, + callback: (result: string | null) => void, ) => void, ) { - promptImplementation = promptFunction; + promptImplementation = promptFunction ?? defaultPrompt; } export const TEST_ONLY = { From d8404d657ac1353f26be7d000fbee686ead143db Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 16 Apr 2025 12:13:02 -0700 Subject: [PATCH 02/12] chore: Add unit tests for Blockly.dialog. --- tests/mocha/dialog_test.js | 136 +++++++++++++++++++++++++++++++++++++ tests/mocha/index.html | 1 + 2 files changed, 137 insertions(+) create mode 100644 tests/mocha/dialog_test.js diff --git a/tests/mocha/dialog_test.js b/tests/mocha/dialog_test.js new file mode 100644 index 00000000000..2d017793b6c --- /dev/null +++ b/tests/mocha/dialog_test.js @@ -0,0 +1,136 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Dialog utilities', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv', {}); + Blockly.dialog.setAlert(undefined); + Blockly.dialog.setPrompt(undefined); + Blockly.dialog.setConfirm(undefined); + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('use the browser alert by default', function () { + const alert = sinon.stub(window, 'alert'); + Blockly.dialog.alert('test'); + assert.isTrue(alert.calledWith('test')); + alert.restore(); + }); + + test('support setting a custom alert handler', function () { + const alert = sinon.spy(); + Blockly.dialog.setAlert(alert); + const callback = () => {}; + const message = 'test'; + Blockly.dialog.alert(message, callback); + assert.isTrue(alert.calledWith('test', callback)); + }); + + test('do not call the browser alert if a custom alert handler is set', function () { + const browserAlert = sinon.stub(window, 'alert'); + + const alert = sinon.spy(); + Blockly.dialog.setAlert(alert); + Blockly.dialog.alert(test); + assert.isFalse(browserAlert.called); + + browserAlert.restore(); + }); + + test('use the browser confirm by default', function () { + const confirm = sinon.stub(window, 'confirm'); + const callback = () => {}; + const message = 'test'; + Blockly.dialog.confirm(message, callback); + assert.isTrue(confirm.calledWith(message)); + confirm.restore(); + }); + + test('support setting a custom confirm handler', function () { + const confirm = sinon.spy(); + Blockly.dialog.setConfirm(confirm); + const callback = () => {}; + const message = 'test'; + Blockly.dialog.confirm(message, callback); + assert.isTrue(confirm.calledWith('test', callback)); + }); + + test('do not call the browser confirm if a custom confirm handler is set', function () { + const browserConfirm = sinon.stub(window, 'confirm'); + + const confirm = sinon.spy(); + Blockly.dialog.setConfirm(confirm); + const callback = () => {}; + const message = 'test'; + Blockly.dialog.confirm(message, callback); + assert.isFalse(browserConfirm.called); + + browserConfirm.restore(); + }); + + test('invokes the provided callback with the confirmation response', function () { + const confirm = sinon.stub(window, 'confirm').returns(true); + const callback = sinon.spy(); + const message = 'test'; + Blockly.dialog.confirm(message, callback); + assert.isTrue(callback.calledWith(true)); + confirm.restore(); + }); + + test('use the browser prompt by default', function () { + const prompt = sinon.stub(window, 'prompt'); + const callback = () => {}; + const message = 'test'; + const defaultValue = 'default'; + Blockly.dialog.prompt(message, defaultValue, callback); + assert.isTrue(prompt.calledWith(message, defaultValue)); + prompt.restore(); + }); + + test('support setting a custom prompt handler', function () { + const prompt = sinon.spy(); + Blockly.dialog.setPrompt(prompt); + const callback = () => {}; + const message = 'test'; + const defaultValue = 'default'; + Blockly.dialog.prompt(message, defaultValue, callback); + assert.isTrue(prompt.calledWith('test', defaultValue, callback)); + }); + + test('do not call the browser prompt if a custom prompt handler is set', function () { + const browserPrompt = sinon.stub(window, 'prompt'); + + const prompt = sinon.spy(); + Blockly.dialog.setPrompt(prompt); + const callback = () => {}; + const message = 'test'; + const defaultValue = 'default'; + Blockly.dialog.prompt(message, defaultValue, callback); + assert.isFalse(browserPrompt.called); + + browserPrompt.restore(); + }); + + test('invokes the provided callback with the prompt response', function () { + const prompt = sinon.stub(window, 'prompt').returns('something'); + const callback = sinon.spy(); + const message = 'test'; + const defaultValue = 'default'; + Blockly.dialog.prompt(message, defaultValue, callback); + assert.isTrue(callback.calledWith('something')); + prompt.restore(); + }); +}); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 690b75a7759..85399318bcf 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -192,6 +192,7 @@ import './contextmenu_items_test.js'; import './contextmenu_test.js'; import './cursor_test.js'; + import './dialog_test.js'; import './dropdowndiv_test.js'; import './event_test.js'; import './event_block_change_test.js'; From 01bd28afbc888515f0f2ca00e14795cbc581bf30 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 16 Apr 2025 12:18:18 -0700 Subject: [PATCH 03/12] fix: Removed TEST_ONLY hack from Blockly.dialog. --- core/dialog.ts | 11 ----------- tests/mocha/contextmenu_items_test.js | 19 +++++++++---------- tests/mocha/test_helpers/workspace.js | 18 +++++++++--------- 3 files changed, 18 insertions(+), 30 deletions(-) diff --git a/core/dialog.ts b/core/dialog.ts index 61afe093157..1af860b390b 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -65,13 +65,6 @@ export function setAlert( * @param callback The callback for handling user response. */ export function confirm(message: string, callback: (result: boolean) => void) { - TEST_ONLY.confirmInternal(message, callback); -} - -/** - * Private version of confirm for stubbing in tests. - */ -function confirmInternal(message: string, callback: (result: boolean) => void) { confirmImplementation(message, callback); } @@ -123,7 +116,3 @@ export function setPrompt( ) { promptImplementation = promptFunction ?? defaultPrompt; } - -export const TEST_ONLY = { - confirmInternal, -}; diff --git a/tests/mocha/contextmenu_items_test.js b/tests/mocha/contextmenu_items_test.js index a9e2bb3de62..d9044ec7e28 100644 --- a/tests/mocha/contextmenu_items_test.js +++ b/tests/mocha/contextmenu_items_test.js @@ -318,9 +318,7 @@ suite('Context Menu Items', function () { test('Deletes all blocks after confirming', function () { // Mocks the confirmation dialog and calls the callback with 'true' simulating ok. - const confirmStub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, true); + const confirmStub = sinon.stub(window, 'confirm').returns(true); this.workspace.newBlock('text'); this.workspace.newBlock('text'); @@ -328,13 +326,13 @@ suite('Context Menu Items', function () { this.clock.runAll(); sinon.assert.calledOnce(confirmStub); assert.equal(this.workspace.getTopBlocks(false).length, 0); + + confirmStub.restore(); }); test('Does not delete blocks if not confirmed', function () { // Mocks the confirmation dialog and calls the callback with 'false' simulating cancel. - const confirmStub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, false); + const confirmStub = sinon.stub(window, 'confirm').returns(false); this.workspace.newBlock('text'); this.workspace.newBlock('text'); @@ -342,19 +340,20 @@ suite('Context Menu Items', function () { this.clock.runAll(); sinon.assert.calledOnce(confirmStub); assert.equal(this.workspace.getTopBlocks(false).length, 2); + + confirmStub.restore(); }); test('No dialog for single block', function () { - const confirmStub = sinon.stub( - Blockly.dialog.TEST_ONLY, - 'confirmInternal', - ); + const confirmStub = sinon.stub(window, 'confirm'); this.workspace.newBlock('text'); this.deleteOption.callback(this.scope); this.clock.runAll(); sinon.assert.notCalled(confirmStub); assert.equal(this.workspace.getTopBlocks(false).length, 0); + + confirmStub.restore(); }); test('Has correct label for multiple blocks', function () { diff --git a/tests/mocha/test_helpers/workspace.js b/tests/mocha/test_helpers/workspace.js index 40b2574fca1..917ce6f629e 100644 --- a/tests/mocha/test_helpers/workspace.js +++ b/tests/mocha/test_helpers/workspace.js @@ -100,9 +100,7 @@ export function testAWorkspace() { test('deleteVariableById(id2) one usage', function () { // Deleting variable one usage should not trigger confirm dialog. - const stub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, true); + const stub = sinon.stub(window, 'confirm').returns(true); this.workspace.deleteVariableById('id2'); sinon.assert.notCalled(stub); @@ -110,13 +108,13 @@ export function testAWorkspace() { assert.isNull(variable); assertVariableValues(this.workspace, 'name1', 'type1', 'id1'); assertBlockVarModelName(this.workspace, 0, 'name1'); + + stub.restore(); }); test('deleteVariableById(id1) multiple usages confirm', function () { // Deleting variable with multiple usages triggers confirm dialog. - const stub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, true); + const stub = sinon.stub(window, 'confirm').returns(true); this.workspace.deleteVariableById('id1'); sinon.assert.calledOnce(stub); @@ -124,13 +122,13 @@ export function testAWorkspace() { assert.isNull(variable); assertVariableValues(this.workspace, 'name2', 'type2', 'id2'); assertBlockVarModelName(this.workspace, 0, 'name2'); + + stub.restore(); }); test('deleteVariableById(id1) multiple usages cancel', function () { // Deleting variable with multiple usages triggers confirm dialog. - const stub = sinon - .stub(Blockly.dialog.TEST_ONLY, 'confirmInternal') - .callsArgWith(1, false); + const stub = sinon.stub(window, 'confirm').returns(false); this.workspace.deleteVariableById('id1'); sinon.assert.calledOnce(stub); @@ -139,6 +137,8 @@ export function testAWorkspace() { assertBlockVarModelName(this.workspace, 0, 'name1'); assertBlockVarModelName(this.workspace, 1, 'name1'); assertBlockVarModelName(this.workspace, 2, 'name2'); + + stub.restore(); }); }); From 4eba436363f3018f34a65a1274c2d5552b961aeb Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 16 Apr 2025 12:47:52 -0700 Subject: [PATCH 04/12] feat: Add a default toast notification implementation. --- core/blockly.ts | 2 + core/toast.ts | 197 +++++++++++++++++++++++++++++++++++++++++++++ core/utils/aria.ts | 11 +++ 3 files changed, 210 insertions(+) create mode 100644 core/toast.ts diff --git a/core/blockly.ts b/core/blockly.ts index 46ea1fcaf43..c38a1d48e4b 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -432,6 +432,8 @@ Names.prototype.populateProcedures = function ( }; // clang-format on +export * from './toast.js'; + // Re-export submodules that no longer declareLegacyNamespace. export { ASTNode, diff --git a/core/toast.ts b/core/toast.ts new file mode 100644 index 00000000000..a36c297e026 --- /dev/null +++ b/core/toast.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Css from './css.js'; +import * as aria from './utils/aria.js'; +import * as dom from './utils/dom.js'; +import {Svg} from './utils/svg.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + +const CLASS_NAME = 'blocklyToast'; +const MESSAGE_CLASS_NAME = 'blocklyToastMessage'; +const CLOSE_BUTTON_CLASS_NAME = 'blocklyToastCloseButton'; + +/** + * Display/configuration options for a toast notification. + */ +export interface ToastOptions { + /** + * Toast ID. If set along with `oncePerSession`, will cause subsequent toasts + * with this ID to not be shown. + */ + id?: string; + + /** + * Flag to show the toast once per session only. + * Subsequent calls are ignored. + */ + oncePerSession?: boolean; + + /** + * Text of the message to display on the toast. + */ + message: string; + + /** + * Duration in seconds before the toast is removed. Defaults to 5. + */ + duration?: number; +} + +/** + * Class that allows for showing and dismissing temporary notifications. + */ +export class Toast { + /** IDs of toasts that have previously been shown. */ + private static shownIds = new Set(); + + /** + * Shows a toast notification. + * + * @param workspace The workspace to show the toast on. + * @param options Configuration options for the toast message, duration, etc. + */ + static show(workspace: WorkspaceSvg, options: ToastOptions) { + if (options.oncePerSession && options.id) { + if (this.shownIds.has(options.id)) return; + this.shownIds.add(options.id); + } + + // Clear any existing toasts. + this.clearToast(workspace); + + const toast = this.createDom(workspace, options); + + // Animate the toast into view. + requestAnimationFrame(() => { + toast.style.bottom = '2rem'; + }); + } + + /** + * Creates the DOM representation of a toast. + * + * @param workspace The workspace to inject the toast notification onto. + * @param options Configuration options for the toast. + * @returns The root DOM element of the toast. + */ + protected static createDom(workspace: WorkspaceSvg, options: ToastOptions) { + const {message, duration = 5} = options; + + const toast = document.createElement('div'); + workspace.getInjectionDiv().appendChild(toast); + toast.dataset.toastId = options.id; + toast.className = CLASS_NAME; + aria.setRole(toast, aria.Role.STATUS); + aria.setState(toast, aria.State.LIVE, 'polite'); + + const messageElement = toast.appendChild(document.createElement('div')); + messageElement.className = MESSAGE_CLASS_NAME; + messageElement.innerText = message; + const closeButton = toast.appendChild(document.createElement('button')); + closeButton.className = CLOSE_BUTTON_CLASS_NAME; + aria.setState(closeButton, aria.State.LABEL, 'Close'); + const closeIcon = dom.createSvgElement( + Svg.SVG, + { + width: 24, + height: 24, + viewBox: '0 0 24 24', + fill: 'none', + }, + closeButton, + ); + aria.setState(closeIcon, aria.State.HIDDEN, true); + dom.createSvgElement( + Svg.RECT, + { + x: 19.7782, + y: 2.80762, + width: 2, + height: 24, + transform: 'rotate(45, 19.7782, 2.80762)', + fill: 'black', + }, + closeIcon, + ); + dom.createSvgElement( + Svg.RECT, + { + x: 2.80762, + y: 4.22183, + width: 2, + height: 24, + transform: 'rotate(-45, 2.80762, 4.22183)', + fill: 'black', + }, + closeIcon, + ); + closeButton.addEventListener('click', () => { + toast.remove(); + workspace.markFocused(); + }); + + let timeout: ReturnType; + const setToastTimeout = () => { + timeout = setTimeout(() => toast.remove(), duration * 1000); + }; + const clearToastTimeout = () => clearTimeout(timeout); + toast.addEventListener('focusin', clearToastTimeout); + toast.addEventListener('focusout', setToastTimeout); + toast.addEventListener('mouseenter', clearToastTimeout); + toast.addEventListener('mousemove', clearToastTimeout); + toast.addEventListener('mouseleave', setToastTimeout); + setToastTimeout(); + + return toast; + } + + /** + * Dismiss a toast, e.g. in response to a user action. + * + * @param workspace The workspace to dismiss a toast in. + * @param id The toast ID, or undefined to clear any toast. + */ + static clearToast(workspace: WorkspaceSvg, id?: string) { + const toast = workspace.getInjectionDiv().querySelector(`.${CLASS_NAME}`); + if (toast instanceof HTMLElement && (!id || id === toast.dataset.toastId)) { + toast.remove(); + } + } +} + +Css.register(` +.${CLASS_NAME} { + font-size: 1.2rem; + position: absolute; + bottom: -10rem; + right: 2rem; + padding: 1rem; + color: black; + background-color: white; + border: 2px solid black; + border-radius: 0.4rem; + z-index: 999; + display: flex; + align-items: center; + gap: 0.8rem; + line-height: 1.5; + transition: bottom 0.3s ease-out; +} + +.${CLASS_NAME} .${MESSAGE_CLASS_NAME} { + maxWidth: 18rem; +} + +.${CLASS_NAME} .${CLOSE_BUTTON_CLASS_NAME} { + margin: 0; + padding: 0.2rem; + background-color: transparent; + color: black; + border: none; + cursor: pointer; +} +`); diff --git a/core/utils/aria.ts b/core/utils/aria.ts index 8089298e4ec..d997b8d0af0 100644 --- a/core/utils/aria.ts +++ b/core/utils/aria.ts @@ -51,6 +51,9 @@ export enum Role { // ARIA role for a visual separator in e.g. a menu. SEPARATOR = 'separator', + + // ARIA role for a live region providing information. + STATUS = 'status', } /** @@ -110,6 +113,14 @@ export enum State { // ARIA property for slider minimum value. Value: number. VALUEMIN = 'valuemin', + + // ARIA property for live region chattiness. + // Value: one of {polite, assertive, off}. + LIVE = 'live', + + // ARIA property for removing elements from the accessibility tree. + // Value: one of {true, false, undefined}. + HIDDEN = 'hidden', } /** From 0d1fb486d3ebef199e2b0196261c9a7e2c4a28bf Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 16 Apr 2025 12:59:08 -0700 Subject: [PATCH 05/12] feat: Add support for toasts to Blockly.dialog. --- core/dialog.ts | 32 ++++++++++++++++++++++++++++++++ tests/mocha/dialog_test.js | 38 +++++++++++++++++++++++++++++++++++--- 2 files changed, 67 insertions(+), 3 deletions(-) diff --git a/core/dialog.ts b/core/dialog.ts index 1af860b390b..8f901b0f9d8 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -6,6 +6,10 @@ // Former goog.module ID: Blockly.dialog +import type {ToastOptions} from './toast.js'; +import {Toast} from './toast.js'; +import type {WorkspaceSvg} from './workspace_svg.js'; + const defaultAlert = function (message: string, opt_callback?: () => void) { window.alert(message); if (opt_callback) { @@ -34,6 +38,9 @@ const defaultPrompt = function ( let promptImplementation = defaultPrompt; +const defaultToast = Toast.show.bind(Toast); +let toastImplementation = defaultToast; + /** * Wrapper to window.alert() that app developers may override via setAlert to * provide alternatives to the modal browser window. @@ -116,3 +123,28 @@ export function setPrompt( ) { promptImplementation = promptFunction ?? defaultPrompt; } + +/** + * Displays a temporary notification atop the workspace. Blockly provides a + * default toast implementation, but developers may provide their own via + * setToast. For simple appearance customization, CSS should be sufficient. + * + * @param workspace The workspace to display the toast notification atop. + * @param options Configuration options for the notification, including its + * message and duration. + */ +export function toast(workspace: WorkspaceSvg, options: ToastOptions) { + toastImplementation(workspace, options); +} + +/** + * Sets the function to be run when Blockly.dialog.toast() is called. + * + * @param promptFunction The function to be run. + * @see Blockly.dialog.toast + */ +export function setToast( + toastFunction?: (workspace: WorkspaceSvg, options: ToastOptions) => void, +) { + toastImplementation = toastFunction ?? defaultToast; +} diff --git a/tests/mocha/dialog_test.js b/tests/mocha/dialog_test.js index 2d017793b6c..2e58db4dc7e 100644 --- a/tests/mocha/dialog_test.js +++ b/tests/mocha/dialog_test.js @@ -14,13 +14,14 @@ suite('Dialog utilities', function () { setup(function () { sharedTestSetup.call(this); this.workspace = Blockly.inject('blocklyDiv', {}); - Blockly.dialog.setAlert(undefined); - Blockly.dialog.setPrompt(undefined); - Blockly.dialog.setConfirm(undefined); }); teardown(function () { sharedTestTeardown.call(this); + Blockly.dialog.setAlert(undefined); + Blockly.dialog.setPrompt(undefined); + Blockly.dialog.setConfirm(undefined); + Blockly.dialog.setToast(undefined); }); test('use the browser alert by default', function () { @@ -133,4 +134,35 @@ suite('Dialog utilities', function () { assert.isTrue(callback.calledWith('something')); prompt.restore(); }); + + test('use the built-in toast by default', function () { + const message = 'test toast'; + Blockly.dialog.toast(this.workspace, {message}); + const toast = this.workspace + .getInjectionDiv() + .querySelector('.blocklyToast'); + assert.isNotNull(toast); + assert.equal(toast.textContent, message); + }); + + test('support setting a custom toast handler', function () { + const toast = sinon.spy(); + Blockly.dialog.setToast(toast); + const message = 'test toast'; + const options = {message}; + Blockly.dialog.toast(this.workspace, options); + assert.isTrue(toast.calledWith(this.workspace, options)); + }); + + test('do not use the built-in toast if a custom toast handler is set', function () { + const builtInToast = sinon.stub(Blockly.Toast, 'show'); + + const toast = sinon.spy(); + Blockly.dialog.setToast(toast); + const message = 'test toast'; + Blockly.dialog.toast(this.workspace, {message}); + assert.isFalse(builtInToast.called); + + builtInToast.restore(); + }); }); From a3bba48546c578ef603b74d035f14ae057c82a18 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 16 Apr 2025 13:54:08 -0700 Subject: [PATCH 06/12] chore: Add tests for default toast implementation. --- core/toast.ts | 4 +- tests/mocha/index.html | 1 + tests/mocha/toast_test.js | 99 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 tests/mocha/toast_test.js diff --git a/core/toast.ts b/core/toast.ts index a36c297e026..e472ef55eed 100644 --- a/core/toast.ts +++ b/core/toast.ts @@ -61,7 +61,7 @@ export class Toast { } // Clear any existing toasts. - this.clearToast(workspace); + this.hide(workspace); const toast = this.createDom(workspace, options); @@ -155,7 +155,7 @@ export class Toast { * @param workspace The workspace to dismiss a toast in. * @param id The toast ID, or undefined to clear any toast. */ - static clearToast(workspace: WorkspaceSvg, id?: string) { + static hide(workspace: WorkspaceSvg, id?: string) { const toast = workspace.getInjectionDiv().querySelector(`.${CLASS_NAME}`); if (toast instanceof HTMLElement && (!id || id === toast.dataset.toastId)) { toast.remove(); diff --git a/tests/mocha/index.html b/tests/mocha/index.html index 85399318bcf..1c9f1fbbc6a 100644 --- a/tests/mocha/index.html +++ b/tests/mocha/index.html @@ -261,6 +261,7 @@ import './shortcut_registry_test.js'; import './touch_test.js'; import './theme_test.js'; + import './toast_test.js'; import './toolbox_test.js'; import './tooltip_test.js'; import './trashcan_test.js'; diff --git a/tests/mocha/toast_test.js b/tests/mocha/toast_test.js new file mode 100644 index 00000000000..414758a5164 --- /dev/null +++ b/tests/mocha/toast_test.js @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {assert} from '../../node_modules/chai/chai.js'; +import { + sharedTestSetup, + sharedTestTeardown, +} from './test_helpers/setup_teardown.js'; + +suite('Toasts', function () { + setup(function () { + sharedTestSetup.call(this); + this.workspace = Blockly.inject('blocklyDiv', {}); + this.toastIsVisible = (message) => { + const toast = this.workspace + .getInjectionDiv() + .querySelector('.blocklyToast'); + return !!(toast && toast.textContent === message); + }; + }); + + teardown(function () { + sharedTestTeardown.call(this); + }); + + test('can be shown', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message}); + assert.isTrue(this.toastIsVisible(message)); + }); + + test('can be shown only once per session', function () { + const options = { + message: 'texas toast', + id: 'test', + oncePerSession: true, + }; + Blockly.Toast.show(this.workspace, options); + assert.isTrue(this.toastIsVisible(options.message)); + Blockly.Toast.hide(this.workspace); + Blockly.Toast.show(this.workspace, options); + assert.isFalse(this.toastIsVisible(options.message)); + }); + + test('oncePerSession is ignored when false', function () { + const options = { + message: 'texas toast', + id: 'some id', + oncePerSession: true, + }; + Blockly.Toast.show(this.workspace, options); + assert.isTrue(this.toastIsVisible(options.message)); + Blockly.Toast.hide(this.workspace); + options.oncePerSession = false; + Blockly.Toast.show(this.workspace, options); + assert.isTrue(this.toastIsVisible(options.message)); + }); + + test('can be hidden', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message}); + assert.isTrue(this.toastIsVisible(message)); + Blockly.Toast.hide(this.workspace); + assert.isFalse(this.toastIsVisible(message)); + }); + + test('can be hidden by ID', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, id: 'test'}); + assert.isTrue(this.toastIsVisible(message)); + Blockly.Toast.hide(this.workspace, 'test'); + assert.isFalse(this.toastIsVisible(message)); + }); + + test('hide does not hide toasts with different ID', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, id: 'test'}); + assert.isTrue(this.toastIsVisible(message)); + Blockly.Toast.hide(this.workspace, 'test2'); + assert.isTrue(this.toastIsVisible(message)); + }); + + test('are shown for the designated duration', function () { + const clock = sinon.useFakeTimers(); + + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, duration: 3}); + for (let i = 0; i < 3; i++) { + assert.isTrue(this.toastIsVisible(message)); + clock.tick(1000); + } + assert.isFalse(this.toastIsVisible(message)); + + clock.restore(); + }); +}); From 9716b1de8ad53a9e71170f8448ff0d4127f5e989 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Wed, 16 Apr 2025 14:02:22 -0700 Subject: [PATCH 07/12] chore: Fix docstring. --- core/dialog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/dialog.ts b/core/dialog.ts index 8f901b0f9d8..ea290e1e9cc 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -140,7 +140,7 @@ export function toast(workspace: WorkspaceSvg, options: ToastOptions) { /** * Sets the function to be run when Blockly.dialog.toast() is called. * - * @param promptFunction The function to be run. + * @param toastFunction The function to be run. * @see Blockly.dialog.toast */ export function setToast( From 112b9d5c74bebb075ac748acd6ea0ca11e116bb2 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 18 Apr 2025 08:26:47 -0700 Subject: [PATCH 08/12] refactor: Use default arguments for dialog functions. --- core/dialog.ts | 26 ++++++++++++++++---------- tests/mocha/dialog_test.js | 8 ++++---- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/core/dialog.ts b/core/dialog.ts index ea290e1e9cc..bd60d09c4c6 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -59,9 +59,12 @@ export function alert(message: string, opt_callback?: () => void) { * @see Blockly.dialog.alert */ export function setAlert( - alertFunction?: (message: string, callback?: () => void) => void, + alertFunction: ( + message: string, + callback?: () => void, + ) => void = defaultAlert, ) { - alertImplementation = alertFunction ?? defaultAlert; + alertImplementation = alertFunction; } /** @@ -82,12 +85,12 @@ export function confirm(message: string, callback: (result: boolean) => void) { * @see Blockly.dialog.confirm */ export function setConfirm( - confirmFunction?: ( + confirmFunction: ( message: string, callback: (result: boolean) => void, - ) => void, + ) => void = defaultConfirm, ) { - confirmImplementation = confirmFunction ?? defaultConfirm; + confirmImplementation = confirmFunction; } /** @@ -115,13 +118,13 @@ export function prompt( * @see Blockly.dialog.prompt */ export function setPrompt( - promptFunction?: ( + promptFunction: ( message: string, defaultValue: string, callback: (result: string | null) => void, - ) => void, + ) => void = defaultPrompt, ) { - promptImplementation = promptFunction ?? defaultPrompt; + promptImplementation = promptFunction; } /** @@ -144,7 +147,10 @@ export function toast(workspace: WorkspaceSvg, options: ToastOptions) { * @see Blockly.dialog.toast */ export function setToast( - toastFunction?: (workspace: WorkspaceSvg, options: ToastOptions) => void, + toastFunction: ( + workspace: WorkspaceSvg, + options: ToastOptions, + ) => void = defaultToast, ) { - toastImplementation = toastFunction ?? defaultToast; + toastImplementation = toastFunction; } diff --git a/tests/mocha/dialog_test.js b/tests/mocha/dialog_test.js index 2e58db4dc7e..f250ff0f8aa 100644 --- a/tests/mocha/dialog_test.js +++ b/tests/mocha/dialog_test.js @@ -18,10 +18,10 @@ suite('Dialog utilities', function () { teardown(function () { sharedTestTeardown.call(this); - Blockly.dialog.setAlert(undefined); - Blockly.dialog.setPrompt(undefined); - Blockly.dialog.setConfirm(undefined); - Blockly.dialog.setToast(undefined); + Blockly.dialog.setAlert(); + Blockly.dialog.setPrompt(); + Blockly.dialog.setConfirm(); + Blockly.dialog.setToast(); }); test('use the browser alert by default', function () { From 7e28c6870f2d2113bee90aedda39dfb9bc711366 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 18 Apr 2025 08:50:09 -0700 Subject: [PATCH 09/12] refactor: Add 'close' to the list of messages. --- core/toast.ts | 3 ++- msg/json/en.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/core/toast.ts b/core/toast.ts index e472ef55eed..c82f2739afb 100644 --- a/core/toast.ts +++ b/core/toast.ts @@ -5,6 +5,7 @@ */ import * as Css from './css.js'; +import {Msg} from './msg.js'; import * as aria from './utils/aria.js'; import * as dom from './utils/dom.js'; import {Svg} from './utils/svg.js'; @@ -93,7 +94,7 @@ export class Toast { messageElement.innerText = message; const closeButton = toast.appendChild(document.createElement('button')); closeButton.className = CLOSE_BUTTON_CLASS_NAME; - aria.setState(closeButton, aria.State.LABEL, 'Close'); + aria.setState(closeButton, aria.State.LABEL, Msg['CLOSE']); const closeIcon = dom.createSvgElement( Svg.SVG, { diff --git a/msg/json/en.json b/msg/json/en.json index 50800bc27e8..eea600cbcf9 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -18,6 +18,7 @@ "DELETE_X_BLOCKS": "Delete %1 Blocks", "DELETE_ALL_BLOCKS": "Delete all %1 blocks?", "CLEAN_UP": "Clean up Blocks", + "CLOSE": "Close", "COLLAPSE_BLOCK": "Collapse Block", "COLLAPSE_ALL": "Collapse Blocks", "EXPAND_BLOCK": "Expand Block", From 1e798b68e06b7ac7bafcb5525aa38225c19ac755 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 18 Apr 2025 12:14:01 -0700 Subject: [PATCH 10/12] chore: Add new message in several other places. --- msg/json/en.json | 2 +- msg/json/qqq.json | 1 + msg/messages.js | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/msg/json/en.json b/msg/json/en.json index eea600cbcf9..33e944aef87 100644 --- a/msg/json/en.json +++ b/msg/json/en.json @@ -1,7 +1,7 @@ { "@metadata": { "author": "Ellen Spertus ", - "lastupdated": "2024-04-16 23:19:53.668551", + "lastupdated": "2025-04-18 12:08:36.485138", "locale": "en", "messagedocumentation" : "qqq" }, diff --git a/msg/json/qqq.json b/msg/json/qqq.json index fcd8897bd04..aebb6a34e14 100644 --- a/msg/json/qqq.json +++ b/msg/json/qqq.json @@ -24,6 +24,7 @@ "DELETE_X_BLOCKS": "context menu - Permanently delete the %1 selected blocks.\n\nParameters:\n* %1 - an integer greater than 1.", "DELETE_ALL_BLOCKS": "confirmation prompt - Question the user if they really wanted to permanently delete all %1 blocks.\n\nParameters:\n* %1 - an integer greater than 1.", "CLEAN_UP": "context menu - Reposition all the blocks so that they form a neat line.", + "CLOSE": "toast notification - Accessibility label for close button.", "COLLAPSE_BLOCK": "context menu - Make the appearance of the selected block smaller by hiding some information about it.", "COLLAPSE_ALL": "context menu - Make the appearance of all blocks smaller by hiding some information about it. Use the same terminology as in the previous message.", "EXPAND_BLOCK": "context menu - Restore the appearance of the selected block by showing information about it that was hidden (collapsed) earlier.", diff --git a/msg/messages.js b/msg/messages.js index 6b9d663a68b..3efbd03e6f0 100644 --- a/msg/messages.js +++ b/msg/messages.js @@ -103,6 +103,9 @@ Blockly.Msg.DELETE_ALL_BLOCKS = 'Delete all %1 blocks?'; /// context menu - Reposition all the blocks so that they form a neat line. Blockly.Msg.CLEAN_UP = 'Clean up Blocks'; /** @type {string} */ +/// toast notification - Accessibility label for close button. +Blockly.Msg.CLOSE = 'Close'; +/** @type {string} */ /// context menu - Make the appearance of the selected block smaller by hiding some information about it. Blockly.Msg.COLLAPSE_BLOCK = 'Collapse Block'; /** @type {string} */ From d51e88cf6a3fb6c4ddeb33e6c9f65b6b89e081b9 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Fri, 18 Apr 2025 12:15:49 -0700 Subject: [PATCH 11/12] chore: clarify docstrings. --- core/dialog.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/core/dialog.ts b/core/dialog.ts index bd60d09c4c6..374961323da 100644 --- a/core/dialog.ts +++ b/core/dialog.ts @@ -55,7 +55,8 @@ export function alert(message: string, opt_callback?: () => void) { /** * Sets the function to be run when Blockly.dialog.alert() is called. * - * @param alertFunction The function to be run. + * @param alertFunction The function to be run, or undefined to restore the + * default implementation. * @see Blockly.dialog.alert */ export function setAlert( @@ -81,7 +82,8 @@ export function confirm(message: string, callback: (result: boolean) => void) { /** * Sets the function to be run when Blockly.dialog.confirm() is called. * - * @param confirmFunction The function to be run. + * @param confirmFunction The function to be run, or undefined to restore the + * default implementation. * @see Blockly.dialog.confirm */ export function setConfirm( @@ -114,7 +116,8 @@ export function prompt( /** * Sets the function to be run when Blockly.dialog.prompt() is called. * - * @param promptFunction The function to be run. + * @param promptFunction The function to be run, or undefined to restore the + * default implementation. * @see Blockly.dialog.prompt */ export function setPrompt( @@ -143,7 +146,8 @@ export function toast(workspace: WorkspaceSvg, options: ToastOptions) { /** * Sets the function to be run when Blockly.dialog.toast() is called. * - * @param toastFunction The function to be run. + * @param toastFunction The function to be run, or undefined to restore the + * default implementation. * @see Blockly.dialog.toast */ export function setToast( From bdbbbc710ccccd7c3eb097842ba23c1eeee3fe17 Mon Sep 17 00:00:00 2001 From: Aaron Dodson Date: Mon, 21 Apr 2025 14:18:58 -0700 Subject: [PATCH 12/12] feat: Make toast assertiveness configurable. --- core/toast.ts | 25 +++++++++++++++++++++++-- tests/mocha/toast_test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/core/toast.ts b/core/toast.ts index c82f2739afb..72559279f57 100644 --- a/core/toast.ts +++ b/core/toast.ts @@ -40,6 +40,12 @@ export interface ToastOptions { * Duration in seconds before the toast is removed. Defaults to 5. */ duration?: number; + + /** + * How prominently/interrupting the readout of the toast should be for + * screenreaders. Corresponds to aria-live and defaults to polite. + */ + assertiveness?: Toast.Assertiveness; } /** @@ -80,14 +86,18 @@ export class Toast { * @returns The root DOM element of the toast. */ protected static createDom(workspace: WorkspaceSvg, options: ToastOptions) { - const {message, duration = 5} = options; + const { + message, + duration = 5, + assertiveness = Toast.Assertiveness.POLITE, + } = options; const toast = document.createElement('div'); workspace.getInjectionDiv().appendChild(toast); toast.dataset.toastId = options.id; toast.className = CLASS_NAME; aria.setRole(toast, aria.Role.STATUS); - aria.setState(toast, aria.State.LIVE, 'polite'); + aria.setState(toast, aria.State.LIVE, assertiveness); const messageElement = toast.appendChild(document.createElement('div')); messageElement.className = MESSAGE_CLASS_NAME; @@ -164,6 +174,17 @@ export class Toast { } } +/** + * Options for how aggressively toasts should be read out by screenreaders. + * Values correspond to those for aria-live. + */ +export namespace Toast { + export enum Assertiveness { + ASSERTIVE = 'assertive', + POLITE = 'polite', + } +} + Css.register(` .${CLASS_NAME} { font-size: 1.2rem; diff --git a/tests/mocha/toast_test.js b/tests/mocha/toast_test.js index 414758a5164..45e02ad5de8 100644 --- a/tests/mocha/toast_test.js +++ b/tests/mocha/toast_test.js @@ -96,4 +96,34 @@ suite('Toasts', function () { clock.restore(); }); + + test('default to polite assertiveness', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, {message, id: 'test'}); + const toast = this.workspace + .getInjectionDiv() + .querySelector('.blocklyToast'); + + assert.equal( + toast.getAttribute('aria-live'), + Blockly.Toast.Assertiveness.POLITE, + ); + }); + + test('respects assertiveness option', function () { + const message = 'texas toast'; + Blockly.Toast.show(this.workspace, { + message, + id: 'test', + assertiveness: Blockly.Toast.Assertiveness.ASSERTIVE, + }); + const toast = this.workspace + .getInjectionDiv() + .querySelector('.blocklyToast'); + + assert.equal( + toast.getAttribute('aria-live'), + Blockly.Toast.Assertiveness.ASSERTIVE, + ); + }); });