-
Notifications
You must be signed in to change notification settings - Fork 3.9k
feat: Add support for displaying toast-style notifications. #8896
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
0a38566
d8404d6
01bd28a
4eba436
0d1fb48
a3bba48
9716b1d
112b9d5
7e28c68
1e798b6
d51e88c
194c5e4
bdbbbc7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,198 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2025 Google LLC | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| 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'; | ||
| 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); | ||
|
gonfunko marked this conversation as resolved.
|
||
|
|
||
| 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'); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Went ahead and made it configurable, defaulting to polite. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sounds good. I agree with @microbit-matt-hillsdon let's start from the general policy that a toast confirming a user action (like |
||
|
|
||
| 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, Msg['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(); | ||
|
gonfunko marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| 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; | ||
| } | ||
| `); | ||
Uh oh!
There was an error while loading. Please reload this page.