feat: use resettable blue label for properties (#5000)

Closes https://github.com/webstudio-is/webstudio/issues/4787

- properties now have blue labels when value is specified
- clicking on label shows tooltip
- alt+click does reset/delete
- responsive layout in text control now wraps when label is >30%

<img width="317" alt="Screenshot 2025-03-14 at 20 25 50"
src="https://github.com/user-attachments/assets/acfa1487-59b8-4742-9374-17f5501f5cbf"
/>
<img width="362" alt="Screenshot 2025-03-14 at 20 25 40"
src="https://github.com/user-attachments/assets/12d07775-f926-4b40-9fd9-571723eb43b0"
/>
This commit is contained in:
Bogdan Chadkin authored and GitHub committed 2025-03-14 23:33:00 +07:00
1 parent b14676bfd6
commit d7bb1141ba
16 files changed
+280 -249

No files matched your search

@@ -6,22 +6,19 @@ import {
} from "~/builder/shared/binding-popover";
import {
type ControlProps,
Label,
RemovePropButton,
$selectedInstanceScope,
updateExpressionValue,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
export const BooleanControl = ({
meta,
prop,
propName,
computedValue,
deletable,
onChange,
onDelete,
}: ControlProps<"boolean">) => {
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
@@ -34,18 +31,14 @@ export const BooleanControl = ({
return (
<Grid
css={{
gridTemplateColumns: deletable
? `1fr max-content max-content`
: `1fr max-content`,
gridTemplateColumns: `1fr max-content`,
minHeight: theme.spacing[13],
justifyItems: "start",
}}
align="center"
gap="2"
>
<Label description={meta.description} readOnly={overwritable === false}>
{label}
</Label>
<PropertyLabel name={propName} readOnly={overwritable === false} />
<BindingControl>
<Switch
disabled={overwritable === false}
@@ -76,7 +69,6 @@ export const BooleanControl = ({
}
/>
</BindingControl>
{deletable && <RemovePropButton onClick={onDelete} />}
</Grid>
);
};
@@ -14,6 +14,7 @@ import {
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
const add = (array: string[], item: string) => {
if (array.includes(item)) {
@@ -34,9 +35,7 @@ export const CheckControl = ({
prop,
propName,
computedValue,
deletable,
onChange,
onDelete,
}: ControlProps<"check" | "inline-check" | "multi-select">) => {
const value = Array.isArray(computedValue)
? computedValue.map((item) => String(item))
@@ -57,16 +56,8 @@ export const CheckControl = ({
return (
<VerticalLayout
label={
<Label
htmlFor={`${id}:${options[0]}`}
description={meta.description}
readOnly={overwritable === false}
>
{label}
</Label>
<PropertyLabel name={propName} readOnly={overwritable === false} />
}
deletable={deletable}
onDelete={onDelete}
>
<BindingControl>
{options.map((option) => (
@@ -1,19 +1,4 @@
import { useStore } from "@nanostores/react";
import { CodeEditor } from "~/builder/shared/code-editor";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import {
useLocalValue,
type ControlProps,
VerticalLayout,
Label,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { useState } from "react";
import {
Button,
@@ -29,6 +14,21 @@ import {
theme,
} from "@webstudio-is/design-system";
import { InfoCircleIcon } from "@webstudio-is/icons";
import { CodeEditor } from "~/builder/shared/code-editor";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import {
useLocalValue,
type ControlProps,
VerticalLayout,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
const ErrorInfo = ({
error,
@@ -126,9 +126,7 @@ export const CodeControl = ({
prop,
propName,
computedValue,
deletable,
onChange,
onDelete,
}: ControlProps<"code"> | ControlProps<"codetext">) => {
const [error, setError] = useState<Error>();
const metaOverride = {
@@ -177,17 +175,10 @@ export const CodeControl = ({
<VerticalLayout
label={
<Flex gap="1" align="center">
<Label
description={metaOverride.description}
readOnly={overwritable === false}
>
{label}
</Label>
<PropertyLabel name={propName} readOnly={overwritable === false} />
{errorInfo}
</Flex>
}
deletable={deletable}
onDelete={onDelete}
>
<BindingControl>
<CodeEditor
@@ -9,13 +9,13 @@ import {
type ControlProps,
VerticalLayout,
useLocalValue,
Label,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { SelectAsset } from "./select-asset";
import { PropertyLabel } from "../property-label";
const UrlInput = ({
id,
@@ -47,7 +47,6 @@ export const FileControl = ({
prop,
propName,
computedValue,
deletable,
onChange,
onDelete,
}: ControlProps<"file">) => {
@@ -81,15 +80,7 @@ export const FileControl = ({
);
return (
<VerticalLayout
label={
<Label htmlFor={id} description={meta.description}>
{label}
</Label>
}
deletable={deletable}
onDelete={onDelete}
>
<VerticalLayout label={<PropertyLabel name={propName} />}>
<Flex css={{ gap: theme.spacing[3] }} direction="column" justify="center">
<BindingControl>
<UrlInput
@@ -5,11 +5,9 @@ import {
type ControlProps,
useLocalValue,
VerticalLayout,
Label,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import {
ExpressionEditor,
@@ -19,15 +17,13 @@ import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { PropertyLabel } from "../property-label";
export const JsonControl = ({
meta,
prop,
propName,
computedValue,
deletable,
onChange,
onDelete,
}: ControlProps<"json">) => {
const [error, setError] = useState<boolean>(false);
const valueString = formatValue(computedValue ?? "");
@@ -50,7 +46,6 @@ export const JsonControl = ({
// empty block
}
});
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression = prop?.type === "expression" ? prop.value : valueString;
@@ -61,12 +56,8 @@ export const JsonControl = ({
return (
<VerticalLayout
label={
<Label description={meta.description} readOnly={overwritable === false}>
{label}
</Label>
<PropertyLabel name={propName} readOnly={overwritable === false} />
}
deletable={deletable}
onDelete={onDelete}
>
<BindingControl>
<ExpressionEditor
@@ -9,12 +9,12 @@ import {
type ControlProps,
useLocalValue,
ResponsiveLayout,
Label,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
export const NumberControl = ({
meta,
@@ -22,8 +22,6 @@ export const NumberControl = ({
propName,
computedValue,
onChange,
deletable,
onDelete,
}: ControlProps<"number">) => {
const id = useId();
@@ -56,16 +54,8 @@ export const NumberControl = ({
return (
<ResponsiveLayout
label={
<Label
htmlFor={id}
description={meta.description}
readOnly={overwritable === false}
>
{label}
</Label>
<PropertyLabel name={propName} readOnly={overwritable === false} />
}
deletable={deletable}
onDelete={onDelete}
>
<BindingControl>
<InputField
@@ -14,15 +14,14 @@ import {
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
export const RadioControl = ({
meta,
prop,
propName,
computedValue,
deletable,
onChange,
onDelete,
}: ControlProps<"radio" | "inline-radio">) => {
const value = computedValue === undefined ? undefined : String(computedValue);
// making sure that the current value is in the list of options
@@ -43,16 +42,8 @@ export const RadioControl = ({
return (
<VerticalLayout
label={
<Label
htmlFor={id}
description={meta.description}
readOnly={overwritable === false}
>
{label}
</Label>
<PropertyLabel name={propName} readOnly={overwritable === false} />
}
deletable={deletable}
onDelete={onDelete}
>
<BindingControl>
<RadioGroup
@@ -1,3 +1,4 @@
import { nanoid } from "nanoid";
import { useId } from "react";
import { useStore } from "@nanostores/react";
import { InputField } from "@webstudio-is/design-system";
@@ -10,22 +11,20 @@ import {
import {
type ControlProps,
useLocalValue,
ResponsiveLayout,
Label,
humanizeAttribute,
VerticalLayout,
} from "../shared";
import { $resources } from "~/shared/nano-states";
import { $selectedInstanceResourceScope } from "../resource-panel";
import { computeExpression } from "~/shared/data-variables";
import { updateWebstudioData } from "~/shared/instance-utils";
import { nanoid } from "nanoid";
import { PropertyLabel } from "../property-label";
export const ResourceControl = ({
meta,
prop,
propName,
instanceId,
deletable,
}: ControlProps<"resource">) => {
const resources = useStore($resources);
const { variableValues, scope, aliases } = useStore(
@@ -98,17 +97,6 @@ export const ResourceControl = ({
});
};
const deletePropAndResource = () => {
updateWebstudioData((data) => {
if (prop?.type === "resource") {
data.resources.delete(prop.value);
}
if (prop) {
data.props.delete(prop.id);
}
});
};
const id = useId();
const label = humanizeAttribute(meta.label || propName);
let variant: BindingVariant = "bound";
@@ -122,14 +110,8 @@ export const ResourceControl = ({
);
return (
<ResponsiveLayout
label={
<Label htmlFor={id} description={meta.description} readOnly={readOnly}>
{label}
</Label>
}
deletable={deletable}
onDelete={deletePropAndResource}
<VerticalLayout
label={<PropertyLabel name={propName} readOnly={readOnly} />}
>
<BindingControl>
<InputField
@@ -156,6 +138,6 @@ export const ResourceControl = ({
}
/>
</BindingControl>
</ResponsiveLayout>
</VerticalLayout>
);
};
@@ -8,21 +8,19 @@ import {
import {
type ControlProps,
VerticalLayout,
Label,
$selectedInstanceScope,
updateExpressionValue,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
export const SelectControl = ({
meta,
prop,
propName,
computedValue,
deletable,
onChange,
onDelete,
}: ControlProps<"select">) => {
const id = useId();
@@ -44,16 +42,8 @@ export const SelectControl = ({
return (
<VerticalLayout
label={
<Label
htmlFor={id}
description={meta.description}
readOnly={overwritable === false}
>
{label}
</Label>
<PropertyLabel name={propName} readOnly={overwritable === false} />
}
deletable={deletable}
onDelete={onDelete}
>
<BindingControl>
<Select
@@ -84,8 +84,6 @@ export const TextContent = ({
{label}
</Label>
}
deletable={false}
onDelete={() => {}}
>
<BindingControl>
<TextArea
@@ -9,21 +9,19 @@ import {
type ControlProps,
useLocalValue,
ResponsiveLayout,
Label,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
export const TextControl = ({
meta,
prop,
propName,
deletable,
computedValue,
onChange,
onDelete,
}: ControlProps<"text">) => {
const localValue = useLocalValue(String(computedValue ?? ""), (value) => {
if (prop?.type === "expression") {
@@ -76,21 +74,11 @@ export const TextControl = ({
</BindingControl>
);
const labelElement = (
<Label
htmlFor={id}
description={meta.description}
readOnly={overwritable === false}
>
{label}
</Label>
);
return (
<ResponsiveLayout
label={labelElement}
deletable={deletable}
onDelete={onDelete}
label={
<PropertyLabel name={propName} readOnly={overwritable === false} />
}
>
{input}
</ResponsiveLayout>
@@ -42,6 +42,7 @@ import {
} from "../shared";
import { SelectAsset } from "./select-asset";
import { createRootFolder } from "@webstudio-is/project-build";
import { PropertyLabel } from "../property-label";
type UrlControlProps = ControlProps<"url">;
@@ -461,7 +462,6 @@ export const UrlControl = ({
prop,
propName,
computedValue,
deletable,
onChange,
onDelete,
}: UrlControlProps) => {
@@ -484,15 +484,7 @@ export const UrlControl = ({
);
return (
<VerticalLayout
label={
<Label htmlFor={id} description={meta.description}>
{label}
</Label>
}
deletable={deletable}
onDelete={onDelete}
>
<VerticalLayout label={<PropertyLabel name={propName} />}>
<Flex
css={{
py: theme.spacing[2],
@@ -0,0 +1,182 @@
import { useMemo, useState } from "react";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import {
Button,
Flex,
Kbd,
Label,
Text,
Tooltip,
rawTheme,
theme,
} from "@webstudio-is/design-system";
import { InfoCircleIcon } from "@webstudio-is/icons";
import type { Prop } from "@webstudio-is/sdk";
import { showAttribute } from "@webstudio-is/react-sdk";
import { updateWebstudioData } from "~/shared/instance-utils";
import { $selectedInstance } from "~/shared/awareness";
import { $props, $registeredComponentPropsMetas } from "~/shared/nano-states";
import { humanizeAttribute, showAttributeMeta } from "./shared";
const usePropMeta = (name: string) => {
const store = useMemo(() => {
return computed(
[$selectedInstance, $registeredComponentPropsMetas],
(instance, propsMetas) => {
if (name === showAttribute) {
return showAttributeMeta;
}
const metas = propsMetas.get(instance?.component ?? "");
const propMeta = metas?.props[name];
return propMeta;
}
);
}, [name]);
return useStore(store);
};
const $selectedInstanceProps = computed(
[$selectedInstance, $props],
(instance, props) => {
const instanceProps = new Map<Prop["name"], Prop>();
for (const prop of props.values()) {
if (prop.instanceId === instance?.id) {
instanceProps.set(prop.name, prop);
}
}
return instanceProps;
}
);
const useProp = (name: string) => {
const store = useMemo(() => {
return computed([$selectedInstanceProps], (selectedInstanceProps) =>
selectedInstanceProps.get(name)
);
}, [name]);
return useStore(store);
};
const deleteProp = (name: string) => {
const instance = $selectedInstance.get();
const instanceProps = $selectedInstanceProps.get();
updateWebstudioData((data) => {
const prop = instanceProps.get(name);
if (prop) {
data.props.delete(prop.id);
}
if (prop?.type === "resource") {
data.resources.delete(prop.value);
}
if (instance?.component === "Image" && name === "src") {
const widthProp = instanceProps.get("width");
if (widthProp) {
data.props.delete(widthProp.id);
}
const heightProp = instanceProps.get("height");
if (heightProp) {
data.props.delete(heightProp.id);
}
}
});
};
const useIsResettable = (name: string) => {
const store = useMemo(() => {
return computed(
[$selectedInstance, $registeredComponentPropsMetas],
(instance, propsMetas) => {
if (name === showAttribute) {
return true;
}
const metas = propsMetas.get(instance?.component ?? "");
return metas?.initialProps?.includes(name);
}
);
}, [name]);
return useStore(store);
};
export const PropertyLabel = ({
name,
readOnly,
}: {
name: string;
readOnly?: boolean;
}) => {
const [isOpen, setIsOpen] = useState(false);
const propMeta = usePropMeta(name);
const prop = useProp(name);
const label = propMeta?.label ?? humanizeAttribute(name);
// 1. not existing properties cannot be deleted
// 2. required properties cannot be deleted
// 3. custom attributes like data-* do not have meta and can be deleted
const isDeletable = prop && !propMeta?.required;
const isResettable = useIsResettable(name);
return (
<Flex align="center" css={{ gap: theme.spacing[3] }}>
{/* prevent label growing */}
<div>
<Tooltip
open={isOpen}
onOpenChange={setIsOpen}
triggerProps={{
onClick: (event) => {
if (event.altKey) {
event.preventDefault();
if (isDeletable) {
deleteProp(name);
}
return;
}
setIsOpen(true);
},
}}
content={
<Flex
direction="column"
gap="2"
css={{ maxWidth: theme.spacing[28] }}
>
<Text variant="titles" css={{ textTransform: "none" }}>
{label}
</Text>
{propMeta?.description && <Text>{propMeta.description}</Text>}
{isDeletable && (
<Button
color="dark"
// to align button text in the middle
prefix={<div></div>}
suffix={<Kbd value={["alt", "click"]} color="moreSubtle" />}
css={{ gridTemplateColumns: "1fr max-content 1fr" }}
onClick={() => {
deleteProp(name);
setIsOpen(false);
}}
>
{isResettable ? "Reset value" : "Delete property"}
</Button>
)}
</Flex>
}
>
<Label truncate color={prop ? "local" : "default"}>
{label}
</Label>
</Tooltip>
</div>
{readOnly && (
<Tooltip
content="The value is controlled by an expression and cannot be changed."
variant="wrapped"
>
<InfoCircleIcon
color={rawTheme.colors.foregroundSubtle}
tabIndex={0}
/>
</Tooltip>
)}
</Flex>
);
};
@@ -3,7 +3,7 @@ import { useStore } from "@nanostores/react";
import type { PropMeta, Instance, Prop } from "@webstudio-is/sdk";
import { collectionComponent, descendantComponent } from "@webstudio-is/sdk";
import { showAttribute, textContentAttribute } from "@webstudio-is/react-sdk";
import type { PropValue } from "../shared";
import { showAttributeMeta, type PropValue } from "../shared";
import {
$isContentMode,
$registeredComponentMetas,
@@ -133,22 +133,6 @@ const getAndDelete = <Value>(map: Map<string, Value>, key: string) => {
return value;
};
const systemPropsMeta: { name: string; meta: PropMeta }[] = [
{
name: showAttribute,
meta: {
label: "Show",
required: false,
control: "boolean",
type: "boolean",
defaultValue: true,
// If you are changing it, change the other one too
description:
"Removes the instance from the DOM. Breakpoints have no effect on this setting.",
},
},
];
/** usePropsLogic expects that key={instanceId} is used on the ancestor component */
export const usePropsLogic = ({
instance,
@@ -198,32 +182,17 @@ export const usePropsLogic = ({
const initialPropsNames = new Set(meta.initialProps ?? []);
const systemProps: PropAndMeta[] = systemPropsMeta
.filter(({ name }) => {
// descendant component is not actually rendered
// but affects styling of nested elements
// hiding descendant does not hide nested elements and confuse users
if (
instance.component === descendantComponent &&
name === showAttribute
) {
return false;
}
return true;
})
.map(({ name, meta }) => {
let saved = getAndDelete<Prop>(unprocessedSaved, name);
if (saved === undefined && meta.defaultValue !== undefined) {
saved = getStartingProp(instance.id, meta, name);
}
getAndDelete(unprocessedKnown, name);
initialPropsNames.delete(name);
return {
prop: saved,
propName: name,
meta,
};
const systemProps: PropAndMeta[] = [];
// descendant component is not actually rendered
// but affects styling of nested elements
// hiding descendant does not hide nested elements and confuse users
if (instance.component !== descendantComponent) {
systemProps.push({
propName: showAttribute,
prop: getAndDelete(unprocessedSaved, showAttribute),
meta: showAttributeMeta,
});
}
const canHaveTextContent =
instanceMeta?.type === "container" &&
@@ -39,11 +39,7 @@ export const SettingsSection = () => {
return (
<Row>
<HorizontalLayout
label={<Label htmlFor={id}>Name</Label>}
deletable={false}
onDelete={() => {}}
>
<HorizontalLayout label={<Label htmlFor={id}>Name</Label>}>
<InputField
id={id}
/* Key is required, otherwise when label is undefined, previous value stayed */
@@ -18,9 +18,8 @@ import {
systemParameter,
} from "@webstudio-is/sdk";
import type { PropMeta, Prop, Asset } from "@webstudio-is/sdk";
import { InfoCircleIcon, MinusIcon } from "@webstudio-is/icons";
import { InfoCircleIcon } from "@webstudio-is/icons";
import {
SmallIconButton,
Label as BaseLabel,
useIsTruncated,
Tooltip,
@@ -40,6 +39,17 @@ import type { BindingVariant } from "~/builder/shared/binding-popover";
import { humanizeString } from "~/shared/string-utils";
import { $selectedInstanceKeyWithRoot } from "~/shared/awareness";
export const showAttributeMeta: PropMeta = {
label: "Show",
required: false,
control: "boolean",
type: "boolean",
defaultValue: true,
// If you are changing it, change the other one too
description:
"Removes the instance from the DOM. Breakpoints have no effect on this setting.",
};
export type PropValue =
| { type: "number"; value: number }
| { type: "string"; value: string }
@@ -60,7 +70,6 @@ export type PropValue =
type PropMetaByControl<Control> = Control extends string
? Extract<PropMeta, { control: Control }>
: never;
export type ControlProps<Control> = {
instanceId: string;
meta: PropMetaByControl<Control>;
@@ -74,10 +83,6 @@ export type ControlProps<Control> = {
onDelete: () => void;
};
export const RemovePropButton = (props: { onClick: () => void }) => (
<SmallIconButton icon={<MinusIcon />} variant="destructive" {...props} />
);
const SimpleLabel = ({
children,
...rest
@@ -141,7 +146,7 @@ export const Label = ({
return (
<Flex align="center" css={{ gap: theme.spacing[3], width: "100%" }}>
{label}
<Box>{label}</Box>
{readOnly && (
<Tooltip
content={
@@ -234,21 +239,14 @@ export const useLocalValue = <Type,>(
type LayoutProps = {
label: ReturnType<typeof Label>;
deletable: boolean;
onDelete: () => void;
children: ReactNode;
};
export const VerticalLayout = ({
label,
deletable,
onDelete,
children,
}: LayoutProps) => (
export const VerticalLayout = ({ label, children }: LayoutProps) => (
<Box>
<Grid
css={{
gridTemplateColumns: deletable ? `1fr max-content` : `1fr`,
gridTemplateColumns: `1fr`,
justifyItems: "start",
}}
align="center"
@@ -256,23 +254,15 @@ export const VerticalLayout = ({
justify="between"
>
{label}
{deletable && <RemovePropButton onClick={onDelete} />}
</Grid>
<Box css={{ py: theme.spacing[2] }}>{children}</Box>
</Box>
);
export const HorizontalLayout = ({
label,
deletable,
onDelete,
children,
}: LayoutProps) => (
export const HorizontalLayout = ({ label, children }: LayoutProps) => (
<Grid
css={{
gridTemplateColumns: deletable
? `${theme.spacing[19]} 1fr max-content`
: `${theme.spacing[19]} 1fr`,
gridTemplateColumns: `${theme.spacing[19]} 1fr`,
minHeight: theme.spacing[12],
}}
align="center"
@@ -280,29 +270,36 @@ export const HorizontalLayout = ({
>
{label}
{children}
{deletable && <RemovePropButton onClick={onDelete} />}
</Grid>
);
export const ResponsiveLayout = ({
label,
deletable,
onDelete,
children,
}: LayoutProps) => {
// more than 9 characters in label trigger ellipsis
// might not cover all cases though
if (label.props.children.length <= 8) {
return (
<HorizontalLayout label={label} deletable={deletable} onDelete={onDelete}>
{children}
</HorizontalLayout>
);
}
export const ResponsiveLayout = ({ label, children }: LayoutProps) => {
return (
<VerticalLayout label={label} deletable={deletable} onDelete={onDelete}>
{children}
</VerticalLayout>
<Flex
align="center"
wrap="wrap"
css={{
columnGap: theme.spacing[5],
rowGap: theme.spacing[3],
paddingBlock: theme.spacing[2],
}}
>
<Box
css={{
// wrap label and input when label is more than ~9 characters
flexBasis: `calc(30% - ${theme.spacing[5]} / 2)`,
// allow content overflow flex basis
minWidth: "auto",
}}
>
{label}
</Box>
<Box
css={{ flexBasis: `calc(70% - ${theme.spacing[5]} / 2)`, flexGrow: 1 }}
>
{children}
</Box>
</Flex>
);
};