diff --git a/.lostpixel/baseline/components-combobox--simple.png b/.lostpixel/baseline/components-combobox--simple.png index 3ab0f6293d..c5bdaf570a 100644 Binary files a/.lostpixel/baseline/components-combobox--simple.png and b/.lostpixel/baseline/components-combobox--simple.png differ diff --git a/.lostpixel/baseline/components-search-field--empty.png b/.lostpixel/baseline/components-search-field--empty.png new file mode 100644 index 0000000000..47f4b13d0e Binary files /dev/null and b/.lostpixel/baseline/components-search-field--empty.png differ diff --git a/.lostpixel/baseline/components-search-field--reset.png b/.lostpixel/baseline/components-search-field--reset.png new file mode 100644 index 0000000000..0a9b4ba5ff Binary files /dev/null and b/.lostpixel/baseline/components-search-field--reset.png differ diff --git a/.lostpixel/baseline/components-search-field--update-value.png b/.lostpixel/baseline/components-search-field--update-value.png new file mode 100644 index 0000000000..4aa4b4be53 Binary files /dev/null and b/.lostpixel/baseline/components-search-field--update-value.png differ diff --git a/.lostpixel/baseline/components-search-field--with-initial-value.png b/.lostpixel/baseline/components-search-field--with-initial-value.png new file mode 100644 index 0000000000..d6f0bf0876 Binary files /dev/null and b/.lostpixel/baseline/components-search-field--with-initial-value.png differ diff --git a/.lostpixel/baseline/components-text-field--click-capture.png b/.lostpixel/baseline/components-text-field--click-capture.png index 673451fbef..470a6171cc 100644 Binary files a/.lostpixel/baseline/components-text-field--click-capture.png and b/.lostpixel/baseline/components-text-field--click-capture.png differ diff --git a/.lostpixel/baseline/components-text-field--focus-events.png b/.lostpixel/baseline/components-text-field--focus-events.png index 8ec53c2226..5542e8888a 100644 Binary files a/.lostpixel/baseline/components-text-field--focus-events.png and b/.lostpixel/baseline/components-text-field--focus-events.png differ diff --git a/.lostpixel/baseline/components-text-field--interactive.png b/.lostpixel/baseline/components-text-field--interactive.png index 165508e4e0..ce559ea3f9 100644 Binary files a/.lostpixel/baseline/components-text-field--interactive.png and b/.lostpixel/baseline/components-text-field--interactive.png differ diff --git a/.lostpixel/baseline/components-text-field--layout.png b/.lostpixel/baseline/components-text-field--layout.png index 70e5007b91..6f721d6ca4 100644 Binary files a/.lostpixel/baseline/components-text-field--layout.png and b/.lostpixel/baseline/components-text-field--layout.png differ diff --git a/.lostpixel/baseline/components-text-field--native-props.png b/.lostpixel/baseline/components-text-field--native-props.png index 6f0764f33b..d0e9a63194 100644 Binary files a/.lostpixel/baseline/components-text-field--native-props.png and b/.lostpixel/baseline/components-text-field--native-props.png differ diff --git a/.lostpixel/baseline/components-text-field--prefix-suffix.png b/.lostpixel/baseline/components-text-field--prefix-suffix.png index 0117b94737..7a46df00c1 100644 Binary files a/.lostpixel/baseline/components-text-field--prefix-suffix.png and b/.lostpixel/baseline/components-text-field--prefix-suffix.png differ diff --git a/.lostpixel/baseline/components-text-field--variants.png b/.lostpixel/baseline/components-text-field--variants.png index f7db5735a2..ea8be5c4ec 100644 Binary files a/.lostpixel/baseline/components-text-field--variants.png and b/.lostpixel/baseline/components-text-field--variants.png differ diff --git a/apps/designer/app/designer/features/breakpoints/use-subscribe-shortcuts.ts b/apps/designer/app/designer/features/breakpoints/use-subscribe-shortcuts.ts index 8849e6a5ad..48e8a2799c 100644 --- a/apps/designer/app/designer/features/breakpoints/use-subscribe-shortcuts.ts +++ b/apps/designer/app/designer/features/breakpoints/use-subscribe-shortcuts.ts @@ -3,6 +3,8 @@ import { useBreakpoints } from "~/shared/nano-states"; import { useZoom, useSelectedBreakpoint } from "../../shared/nano-states"; import { minZoom } from "./zoom-setting"; import { utils } from "@webstudio-is/project"; +import { shortcuts } from "~/shared/shortcuts"; +import { useHotkeys } from "react-hotkeys-hook"; export const useSubscribeSelectBreakpointFromShortcut = () => { const [breakpoints] = useBreakpoints(); @@ -17,6 +19,17 @@ const zoomStep = 20; export const useSubscribeZoomFromShortcut = () => { const [zoom, setZoom] = useZoom(); + + // We need to prevent browser default behavior for zooming in/out + // Maybe we should use shift modifier and keep the default behaivor functional? + useHotkeys( + shortcuts.zoom, + (event) => { + event.preventDefault(); + }, + [] + ); + useSubscribe("zoom", (direction) => { if (direction === "zoomIn") { setZoom(Math.min(zoom + zoomStep, 100)); diff --git a/apps/designer/app/designer/features/style-panel/controls/font-family/font-family-control.tsx b/apps/designer/app/designer/features/style-panel/controls/font-family/font-family-control.tsx index 20b8af6320..a4026cb7c2 100644 --- a/apps/designer/app/designer/features/style-panel/controls/font-family/font-family-control.tsx +++ b/apps/designer/app/designer/features/style-panel/controls/font-family/font-family-control.tsx @@ -3,6 +3,7 @@ import { FontsManager } from "~/designer/shared/fonts-manager"; import type { ControlProps } from "../../style-sections"; import { getFinalValue } from "../../shared/get-final-value"; import { ValuePickerPopover } from "../../shared/value-picker-popover"; +import { useState } from "react"; export const FontFamilyControl = ({ currentStyle, @@ -16,6 +17,7 @@ export const FontFamilyControl = ({ inheritedStyle, property: styleConfig.property, }); + const [isOpen, setIsOpen] = useState(false); if (value === undefined) return null; @@ -25,9 +27,13 @@ export const FontFamilyControl = ({ } + onOpenChange={setIsOpen} > {/* @todo this should be part of the design system, probably a varian="button" */} - + ); }; diff --git a/apps/designer/app/designer/features/style-panel/search.tsx b/apps/designer/app/designer/features/style-panel/search.tsx deleted file mode 100644 index 299ce19933..0000000000 --- a/apps/designer/app/designer/features/style-panel/search.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { useRef, useState } from "react"; -import { IconButton, TextField } from "@webstudio-is/design-system"; -import { Cross2Icon, MagnifyingGlassIcon } from "@webstudio-is/icons"; -import { components } from "@webstudio-is/react-sdk"; -import type { SelectedInstanceData } from "~/shared/canvas-components"; - -type OnSearch = (search: string) => void; - -const useSearch = (onSearch: OnSearch): [string, OnSearch] => { - const [search, setSearch] = useState(""); - return [ - search, - (search: string) => { - setSearch(search); - onSearch(search); - }, - ]; -}; - -type SearchProps = { - onSearch: OnSearch; - selectedInstanceData: SelectedInstanceData; -}; - -export const Search = ({ onSearch, selectedInstanceData }: SearchProps) => { - const [search, setSearch] = useSearch(onSearch); - const inputRef = useRef(null); - return ( - - - - } - suffix={ - search.length > 0 && ( - { - setSearch(""); - inputRef.current?.focus(); - }} - > - - - ) - } - placeholder={components[selectedInstanceData.component].label} - onChange={(event) => { - const { value } = event.target; - setSearch(value.toLowerCase()); - }} - /> - ); -}; diff --git a/apps/designer/app/designer/features/style-panel/shared/css-value-input/css-value-input.tsx b/apps/designer/app/designer/features/style-panel/shared/css-value-input/css-value-input.tsx index 504bfcd149..225ce4f213 100644 --- a/apps/designer/app/designer/features/style-panel/shared/css-value-input/css-value-input.tsx +++ b/apps/designer/app/designer/features/style-panel/shared/css-value-input/css-value-input.tsx @@ -7,8 +7,8 @@ import { ComboboxPopperAnchor, ComboboxListbox, ComboboxListboxItem, - IconButton, numericScrubControl, + TextFieldIconButton, } from "@webstudio-is/design-system"; import { ChevronDownIcon } from "@webstudio-is/icons"; import { @@ -263,9 +263,12 @@ export const CssValueInput = ({ const suffix = value.type === "keyword" ? ( - + - + ) : value.type === "unit" ? ( unitSelectElement ) : null; diff --git a/apps/designer/app/designer/features/style-panel/shared/css-value-input/unit-select.tsx b/apps/designer/app/designer/features/style-panel/shared/css-value-input/unit-select.tsx index dc2f8aad55..d99374e33e 100644 --- a/apps/designer/app/designer/features/style-panel/shared/css-value-input/unit-select.tsx +++ b/apps/designer/app/designer/features/style-panel/shared/css-value-input/unit-select.tsx @@ -8,6 +8,7 @@ import { SelectViewport, SelectItem, SelectContent, + TextFieldIconButton, } from "@webstudio-is/design-system"; import { ChevronDownIcon, ChevronUpIcon } from "@webstudio-is/icons"; @@ -75,21 +76,8 @@ export const useUnitSelect = ({ return [isOpen, select]; }; -const StyledTrigger = styled(SelectPrimitive.SelectTrigger, { - all: "unset", - height: "$5", - px: "$1", - borderRadius: 2, - display: "inline-flex", - alignItems: "center", - color: "$hiContrast", - "&:hover": { - backgroundColor: "$slate6", - }, - "&:focus": { - backgroundColor: "$blue10", - color: "$loContrast", - }, +const StyledTrigger = styled(TextFieldIconButton, { + px: 3, }); type UnitSelectProps = { @@ -116,9 +104,11 @@ const UnitSelect = ({ onOpenChange={onOpenChange} open={open} > - - {value} - + + + {value} + + diff --git a/apps/designer/app/designer/features/style-panel/shared/value-picker-popover.tsx b/apps/designer/app/designer/features/style-panel/shared/value-picker-popover.tsx index 72811d8bd9..9fecafec57 100644 --- a/apps/designer/app/designer/features/style-panel/shared/value-picker-popover.tsx +++ b/apps/designer/app/designer/features/style-panel/shared/value-picker-popover.tsx @@ -17,27 +17,32 @@ const usePickerSideOffset = ( return [ref, sideOffset]; }; -type AssetPickerProps = { +type ValuePickerPopoverProps = { title: string; content: JSX.Element; children: JSX.Element; + onOpenChange?: (isOpen: boolean) => void; }; export const ValuePickerPopover = ({ title, content, children, -}: AssetPickerProps) => { + onOpenChange, +}: ValuePickerPopoverProps) => { const [isOpen, setIsOpen] = useState(false); const [ref, sideOffset] = usePickerSideOffset(isOpen); - + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + onOpenChange?.(open); + }; return ( - + { - setIsOpen(true); + handleOpenChange(true); }} > {children} @@ -47,7 +52,8 @@ export const ValuePickerPopover = ({ sideOffset={sideOffset} side="right" hideArrow - css={{ minHeight: 500 }} + align="start" + css={{ width: PANEL_WIDTH }} > {content} diff --git a/apps/designer/app/designer/features/style-panel/style-panel.tsx b/apps/designer/app/designer/features/style-panel/style-panel.tsx index b326756ce3..1e36ab7d72 100644 --- a/apps/designer/app/designer/features/style-panel/style-panel.tsx +++ b/apps/designer/app/designer/features/style-panel/style-panel.tsx @@ -1,10 +1,9 @@ import type { Publish } from "~/shared/pubsub"; import { willRender } from "~/designer/shared/breakpoints"; -import { Box, Card, Paragraph } from "@webstudio-is/design-system"; +import { Box, Card, Paragraph, SearchField } from "@webstudio-is/design-system"; import type { SelectedInstanceData } from "~/shared/canvas-components"; import { useStyleData } from "./shared/use-style-data"; import { StyleSettings } from "./style-settings"; -import { Search } from "./search"; import { useState } from "react"; import { useCanvasWidth, @@ -56,9 +55,14 @@ export const StylePanel = ({ return ( <> - { + setSearch(event.target.value); + }} + onCancel={() => { + setSearch(""); + }} /> 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 2161be9a49..796c13daa9 100644 --- a/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx +++ b/apps/designer/app/designer/shared/fonts-manager/fonts-manager.tsx @@ -1,70 +1,57 @@ -/* This entire module is WIP */ import type { Asset } from "@webstudio-is/asset-uploader"; import { Flex, - Box, - ComboboxListboxItem, - TextField, - DropdownMenu, - DropdownMenuTrigger, - IconButton, - DropdownMenuContent, - DropdownMenuItem, - Text, - DropdownMenuPortal, useCombobox, + Separator, + SearchField, } from "@webstudio-is/design-system"; import { AssetUpload, PreviewAsset, useAssets } from "~/designer/shared/assets"; import { SYSTEM_FONTS } from "@webstudio-is/fonts"; -import { DotsHorizontalIcon, MagnifyingGlassIcon } from "@webstudio-is/icons"; -import { useMemo } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useState } from "react"; +import { ItemMenu } from "./item-menu"; +import { Listbox, ListboxItem } from "./listbox"; -const getItems = ( - assets: Array -): Array<{ label: string }> => { - const system = Array.from(SYSTEM_FONTS.keys()).map((label) => ({ label })); +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 }); + uploaded.set(asset.meta.family, { + label: asset.meta.family, + type: "uploaded", + }); } } - return [...system, ...uploaded.values()]; + return [...uploaded.values(), ...system]; }; -const ItemMenu = ({ onDelete }: { onDelete: () => void }) => { +const NotFound = () => { return ( - - - - - - - - - { - onDelete(); - }} - > - Delete font - - - - + + Font not found + ); }; -type FontsManagerProps = { +const useLogic = ({ + value, + onChange, +}: { value: string; onChange: (value: string) => void; -}; - -export const FontsManager = ({ value, onChange }: FontsManagerProps) => { +}) => { + const [search, setSearch] = useState(""); const { assets, handleDelete } = useAssets("font"); - const handleDeleteByLabel = (family: string) => { // One family may have multiple assets for different formats, so we need to delete them all. const ids = assets @@ -79,63 +66,169 @@ export const FontsManager = ({ value, onChange }: FontsManagerProps) => { handleDelete(ids); }; - const fontItems = useMemo(() => getItems(assets), [assets]); + const fontItems = useMemo(() => toItems(assets), [assets]); + const selectedItem = fontItems.find((item) => item.label === value) ?? fontItems[0]; - const { items, getComboboxProps, getMenuProps, getItemProps, getInputProps } = - useCombobox({ - items: fontItems, - value: selectedItem, - itemToString: (item) => item?.label ?? "", - onItemSelect: (value) => { - if (value !== null) { - onChange(value.label); - } - }, - }); + const { + items: filteredItems, + resetFilter, + highlightedIndex, + isOpen, + inputValue, + ...comboboxProps + } = useCombobox({ + // Makes sure first keydown selectsthe item instead of opening the list first otherwise. + isOpen: true, + items: fontItems, + value: selectedItem, + itemToString(item) { + return item?.label ?? ""; + }, + onItemSelect(value) { + if (value !== null) { + onChange(value.label); + handleCancelSearch(); + } + }, + }); - const { isEmpty, ...menuProps } = getMenuProps(); + const { uploadedItems, systemItems } = useMemo(() => { + const uploadedItems = filteredItems.filter( + (item) => item.type === "uploaded" + ); + const systemItems = filteredItems.filter((item) => item.type === "system"); + return { uploadedItems, systemItems }; + }, [filteredItems]); + + useEffect(() => { + setSearch(inputValue); + }, [inputValue]); + + const handleCancelSearch = useCallback(() => { + resetFilter(); + setSearch(""); + }, [resetFilter]); + + useEffect(handleCancelSearch, [fontItems, selectedItem, handleCancelSearch]); + + return { + filteredItems, + uploadedItems, + systemItems, + handleDelete: handleDeleteByLabel, + handleCancelSearch, + search, + ...comboboxProps, + }; +}; + +type FontsManagerProps = { + value: string; + onChange: (value: string) => void; +}; + +export const FontsManager = ({ value, onChange }: FontsManagerProps) => { + const { + filteredItems, + uploadedItems, + systemItems, + getInputProps, + handleDelete, + handleCancelSearch, + getComboboxProps, + getMenuProps, + getItemProps, + setHighlightedIndex, + search, + } = useLogic({ value, onChange }); return ( - - + + - - + { + setHighlightedIndex(-1); + }} + onCancel={handleCancelSearch} + /> + + + {filteredItems.length === 0 && } - } - {...getInputProps()} - /> -
- Choose an item - - {items.map((item, index) => { - return ( - - {item.label} + + {uploadedItems.length !== 0 && ( + {"Uploaded"} + )} + {uploadedItems.map((item, index) => { + return ( + { + setHighlightedIndex(index); + }} onDelete={() => { - handleDeleteByLabel(item.label); + handleDelete(item.label); + }} + onFocusTrigger={() => { + setHighlightedIndex(-1); }} /> - - ); - })} - -
+ } + onFocus={(event) => { + // We need to ignore focus on a menu button inside + if (event.target === event.currentTarget) { + setHighlightedIndex(index); + } + }} + > + {item.label} + + ); + })} + {systemItems.length !== 0 && ( + <> + + {"System"} + + )} + {systemItems.map((item, index) => { + const globalIndex = uploadedItems.length + index; + return ( + { + setHighlightedIndex(globalIndex); + }} + > + {item.label} + + ); + })} +
); diff --git a/apps/designer/app/designer/shared/fonts-manager/item-menu.tsx b/apps/designer/app/designer/shared/fonts-manager/item-menu.tsx new file mode 100644 index 0000000000..5d3abc0ea4 --- /dev/null +++ b/apps/designer/app/designer/shared/fonts-manager/item-menu.tsx @@ -0,0 +1,73 @@ +import { + DropdownMenu, + DropdownMenuTrigger, + IconButton, + DropdownMenuContent, + DropdownMenuItem, + Text, + DropdownMenuPortal, + styled, +} from "@webstudio-is/design-system"; +import { DotsHorizontalIcon } from "@webstudio-is/icons"; +import { cssVars } from "@webstudio-is/css-vars"; +import { type FocusEventHandler, useState } from "react"; + +const visibilityVar = cssVars.define("visibility"); + +export const getItemMenuVars = (state: "visible" | "hidden") => { + return { + [visibilityVar]: state, + }; +}; + +const MenuButton = styled(IconButton, { + visibility: cssVars.use(visibilityVar, "hidden"), + color: "$hint", + "&:hover, &:focus": { + color: "$hiContrast", + }, +}); + +type ItemMenuProps = { + onDelete: () => void; + onOpenChange: (open: boolean) => void; + onFocusTrigger?: FocusEventHandler; +}; + +export const ItemMenu = ({ + onDelete, + onOpenChange, + onFocusTrigger, +}: ItemMenuProps) => { + const [isOpen, setIsOpen] = useState(false); + return ( + { + setIsOpen(open); + onOpenChange(open); + }} + > + + + + + + + + { + onDelete(); + }} + > + Delete font + + + + + ); +}; diff --git a/apps/designer/app/designer/shared/fonts-manager/listbox.tsx b/apps/designer/app/designer/shared/fonts-manager/listbox.tsx new file mode 100644 index 0000000000..78acce1ee8 --- /dev/null +++ b/apps/designer/app/designer/shared/fonts-manager/listbox.tsx @@ -0,0 +1,79 @@ +import { Flex, styled, Text } from "@webstudio-is/design-system"; +import { CheckIcon } from "@webstudio-is/icons"; +import { ComponentProps, forwardRef } from "react"; +import { getItemMenuVars } from "./item-menu"; + +export const Listbox = styled("ul", { + display: "flex", + flexDirection: "column", + margin: 0, + padding: 0, + variants: { + empty: { + true: { + display: "none", + }, + }, + }, +}); + +const ListboxItemBase = styled("li", { + display: "grid", + gridTemplateColumns: "$4 1fr", + alignItems: "center", + justifyContent: "space-between", + height: "$5", + px: "$1", + listStyle: "none", + borderRadius: "$1", + outline: 0, + "&[data-highlighted]": { + boxShadow: "0px 0px 0px 2px $colors$blue10, 0px 0px 0px 2px $colors$blue10", + ...getItemMenuVars("visible"), + }, + "&:focus-within": getItemMenuVars("visible"), + "&[disabled]": { + pointerEvents: "none", + }, +}); + +export const ListboxItem = forwardRef< + HTMLLIElement, + ComponentProps & { + highlighted?: boolean; + disabled?: boolean; + selected?: boolean; + suffix?: JSX.Element; + } +>( + ( + { + highlighted, + disabled = false, + selected = false, + children, + suffix, + ...props + }, + ref + ) => { + return ( + + {selected && } + + + {children} + + {suffix} + + + ); + } +); +ListboxItem.displayName = "ListboxItem"; diff --git a/apps/designer/app/designer/shared/shortcuts/use-publish-shortcuts.ts b/apps/designer/app/designer/shared/shortcuts/use-publish-shortcuts.ts index 8ba8c3da72..9eaf306c1a 100644 --- a/apps/designer/app/designer/shared/shortcuts/use-publish-shortcuts.ts +++ b/apps/designer/app/designer/shared/shortcuts/use-publish-shortcuts.ts @@ -20,7 +20,6 @@ export const usePublishShortcuts = (publish: Publish) => { useHotkeys( shortcuts[name], (event) => { - event.preventDefault(); publish({ type: "shortcut", payload: { name, key: event.key }, diff --git a/packages/design-system/src/components/button.tsx b/packages/design-system/src/components/button.tsx index eb9ad25417..ade004b281 100644 --- a/packages/design-system/src/components/button.tsx +++ b/packages/design-system/src/components/button.tsx @@ -7,7 +7,7 @@ export const Button = styled("button", { boxSizing: "border-box", border: "none", userSelect: "none", - transition: "all 200ms ease-out", + transition: "all 150ms ease-out", "&::before": { boxSizing: "border-box", }, @@ -40,7 +40,7 @@ export const Button = styled("button", { variants: { size: { "1": { - borderRadius: 2, + borderRadius: "$1", height: "$5", px: "$3", fontSize: "$1", @@ -82,7 +82,7 @@ export const Button = styled("button", { }, blue: { backgroundColor: "$blue10", - color: "$loContrast", + color: "white", "@hover": { "&:hover": { backgroundColor: "$loContrast", @@ -269,7 +269,8 @@ export const Button = styled("button", { backgroundColor: "$blueA4", }, "&:focus": { - boxShadow: "inset 0 0 0 1px $colors$blueA8, 0 0 0 1px $colors$blueA8", + boxShadow: + "0px 0px 0px 2px $colors$blue10, 0px 0px 0px 2px $colors$blue10", }, '&[data-state="open"]': { backgroundColor: "$blueA4", diff --git a/packages/design-system/src/components/combobox.tsx b/packages/design-system/src/components/combobox.tsx index c81ee172c4..697c8c4743 100644 --- a/packages/design-system/src/components/combobox.tsx +++ b/packages/design-system/src/components/combobox.tsx @@ -10,8 +10,9 @@ import React, { import { CheckIcon, ChevronDownIcon } from "@webstudio-is/icons"; import { Popper, PopperContent, PopperAnchor } from "@radix-ui/react-popper"; import { - DownshiftState, - UseComboboxStateChangeOptions, + type DownshiftState, + type UseComboboxStateChangeOptions, + type UseComboboxProps as UseDownshiftComboboxProps, useCombobox as useDownshiftCombobox, } from "downshift"; import { matchSorter } from "match-sorter"; @@ -37,7 +38,7 @@ const Listbox = styled("ul", panelStyles, { display: "none", }, }, - isEmpty: { + empty: { true: { display: "none", }, @@ -128,7 +129,7 @@ const useFilter = ({ }; }; -type UseComboboxProps = { +type UseComboboxProps = UseDownshiftComboboxProps & { items: Array; itemToString: (item: Item | null) => string; value: Item | null; // This is to prevent: "downshift: A component has changed the uncontrolled prop "selectedItem" to be controlled." @@ -151,6 +152,7 @@ export const useCombobox = ({ onItemHighlight, stateReducer = (state, { changes }) => changes, match, + ...rest }: UseComboboxProps) => { const { filteredItems, filter, resetFilter } = useFilter({ items, @@ -159,7 +161,9 @@ export const useCombobox = ({ }); const downshiftProps = useDownshiftCombobox({ + ...rest, items: filteredItems, + defaultHighlightedIndex: -1, selectedItem: value, // Prevent downshift warning about switching controlled mode stateReducer, itemToString, @@ -203,7 +207,7 @@ export const useCombobox = ({ return { ...getMenuProps(options), state: isOpen ? "open" : "closed", - isEmpty: filteredItems.length === 0, + empty: filteredItems.length === 0, }; }, [getMenuProps, isOpen, filteredItems.length] @@ -214,6 +218,7 @@ export const useCombobox = ({ items: filteredItems, getItemProps: enhancedGetItemProps, getMenuProps: enhancedGetMenuProps, + resetFilter, }; }; diff --git a/packages/design-system/src/components/icon-button.tsx b/packages/design-system/src/components/icon-button.tsx index 77c7c429db..e1169ad5ec 100644 --- a/packages/design-system/src/components/icon-button.tsx +++ b/packages/design-system/src/components/icon-button.tsx @@ -30,6 +30,8 @@ export const IconButton = styled("button", { "&:focus": { backgroundColor: "$slate6", outline: "none", + border: "2px solid white", + boxShadow: "0px 0px 0px 2px $colors$blue10, 0px 0px 0px 2px $colors$blue10", }, "&:disabled": { pointerEvents: "none", @@ -41,8 +43,12 @@ export const IconButton = styled("button", { size: { "1": { borderRadius: 2, - height: "$5", - width: "$5", + height: "$3", + width: "$3", + "&:hover, &:focus": { + background: "none", + borderColor: "transparent", + }, }, "2": { borderRadius: "$1", @@ -60,12 +66,6 @@ export const IconButton = styled("button", { width: "$8", }, }, - state: { - active: { - color: "$loContrast", - backgroundColor: "$blue10", - }, - }, }, defaultVariants: { size: "1", diff --git a/packages/design-system/src/components/panel.tsx b/packages/design-system/src/components/panel.tsx index b4a361629b..d4abe937ef 100644 --- a/packages/design-system/src/components/panel.tsx +++ b/packages/design-system/src/components/panel.tsx @@ -2,10 +2,9 @@ import { css, styled } from "../stitches.config"; export const panelStyles = css({ overflow: "auto", - backgroundColor: "$colors$slate4", + backgroundColor: "$slate4", borderRadius: "$1", - boxShadow: - "0px 2px 7px rgba(0, 0, 0, 0.1), 0px 5px 17px rgba(0, 0, 0, 0.15), inset 0 0 1px 1px $colors$slate1, 0 0 0 1px $colors$slate8", + boxShadow: "0px 2px 7px rgba(0, 0, 0, 0.1), 0px 5px 17px rgba(0, 0, 0, 0.15)", }); export const Panel = styled("div", panelStyles); diff --git a/packages/design-system/src/components/popover.tsx b/packages/design-system/src/components/popover.tsx index 1e7bf750f0..7ef6455061 100644 --- a/packages/design-system/src/components/popover.tsx +++ b/packages/design-system/src/components/popover.tsx @@ -74,12 +74,12 @@ export const PopoverHeader = ({ title }: PopoverHeaderProps) => { > {title} - + - + ); }; diff --git a/packages/design-system/src/components/primitives/dnd/placement-indicator.tsx b/packages/design-system/src/components/primitives/dnd/placement-indicator.tsx index 43b51fe45a..c9770086eb 100644 --- a/packages/design-system/src/components/primitives/dnd/placement-indicator.tsx +++ b/packages/design-system/src/components/primitives/dnd/placement-indicator.tsx @@ -4,7 +4,7 @@ import { type Placement } from "./geometry-utils"; const placementStyle = { boxSizing: "content-box", position: "absolute", - background: "$dropPlacement", + background: "$blue10", pointerEvents: "none", }; diff --git a/packages/design-system/src/components/search-field.stories.tsx b/packages/design-system/src/components/search-field.stories.tsx new file mode 100644 index 0000000000..275f521221 --- /dev/null +++ b/packages/design-system/src/components/search-field.stories.tsx @@ -0,0 +1,45 @@ +import { expect } from "@storybook/jest"; +import { type ComponentStory } from "@storybook/react"; +import { userEvent, waitFor, within } from "@storybook/testing-library"; +import React from "react"; +import { SearchField } from "./search-field"; + +export default { + component: SearchField, +}; + +export const Empty: ComponentStory = () => { + return ; +}; + +export const WithInitialValue: ComponentStory = () => { + return ; +}; + +let updateValue: (value: string) => void; + +export const UpdateValue: ComponentStory = () => { + const [value, setValue] = React.useState("something"); + updateValue = setValue; + return ; +}; + +UpdateValue.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + const search = canvas.getByTitle("Search"); + expect(search.value).toBe("something"); + updateValue?.("test"); + await waitFor(() => expect(search.value).toBe("test")); +}; + +export const Reset: ComponentStory = () => { + return ; +}; + +Reset.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + const search = canvas.getByTitle("Search"); + await userEvent.click(canvas.getByTitle("Reset search")); + await waitFor(() => expect(search.value).toBe("")); + expect(await canvas.queryByTitle("Reset search")).toBeNull(); +}; diff --git a/packages/design-system/src/components/search-field.tsx b/packages/design-system/src/components/search-field.tsx new file mode 100644 index 0000000000..2bd137cfd6 --- /dev/null +++ b/packages/design-system/src/components/search-field.tsx @@ -0,0 +1,75 @@ +import { + type ComponentProps, + type ForwardRefRenderFunction, + forwardRef, + useRef, + useState, + useEffect, +} from "react"; +import { Cross2Icon, MagnifyingGlassIcon } from "@webstudio-is/icons"; +import { TextField } from "./text-field"; +import { IconButton } from "./icon-button"; +import { styled } from "../stitches.config"; + +const SearchIcon = styled(MagnifyingGlassIcon, { + color: "$hint", + padding: "$1", +}); + +const SearchFieldBase: ForwardRefRenderFunction< + HTMLInputElement, + ComponentProps & { onCancel?: () => void } +> = (props, ref) => { + const { + onChange, + onCancel, + value: propsValue = "", + onKeyDown, + ...rest + } = props; + const [value, setValue] = useState(String(propsValue)); + const inputRef = useRef(null); + useEffect(() => { + setValue(String(propsValue)); + }, [propsValue]); + return ( + } + suffix={ + value.length > 0 ? ( + { + inputRef.current?.focus(); + setValue(""); + onCancel?.(); + }} + > + + + ) : null + } + onChange={(event) => { + setValue(event.target.value); + onChange?.(event); + }} + onKeyDown={(event) => { + // Make sure we clear the search on esc and preserve the default browser behavior + if (event.key === "Escape" && value.length !== 0) { + event.stopPropagation(); + return; + } + onKeyDown?.(event); + }} + /> + ); +}; + +export const SearchField = forwardRef(SearchFieldBase); diff --git a/packages/design-system/src/components/text-area.tsx b/packages/design-system/src/components/text-area.tsx index 191ad5438c..f04dcbeb2d 100644 --- a/packages/design-system/src/components/text-area.tsx +++ b/packages/design-system/src/components/text-area.tsx @@ -11,7 +11,7 @@ export const TextArea = styled("textarea", { width: "100%", WebkitTapHighlightColor: "rgba(0,0,0,0)", backgroundColor: "$loContrast", - boxShadow: "inset 0 0 0 1px $colors$slate7", + boxShadow: "inset 0 0 0 1px $colors$muted", color: "$hiContrast", fontVariantNumeric: "tabular-nums", position: "relative", diff --git a/packages/design-system/src/components/text-field.stories.tsx b/packages/design-system/src/components/text-field.stories.tsx index f49a1c0adb..0a7fa39955 100644 --- a/packages/design-system/src/components/text-field.stories.tsx +++ b/packages/design-system/src/components/text-field.stories.tsx @@ -30,6 +30,7 @@ export const NativeProps: ComponentStory = () => { + @@ -41,6 +42,8 @@ export const Variants: ComponentStory = () => { + + ); }; diff --git a/packages/design-system/src/components/text-field.tsx b/packages/design-system/src/components/text-field.tsx index b1760fcf84..6f6574903c 100644 --- a/packages/design-system/src/components/text-field.tsx +++ b/packages/design-system/src/components/text-field.tsx @@ -1,8 +1,71 @@ import { mergeRefs } from "@react-aria/utils"; import React from "react"; import { useFocusWithin } from "@react-aria/interactions"; -import { styled } from "../stitches.config"; -import { Flex } from "./flex"; +import { css, styled } from "../stitches.config"; +import { ChevronLeftIcon } from "@webstudio-is/icons"; +import { cssVars } from "@webstudio-is/css-vars"; + +const backgroundColorVar = cssVars.define("background-color"); +const colorVar = cssVars.define("color"); + +const getTextFieldSuffixCssVars = (state: "focus" | "hover") => { + if (state === "focus") { + return { + [backgroundColorVar]: "$colors$blue10", + [colorVar]: "white", + }; + } + + return { + [backgroundColorVar]: "$colors$slate7", + [colorVar]: "$colors$hiContrast", + }; +}; + +const textFieldIconBaseStyle = css({ + height: "$5", + minWidth: "$2", + display: "inline-flex", + alignItems: "center", + justifyContent: "center", + borderRadius: 2, +}); + +// Trigger can be used as a button, which is focusable/hoverable itself or as an icon, +// which has the same states but activated over the parent component element. +export const TextFieldIconButton = styled( + "button", + { + all: "unset", + "&:hover": { + backgroundColor: "$slate7", + color: "$hiContrast", + }, + "&:focus": { + backgroundColor: "$blue10", + color: "white", + }, + variants: { + state: { + active: { + backgroundColor: "$blue10", + color: "white", + "&:hover": { + backgroundColor: "$blue10", + color: "white", + }, + }, + }, + }, + }, + textFieldIconBaseStyle +); + +export const TextFieldIcon = styled("span", textFieldIconBaseStyle, { + // Icon receives colors from parent. + backgroundColor: cssVars.use(backgroundColorVar), + color: cssVars.use(colorVar), +}); const InputBase = styled("input", { // Reset @@ -52,7 +115,7 @@ const InputBase = styled("input", { }, "&::placeholder": { - color: "$slate9", + color: "$hint", }, "&:disabled": { @@ -78,12 +141,10 @@ const TextFieldBase = styled("div", { fontSize: "$1", height: 28, // @todo waiting for the sizing scale lineHeight: 1, - "&:focus-within": { boxShadow: "inset 0px 0px 0px 1px $colors$blue10, 0px 0px 0px 1px $colors$blue10", }, - "&[aria-disabled=true]": { pointerEvents: "none", backgroundColor: "$slate2", @@ -94,7 +155,6 @@ const TextFieldBase = styled("div", { boxShadow: "inset 0px 0px 0px 1px $colors$slate7", }, }, - variants: { variant: { ghost: { @@ -108,7 +168,7 @@ const TextFieldBase = styled("div", { "&:focus": { backgroundColor: "$loContrast", boxShadow: - "inset 0px 0px 0px 1px $colors$blue8, 0px 0px 0px 1px $colors$blue10", + "inset 0px 0px 0px 1px $colors$blue10, 0px 0px 0px 1px $colors$blue10", }, "&:disabled": { backgroundColor: "transparent", @@ -117,6 +177,10 @@ const TextFieldBase = styled("div", { backgroundColor: "transparent", }, }, + button: { + "&:hover": getTextFieldSuffixCssVars("hover"), + "&:focus-within": getTextFieldSuffixCssVars("focus"), + }, }, state: { invalid: { @@ -133,20 +197,46 @@ const TextFieldBase = styled("div", { "inset 0px 0px 0px 1px $colors$green8, 0px 0px 0px 1px $colors$green8", }, }, + active: { + boxShadow: + "inset 0px 0px 0px 1px $colors$blue10, 0px 0px 0px 1px $colors$blue10", + ...getTextFieldSuffixCssVars("focus"), + }, }, + // Preffix and suffix are responsible for their spacing withPrefix: { true: { - paddingLeft: 2, + paddingLeft: 0, }, }, withSuffix: { true: { - paddingRight: 2, + paddingRight: 0, }, }, }, }); +const PrefixSlot = styled("div", { + display: "flex", + alignItems: "center", + flexShrink: 0, + order: 0, + padding: 2, + paddingRight: 0, + borderRadius: 2, +}); + +const SuffixSlot = styled("div", { + display: "flex", + alignItems: "center", + flexShrink: 0, + order: 2, + padding: 2, + paddingLeft: 0, + borderRadius: 2, +}); + export type TextFieldProps = Pick< React.ComponentProps, "variant" | "state" | "css" @@ -161,16 +251,22 @@ export const TextField = React.forwardRef( (props, forwardedRef) => { const { prefix, - suffix, css, disabled, inputRef, state, - variant, + variant: variantProp, onFocus, onBlur, + onClick, + type, + // prevent spreading it into the dom + suffix: suffixProp, ...textFieldProps } = props; + let suffix = suffixProp; + const variant = + type === "button" && variantProp === undefined ? "button" : variantProp; const internalInputRef = React.useRef(null); @@ -180,16 +276,23 @@ export const TextField = React.forwardRef( const { focusWithinProps } = useFocusWithin({ isDisabled: disabled, - onFocusWithin: (event) => { - // @ts-expect-error Type mismatch from react-aria - onFocus?.(event); - }, - onBlurWithin: (event) => { - // @ts-expect-error Type mismatch from react-aria - onBlur?.(event); - }, + // @ts-expect-error Type mismatch from react-aria + onFocusWithin: onFocus, + // @ts-expect-error Type mismatch from react-aria + onBlurWithin: onBlur, }); + if (type === "button" && suffix === undefined) { + suffix = ( + { + internalInputRef.current?.click(); + }} + /> + ); + } + return ( ( > {/* We want input to be the first element in DOM so it receives the focus first */} - {prefix && ( - - {prefix} - - )} - - {suffix && ( - - {suffix} - - )} + {prefix && {prefix}} + {suffix && {suffix}} ); } diff --git a/packages/design-system/src/components/tree/placement-indicator.tsx b/packages/design-system/src/components/tree/placement-indicator.tsx index de38e5d226..b9ef27e8b1 100644 --- a/packages/design-system/src/components/tree/placement-indicator.tsx +++ b/packages/design-system/src/components/tree/placement-indicator.tsx @@ -23,7 +23,7 @@ const Circle = styled(Box, { width: CIRCLE_SIZE, height: CIRCLE_SIZE, position: "absolute", - border: "solid $dropPlacement", + border: "solid $blue10", borderWidth: 2, borderRadius: "50%", pointerEvents: "none", @@ -32,7 +32,7 @@ const Circle = styled(Box, { const Line = styled(Box, { boxSizing: "content-box", position: "absolute", - background: "$dropPlacement", + background: "$blue10", pointerEvents: "none", outline: "solid $loContrast", outlineWidth: OUTLINE_WIDTH, diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts index 4d6604439f..4cf5adcdbd 100644 --- a/packages/design-system/src/index.ts +++ b/packages/design-system/src/index.ts @@ -10,7 +10,8 @@ export { Label } from "./components/label"; export * as ToggleGroup from "./components/toggle-group"; export * from "./components/progress-radial"; export * from "./components/select"; -export { TextField } from "./components/text-field"; +export * from "./components/text-field"; +export { SearchField } from "./components/search-field"; export { Flex } from "./components/flex"; export { Image } from "./components/image"; export { Grid } from "./components/grid"; diff --git a/packages/design-system/src/stitches.config.ts b/packages/design-system/src/stitches.config.ts index 460364c816..614df53171 100644 --- a/packages/design-system/src/stitches.config.ts +++ b/packages/design-system/src/stitches.config.ts @@ -195,9 +195,9 @@ const { // Semantic colors hiContrast: "$slate12", loContrast: "$slate1", - hint: "$slate10", + hint: "$slate9", muted: "$slate6", - primary: "$blue12", + primary: "$blue10", canvas: "hsl(0 0% 93%)", panel: "white", panelOutline: "hsl(0deg 0% 0% / 10%)", @@ -206,7 +206,6 @@ const { shadowDark: "hsl(206 22% 7% / 20%)", background: "$slate1", text: "$slate12", - dropPlacement: "$blue9", }, fonts: { sans: "Inter, -apple-system, system-ui, sans-serif", @@ -478,12 +477,11 @@ export const darkTheme = createTheme({ ...goldDarkA, // Semantic colors - hint: "$slate10", - muted: "$slate8", - primary: "$blue9", hiContrast: "$slate12", - // radix colors in this range are either too dark/too light, nothing gray/black in the 16-17% luminosity range loContrast: "hsl(0 0% 17%)", + hint: "$slate9", + muted: "$slate8", + primary: "$blue10", canvas: "hsl(0 0% 15%)", panel: "$slate3", panelOutline: "hsl(0deg 0% 100% / 10%)", @@ -492,6 +490,5 @@ export const darkTheme = createTheme({ shadowDark: "hsl(206 22% 7% / 20%)", background: "$slate12", text: "$slate1", - dropPlacement: "$blue9", }, });