Merge pull request #322 from webstudio-is/css-value-input-final

CSS Value Input that combines the TextField and Combobox updates all in one
This commit is contained in:
Oleg Isonen authored and GitHub committed 2022-10-09 10:44:13 +02:00
commit 0b8c924cc0
23 files changed
+193 -128

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
node: ["14.x", "16.x"]
node: ["16.x"]
os: [ubuntu-latest]
steps:
- name: Checkout repo
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

-4
View File
@@ -1,9 +1,7 @@
import { LinksFunction, MetaFunction } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { useState } from "react";
import {
Box,
Card,
Flex,
Heading,
@@ -12,7 +10,6 @@ import {
TextField,
} from "@webstudio-is/design-system";
import interStyles from "~/shared/font-faces/inter.css";
import { GithubIcon, CommitIcon, GoogleIcon } from "@webstudio-is/icons";
import { LoginButton } from "./components/login-button";
import loginStyles from "./login.css";
@@ -87,7 +84,6 @@ export const Login = ({ errorMessage }: { errorMessage: string }) => {
}}
>
<TextField
size={2}
name="secret"
type="text"
minLength={2}
@@ -55,7 +55,6 @@ export const SelectProjectCard = ({
<Form method="post">
<Flex gap="1">
<TextField
size={2}
state={errors ? "invalid" : undefined}
name="project"
defaultValue={newProject}
@@ -10,19 +10,100 @@ import type { SelectedInstanceData } from "~/shared/canvas-components";
import {
Box,
Button,
Combobox,
ComboboxTextField,
ComboboxPopperContent,
Grid,
TextField,
Tooltip,
useCombobox,
ComboboxPopper,
ComboboxPopperContent,
ComboboxPopperAnchor,
ComboboxListbox,
ComboboxListboxItem,
IconButton,
} from "@webstudio-is/design-system";
import {
PlusIcon,
TrashIcon,
ExclamationTriangleIcon,
ChevronDownIcon,
} from "@webstudio-is/icons";
import { handleChangePropType, usePropsLogic } from "./use-props-logic";
type ComboboxProps = {
isReadonly: boolean;
isInvalid: boolean;
items: Array<string>;
value: string;
onItemSelect: (value: string | null) => void;
onSubmit: (value: string) => void;
};
const Combobox = ({
isReadonly,
isInvalid,
items: itemsProp,
value,
onItemSelect,
onSubmit,
}: ComboboxProps) => {
const {
items,
getInputProps,
getComboboxProps,
getToggleButtonProps,
getMenuProps,
getItemProps,
isOpen,
} = useCombobox({
items: itemsProp,
value,
itemToString: (item) => item ?? "",
onItemSelect,
});
return (
<ComboboxPopper>
<Box {...getComboboxProps()}>
<ComboboxPopperAnchor>
<TextField
{...getInputProps({
onKeyPress: (event) => {
if (event.key === "Enter") {
onSubmit(event.currentTarget.value);
}
},
})}
name="prop"
placeholder="Property"
readOnly={isReadonly}
state={isInvalid ? "invalid" : undefined}
suffix={
<IconButton {...getToggleButtonProps()}>
<ChevronDownIcon />
</IconButton>
}
/>
</ComboboxPopperAnchor>
<ComboboxPopperContent align="start" sideOffset={5}>
<ComboboxListbox {...getMenuProps()}>
{isOpen &&
items.map((item, index) => {
return (
<ComboboxListboxItem
{...getItemProps({ item, index })}
key={index}
>
{item}
</ComboboxListboxItem>
);
})}
</ComboboxListbox>
</ComboboxPopperContent>
</Box>
</ComboboxPopper>
);
};
type PropertyProps = UserProp & {
component: Instance["component"];
onChange: handleChangePropType;
@@ -33,14 +114,14 @@ const Property = ({
id,
prop,
value,
required,
required = false,
component,
onChange,
onDelete,
}: PropertyProps) => {
const meta = componentsMeta[component];
const argType = meta.argTypes?.[prop as keyof typeof meta.argTypes];
const isInvalidProp =
const isInvalid =
prop != null &&
prop.length > 0 &&
typeof argType === "undefined" &&
@@ -56,30 +137,20 @@ const Property = ({
css={{ gridTemplateColumns: "1fr 1fr auto", alignItems: "center" }}
>
<Combobox
name="prop"
placeholder="Property"
items={allProps}
value={prop}
itemToString={(item) => item ?? ""}
onItemSelect={(value) => {
onItemSelect={(value: PropertyProps["value"] | null) => {
if (value !== null) {
onChange(id, "prop", value);
}
}}
onSubmit={(value) => {
onChange(id, "prop", value);
}}
renderTextField={({ inputProps, toggleProps }) => (
<ComboboxTextField
toggleProps={toggleProps}
inputProps={{
...inputProps,
readOnly: required,
state: isInvalidProp ? "invalid" : undefined,
}}
/>
)}
renderPopperContent={(props) => (
<ComboboxPopperContent {...props} align="start" sideOffset={5} />
)}
isInvalid={isInvalid}
isReadonly={required}
/>
{isInvalidProp ? (
{isInvalid ? (
<Tooltip content={`Invalid property name: ${prop}`}>
<ExclamationTriangleIcon width={12} height={12} />
</Tooltip>
@@ -30,7 +30,11 @@ export const Search = ({ onSearch, selectedInstanceData }: SearchProps) => {
type="search"
value={search}
inputRef={inputRef}
prefix={<MagnifyingGlassIcon />}
prefix={
<IconButton aria-label="Search">
<MagnifyingGlassIcon />
</IconButton>
}
suffix={
search.length > 0 && (
<IconButton
+1 -1
View File
@@ -29,7 +29,7 @@
},
"engines": {
"npm": ">=7.0.0",
"node": ">=14 <=16"
"node": "16"
},
"dependencies": {},
"packageManager": "yarn@1.22.19",
@@ -25,7 +25,7 @@ export const Button = styled("button", {
// Custom
height: "$5",
px: "$2",
fontFamily: "$untitled",
fontFamily: "$sans",
fontSize: "$2",
fontWeight: 500,
fontVariantNumeric: "tabular-nums",
@@ -11,11 +11,7 @@ export default {
};
export const Simple: ComponentStory<typeof Combobox> = () => {
const items = [
{ label: "Apple" },
{ label: "Banana" },
{ label: "Orange" },
] as const;
const items = [{ label: "Apple" }, { label: "Banana" }, { label: "Orange" }];
const [value, setValue] = React.useState<typeof items[number] | null>(null);
return (
<Combobox
@@ -61,12 +61,53 @@ export const ListboxItemBase: ForwardRefRenderFunction<
);
};
export const ComboboxListbox = Listbox;
export const ComboboxListboxItem = forwardRef(ListboxItemBase);
export const ComboboxPopper = Popper;
export const ComboboxPopperContent = PopperContent;
export const ComboboxPopperAnchor = PopperAnchor;
const useFilter = <Item,>({
items,
itemToString,
}: {
items: Array<Item>;
itemToString: (item: Item | null) => string;
}) => {
const [filteredItems, setFilteredItems] = useState<Array<Item>>(items);
const cachedItems = useRef(items);
useEffect(() => {
cachedItems.current = items;
}, [items]);
const filter = useCallback(
(search?: string) => {
const foundItems: Array<Item> = matchSorter(items, search ?? "", {
keys: [(item) => itemToString(item)],
});
setFilteredItems(foundItems);
},
[itemToString, items]
);
const resetFilter = useCallback(() => {
setFilteredItems(cachedItems.current);
}, []);
return {
filteredItems,
filter,
resetFilter,
};
};
type useComboboxProps<Item> = {
items: ReadonlyArray<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."
onItemSelect?: (value: Item | null) => void;
@@ -77,32 +118,26 @@ type useComboboxProps<Item> = {
) => Partial<UseComboboxStateChangeOptions<Item>>;
};
// eslint-disable-next-line func-style
export function useCombobox<Item>({
export const useCombobox = <Item,>({
items,
value,
itemToString,
onItemSelect,
onItemHighlight,
stateReducer,
}: useComboboxProps<Item>) {
const [filteredItems, setFilteredItems] = useState(items);
const cachedItems = useRef(items);
stateReducer = (state, { changes }) => changes,
}: useComboboxProps<Item>) => {
const { filteredItems, filter, resetFilter } = useFilter<Item>({
items,
itemToString,
});
const downshiftProps = useDownshiftCombobox({
items: filteredItems as Item[],
selectedItem: value, // Avoid downshift warning about switching controlled mode
items: filteredItems,
selectedItem: value, // Prevent downshift warning about switching controlled mode
stateReducer,
itemToString,
onInputValueChange({ inputValue }) {
const foundItems: ReadonlyArray<Item> = matchSorter(
items,
inputValue ?? "",
{
keys: [(item) => itemToString(item)],
}
);
setFilteredItems(foundItems);
filter(inputValue);
},
onSelectedItemChange({ selectedItem }) {
onItemSelect?.(selectedItem ?? null);
@@ -118,14 +153,10 @@ export function useCombobox<Item>({
downshiftProps;
useEffect(() => {
cachedItems.current = items;
}, [items]);
useEffect(() => {
if (!isOpen) {
setFilteredItems(cachedItems.current);
if (isOpen === false) {
resetFilter();
}
}, [isOpen]);
}, [isOpen, resetFilter]);
const enhancedGetItemProps = useCallback(
(options) => {
@@ -142,10 +173,10 @@ export function useCombobox<Item>({
return {
...downshiftProps,
items: filteredItems, // Return filtered items
items: filteredItems,
getItemProps: enhancedGetItemProps,
};
}
};
type ComboboxProps<Item> = useComboboxProps<Item> & {
name: string;
@@ -153,8 +184,7 @@ type ComboboxProps<Item> = useComboboxProps<Item> & {
placeholder?: string;
};
// eslint-disable-next-line func-style
export function Combobox<Item>({
export const Combobox = <Item,>({
items,
value = null,
name,
@@ -162,15 +192,7 @@ export function Combobox<Item>({
itemToString,
onItemSelect,
onItemHighlight,
}: ComboboxProps<Item>) {
const stateReducer = useCallback((state, actionAndChanges) => {
const { type, changes } = actionAndChanges;
switch (type) {
default:
return changes; // otherwise business as usual.
}
}, []);
}: ComboboxProps<Item>) => {
const {
items: foundItems,
getInputProps,
@@ -185,7 +207,6 @@ export function Combobox<Item>({
itemToString,
onItemSelect,
onItemHighlight,
stateReducer,
});
return (
<Popper>
@@ -222,6 +243,6 @@ export function Combobox<Item>({
</Box>
</Popper>
);
}
};
Combobox.displayName = "Combobox";
@@ -10,7 +10,7 @@ export const baseItemCss = css({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
fontFamily: "$untitled",
fontFamily: "$sans",
fontSize: "$1",
fontVariantNumeric: "tabular-nums",
lineHeight: "1",
@@ -2,7 +2,7 @@ import { expect } from "@storybook/jest";
import { userEvent, waitFor, within } from "@storybook/testing-library";
import React from "react";
import { ComponentStory } from "@storybook/react";
import { BrushIcon, ChevronDownIcon } from "@webstudio-is/icons";
import { RowGapIcon, ChevronDownIcon } from "@webstudio-is/icons";
import { Button } from "./button";
import { Flex } from "./flex";
import { IconButton } from "./icon-button";
@@ -36,15 +36,6 @@ export const NativeProps: ComponentStory<typeof TextField> = () => {
);
};
export const Sizes: ComponentStory<typeof TextField> = () => {
return (
<Flex direction="column" gap={3}>
<TextField size={1} />
<TextField size={2} />
</Flex>
);
};
export const Variants: ComponentStory<typeof TextField> = () => {
return (
<Flex direction="column" gap={3}>
@@ -68,7 +59,7 @@ export const PrefixSuffix: ComponentStory<typeof TextField> = () => {
return (
<Flex direction="column" gap={3}>
<TextField
prefix={<BrushIcon />}
prefix={<RowGapIcon />}
suffix={
<IconButton>
<ChevronDownIcon />
@@ -77,25 +68,16 @@ export const PrefixSuffix: ComponentStory<typeof TextField> = () => {
/>
<TextField
state="invalid"
prefix={<BrushIcon />}
prefix={<RowGapIcon />}
suffix={
<IconButton>
<ChevronDownIcon />
</IconButton>
}
/>
<TextField
size={2}
prefix={<BrushIcon />}
suffix={
<IconButton size={2}>
<ChevronDownIcon />
</IconButton>
}
/>
<TextField
disabled
prefix={<BrushIcon />}
prefix={<RowGapIcon />}
suffix={
<IconButton>
<ChevronDownIcon />
@@ -112,7 +94,7 @@ export const Layout: ComponentStory<typeof TextField> = () => {
<Flex direction="row" gap={2} css={{ justifyContent: "space-between" }}>
<TextField
value="Long content comes here and it doesn't wrap"
prefix={<BrushIcon />}
prefix={<RowGapIcon />}
suffix={
<IconButton>
<ChevronDownIcon />
@@ -147,7 +129,7 @@ export const Layout: ComponentStory<typeof TextField> = () => {
<TextField
id="field"
value="Long content comes here and it doesn't wrap"
prefix={<BrushIcon />}
prefix={<RowGapIcon />}
/>
</Box>
<Box css={{ background: "$muted" }}>
@@ -222,7 +204,7 @@ export const ClickCapture: ComponentStory<typeof TextField> = (args) => {
placeholder="Click on the icon to focus input"
prefix={
<Flex title="icon">
<BrushIcon />
<RowGapIcon />
</Flex>
}
suffix={
@@ -12,6 +12,7 @@ const InputBase = styled("input", {
boxSizing: "border-box",
fontFamily: "inherit",
fontSize: "inherit",
color: "inherit",
margin: "0",
padding: "0",
flexGrow: 1,
@@ -43,7 +44,7 @@ const InputBase = styled("input", {
},
"&:-webkit-autofill::first-line": {
fontFamily: "$untitled",
fontFamily: "$sans",
color: "$hiContrast",
},
@@ -69,9 +70,23 @@ const TextFieldBase = styled("div", {
color: "$hiContrast",
fontVariantNumeric: "tabular-nums",
px: "$2",
borderRadius: "$1",
fontFamily: "$sans",
fontSize: "$1",
height: 28, // @todo waiting for the sizing scale
lineHeight: 1,
"&[data-has-prefix]": {
paddingLeft: 2,
},
"&[data-has-suffix]": {
paddingRight: 2,
},
"&:focus-within": {
boxShadow:
"inset 0px 0px 0px 1px $colors$blue8, 0px 0px 0px 1px $colors$blue8",
"inset 0px 0px 0px 1px $colors$blue10, 0px 0px 0px 1px $colors$blue10",
},
"&[aria-disabled=true]": {
@@ -86,22 +101,6 @@ const TextFieldBase = styled("div", {
},
variants: {
size: {
"1": {
px: "$1",
borderRadius: "$1",
fontSize: "$1",
height: "$5",
lineHeight: "$sizes$5",
},
"2": {
px: "$2",
borderRadius: "$2",
fontSize: "$3",
height: "$6",
lineHeight: "$sizes$6",
},
},
variant: {
ghost: {
boxShadow: "none",
@@ -114,7 +113,7 @@ const TextFieldBase = styled("div", {
"&:focus": {
backgroundColor: "$loContrast",
boxShadow:
"inset 0px 0px 0px 1px $colors$blue8, 0px 0px 0px 1px $colors$blue8",
"inset 0px 0px 0px 1px $colors$blue8, 0px 0px 0px 1px $colors$blue10",
},
"&:disabled": {
backgroundColor: "transparent",
@@ -141,14 +140,11 @@ const TextFieldBase = styled("div", {
},
},
},
defaultVariants: {
size: "1",
},
});
type TextFieldProps = Pick<
React.ComponentProps<typeof TextFieldBase>,
"size" | "variant" | "state" | "css"
"variant" | "state" | "css"
> &
Omit<React.ComponentProps<"input">, "prefix" | "children"> & {
inputRef?: React.Ref<HTMLInputElement>;
@@ -164,7 +160,6 @@ export const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
css,
disabled,
inputRef,
size,
state,
variant,
onFocus,
@@ -194,11 +189,12 @@ export const TextField = React.forwardRef<HTMLDivElement, TextFieldProps>(
<TextFieldBase
aria-disabled={disabled}
ref={forwardedRef}
size={size}
state={state}
variant={variant}
css={css}
{...focusWithinProps}
{...(prefix && { "data-has-prefix": true })}
{...(suffix && { "data-has-suffix": true })}
onClickCapture={focusInnerInput}
>
{/* We want input to be the first element in DOM so it receives the focus first */}