Skip to content

Commit 95f7f2c

Browse files
microbit-robertmicrobit-matt-hillsdonriknoll
authored
Add keyboard navigation help documentation (#10556)
* Keyboard Controls documentation This uses the sidebar but not the docs system which allows us to pull live shortcut bindings. Particularly useful if we allow custom shortcut bindings going forward. - Moves the tab order of the sidebar docs forward and adds a focus ring. - Handle shortcuts triggered when the sim has focus. - Maximise the mini sim for region nav as it's too small for accessible use. - Added support for escape to close keyboard controls - note this doesn't work for reference docs due to the iframe. Closes #10556 * Update jump to region key * Use registry shortcuts whereever possible. Inline Blockly content. * Use correct meta key for platform rather than both Can't use the normal pxtlib method to check platform here but at least Mac is easy to test. * Visibility/naming tweak * Consistently use up-front translated key names * Fix lf quote style --------- Co-authored-by: Matt Hillsdon <matt.hillsdon@microbit.org> Co-authored-by: Richard Knoll <riknoll@users.noreply.github.com>
1 parent bbe54d3 commit 95f7f2c

File tree

11 files changed

+482
-10
lines changed

11 files changed

+482
-10
lines changed

localtypings/pxteditor.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -902,6 +902,8 @@ declare namespace pxt.editor {
902902

903903
export type Activity = "tutorial" | "recipe" | "example";
904904

905+
export type BuiltInHelp = "keyboardControls";
906+
905907
export interface IProjectView {
906908
state: IAppState;
907909
setState(st: IAppState): void;
@@ -959,6 +961,7 @@ declare namespace pxt.editor {
959961
setSideFile(fn: IFile, line?: number): void;
960962
navigateToError(diag: pxtc.KsDiagnostic): void;
961963
setSideDoc(path: string, blocksEditor?: boolean): void;
964+
toggleBuiltInSideDoc(help: BuiltInHelp, focusIfOpen: boolean): void;
962965
setSideMarkdown(md: string): void;
963966
setSideDocCollapsed(shouldCollapse?: boolean): void;
964967
removeFile(fn: IFile, skipConfirm?: boolean): void;

pxtsim/accessibility.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,14 @@ namespace pxsim.accessibility {
99
}
1010

1111
export function getGlobalAction(e: KeyboardEvent): pxsim.GlobalAction | null {
12-
const meta = e.metaKey || e.ctrlKey;
12+
const isMac = window.navigator && /Mac/i.test(window.navigator.platform);
13+
const meta = isMac ? e.metaKey : e.ctrlKey;
1314
if (e.key === "Escape") {
1415
e.preventDefault();
1516
return "escape"
17+
} else if (e.key === "/" && meta) {
18+
e.preventDefault();
19+
return "togglekeyboardcontrolshelp";
1620
} else if (e.key === "b" && meta) {
1721
e.preventDefault();
1822
return "navigateregions"

pxtsim/embed.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ namespace pxsim {
7575
url: string;
7676
}
7777

78-
export type GlobalAction = "escape" | "navigateregions";
78+
export type GlobalAction = "escape" | "navigateregions" | "togglekeyboardcontrolshelp";
7979

8080
export interface SimulatorActionMessage extends SimulatorMessage {
8181
type: "action";

theme/pxt.less

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
@import 'tutorial';
1212
@import 'tutorial-sidebar';
1313
@import 'sidedoc';
14+
@import 'sidedoc-keyboard-nav-help';
1415
@import 'home';
1516
@import 'serial';
1617
@import 'docs';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/* Import all components */
2+
@import 'themes/default/globals/site.variables';
3+
@import 'themes/pxt/globals/site.variables';
4+
5+
/* Reference import */
6+
@import (reference) "semantic.less";
7+
8+
/*******************************
9+
Keyboard nav help
10+
*******************************/
11+
12+
#keyboardnavhelp {
13+
font-family: @docsPageFont !important;
14+
color: @docsTextColor;
15+
background-color: @docsBackgroundColor;
16+
17+
height: 100%;
18+
padding: 1rem;
19+
overflow: auto;
20+
.key {
21+
display: inline-flex;
22+
justify-content: center;
23+
padding: 0.2rem;
24+
border: 1px solid var(--pxt-neutral-foreground1);
25+
border-radius: 5px;
26+
min-width: 1.8em;
27+
}
28+
.shortcut {
29+
gap: 0.5rem;
30+
}
31+
.hint {
32+
font-size: 85%;
33+
line-height: 1;
34+
}
35+
table {
36+
width: 100%;
37+
table-layout: fixed;
38+
text-align: left;
39+
}
40+
th, td {
41+
vertical-align: top;
42+
padding: 0.4rem 0.2rem
43+
}
44+
tr {
45+
margin-bottom: 0.3rem;
46+
}
47+
h3 {
48+
margin-top: 2rem;
49+
}
50+
}

theme/sidedoc.less

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ code.hljs {
6363
border-top-left-radius: 5px;
6464
border-bottom-left-radius: 5px;
6565
z-index: @sidedocZIndex;
66+
67+
&:has(aside:focus-visible) {
68+
outline: @editorFocusBorderSize solid var(--pxt-focus-border);
69+
}
6670
}
6771

6872
.sideDocs #sidedocsframe {

webapp/src/app.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,10 @@ export class ProjectView
338338
this.showNavigateRegions();
339339
return
340340
}
341+
case "togglekeyboardcontrolshelp": {
342+
this.toggleBuiltInSideDoc("keyboardControls", false);
343+
return
344+
}
341345
}
342346
}
343347

@@ -1470,6 +1474,12 @@ export class ProjectView
14701474
}
14711475
}
14721476

1477+
toggleBuiltInSideDoc(help: pxt.editor.BuiltInHelp, focusIfVisible: boolean) {
1478+
let sd = this.refs["sidedoc"] as container.SideDocs;
1479+
if (!sd) return;
1480+
sd.toggleBuiltInHelp(help, focusIfVisible);
1481+
}
1482+
14731483
setTutorialInstructionsExpanded(value: boolean): void {
14741484
const tutorialOptions = this.state.tutorialOptions;
14751485
tutorialOptions.tutorialStepExpanded = value;
@@ -1830,6 +1840,13 @@ export class ProjectView
18301840
this.shouldTryDecompile = true;
18311841
}
18321842

1843+
// Onboard accessible blocks if accessible blocks has just been enabled
1844+
const onboardAccessibleBlocks = pxt.storage.getLocal("onboardAccessibleBlocks") === "1"
1845+
const sideDocsLoadUrl = onboardAccessibleBlocks ? `${container.builtInPrefix}keyboardControls` : ""
1846+
if (onboardAccessibleBlocks) {
1847+
pxt.storage.setLocal("onboardAccessibleBlocks", "0")
1848+
}
1849+
18331850
this.setState({
18341851
home: false,
18351852
showFiles: h.githubId ? true : false,
@@ -1838,7 +1855,7 @@ export class ProjectView
18381855
header: h,
18391856
projectName: h.name,
18401857
currFile: file,
1841-
sideDocsLoadUrl: '',
1858+
sideDocsLoadUrl: sideDocsLoadUrl,
18421859
debugging: false,
18431860
isMultiplayerGame: false
18441861
});
@@ -5207,6 +5224,10 @@ export class ProjectView
52075224
}
52085225

