Merge branch 'main' into fix-css-value-input
No files matched your search
@@ -0,0 +1,19 @@
|
||||
name: Fonts
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "packages/fonts/**/*"
|
||||
- ".github/workflows/fonts.yml"
|
||||
- ".github/actions/publish/action.yaml"
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/publish
|
||||
with:
|
||||
workdir: ./packages/fonts
|
||||
filter: "@webstudio-is/fonts"
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -57,4 +57,16 @@ jobs:
|
||||
with:
|
||||
workdir: ./packages/tsconfig
|
||||
filter: "@webstudio-is/tsconfig"
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
- uses: ./.github/actions/publish-test
|
||||
if: always()
|
||||
with:
|
||||
workdir: ./packages/fonts
|
||||
filter: "@webstudio-is/fonts"
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
- uses: ./.github/actions/publish-test
|
||||
if: always()
|
||||
with:
|
||||
workdir: ./packages/prisma-client
|
||||
filter: "@webstudio-is/prisma-client"
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Prisma Client
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "packages/prisma-client/**/*"
|
||||
- ".github/workflows/prisma-client.yml"
|
||||
- ".github/actions/publish/action.yaml"
|
||||
branches:
|
||||
- main
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: ./.github/actions/publish
|
||||
with:
|
||||
workdir: ./packages/prisma-client
|
||||
filter: "@webstudio-is/prisma-client"
|
||||
token: ${{ secrets.NPM_TOKEN }}
|
||||
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -65,6 +65,7 @@ const useElementsTree = () => {
|
||||
if (rootInstance === undefined) return;
|
||||
|
||||
return createElementsTree({
|
||||
sandbox: true,
|
||||
instance: rootInstance,
|
||||
breakpoints,
|
||||
Component: WrapperComponentDev,
|
||||
|
||||
@@ -16,7 +16,7 @@ export const useSync = ({
|
||||
if (entries.length === 0) return;
|
||||
|
||||
// @todo this entire queueing logic needs to be gone, it's a workaround,
|
||||
// because prisma can't do atomic updates yet with embeded documents
|
||||
// because prisma can't do atomic updates yet with sandbox documents
|
||||
// and backend fetches and updates big objects, so if we send quickly,
|
||||
// we end up overwriting things
|
||||
enqueue(() =>
|
||||
|
||||
@@ -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 {
|
||||
@@ -270,9 +270,12 @@ export const CssValueInput = ({
|
||||
|
||||
const suffix =
|
||||
value.type === "keyword" ? (
|
||||
<IconButton css={{ width: "auto" }} {...getToggleButtonProps()}>
|
||||
<TextFieldIconButton
|
||||
{...getToggleButtonProps()}
|
||||
state={isOpen ? "active" : undefined}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
</TextFieldIconButton>
|
||||
) : value.type === "unit" ? (
|
||||
unitSelectElement
|
||||
) : null;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectViewport,
|
||||
SelectItem,
|
||||
SelectContent,
|
||||
TextFieldIconButton,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "@webstudio-is/icons";
|
||||
import { isValid } from "../parse-css-value";
|
||||
@@ -88,23 +89,8 @@ export const useUnitSelect = ({
|
||||
return [isOpen, select];
|
||||
};
|
||||
|
||||
const StyledTrigger = styled(SelectPrimitive.SelectTrigger, {
|
||||
all: "unset",
|
||||
height: "$5",
|
||||
width: "auto",
|
||||
px: "$1",
|
||||
borderRadius: 2,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
color: "$hiContrast",
|
||||
justifyContent: "center",
|
||||
"&:hover": {
|
||||
backgroundColor: "$slate6",
|
||||
},
|
||||
"&:focus": {
|
||||
backgroundColor: "$blue10",
|
||||
color: "$loContrast",
|
||||
},
|
||||
const StyledTrigger = styled(TextFieldIconButton, {
|
||||
px: 3,
|
||||
});
|
||||
|
||||
type UnitSelectProps = {
|
||||
@@ -131,9 +117,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 },
|
||||
|
||||
@@ -21,12 +21,18 @@ initSentry({
|
||||
|
||||
// Forward DEBUG env variable defined on the server to the client-side debug() utility.
|
||||
// This way you can set it once server-side and have the logging or feature flags enabled on both, server and client
|
||||
if (
|
||||
typeof localStorage !== "undefined" &&
|
||||
// Client-side value should override the server-side one
|
||||
(localStorage.debug == null || localStorage.debug === "")
|
||||
) {
|
||||
localStorage.debug = env.DEBUG;
|
||||
// https://github.com/debug-js/debug#browser-support
|
||||
try {
|
||||
if (
|
||||
// Client-side value should override the server-side one
|
||||
localStorage.debug == null ||
|
||||
localStorage.debug === ""
|
||||
) {
|
||||
localStorage.debug = env.DEBUG;
|
||||
}
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Failed to set localStorage.debug due to Error:", e);
|
||||
}
|
||||
|
||||
hydrate(<RemixBrowser />, document);
|
||||
@@ -1,25 +0,0 @@
|
||||
<svg
|
||||
width="430"
|
||||
height="430"
|
||||
viewBox="0 0 430 430"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M93.5156 350.898C90.9115 354.674 86.2891 356.562 79.6484 356.562C73.138 356.562 67.6042 354.74 63.0469 351.094C58.4896 347.448 54.7135 342.891 51.7188 337.422C45.7292 326.484 42.7344 315.547 42.7344 304.609C42.7344 293.672 43.9062 282.474 46.25 271.016C48.724 259.557 52.0443 247.513 56.2109 234.883C60.3776 222.122 65.1953 209.102 70.6641 195.82C76.263 182.539 82.1875 169.583 88.4375 156.953C101.198 130.651 113.893 107.865 126.523 88.5938H124.375C108.88 88.5938 94.4922 92.1094 81.2109 99.1406C77.9557 101.615 74.8307 102.852 71.8359 102.852C65.5859 102.852 59.5312 99.7917 53.6719 93.6719C40.1302 79.349 48.2031 67.5 77.8906 58.125C91.8229 53.5677 103.021 51.2891 111.484 51.2891C120.078 51.2891 126.979 52.6562 132.188 55.3906C137.396 57.9948 141.823 61.1198 145.469 64.7656C151.198 70.4948 155.234 76.1589 157.578 81.7578C160.052 87.3568 161.289 92.3047 161.289 96.6016C161.289 100.768 159.987 106.432 157.383 113.594C154.909 120.625 151.719 128.177 147.812 136.25L134.531 162.031C129.844 171.016 125.156 180.456 120.469 190.352C115.781 200.247 111.094 210.143 106.406 220.039C101.719 229.935 97.5521 239.57 93.9062 248.945C85.9635 269.909 81.9922 287.682 81.9922 302.266C81.9922 310.339 82.7734 317.305 84.3359 323.164L104.844 288.984C146.51 221.797 187.656 171.276 228.281 137.422C239.74 127.786 251.068 120.234 262.266 114.766C264.089 113.854 266.237 113.398 268.711 113.398C271.185 113.398 273.789 113.724 276.523 114.375C279.388 114.896 282.057 115.742 284.531 116.914C290.26 119.648 293.125 122.969 293.125 126.875C293.125 128.698 292.474 130.391 291.172 131.953L284.336 139.375C258.294 166.979 240.781 205.521 231.797 255C228.932 270.234 227.5 282.344 227.5 291.328C227.5 300.182 227.76 306.237 228.281 309.492C228.802 312.747 229.844 315.807 231.406 318.672C234.922 325.182 240.195 328.438 247.227 328.438C254.258 328.438 262.07 325.833 270.664 320.625C279.258 315.417 287.917 308.255 296.641 299.141C305.495 290.026 314.023 279.349 322.227 267.109C330.56 254.74 337.917 241.523 344.297 227.461C358.62 196.341 365.781 166.654 365.781 138.398C365.781 116.654 360.964 98.2292 351.328 83.125C348.724 79.2188 351.263 74.1406 358.945 67.8906C366.758 61.3802 375.547 58.125 385.312 58.125C398.203 58.125 406.927 69.5833 411.484 92.5C413.047 100.052 413.828 109.948 413.828 122.188C413.828 134.427 412.331 147.904 409.336 162.617C406.341 177.331 402.109 191.719 396.641 205.781C391.302 219.714 384.922 233.125 377.5 246.016C370.208 258.906 362.201 270.951 353.477 282.148C344.753 293.346 335.508 303.568 325.742 312.812C315.977 321.927 306.016 329.74 295.859 336.25C274.896 350.052 255.039 356.953 236.289 356.953C219.102 356.953 206.276 350.182 197.812 336.641C190.391 324.922 186.68 310.013 186.68 291.914C186.68 261.966 192.474 232.865 204.062 204.609C193.255 216.719 182.708 229.219 172.422 242.109C146.901 275.443 129.128 299.336 119.102 313.789L93.5156 350.898Z"
|
||||
fill="url(#paint0_linear_2_9)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_2_9"
|
||||
x1="203.5"
|
||||
y1="64"
|
||||
x2="203.5"
|
||||
y2="364"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stop-color="#3355FA" />
|
||||
<stop offset="0.869792" stop-color="#663399" stop-opacity="0.87" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -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";
|
||||
@@ -38,7 +39,7 @@ const Listbox = styled("ul", panelStyles, {
|
||||
display: "none",
|
||||
},
|
||||
},
|
||||
isEmpty: {
|
||||
empty: {
|
||||
true: {
|
||||
display: "none",
|
||||
},
|
||||
@@ -129,7 +130,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."
|
||||
@@ -152,6 +153,7 @@ export const useCombobox = <Item,>({
|
||||
onItemHighlight,
|
||||
stateReducer = (state, { changes }) => changes,
|
||||
match,
|
||||
...rest
|
||||
}: UseComboboxProps<Item>) => {
|
||||
const { filteredItems, filter, resetFilter } = useFilter<Item>({
|
||||
items,
|
||||
@@ -160,7 +162,9 @@ export const useCombobox = <Item,>({
|
||||
});
|
||||
|
||||
const downshiftProps = useDownshiftCombobox({
|
||||
...rest,
|
||||
items: filteredItems,
|
||||
defaultHighlightedIndex: -1,
|
||||
selectedItem: value, // Prevent downshift warning about switching controlled mode
|
||||
stateReducer,
|
||||
itemToString,
|
||||
@@ -204,7 +208,7 @@ export const useCombobox = <Item,>({
|
||||
return {
|
||||
...getMenuProps(options),
|
||||
state: isOpen ? "open" : "closed",
|
||||
isEmpty: filteredItems.length === 0,
|
||||
empty: filteredItems.length === 0,
|
||||
};
|
||||
},
|
||||
[getMenuProps, isOpen, filteredItems.length]
|
||||
@@ -215,6 +219,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, &[data-state=focus]": {
|
||||
"&: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,
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@webstudio-is/react-sdk",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"description": "Webstudio JavaScript / TypeScript API",
|
||||
"author": "Webstudio <github@webstudio.is>",
|
||||
"homepage": "https://webstudio.is",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Instance } from "../db";
|
||||
import type { Breakpoint } from "../css";
|
||||
import { type WrapperComponentProps } from "./wrapper-component";
|
||||
import { Scripts, ScrollRestoration } from "@remix-run/react";
|
||||
import { SessionStoragePolyfill } from "./session-storage-polyfill";
|
||||
|
||||
export type ChildrenUpdates = Array<
|
||||
| string
|
||||
@@ -24,11 +25,13 @@ export type OnChangeChildren = (change: {
|
||||
}) => void;
|
||||
|
||||
export const createElementsTree = ({
|
||||
sandbox,
|
||||
instance,
|
||||
breakpoints,
|
||||
Component,
|
||||
onChangeChildren,
|
||||
}: {
|
||||
sandbox?: boolean;
|
||||
instance: Instance;
|
||||
breakpoints: Array<Breakpoint>;
|
||||
Component: (props: WrapperComponentProps) => JSX.Element;
|
||||
@@ -46,6 +49,7 @@ export const createElementsTree = ({
|
||||
children: [
|
||||
<Fragment key="children">
|
||||
{children}
|
||||
{sandbox && <SessionStoragePolyfill />}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</Fragment>,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// This is a temporary work around for https://github.com/remix-run/remix/issues/3659
|
||||
|
||||
// The code is based on this discussion https://bugs.chromium.org/p/chromium/issues/detail?id=357625
|
||||
const polyfill = function () {
|
||||
try {
|
||||
const key = "__session_storage_availability_test__";
|
||||
sessionStorage.setItem(key, "test"); // test
|
||||
sessionStorage.removeItem(key); // cleanup
|
||||
} catch (error) {
|
||||
alert(
|
||||
'It looks like you have disabled cookies in your browser. Webstudio designer may not work properly.\n\nTo enable cookies, go to "Setting" > "Privacy and security" > "Cookies and other site data", and make sure neither "Block all cookies" nor "Block third-party cookies" are selected.\n\nRead more at https://support.google.com/chrome/answer/95647'
|
||||
);
|
||||
const data = new Map();
|
||||
Object.defineProperty(window, "sessionStorage", {
|
||||
value: {
|
||||
setItem: (key: string, val: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Session storage is unavailable due to Error "${
|
||||
(error as Error).message
|
||||
}". A polyfill is used to set value of "${key}". The value will be lost when the page is reloaded.`
|
||||
);
|
||||
data.set(key, String(val));
|
||||
},
|
||||
getItem: (key: string) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Session storage is unavailable due to Error "${
|
||||
(error as Error).message
|
||||
}". A polyfill is used to get value of "${key}". The value will be undefined if the page was reloaded after it was set.`
|
||||
);
|
||||
return data.get(key);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
data.delete(key);
|
||||
},
|
||||
clear: () => {
|
||||
data.clear();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}.toString();
|
||||
|
||||
export const SessionStoragePolyfill = () => (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{ __html: `(${polyfill})()` }}
|
||||
suppressHydrationWarning
|
||||
/>
|
||||
);
|
||||