Skip to content

Commit c6e58c4

Browse files
authored
feat: Add support for displaying toast-style notifications. (#8896)
* feat: Allow resetting alert/prompt/confirm to defaults. * chore: Add unit tests for Blockly.dialog. * fix: Removed TEST_ONLY hack from Blockly.dialog. * feat: Add a default toast notification implementation. * feat: Add support for toasts to Blockly.dialog. * chore: Add tests for default toast implementation. * chore: Fix docstring. * refactor: Use default arguments for dialog functions. * refactor: Add 'close' to the list of messages. * chore: Add new message in several other places. * chore: clarify docstrings. * feat: Make toast assertiveness configurable.
1 parent 9d12769 commit c6e58c4

File tree

12 files changed

+620
-46
lines changed

12 files changed

+620
-46
lines changed

core/blockly.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,8 @@ Names.prototype.populateProcedures = function (
432432
};
433433
// clang-format on
434434

435+
export * from './toast.js';
436+
435437
// Re-export submodules that no longer declareLegacyNamespace.
436438
export {
437439
ASTNode,

core/dialog.ts

Lines changed: 66 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,41 @@
66

77
// Former goog.module ID: Blockly.dialog
88

9-
let alertImplementation = function (
10-
message: string,
11-
opt_callback?: () => void,
12-
) {
9+
import type {ToastOptions} from './toast.js';
10+
import {Toast} from './toast.js';
11+
import type {WorkspaceSvg} from './workspace_svg.js';
12+
13+
const defaultAlert = function (message: string, opt_callback?: () => void) {
1314
window.alert(message);
1415
if (opt_callback) {
1516
opt_callback();
1617
}
1718
};
1819

19-
let confirmImplementation = function (
20+
let alertImplementation = defaultAlert;
21+
22+
const defaultConfirm = function (
2023
message: string,
2124
callback: (result: boolean) => void,
2225
) {
2326
callback(window.confirm(message));
2427
};
2528

26-
let promptImplementation = function (
29+
let confirmImplementation = defaultConfirm;
30+
31+
const defaultPrompt = function (
2732
message: string,
2833
defaultValue: string,
2934
callback: (result: string | null) => void,
3035
) {
3136
callback(window.prompt(message, defaultValue));
3237
};
3338

39+
let promptImplementation = defaultPrompt;
40+
41+
const defaultToast = Toast.show.bind(Toast);
42+
let toastImplementation = defaultToast;
43+
3444
/**
3545
* Wrapper to window.alert() that app developers may override via setAlert to
3646
* provide alternatives to the modal browser window.
@@ -45,10 +55,16 @@ export function alert(message: string, opt_callback?: () => void) {
4555
/**
4656
* Sets the function to be run when Blockly.dialog.alert() is called.
4757
*
48-
* @param alertFunction The function to be run.
58+
* @param alertFunction The function to be run, or undefined to restore the
59+
* default implementation.
4960
* @see Blockly.dialog.alert
5061
*/
51-
export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) {
62+
export function setAlert(
63+
alertFunction: (
64+
message: string,
65+
callback?: () => void,
66+
) => void = defaultAlert,
67+
) {
5268
alertImplementation = alertFunction;
5369
}
5470

@@ -59,25 +75,22 @@ export function setAlert(alertFunction: (p1: string, p2?: () => void) => void) {
5975
* @param message The message to display to the user.
6076
* @param callback The callback for handling user response.
6177
*/
62-
export function confirm(message: string, callback: (p1: boolean) => void) {
63-
TEST_ONLY.confirmInternal(message, callback);
64-
}
65-
66-
/**
67-
* Private version of confirm for stubbing in tests.
68-
*/
69-
function confirmInternal(message: string, callback: (p1: boolean) => void) {
78+
export function confirm(message: string, callback: (result: boolean) => void) {
7079
confirmImplementation(message, callback);
7180
}
7281

7382
/**
7483
* Sets the function to be run when Blockly.dialog.confirm() is called.
7584
*
76-
* @param confirmFunction The function to be run.
85+
* @param confirmFunction The function to be run, or undefined to restore the
86+
* default implementation.
7787
* @see Blockly.dialog.confirm
7888
*/
7989
export function setConfirm(
80-
confirmFunction: (p1: string, p2: (p1: boolean) => void) => void,
90+
confirmFunction: (
91+
message: string,
92+
callback: (result: boolean) => void,
93+
) => void = defaultConfirm,
8194
) {
8295
confirmImplementation = confirmFunction;
8396
}
@@ -95,27 +108,53 @@ export function setConfirm(
95108
export function prompt(
96109
message: string,
97110
defaultValue: string,
98-
callback: (p1: string | null) => void,
111+
callback: (result: string | null) => void,
99112
) {
100113
promptImplementation(message, defaultValue, callback);
101114
}
102115

103116
/**
104117
* Sets the function to be run when Blockly.dialog.prompt() is called.
105118
*
106-
* @param promptFunction The function to be run.
119+
* @param promptFunction The function to be run, or undefined to restore the
120+
* default implementation.
107121
* @see Blockly.dialog.prompt
108122
*/
109123
export function setPrompt(
110124
promptFunction: (
111-
p1: string,
112-
p2: string,
113-
p3: (p1: string | null) => void,
114-
) => void,
125+
message: string,
126+
defaultValue: string,
127+
callback: (result: string | null) => void,
128+
) => void = defaultPrompt,
115129
) {
116130
promptImplementation = promptFunction;
117131
}
118132

119-
export const TEST_ONLY = {
120-
confirmInternal,
121-
};
133+
/**
134+
* Displays a temporary notification atop the workspace. Blockly provides a
135+
* default toast implementation, but developers may provide their own via
136+
* setToast. For simple appearance customization, CSS should be sufficient.
137+
*
138+
* @param workspace The workspace to display the toast notification atop.
139+
* @param options Configuration options for the notification, including its
140+
* message and duration.
141+
*/
142+
export function toast(workspace: WorkspaceSvg, options: ToastOptions) {
143+
toastImplementation(workspace, options);
144+
}
145+
146+
/**
147+
* Sets the function to be run when Blockly.dialog.toast() is called.
148+
*
149+
* @param toastFunction The function to be run, or undefined to restore the
150+
* default implementation.
151+
* @see Blockly.dialog.toast
152+
*/
153+
export function setToast(
154+
toastFunction: (
155+
workspace: WorkspaceSvg,
156+
options: ToastOptions,
157+
) => void = defaultToast,
158+
) {
159+
toastImplementation = toastFunction;
160+
}

core/toast.ts

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/**
2+
* @license
3+
* Copyright 2025 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
import * as Css from './css.js';
8+
import {Msg} from './msg.js';
9+
import * as aria from './utils/aria.js';
10+
import * as dom from './utils/dom.js';
11+
import {Svg} from './utils/svg.js';
12+
import type {WorkspaceSvg} from './workspace_svg.js';
13+
14+
const CLASS_NAME = 'blocklyToast';
15+
const MESSAGE_CLASS_NAME = 'blocklyToastMessage';
16+
const CLOSE_BUTTON_CLASS_NAME = 'blocklyToastCloseButton';
17+
18+
/**
19+
* Display/configuration options for a toast notification.
20+
*/
21+
export interface ToastOptions {
22+
/**
23+
* Toast ID. If set along with `oncePerSession`, will cause subsequent toasts
24+
* with this ID to not be shown.
25+
*/
26+
id?: string;
27+
28+
/**
29+
* Flag to show the toast once per session only.
30+
* Subsequent calls are ignored.
31+
*/
32+
oncePerSession?: boolean;
33+
34+
/**
35+
* Text of the message to display on the toast.
36+
*/
37+
message: string;
38+
39+
/**
40+
* Duration in seconds before the toast is removed. Defaults to 5.
41+
*/
42+
duration?: number;
43+
44+
/**
45+
* How prominently/interrupting the readout of the toast should be for
46+
* screenreaders. Corresponds to aria-live and defaults to polite.
47+
*/
48+
assertiveness?: Toast.Assertiveness;
49+
}
50+
51+
/**
52+
* Class that allows for showing and dismissing temporary notifications.
53+
*/
54+
export class Toast {
55+
/** IDs of toasts that have previously been shown. */
56+
private static shownIds = new Set<string>();
57+
58+
/**
59+
* Shows a toast notification.
60+
*
61+
* @param workspace The workspace to show the toast on.
62+
* @param options Configuration options for the toast message, duration, etc.
63+
*/
64+
static show(workspace: WorkspaceSvg, options: ToastOptions) {
65+
if (options.oncePerSession && options.id) {
66+
if (this.shownIds.has(options.id)) return;
67+
this.shownIds.add(options.id);
68+
}
69+
70+
// Clear any existing toasts.
71+
this.hide(workspace);
72+
73+
const toast = this.createDom(workspace, options);
74+
75+
// Animate the toast into view.
76+
requestAnimationFrame(() => {
77+
toast.style.bottom = '2rem';
78+
});
79+
}
80+
81+
/**
82+
* Creates the DOM representation of a toast.
83+
*
84+
* @param workspace The workspace to inject the toast notification onto.
85+
* @param options Configuration options for the toast.
86+
* @returns The root DOM element of the toast.
87+
*/
88+
protected static createDom(workspace: WorkspaceSvg, options: ToastOptions) {
89+
const {
90+
message,
91+
duration = 5,
92+
assertiveness = Toast.Assertiveness.POLITE,
93+
} = options;
94+
95+
const toast = document.createElement('div');
96+
workspace.getInjectionDiv().appendChild(toast);
97+
toast.dataset.toastId = options.id;
98+
toast.className = CLASS_NAME;
99+
aria.setRole(toast, aria.Role.STATUS);
100+
aria.setState(toast, aria.State.LIVE, assertiveness);
101+
102+
const messageElement = toast.appendChild(document.createElement('div'));
103+
messageElement.className = MESSAGE_CLASS_NAME;
104+
messageElement.innerText = message;
105+
const closeButton = toast.appendChild(document.createElement('button'));
106+
closeButton.className = CLOSE_BUTTON_CLASS_NAME;
107+
aria.setState(closeButton, aria.State.LABEL, Msg['CLOSE']);
108+
const closeIcon = dom.createSvgElement(
109+
Svg.SVG,
110+
{
111+
width: 24,
112+
height: 24,
113+
viewBox: '0 0 24 24',
114+
fill: 'none',
115+
},
116+
closeButton,
117+
);
118+
aria.setState(closeIcon, aria.State.HIDDEN, true);
119+
dom.createSvgElement(
120+
Svg.RECT,
121+
{
122+
x: 19.7782,
123+
y: 2.80762,
124+
width: 2,
125+
height: 24,
126+
transform: 'rotate(45, 19.7782, 2.80762)',
127+
fill: 'black',
128+
},
129+
closeIcon,
130+
);
131+
dom.createSvgElement(
132+
Svg.RECT,
133+
{
134+
x: 2.80762,
135+
y: 4.22183,
136+
width: 2,
137+
height: 24,
138+
transform: 'rotate(-45, 2.80762, 4.22183)',
139+
fill: 'black',
140+
},
141+
closeIcon,
142+
);
143+
closeButton.addEventListener('click', () => {
144+
toast.remove();
145+
workspace.markFocused();
146+
});
147+
148+
let timeout: ReturnType<typeof setTimeout>;
149+
const setToastTimeout = () => {
150+
timeout = setTimeout(() => toast.remove(), duration * 1000);
151+
};
152+
const clearToastTimeout = () => clearTimeout(timeout);
153+
toast.addEventListener('focusin', clearToastTimeout);
154+
toast.addEventListener('focusout', setToastTimeout);
155+
toast.addEventListener('mouseenter', clearToastTimeout);
156+
toast.addEventListener('mousemove', clearToastTimeout);
157+
toast.addEventListener('mouseleave', setToastTimeout);
158+
setToastTimeout();
159+
160+
return toast;
161+
}
162+
163+
/**
164+
* Dismiss a toast, e.g. in response to a user action.
165+
*
166+
* @param workspace The workspace to dismiss a toast in.
167+
* @param id The toast ID, or undefined to clear any toast.
168+
*/
169+
static hide(workspace: WorkspaceSvg, id?: string) {
170+
const toast = workspace.getInjectionDiv().querySelector(`.${CLASS_NAME}`);
171+
if (toast instanceof HTMLElement && (!id || id === toast.dataset.toastId)) {
172+
toast.remove();
173+
}
174+
}
175+
}
176+
177+
/**
178+
* Options for how aggressively toasts should be read out by screenreaders.
179+
* Values correspond to those for aria-live.
180+
*/
181+
export namespace Toast {
182+
export enum Assertiveness {
183+
ASSERTIVE = 'assertive',
184+
POLITE = 'polite',
185+
}
186+
}
187+
188+
Css.register(`
189+
.${CLASS_NAME} {
190+
font-size: 1.2rem;
191+
position: absolute;
192+
bottom: -10rem;
193+
right: 2rem;
194+
padding: 1rem;
195+
color: black;
196+
background-color: white;
197+
border: 2px solid black;
198+
border-radius: 0.4rem;
199+
z-index: 999;
200+
display: flex;
201+
align-items: center;
202+
gap: 0.8rem;
203+
line-height: 1.5;
204+
transition: bottom 0.3s ease-out;
205+
}
206+
207+
.${CLASS_NAME} .${MESSAGE_CLASS_NAME} {
208+
maxWidth: 18rem;
209+
}
210+
211+
.${CLASS_NAME} .${CLOSE_BUTTON_CLASS_NAME} {
212+
margin: 0;
213+
padding: 0.2rem;
214+
background-color: transparent;
215+
color: black;
216+
border: none;
217+
cursor: pointer;
218+
}
219+
`);

0 commit comments

Comments
 (0)