Improve token selelection and creation (#1026)

Ref https://github.com/webstudio-is/webstudio-designer/issues/807

- aligned combobox popup with design, addeed badges and labels
- added "create token" option when no exact matches found
- select first element by default to make "creation" explicit
- show only 5 matches while searching
- not show matches already added to value

<img width="201" alt="image"
src="https://user-images.githubusercontent.com/5635476/218684986-d7395918-8fa1-46a1-96d8-b1b9f0109906.png">
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2023-02-14 16:53:27 +03:00
1 parent 0c506ed79e
commit a9016f3065
4 files changed
+130 -27

No files matched your search

@@ -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 = () => {
<StyleSourceInput
items={items}
value={value}
onCreateItem={({ label }) => {
createStyleSource(label);
}}
onCreateItem={createStyleSource}
onSelectAutocompleteItem={({ id }) => {
addStyleSourceToInstace(id);
}}
@@ -89,7 +89,7 @@ export const Basic: ComponentStory<typeof StyleSourceInput> = () => {
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<typeof StyleSourceInput> = () => {
);
}}
onEditItem={setEditingItem}
onCreateItem={({ label }) => {
onCreateItem={(label) => {
createItem(label, value, setValue);
}}
onSelectAutocompleteItem={(item) => {
@@ -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<Item> = Omit<ComponentProps<"input">, "value"> &
@@ -181,7 +186,7 @@ type StyleSourceInputProps<Item> = {
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<Item> = {
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 = <Item extends IntermediateItem>(
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 = <Item extends IntermediateItem>(
props: StyleSourceInputProps<Item>
) => {
const value = props.value ?? [];
const [label, setLabel] = useState("");
const {
items,
getInputProps,
@@ -203,8 +248,8 @@ export const StyleSourceInput = <Item extends IntermediateItem>(
getMenuProps,
getItemProps,
isOpen,
} = useCombobox({
items: props.items ?? [],
} = useCombobox<IntermediateItem>({
items: markAddedValues(props.items ?? [], value),
value: {
label,
state: "unselected",
@@ -213,29 +258,32 @@ export const StyleSourceInput = <Item extends IntermediateItem>(
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 (
<ComboboxPopper>
<Box {...getComboboxProps()}>
@@ -259,13 +307,50 @@ export const StyleSourceInput = <Item extends IntermediateItem>(
<ComboboxListbox {...getMenuProps()}>
{isOpen &&
items.map((item, index) => {
if (item.id === newItemId) {
hasNewTokenItem = true;
return (
<>
<ComboboxLabel>New Token</ComboboxLabel>
<ComboboxListboxItem
{...getItemProps({ item, index })}
key={index}
selectable={false}
>
<div>
Create{" "}
<StyleSourceBadge source="token">
{item.label}
</StyleSourceBadge>
</div>
</ComboboxListboxItem>
</>
);
}
const firstExistingItemIndex = hasNewTokenItem ? 1 : 0;
const label = index === firstExistingItemIndex && (
<>
{hasNewTokenItem && <ComboboxSeparator />}
<ComboboxLabel>Existing Tokens</ComboboxLabel>
</>
);
if (item.source === "local") {
return;
}
return (
<ComboboxListboxItem
{...getItemProps({ item, index })}
key={index}
>
{item.label}
</ComboboxListboxItem>
<>
{label}
<ComboboxListboxItem
{...getItemProps({ item, index })}
key={index}
selectable={false}
>
<StyleSourceBadge source={item.source}>
{item.label}
</StyleSourceBadge>
</ComboboxListboxItem>
</>
);
})}
</ComboboxListbox>
@@ -96,6 +96,12 @@ export const ComboboxPopperContent = PopperContent;
export const ComboboxPopperAnchor = PopperAnchor;
type Match<Item> = (
search: string,
items: Item[],
itemToString: (item: Item | null) => string
) => Item[];
const defaultMatch = <Item,>(
search: string,
items: Array<Item>,
@@ -112,7 +118,7 @@ const useFilter = <Item,>({
}: {
items: Array<Item>;
itemToString: (item: Item | null) => string;
match?: typeof defaultMatch;
match?: Match<Item>;
}) => {
const [filteredItems, setFilteredItems] = useState<Array<Item>>(items);
const cachedItems = useRef(items);
@@ -152,7 +158,8 @@ type UseComboboxProps<Item> = UseDownshiftComboboxProps<Item> & {
state: DownshiftState<Item>,
changes: UseComboboxStateChangeOptions<Item>
) => Partial<UseComboboxStateChangeOptions<Item>>;
match?: typeof defaultMatch;
match?: Match<Item>;
defaultHighlightedIndex?: number;
};
export const comboboxStateChangeTypes = useDownshiftCombobox.stateChangeTypes;
@@ -167,6 +174,7 @@ export const useCombobox = <Item,>({
onItemHighlight,
stateReducer = (state, { changes }) => changes,
match,
defaultHighlightedIndex = -1,
...rest
}: UseComboboxProps<Item>) => {
const [isOpen, setIsOpen] = useState(false);
@@ -184,7 +192,7 @@ export const useCombobox = <Item,>({
const downshiftProps = useDownshiftCombobox({
...rest,
items: filteredItems,
defaultHighlightedIndex: -1,
defaultHighlightedIndex,
selectedItem: selectedItem ?? null, // Prevent downshift warning about switching controlled mode
isOpen,