Skip to content

Commit b3e1be9

Browse files
feat: illustrative toast
We're using these interactions in user testing and may add a toast alongside Blockly.dialog in future.
1 parent f3d44ab commit b3e1be9

File tree

3 files changed

+179
-4
lines changed

3 files changed

+179
-4
lines changed

src/actions/clipboard.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type {BlockSvg, WorkspaceSvg} from 'blockly';
1919
import {Navigation} from '../navigation';
2020
import {ScopeWithConnection} from './action_menu';
2121
import {getShortActionShortcut} from '../shortcut_formatting';
22+
import {toast} from '../toast';
2223

2324
const KeyCodes = blocklyUtils.KeyCodes;
2425
const createSerializedKey = ShortcutRegistry.registry.createSerializedKey.bind(
@@ -274,8 +275,14 @@ export class Clipboard {
274275
this.copyData = sourceBlock.toCopyData();
275276
this.copyWorkspace = sourceBlock.workspace;
276277
const copied = !!this.copyData;
277-
if (copied && navigationState === Constants.STATE.FLYOUT) {
278-
this.navigation.focusWorkspace(workspace);
278+
if (copied) {
279+
if (navigationState === Constants.STATE.FLYOUT) {
280+
this.navigation.focusWorkspace(workspace);
281+
}
282+
toast(workspace, {
283+
message: `Copied. Press ${getShortActionShortcut('paste')} to paste.`,
284+
duration: 7000,
285+
});
279286
}
280287
return copied;
281288
}

src/actions/enter.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
Events,
1010
ShortcutRegistry,
1111
utils as BlocklyUtils,
12-
dialog,
1312
} from 'blockly/core';
1413

1514
import type {
@@ -24,6 +23,7 @@ import * as Constants from '../constants';
2423
import type {Navigation} from '../navigation';
2524
import {getShortActionShortcut} from '../shortcut_formatting';
2625
import {Mover} from './mover';
26+
import {toast} from '../toast';
2727

2828
const KeyCodes = BlocklyUtils.KeyCodes;
2929

@@ -106,7 +106,7 @@ export class EnterAction {
106106
if (!this.tryShowFullBlockFieldEditor(block)) {
107107
const shortcut = getShortActionShortcut('list_shortcuts');
108108
const message = `Press ${shortcut} for help on keyboard controls`;
109-
dialog.alert(message);
109+
toast(workspace, {message});
110110
}
111111
} else if (curNode.isConnection() || nodeType === ASTNode.types.WORKSPACE) {
112112
this.navigation.openToolboxOrFlyout(workspace);
@@ -120,6 +120,7 @@ export class EnterAction {
120120
* Tries to find a connection on the block to connect to the marked
121121
* location. If no connection has been marked, or there is not a compatible
122122
* connection then the block is placed on the workspace.
123+
* Trigger a toast per session if possible.
123124
*
124125
* @param workspace The main workspace. The workspace
125126
* the block will be placed on.
@@ -150,6 +151,32 @@ export class EnterAction {
150151
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
151152
workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!);
152153
this.mover.startMove(workspace);
154+
155+
const sessionItemKey = 'isToastInsertFromFlyoutShown';
156+
if (!this.sessionStorageIfPossible[sessionItemKey]) {
157+
const enter = getShortActionShortcut(
158+
Constants.SHORTCUT_NAMES.EDIT_OR_CONFIRM,
159+
);
160+
const message = `Use the arrow keys to move, then ${enter} to accept the position`;
161+
toast(workspace, {message});
162+
this.sessionStorageIfPossible[sessionItemKey] = 'true';
163+
}
164+
}
165+
166+
private sessionStorageIfPossible = this.getSessionStorageIfPossible();
167+
168+
/**
169+
* Gets session storage if possible.
170+
* If session storage is not possible, fallback on internal tracker, which
171+
* resets per intialisation instead of per session.
172+
*/
173+
private getSessionStorageIfPossible() {
174+
try {
175+
return window.sessionStorage;
176+
} catch (e) {
177+
// Handle possible SecurityError, absent window.
178+
return {} as Record<string, string>;
179+
}
153180
}
154181

155182
/**

src/toast.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {WorkspaceSvg} from 'blockly';
2+
3+
/**
4+
* Toast options.
5+
*/
6+
export interface ToastOptions {
7+
/**
8+
* Message text.
9+
*/
10+
message: string;
11+
/**
12+
* Duration in milliseconds before the toast is removed.
13+
* Defaults to 5000.
14+
*/
15+
duration?: number;
16+
}
17+
18+
/**
19+
* Shows a message as a toast positioned over the workspace.
20+
*
21+
* This is illustrative to gather feedback on the interaction.
22+
*
23+
* If retained, we'd expect to allow applications to override with their own implementations.
24+
*
25+
* Further work is needed on the accessibility of this toast:
26+
* - testing screen reader support
27+
* - considering whether support for stacked toasts is needed
28+
* - shortcut to focus? though it's the next tab stop currently
29+
*
30+
* @param workspace The workspace for positioning.
31+
* @param options Options.
32+
*/
33+
export function toast(workspace: WorkspaceSvg, options: ToastOptions): void {
34+
const {message, duration = 10000} = options;
35+
const className = 'blocklyToast';
36+
workspace.getInjectionDiv().querySelector(`.${className}`)?.remove();
37+
38+
const foregroundColor = 'black';
39+
const toast = document.createElement('div');
40+
toast.className = className;
41+
toast.setAttribute('role', 'status');
42+
toast.setAttribute('aria-live', 'polite');
43+
assignStyle(toast, {
44+
fontSize: '1.2rem',
45+
position: 'absolute',
46+
bottom: '-10rem',
47+
right: '2rem',
48+
padding: '1rem',
49+
color: foregroundColor,
50+
backgroundColor: 'white',
51+
border: '2px solid black',
52+
borderRadius: '0.4rem',
53+
zIndex: '999',
54+
display: 'flex',
55+
alignItems: 'center',
56+
gap: '0.8rem',
57+
lineHeight: '1.5',
58+
transition: 'bottom 0.3s ease-out',
59+
});
60+
61+
toast.appendChild(
62+
infoIcon({
63+
width: '1.5em',
64+
height: '1.5em',
65+
}),
66+
);
67+
const messageElement = toast.appendChild(document.createElement('div'));
68+
assignStyle(messageElement, {
69+
maxWidth: '18rem',
70+
});
71+
messageElement.innerText = message;
72+
const closeButton = toast.appendChild(document.createElement('button'));
73+
assignStyle(closeButton, {
74+
margin: '0',
75+
padding: '0.2rem',
76+
backgroundColor: 'transparent',
77+
color: foregroundColor,
78+
border: 'none',
79+
});
80+
closeButton.ariaLabel = 'Close';
81+
closeButton.appendChild(
82+
closeIcon({
83+
width: '1.5em',
84+
height: '1.5em',
85+
}),
86+
);
87+
closeButton.addEventListener('click', () => {
88+
toast.remove();
89+
workspace.markFocused();
90+
});
91+
92+
workspace.getInjectionDiv().appendChild(toast);
93+
requestAnimationFrame(() => {
94+
toast.style.bottom = '2rem';
95+
});
96+
97+
let timeout: ReturnType<typeof setTimeout> | undefined;
98+
const setToastTimeout = () => {
99+
timeout = setTimeout(() => toast.remove(), duration);
100+
};
101+
const clearToastTimeout = () => clearTimeout(timeout);
102+
toast.addEventListener('focusin', clearToastTimeout);
103+
toast.addEventListener('focusout', setToastTimeout);
104+
toast.addEventListener('mouseenter', clearToastTimeout);
105+
toast.addEventListener('mousemove', clearToastTimeout);
106+
toast.addEventListener('mouseleave', setToastTimeout);
107+
setToastTimeout();
108+
}
109+
110+
function icon(innerHTML: string, style: Partial<CSSStyleDeclaration> = {}) {
111+
const icon = document.createElement('svg');
112+
assignStyle(icon, style);
113+
icon.innerHTML = innerHTML;
114+
icon.ariaHidden = 'hidden';
115+
return icon;
116+
}
117+
118+
function infoIcon(style: Partial<CSSStyleDeclaration> = {}) {
119+
return icon(
120+
`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
121+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
122+
<rect x="11" y="9" width="2" height="9" fill="currentColor"/>
123+
<circle cx="12.0345" cy="7.03448" r="1.03448" fill="currentColor"/>
124+
</svg>`,
125+
style,
126+
);
127+
}
128+
129+
function closeIcon(style: Partial<CSSStyleDeclaration> = {}) {
130+
return icon(
131+
`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
132+
<rect x="19.7782" y="2.80762" width="2" height="24" transform="rotate(45 19.7782 2.80762)" fill="currentColor"/>
133+
<rect x="2.80762" y="4.22183" width="2" height="24" transform="rotate(-45 2.80762 4.22183)" fill="currentColor"/>
134+
</svg>`,
135+
style,
136+
);
137+
}
138+
139+
function assignStyle(target: HTMLElement, style: Partial<CSSStyleDeclaration>) {
140+
return Object.assign(target.style, style);
141+
}

0 commit comments

Comments
 (0)