Create SuggestBox component

This commit is contained in:
Koen Vlaswinkel
2024-01-11 14:13:02 +01:00
parent 7f730d24b0
commit 0df0cca9d8
7 changed files with 500 additions and 22 deletions

View File

@@ -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",

View File

@@ -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",

View 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,
};

View File

@@ -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<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;
};
export const SuggestBox = <T extends Option<T>>({
value = "",
onChange,
options,
parseValueToTokens,
getIcon,
getDetails,
disabled,
"aria-label": ariaLabel,
}: Props<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 (
<>
<Input
{...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>
)}
</>
);
};

View File

@@ -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";

View File

@@ -0,0 +1 @@
export * from "./SuggestBox";

View File

@@ -1,5 +1,6 @@
type Option<T extends Option<T>> = {
export type Option<T extends Option<T>> = {
label: string;
value: string;
followup?: T[];
};