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.
This commit is contained in:
Koen Vlaswinkel
2024-01-16 14:24:09 +01:00
parent 6ec727a8a2
commit 4429385dfd
5 changed files with 291 additions and 1 deletions

View File

@@ -18,6 +18,7 @@ import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react";
import type { Option } from "./options";
import { findMatchingOptions } from "./options";
import { SuggestBoxItem } from "./SuggestBoxItem";
import { useOpenKey } from "./useOpenKey";
const Input = styled(VSCodeTextField)`
width: 430px;
@@ -125,6 +126,7 @@ export const SuggestBox = <T extends Option<T>>({
const focus = useFocus(context);
const role = useRole(context, { role: "listbox" });
const dismiss = useDismiss(context);
const openKey = useOpenKey(context);
const listNav = useListNavigation(context, {
listRef,
activeIndex,
@@ -134,7 +136,7 @@ export const SuggestBox = <T extends Option<T>>({
});
const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions(
[focus, role, dismiss, listNav],
[focus, role, dismiss, openKey, listNav],
);
const handleInput = useCallback(

View File

@@ -0,0 +1,26 @@
import { renderHook } from "@testing-library/react";
import { useEffectEvent } from "../useEffectEvent";
describe("useEffectEvent", () => {
it("does not change reference when changing the callback function", () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
const { result, rerender } = renderHook(
(callback) => useEffectEvent(callback),
{
initialProps: callback1,
},
);
const callbackResult = result.current;
rerender();
expect(result.current).toBe(callbackResult);
rerender(callback2);
expect(result.current).toBe(callbackResult);
});
});

View File

@@ -0,0 +1,194 @@
import type { KeyboardEvent } from "react";
import { renderHook } from "@testing-library/react";
import type { FloatingContext } from "@floating-ui/react";
import { mockedObject } from "../../../../../test/vscode-tests/utils/mocking.helpers";
import { useOpenKey } from "../useOpenKey";
describe("useOpenKey", () => {
const onOpenChange = jest.fn();
beforeEach(() => {
onOpenChange.mockReset();
});
const render = ({ open }: { open: boolean }) => {
const context = mockedObject<FloatingContext>({
open,
onOpenChange,
});
const { result } = renderHook(() => useOpenKey(context));
expect(result.current).toEqual({
reference: {
onKeyDown: expect.any(Function),
},
});
const onKeyDown = result.current.reference?.onKeyDown;
if (!onKeyDown) {
throw new Error("onKeyDown is undefined");
}
return {
onKeyDown,
};
};
const mockKeyboardEvent = ({
key = "",
altKey = false,
ctrlKey = false,
metaKey = false,
shiftKey = false,
preventDefault = jest.fn(),
}: Partial<KeyboardEvent>) =>
mockedObject<KeyboardEvent>({
key,
altKey,
ctrlKey,
metaKey,
shiftKey,
preventDefault,
});
const pressKey = (event: Parameters<typeof mockKeyboardEvent>[0]) => {
const { onKeyDown } = render({
open: false,
});
const keyboardEvent = mockKeyboardEvent(event);
onKeyDown(keyboardEvent);
return {
onKeyDown,
keyboardEvent,
};
};
it("opens when pressing Ctrl + Space and it is closed", () => {
const { keyboardEvent } = pressKey({
key: " ",
ctrlKey: true,
});
expect(keyboardEvent.preventDefault).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledTimes(1);
expect(onOpenChange).toHaveBeenCalledWith(true, keyboardEvent);
});
it("does not open when pressing Ctrl + Space and it is open", () => {
const { onKeyDown } = render({
open: true,
});
// Do not mock any properties to ensure that none of them are used.
const keyboardEvent = mockedObject<KeyboardEvent>({});
onKeyDown(keyboardEvent);
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not open when pressing Cmd + Space", () => {
pressKey({
key: " ",
metaKey: true,
});
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not open when pressing Ctrl + Shift + Space", () => {
pressKey({
key: " ",
ctrlKey: true,
shiftKey: true,
});
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not open when pressing Ctrl + Alt + Space", () => {
pressKey({
key: " ",
ctrlKey: true,
altKey: true,
});
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not open when pressing Ctrl + Cmd + Space", () => {
pressKey({
key: " ",
ctrlKey: true,
metaKey: true,
});
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not open when pressing Ctrl + Shift + Alt + Space", () => {
pressKey({
key: " ",
ctrlKey: true,
altKey: true,
shiftKey: true,
});
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not open when pressing Space", () => {
pressKey({
key: " ",
});
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not open when pressing Ctrl + Tab", () => {
pressKey({
key: "Tab",
ctrlKey: true,
});
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not open when pressing Ctrl + a letter", () => {
pressKey({
key: "a",
ctrlKey: true,
});
expect(onOpenChange).not.toHaveBeenCalled();
});
it("does not change reference when the context changes", () => {
const context = mockedObject<FloatingContext>({
open: false,
onOpenChange,
});
const { result, rerender } = renderHook((context) => useOpenKey(context), {
initialProps: context,
});
const firstOnKeyDown = result.current.reference?.onKeyDown;
expect(firstOnKeyDown).toBeDefined();
rerender(
mockedObject<FloatingContext>({
open: true,
onOpenChange: jest.fn(),
}),
);
const secondOnKeyDown = result.current.reference?.onKeyDown;
// test that useEffectEvent is used correctly and the reference doesn't change
expect(secondOnKeyDown).toBe(firstOnKeyDown);
});
});

View File

@@ -0,0 +1,23 @@
import { useCallback, useInsertionEffect, useRef } from "react";
// Copy of https://github.com/floating-ui/floating-ui/blob/5d025db1167e0bc13e7d386d7df2498b9edf2f8a/packages/react/src/hooks/utils/useEffectEvent.ts
// since it's not exported
/**
* Creates a reference to a callback that will never change in value. This will ensure that when a callback gets changed,
* no new reference to the callback will be created and thus no unnecessary re-renders will be triggered.
*
* @param callback The callback to call when the event is triggered.
*/
export function useEffectEvent<T extends (...args: any[]) => any>(callback: T) {
const ref = useRef<T>(callback);
useInsertionEffect(() => {
ref.current = callback;
});
return useCallback<(...args: Parameters<T>) => ReturnType<T>>(
(...args) => ref.current(...args),
[],
) as T;
}

View File

@@ -0,0 +1,45 @@
import type { KeyboardEvent } from "react";
import { useMemo } from "react";
import type {
ElementProps,
FloatingContext,
ReferenceType,
} from "@floating-ui/react";
import { isReactEvent } from "@floating-ui/react/utils";
import { useEffectEvent } from "./useEffectEvent";
/**
* Open the floating element when Ctrl+Space is pressed.
*/
export const useOpenKey = <RT extends ReferenceType = ReferenceType>(
context: FloatingContext<RT>,
): ElementProps => {
const { open, onOpenChange } = context;
const openOnOpenKey = useEffectEvent(
(event: KeyboardEvent<Element> | KeyboardEvent) => {
if (open) {
return;
}
if (
event.key === " " &&
event.ctrlKey &&
!event.altKey &&
!event.metaKey &&
!event.shiftKey
) {
event.preventDefault();
onOpenChange(true, isReactEvent(event) ? event.nativeEvent : event);
}
},
);
return useMemo((): ElementProps => {
return {
reference: {
onKeyDown: openOnOpenKey,
},
};
}, [openOnOpenKey]);
};