combobox instead of autocomplete

This commit is contained in:
Oleg Isonen committed 2022-05-26 00:21:25 +02:00
1 parent 671e52e34b
commit d08f42e33d
7 files changed
+133 -248

No files matched your search

@@ -1,232 +0,0 @@
import {
useState,
useRef,
useCallback,
useMemo,
type ComponentProps,
useEffect,
} from "react";
import useDebounce from "react-use/lib/useDebounce";
import {
TextField,
MenuAnchor,
Menu,
MenuContent,
MenuItem,
} from "~/shared/design-system";
type Items = Array<{ label: string }>;
const findIndex = (items: Items, value: string, exact = false): number => {
const item = items.find(({ label }) => {
return exact ? label === value : label.includes(value);
});
if (item === undefined) return -1;
return items.indexOf(item);
};
type AutocompleteProps = Omit<ComponentProps<typeof TextField>, "onChange"> & {
items: Items;
onChange: (value: string) => void;
value: string;
};
export const Autocomplete = ({
items,
onChange,
value,
...rest
}: AutocompleteProps) => {
const [debouncedValue, setDebouncedValue] = useState<string>();
const [currentValue, setCurrentValue] = useState<string>(value);
const [isOpen, setIsOpen] = useState<boolean>();
const inputRef = useRef<HTMLInputElement>(null);
const contentRef = useRef<HTMLDivElement>();
const isMouseInRef = useRef<boolean>(false);
const previousValueRef = useRef<string>(value);
useEffect(() => {
if (isOpen === false) {
// Remember the value we had before opening autocomplete
// so that when we press ESC we can restore it.
previousValueRef.current = value;
}
setCurrentValue(value);
}, [value, isOpen]);
// When Enter was pressed, we set current value to "pending"
// and then update it from props.value
useEffect(() => {
if (currentValue === "pending") {
setCurrentValue(value);
}
}, [value, currentValue]);
const [, cancelDebounce] = useDebounce(
() => {
if (debouncedValue !== undefined) {
select(debouncedValue);
}
},
500,
[debouncedValue]
);
const select = (nextValue?: string) => {
cancelDebounce();
if (nextValue !== undefined && nextValue !== value) {
onChange(nextValue);
}
};
const open = () => {
if (isOpen !== true && currentIndex !== -1) setIsOpen(true);
};
const abort = () => {
if (previousValueRef.current !== undefined) {
select(previousValueRef.current);
}
close();
};
const close = useCallback(() => {
setDebouncedValue(undefined);
cancelDebounce();
setIsOpen(false);
}, [cancelDebounce]);
const currentIndex = useMemo(() => {
// We try to match exact item first, then non-exact as a fallback.
const exactMatchIndex = findIndex(items, value, true);
const index =
exactMatchIndex === -1 ? findIndex(items, value) : exactMatchIndex;
return index;
}, [items, value]);
useEffect(() => {
if (isOpen === false) return;
if (currentIndex === -1) {
close();
return;
}
// We don't want to auto scroll when pointer is on the menu
if (contentRef.current && isMouseInRef.current === false) {
contentRef.current.children[currentIndex]?.scrollIntoView({
block: "center",
});
}
}, [currentIndex, isOpen, close]);
const contentRefCallback = useCallback(
(contentElement) => {
contentRef.current = contentElement;
// When we open with an arrow
// menu catches the key event and focuses the first item,
// which triggers onFocus handler.
// We need to prevent that handler from executing.
cancelDebounce();
if (contentElement && inputRef.current !== null) {
inputRef.current.focus();
}
},
[inputRef, cancelDebounce]
);
return (
<Menu open={isOpen} modal={false}>
<MenuAnchor asChild>
<TextField
{...rest}
value={currentValue}
ref={inputRef}
autoComplete="off"
onClick={() => {
open();
}}
onChange={(event) => {
setCurrentValue(event.target.value);
}}
onKeyDown={(event) => {
switch (event.key) {
case "Escape":
event.preventDefault();
abort();
break;
case "Enter": {
event.preventDefault();
setCurrentValue("pending");
select(currentValue);
isOpen ? close() : open();
break;
}
case "ArrowDown": {
event.preventDefault();
if (isOpen === false) {
open();
break;
}
const index = items[currentIndex + 1] ? currentIndex + 1 : 0;
select(items[index]?.label);
break;
}
case "ArrowUp": {
event.preventDefault();
if (isOpen === false) {
open();
break;
}
const index = items[currentIndex - 1]
? currentIndex - 1
: items.length - 1;
select(items[index]?.label);
break;
}
}
}}
/>
</MenuAnchor>
<MenuContent
loop
portalled
css={{ maxHeight: 200, overflow: "auto" }}
asChild
onMouseEnter={() => {
isMouseInRef.current = true;
}}
onMouseLeave={() => {
isMouseInRef.current = false;
}}
onPointerDownOutside={() => {
abort();
}}
>
<div ref={contentRefCallback}>
{items.map(({ label }, index) => {
return (
<MenuItem
key={index}
{...(index === currentIndex
? { "data-found": true }
: undefined)}
onSelect={() => {
select(label);
close();
}}
onFocus={() => {
setDebouncedValue(items[index]?.label);
}}
>
{label}
</MenuItem>
);
})}
</div>
</MenuContent>
</Menu>
);
};
@@ -1,4 +1,4 @@
import { Flex, Grid, Label, Text } from "~/shared/design-system";
import { Flex, Grid, Label, Text, Combobox } from "~/shared/design-system";
import type { StyleConfig } from "~/shared/style-panel-configs";
import {
categories,
@@ -9,7 +9,6 @@ import {
} from "@webstudio-is/sdk";
import type { SetProperty } from "./use-style-data";
import type { InheritedStyle } from "./get-inherited-style";
import { Autocomplete } from "./lib/autocomplete";
import { ColorPicker } from "./lib/color-picker";
import {
SpacingWidget,
@@ -83,13 +82,13 @@ type ControlProps = {
styleConfig: StyleConfig;
};
const ColorField = ({
const ColorControl = ({
currentStyle,
inheritedStyle,
setProperty,
styleConfig,
}: ControlProps) => {
if (styleConfig.control !== "ColorField") return null;
if (styleConfig.control !== "Color") return null;
// @todo show which instance we inherited the value from
const value = getFinalValue({
currentStyle,
@@ -116,7 +115,7 @@ const ColorField = ({
);
};
const Spacing = ({
const SpacingControl = ({
currentStyle,
inheritedStyle,
setProperty,
@@ -154,13 +153,13 @@ const Spacing = ({
//const cursorUrl = `data:image/svg+xml;base64,${btoa(svgCursor)}`;
const TextFieldWithAutocomplete = ({
const ComboboxControl = ({
currentStyle,
inheritedStyle,
setProperty,
styleConfig,
}: ControlProps) => {
if (styleConfig.control !== "TextFieldWithAutocomplete") return null;
if (styleConfig.control !== "Combobox") return null;
// @todo show which instance we inherited the value from
const value = getFinalValue({
@@ -171,22 +170,32 @@ const TextFieldWithAutocomplete = ({
if (value === undefined) return null;
const setValue = setProperty(styleConfig.property);
return (
<Grid columns={2} align="center" gapX="1">
<PropertyName property={styleConfig.property} label={styleConfig.label} />
<Flex align="center" css={{ gridColumn: "2/4" }} gap="1">
<Autocomplete
<Combobox
id={styleConfig.property}
items={styleConfig.items}
variant="ghost"
css={{
// @todo drag&drop cursor to adjust numeric value
// const cursorUrl = data:image/svg+xml;base64,${btoa(svgCursor)}
//cursor: `url(${cursorUrl}), text`,
textAlign: "right",
}}
state={value.type === "invalid" ? "invalid" : undefined}
value={String(value.value)}
onChange={setProperty(styleConfig.property)}
onValueSelect={setValue}
onValueEnter={setValue}
onItemEnter={(value) => {
setValue(value, { isEphemeral: true });
}}
onItemLeave={() => {
setValue(String(value.value), { isEphemeral: true });
}}
/>
<Unit value={value} />
</Flex>
@@ -197,9 +206,9 @@ const TextFieldWithAutocomplete = ({
const controls: {
[key: string]: (props: ControlProps) => JSX.Element | null;
} = {
ColorField,
Spacing,
TextFieldWithAutocomplete,
Color: ColorControl,
Spacing: SpacingControl,
Combobox: ComboboxControl,
};
type RenderPropertyProps = {
@@ -97,7 +97,9 @@ export const useStyleData = ({
const type = options.isEphemeral ? "preview" : "update";
publishUpdates(type, updates);
}
setCurrentStyle({ ...currentStyle, [property]: nextValue });
if (options.isEphemeral === false) {
setCurrentStyle({ ...currentStyle, [property]: nextValue });
}
};
};
@@ -0,0 +1,105 @@
import { ChevronDownIcon } from "@radix-ui/react-icons";
import { type ComponentProps, useState, useEffect } from "react";
import {
TextField,
MenuAnchor,
Menu,
MenuContent,
MenuItem,
IconButton,
} from "~/shared/design-system";
type Item = { label: string };
type ComboboxProps = ComponentProps<typeof TextField> & {
items: Array<Item>;
onValueSelect: (value: string) => void;
onValueEnter: (value: string) => void;
onItemEnter: (value: string) => void;
onItemLeave: (value: string) => void;
value: string;
};
export const Combobox = ({
items,
onValueSelect,
onValueEnter,
onItemEnter,
onItemLeave,
value,
css,
...rest
}: ComboboxProps) => {
const [isOpen, setIsOpen] = useState(false);
const [currentValue, setCurrentValue] = useState(value);
useEffect(() => {
setCurrentValue(value);
}, [value]);
return (
<Menu open={isOpen} modal={true} onOpenChange={setIsOpen}>
<MenuAnchor asChild>
<TextField
{...rest}
value={currentValue}
autoComplete="off"
// @todo avoid hardcoding padding
css={{ ...css, paddingRight: 30 }}
onChange={(event) => {
setCurrentValue(event.target.value);
}}
onKeyDown={(event) => {
switch (event.key) {
case "ArrowDown":
case "ArrowUp": {
setIsOpen(true);
break;
}
case "Enter": {
onValueEnter(event.currentTarget.value);
break;
}
}
}}
/>
</MenuAnchor>
<IconButton
variant="ghost"
size="1"
// @todo avoid hardcoding margin
css={{ marginLeft: -32 }}
onClick={() => {
setIsOpen(true);
}}
>
<ChevronDownIcon />
</IconButton>
<MenuContent loop portalled asChild>
<div>
{items.map(({ label }, index) => {
return (
<MenuItem
key={index}
onMouseEnter={() => {
onItemEnter(label);
}}
onMouseLeave={() => {
onItemLeave(label);
}}
onFocus={() => {
onItemEnter(label);
}}
onSelect={() => {
onValueSelect(label);
}}
>
{label}
</MenuItem>
);
})}
</div>
</MenuContent>
</Menu>
);
};
+1
View File
@@ -53,3 +53,4 @@ export { Switch } from "./components/switch";
export { Panel } from "./components/panel";
export * from "./components/slider";
export * from "./components/control-group";
export * from "./components/combobox";
+2 -2
View File
@@ -17,7 +17,7 @@ type Property = keyof typeof keywordValues;
const getControl = (property: StyleProperty): Control => {
if (property.toLocaleLowerCase().includes("color")) {
return "ColorField";
return "Color";
}
// Spacing properties is more narrow than StyleProperty,
// so we have to widen it to be able to run .includes.
@@ -28,7 +28,7 @@ const getControl = (property: StyleProperty): Control => {
return "Spacing";
}
return "TextFieldWithAutocomplete";
return "Combobox";
};
const createStyleConfigs = () => {
+1 -1
View File
@@ -6,7 +6,7 @@ type BaseStyleConfig = {
appliesTo: AppliesTo;
};
export type Control = "Spacing" | "TextFieldWithAutocomplete" | "ColorField";
export type Control = "Spacing" | "Combobox" | "Color";
type StyleConfigWithItems = BaseStyleConfig & {
control: Control;