52095226
async toggleAccessibleBlocks() {
5227+
const nextEnabled = !this.getData<boolean>(auth.ACCESSIBLE_BLOCKS);
5228+
if (nextEnabled) {
5229+
pxt.storage.setLocal("onboardAccessibleBlocks", "1")
5230+
}
52105231
await core.toggleAccessibleBlocks()
52115232
this.reloadEditor();
52125233
}
@@ -5544,8 +5565,8 @@ export class ProjectView
55445565
<projects.Projects parent={this} ref={this.handleHomeRef} />
55455566
</div>
55465567
</div> : undefined}
5547-
{showEditorToolbar && <editortoolbar.EditorToolbar ref="editortools" parent={this} />}
55485568
{sideDocs ? <container.SideDocs ref="sidedoc" parent={this} sideDocsCollapsed={this.state.sideDocsCollapsed} docsUrl={this.state.sideDocsLoadUrl} /> : undefined}
5569+
{showEditorToolbar && <editortoolbar.EditorToolbar ref="editortools" parent={this} />}
55495570
{sandbox ? undefined : <scriptsearch.ScriptSearch parent={this} ref={this.handleScriptSearchRef} />}
55505571
{sandbox ? undefined : <extensions.Extensions parent={this} ref={this.handleExtensionRef} />}
55515572
{inHome ? <projects.ImportDialog parent={this} ref={this.handleImportDialogRef} /> : undefined}

webapp/src/blocks.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -579,10 +579,25 @@ export class Editor extends toolboxeditor.ToolboxEditor {
579579
focusRingDiv.className = "blocklyWorkspaceFocusRingLayer";
580580
this.editor.getSvgGroup().addEventListener("focus", () => {
581581
focusRingDiv.dataset.focused = "true";
582-
})
582+
});
583583
this.editor.getSvgGroup().addEventListener("blur", () => {
584584
delete focusRingDiv.dataset.focused;
585-
})
585+
});
586+
587+
const listShortcuts = Blockly.ShortcutRegistry.registry.getRegistry()["list_shortcuts"];
588+
Blockly.ShortcutRegistry.registry.unregister(listShortcuts.name);
589+
Blockly.ShortcutRegistry.registry.register({
590+
...listShortcuts,
591+
keyCodes: [
592+
Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.SLASH, [
593+
Blockly.utils.KeyCodes.META,
594+
]),
595+
Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.SLASH, [
596+
Blockly.utils.KeyCodes.CTRL,
597+
]),
598+
]
599+
});
600+
586601

