mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-07 20:44:05 +09:00
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:
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);
|
||||
}}
|
||||
|
||||
+3
-3
@@ -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) => {
|
||||
|
||||
+101
-16
@@ -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,
|
||||
|
||||
|
||||
Reference in new issue
Block a user