Merge branch 'main' into remix/watch-less

This commit is contained in:
Oleg Isonen authored and GitHub committed 2022-10-31 08:56:37 +01:00
commit 233906d548
37 files changed
+674 -256

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

@@ -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));
@@ -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 = ({
<ValuePickerPopover
title="Fonts"
content={<FontsManager value={String(value.value)} onChange={setValue} />}
onOpenChange={setIsOpen}
>
{/* @todo this should be part of the design system, probably a varian="button" */}
<TextField defaultValue={value.value} />
<TextField
defaultValue={value.value}
state={isOpen ? "active" : undefined}
/>
</ValuePickerPopover>
);
};
@@ -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<HTMLInputElement>(null);
return (
<TextField
type="search"
value={search}
inputRef={inputRef}
prefix={
<IconButton aria-label="Search">
<MagnifyingGlassIcon />
</IconButton>
}
suffix={
search.length > 0 && (
<IconButton
aria-label="Reset search"
onClick={() => {
setSearch("");
inputRef.current?.focus();
}}
>
<Cross2Icon />
</IconButton>
)
}
placeholder={components[selectedInstanceData.component].label}
onChange={(event) => {
const { value } = event.target;
setSearch(value.toLowerCase());
}}
/>
);
};
@@ -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" ? (
<IconButton {...getToggleButtonProps()}>
<TextFieldIconButton
{...getToggleButtonProps()}
state={isOpen ? "active" : undefined}
>
<ChevronDownIcon />
</IconButton>
</TextFieldIconButton>
) : value.type === "unit" ? (
unitSelectElement
) : null;
@@ -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}
>
<StyledTrigger>
<SelectPrimitive.Value>{value}</SelectPrimitive.Value>
</StyledTrigger>
<SelectPrimitive.SelectTrigger asChild>
<StyledTrigger>
<SelectPrimitive.Value>{value}</SelectPrimitive.Value>
</StyledTrigger>
</SelectPrimitive.SelectTrigger>
<SelectPrimitive.Portal>
<SelectContent onCloseAutoFocus={onCloseAutoFocus}>
<SelectScrollUpButton>
@@ -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 (
<Popover open={isOpen} onOpenChange={setIsOpen} modal>
<Popover open={isOpen} onOpenChange={handleOpenChange} modal>
<PopoverTrigger
asChild
ref={ref}
onClick={() => {
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 }}
>
<PopoverHeader title={title} />
{content}
@@ -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 (
<>
<Box css={{ px: "$3", py: "$1" }}>
<Search
onSearch={setSearch}
selectedInstanceData={selectedInstanceData}
<SearchField
placeholder="Search"
onChange={(event) => {
setSearch(event.target.value);
}}
onCancel={() => {
setSearch("");
}}
/>
</Box>
<Box css={{ overflow: "auto" }}>
@@ -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<Asset | PreviewAsset>
): Array<{ label: string }> => {
const system = Array.from(SYSTEM_FONTS.keys()).map((label) => ({ label }));
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 });
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton aria-label="Font menu button">
<DotsHorizontalIcon />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent>
<DropdownMenuItem
onSelect={() => {
onDelete();
}}
>
<Text>Delete font</Text>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
<Flex align="center" justify="center" css={{ height: 100 }}>
Font not found
</Flex>
);
};
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 (
<Flex
gap="3"
direction="column"
css={{ padding: "$1", paddingTop: "$2", height: 460, overflow: "hidden" }}
>
<Box css={{ padding: "$2" }}>
<Flex direction="column" css={{ overflow: "hidden", py: "$1" }}>
<Flex css={{ py: "$2", px: "$3" }} gap="2" direction="column">
<AssetUpload type="font" />
</Box>
<SearchField
{...getInputProps({ value: search })}
autoFocus
placeholder="Search"
onBlur={undefined}
onFocus={() => {
setHighlightedIndex(-1);
}}
onCancel={handleCancelSearch}
/>
</Flex>
<Separator css={{ my: "$1" }} />
{filteredItems.length === 0 && <NotFound />}
<Flex
{...getComboboxProps()}
css={{ flexDirection: "column", gap: "$3" }}
css={{
flexDirection: "column",
gap: "$3",
px: "$3",
}}
>
<TextField
type="search"
prefix={<MagnifyingGlassIcon />}
{...getInputProps()}
/>
<fieldset>
<legend>Choose an item</legend>
<Flex {...menuProps} css={{ flexDirection: "column" }}>
{items.map((item, index) => {
return (
<ComboboxListboxItem
{...getItemProps({ item, index })}
key={index}
>
{item.label}
<Listbox {...getMenuProps()}>
{uploadedItems.length !== 0 && (
<ListboxItem disabled>{"Uploaded"}</ListboxItem>
)}
{uploadedItems.map((item, index) => {
return (
<ListboxItem
{...getItemProps({
item,
index,
})}
key={index}
suffix={
<ItemMenu
onOpenChange={() => {
setHighlightedIndex(index);
}}
onDelete={() => {
handleDeleteByLabel(item.label);
handleDelete(item.label);
}}
onFocusTrigger={() => {
setHighlightedIndex(-1);
}}
/>
</ComboboxListboxItem>
);
})}
</Flex>
</fieldset>
}
onFocus={(event) => {
// We need to ignore focus on a menu button inside
if (event.target === event.currentTarget) {
setHighlightedIndex(index);
}
}}
>
{item.label}
</ListboxItem>
);
})}
{systemItems.length !== 0 && (
<>
<Separator css={{ my: "$1" }} />
<ListboxItem disabled>{"System"}</ListboxItem>
</>
)}
{systemItems.map((item, index) => {
const globalIndex = uploadedItems.length + index;
return (
<ListboxItem
{...getItemProps({
item,
index: globalIndex,
})}
key={index}
onFocus={() => {
setHighlightedIndex(globalIndex);
}}
>
{item.label}
</ListboxItem>
);
})}
</Listbox>
</Flex>
</Flex>
);
@@ -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 (
<DropdownMenu
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
onOpenChange(open);
}}
>
<DropdownMenuTrigger asChild>
<MenuButton
aria-label="Font menu"
onFocus={onFocusTrigger}
tabIndex={0}
>
<DotsHorizontalIcon />
</MenuButton>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent align="start">
<DropdownMenuItem
onSelect={() => {
onDelete();
}}
>
<Text>Delete font</Text>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
);
};
@@ -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<typeof ListboxItemBase> & {
highlighted?: boolean;
disabled?: boolean;
selected?: boolean;
suffix?: JSX.Element;
}
>(
(
{
highlighted,
disabled = false,
selected = false,
children,
suffix,
...props
},
ref
) => {
return (
<ListboxItemBase
{...props}
ref={ref}
{...(highlighted ? { ["data-highlighted"]: true } : {})}
{...(disabled ? { ["aria-disabled"]: true, disabled: true } : {})}
tabIndex={disabled ? -1 : 0}
>
{selected && <CheckIcon />}
<Flex css={{ gridColumn: 2 }} align="center">
<Text variant="label" truncate color={disabled ? "hint" : "contrast"}>
{children}
</Text>
{suffix}
</Flex>
</ListboxItemBase>
);
}
);
ListboxItem.displayName = "ListboxItem";
@@ -20,7 +20,6 @@ export const usePublishShortcuts = (publish: Publish) => {
useHotkeys(
shortcuts[name],
(event) => {
event.preventDefault();
publish({
type: "shortcut",
payload: { name, key: event.key },
@@ -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",
@@ -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 = <Item,>({
};
};
type UseComboboxProps<Item> = {
type UseComboboxProps<Item> = UseDownshiftComboboxProps<Item> & {
items: Array<Item>;
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 = <Item,>({
onItemHighlight,
stateReducer = (state, { changes }) => changes,
match,
...rest
}: UseComboboxProps<Item>) => {
const { filteredItems, filter, resetFilter } = useFilter<Item>({
items,
@@ -159,7 +161,9 @@ export const useCombobox = <Item,>({
});
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 = <Item,>({
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 = <Item,>({
items: filteredItems,
getItemProps: enhancedGetItemProps,
getMenuProps: enhancedGetMenuProps,
resetFilter,
};
};
@@ -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",
@@ -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);
@@ -74,12 +74,12 @@ export const PopoverHeader = ({ title }: PopoverHeaderProps) => {
>
<Text variant="title">{title}</Text>
<PopoverClose asChild>
<IconButton size="1" css={{ marginRight: "$2" }} aria-label="Close">
<IconButton size="2" css={{ marginRight: "$2" }} aria-label="Close">
<Cross1Icon />
</IconButton>
</PopoverClose>
</Flex>
<Separator css={{ height: 2 }} />
<Separator />
</>
);
};
@@ -4,7 +4,7 @@ import { type Placement } from "./geometry-utils";
const placementStyle = {
boxSizing: "content-box",
position: "absolute",
background: "$dropPlacement",
background: "$blue10",
pointerEvents: "none",
};
@@ -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<typeof SearchField> = () => {
return <SearchField />;
};
export const WithInitialValue: ComponentStory<typeof SearchField> = () => {
return <SearchField value={"somevalue"} title="Search" />;
};
let updateValue: (value: string) => void;
export const UpdateValue: ComponentStory<typeof SearchField> = () => {
const [value, setValue] = React.useState("something");
updateValue = setValue;
return <SearchField value={value} title="Search" />;
};
UpdateValue.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const search = canvas.getByTitle<HTMLInputElement>("Search");
expect(search.value).toBe("something");
updateValue?.("test");
await waitFor(() => expect(search.value).toBe("test"));
};
export const Reset: ComponentStory<typeof SearchField> = () => {
return <SearchField value="something" title="Search" />;
};
Reset.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
const search = canvas.getByTitle<HTMLInputElement>("Search");
await userEvent.click(canvas.getByTitle("Reset search"));
await waitFor(() => expect(search.value).toBe(""));
expect(await canvas.queryByTitle("Reset search")).toBeNull();
};
@@ -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<typeof TextField> & { onCancel?: () => void }
> = (props, ref) => {
const {
onChange,
onCancel,
value: propsValue = "",
onKeyDown,
...rest
} = props;
const [value, setValue] = useState(String(propsValue));
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setValue(String(propsValue));
}, [propsValue]);
return (
<TextField
{...rest}
ref={ref}
type="search"
value={value}
inputRef={inputRef}
prefix={<SearchIcon />}
suffix={
value.length > 0 ? (
<IconButton
aria-label="Reset search"
title="Reset search"
tabIndex={-1}
onClick={() => {
inputRef.current?.focus();
setValue("");
onCancel?.();
}}
>
<Cross2Icon />
</IconButton>
) : 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);
@@ -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",
@@ -30,6 +30,7 @@ export const NativeProps: ComponentStory<typeof TextField> = () => {
<TextField disabled placeholder="This is a disabled placeholder" />
<TextField type="number" defaultValue={25} />
<TextField type="search" placeholder="This is a search input" />
<TextField type="button" defaultValue={"Arial"} />
<TextField readOnly value="Read-only" />
<TextField disabled value="Disabled" />
</Flex>
@@ -41,6 +42,8 @@ export const Variants: ComponentStory<typeof TextField> = () => {
<Flex direction="column" gap={3}>
<TextField />
<TextField variant="ghost" />
<TextField type="button" />
<TextField type="button" state="active" />
</Flex>
);
};
@@ -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<typeof TextFieldBase>,
"variant" | "state" | "css"
@@ -161,16 +251,22 @@ export const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
(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<HTMLInputElement>(null);
@@ -180,16 +276,23 @@ export const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
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 = (
<TextFieldIcon
as={ChevronLeftIcon}
onClick={() => {
internalInputRef.current?.click();
}}
/>
);
}
return (
<TextFieldBase
{...focusWithinProps}
@@ -204,34 +307,15 @@ export const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
>
{/* We want input to be the first element in DOM so it receives the focus first */}
<InputBase
disabled={disabled}
{...textFieldProps}
type={type}
disabled={disabled}
onClick={onClick}
ref={mergeRefs(internalInputRef, inputRef ?? null)}
/>
{prefix && (
<Flex
css={{
alignItems: "center",
flexShrink: 0,
order: 0,
}}
>
{prefix}
</Flex>
)}
{suffix && (
<Flex
css={{
alignItems: "center",
flexShrink: 0,
order: 2,
}}
>
{suffix}
</Flex>
)}
{prefix && <PrefixSlot>{prefix}</PrefixSlot>}
{suffix && <SuffixSlot>{suffix}</SuffixSlot>}
</TextFieldBase>
);
}
@@ -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,
+2 -1
View File
@@ -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";
@@ -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",
},
});