587602
const cleanUpWorkspace = Blockly.ShortcutRegistry.registry.getRegistry()["clean_up_workspace"];
588603
Blockly.ShortcutRegistry.registry.unregister(cleanUpWorkspace.name);
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import * as React from "react";
2+
import { getActionShortcut, getActionShortcutsAsKeys, ShortcutNames } from "../shortcut_formatting";
3+
4+
const KeyboardControlsHelp = () => {
5+
const ref = React.useRef<HTMLElement>(null);
6+
React.useEffect(() => {
7+
ref.current?.focus()
8+
}, []);
9+
const ctrl = lf("Ctrl");
10+
const cmd = pxt.BrowserUtils.isMac() ? "⌘" : ctrl;
11+
const optionOrCtrl = pxt.BrowserUtils.isMac() ? "⌥" : ctrl;
12+
const contextMenuRow = <Row name={lf("Open context menu")} shortcuts={[ShortcutNames.MENU]} />
13+
const cleanUpRow = <Row name={lf("Workspace: Format code")} shortcuts={[ShortcutNames.CLEAN_UP]} />
14+
const orAsJoiner = lf("or")
15+
const enterOrSpace = { shortcuts: getActionShortcutsAsKeys(ShortcutNames.EDIT_OR_CONFIRM), joiner: orAsJoiner}
16+
const editOrConfirmRow = <Row name={lf("Edit or confirm")} {...enterOrSpace} />
17+
return (
18+
<aside id="keyboardnavhelp" aria-label={lf("Keyboard Controls")} ref={ref} tabIndex={0}>
19+
<h2>{lf("Keyboard Controls")}</h2>
20+
<table>
21+
<tbody>
22+
<Row name={lf("Show/hide shortcut help")} shortcuts={[ShortcutNames.LIST_SHORTCUTS]} />
23+
<Row name={lf("Jump to region")} shortcuts={[[cmd, "B"]]} />
24+
<Row name={lf("Block and toolbox navigation")} shortcuts={[ShortcutNames.UP, ShortcutNames.DOWN, ShortcutNames.LEFT, ShortcutNames.RIGHT]} />
25+
<Row name={lf("Toolbox or insert")} shortcuts={[ShortcutNames.TOOLBOX, ShortcutNames.INSERT]} joiner={orAsJoiner} />
26+
{editOrConfirmRow}
27+
<Row name={lf("Move mode")} shortcuts={[ShortcutNames.MOVE]} >
28+
<br /><span className="hint">{lf("Move with arrow keys")}</span>
29+
<br /><span className="hint">{lf("Hold {0} for free movement", optionOrCtrl)}</span>
30+
</Row>
31+
<Row name={lf("Copy / paste")} shortcuts={[ShortcutNames.COPY, ShortcutNames.PASTE]} joiner="/" />
32+
{cleanUpRow}
33+
{contextMenuRow}
34+
</tbody>
35+
</table>
36+
<h3>{lf("Editor Overview")}</h3>
37+
<table>
38+
<tbody>
39+
<Row name={lf("Move between menus, simulator and the workspace")} shortcuts={[[lf("Tab")], [lf("Shift"), lf("Tab")]]} joiner="row"/>
40+
<Row name={lf("Jump to region")} shortcuts={[[cmd, "B"]]} />
41+
<Row name={lf("Exit")} shortcuts={[ShortcutNames.EXIT]} />
42+
<Row name={lf("Toolbox")} shortcuts={[ShortcutNames.TOOLBOX]} />
43+
<Row name={lf("Toolbox: Move in and out of categories")} shortcuts={[ShortcutNames.LEFT, ShortcutNames.RIGHT]} />
44+
<Row name={lf("Toolbox: Navigate categories or blocks")} shortcuts={[ShortcutNames.UP, ShortcutNames.DOWN]} />
45+
<Row name={lf("Toolbox: Insert block")} {...enterOrSpace} />
46+
<Row name={lf("Workspace: Select workspace")} shortcuts={[ShortcutNames.CREATE_WS_CURSOR]} />
47+
{cleanUpRow}
48+
</tbody>
49+
</table>
50+
<h3>{lf("Edit Blocks")}</h3>
51+
<table>
52+
<tbody>
53+
<Row name={lf("Move in and out of a block")} shortcuts={[ShortcutNames.LEFT, ShortcutNames.RIGHT]} />
54+
{editOrConfirmRow}
55+
<Row name={lf("Cancel or exit")} shortcuts={[ShortcutNames.EXIT]} />
56+
<Row name={lf("Insert block at current position")} shortcuts={[ShortcutNames.INSERT]} />
57+
<Row name={lf("Copy")} shortcuts={[ShortcutNames.COPY]} />
58+
<Row name={lf("Paste")} shortcuts={[ShortcutNames.PASTE]} />
59+
<Row name={lf("Cut")} shortcuts={[ShortcutNames.CUT]} />
60+
<Row name={lf("Delete")} shortcuts={getActionShortcutsAsKeys(ShortcutNames.DELETE)} joiner={orAsJoiner} />
61+
<Row name={lf("Undo")} shortcuts={[ShortcutNames.UNDO]} />
62+
<Row name={lf("Redo")} shortcuts={[ShortcutNames.REDO]} />
63+
{contextMenuRow}
64+
</tbody>
65+
</table>
66+
<h3>{lf("Moving Blocks")}</h3>
67+
<table>
68+
<tbody>
69+
<Row name={lf("Move mode")} shortcuts={[ShortcutNames.MOVE]} />
70+
<Row name={lf("Move mode: Move to new position")} shortcuts={[ShortcutNames.UP, ShortcutNames.DOWN, ShortcutNames.LEFT, ShortcutNames.RIGHT]} />
71+
<Row name={lf("Move mode: Free movement")}>
72+
{lf("Hold {0} and press arrow keys", optionOrCtrl)}
73+
</Row>
74+
<Row name={lf("Move mode: Confirm")} {...enterOrSpace} />
75+
<Row name={lf("Move mode: Cancel")} shortcuts={[ShortcutNames.EXIT]} />
76+
<Row name={lf("Disconnect blocks")} shortcuts={[ShortcutNames.DISCONNECT]} />
77+
</tbody>
78+
</table>
79+
</aside>
80+
);
81+
}
82+
83+
const Shortcut = ({ keys }: { keys: string[] }) => {
84+
const joiner = pxt.BrowserUtils.isMac() ? " " : " + "
85+
return (
86+
<span className="shortcut">
87+
{keys.reduce((acc, key) => {
88+
return acc.length === 0
89+
? [...acc, <Key key={key} value={key} />]
90+
: [...acc, joiner, <Key key={key} value={key} />]
91+
}, [])}
92+
</span>
93+
);
94+
}
95+
96+
interface RowProps {
97+
name: string;
98+
shortcuts?: Array<string | string[]>;
99+
joiner?: string;
100+
children?: React.ReactNode;
101+
}
102+
103+
const Row = ({ name, shortcuts = [], joiner, children}: RowProps) => {
104+
const shortcutElements = shortcuts.map((s, idx) => {
105+
if (typeof s === "string") {
106+
// Pull keys from shortcut registry.
107+
return <Shortcut key={idx} keys={getActionShortcut(s)} />
108+
} else {
109+
// Display keys as specified.
110+
return <Shortcut key={idx} keys={s} />
111+
}
112+
})
113+
return joiner === "row" ? (
114+
<>
115+
<tr>
116+
<td width="50%" rowSpan={shortcuts.length}>{name}</td>
117+
<td width="50%">
118+
{shortcutElements[0]}
119+
</td>
120+
</tr>
121+
{shortcutElements.map((el, idx) => idx === 0
122+
? undefined
123+
: (<tr key={idx}>
124+
<td width="50%">
125+
{el}
126+
</td>
127+
</tr>))}
128+
</>
129+
) : (
130+
<tr>
131+
<td width="50%">{name}</td>
132+
<td width="50%">
133+
{shortcutElements.reduce((acc, shortcut) => {
134+
return acc.length === 0
135+
? [...acc, shortcut]
136+
: [...acc, joiner ? ` ${joiner} ` : " ", shortcut]
137+
}, [])}
138+
{children}
139+
<br />
140+
</td>
141+
</tr>
142+
)
143+
}
144+
145+
const Key = ({ value }: { value: string }) => {
146+
let aria;
147+
switch (value) {
148+
case "↑": {
149+
aria = lf("Up Arrow");
150+
break;
151+
}
152+
case "↓": {
153+
aria = lf("Down Arrow");
154+
break;
155+
}
156+
case "←": {
157+
aria = lf("Left Arrow");
158+
break;
159+
}
160+
case "→": {
161+
aria = lf("Right Arrow");
162+
break;
163+
}
164+
case "⌘": {
165+
aria = lf("Command");
166+
break;
167+
}
168+
case "⌥": {
169+
aria = lf("Option");
170+
break;
171+
}
172+
}
173+
return <span className="key" aria-label={aria}>{value}</span>
174+
}
175+
176+
export default KeyboardControlsHelp;

0 commit comments

Comments
 (0)