diff --git a/apps/builder/app/builder/features/style-panel/style-source-section.tsx b/apps/builder/app/builder/features/style-panel/style-source-section.tsx index 7e6c0178fb..7603728c13 100644 --- a/apps/builder/app/builder/features/style-panel/style-source-section.tsx +++ b/apps/builder/app/builder/features/style-panel/style-source-section.tsx @@ -5,7 +5,11 @@ import type { StyleSource, StyleSourceSelection, } from "@webstudio-is/project-build"; -import { ItemState, StyleSourceInput } from "./style-source"; +import { + type ItemSource, + type ItemState, + StyleSourceInput, +} from "./style-source"; import { useStore } from "@nanostores/react"; import { availableStyleSourcesStore, @@ -115,10 +119,18 @@ const reorderStyleSources = (styleSourceIds: StyleSource["id"][]) => { ); }; +type StyleSourceInputItem = { + id: string; + label: string; + isEditable: boolean; + state: ItemState; + source: ItemSource; +}; + const convertToInputItem = ( styleSource: StyleSource, selectedStyleSource?: StyleSource["id"] -) => { +): StyleSourceInputItem => { const state: ItemState = selectedStyleSource === styleSource.id ? "selected" : "unselected"; if (styleSource.type === "local") { @@ -161,9 +173,7 @@ export const StyleSourcesSection = () => { { - createStyleSource(label); - }} + onCreateItem={createStyleSource} onSelectAutocompleteItem={({ id }) => { addStyleSourceToInstace(id); }} diff --git a/apps/builder/app/builder/features/style-panel/style-source/style-source-input.stories.tsx b/apps/builder/app/builder/features/style-panel/style-source/style-source-input.stories.tsx index 9a7d5e869a..0939497fd4 100644 --- a/apps/builder/app/builder/features/style-panel/style-source/style-source-input.stories.tsx +++ b/apps/builder/app/builder/features/style-panel/style-source/style-source-input.stories.tsx @@ -89,7 +89,7 @@ export const Basic: ComponentStory = () => { css={{ width: 300 }} items={getItems()} value={value} - onCreateItem={({ label }) => { + onCreateItem={(label) => { createItem(label, value, setValue); }} onSelectAutocompleteItem={(item) => { @@ -121,7 +121,7 @@ export const WithTruncatedItem: ComponentStory< css={{ width: 300 }} items={getItems()} value={value} - onCreateItem={({ label }) => { + onCreateItem={(label) => { createItem(label, value, setValue); }} onSelectAutocompleteItem={(item) => { @@ -170,7 +170,7 @@ export const Complete: ComponentStory = () => { ); }} onEditItem={setEditingItem} - onCreateItem={({ label }) => { + onCreateItem={(label) => { createItem(label, value, setValue); }} onSelectAutocompleteItem={(item) => { diff --git a/apps/builder/app/builder/features/style-panel/style-source/style-source-input.tsx b/apps/builder/app/builder/features/style-panel/style-source/style-source-input.tsx index 14c1454f3c..406412a2b0 100644 --- a/apps/builder/app/builder/features/style-panel/style-source/style-source-input.tsx +++ b/apps/builder/app/builder/features/style-panel/style-source/style-source-input.tsx @@ -24,6 +24,8 @@ import { useTextFieldFocus, useCombobox, type CSS, + ComboboxLabel, + ComboboxSeparator, } from "@webstudio-is/design-system"; import { forwardRef, @@ -41,6 +43,8 @@ import { } from "./style-source"; import { useSortable } from "./use-sortable"; import { theme } from "@webstudio-is/design-system"; +import { matchSorter } from "match-sorter"; +import { StyleSourceBadge } from "./style-source-badge"; type IntermediateItem = { id: string; @@ -48,6 +52,7 @@ type IntermediateItem = { isEditable: boolean; state: ItemState; source: ItemSource; + isAdded?: boolean; }; type TextFieldBaseWrapperProps = Omit, "value"> & @@ -181,7 +186,7 @@ type StyleSourceInputProps = { editingItem?: Item; onSelectAutocompleteItem?: (item: Item) => void; onRemoveItem?: (item: Item) => void; - onCreateItem?: (item: Item) => void; + onCreateItem?: (label: string) => void; onChangeItem?: (item: Item) => void; onSelectItem?: (item?: Item) => void; onEditItem?: (item?: Item) => void; @@ -191,11 +196,51 @@ type StyleSourceInputProps = { css?: CSS; }; +const newItemId = "__NEW__"; + +const matchOrSuggestToCreate = ( + search: string, + items: IntermediateItem[], + itemToString: (item: IntermediateItem | null) => string +): IntermediateItem[] => { + const matched = matchSorter(items, search, { + keys: [itemToString], + }); + if ( + search.trim() !== "" && + itemToString(matched[0]).toLocaleLowerCase() !== + search.toLocaleLowerCase().trim() + ) { + matched.unshift({ + id: newItemId, + label: search.trim(), + state: "unselected", + source: "token", + isEditable: true, + isAdded: false, + }); + } + // skip already added values + return matched.filter((item) => item.isAdded === false).slice(0, 5); +}; + +const markAddedValues = ( + items: Item[], + value: Item[] +) => { + const valueIds = new Set(); + for (const item of value) { + valueIds.add(item.id); + } + return items.map((item) => ({ ...item, isAdded: valueIds.has(item.id) })); +}; + export const StyleSourceInput = ( props: StyleSourceInputProps ) => { const value = props.value ?? []; const [label, setLabel] = useState(""); + const { items, getInputProps, @@ -203,8 +248,8 @@ export const StyleSourceInput = ( getMenuProps, getItemProps, isOpen, - } = useCombobox({ - items: props.items ?? [], + } = useCombobox({ + items: markAddedValues(props.items ?? [], value), value: { label, state: "unselected", @@ -213,29 +258,32 @@ export const StyleSourceInput = ( isEditable: true, }, selectedItem: undefined, + match: matchOrSuggestToCreate, + defaultHighlightedIndex: 0, itemToString: (item) => (item ? item.label : ""), onItemSelect(item) { setLabel(""); - props.onSelectAutocompleteItem?.(item as Item); + if (item.id === newItemId) { + props.onCreateItem?.(item.label); + } else { + props.onSelectAutocompleteItem?.(item as Item); + } }, onInputChange(label) { setLabel(label ?? ""); }, }); + const inputProps = getInputProps({ onKeyDown(event) { if (event.key === "Backspace" && label === "") { props.onRemoveItem?.(value[value.length - 1]); } }, - onKeyPress(event) { - if (event.key === "Enter" && label.trim() !== "") { - setLabel(""); - props.onCreateItem?.({ label } as Item); - } - }, }); + let hasNewTokenItem = false; + return ( @@ -259,13 +307,50 @@ export const StyleSourceInput = ( {isOpen && items.map((item, index) => { + if (item.id === newItemId) { + hasNewTokenItem = true; + return ( + <> + New Token + +
+ Create{" "} + + {item.label} + +
+
+ + ); + } + + const firstExistingItemIndex = hasNewTokenItem ? 1 : 0; + const label = index === firstExistingItemIndex && ( + <> + {hasNewTokenItem && } + Existing Tokens + + ); + if (item.source === "local") { + return; + } return ( - - {item.label} - + <> + {label} + + + {item.label} + + + ); })}
diff --git a/packages/design-system/src/components/combobox.tsx b/packages/design-system/src/components/combobox.tsx index e54c2e5ba8..ffe1557eaa 100644 --- a/packages/design-system/src/components/combobox.tsx +++ b/packages/design-system/src/components/combobox.tsx @@ -96,6 +96,12 @@ export const ComboboxPopperContent = PopperContent; export const ComboboxPopperAnchor = PopperAnchor; +type Match = ( + search: string, + items: Item[], + itemToString: (item: Item | null) => string +) => Item[]; + const defaultMatch = ( search: string, items: Array, @@ -112,7 +118,7 @@ const useFilter = ({ }: { items: Array; itemToString: (item: Item | null) => string; - match?: typeof defaultMatch; + match?: Match; }) => { const [filteredItems, setFilteredItems] = useState>(items); const cachedItems = useRef(items); @@ -152,7 +158,8 @@ type UseComboboxProps = UseDownshiftComboboxProps & { state: DownshiftState, changes: UseComboboxStateChangeOptions ) => Partial>; - match?: typeof defaultMatch; + match?: Match; + defaultHighlightedIndex?: number; }; export const comboboxStateChangeTypes = useDownshiftCombobox.stateChangeTypes; @@ -167,6 +174,7 @@ export const useCombobox = ({ onItemHighlight, stateReducer = (state, { changes }) => changes, match, + defaultHighlightedIndex = -1, ...rest }: UseComboboxProps) => { const [isOpen, setIsOpen] = useState(false); @@ -184,7 +192,7 @@ export const useCombobox = ({ const downshiftProps = useDownshiftCombobox({ ...rest, items: filteredItems, - defaultHighlightedIndex: -1, + defaultHighlightedIndex, selectedItem: selectedItem ?? null, // Prevent downshift warning about switching controlled mode isOpen,