Skip to content

Commit a1b4cf0

Browse files
committed
frontend/aria: hotkey nav
1 parent 0a92469 commit a1b4cf0

31 files changed

+3104
-12
lines changed

src/dev/ARIA.md

Lines changed: 625 additions & 5 deletions
Large diffs are not rendered by default.

src/packages/frontend/account/account-preferences-appearance.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import {
2727
get_dark_mode_config,
2828
} from "./dark-mode";
2929
import { EditorSettingsColorScheme } from "./editor-settings/color-schemes";
30+
import { HotkeyDelayTest } from "./hotkey-delay-test";
31+
import { HotkeySelector } from "./hotkey-selector";
3032
import { I18NSelector, I18N_MESSAGE, I18N_TITLE } from "./i18n-selector";
3133
import { OtherSettings } from "./other-settings";
3234
import { TerminalSettings } from "./terminal-settings";
@@ -241,6 +243,78 @@ export function AccountPreferencesAppearance() {
241243
</HelpIcon>
242244
</div>
243245
</LabeledRow>
246+
<LabeledRow
247+
label={
248+
<>
249+
<Icon name="flash" /> Quick Navigation Hotkey
250+
</>
251+
}
252+
>
253+
<HotkeySelector
254+
value={other_settings.get("quick_nav_hotkey") ?? "shift+shift"}
255+
onChange={(value) => on_change("quick_nav_hotkey", value)}
256+
style={{ width: 200 }}
257+
/>
258+
</LabeledRow>
259+
{(other_settings.get("quick_nav_hotkey") ?? "shift+shift") ===
260+
"shift+shift" && (
261+
<LabeledRow
262+
label={
263+
<>
264+
<Icon name="clock" /> Hotkey Delay (milliseconds)
265+
</>
266+
}
267+
>
268+
<div style={{ display: "flex", gap: 16, alignItems: "stretch" }}>
269+
<div
270+
style={{
271+
flex: "1 1 auto",
272+
display: "flex",
273+
alignItems: "center",
274+
}}
275+
>
276+
<Slider
277+
min={100}
278+
max={800}
279+
step={100}
280+
value={other_settings.get("quick_nav_hotkey_delay") ?? 300}
281+
onChange={(value) =>
282+
on_change("quick_nav_hotkey_delay", value)
283+
}
284+
style={{ flex: 1 }}
285+
marks={Object.fromEntries(
286+
Array.from({ length: 8 }, (_, i) => (i + 1) * 100).map(
287+
(ms) => [ms, `${ms}ms`],
288+
),
289+
)}
290+
/>
291+
<span
292+
style={{
293+
minWidth: 60,
294+
textAlign: "right",
295+
fontWeight: 500,
296+
color: COLORS.GRAY_M,
297+
marginLeft: 12,
298+
}}
299+
>
300+
{other_settings.get("quick_nav_hotkey_delay") ?? 300}ms
301+
</span>
302+
</div>
303+
<div
304+
style={{
305+
flex: "0 1 auto",
306+
display: "flex",
307+
flexDirection: "column",
308+
gap: 8,
309+
}}
310+
>
311+
<HotkeyDelayTest
312+
delayMs={other_settings.get("quick_nav_hotkey_delay") ?? 300}
313+
/>
314+
</div>
315+
</div>
316+
</LabeledRow>
317+
)}
244318
<Switch
245319
checked={!!other_settings.get("auto_focus")}
246320
onChange={(e) => on_change("auto_focus", e.target.checked)}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import { Button } from "antd";
7+
import React, { useEffect, useRef, useState } from "react";
8+
import { COLORS } from "@cocalc/util/theme";
9+
import { useAppContext } from "@cocalc/frontend/app/context";
10+
11+
interface HotkeyDelayTestProps {
12+
delayMs: number;
13+
}
14+
15+
/**
16+
* Test component for double-Shift hotkey detection
17+
*
18+
* Allows users to verify that their hotkey delay setting works correctly.
19+
* When focused, it listens to Shift presses and indicates when a double-Shift
20+
* is detected within the configured delay.
21+
*/
22+
export const HotkeyDelayTest: React.FC<HotkeyDelayTestProps> = ({
23+
delayMs,
24+
}) => {
25+
const { setBlockShiftShiftHotkey } = useAppContext();
26+
const [isActive, setIsActive] = useState(false);
27+
const [testResult, setTestResult] = useState<
28+
"waiting" | "detected" | "failed"
29+
>("waiting");
30+
const lastShiftTimeRef = useRef<number>(0);
31+
const buttonRef = useRef<HTMLButtonElement>(null);
32+
33+
useEffect(() => {
34+
// Set block flag when test becomes active, unblock when inactive
35+
setBlockShiftShiftHotkey?.(isActive);
36+
37+
if (!isActive) {
38+
return;
39+
}
40+
41+
const handleKeyDown = (e: KeyboardEvent) => {
42+
// Only listen for Shift key
43+
if (e.key !== "Shift") {
44+
return;
45+
}
46+
47+
const now = Date.now();
48+
const timeSinceLastShift = now - lastShiftTimeRef.current;
49+
50+
// Check if this Shift is within delayMs of the last one
51+
if (timeSinceLastShift <= delayMs && timeSinceLastShift > 0) {
52+
// Double Shift detected! Prevent it from bubbling to the global detector
53+
e.preventDefault();
54+
e.stopPropagation();
55+
56+
setTestResult("detected");
57+
lastShiftTimeRef.current = 0;
58+
59+
// Reset after 1 second to show success state
60+
const timer = setTimeout(() => {
61+
setTestResult("waiting");
62+
}, 1000);
63+
64+
return () => clearTimeout(timer);
65+
}
66+
67+
// Record this Shift press
68+
lastShiftTimeRef.current = now;
69+
70+
// Reset counter after delayMs * 2
71+
const resetTimer = setTimeout(() => {
72+
lastShiftTimeRef.current = 0;
73+
}, delayMs * 2);
74+
75+
return () => clearTimeout(resetTimer);
76+
};
77+
78+
// Use capture phase to catch Shift key early
79+
window.addEventListener("keydown", handleKeyDown, true);
80+
81+
return () => {
82+
window.removeEventListener("keydown", handleKeyDown, true);
83+
// Ensure we unblock when component unmounts or isActive becomes false
84+
setBlockShiftShiftHotkey?.(false);
85+
};
86+
}, [isActive, delayMs, setBlockShiftShiftHotkey]);
87+
88+
const getButtonStyle = (): React.CSSProperties => {
89+
const baseStyle: React.CSSProperties = {
90+
fontWeight: 500,
91+
minWidth: 100,
92+
};
93+
94+
switch (testResult) {
95+
case "waiting":
96+
return {
97+
...baseStyle,
98+
backgroundColor: isActive ? COLORS.ANTD_BLUE : COLORS.GRAY_LL,
99+
color: isActive ? "white" : COLORS.GRAY_M,
100+
borderColor: isActive ? COLORS.ANTD_BLUE : COLORS.GRAY_L,
101+
};
102+
case "detected":
103+
return {
104+
...baseStyle,
105+
backgroundColor: COLORS.ANTD_SUCCESS_GREEN,
106+
color: "white",
107+
borderColor: COLORS.ANTD_SUCCESS_GREEN,
108+
};
109+
case "failed":
110+
return {
111+
...baseStyle,
112+
backgroundColor: COLORS.ANTD_ERROR_RED,
113+
color: "white",
114+
borderColor: COLORS.ANTD_ERROR_RED,
115+
};
116+
}
117+
};
118+
119+
const getButtonLabel = (): string => {
120+
if (!isActive) {
121+
return "Test Hotkey";
122+
}
123+
switch (testResult) {
124+
case "waiting":
125+
return "Press Shift Twice";
126+
case "detected":
127+
return "✓ Detected!";
128+
case "failed":
129+
return "✗ Try Again";
130+
}
131+
};
132+
133+
return (
134+
<div
135+
style={{
136+
display: "flex",
137+
flexDirection: "column",
138+
gap: 8,
139+
alignItems: "flex-start",
140+
}}
141+
>
142+
<Button
143+
ref={buttonRef}
144+
style={getButtonStyle()}
145+
onClick={() => {
146+
setIsActive(!isActive);
147+
setTestResult("waiting");
148+
lastShiftTimeRef.current = 0;
149+
}}
150+
>
151+
{getButtonLabel()}
152+
</Button>
153+
<span style={{ color: COLORS.GRAY_M, fontSize: 12 }}>
154+
{isActive
155+
? `Press Shift twice within ${delayMs}ms`
156+
: "Click to test hotkey detection"}
157+
</span>
158+
</div>
159+
);
160+
};
161+
162+
export default HotkeyDelayTest;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/*
2+
* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.
3+
* License: MS-RSL – see LICENSE.md for details
4+
*/
5+
6+
import { Select, SelectProps } from "antd";
7+
import { IS_MACOS } from "@cocalc/frontend/feature";
8+
9+
export type HotkeyOption =
10+
| "shift+shift"
11+
| "alt+shift+h"
12+
| "alt+shift+space"
13+
| "disabled";
14+
15+
interface HotkeySelectorProps
16+
extends Omit<SelectProps, "options" | "onChange"> {
17+
value?: HotkeyOption;
18+
onChange?: (hotkey: HotkeyOption) => void;
19+
}
20+
21+
/**
22+
* A selector for choosing the global hotkey to open quick navigation dialog
23+
*/
24+
export function HotkeySelector({
25+
value,
26+
onChange,
27+
...props
28+
}: HotkeySelectorProps) {
29+
const altShiftH = IS_MACOS ? "Cmd+Shift+H" : "Alt+Shift+H";
30+
const altShiftSpace = IS_MACOS ? "Cmd+Shift+Space" : "Alt+Shift+Space";
31+
32+
const options = [
33+
{
34+
value: "shift+shift" as HotkeyOption,
35+
label: "Shift, Shift (double tap)",
36+
},
37+
{
38+
value: "alt+shift+h" as HotkeyOption,
39+
label: `${altShiftH}`,
40+
},
41+
{
42+
value: "alt+shift+space" as HotkeyOption,
43+
label: `${altShiftSpace}`,
44+
},
45+
{
46+
value: "disabled" as HotkeyOption,
47+
label: "<disabled>",
48+
},
49+
];
50+
51+
return (
52+
<Select
53+
value={value ?? "shift+shift"}
54+
onChange={onChange}
55+
options={options}
56+
placeholder="Select hotkey..."
57+
popupMatchSelectWidth={false}
58+
{...props}
59+
/>
60+
);
61+
}

src/packages/frontend/app/context.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ export function useAppContextProvider(): AppState {
2626

2727
const [narrow, setNarrow] = useState<boolean>(isNarrow());
2828

29+
const [blockShiftShiftHotkey, setBlockShiftShiftHotkey] =
30+
useState<boolean>(false);
31+
2932
function update() {
3033
setNarrow(isNarrow());
3134
if (window.innerWidth != pageWidthPx) {
@@ -74,6 +77,8 @@ export function useAppContextProvider(): AppState {
7477
pageWidthPx,
7578
pageStyle,
7679
showActBarLabels,
80+
blockShiftShiftHotkey,
81+
setBlockShiftShiftHotkey,
7782
};
7883
}
7984

0 commit comments

Comments
 (0)