diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-content.tsx b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-content.tsx
index 372c741656..ccd6333133 100644
--- a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-content.tsx
+++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-content.tsx
@@ -25,6 +25,7 @@ import {
type ExtractedTransitionProperties,
} from "@webstudio-is/css-data";
import type {
+ CreateBatchUpdate,
DeleteProperty,
StyleUpdateOptions,
} from "../../shared/use-style-data";
@@ -32,6 +33,14 @@ import { type IntermediateStyleValue } from "../../shared/css-value-input";
import { TransitionProperty } from "./transition-property";
import { TransitionTiming } from "./transition-timing";
import { CssValueInputContainer } from "../../shared/css-value-input";
+import {
+ defaultTransitionProperty,
+ defaultTransitionDuration,
+ defaultTransitionTimingFunction,
+ defaultTransitionDelay,
+ deleteTransitionLayer,
+} from "./transition-utils";
+import type { StyleInfo } from "../../shared/style-info";
type TransitionContentProps = {
index: number;
@@ -45,16 +54,18 @@ type TransitionContentProps = {
options: StyleUpdateOptions
) => void;
deleteProperty: DeleteProperty;
+ currentStyle: StyleInfo;
+ createBatchUpdate: CreateBatchUpdate;
};
export const TransitionContent = ({
layer,
index,
tooltip,
- property: transitionPropertyKey,
onEditLayer,
propertyValue,
- deleteProperty,
+ createBatchUpdate,
+ currentStyle,
}: TransitionContentProps) => {
const [intermediateValue, setIntermediateValue] = useState<
IntermediateStyleValue | InvalidValue | undefined
@@ -73,7 +84,7 @@ export const TransitionContent = ({
});
};
- const handleComplete = () => {
+ const handleComplete = (options: StyleUpdateOptions) => {
if (intermediateValue === undefined) {
return;
}
@@ -87,7 +98,7 @@ export const TransitionContent = ({
return;
}
- onEditLayer(index, layers, { isEphemeral: false });
+ onEditLayer(index, layers, options);
};
const handlePropertyUpdate = (
@@ -129,8 +140,7 @@ export const TransitionContent = ({
}}
>
@@ -157,14 +167,13 @@ export const TransitionContent = ({
key={"transitionDuration"}
property={"transitionDuration"}
styleSource="local"
- /* Browser default for transition-duration */
- value={duration ?? { type: "unit", value: 0, unit: "ms" }}
+ value={duration ?? defaultTransitionDuration}
keywords={[]}
deleteProperty={() => {
handlePropertyUpdate({ duration });
}}
setValue={(value, options) => {
- if (value === undefined) {
+ if (value === undefined || value.type !== "unit") {
return;
}
handlePropertyUpdate({ duration: value }, options);
@@ -193,12 +202,11 @@ export const TransitionContent = ({
property={"transitionDelay"}
key={"transitionDelay"}
styleSource="local"
- /* Browser default for transition-delay */
- value={delay ?? { type: "unit", value: 0, unit: "ms" }}
+ value={delay ?? defaultTransitionDelay}
keywords={[]}
deleteProperty={() => handlePropertyUpdate({ delay })}
setValue={(value, options) => {
- if (value === undefined) {
+ if (value === undefined || value.type !== "unit") {
return;
}
handlePropertyUpdate({ delay: value }, options);
@@ -206,8 +214,7 @@ export const TransitionContent = ({
/>
@@ -235,12 +242,12 @@ export const TransitionContent = ({
color={intermediateValue?.type === "invalid" ? "error" : undefined}
value={intermediateValue?.value ?? ""}
onChange={handleChange}
- onBlur={handleComplete}
+ onBlur={() => handleComplete({ isEphemeral: true })}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") {
- handleComplete();
+ handleComplete({ isEphemeral: false });
event.preventDefault();
}
@@ -249,7 +256,12 @@ export const TransitionContent = ({
return;
}
- deleteProperty(transitionPropertyKey, { isEphemeral: true });
+ deleteTransitionLayer({
+ currentStyle,
+ createBatchUpdate,
+ index,
+ options: { isEphemeral: true },
+ });
setIntermediateValue(undefined);
event.preventDefault();
}
diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-property.tsx b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-property.tsx
index 1e2ba95ff6..7198831b43 100644
--- a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-property.tsx
+++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-property.tsx
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import {
animatableProperties,
+ commonTransitionProperties,
isAnimatableProperty,
} from "@webstudio-is/css-data";
import {
@@ -31,17 +32,7 @@ type TransitionPropertyProps = {
onPropertySelection: (params: { property: KeywordValue }) => void;
};
-const commonPropertiesSet = new Set([
- "all",
- "opacity",
- "margin",
- "padding",
- "border",
- "transform",
- "filter",
- "flex",
- "background-color",
-]);
+const commonPropertiesSet = new Set(commonTransitionProperties);
const properties = Array.from(
setUnion(commonPropertiesSet, new Set(animatableProperties))
diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.test.ts b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.test.ts
new file mode 100644
index 0000000000..ad0be47f8c
--- /dev/null
+++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.test.ts
@@ -0,0 +1,256 @@
+import { afterEach, describe, test, expect } from "@jest/globals";
+import type { CreateBatchUpdate } from "../../shared/use-style-data";
+import {
+ toValue,
+ type StyleProperty,
+ type StyleValue,
+} from "@webstudio-is/css-engine";
+import type { StyleInfo } from "../../shared/style-info";
+import {
+ addDefaultTransitionLayer,
+ convertIndividualTransitionToLayers,
+ deleteTransitionLayer,
+ editTransitionLayer,
+ getTransitionProperties,
+ hideTransitionLayer,
+ swapTransitionLayers,
+} from "./transition-utils";
+import type { TransitionProperties } from "./transition-utils";
+import type { LayersValue } from "@webstudio-is/css-engine";
+
+let styleInfo: StyleInfo = {};
+let published = false;
+const deletedProperties = new Set();
+
+afterEach(() => {
+ styleInfo = {};
+ published = false;
+ deletedProperties.clear();
+});
+
+const createBatchUpdate: CreateBatchUpdate = () => ({
+ setProperty: (propertyName: StyleProperty) => (newValue: StyleValue) => {
+ styleInfo[propertyName] = { value: newValue, local: newValue };
+ },
+ deleteProperty: (propertyName: string) => {
+ deletedProperties.add(propertyName);
+ },
+ publish: (options?: unknown) => {
+ published = true;
+ },
+});
+
+describe("transition-utils", () => {
+ test("convert individual transition properties into short-hand transition layers", () => {
+ const properties: Record = {
+ transitionProperty: {
+ type: "layers",
+ value: [
+ { type: "keyword", value: "opacity" },
+ { type: "keyword", value: "width" },
+ { type: "keyword", value: "all" },
+ { type: "keyword", value: "height" },
+ ],
+ },
+ transitionDuration: {
+ type: "layers",
+ value: [
+ { type: "unit", value: 50, unit: "ms" },
+ { type: "unit", value: 0, unit: "ms" },
+ ],
+ },
+ transitionTimingFunction: {
+ type: "layers",
+ value: [
+ { type: "keyword", value: "ease" },
+ { type: "keyword", value: "ease-in-out" },
+ { type: "keyword", value: "cubic-bezier(0.25,1,0.5,1)" },
+ ],
+ },
+ transitionDelay: {
+ type: "layers",
+ value: [
+ { type: "unit", value: 100, unit: "ms" },
+ { type: "unit", value: 0, unit: "ms" },
+ ],
+ },
+ };
+
+ const layers = convertIndividualTransitionToLayers(properties);
+ expect(toValue(layers)).toMatchInlineSnapshot(
+ `"opacity 50ms ease 100ms, width 0ms ease-in-out 0ms, all 50ms cubic-bezier(0.25,1,0.5,1) 100ms, height 0ms ease 0ms"`
+ );
+ });
+
+ test("add a layer to transition long hand proeprties", () => {
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ const properties = getTransitionProperties(styleInfo);
+ const layers = convertIndividualTransitionToLayers(properties);
+
+ expect(published).toBe(true);
+ expect(toValue(layers)).toMatchInlineSnapshot(`"opacity 200ms ease 0s"`);
+ });
+
+ test("update individual transition properties", () => {
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ editTransitionLayer({
+ currentStyle: styleInfo,
+ index: 1,
+ createBatchUpdate,
+ layers: {
+ type: "layers",
+ value: [
+ { type: "tuple", value: [{ type: "keyword", value: "width" }] },
+ ],
+ },
+ options: { isEphemeral: false },
+ });
+
+ const layers = convertIndividualTransitionToLayers(
+ getTransitionProperties(styleInfo)
+ );
+
+ expect(published).toBe(true);
+ expect(layers.value.length).toBe(3);
+ expect(toValue(layers.value[1])).toMatchInlineSnapshot(
+ `"width 0ms ease 0ms"`
+ );
+ expect(toValue(layers)).toMatchInlineSnapshot(
+ `"opacity 200ms ease 0s, width 0ms ease 0ms, opacity 200ms ease 0s"`
+ );
+ });
+
+ test("delete a layer from transition properties", () => {
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ editTransitionLayer({
+ currentStyle: styleInfo,
+ index: 2,
+ createBatchUpdate,
+ layers: {
+ type: "layers",
+ value: [
+ {
+ type: "tuple",
+ value: [
+ { type: "keyword", value: "width" },
+ { type: "keyword", value: "ease-in-out" },
+ ],
+ },
+ ],
+ },
+ options: { isEphemeral: false },
+ });
+
+ deleteTransitionLayer({
+ currentStyle: styleInfo,
+ createBatchUpdate,
+ index: 1,
+ });
+
+ const layers = convertIndividualTransitionToLayers(
+ getTransitionProperties(styleInfo)
+ );
+
+ expect(published).toBe(true);
+ expect(toValue(layers)).toMatchInlineSnapshot(
+ `"opacity 200ms ease 0s, width 0ms ease-in-out 0ms"`
+ );
+ });
+
+ test("hide a layer from transition properties", () => {
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ addDefaultTransitionLayer({
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ hideTransitionLayer({
+ index: 1,
+ createBatchUpdate,
+ currentStyle: styleInfo,
+ });
+
+ const layers = convertIndividualTransitionToLayers(
+ getTransitionProperties(styleInfo)
+ );
+
+ expect(published).toBe(true);
+ expect(layers.value.length).toBe(2);
+ expect(toValue(layers)).toMatchInlineSnapshot(`"opacity 200ms ease 0s"`);
+ });
+
+ test("swap layer positions in transitions", () => {
+ addDefaultTransitionLayer({ currentStyle: styleInfo, createBatchUpdate });
+ addDefaultTransitionLayer({ currentStyle: styleInfo, createBatchUpdate });
+ editTransitionLayer({
+ currentStyle: styleInfo,
+ index: 1,
+ createBatchUpdate,
+ layers: {
+ type: "layers",
+ value: [
+ {
+ type: "tuple",
+ value: [
+ { type: "keyword", value: "width" },
+ { type: "keyword", value: "ease-in-out" },
+ ],
+ },
+ ],
+ },
+ options: { isEphemeral: false },
+ });
+ swapTransitionLayers({
+ oldIndex: 1,
+ newIndex: 0,
+ currentStyle: styleInfo,
+ createBatchUpdate,
+ });
+
+ const layers = convertIndividualTransitionToLayers(
+ getTransitionProperties(styleInfo)
+ );
+
+ expect(published).toBe(true);
+ expect(layers.value.length).toBe(2);
+ expect(toValue(layers)).toMatchInlineSnapshot(
+ `"width 0ms ease-in-out 0ms, opacity 200ms ease 0s"`
+ );
+ });
+});
diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.ts b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.ts
index 4a2a91d2a0..233f3dea97 100644
--- a/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.ts
+++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transition-utils.ts
@@ -1,3 +1,41 @@
+import {
+ type KeywordValue,
+ type LayersValue,
+ type TupleValue,
+ type UnitValue,
+} from "@webstudio-is/css-engine";
+import type { StyleInfo } from "../../shared/style-info";
+import type {
+ CreateBatchUpdate,
+ StyleUpdateOptions,
+} from "../../shared/use-style-data";
+import {
+ extractTransitionProperties,
+ isValidTransitionValue,
+ parseTransition,
+ transitionLongHandProperties,
+} from "@webstudio-is/css-data";
+
+export const initialTransition = "opacity 200ms ease 0s";
+export const defaultTransitionProperty: KeywordValue = {
+ type: "keyword",
+ value: "opacity",
+};
+export const defaultTransitionDuration: UnitValue = {
+ type: "unit",
+ value: 0,
+ unit: "ms",
+};
+export const defaultTransitionDelay: UnitValue = {
+ type: "unit",
+ value: 0,
+ unit: "ms",
+};
+export const defaultTransitionTimingFunction: KeywordValue = {
+ type: "keyword",
+ value: "ease",
+};
+
export const defaultFunctions = {
linear: "linear",
ease: "ease",
@@ -63,3 +101,289 @@ export const findTimingFunctionFromValue = (
(key: TimingFunctions) => timingFunctions[key] === timingFunction
);
};
+
+export type TransitionProperties =
+ (typeof transitionLongHandProperties)[number];
+
+export const getTransitionProperties = (
+ currentyStyle: StyleInfo
+): Record => {
+ const properties: Record = {
+ transitionProperty: { type: "layers", value: [] },
+ transitionTimingFunction: { type: "layers", value: [] },
+ transitionDelay: { type: "layers", value: [] },
+ transitionDuration: { type: "layers", value: [] },
+ };
+ for (const property of transitionLongHandProperties) {
+ const value = currentyStyle[property];
+
+ if (value !== undefined && value.value.type === "layers") {
+ properties[property] = value.value;
+ }
+ }
+
+ return properties;
+};
+
+const getValueOrRepeatLast = (arr: LayersValue["value"], index: number) => {
+ return arr[index % arr.length];
+};
+
+export const convertIndividualTransitionToLayers = (
+ properties: Record
+) => {
+ const layers: { type: "layers"; value: Array } = {
+ type: "layers",
+ value: [],
+ };
+
+ const {
+ transitionProperty,
+ transitionDuration,
+ transitionDelay,
+ transitionTimingFunction,
+ } = properties;
+
+ // https://developer.mozilla.org/en-US/docs/Web/CSS/transition-timing-function
+ // You may specify multiple easing, duration and delay values;
+ // each one will be applied to the corresponding property as specified by the transition-property property,
+ // which acts as a transition-property list. If there are fewer easing, duration and transition values specified than in the transition-property list,
+ // the user agent must calculate which value is used by repeating the list of values until there is one for each transition property.
+ // If there are more values, the list is truncated to the right size. In both cases, the CSS declaration stays valid.
+ // And so, we take the lenght of the transiton-property as the base length of the layers
+
+ for (let index = 0; index < transitionProperty.value.length; index++) {
+ const property = getValueOrRepeatLast(transitionProperty.value, index);
+ const duration =
+ getValueOrRepeatLast(transitionDuration.value, index) ??
+ defaultTransitionDuration;
+ const timingFunction =
+ getValueOrRepeatLast(transitionTimingFunction.value, index) ??
+ defaultTransitionTimingFunction;
+ const delay =
+ getValueOrRepeatLast(transitionDelay.value, index) ??
+ defaultTransitionDelay;
+
+ if (
+ isValidTransitionValue(property) &&
+ isValidTransitionValue(duration) &&
+ isValidTransitionValue(timingFunction) &&
+ isValidTransitionValue(delay)
+ ) {
+ const layer: TupleValue = {
+ type: "tuple",
+ value: [property, duration, timingFunction, delay],
+ hidden: property.hidden ?? false,
+ };
+ layers.value.push(layer);
+ }
+ }
+
+ return layers;
+};
+
+export const deleteTransitionProperties = (props: {
+ createBatchUpdate: CreateBatchUpdate;
+}) => {
+ const batch = props.createBatchUpdate();
+ transitionLongHandProperties.forEach((property) => {
+ batch.deleteProperty(property);
+ });
+ batch.publish();
+};
+
+export const addDefaultTransitionLayer = (props: {
+ createBatchUpdate: CreateBatchUpdate;
+ currentStyle: StyleInfo;
+}) => {
+ const { createBatchUpdate, currentStyle } = props;
+ const properties = getTransitionProperties(currentStyle);
+ const { timing, property, delay, duration } = extractTransitionProperties(
+ parseTransition(initialTransition).value[0] as TupleValue
+ );
+ const batch = createBatchUpdate();
+
+ if (property) {
+ batch.setProperty("transitionProperty")({
+ type: "layers",
+ value: [...properties.transitionProperty.value, property],
+ });
+ }
+
+ if (timing) {
+ batch.setProperty("transitionTimingFunction")({
+ type: "layers",
+ value: [...properties.transitionTimingFunction.value, timing],
+ });
+ }
+
+ if (duration) {
+ batch.setProperty("transitionDuration")({
+ type: "layers",
+ value: [...properties.transitionDuration.value, duration],
+ });
+ }
+
+ if (delay) {
+ batch.setProperty("transitionDelay")({
+ type: "layers",
+ value: [...properties.transitionDelay.value, delay],
+ });
+ }
+
+ batch.publish();
+};
+
+export const deleteTransitionLayer = (props: {
+ index: number;
+ createBatchUpdate: CreateBatchUpdate;
+ currentStyle: StyleInfo;
+ options?: StyleUpdateOptions;
+}) => {
+ const {
+ index,
+ createBatchUpdate,
+ currentStyle,
+ options = { isEphemeral: false },
+ } = props;
+
+ const properties = getTransitionProperties(currentStyle);
+ const batch = createBatchUpdate();
+ transitionLongHandProperties.forEach((property) => {
+ const value = properties[property].value;
+ batch.setProperty(property)({
+ type: "layers",
+ value: value.filter((_, i) => i !== index),
+ });
+ });
+ batch.publish(options);
+};
+
+export const editTransitionLayer = (props: {
+ index: number;
+ layers: LayersValue;
+ options: StyleUpdateOptions;
+ currentStyle: StyleInfo;
+ createBatchUpdate: CreateBatchUpdate;
+}) => {
+ const { index, layers, createBatchUpdate, options, currentStyle } = props;
+ const batch = createBatchUpdate();
+
+ const properties = getTransitionProperties(currentStyle);
+ const {
+ transitionProperty,
+ transitionDelay,
+ transitionDuration,
+ transitionTimingFunction,
+ } = properties;
+
+ const newTransitionProperties: KeywordValue[] = [];
+ const newTransitionDurations: UnitValue[] = [];
+ const newTransitionDelays: UnitValue[] = [];
+ const newTransitionTimingFunctions: KeywordValue[] = [];
+
+ for (const layer of layers.value) {
+ if (layer.type !== "tuple") {
+ continue;
+ }
+
+ const {
+ property,
+ duration = defaultTransitionDuration,
+ timing = defaultTransitionTimingFunction,
+ delay = defaultTransitionDelay,
+ } = extractTransitionProperties(layer);
+
+ // transition-property can't be undefined
+ if (property === undefined) {
+ return;
+ }
+
+ newTransitionProperties.push(property);
+ newTransitionDurations.push(duration);
+ newTransitionDelays.push(delay);
+ newTransitionTimingFunctions.push(timing);
+ }
+
+ const newProperty = [...transitionProperty.value];
+ newProperty.splice(index, 1, ...newTransitionProperties);
+ batch.setProperty("transitionProperty")({
+ type: "layers",
+ value: newProperty,
+ });
+
+ const newDuration = [...transitionDuration.value];
+ newDuration.splice(index, 1, ...newTransitionDurations);
+ batch.setProperty("transitionDuration")({
+ type: "layers",
+ value: newDuration,
+ });
+
+ const newTiming = [...transitionTimingFunction.value];
+ newTiming.splice(index, 1, ...newTransitionTimingFunctions);
+ batch.setProperty("transitionTimingFunction")({
+ type: "layers",
+ value: newTiming,
+ });
+
+ const newDelay = [...transitionDelay.value];
+ newDelay.splice(index, 1, ...newTransitionDelays);
+ batch.setProperty("transitionDelay")({
+ type: "layers",
+ value: newDelay,
+ });
+
+ batch.publish(options);
+};
+
+export const swapTransitionLayers = (props: {
+ oldIndex: number;
+ newIndex: number;
+ createBatchUpdate: CreateBatchUpdate;
+ currentStyle: StyleInfo;
+}) => {
+ const { oldIndex, newIndex, createBatchUpdate, currentStyle } = props;
+ const properties = getTransitionProperties(currentStyle);
+ const batch = createBatchUpdate();
+
+ for (const property of transitionLongHandProperties) {
+ const layer = properties[property];
+ const layervalue = [...layer.value];
+ layervalue.splice(oldIndex, 1);
+ layervalue.splice(newIndex, 0, layer.value[oldIndex]);
+ batch.setProperty(property)({
+ ...layer,
+ value: layervalue,
+ });
+ }
+
+ batch.publish();
+};
+
+export const hideTransitionLayer = (props: {
+ index: number;
+ createBatchUpdate: CreateBatchUpdate;
+ currentStyle: StyleInfo;
+}) => {
+ const { createBatchUpdate, index, currentStyle } = props;
+ const properties = getTransitionProperties(currentStyle);
+ const batch = createBatchUpdate();
+
+ for (const property of transitionLongHandProperties) {
+ const propertyLayer = properties[property];
+ batch.setProperty(property)({
+ type: "layers",
+ value: propertyLayer.value.map((layer, i) => {
+ if (layer.type !== "keyword" && layer.type !== "unit") {
+ return layer;
+ }
+
+ return i === index
+ ? { ...layer, hidden: layer.hidden ? false : true }
+ : layer;
+ }),
+ });
+ }
+
+ batch.publish();
+};
diff --git a/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx b/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx
index 0fd4cec926..32957f0148 100644
--- a/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx
+++ b/apps/builder/app/builder/features/style-panel/sections/transitions/transitions.tsx
@@ -1,39 +1,47 @@
-import type { StyleProperty } from "@webstudio-is/css-engine";
-import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section";
-import type { SectionProps } from "../shared/section";
+import { useState, useMemo } from "react";
+import { useStore } from "@nanostores/react";
+import { InfoCircleIcon, PlusIcon } from "@webstudio-is/icons";
import {
SectionTitle,
SectionTitleButton,
SectionTitleLabel,
- Tooltip,
Text,
+ Tooltip,
} from "@webstudio-is/design-system";
-import { useState } from "react";
+import { transitionLongHandProperties } from "@webstudio-is/css-data";
+
+import { CollapsibleSectionRoot } from "~/builder/shared/collapsible-section";
+import type { SectionProps } from "../shared/section";
import { getDots } from "../../shared/collapsible-section";
-import { getStyleSource } from "../../shared/style-info";
-import { InfoCircleIcon, PlusIcon } from "@webstudio-is/icons";
import { PropertyName } from "../../shared/property-name";
-import { addLayer } from "../../style-layer-utils";
-import { parseTransition } from "@webstudio-is/css-data";
import { LayersList } from "../../style-layers-list";
import { $selectedOrLastStyleSourceSelector } from "~/shared/nano-states";
-import { useStore } from "@nanostores/react";
import { TransitionContent } from "./transition-content";
+import {
+ initialTransition,
+ getTransitionProperties,
+ deleteTransitionProperties,
+ addDefaultTransitionLayer,
+ deleteTransitionLayer,
+ editTransitionLayer,
+ swapTransitionLayers,
+ hideTransitionLayer,
+ convertIndividualTransitionToLayers,
+} from "./transition-utils";
+import type { StyleProperty } from "@webstudio-is/css-engine";
-export const properties = ["transition"] satisfies Array;
-
-const property: StyleProperty = properties[0];
const label = "Transitions";
-const initialTransition = "opacity 200ms ease";
+export const properties =
+ transitionLongHandProperties as unknown as Array;
export const Section = (props: SectionProps) => {
- const { currentStyle, deleteProperty } = props;
+ const { currentStyle, createBatchUpdate } = props;
const [isOpen, setIsOpen] = useState(true);
- const value = currentStyle[property]?.value;
- const sectionStyleSource =
- value?.type === "unparsed" || value?.type === "guaranteedInvalid"
- ? undefined
- : getStyleSource(currentStyle[property]);
+ const extractedProperties = getTransitionProperties(currentStyle);
+ const layers = useMemo(
+ () => convertIndividualTransitionToLayers(extractedProperties),
+ [extractedProperties]
+ );
const selectedOrLastStyleSourceSelector = useStore(
$selectedOrLastStyleSourceSelector
@@ -51,7 +59,7 @@ export const Section = (props: SectionProps) => {
onOpenChange={setIsOpen}
trigger={
{
}
- onClick={() => {
- addLayer(
- property,
- parseTransition(initialTransition),
+ onClick={() =>
+ addDefaultTransitionLayer({
+ createBatchUpdate,
currentStyle,
- props.createBatchUpdate
- );
- setIsOpen(true);
- }}
+ })
+ }
/>
}
@@ -81,23 +86,49 @@ export const Section = (props: SectionProps) => {
style={currentStyle}
description="Animate the transition between states on this instance."
properties={properties}
- label={
-
- {label}
-
+ label={{label}}
+ onReset={() =>
+ deleteTransitionProperties({
+ createBatchUpdate,
+ })
}
- onReset={() => deleteProperty(property)}
/>
}
>
- {value?.type === "layers" && value.value.length > 0 && (
+ {layers.value.length > 0 && (
+ deleteTransitionProperties({
+ createBatchUpdate,
+ })
+ }
+ deleteLayer={(index) =>
+ deleteTransitionLayer({
+ index,
+ createBatchUpdate,
+ currentStyle,
+ })
+ }
+ swapLayers={(oldIndex, newIndex) =>
+ swapTransitionLayers({
+ oldIndex,
+ newIndex,
+ createBatchUpdate,
+ currentStyle,
+ })
+ }
+ hideLayer={(index) => {
+ hideTransitionLayer({
+ index,
+ createBatchUpdate,
+ currentStyle,
+ });
+ }}
renderContent={(layerProps) => {
if (layerProps.layer.type !== "tuple") {
return <>>;
@@ -106,7 +137,18 @@ export const Section = (props: SectionProps) => {
return (
+ editTransitionLayer({
+ index,
+ layers,
+ options,
+ createBatchUpdate,
+ currentStyle,
+ })
+ }
tooltip={
boolean | void;
+ swapLayers?: (oldIndex: number, newIndex: number) => void;
+ hideLayer?: (index: number) => void;
renderContent: (props: {
index: number;
layer: TupleValue | FunctionValue;
@@ -90,15 +94,16 @@ const extractPropertiesFromLayer = (layer: TupleValue | FunctionValue) => {
return { name: name.join(" "), value: shadow.join(" "), color };
};
-export const LayersList = ({
- label,
- property,
- value,
- currentStyle,
- createBatchUpdate,
- renderContent,
- deleteProperty,
-}: LayerListProps) => {
+export const LayersList = (props: LayerListProps) => {
+ const {
+ label,
+ property,
+ value,
+ currentStyle,
+ createBatchUpdate,
+ renderContent,
+ deleteProperty,
+ } = props;
const layersCount = getLayerCount(property, currentStyle);
const sortableItems = useMemo(
@@ -112,17 +117,28 @@ export const LayersList = ({
const { dragItemId, placementIndicator, sortableRefCallback } = useSortable({
items: sortableItems,
- onSort: (newIndex, oldIndex) => {
- swapLayers(property, newIndex, oldIndex, currentStyle, createBatchUpdate);
- },
+ onSort: (newIndex, oldIndex) =>
+ props.swapLayers
+ ? props.swapLayers(oldIndex, newIndex)
+ : swapLayers(
+ property,
+ newIndex,
+ oldIndex,
+ currentStyle,
+ createBatchUpdate
+ ),
});
const handleDeleteLayer = (index: number) => {
- return deleteLayer(property, index, value, createBatchUpdate);
+ return props?.deleteLayer
+ ? props.deleteLayer(index)
+ : deleteLayer(property, index, value, createBatchUpdate);
};
const handleHideLayer = (index: number) => {
- return hideLayer(property, index, value, createBatchUpdate);
+ return props?.hideLayer
+ ? props.hideLayer(index)
+ : hideLayer(property, index, value, createBatchUpdate);
};
const onEditLayer = (
diff --git a/packages/css-data/src/parse-css-value.ts b/packages/css-data/src/parse-css-value.ts
index 97354b41fc..5eedb7aaa5 100644
--- a/packages/css-data/src/parse-css-value.ts
+++ b/packages/css-data/src/parse-css-value.ts
@@ -10,7 +10,13 @@ import {
} from "@webstudio-is/css-engine";
import { keywordValues } from "./__generated__/keyword-values";
import { units } from "./__generated__/units";
-import { parseFilter, parseShadow, parseTransition } from "./property-parsers";
+import {
+ isTransitionLongHandProperty,
+ parseFilter,
+ parseShadow,
+ parseTransition,
+ parseTransitionLonghands,
+} from "./property-parsers";
import { camelCase } from "change-case";
export const cssTryParseValue = (input: string) => {
@@ -92,6 +98,10 @@ export const parseCssValue = (
return parseTransition(input);
}
+ if (isTransitionLongHandProperty(property)) {
+ return parseTransitionLonghands(property, input);
+ }
+
if (
ast != null &&
ast.type === "Value" &&
diff --git a/packages/css-data/src/property-parsers/transition-property-extractor.ts b/packages/css-data/src/property-parsers/transition-property-extractor.ts
index 309a422265..ffbbba8f6e 100644
--- a/packages/css-data/src/property-parsers/transition-property-extractor.ts
+++ b/packages/css-data/src/property-parsers/transition-property-extractor.ts
@@ -1,6 +1,5 @@
import type {
KeywordValue,
- StyleValue,
TupleValue,
UnitValue,
} from "@webstudio-is/css-engine";
@@ -12,19 +11,17 @@ export const isTimingFunction = (timing: string) => {
};
export type ExtractedTransitionProperties = {
- property?: KeywordValue | null;
- timing?: KeywordValue | null;
- delay?: StyleValue | null;
- duration?: StyleValue | null;
+ property?: KeywordValue;
+ timing?: KeywordValue;
+ delay?: UnitValue;
+ duration?: UnitValue;
};
export const extractTransitionProperties = (
transition: TupleValue
): ExtractedTransitionProperties => {
- let property: KeywordValue | null = null;
- let timing: KeywordValue | null = null;
- let duration: UnitValue | null = null;
- let delay: UnitValue | null = null;
+ let property: KeywordValue | undefined;
+ let timing: KeywordValue | undefined;
const keywordValues: KeywordValue[] = [];
const unitValues: UnitValue[] = [];
@@ -43,11 +40,11 @@ export const extractTransitionProperties = (
timing = keywordValues[0];
} else {
property = keywordValues[0];
- timing = keywordValues[1] ?? null;
+ timing = keywordValues[1] ?? undefined;
}
- duration = unitValues[0] ?? null;
- delay = unitValues[1] ?? null;
+ const duration: UnitValue | undefined = unitValues[0] ?? undefined;
+ const delay: UnitValue | undefined = unitValues[1] ?? undefined;
return {
property,
diff --git a/packages/css-data/src/property-parsers/transition.test.ts b/packages/css-data/src/property-parsers/transition.test.ts
index 0b8af3dd45..aefdba6a43 100644
--- a/packages/css-data/src/property-parsers/transition.test.ts
+++ b/packages/css-data/src/property-parsers/transition.test.ts
@@ -1,20 +1,18 @@
-import { describe, expect, test } from "@jest/globals";
+import { expect, test } from "@jest/globals";
import { parseTransition } from "./transition";
-describe("parseTransition", () => {
- test("parses value and returns invalid when used a invalid color", () => {
- expect(parseTransition(`10px 10px 5px foo`)).toMatchInlineSnapshot(`
+test("parses value and returns invalid when used a invalid color", () => {
+ expect(parseTransition(`10px 10px 5px foo`)).toMatchInlineSnapshot(`
{
"type": "invalid",
"value": "10px 10px 5px foo",
}
`);
- });
+});
- test("parse value and returns multiple valid layers", () => {
- expect(parseTransition("color .2s, text-shadow .2s"))
- .toMatchInlineSnapshot(`
+test("parse value and returns multiple valid layers", () => {
+ expect(parseTransition("color .2s, text-shadow .2s")).toMatchInlineSnapshot(`
{
"type": "layers",
"value": [
@@ -49,10 +47,10 @@ describe("parseTransition", () => {
],
}
`);
- });
+});
- test("parse value and returns valid layer", () => {
- expect(parseTransition("opacity 200ms ease 0s")).toMatchInlineSnapshot(`
+test("parse value and returns valid layer", () => {
+ expect(parseTransition("opacity 200ms ease 0s")).toMatchInlineSnapshot(`
{
"type": "layers",
"value": [
@@ -82,27 +80,24 @@ describe("parseTransition", () => {
],
}
`);
- });
+});
- test("throws error if any custom transition proeprty is used", () => {
- const transition = parseTransition(
- "all 200ms ease 0s, --foo 200ms ease 0s"
- );
- expect(transition).toMatchInlineSnapshot(`
+test("throws error if any custom transition proeprty is used", () => {
+ const transition = parseTransition("all 200ms ease 0s, --foo 200ms ease 0s");
+ expect(transition).toMatchInlineSnapshot(`
{
"type": "invalid",
"value": "all 200ms ease 0s, --foo 200ms ease 0s",
}
`);
- });
+});
- test("throws error if any custom transition timing function is used", () => {
- const transition = parseTransition("all 200ms custom-function 0s");
- expect(transition).toMatchInlineSnapshot(`
+test("throws error if any custom transition timing function is used", () => {
+ const transition = parseTransition("all 200ms custom-function 0s");
+ expect(transition).toMatchInlineSnapshot(`
{
"type": "invalid",
"value": "all 200ms custom-function 0s",
}
`);
- });
});
diff --git a/packages/css-data/src/property-parsers/transition.ts b/packages/css-data/src/property-parsers/transition.ts
index ddc38320a9..3da05efe90 100644
--- a/packages/css-data/src/property-parsers/transition.ts
+++ b/packages/css-data/src/property-parsers/transition.ts
@@ -1,20 +1,63 @@
import * as csstree from "css-tree";
import type {
InvalidValue,
+ KeywordValue,
+ LayerValueItem,
LayersValue,
TupleValue,
TupleValueItem,
Unit,
+ UnparsedValue,
+ UnitValue,
} from "@webstudio-is/css-engine";
import { animatableProperties } from "../";
import { isTimingFunction } from "./transition-property-extractor";
import { cssTryParseValue } from "../parse-css-value";
+import { kebabCase } from "change-case";
type AnimatableProperty = (typeof animatableProperties)[number];
+
+export const transitionLongHandProperties = [
+ "transitionProperty",
+ "transitionTimingFunction",
+ "transitionDelay",
+ "transitionDuration",
+] as const;
+
+export const commonTransitionProperties = [
+ "all",
+ "opacity",
+ "margin",
+ "padding",
+ "border",
+ "transform",
+ "filter",
+ "flex",
+ "background-color",
+];
+
export const isAnimatableProperty = (
property: string
): property is AnimatableProperty => {
- return animatableProperties.some((item) => item === property);
+ if (property === "all") {
+ return true;
+ }
+
+ return [...commonTransitionProperties, ...animatableProperties].some(
+ (item) => item === property
+ );
+};
+
+export const isTransitionLongHandProperty = (
+ property: string
+): property is (typeof transitionLongHandProperties)[number] => {
+ return transitionLongHandProperties.some((item) => item === property);
+};
+
+export const isValidTransitionValue = (
+ value: LayerValueItem
+): value is KeywordValue | UnitValue => {
+ return value.type === "keyword" || value.type === "unit";
};
export const parseTransition = (
@@ -34,7 +77,6 @@ export const parseTransition = (
}
const cssAst = cssTryParseValue(tokenStream);
-
if (cssAst === undefined) {
return {
type: "invalid",
@@ -119,3 +161,46 @@ export const parseTransition = (
};
}
};
+
+export const parseTransitionLonghands = (
+ property: (typeof transitionLongHandProperties)[number],
+ input: string
+): LayersValue | InvalidValue | UnparsedValue => {
+ const cssAst = cssTryParseValue(input);
+ if (cssAst === undefined) {
+ return {
+ type: "invalid",
+ value: input,
+ };
+ }
+
+ const parsed = csstree.lexer.matchProperty(kebabCase(property), cssAst);
+ if (parsed.error) {
+ return {
+ type: "invalid",
+ value: input,
+ };
+ }
+
+ const layers: LayersValue = { type: "layers", value: [] };
+ csstree.walk(cssAst, (node) => {
+ if (node.type === "Identifier") {
+ if (
+ isAnimatableProperty(node.name) === true ||
+ isTimingFunction(node.name) === true
+ ) {
+ layers.value.push({ type: "keyword", value: node.name });
+ }
+ }
+
+ if (node.type === "Dimension") {
+ layers.value.push({
+ type: "unit",
+ value: Number(node.value),
+ unit: node.unit as Unit,
+ });
+ }
+ });
+
+ return layers.value.length > 0 ? layers : { type: "invalid", value: input };
+};
diff --git a/packages/css-engine/src/schema.ts b/packages/css-engine/src/schema.ts
index c257848fe8..76dfbccff4 100644
--- a/packages/css-engine/src/schema.ts
+++ b/packages/css-engine/src/schema.ts
@@ -16,6 +16,7 @@ export const UnitValue = z.object({
type: z.literal("unit"),
unit: Unit,
value: z.number(),
+ hidden: z.boolean().optional(),
});
export type UnitValue = z.infer;
@@ -24,6 +25,7 @@ export const KeywordValue = z.object({
type: z.literal("keyword"),
// @todo use exact type
value: z.string(),
+ hidden: z.boolean().optional(),
});
export type KeywordValue = z.infer;