Skip to content

Commit 4429385

Browse files
committed
Open suggest box with Ctrl + Space
This adds a keyboard shortcut to open the suggest box when it's closed. This matches the behavior in VS Code itself.
1 parent 6ec727a commit 4429385

File tree

5 files changed

+291
-1
lines changed

5 files changed

+291
-1
lines changed

extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react";
1818
import type { Option } from "./options";
1919
import { findMatchingOptions } from "./options";
2020
import { SuggestBoxItem } from "./SuggestBoxItem";
21+
import { useOpenKey } from "./useOpenKey";
2122

2223
const Input = styled(VSCodeTextField)`
2324
width: 430px;
@@ -125,6 +126,7 @@ export const SuggestBox = <T extends Option<T>>({
125126
const focus = useFocus(context);
126127
const role = useRole(context, { role: "listbox" });
127128
const dismiss = useDismiss(context);
129+
const openKey = useOpenKey(context);
128130
const listNav = useListNavigation(context, {
129131
listRef,
130132
activeIndex,
@@ -134,7 +136,7 @@ export const SuggestBox = <T extends Option<T>>({
134136
});
135137

136138
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
137-
[focus, role, dismiss, listNav],
139+
[focus, role, dismiss, openKey, listNav],
138140
);
139141

140142
const handleInput = useCallback(
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { renderHook } from "@testing-library/react";
2+
import { useEffectEvent } from "../useEffectEvent";
3+
4+
describe("useEffectEvent", () => {
5+
it("does not change reference when changing the callback function", () => {
6+
const callback1 = jest.fn();
7+
const callback2 = jest.fn();
8+
9+
const { result, rerender } = renderHook(
10+
(callback) => useEffectEvent(callback),
11+
{
12+
initialProps: callback1,
13+
},
14+
);
15+
16+
const callbackResult = result.current;
17+
18+
rerender();
19+
20+
expect(result.current).toBe(callbackResult);
21+
22+
rerender(callback2);
23+
24+
expect(result.current).toBe(callbackResult);
25+
});
26+
});
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import type { KeyboardEvent } from "react";
2+
import { renderHook } from "@testing-library/react";
3+
import type { FloatingContext } from "@floating-ui/react";
4+
import { mockedObject } from "../../../../../test/vscode-tests/utils/mocking.helpers";
5+
import { useOpenKey } from "../useOpenKey";
6+
7+
describe("useOpenKey", () => {
8+
const onOpenChange = jest.fn();
9+
10+
beforeEach(() => {
11+
onOpenChange.mockReset();
12+
});
13+
14+
const render = ({ open }: { open: boolean }) => {
15+
const context = mockedObject<FloatingContext>({
16+
open,
17+
onOpenChange,
18+
});
19+
20+
const { result } = renderHook(() => useOpenKey(context));
21+
22+
expect(result.current).toEqual({
23+
reference: {
24+
onKeyDown: expect.any(Function),
25+
},
26+
});
27+
28+
const onKeyDown = result.current.reference?.onKeyDown;
29+
if (!onKeyDown) {
30+
throw new Error("onKeyDown is undefined");
31+
}
32+
33+
return {
34+
onKeyDown,
35+
};
36+
};
37+
38+
const mockKeyboardEvent = ({
39+
key = "",
40+
altKey = false,
41+
ctrlKey = false,
42+
metaKey = false,
43+
shiftKey = false,
44+
preventDefault = jest.fn(),
45+
}: Partial<KeyboardEvent>) =>
46+
mockedObject<KeyboardEvent>({
47+
key,
48+
altKey,
49+
ctrlKey,
50+
metaKey,
51+
shiftKey,
52+
preventDefault,
53+
});
54+
55+
const pressKey = (event: Parameters<typeof mockKeyboardEvent>[0]) => {
56+
const { onKeyDown } = render({
57+
open: false,
58+
});
59+
60+
const keyboardEvent = mockKeyboardEvent(event);
61+
62+
onKeyDown(keyboardEvent);
63+
64+
return {
65+
onKeyDown,
66+
keyboardEvent,
67+
};
68+
};
69+
70+
it("opens when pressing Ctrl + Space and it is closed", () => {
71+
const { keyboardEvent } = pressKey({
72+
key: " ",
73+
ctrlKey: true,
74+
});
75+
76+
expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(1);
77+
expect(onOpenChange).toHaveBeenCalledTimes(1);
78+
expect(onOpenChange).toHaveBeenCalledWith(true, keyboardEvent);
79+
});
80+
81+
it("does not open when pressing Ctrl + Space and it is open", () => {
82+
const { onKeyDown } = render({
83+
open: true,
84+
});
85+
86+
// Do not mock any properties to ensure that none of them are used.
87+
const keyboardEvent = mockedObject<KeyboardEvent>({});
88+
89+
onKeyDown(keyboardEvent);
90+
91+
expect(onOpenChange).not.toHaveBeenCalled();
92+
});
93+
94+
it("does not open when pressing Cmd + Space", () => {
95+
pressKey({
96+
key: " ",
97+
metaKey: true,
98+
});
99+
100+
expect(onOpenChange).not.toHaveBeenCalled();
101+
});
102+
103+
it("does not open when pressing Ctrl + Shift + Space", () => {
104+
pressKey({
105+
key: " ",
106+
ctrlKey: true,
107+
shiftKey: true,
108+
});
109+
110+
expect(onOpenChange).not.toHaveBeenCalled();
111+
});
112+
113+
it("does not open when pressing Ctrl + Alt + Space", () => {
114+
pressKey({
115+
key: " ",
116+
ctrlKey: true,
117+
altKey: true,
118+
});
119+
120+
expect(onOpenChange).not.toHaveBeenCalled();
121+
});
122+
123+
it("does not open when pressing Ctrl + Cmd + Space", () => {
124+
pressKey({
125+
key: " ",
126+
ctrlKey: true,
127+
metaKey: true,
128+
});
129+
130+
expect(onOpenChange).not.toHaveBeenCalled();
131+
});
132+
133+
it("does not open when pressing Ctrl + Shift + Alt + Space", () => {
134+
pressKey({
135+
key: " ",
136+
ctrlKey: true,
137+
altKey: true,
138+
shiftKey: true,
139+
});
140+
141+
expect(onOpenChange).not.toHaveBeenCalled();
142+
});
143+
144+
it("does not open when pressing Space", () => {
145+
pressKey({
146+
key: " ",
147+
});
148+
149+
expect(onOpenChange).not.toHaveBeenCalled();
150+
});
151+
152+
it("does not open when pressing Ctrl + Tab", () => {
153+
pressKey({
154+
key: "Tab",
155+
ctrlKey: true,
156+
});
157+
158+
expect(onOpenChange).not.toHaveBeenCalled();
159+
});
160+
161+
it("does not open when pressing Ctrl + a letter", () => {
162+
pressKey({
163+
key: "a",
164+
ctrlKey: true,
165+
});
166+
167+
expect(onOpenChange).not.toHaveBeenCalled();
168+
});
169+
170+
it("does not change reference when the context changes", () => {
171+
const context = mockedObject<FloatingContext>({
172+
open: false,
173+
onOpenChange,
174+
});
175+
176+
const { result, rerender } = renderHook((context) => useOpenKey(context), {
177+
initialProps: context,
178+
});
179+
180+
const firstOnKeyDown = result.current.reference?.onKeyDown;
181+
expect(firstOnKeyDown).toBeDefined();
182+
183+
rerender(
184+
mockedObject<FloatingContext>({
185+
open: true,
186+
onOpenChange: jest.fn(),
187+
}),
188+
);
189+
190+
const secondOnKeyDown = result.current.reference?.onKeyDown;
191+
// test that useEffectEvent is used correctly and the reference doesn't change
192+
expect(secondOnKeyDown).toBe(firstOnKeyDown);
193+
});
194+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { useCallback, useInsertionEffect, useRef } from "react";
2+
3+
// Copy of https://github.com/floating-ui/floating-ui/blob/5d025db1167e0bc13e7d386d7df2498b9edf2f8a/packages/react/src/hooks/utils/useEffectEvent.ts
4+
// since it's not exported
5+
6+
/**
7+
* Creates a reference to a callback that will never change in value. This will ensure that when a callback gets changed,
8+
* no new reference to the callback will be created and thus no unnecessary re-renders will be triggered.
9+
*
10+
* @param callback The callback to call when the event is triggered.
11+
*/
12+
export function useEffectEvent<T extends (...args: any[]) => any>(callback: T) {
13+
const ref = useRef<T>(callback);
14+
15+
useInsertionEffect(() => {
16+
ref.current = callback;
17+
});
18+
19+
return useCallback<(...args: Parameters<T>) => ReturnType<T>>(
20+
(...args) => ref.current(...args),
21+
[],
22+
) as T;
23+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { KeyboardEvent } from "react";
2+
import { useMemo } from "react";
3+
import type {
4+
ElementProps,
5+
FloatingContext,
6+
ReferenceType,
7+
} from "@floating-ui/react";
8+
import { isReactEvent } from "@floating-ui/react/utils";
9+
import { useEffectEvent } from "./useEffectEvent";
10+
11+
/**
12+
* Open the floating element when Ctrl+Space is pressed.
13+
*/
14+
export const useOpenKey = <RT extends ReferenceType = ReferenceType>(
15+
context: FloatingContext<RT>,
16+
): ElementProps => {
17+
const { open, onOpenChange } = context;
18+
19+
const openOnOpenKey = useEffectEvent(
20+
(event: KeyboardEvent<Element> | KeyboardEvent) => {
21+
if (open) {
22+
return;
23+
}
24+
25+
if (
26+
event.key === " " &&
27+
event.ctrlKey &&
28+
!event.altKey &&
29+
!event.metaKey &&
30+
!event.shiftKey
31+
) {
32+
event.preventDefault();
33+
onOpenChange(true, isReactEvent(event) ? event.nativeEvent : event);
34+
}
35+
},
36+
);
37+
38+
return useMemo((): ElementProps => {
39+
return {
40+
reference: {
41+
onKeyDown: openOnOpenKey,
42+
},
43+
};
44+
}, [openOnOpenKey]);
45+
};

0 commit comments

Comments
 (0)