Merge pull request #3230 from github/koesie10/suggest-box
Create `SuggestBox` component
This commit is contained in:
58
extensions/ql-vscode/package-lock.json
generated
58
extensions/ql-vscode/package-lock.json
generated
@@ -10,6 +10,7 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.26.5",
|
||||||
"@octokit/plugin-retry": "^6.0.1",
|
"@octokit/plugin-retry": "^6.0.1",
|
||||||
"@octokit/rest": "^20.0.2",
|
"@octokit/rest": "^20.0.2",
|
||||||
"@vscode/codicons": "^0.0.35",
|
"@vscode/codicons": "^0.0.35",
|
||||||
@@ -2965,42 +2966,57 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/core": {
|
"node_modules/@floating-ui/core": {
|
||||||
"version": "1.5.2",
|
"version": "1.5.3",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.5.3.tgz",
|
||||||
"integrity": "sha512-Ii3MrfY/GAIN3OhXNzpCKaLxHQfJF9qvwq/kEJYdqDxeIHa01K8sldugal6TmeeXl+WMvhv9cnVzUTaFFJF09A==",
|
"integrity": "sha512-O0WKDOo0yhJuugCx6trZQj5jVJ9yR0ystG2JaNAemYUWce+pmM6WUEFIibnWyEJKdrDxhm75NoSRME35FNaM/Q==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/utils": "^0.1.3"
|
"@floating-ui/utils": "^0.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/dom": {
|
"node_modules/@floating-ui/dom": {
|
||||||
"version": "1.5.3",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.3.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.5.4.tgz",
|
||||||
"integrity": "sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==",
|
"integrity": "sha512-jByEsHIY+eEdCjnTVu+E3ephzTOzkQ8hgUfGwos+bg7NlH33Zc5uO+QHz1mrQUOgIKKDD1RtS201P9NvAfq3XQ==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/core": "^1.4.2",
|
"@floating-ui/core": "^1.5.3",
|
||||||
"@floating-ui/utils": "^0.1.3"
|
"@floating-ui/utils": "^0.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@floating-ui/react-dom": {
|
"node_modules/@floating-ui/react": {
|
||||||
"version": "2.0.4",
|
"version": "0.26.5",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.5.tgz",
|
||||||
"integrity": "sha512-CF8k2rgKeh/49UrnIBs4BdxPUV6vize/Db1d/YbCLyp9GiVZ0BEwf5AiDSxJRCr6yOkGqTFHtmrULxkEfYZ7dQ==",
|
"integrity": "sha512-LJeSQa+yOwV0Tdpc/C3Vr92QMrwRqRMTk4yOwsRJKc57x3Lcw317GE0EV+ECM7+Z89yEAPBe7nzbDEWfkWCrBA==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"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": {
|
"peerDependencies": {
|
||||||
"react": ">=16.8.0",
|
"react": ">=16.8.0",
|
||||||
"react-dom": ">=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": {
|
"node_modules/@floating-ui/utils": {
|
||||||
"version": "0.1.6",
|
"version": "0.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.1.tgz",
|
||||||
"integrity": "sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==",
|
"integrity": "sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@github/browserslist-config": {
|
"node_modules/@github/browserslist-config": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
|||||||
@@ -1928,6 +1928,7 @@
|
|||||||
"prepare": "cd ../.. && husky install"
|
"prepare": "cd ../.. && husky install"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.26.5",
|
||||||
"@octokit/plugin-retry": "^6.0.1",
|
"@octokit/plugin-retry": "^6.0.1",
|
||||||
"@octokit/rest": "^20.0.2",
|
"@octokit/rest": "^20.0.2",
|
||||||
"@vscode/codicons": "^0.0.35",
|
"@vscode/codicons": "^0.0.35",
|
||||||
|
|||||||
146
extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx
Normal file
146
extensions/ql-vscode/src/stories/common/SuggestBox.stories.tsx
Normal file
@@ -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<typeof SuggestBoxComponent>;
|
||||||
|
|
||||||
|
type StoryOption = {
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
details?: string;
|
||||||
|
value: string;
|
||||||
|
followup?: StoryOption[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const Template: StoryFn<typeof SuggestBoxComponent<StoryOption>> = (args) => {
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
args.onChange(value);
|
||||||
|
setValue(value);
|
||||||
|
},
|
||||||
|
[args],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SuggestBoxComponent<StoryOption>
|
||||||
|
{...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) => <Icon name={option.icon} />,
|
||||||
|
getDetails: (option: StoryOption) => option.details,
|
||||||
|
};
|
||||||
236
extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx
Normal file
236
extensions/ql-vscode/src/view/common/SuggestBox/SuggestBox.tsx
Normal file
@@ -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<T extends Option<T>> = {
|
||||||
|
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<string, unknown>) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SuggestBox = <T extends Option<T>>({
|
||||||
|
value = "",
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
parseValueToTokens,
|
||||||
|
getIcon,
|
||||||
|
getDetails,
|
||||||
|
disabled,
|
||||||
|
"aria-label": ariaLabel,
|
||||||
|
renderInputComponent = (props) => <Input {...props} />,
|
||||||
|
}: SuggestBoxProps<T>) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [activeIndex, setActiveIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const listRef = useRef<Array<HTMLElement | null>>([]);
|
||||||
|
|
||||||
|
const { refs, floatingStyles, context } = useFloating<HTMLInputElement>({
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 && (
|
||||||
|
<FloatingPortal>
|
||||||
|
{value && suggestionItems.length === 0 && (
|
||||||
|
<NoSuggestionsContainer
|
||||||
|
{...getFloatingProps({
|
||||||
|
ref: refs.setFloating,
|
||||||
|
style: floatingStyles,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<NoSuggestionsText>No suggestions.</NoSuggestionsText>
|
||||||
|
</NoSuggestionsContainer>
|
||||||
|
)}
|
||||||
|
{suggestionItems.length > 0 && (
|
||||||
|
<FloatingFocusManager
|
||||||
|
context={context}
|
||||||
|
initialFocus={-1}
|
||||||
|
visuallyHiddenDismiss
|
||||||
|
>
|
||||||
|
<ListContainer
|
||||||
|
{...getFloatingProps({
|
||||||
|
ref: refs.setFloating,
|
||||||
|
style: floatingStyles,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{suggestionItems.map((item, index) => (
|
||||||
|
<SuggestBoxItem
|
||||||
|
key={item.label}
|
||||||
|
{...getItemProps({
|
||||||
|
key: item.label,
|
||||||
|
ref(node) {
|
||||||
|
listRef.current[index] = node;
|
||||||
|
},
|
||||||
|
onClick() {
|
||||||
|
onChange(item.value);
|
||||||
|
setIsOpen(false);
|
||||||
|
|
||||||
|
refs.domReference.current?.focus();
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
active={activeIndex === index}
|
||||||
|
icon={getIcon?.(item)}
|
||||||
|
labelText={item.label}
|
||||||
|
details={getDetails?.(item)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ListContainer>
|
||||||
|
</FloatingFocusManager>
|
||||||
|
)}
|
||||||
|
</FloatingPortal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<HTMLDivElement>
|
||||||
|
>(({ children, active, icon, labelText, details, ...props }, ref) => {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<Container
|
||||||
|
ref={ref}
|
||||||
|
role="option"
|
||||||
|
id={id}
|
||||||
|
aria-selected={active}
|
||||||
|
$active={active}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<LabelContainer>
|
||||||
|
<Label>{labelText}</Label>
|
||||||
|
{details && <DetailsLabel>{details}</DetailsLabel>}
|
||||||
|
</LabelContainer>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
SuggestBoxItem.displayName = "SuggestBoxItem";
|
||||||
@@ -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<SuggestBoxProps<TestOption>>) =>
|
||||||
|
reactRender(
|
||||||
|
<SuggestBox
|
||||||
|
options={options}
|
||||||
|
onChange={onChange}
|
||||||
|
parseValueToTokens={parseValueToTokens}
|
||||||
|
renderInputComponent={(props) => <input {...props} />}
|
||||||
|
{...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);
|
||||||
|
});
|
||||||
|
});
|
||||||
1
extensions/ql-vscode/src/view/common/SuggestBox/index.ts
Normal file
1
extensions/ql-vscode/src/view/common/SuggestBox/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from "./SuggestBox";
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
type Option<T extends Option<T>> = {
|
export type Option<T extends Option<T>> = {
|
||||||
label: string;
|
label: string;
|
||||||
|
value: string;
|
||||||
followup?: T[];
|
followup?: T[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user