Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions core/blockly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,8 @@ Names.prototype.populateProcedures = function (
};
// clang-format on

export * from './toast.js';

// Re-export submodules that no longer declareLegacyNamespace.
export {
ASTNode,
Expand Down
83 changes: 56 additions & 27 deletions core/dialog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,41 @@

// Former goog.module ID: Blockly.dialog

let alertImplementation = function (
message: string,
opt_callback?: () => void,
) {
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) {
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,
) {
callback(window.prompt(message, defaultValue));
};

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.
Expand All @@ -48,8 +58,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;
}

/**
Expand All @@ -59,14 +71,7 @@ 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) {
TEST_ONLY.confirmInternal(message, callback);
}

/**
* Private version of confirm for stubbing in tests.
*/
function confirmInternal(message: string, callback: (p1: boolean) => void) {
export function confirm(message: string, callback: (result: boolean) => void) {
confirmImplementation(message, callback);
}

Expand All @@ -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;
}

/**
Expand All @@ -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);
}
Expand All @@ -107,15 +115,36 @@ 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 = {
confirmInternal,
};
/**
* 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 toastFunction The function to be run.
* @see Blockly.dialog.toast
*/
export function setToast(
toastFunction?: (workspace: WorkspaceSvg, options: ToastOptions) => void,
) {
toastImplementation = toastFunction ?? defaultToast;
}
197 changes: 197 additions & 0 deletions core/toast.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

/**
* 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.hide(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');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if role alert and state assertive would be more appropriate? I'm not sure how aria decides when a polite notification should fire, but it would be unfortunate if the message goes away before it does, and it seems we are using the toasts to be more of an alert, i.e. you want to give immediate feedback that the keyboard shortcut didn't fire or something. https://sheribyrnehaber.medium.com/designing-toast-messages-for-accessibility-fb610ac364be

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know; I assumed @microbit-matt-hillsdon had good reason for choosing these values though?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am curious to hear matt's thoughts, but you don't need to wait on this to merge

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the role is correct (at least for toasts in the general case), but there's a case to be made for assertive, and perhaps for it to be configurable on a per toast basis.

I'd review something like this with a good accessibility reputation and comprehensive implementation (support for > 1 toast is a jump in complexity though). In this case foreground toasts use "assertive" and I think a toast in response to a user action would be assertive. When we have blocks being read out it would be good to test in combination.

Tagging @kmcnaught too.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went ahead and made it configurable, defaulting to polite.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. I agree with role=status and polite or assertive depending on context (polite is a good default, but at some point we might want to add some examples to API docs to encourage platform developers from making good choices).

@microbit-matt-hillsdon let's start from the general policy that a toast confirming a user action (like "copied. press ctrl+v to paste") should be polite, where a toast explaining why a user action has failed (like "unable to copy this block" - hypothetically), should be assertive.


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<typeof setTimeout>;
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 hide(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;
}
`);
11 changes: 11 additions & 0 deletions core/utils/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
}

/**
Expand Down Expand Up @@ -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',
}

/**
Expand Down
Loading
Loading