diff --git a/extensions/ql-vscode/package-lock.json b/extensions/ql-vscode/package-lock.json index db41e1b8a..e62b4f67c 100644 --- a/extensions/ql-vscode/package-lock.json +++ b/extensions/ql-vscode/package-lock.json @@ -10,6 +10,7 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { + "@floating-ui/react": "^0.26.5", "@octokit/plugin-retry": "^6.0.1", "@octokit/rest": "^20.0.2", "@vscode/codicons": "^0.0.35", @@ -2965,42 +2966,57 @@ "dev": true }, "node_modules/@floating-ui/core": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz", - "integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==", - "dev": true, + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.3.tgz", + "integrity": "sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q==", "dependencies": { - "@floating-ui/utils": "^0.1.3" + "@floating-ui/utils": "^0.2.0" } }, "node_modules/@floating-ui/dom": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz", - "integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==", - "dev": true, + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.4.tgz", + "integrity": "sha512-jByEsHIY+eEdCjnTVu+E3ephzTOzkQ8hgUfGwos+bg7NlH33Zc5uO+QHz1mrQUOgIKKDD1RtS201P9NvAfq3XQ==", "dependencies": { - "@floating-ui/core": "^1.4.2", - "@floating-ui/utils": "^0.1.3" + "@floating-ui/core": "^1.5.3", + "@floating-ui/utils": "^0.2.0" } }, - "node_modules/@floating-ui/react-dom": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz", - "integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==", - "dev": true, + "node_modules/@floating-ui/react": { + "version": "0.26.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.5.tgz", + "integrity": "sha512-LJeSQa+yOwV0Tdpc/C3Vr92QMrwRqRMTk4yOwsRJKc57x3Lcw317GE0EV+ECM7+Z89yEAPBe7nzbDEWfkWCrBA==", "dependencies": { - "@floating-ui/dom": "^1.5.1" + "@floating-ui/react-dom": "^2.0.5", + "@floating-ui/utils": "^0.2.0", + "tabbable": "^6.0.1" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, + "node_modules/@floating-ui/react-dom": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.5.tgz", + "integrity": "sha512-UsBK30Bg+s6+nsgblXtZmwHhgS2vmbuQK22qgt2pTQM6M3X6H1+cQcLXqgRY3ihVLcZJE6IvqDQozhsnIVqK/Q==", + "dependencies": { + "@floating-ui/dom": "^1.5.4" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/react/node_modules/tabbable": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", + "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" + }, "node_modules/@floating-ui/utils": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz", - "integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==", - "dev": true + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz", + "integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q==" }, "node_modules/@github/browserslist-config": { "version": "1.0.0", diff --git a/extensions/ql-vscode/package.json b/extensions/ql-vscode/package.json index cb43a9daa..5227fd8aa 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -1928,6 +1928,7 @@ "prepare": "cd ../.. && husky install" }, "dependencies": { + "@floating-ui/react": "^0.26.5", "@octokit/plugin-retry": "^6.0.1", "@octokit/rest": "^20.0.2", "@vscode/codicons": "^0.0.35", diff --git a/extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx b/extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx new file mode 100644 index 000000000..ce4c2f5c3 --- /dev/null +++ b/extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx @@ -0,0 +1,146 @@ +import type { Meta, StoryFn } from "@storybook/react"; + +import { styled } from "styled-components"; + +import { Codicon } from "../../view/common"; +import { SuggestBox as SuggestBoxComponent } from "../../view/common/SuggestBox/SuggestBox"; +import { useCallback, useState } from "react"; + +export default { + title: "Suggest Box", + component: SuggestBoxComponent, +} as Meta; + +type StoryOption = { + label: string; + icon: string; + details?: string; + value: string; + followup?: StoryOption[]; +}; + +const Template: StoryFn> = (args) => { + const [value, setValue] = useState(""); + + const handleChange = useCallback( + (value: string) => { + args.onChange(value); + setValue(value); + }, + [args], + ); + + return ( + + {...args} + value={value} + onChange={handleChange} + /> + ); +}; + +const Icon = styled(Codicon)` + margin-right: 4px; + color: var(--vscode-symbolIcon-fieldForeground); + font-size: 16px; +`; + +const suggestedOptions: StoryOption[] = [ + { + label: "Argument[self]", + icon: "symbol-class", + details: "sqlite3.SQLite3::Database", + value: "Argument[self]", + }, + { + label: "Argument[0]", + icon: "symbol-parameter", + details: "name", + value: "Argument[0]", + followup: [ + { + label: "Element[0]", + icon: "symbol-field", + value: "Argument[0].Element[0]", + details: "first character", + }, + { + label: "Element[1]", + icon: "symbol-field", + value: "Argument[0].Element[1]", + details: "second character", + }, + { + label: "Element[any]", + icon: "symbol-field", + value: "Argument[0].Element[any]", + details: "any character", + }, + ], + }, + { + label: "Argument[1]", + icon: "symbol-parameter", + details: "arity", + value: "Argument[1]", + }, + { + label: "Argument[text_rep:]", + icon: "symbol-parameter", + details: "text_rep:", + value: "Argument[text_rep:]", + }, + { + label: "Argument[block]", + icon: "symbol-parameter", + details: "&block", + value: "Argument[block]", + followup: [ + { + label: "Parameter[0]", + icon: "symbol-parameter", + value: "Argument[block].Parameter[0]", + details: "val", + followup: [ + { + label: "Element[:query]", + icon: "symbol-key", + value: "Argument[block].Parameter[0].Element[:query]", + }, + { + label: "Element[:parameters]", + icon: "symbol-key", + value: "Argument[block].Parameter[0].Element[:parameters]", + }, + ], + }, + { + label: "Parameter[1]", + icon: "symbol-parameter", + value: "Argument[block].Parameter[1]", + details: "context", + followup: [ + { + label: "Field[@query]", + icon: "symbol-field", + value: "Argument[block].Parameter[1].Field[@query]", + }, + ], + }, + ], + }, + { + label: "ReturnValue", + icon: "symbol-variable", + details: undefined, + value: "ReturnValue", + }, +]; + +export const AccessPath = Template.bind({}); +AccessPath.args = { + options: suggestedOptions, + parseValueToTokens: (value: string) => value.split("."), + getIcon: (option: StoryOption) => , + getDetails: (option: StoryOption) => option.details, +}; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx new file mode 100644 index 000000000..0e338892f --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx @@ -0,0 +1,236 @@ +import type { FormEvent, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + autoUpdate, + flip, + FloatingFocusManager, + FloatingPortal, + size, + useDismiss, + useFloating, + useFocus, + useInteractions, + useListNavigation, + useRole, +} from "@floating-ui/react"; +import { styled } from "styled-components"; +import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react"; +import type { Option } from "./options"; +import { findMatchingOptions } from "./options"; +import { SuggestBoxItem } from "./SuggestBoxItem"; + +const Input = styled(VSCodeTextField)` + width: 430px; + + font-family: var(--vscode-editor-font-family); +`; + +const Container = styled.div` + width: 430px; + display: flex; + flex-direction: column; + border-radius: 3px; + + background-color: var(--vscode-editorSuggestWidget-background); + border: 1px solid var(--vscode-editorSuggestWidget-border); + + user-select: none; +`; + +const ListContainer = styled(Container)` + font-size: 95%; +`; + +const NoSuggestionsContainer = styled(Container)` + padding-top: 2px; + padding-bottom: 2px; +`; + +const NoSuggestionsText = styled.div` + padding-left: 22px; +`; + +export type SuggestBoxProps> = { + value?: string; + onChange: (value: string) => void; + options: T[]; + + /** + * Parse the value into tokens that can be used to match against the options. The + * tokens will be passed to {@link findMatchingOptions}. + * @param value The user-entered value to parse. + */ + parseValueToTokens: (value: string) => string[]; + + /** + * Get the icon to display for an option. + * @param option The option to get the icon for. + */ + getIcon?: (option: T) => ReactNode | undefined; + /** + * Get the details text to display for an option. + * @param option The option to get the details for. + */ + getDetails?: (option: T) => ReactNode | undefined; + + disabled?: boolean; + + "aria-label"?: string; + + /** + * Can be used to render a different component for the input. This is used + * in testing to use default HTML components rather than the VSCodeTextField + * for easier testing. + * @param props The props returned by `getReferenceProps` of {@link useInteractions} + */ + renderInputComponent?: (props: Record) => ReactNode; +}; + +export const SuggestBox = >({ + value = "", + onChange, + options, + parseValueToTokens, + getIcon, + getDetails, + disabled, + "aria-label": ariaLabel, + renderInputComponent = (props) => , +}: SuggestBoxProps) => { + const [isOpen, setIsOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + + const listRef = useRef>([]); + + const { refs, floatingStyles, context } = useFloating({ + whileElementsMounted: autoUpdate, + open: isOpen, + onOpenChange: setIsOpen, + placement: "bottom-start", + middleware: [ + // Flip when the popover is too close to the bottom of the screen + flip({ padding: 10 }), + // Resize the popover to be fill the available height + size({ + apply({ availableHeight, elements }) { + Object.assign(elements.floating.style, { + maxHeight: `${availableHeight}px`, + }); + }, + padding: 10, + }), + ], + }); + + const focus = useFocus(context); + const role = useRole(context, { role: "listbox" }); + const dismiss = useDismiss(context); + const listNav = useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + virtual: true, + loop: true, + }); + + const { getReferenceProps, getFloatingProps, getItemProps } = useInteractions( + [focus, role, dismiss, listNav], + ); + + const handleInput = useCallback( + (event: FormEvent) => { + const value = event.currentTarget.value; + onChange(value); + setIsOpen(true); + setActiveIndex(0); + }, + [onChange], + ); + + const suggestionItems = useMemo(() => { + return findMatchingOptions(options, parseValueToTokens(value)); + }, [options, value, parseValueToTokens]); + + useEffect(() => { + if (disabled) { + setIsOpen(false); + } + }, [disabled]); + + return ( + <> + {renderInputComponent( + getReferenceProps({ + ref: refs.setReference, + value, + onInput: handleInput, + "aria-autocomplete": "list", + "aria-label": ariaLabel, + onKeyDown: (event) => { + // When the user presses the enter key, select the active item + if ( + event.key === "Enter" && + activeIndex !== null && + suggestionItems[activeIndex] + ) { + onChange(suggestionItems[activeIndex].value); + setActiveIndex(null); + setIsOpen(false); + } + }, + disabled, + }), + )} + {isOpen && ( + + {value && suggestionItems.length === 0 && ( + + No suggestions. + + )} + {suggestionItems.length > 0 && ( + + + {suggestionItems.map((item, index) => ( + + ))} + + + )} + + )} + + ); +}; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBoxItem.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBoxItem.tsx new file mode 100644 index 000000000..1693d46f1 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBoxItem.tsx @@ -0,0 +1,86 @@ +import type { HTMLProps, ReactNode } from "react"; +import { forwardRef } from "react"; +import { useId } from "@floating-ui/react"; +import { styled } from "styled-components"; + +const Container = styled.div<{ $active: boolean }>` + display: flex; + box-sizing: border-box; + padding-right: 10px; + background-repeat: no-repeat; + background-position: 2px 2px; + white-space: nowrap; + cursor: pointer; + touch-action: none; + padding-left: 2px; + + font-family: var(--vscode-editor-font-family); + + color: ${(props) => + props.$active + ? "var(--vscode-editorSuggestWidget-selectedForeground)" + : "var(--vscode-editorSuggestWidget-foreground)"}; + background-color: ${(props) => + props.$active + ? "var(--vscode-editorSuggestWidget-selectedBackground)" + : "transparent"}; +`; + +const LabelContainer = styled.div` + flex: 1; + display: flex; + overflow: hidden; + white-space: pre; + justify-content: space-between; + align-items: center; +`; + +const Label = styled.span` + flex-shrink: 1; + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; +`; + +const DetailsLabel = styled.span` + overflow: hidden; + flex-shrink: 4; + max-width: 70%; + + font-size: 85%; + margin-left: 1.1em; + opacity: 0.7; + text-overflow: ellipsis; + white-space: nowrap; +`; + +type Props = { + active: boolean; + icon?: ReactNode; + labelText: ReactNode; + details?: ReactNode; +}; + +export const SuggestBoxItem = forwardRef< + HTMLDivElement, + Props & HTMLProps +>(({ children, active, icon, labelText, details, ...props }, ref) => { + const id = useId(); + return ( + + {icon} + + + {details && {details}} + + + ); +}); +SuggestBoxItem.displayName = "SuggestBoxItem"; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx new file mode 100644 index 000000000..21225f898 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/__tests__/SuggestBox.test.tsx @@ -0,0 +1,240 @@ +import { render as reactRender, screen } from "@testing-library/react"; +import type { SuggestBoxProps } from "../SuggestBox"; +import { SuggestBox } from "../SuggestBox"; +import { userEvent } from "@testing-library/user-event"; + +type TestOption = { + label: string; + value: string; + followup?: TestOption[]; +}; + +const options: TestOption[] = [ + { + label: "Argument[self]", + value: "Argument[self]", + }, + { + label: "Argument[0]", + value: "Argument[0]", + followup: [ + { + label: "Element[0]", + value: "Argument[0].Element[0]", + }, + { + label: "Element[1]", + value: "Argument[0].Element[1]", + }, + { + label: "Element[any]", + value: "Argument[0].Element[any]", + }, + ], + }, + { + label: "Argument[1]", + value: "Argument[1]", + }, + { + label: "Argument[text_rep:]", + value: "Argument[text_rep:]", + }, + { + label: "Argument[block]", + value: "Argument[block]", + followup: [ + { + label: "Parameter[0]", + value: "Argument[block].Parameter[0]", + followup: [ + { + label: "Element[:query]", + value: "Argument[block].Parameter[0].Element[:query]", + }, + { + label: "Element[:parameters]", + value: "Argument[block].Parameter[0].Element[:parameters]", + }, + ], + }, + { + label: "Parameter[1]", + value: "Argument[block].Parameter[1]", + followup: [ + { + label: "Field[@query]", + value: "Argument[block].Parameter[1].Field[@query]", + }, + ], + }, + ], + }, + { + label: "ReturnValue", + value: "ReturnValue", + }, +]; + +describe("SuggestBox", () => { + const onChange = jest.fn(); + const parseValueToTokens = jest.fn(); + const render = (props?: Partial>) => + reactRender( + } + {...props} + />, + ); + + beforeEach(() => { + onChange.mockReset(); + parseValueToTokens + .mockReset() + .mockImplementation((value: string) => value.split(".")); + }); + + it("does not render the options by default", () => { + render(); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("renders the options after clicking on the text field", async () => { + render(); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(options.length); + }); + + it("calls onChange after entering text", async () => { + render({ + value: "Argument[block]", + }); + + await userEvent.type(screen.getByRole("combobox"), "."); + + expect(onChange).toHaveBeenCalledWith("Argument[block]."); + }); + + it("calls onChange after clearing text", async () => { + render({ + value: "Argument[block].", + }); + + await userEvent.clear(screen.getByRole("combobox")); + + expect(onChange).toHaveBeenCalledWith(""); + }); + + it("renders matching options with a single token", async () => { + render({ + value: "block", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getByRole("option")).toHaveTextContent("Argument[block]"); + }); + + it("renders followup options with a token and an empty token", async () => { + render({ + value: "Argument[block].", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(2); + }); + + it("renders matching followup options with two tokens", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getByRole("option")).toHaveTextContent("Parameter[1]"); + }); + + it("closes the options when selecting an option", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{Enter}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("shows no suggestions with no matching followup options", async () => { + render({ + value: "Argument[block].block", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + expect(screen.getByText("No suggestions.")).toBeInTheDocument(); + }); + + it("can navigate the options using the keyboard", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard( + "{ArrowDown}{ArrowDown}{ArrowUp}{ArrowDown}{ArrowDown}{Enter}", + ); + + expect(onChange).toHaveBeenCalledWith("Argument[text_rep:]"); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("can use loop navigation when using the keyboard", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{ArrowUp}{ArrowUp}{Enter}"); + + expect(onChange).toHaveBeenCalledWith("Argument[block]"); + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("can close the options using escape", async () => { + render({ + value: "", + }); + + await userEvent.click(screen.getByRole("combobox")); + + expect(screen.getAllByRole("option")).toHaveLength(options.length); + + await userEvent.keyboard("{Escape}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + }); + + it("opens the options when using backspace on a selected option", async () => { + render({ + value: "Argument[block].1", + }); + + await userEvent.click(screen.getByRole("combobox")); + await userEvent.keyboard("{Enter}"); + + expect(screen.queryByRole("option")).not.toBeInTheDocument(); + + await userEvent.keyboard("{Backspace}"); + + expect(screen.getAllByRole("option")).toHaveLength(1); + }); +}); diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/index.ts b/extensions/ql-vscode/src/view/common/SuggestBox/index.ts new file mode 100644 index 000000000..d5914f4d3 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/index.ts @@ -0,0 +1 @@ +export * from "./SuggestBox"; diff --git a/extensions/ql-vscode/src/view/common/SuggestBox/options.ts b/extensions/ql-vscode/src/view/common/SuggestBox/options.ts index 2f15b45cf..dfaadbad4 100644 --- a/extensions/ql-vscode/src/view/common/SuggestBox/options.ts +++ b/extensions/ql-vscode/src/view/common/SuggestBox/options.ts @@ -1,5 +1,6 @@ -type Option> = { +export type Option> = { label: string; + value: string; followup?: T[]; };