Skip to content

Commit 6e78753

Browse files
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)
1 parent 8a9e9d9 commit 6e78753

File tree

3 files changed

+169
-49
lines changed

3 files changed

+169
-49
lines changed

src/index.ts

Lines changed: 61 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,27 @@ 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+
* Selection ring inside the workspace.
40+
*/
41+
private workspaceSelectionRing: Element | null = null;
42+
43+
/**
44+
* Used to restore monkey patch.
45+
*/
46+
private oldWorkspaceResize:
47+
| InstanceType<typeof Blockly.WorkspaceSvg>['resize']
48+
| null = null;
49+
2850
/**
2951
* Constructs the keyboard navigation.
3052
*
@@ -37,6 +59,7 @@ export class KeyboardNavigation {
3759
this.navigationController.init();
3860
this.navigationController.addWorkspace(workspace);
3961
this.navigationController.enable(workspace);
62+
this.inputModeTracker = new InputModeTracker(workspace);
4063

4164
this.originalTheme = workspace.getTheme();
4265
this.setGlowTheme();
@@ -57,18 +80,56 @@ export class KeyboardNavigation {
5780
workspace.getParentSvg(),
5881
);
5982
}
83+
84+
this.oldWorkspaceResize = workspace.resize;
85+
workspace.resize = () => {
86+
this.oldWorkspaceResize?.call(this.workspace);
87+
this.resizeWorkspaceRings();
88+
};
89+
this.workspaceSelectionRing = Blockly.utils.dom.createSvgElement('rect', {
90+
fill: 'none',
91+
class: 'blocklyWorkspaceSelectionRing',
92+
});
93+
workspace.getSvgGroup().appendChild(this.workspaceSelectionRing);
94+
this.workspaceFocusRing = Blockly.utils.dom.createSvgElement('rect', {
95+
fill: 'none',
96+
class: 'blocklyWorkspaceFocusRing',
97+
});
98+
workspace.getSvgGroup().appendChild(this.workspaceFocusRing);
99+
this.resizeWorkspaceRings();
100+
}
101+
102+
private resizeWorkspaceRings() {
103+
if (!this.workspaceFocusRing || !this.workspaceSelectionRing) return;
104+
this.resizeFocusRingInternal(this.workspaceSelectionRing, 5);
105+
this.resizeFocusRingInternal(this.workspaceFocusRing, 0);
106+
}
107+
108+
private resizeFocusRingInternal(ring: Element, inset: number) {
109+
const metrics = this.workspace.getMetrics();
110+
ring.setAttribute('x', (metrics.absoluteLeft + inset).toString());
111+
ring.setAttribute('y', (metrics.absoluteTop + inset).toString());
112+
ring.setAttribute('width', (metrics.viewWidth - inset * 2).toString());
113+
ring.setAttribute('height', (metrics.svgHeight - inset * 2).toString());
60114
}
61115

62116
/**
63117
* Disables keyboard navigation for this navigator's workspace.
64118
*/
65119
dispose() {
120+
this.workspaceFocusRing?.remove();
121+
this.workspaceSelectionRing?.remove();
122+
if (this.oldWorkspaceResize) {
123+
this.workspace.resize = this.oldWorkspaceResize;
124+
}
125+
66126
// Remove the event listener that enables blocks on drag
67127
this.workspace.removeChangeListener(enableBlocksOnDrag);
68128

69129
this.workspace.setTheme(this.originalTheme);
70130

71131
this.navigationController.dispose();
132+
this.inputModeTracker.dispose();
72133
}
73134

74135
/**

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)