Skip to content

Commit 67159d4

Browse files
feat: illustrative toast
Use for Copy and Enter on a block. This is closer to the interaction envisaged on #192
1 parent f3d44ab commit 67159d4

File tree

3 files changed

+150
-4
lines changed

3 files changed

+150
-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: 4500,
285+
});
279286
}
280287
return copied;
281288
}

src/actions/enter.ts

Lines changed: 2 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);

src/toast.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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 = 7500} = options;
35+
const className = 'blocklyToast';
36+
workspace.getInjectionDiv().querySelector(`.${className}`)?.remove();
37+
38+
const toast = document.createElement('div');
39+
toast.className = className;
40+
toast.setAttribute('role', 'status');
41+
toast.setAttribute('aria-live', 'polite');
42+
assignStyle(toast, {
43+
fontSize: '1.2rem',
44+
position: 'absolute',
45+
bottom: '2rem',
46+
right: '-50rem',
47+
padding: '1rem',
48+
color: 'white',
49+
backgroundColor: 'rebeccapurple',
50+
borderRadius: '0.4rem',
51+
zIndex: '999',
52+
display: 'flex',
53+
alignItems: 'center',
54+
gap: '0.8rem',
55+
lineHeight: '1.5',
56+
transition: 'right 0.3s ease-out',
57+
});
58+
59+
toast.appendChild(
60+
infoIcon({
61+
width: '1.5em',
62+
height: '1.5em',
63+
}),
64+
);
65+
const messageElement = toast.appendChild(document.createElement('div'));
66+
assignStyle(messageElement, {
67+
maxWidth: '18rem',
68+
});
69+
messageElement.innerText = message;
70+
const closeButton = toast.appendChild(document.createElement('button'));
71+
assignStyle(closeButton, {
72+
margin: '0',
73+
padding: '0.2rem',
74+
backgroundColor: 'transparent',
75+
color: 'white',
76+
border: 'none',
77+
});
78+
closeButton.ariaLabel = 'Close';
79+
closeButton.appendChild(
80+
closeIcon({
81+
width: '1.5em',
82+
height: '1.5em',
83+
}),
84+
);
85+
closeButton.addEventListener('click', () => {
86+
toast.remove();
87+
workspace.markFocused();
88+
});
89+
90+
workspace.getInjectionDiv().appendChild(toast);
91+
requestAnimationFrame(() => {
92+
toast.style.right = '2rem';
93+
});
94+
95+
let timeout: ReturnType<typeof setTimeout> | undefined;
96+
const setToastTimeout = () => {
97+
timeout = setTimeout(() => toast.remove(), duration);
98+
};
99+
const clearToastTimeout = () => clearTimeout(timeout);
100+
toast.addEventListener('focusin', clearToastTimeout);
101+
toast.addEventListener('focusout', setToastTimeout);
102+
toast.addEventListener('mouseenter', clearToastTimeout);
103+
toast.addEventListener('mousemove', clearToastTimeout);
104+
toast.addEventListener('mouseleave', setToastTimeout);
105+
setToastTimeout();
106+
}
107+
108+
function icon(innerHTML: string, style: Partial<CSSStyleDeclaration> = {}) {
109+
const icon = document.createElement('svg');
110+
assignStyle(icon, style);
111+
icon.innerHTML = innerHTML;
112+
icon.ariaHidden = 'hidden';
113+
return icon;
114+
}
115+
116+
function infoIcon(style: Partial<CSSStyleDeclaration> = {}) {
117+
return icon(
118+
`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
119+
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
120+
<rect x="11" y="9" width="2" height="9" fill="currentColor"/>
121+
<circle cx="12.0345" cy="7.03448" r="1.03448" fill="currentColor"/>
122+
</svg>`,
123+
style,
124+
);
125+
}
126+
127+
function closeIcon(style: Partial<CSSStyleDeclaration> = {}) {
128+
return icon(
129+
`<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
130+
<rect x="19.7782" y="2.80762" width="2" height="24" transform="rotate(45 19.7782 2.80762)" fill="currentColor"/>
131+
<rect x="2.80762" y="4.22183" width="2" height="24" transform="rotate(-45 2.80762 4.22183)" fill="currentColor"/>
132+
</svg>`,
133+
style,
134+
);
135+
}
136+
137+
function assignStyle(target: HTMLElement, style: Partial<CSSStyleDeclaration>) {
138+
return Object.assign(target.style, style);
139+
}

0 commit comments

Comments
 (0)