mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 04:55:06 +09:00
combobox instead of autocomplete
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
@@ -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";
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user