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,
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
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;
|
||||
value: string;
|
||||
followup?: T[];
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user