diff --git a/extensions/ql-vscode/package-lock.json b/extensions/ql-vscode/package-lock.json index fcc53e582..43263180a 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", @@ -2967,42 +2968,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 1fa606758..c2b7dd8c6 100644 --- a/extensions/ql-vscode/package.json +++ b/extensions/ql-vscode/package.json @@ -1908,6 +1908,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..544de25c1 --- /dev/null +++ b/extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx @@ -0,0 +1,227 @@ +import type { FormEvent, ReactNode } from "react"; +import { useCallback, useMemo, useRef, useState, useEffect } 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; +`; + +type Props> = { + 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; +}; + +export const SuggestBox = >({ + value = "", + onChange, + options, + parseValueToTokens, + getIcon, + getDetails, + disabled, + "aria-label": ariaLabel, +}: Props) => { + 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 ( + <> + { + // 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/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[]; };