diff --git a/apps/builder/app/builder/features/settings-panel/variables.stories.tsx b/apps/builder/app/builder/features/settings-panel/variables.stories.tsx
new file mode 100644
index 0000000000..46da2d2aec
--- /dev/null
+++ b/apps/builder/app/builder/features/settings-panel/variables.stories.tsx
@@ -0,0 +1,38 @@
+import type { Meta, StoryObj } from "@storybook/react";
+import { CodeEditor as CodeEditorComponent } from "./code-editor";
+import {
+ Button,
+ FloatingPanelPopover,
+ FloatingPanelPopoverContent,
+ FloatingPanelPopoverTrigger,
+} from "@webstudio-is/design-system";
+import { VariablesPanel } from "./variables";
+import { selectedInstanceSelectorStore } from "~/shared/nano-states";
+import { registerContainers } from "~/shared/sync";
+import { useState } from "react";
+
+export default {
+ title: "Builder/Variables",
+ component: CodeEditorComponent,
+} satisfies Meta;
+
+registerContainers();
+selectedInstanceSelectorStore.set(["root"]);
+
+const VariablesPopover = () => {
+ const [isOpen, setIsOpen] = useState(true);
+ return (
+
+
+
+
+
+
+
+
+ );
+};
+
+export const Variables: StoryObj = {
+ render: () => ,
+};
diff --git a/apps/builder/app/builder/features/settings-panel/variables.tsx b/apps/builder/app/builder/features/settings-panel/variables.tsx
new file mode 100644
index 0000000000..a930ceb1a5
--- /dev/null
+++ b/apps/builder/app/builder/features/settings-panel/variables.tsx
@@ -0,0 +1,380 @@
+import { useEffect, useId, useMemo, useState } from "react";
+import { nanoid } from "nanoid";
+import { computed } from "nanostores";
+import { useStore } from "@nanostores/react";
+import {
+ Button,
+ CssValueListArrowFocus,
+ CssValueListItem,
+ Flex,
+ FloatingPanelPopoverTitle,
+ InputErrorsTooltip,
+ InputField,
+ Label,
+ ScrollArea,
+ SmallIconButton,
+ Text,
+ Tooltip,
+ theme,
+} from "@webstudio-is/design-system";
+import { MenuIcon, MinusIcon, PlusIcon } from "@webstudio-is/icons";
+import type { DataSource } from "@webstudio-is/sdk";
+import { validateExpression } from "@webstudio-is/react-sdk";
+import {
+ dataSourcesStore,
+ selectedInstanceSelectorStore,
+} from "~/shared/nano-states";
+import { serverSyncStore } from "~/shared/sync";
+import { CodeEditor } from "./code-editor";
+
+type VariableDataSource = Extract;
+
+/**
+ * convert value expression to js value
+ * validating out accessing any identifier
+ */
+const parseVariableValue = (code: string) => {
+ const result: { value?: unknown; error?: string } = {};
+ const ids = new Set();
+ try {
+ validateExpression(code, {
+ transformIdentifier: (id) => {
+ ids.add(id);
+ return id;
+ },
+ });
+ } catch (error) {
+ result.error = (error as Error).message;
+ return result;
+ }
+ if (ids.size === 0) {
+ try {
+ result.value = eval(code);
+ } catch (error) {
+ result.error = `Parse Error: ${(error as Error).message}`;
+ }
+ } else {
+ const idsList = Array.from(ids).join(", ");
+ result.error = `Cannot use variables ${idsList} as variable value`;
+ }
+ return result;
+};
+
+const saveVariable = (
+ dataSourceId: string = nanoid(),
+ name: string,
+ valueString: string
+): undefined | { error?: string } => {
+ const { value, error } = parseVariableValue(valueString);
+ if (error !== undefined) {
+ return { error };
+ }
+
+ const instanceSelector = selectedInstanceSelectorStore.get();
+ if (instanceSelector === undefined) {
+ return;
+ }
+ const [instanceId] = instanceSelector;
+ serverSyncStore.createTransaction([dataSourcesStore], (dataSources) => {
+ let variableValue: VariableDataSource["value"] = {
+ type: "string",
+ value: "",
+ };
+ if (typeof value === "string") {
+ variableValue = { type: "string", value };
+ }
+ if (typeof value === "number") {
+ variableValue = { type: "number", value };
+ }
+ if (typeof value === "boolean") {
+ variableValue = { type: "boolean", value };
+ }
+ dataSources.set(dataSourceId, {
+ id: dataSourceId,
+ // preserve existing instance scope when edit
+ scopeInstanceId:
+ dataSources.get(dataSourceId)?.scopeInstanceId ?? instanceId,
+ name,
+ type: "variable",
+ value: variableValue,
+ });
+ });
+};
+
+const VariablePanel = ({
+ variable,
+ onBack,
+}: {
+ variable?: VariableDataSource;
+ onBack: () => void;
+}) => {
+ // variable value cannot have an access to other variables
+ const variables = useMemo(() => new Map(), []);
+ const nameId = useId();
+ const [name, setName] = useState(variable?.name ?? "");
+ const [nameErrors, setNameErrors] = useState();
+ const [value, setValue] = useState(
+ JSON.stringify(variable?.value.value ?? "")
+ );
+ const [valueErrors, setValueErrors] = useState();
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ // prevent closing popover
+ event.preventDefault();
+ onBack();
+ }
+ };
+ document.addEventListener("keydown", handleKeyDown, { capture: true });
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown, { capture: true });
+ };
+ }, [onBack]);
+
+ return (
+
+
+
+
+
+ setName(event.target.value)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+const selectedInstanceVariables = computed(
+ [selectedInstanceSelectorStore, dataSourcesStore],
+ (instanceSelector, dataSources) => {
+ const matchedVariables: VariableDataSource[] = [];
+ if (instanceSelector === undefined) {
+ return matchedVariables;
+ }
+ for (const dataSource of dataSources.values()) {
+ if (
+ dataSource.type === "variable" &&
+ dataSource.scopeInstanceId !== undefined &&
+ instanceSelector.includes(dataSource.scopeInstanceId)
+ ) {
+ matchedVariables.push(dataSource);
+ }
+ }
+ return matchedVariables;
+ }
+);
+
+const EmptyList = ({ onAdd }: { onAdd: () => void }) => {
+ return (
+
+
+ No variables yet
+
+
+ } onClick={onAdd}>
+ Create variable
+
+
+
+ );
+};
+
+const deleteVariable = (variable: VariableDataSource) => {
+ // @todo prevent delete when variable is used
+ serverSyncStore.createTransaction([dataSourcesStore], (dataSources) => {
+ dataSources.delete(variable.id);
+ });
+};
+
+const ListItem = ({
+ index,
+ selectedId,
+ variable,
+ onSelect,
+ onEdit,
+}: {
+ index: number;
+ selectedId: undefined | string;
+ variable: VariableDataSource;
+ onSelect: (variableId: DataSource["id"]) => void;
+ onEdit: (variable: VariableDataSource) => void;
+}) => {
+ return (
+
+ {variable.name}: {JSON.stringify(variable.value.value)}
+
+ }
+ id={variable.id}
+ index={index}
+ active={selectedId === variable.id}
+ buttons={
+ <>
+
+ }
+ onClick={() => onEdit(variable)}
+ />
+
+
+ }
+ onClick={() => deleteVariable(variable)}
+ />
+
+ >
+ }
+ onClick={() => onSelect(variable.id)}
+ />
+ );
+};
+
+const ListPanel = ({
+ onAdd,
+ onEdit,
+}: {
+ onAdd: () => void;
+ onEdit: (variable: VariableDataSource) => void;
+}) => {
+ const matchedVariables = useStore(selectedInstanceVariables);
+ const [selectedId, setSelectedId] = useState(undefined);
+ return (
+
+ {matchedVariables.length === 0 ? (
+
+ ) : (
+
+ {matchedVariables.map((variable, index) => (
+
+ ))}
+
+ )}
+
+ );
+};
+
+export const VariablesPanel = () => {
+ const [view, setView] = useState<
+ | { name: "list" }
+ | { name: "add" }
+ | { name: "edit"; variable: VariableDataSource }
+ >({ name: "list" });
+ if (view.name === "add") {
+ return (
+ <>
+ setView({ name: "list" })} />
+ {/* put after content to avoid auto focusing "Close" button */}
+ New Variable
+ >
+ );
+ }
+ if (view.name === "edit") {
+ return (
+ <>
+ setView({ name: "list" })}
+ />
+ {/* put after content to avoid auto focusing "Close" button */}
+ Edit Variable
+ >
+ );
+ }
+ return (
+ <>
+ setView({ name: "add" })}
+ onEdit={(variable) => setView({ name: "edit", variable })}
+ />
+ {/* put after content to avoid auto focusing "New variable" button */}
+
+ }
+ color="ghost"
+ onClick={() => setView({ name: "add" })}
+ />
+
+ }
+ >
+ Variables
+
+ >
+ );
+};
diff --git a/packages/design-system/src/components/css-value-list-item.tsx b/packages/design-system/src/components/css-value-list-item.tsx
index 5a7b32c473..1ef287eb81 100644
--- a/packages/design-system/src/components/css-value-list-item.tsx
+++ b/packages/design-system/src/components/css-value-list-item.tsx
@@ -40,7 +40,6 @@ const IconButtonsWrapper = styled(Flex, {
top: 0,
bottom: 0,
paddingRight: sharedPaddingRight,
- visibility: "hidden",
});
const FakeIconButtonsWrapper = styled(Flex, {
@@ -118,11 +117,6 @@ const ItemWrapper = styled("div", {
visibility: "visible",
},
},
- "&:hover, &[data-active=true], &:focus-within": {
- [`& ${IconButtonsWrapper}`]: {
- visibility: "visible",
- },
- },
});
const FakeSmallButton = styled("div", {
diff --git a/packages/design-system/src/components/floating-panel-popover.tsx b/packages/design-system/src/components/floating-panel-popover.tsx
index dfcb959cb4..342f2de38c 100644
--- a/packages/design-system/src/components/floating-panel-popover.tsx
+++ b/packages/design-system/src/components/floating-panel-popover.tsx
@@ -45,17 +45,22 @@ FloatingPanelPopoverContent.displayName = "FloatingPanelPopoverContent";
export const FloatingPanelPopoverTitle = ({
children,
+ actions,
closeLabel = "Close dialog",
}: {
children: ReactNode;
+ actions?: ReactNode;
closeLabel?: string;
}) => (
-
-
+ <>
+ {actions}
+
+
+
+ >
}
>
{children}