mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-08 21:15:18 +09:00
move more logic to list and renderItem
This commit is contained in:
1 parent
5b17bc13db
commit
cf6ec5790f
5 files changed
+217
-184
No files matched your search
@@ -1,4 +1,3 @@
|
||||
import type { Asset } from "@webstudio-is/asset-uploader";
|
||||
import {
|
||||
Flex,
|
||||
Separator,
|
||||
@@ -8,43 +7,18 @@ import {
|
||||
useList,
|
||||
findNextListIndex,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { AssetUpload, PreviewAsset, useAssets } from "~/designer/shared/assets";
|
||||
import { SYSTEM_FONTS } from "@webstudio-is/fonts";
|
||||
import {
|
||||
type KeyboardEventHandler,
|
||||
type ChangeEventHandler,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { ItemMenu } from "./item-menu";
|
||||
import { AssetUpload, useAssets } from "~/designer/shared/assets";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMenu } from "./item-menu";
|
||||
import { CheckIcon } from "@webstudio-is/icons";
|
||||
|
||||
type Item = {
|
||||
label: string;
|
||||
type: "uploaded" | "system";
|
||||
};
|
||||
|
||||
const toItems = (assets: Array<Asset | PreviewAsset>): Array<Item> => {
|
||||
const system = Array.from(SYSTEM_FONTS.keys()).map((label) => ({
|
||||
label,
|
||||
type: "system",
|
||||
}));
|
||||
// We can have 2+ assets with the same family name, so we use a map to dedupe.
|
||||
const uploaded = new Map();
|
||||
for (const asset of assets) {
|
||||
// @todo need to teach ts the right type from useAssets
|
||||
if ("meta" in asset && "family" in asset.meta) {
|
||||
uploaded.set(asset.meta.family, {
|
||||
label: asset.meta.family,
|
||||
type: "uploaded",
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...uploaded.values(), ...system];
|
||||
};
|
||||
import {
|
||||
type Item,
|
||||
filterIdsByFamily,
|
||||
filterItems,
|
||||
groupItemsByType,
|
||||
toItems,
|
||||
} from "./item-utils";
|
||||
import { useSearch } from "./use-search";
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
@@ -54,35 +28,6 @@ const NotFound = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const filterIdsByFamily = (
|
||||
family: string,
|
||||
assets: Array<Asset | PreviewAsset>
|
||||
) => {
|
||||
// One family may have multiple assets for different formats, so we need to find them all.
|
||||
return assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
// @todo need to teach TS the right type from useAssets
|
||||
"meta" in asset &&
|
||||
"family" in asset.meta &&
|
||||
asset.meta.family === family
|
||||
)
|
||||
.map((asset) => asset.id);
|
||||
};
|
||||
|
||||
const groupItemsByType = (items: Array<Item>) => {
|
||||
const uploadedItems = items.filter((item) => item.type === "uploaded");
|
||||
const systemItems = items.filter((item) => item.type === "system");
|
||||
const groupedItems = [...uploadedItems, ...systemItems];
|
||||
return { uploadedItems, systemItems, groupedItems };
|
||||
};
|
||||
|
||||
const filter = (search: string, items: Array<Item>) => {
|
||||
return matchSorter(items, search, {
|
||||
keys: [(item) => item.label],
|
||||
});
|
||||
};
|
||||
|
||||
const useLogic = ({
|
||||
onChange,
|
||||
value,
|
||||
@@ -90,86 +35,75 @@ const useLogic = ({
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}) => {
|
||||
const { assets, handleDelete } = useAssets("font");
|
||||
const { assets, handleDelete: handleDeleteAssets } = useAssets("font");
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [currentIndex, setCurrentIndex] = useState(-1);
|
||||
const fontItems = useMemo(() => toItems(assets), [assets]);
|
||||
const [filteredItems, setFilteredItems] = useState(fontItems);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const items = filter(search, fontItems);
|
||||
setFilteredItems(items);
|
||||
}, [fontItems, search]);
|
||||
|
||||
const { uploadedItems, systemItems, groupedItems } = useMemo(
|
||||
() => groupItemsByType(filteredItems),
|
||||
[filteredItems]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentIndex(groupedItems.findIndex((item) => item.label === value));
|
||||
}, []);
|
||||
const [currentIndex, setCurrentIndex] = useState(() =>
|
||||
groupedItems.findIndex((item) => item.label === value)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const item = groupedItems[currentIndex];
|
||||
if (item !== undefined) onChange(item.label);
|
||||
}, [currentIndex]);
|
||||
const handleChangeCurrent = (nextCurrentIndex: number) => {
|
||||
const item = groupedItems[nextCurrentIndex];
|
||||
if (item !== undefined) {
|
||||
setCurrentIndex(nextCurrentIndex);
|
||||
onChange(item.label);
|
||||
}
|
||||
};
|
||||
|
||||
const { getItemProps, getListProps } = useList({
|
||||
items: groupedItems,
|
||||
selectedIndex,
|
||||
currentIndex,
|
||||
onSelect: setSelectedIndex,
|
||||
onChangeCurrent: setCurrentIndex,
|
||||
onChangeCurrent: handleChangeCurrent,
|
||||
});
|
||||
|
||||
const handleDeleteByLabel = (family: string) => {
|
||||
const handleDelete = (index: number) => {
|
||||
const family = groupedItems[index].label;
|
||||
const ids = filterIdsByFamily(family, assets);
|
||||
handleDelete(ids);
|
||||
handleDeleteAssets(ids);
|
||||
setFilteredItems(filteredItems.filter((item) => item.label !== family));
|
||||
};
|
||||
|
||||
const handleCancelSearch = () => {
|
||||
setFilteredItems(fontItems);
|
||||
};
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = (event) => {
|
||||
switch (event.code) {
|
||||
case "ArrowUp":
|
||||
case "ArrowDown": {
|
||||
const nextIndex = findNextListIndex(
|
||||
selectedIndex,
|
||||
groupedItems.length,
|
||||
event.code === "ArrowUp" ? "previous" : "next"
|
||||
);
|
||||
setSelectedIndex(nextIndex);
|
||||
break;
|
||||
const getSearchProps = useSearch({
|
||||
onCancel() {
|
||||
setFilteredItems(fontItems);
|
||||
},
|
||||
onSearch(search) {
|
||||
const items = filterItems(search, fontItems);
|
||||
setFilteredItems(items);
|
||||
},
|
||||
onSelect(direction) {
|
||||
if (direction === "current") {
|
||||
handleChangeCurrent(selectedIndex);
|
||||
return;
|
||||
}
|
||||
case "Enter": {
|
||||
setCurrentIndex(selectedIndex);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
setSearch(event.currentTarget.value);
|
||||
};
|
||||
const nextIndex = findNextListIndex(
|
||||
selectedIndex,
|
||||
groupedItems.length,
|
||||
direction
|
||||
);
|
||||
setSelectedIndex(nextIndex);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
search,
|
||||
groupedItems,
|
||||
uploadedItems,
|
||||
systemItems,
|
||||
selectedIndex,
|
||||
handleDelete: handleDeleteByLabel,
|
||||
handleCancelSearch,
|
||||
handleSelectItem: setSelectedIndex,
|
||||
handleKeyDown,
|
||||
handleSearch,
|
||||
handleDelete,
|
||||
handleSelect: setSelectedIndex,
|
||||
getItemProps,
|
||||
getListProps,
|
||||
getSearchProps,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -180,36 +114,42 @@ type FontsManagerProps = {
|
||||
|
||||
export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
|
||||
const {
|
||||
search,
|
||||
groupedItems,
|
||||
uploadedItems,
|
||||
systemItems,
|
||||
handleSearch,
|
||||
handleDelete,
|
||||
handleCancelSearch,
|
||||
handleSelectItem,
|
||||
handleKeyDown,
|
||||
handleSelect,
|
||||
selectedIndex,
|
||||
getListProps,
|
||||
getItemProps,
|
||||
getSearchProps,
|
||||
} = useLogic({ onChange, value });
|
||||
const openMenu = useRef(-1);
|
||||
const focusedMenuTrigger = useRef(-1);
|
||||
|
||||
const listProps = getListProps();
|
||||
const { render: renderMenu, isOpen: isMenuOpen } = useMenu({
|
||||
selectedIndex,
|
||||
onSelect: handleSelect,
|
||||
onDelete: handleDelete,
|
||||
});
|
||||
|
||||
const renderItem = (item: Item, index: number) => {
|
||||
const itemProps = getItemProps({ index });
|
||||
return (
|
||||
<ListItem
|
||||
{...itemProps}
|
||||
prefix={itemProps.current ? <CheckIcon /> : undefined}
|
||||
suffix={item.type === "uploaded" ? renderMenu(index) : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</ListItem>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex direction="column" css={{ overflow: "hidden", py: "$1" }}>
|
||||
<Flex css={{ py: "$2", px: "$3" }} gap="2" direction="column">
|
||||
<AssetUpload type="font" />
|
||||
<SearchField
|
||||
value={search}
|
||||
autoFocus
|
||||
placeholder="Search"
|
||||
onCancel={handleCancelSearch}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleSearch}
|
||||
/>
|
||||
<SearchField {...getSearchProps()} autoFocus placeholder="Search" />
|
||||
</Flex>
|
||||
<Separator css={{ my: "$1" }} />
|
||||
{groupedItems.length === 0 && <NotFound />}
|
||||
@@ -223,7 +163,7 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
|
||||
<List
|
||||
{...listProps}
|
||||
onBlur={(event) => {
|
||||
if (openMenu.current === -1) {
|
||||
if (isMenuOpen === false) {
|
||||
listProps.onBlur(event);
|
||||
}
|
||||
}}
|
||||
@@ -231,61 +171,16 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => {
|
||||
{uploadedItems.length !== 0 && (
|
||||
<ListItem state="disabled">{"Uploaded"}</ListItem>
|
||||
)}
|
||||
{uploadedItems.map((item, index) => {
|
||||
return (
|
||||
<ListItem
|
||||
{...getItemProps({ index })}
|
||||
key={index}
|
||||
state={selectedIndex === index ? "selected" : undefined}
|
||||
prefix={item.label === value ? <CheckIcon /> : undefined}
|
||||
current={item.label === value}
|
||||
suffix={
|
||||
selectedIndex === index ||
|
||||
openMenu.current === index ||
|
||||
focusedMenuTrigger.current === index ? (
|
||||
<ItemMenu
|
||||
onOpenChange={(open) => {
|
||||
openMenu.current = open === true ? index : -1;
|
||||
handleSelectItem(index);
|
||||
}}
|
||||
onDelete={() => {
|
||||
handleDelete(item.label);
|
||||
}}
|
||||
onFocusTrigger={() => {
|
||||
focusedMenuTrigger.current = index;
|
||||
handleSelectItem(-1);
|
||||
}}
|
||||
onBlurTrigger={() => {
|
||||
focusedMenuTrigger.current = -1;
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
{uploadedItems.map(renderItem)}
|
||||
{systemItems.length !== 0 && (
|
||||
<>
|
||||
{uploadedItems.length !== 0 && <Separator css={{ my: "$1" }} />}
|
||||
<ListItem state="disabled">{"System"}</ListItem>
|
||||
</>
|
||||
)}
|
||||
{systemItems.map((item, index) => {
|
||||
const globalIndex = uploadedItems.length + index;
|
||||
return (
|
||||
<ListItem
|
||||
{...getItemProps({ index: globalIndex })}
|
||||
key={globalIndex}
|
||||
state={selectedIndex === globalIndex ? "selected" : undefined}
|
||||
prefix={item.label === value ? <CheckIcon /> : undefined}
|
||||
current={item.label === value}
|
||||
>
|
||||
{item.label}
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
{systemItems.map((item, index) =>
|
||||
renderItem(item, index + uploadedItems.length)
|
||||
)}
|
||||
</List>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
styled,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { DotsHorizontalIcon } from "@webstudio-is/icons";
|
||||
import { type FocusEventHandler, useState } from "react";
|
||||
import { type FocusEventHandler, useState, useRef } from "react";
|
||||
|
||||
const MenuButton = styled(IconButton, {
|
||||
color: "$hint",
|
||||
@@ -25,7 +25,7 @@ type ItemMenuProps = {
|
||||
onBlurTrigger?: FocusEventHandler;
|
||||
};
|
||||
|
||||
export const ItemMenu = ({
|
||||
const ItemMenu = ({
|
||||
onDelete,
|
||||
onOpenChange,
|
||||
onFocusTrigger,
|
||||
@@ -72,3 +72,46 @@ export const ItemMenu = ({
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
type UseMenu = {
|
||||
selectedIndex: number;
|
||||
onSelect: (index: number) => void;
|
||||
onDelete: (index: number) => void;
|
||||
};
|
||||
|
||||
export const useMenu = ({ selectedIndex, onSelect, onDelete }: UseMenu) => {
|
||||
const openMenu = useRef(-1);
|
||||
const focusedMenuTrigger = useRef(-1);
|
||||
|
||||
const render = (index: number) => {
|
||||
const show =
|
||||
selectedIndex === index ||
|
||||
openMenu.current === index ||
|
||||
focusedMenuTrigger.current === index;
|
||||
|
||||
openMenu.current = show ? index : -1;
|
||||
|
||||
if (show === false) return;
|
||||
|
||||
return (
|
||||
<ItemMenu
|
||||
onOpenChange={(open) => {
|
||||
openMenu.current = open === true ? index : -1;
|
||||
onSelect(index);
|
||||
}}
|
||||
onDelete={() => {
|
||||
onDelete(index);
|
||||
}}
|
||||
onFocusTrigger={() => {
|
||||
focusedMenuTrigger.current = index;
|
||||
onSelect(-1);
|
||||
}}
|
||||
onBlurTrigger={() => {
|
||||
focusedMenuTrigger.current = -1;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return { render, isOpen: openMenu.current !== -1 };
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Asset } from "@webstudio-is/asset-uploader";
|
||||
import { SYSTEM_FONTS } from "@webstudio-is/fonts";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import { PreviewAsset } from "../assets";
|
||||
|
||||
export type Item = {
|
||||
label: string;
|
||||
type: "uploaded" | "system";
|
||||
};
|
||||
|
||||
export const toItems = (assets: Array<Asset | PreviewAsset>): Array<Item> => {
|
||||
const system = Array.from(SYSTEM_FONTS.keys()).map((label) => ({
|
||||
label,
|
||||
type: "system",
|
||||
}));
|
||||
// We can have 2+ assets with the same family name, so we use a map to dedupe.
|
||||
const uploaded = new Map();
|
||||
for (const asset of assets) {
|
||||
// @todo need to teach ts the right type from useAssets
|
||||
if ("meta" in asset && "family" in asset.meta) {
|
||||
uploaded.set(asset.meta.family, {
|
||||
label: asset.meta.family,
|
||||
type: "uploaded",
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...uploaded.values(), ...system];
|
||||
};
|
||||
|
||||
export const filterIdsByFamily = (
|
||||
family: string,
|
||||
assets: Array<Asset | PreviewAsset>
|
||||
) => {
|
||||
// One family may have multiple assets for different formats, so we need to find them all.
|
||||
return assets
|
||||
.filter(
|
||||
(asset) =>
|
||||
// @todo need to teach TS the right type from useAssets
|
||||
"meta" in asset &&
|
||||
"family" in asset.meta &&
|
||||
asset.meta.family === family
|
||||
)
|
||||
.map((asset) => asset.id);
|
||||
};
|
||||
|
||||
export const groupItemsByType = (items: Array<Item>) => {
|
||||
const uploadedItems = items.filter((item) => item.type === "uploaded");
|
||||
const systemItems = items.filter((item) => item.type === "system");
|
||||
const groupedItems = [...uploadedItems, ...systemItems];
|
||||
return { uploadedItems, systemItems, groupedItems };
|
||||
};
|
||||
|
||||
export const filterItems = (search: string, items: Array<Item>) => {
|
||||
return matchSorter(items, search, {
|
||||
keys: [(item) => item.label],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ChangeEventHandler, KeyboardEventHandler, useState } from "react";
|
||||
|
||||
type UseSearch = {
|
||||
onCancel: () => void;
|
||||
onSearch: (search: string) => void;
|
||||
onSelect: (direction: "next" | "previous" | "current") => void;
|
||||
};
|
||||
|
||||
export const useSearch = ({ onCancel, onSearch, onSelect }: UseSearch) => {
|
||||
const [search, setSearch] = useState("");
|
||||
const handleKeyDown: KeyboardEventHandler = ({ code }) => {
|
||||
const keyMap = {
|
||||
ArrowUp: "previous",
|
||||
ArrowDown: "next",
|
||||
Enter: "current",
|
||||
} as const;
|
||||
const direction = keyMap[code as keyof typeof keyMap];
|
||||
if (direction !== undefined) onSelect(direction);
|
||||
};
|
||||
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
|
||||
const { value } = event.currentTarget;
|
||||
setSearch(value);
|
||||
onSearch(value);
|
||||
};
|
||||
|
||||
return () => {
|
||||
return {
|
||||
value: search,
|
||||
onCancel,
|
||||
onChange: handleChange,
|
||||
onKeyDown: handleKeyDown,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -105,20 +105,23 @@ export const findNextListIndex = (
|
||||
type UseList<Item = unknown> = {
|
||||
items: Array<Item>;
|
||||
selectedIndex: number;
|
||||
onSelect: (index: number) => void;
|
||||
currentIndex: number;
|
||||
onSelect: (index: number) => void;
|
||||
onChangeCurrent: (index: number) => void;
|
||||
};
|
||||
|
||||
export const useList = ({
|
||||
items,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
currentIndex,
|
||||
onSelect,
|
||||
onChangeCurrent,
|
||||
}: UseList) => {
|
||||
const getItemProps = ({ index }: { index: number }) => {
|
||||
return {
|
||||
state: selectedIndex === index ? ("selected" as const) : undefined,
|
||||
key: index,
|
||||
current: currentIndex === index,
|
||||
onFocus(event: FocusEvent) {
|
||||
const isItem = event.target === event.currentTarget;
|
||||
// We need to ignore focus on anything inside
|
||||
|
||||
Reference in new issue
Block a user