Skip to content

Commit 8b6787f

Browse files
feat: active/passive focus styling (#511)
* feat: active/passive focus styling - Fix connection passive focus (unexpectedly display: none) - Limit passive focus to the workspace - Add active tree focus outlines - Limit focus styling to when keyboard nav is enabled (based on last input event) * fix: ensure valid rect width/height This affects MakeCode during initialization. * fix: whitespace
1 parent 8a9e9d9 commit 8b6787f

File tree

3 files changed

+176
-49
lines changed

3 files changed

+176
-49
lines changed

src/index.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import * as Blockly from 'blockly/core';
88
import {NavigationController} from './navigation_controller';
99
import {enableBlocksOnDrag} from './disabled_blocks';
10+
import {InputModeTracker} from './input_mode_tracker';
1011

1112
/** Plugin for keyboard navigation. */
1213
export class KeyboardNavigation {
@@ -25,6 +26,28 @@ export class KeyboardNavigation {
2526
*/
2627
private originalTheme: Blockly.Theme;
2728

29+
/**
30+
* Input mode tracking.
31+
*/
32+
private inputModeTracker: InputModeTracker;
33+
34+
/**
35+
* Focus ring in the workspace.
36+
*/
37+
private workspaceFocusRing: Element | null = null;
38+
39+
/**
40+
* Selection ring inside the workspace.
41+
*/
42+
private workspaceSelectionRing: Element | null = null;
43+
44+
/**
45+
* Used to restore monkey patch.
46+
*/
47+
private oldWorkspaceResize:
48+
| InstanceType<typeof Blockly.WorkspaceSvg>['resize']
49+
| null = null;
50+
2851
/**
2952
* Constructs the keyboard navigation.
3053
*
@@ -37,6 +60,7 @@ export class KeyboardNavigation {
3760
this.navigationController.init();
3861
this.navigationController.addWorkspace(workspace);
3962
this.navigationController.enable(workspace);
63+
this.inputModeTracker = new InputModeTracker(workspace);
4064

4165
this.originalTheme = workspace.getTheme();
4266
this.setGlowTheme();
@@ -57,18 +81,62 @@ export class KeyboardNavigation {
5781
workspace.getParentSvg(),
5882
);
5983
}
84+
85+
this.oldWorkspaceResize = workspace.resize;
86+
workspace.resize = () => {
87+
this.oldWorkspaceResize?.call(this.workspace);
88+
this.resizeWorkspaceRings();
89+
};
90+
this.workspaceSelectionRing = Blockly.utils.dom.createSvgElement('rect', {
91+
fill: 'none',
92+
class: 'blocklyWorkspaceSelectionRing',
93+
});
94+
workspace.getSvgGroup().appendChild(this.workspaceSelectionRing);
95+
this.workspaceFocusRing = Blockly.utils.dom.createSvgElement('rect', {
96+
fill: 'none',
97+
class: 'blocklyWorkspaceFocusRing',
98+
});
99+
workspace.getSvgGroup().appendChild(this.workspaceFocusRing);
100+
this.resizeWorkspaceRings();
101+
}
102+
103+
private resizeWorkspaceRings() {
104+
if (!this.workspaceFocusRing || !this.workspaceSelectionRing) return;
105+
this.resizeFocusRingInternal(this.workspaceSelectionRing, 5);
106+
this.resizeFocusRingInternal(this.workspaceFocusRing, 0);
107+
}
108+
109+
private resizeFocusRingInternal(ring: Element, inset: number) {
110+
const metrics = this.workspace.getMetrics();
111+
ring.setAttribute('x', (metrics.absoluteLeft + inset).toString());
112+
ring.setAttribute('y', (metrics.absoluteTop + inset).toString());
113+
ring.setAttribute(
114+
'width',
115+
Math.max(0, metrics.viewWidth - inset * 2).toString(),
116+
);
117+
ring.setAttribute(
118+
'height',
119+
Math.max(0, metrics.svgHeight - inset * 2).toString(),
120+
);
60121
}
61122

62123
/**
63124
* Disables keyboard navigation for this navigator's workspace.
64125
*/
65126
dispose() {
127+
this.workspaceFocusRing?.remove();
128+
this.workspaceSelectionRing?.remove();
129+
if (this.oldWorkspaceResize) {
130+
this.workspace.resize = this.oldWorkspaceResize;
131+
}
132+
66133
// Remove the event listener that enables blocks on drag
67134
this.workspace.removeChangeListener(enableBlocksOnDrag);
68135

69136
this.workspace.setTheme(this.originalTheme);
70137

71138
this.navigationController.dispose();
139+
this.inputModeTracker.dispose();
72140
}
73141

74142
/**

src/input_mode_tracker.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {WorkspaceSvg} from 'blockly';
2+
3+
/**
4+
* Types of user input.
5+
*/
6+
const enum InputMode {
7+
Keyboard,
8+
Pointer,
9+
}
10+
11+
/**
12+
* Tracks the most recent input mode and sets a class indicating we're in
13+
* keyboard nav mode.
14+
*/
15+
export class InputModeTracker {
16+
private lastEventMode: InputMode | null = null;
17+
18+
private pointerEventHandler = () => {
19+
this.lastEventMode = InputMode.Pointer;
20+
};
21+
private keyboardEventHandler = () => {
22+
this.lastEventMode = InputMode.Keyboard;
23+
};
24+
private focusChangeHandler = () => {
25+
const isKeyboard = this.lastEventMode === InputMode.Keyboard;
26+
const classList = this.workspace.getInjectionDiv().classList;
27+
const className = 'blocklyKeyboardNavigation';
28+
if (isKeyboard) {
29+
classList.add(className);
30+
} else {
31+
classList.remove(className);
32+
}
33+
};
34+
35+
constructor(private workspace: WorkspaceSvg) {
36+
document.addEventListener('pointerdown', this.pointerEventHandler, true);
37+
document.addEventListener('keydown', this.keyboardEventHandler, true);
38+
document.addEventListener('focusout', this.focusChangeHandler, true);
39+
document.addEventListener('focusin', this.focusChangeHandler, true);
40+
}
41+
42+
dispose() {
43+
document.removeEventListener('pointerdown', this.pointerEventHandler, true);
44+
document.removeEventListener('keydown', this.keyboardEventHandler, true);
45+
document.removeEventListener('focusout', this.focusChangeHandler, true);
46+
document.removeEventListener('focusin', this.focusChangeHandler, true);
47+
}
48+
}

test/index.html

Lines changed: 60 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,6 @@
3131
width: 100%;
3232
max-height: 100%;
3333
position: relative;
34-
--outline-width: 5px;
35-
}
36-
37-
.blocklyFlyout {
38-
top: var(--outline-width);
39-
left: var(--outline-width);
40-
height: calc(100% - calc(var(--outline-width) * 2));
4134
}
4235

4336
.blocklyToolboxDiv ~ .blocklyFlyout:focus {
@@ -97,52 +90,70 @@
9790
font-weight: bold;
9891
}
9992

100-
.blocklyActiveFocus:is(
101-
.blocklyField,
102-
.blocklyPath,
103-
.blocklyHighlightedConnectionPath
104-
) {
105-
stroke: #ffa200;
106-
stroke-width: 3px;
107-
}
108-
.blocklyActiveFocus > .blocklyFlyoutBackground,
109-
.blocklyActiveFocus > .blocklyMainBackground {
110-
stroke: #ffa200;
111-
stroke-width: 3px;
112-
}
113-
.blocklyActiveFocus:is(
114-
.blocklyToolbox,
115-
.blocklyToolboxCategoryContainer
116-
) {
117-
outline: 3px solid #ffa200;
118-
}
119-
.blocklyPassiveFocus:is(
120-
.blocklyField,
121-
.blocklyPath,
122-
.blocklyHighlightedConnectionPath
123-
) {
124-
stroke: #ffa200;
125-
stroke-dasharray: 5px 3px;
126-
stroke-width: 3px;
93+
html {
94+
--blockly-active-node-color: #ffa200;
95+
--blockly-active-tree-color: #60a5fa;
96+
--blockly-selection-width: 3px;
12797
}
128-
.blocklyPassiveFocus > .blocklyFlyoutBackground,
129-
.blocklyPassiveFocus > .blocklyMainBackground {
130-
stroke: #ffa200;
131-
stroke-dasharray: 5px 3px;
132-
stroke-width: 3px;
98+
* {
99+
box-sizing: border-box;
133100
}
134-
.blocklyPassiveFocus:is(
135-
.blocklyToolbox,
136-
.blocklyToolboxCategoryContainer
137-
) {
138-
border: 3px dashed #ffa200;
101+
102+
/* Blocks, connections and fields. */
103+
.blocklyKeyboardNavigation
104+
.blocklyActiveFocus:is(.blocklyPath, .blocklyHighlightedConnectionPath),
105+
.blocklyKeyboardNavigation
106+
.blocklyActiveFocus.blocklyField
107+
> .blocklyFieldRect {
108+
stroke: var(--blockly-active-node-color);
109+
stroke-width: var(--blockly-selection-width);
110+
}
111+
.blocklyKeyboardNavigation
112+
.blocklyPassiveFocus:is(
113+
.blocklyPath:not(.blocklyFlyout .blocklyPath),
114+
.blocklyHighlightedConnectionPath
115+
),
116+
.blocklyKeyboardNavigation
117+
.blocklyPassiveFocus.blocklyField
118+
> .blocklyFieldRect {
119+
stroke: var(--blockly-active-node-color);
120+
stroke-dasharray: 5px 3px;
121+
stroke-width: var(--blockly-selection-width);
139122
}
140-
.blocklySelected:is(.blocklyPath) {
141-
stroke: #ffa200;
142-
stroke-width: 5;
123+
.blocklyKeyboardNavigation
124+
.blocklyPassiveFocus.blocklyHighlightedConnectionPath {
125+
/* The connection path is being unexpectedly hidden in core */
126+
display: unset !important;
143127
}
144-
.blocklySelected > .blocklyPathLight {
145-
display: none;
128+
129+
/* Toolbox and flyout. */
130+
.blocklyKeyboardNavigation .blocklyFlyout:has(.blocklyActiveFocus),
131+
.blocklyKeyboardNavigation .blocklyToolbox:has(.blocklyActiveFocus),
132+
.blocklyKeyboardNavigation
133+
.blocklyActiveFocus:is(.blocklyFlyout, .blocklyToolbox) {
134+
outline-offset: calc(var(--blockly-selection-width) * -1);
135+
outline: var(--blockly-selection-width) solid
136+
var(--blockly-active-tree-color);
137+
}
138+
/* Workspace */
139+
.blocklyKeyboardNavigation
140+
.blocklyWorkspace:has(.blocklyActiveFocus)
141+
.blocklyWorkspaceFocusRing,
142+
.blocklyKeyboardNavigation
143+
.blocklyWorkspace.blocklyActiveFocus
144+
.blocklyWorkspaceFocusRing {
145+
stroke: var(--blockly-active-tree-color);
146+
stroke-width: calc(var(--blockly-selection-width) * 2);
147+
}
148+
.blocklyKeyboardNavigation
149+
.blocklyWorkspace.blocklyActiveFocus
150+
.blocklyWorkspaceSelectionRing {
151+
stroke: var(--blockly-active-node-color);
152+
stroke-width: var(--blockly-selection-width);
153+
}
154+
.blocklyKeyboardNavigation
155+
.blocklyToolboxCategoryContainer:focus-visible {
156+
outline: none;
146157
}
147158
</style>
148159
</head>

0 commit comments

Comments
 (0)