From d08f42e33decfbb24a72f6eea754a9bc5ad582fd Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Thu, 26 May 2022 00:21:25 +0200 Subject: [PATCH] combobox instead of autocomplete --- .../features/style-panel/lib/autocomplete.tsx | 232 ------------------ .../features/style-panel/render-property.tsx | 33 ++- .../features/style-panel/use-style-data.ts | 4 +- .../design-system/components/combobox.tsx | 105 ++++++++ app/shared/design-system/index.ts | 1 + app/shared/style-panel-configs/configs.ts | 4 +- app/shared/style-panel-configs/types.ts | 2 +- 7 files changed, 133 insertions(+), 248 deletions(-) delete mode 100644 app/designer/features/style-panel/lib/autocomplete.tsx create mode 100644 app/shared/design-system/components/combobox.tsx diff --git a/app/designer/features/style-panel/lib/autocomplete.tsx b/app/designer/features/style-panel/lib/autocomplete.tsx deleted file mode 100644 index f24199720a..0000000000 --- a/app/designer/features/style-panel/lib/autocomplete.tsx +++ /dev/null @@ -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, "onChange"> & { - items: Items; - onChange: (value: string) => void; - value: string; -}; - -export const Autocomplete = ({ - items, - onChange, - value, - ...rest -}: AutocompleteProps) => { - const [debouncedValue, setDebouncedValue] = useState(); - const [currentValue, setCurrentValue] = useState(value); - const [isOpen, setIsOpen] = useState(); - const inputRef = useRef(null); - const contentRef = useRef(); - const isMouseInRef = useRef(false); - const previousValueRef = useRef(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 ( - - - { - 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; - } - } - }} - /> - - { - isMouseInRef.current = true; - }} - onMouseLeave={() => { - isMouseInRef.current = false; - }} - onPointerDownOutside={() => { - abort(); - }} - > -
- {items.map(({ label }, index) => { - return ( - { - select(label); - close(); - }} - onFocus={() => { - setDebouncedValue(items[index]?.label); - }} - > - {label} - - ); - })} -
-
-
- ); -}; diff --git a/app/designer/features/style-panel/render-property.tsx b/app/designer/features/style-panel/render-property.tsx index 40da35a817..ce5fe23a6e 100644 --- a/app/designer/features/style-panel/render-property.tsx +++ b/app/designer/features/style-panel/render-property.tsx @@ -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 ( - { + setValue(value, { isEphemeral: true }); + }} + onItemLeave={() => { + setValue(String(value.value), { isEphemeral: true }); + }} /> @@ -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 = { diff --git a/app/designer/features/style-panel/use-style-data.ts b/app/designer/features/style-panel/use-style-data.ts index b09e54e61c..56e7cd7bd0 100644 --- a/app/designer/features/style-panel/use-style-data.ts +++ b/app/designer/features/style-panel/use-style-data.ts @@ -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 }); + } }; }; diff --git a/app/shared/design-system/components/combobox.tsx b/app/shared/design-system/components/combobox.tsx new file mode 100644 index 0000000000..3a78daba2a --- /dev/null +++ b/app/shared/design-system/components/combobox.tsx @@ -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 & { + items: Array; + 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 ( + + + { + setCurrentValue(event.target.value); + }} + onKeyDown={(event) => { + switch (event.key) { + case "ArrowDown": + case "ArrowUp": { + setIsOpen(true); + break; + } + case "Enter": { + onValueEnter(event.currentTarget.value); + break; + } + } + }} + /> + + { + setIsOpen(true); + }} + > + + + +
+ {items.map(({ label }, index) => { + return ( + { + onItemEnter(label); + }} + onMouseLeave={() => { + onItemLeave(label); + }} + onFocus={() => { + onItemEnter(label); + }} + onSelect={() => { + onValueSelect(label); + }} + > + {label} + + ); + })} +
+
+
+ ); +}; diff --git a/app/shared/design-system/index.ts b/app/shared/design-system/index.ts index 9461feeae8..5112a0666e 100644 --- a/app/shared/design-system/index.ts +++ b/app/shared/design-system/index.ts @@ -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"; diff --git a/app/shared/style-panel-configs/configs.ts b/app/shared/style-panel-configs/configs.ts index a44dfa2a69..04b58a63af 100644 --- a/app/shared/style-panel-configs/configs.ts +++ b/app/shared/style-panel-configs/configs.ts @@ -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 = () => { diff --git a/app/shared/style-panel-configs/types.ts b/app/shared/style-panel-configs/types.ts index f28487122b..985a1a089f 100644 --- a/app/shared/style-panel-configs/types.ts +++ b/app/shared/style-panel-configs/types.ts @@ -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;