diff --git a/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx b/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx index f518c5c613..c621e45d13 100644 --- a/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx +++ b/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx @@ -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): Array => { - 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 -) => { - // 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) => { - 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) => { - 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 = (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 ( + : undefined} + suffix={item.type === "uploaded" ? renderMenu(index) : undefined} + > + {item.label} + + ); + }; return ( - + {groupedItems.length === 0 && } @@ -223,7 +163,7 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => { { - if (openMenu.current === -1) { + if (isMenuOpen === false) { listProps.onBlur(event); } }} @@ -231,61 +171,16 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => { {uploadedItems.length !== 0 && ( {"Uploaded"} )} - {uploadedItems.map((item, index) => { - return ( - : undefined} - current={item.label === value} - suffix={ - selectedIndex === index || - openMenu.current === index || - focusedMenuTrigger.current === index ? ( - { - 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} - - ); - })} + {uploadedItems.map(renderItem)} {systemItems.length !== 0 && ( <> {uploadedItems.length !== 0 && } {"System"} )} - {systemItems.map((item, index) => { - const globalIndex = uploadedItems.length + index; - return ( - : undefined} - current={item.label === value} - > - {item.label} - - ); - })} + {systemItems.map((item, index) => + renderItem(item, index + uploadedItems.length) + )} diff --git a/apps/designer/app/designer/shared/fonts-manager/item-menu.tsx b/apps/designer/app/designer/shared/fonts-manager/item-menu.tsx index 29a9edb598..64e2fec8c2 100644 --- a/apps/designer/app/designer/shared/fonts-manager/item-menu.tsx +++ b/apps/designer/app/designer/shared/fonts-manager/item-menu.tsx @@ -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 = ({ ); }; + +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 ( + { + 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 }; +}; diff --git a/apps/designer/app/designer/shared/fonts-manager/item-utils.ts b/apps/designer/app/designer/shared/fonts-manager/item-utils.ts new file mode 100644 index 0000000000..75e12dafd4 --- /dev/null +++ b/apps/designer/app/designer/shared/fonts-manager/item-utils.ts @@ -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): Array => { + 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 +) => { + // 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) => { + 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) => { + return matchSorter(items, search, { + keys: [(item) => item.label], + }); +}; diff --git a/apps/designer/app/designer/shared/fonts-manager/use-search.tsx b/apps/designer/app/designer/shared/fonts-manager/use-search.tsx new file mode 100644 index 0000000000..3bc53feac6 --- /dev/null +++ b/apps/designer/app/designer/shared/fonts-manager/use-search.tsx @@ -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 = (event) => { + const { value } = event.currentTarget; + setSearch(value); + onSearch(value); + }; + + return () => { + return { + value: search, + onCancel, + onChange: handleChange, + onKeyDown: handleKeyDown, + }; + }; +}; diff --git a/packages/design-system/src/components/list.tsx b/packages/design-system/src/components/list.tsx index ecd6ccf904..5eb7174c36 100644 --- a/packages/design-system/src/components/list.tsx +++ b/packages/design-system/src/components/list.tsx @@ -105,20 +105,23 @@ export const findNextListIndex = ( type UseList = { items: Array; 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