Skip to content

Commit 9e77277

Browse files
authored
experimental: Enable/disable animations per breakpoints (Builder UI only) (#5010)
## Description # !! Builder UI only !! Enable/disable animations per breakpoints. <img width="256" alt="image" src="https://github.com/user-attachments/assets/89792625-e2b3-4c58-8b85-0a44b18b9e53" /> ### Next PR: Component support ## Steps for reproduction Disable animation at some breakpoint. See in builder UI it's disabled for all corresponding breakpoints too. ## Code Review - [ ] hi @kof, I need you to do - conceptual review (architecture, feature-correctness) - detailed review (read every line) - test it on preview ## Before requesting a review - [ ] made a self-review - [ ] added inline comments where things may be not obvious (the "why", not "what") ## Before merging - [ ] tested locally and on preview environment (preview dev login: 0000) - [ ] updated [test cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md) document - [ ] added tests - [ ] if any new env variables are added, added them to `.env` file
1 parent ac8ca94 commit 9e77277

File tree

8 files changed

+243
-92
lines changed

8 files changed

+243
-92
lines changed

apps/builder/app/builder/features/settings-panel/props-section/animation/animation-section.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,16 @@ const animationSources = Object.keys(
181181
export const AnimateSection = ({
182182
animationAction,
183183
onChange,
184+
isAnimationEnabled,
185+
selectedBreakpointId,
184186
}: {
185187
animationAction: PropAndMeta;
186188
onChange: ((value: undefined, isEphemeral: true) => void) &
187189
((value: AnimationAction, isEphemeral: boolean) => void);
190+
isAnimationEnabled: (
191+
enabled: [breakpointId: string, enabled: boolean][] | undefined
192+
) => boolean | undefined;
193+
selectedBreakpointId: string;
188194
}) => {
189195
const fieldIds = useIds([
190196
"type",
@@ -416,7 +422,12 @@ export const AnimateSection = ({
416422
</Grid>
417423
)}
418424

419-
<AnimationsSelect value={value} onChange={handleChange} />
425+
<AnimationsSelect
426+
value={value}
427+
onChange={handleChange}
428+
isAnimationEnabled={isAnimationEnabled}
429+
selectedBreakpointId={selectedBreakpointId}
430+
/>
420431
</Grid>
421432
</Grid>
422433
);

apps/builder/app/builder/features/settings-panel/props-section/animation/animations-select.tsx

Lines changed: 127 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
FloatingPanel,
2121
InputField,
2222
DialogTitle,
23+
Tooltip,
2324
} from "@webstudio-is/design-system";
2425
import {
2526
EyeClosedIcon,
@@ -45,15 +46,24 @@ const newAnimationsPerType: {
4546
view: newViewAnimations,
4647
};
4748

48-
type Props = {
49+
type AnimationsSelectProps = {
4950
value: AnimationAction;
5051
onChange: ((value: unknown, isEphemeral: boolean) => void) &
5152
((value: undefined, isEphemeral: true) => void);
53+
isAnimationEnabled: (
54+
enabled: [breakpointId: string, enabled: boolean][] | undefined
55+
) => boolean | undefined;
56+
selectedBreakpointId: string;
5257
};
5358

5459
const floatingPanelOffset = { alignmentAxis: -100 };
5560

56-
export const AnimationsSelect = ({ value, onChange }: Props) => {
61+
export const AnimationsSelect = ({
62+
value,
63+
onChange,
64+
isAnimationEnabled,
65+
selectedBreakpointId,
66+
}: AnimationsSelectProps) => {
5767
const fieldIds = useIds(["addAnimation"] as const);
5868

5969
const [newAnimationHint, setNewAnimationHint] = useState<string | undefined>(
@@ -145,106 +155,134 @@ export const AnimationsSelect = ({ value, onChange }: Props) => {
145155
</DropdownMenu>
146156
<CssValueListArrowFocus dragItemId={dragItemId}>
147157
<Grid gap={1} css={{ gridColumn: "span 2" }} ref={sortableRefCallback}>
148-
{value.animations.map((animation, index) => (
149-
<FloatingPanel
150-
key={index}
151-
title={
152-
<DialogTitle css={{ paddingLeft: theme.spacing[6] }}>
153-
<InputField
154-
css={{
155-
width: "100%",
156-
fontWeight: `inherit`,
157-
}}
158-
variant="chromeless"
159-
value={animation.name}
160-
autoFocus={true}
161-
placeholder="Enter animation name"
162-
onChange={(event) => {
163-
const name = event.currentTarget.value;
164-
const newAnimations = [...value.animations];
165-
newAnimations[index] = { ...animation, name };
158+
{value.animations.map((animation, index) => {
159+
const isEnabled = isAnimationEnabled(animation.enabled) ?? true;
166160

167-
const newValue = {
168-
...value,
169-
animations: newAnimations,
170-
};
171-
172-
handleChange(newValue, false);
173-
}}
174-
/>
175-
</DialogTitle>
176-
}
177-
content={
178-
<AnimationPanelContent
179-
type={value.type}
180-
value={animation}
181-
onChange={(animation, isEphemeral) => {
182-
if (animation === undefined) {
183-
// Reset ephemeral state
184-
handleChange(undefined, true);
185-
return;
186-
}
187-
188-
const newAnimations = [...value.animations];
189-
newAnimations[index] = animation;
190-
const newValue = {
191-
...value,
192-
animations: newAnimations,
193-
};
194-
handleChange(newValue, isEphemeral);
195-
}}
196-
/>
197-
}
198-
offset={floatingPanelOffset}
199-
>
200-
<CssValueListItem
161+
return (
162+
<FloatingPanel
201163
key={index}
202-
label={
203-
<Label disabled={false} truncate>
204-
{animation.name ?? "Unnamed"}
205-
</Label>
206-
}
207-
hidden={false}
208-
draggable
209-
active={dragItemId === String(index)}
210-
state={undefined}
211-
index={index}
212-
id={String(index)}
213-
buttons={
214-
<>
215-
<SmallToggleButton
216-
pressed={false}
217-
onPressedChange={() => {
218-
alert("Not implemented");
164+
title={
165+
<DialogTitle css={{ paddingLeft: theme.spacing[6] }}>
166+
<InputField
167+
css={{
168+
width: "100%",
169+
fontWeight: `inherit`,
219170
}}
220-
variant="normal"
221-
tabIndex={-1}
222-
icon={
223-
// eslint-disable-next-line no-constant-condition
224-
false ? <EyeClosedIcon /> : <EyeOpenIcon />
225-
}
226-
/>
227-
228-
<SmallIconButton
229-
variant="destructive"
230-
tabIndex={-1}
231-
icon={<MinusIcon />}
232-
onClick={() => {
171+
variant="chromeless"
172+
value={animation.name}
173+
autoFocus={true}
174+
placeholder="Enter animation name"
175+
onChange={(event) => {
176+
const name = event.currentTarget.value;
233177
const newAnimations = [...value.animations];
234-
newAnimations.splice(index, 1);
178+
newAnimations[index] = { ...animation, name };
235179

236180
const newValue = {
237181
...value,
238182
animations: newAnimations,
239183
};
184+
240185
handleChange(newValue, false);
241186
}}
242187
/>
243-
</>
188+
</DialogTitle>
244189
}
245-
/>
246-
</FloatingPanel>
247-
))}
190+
content={
191+
<AnimationPanelContent
192+
type={value.type}
193+
value={animation}
194+
onChange={(animation, isEphemeral) => {
195+
if (animation === undefined) {
196+
// Reset ephemeral state
197+
handleChange(undefined, true);
198+
return;
199+
}
200+
201+
const newAnimations = [...value.animations];
202+
newAnimations[index] = animation;
203+
const newValue = {
204+
...value,
205+
animations: newAnimations,
206+
};
207+
handleChange(newValue, isEphemeral);
208+
}}
209+
/>
210+
}
211+
offset={floatingPanelOffset}
212+
>
213+
<CssValueListItem
214+
key={index}
215+
label={
216+
<Label disabled={false} truncate>
217+
{animation.name ?? "Unnamed"}
218+
</Label>
219+
}
220+
hidden={!isEnabled}
221+
draggable
222+
active={dragItemId === String(index)}
223+
state={undefined}
224+
index={index}
225+
id={String(index)}
226+
buttons={
227+
<>
228+
<Tooltip
229+
content={
230+
isEnabled
231+
? "Disable animation at breakpoint"
232+
: "Enable animation at breakpoint"
233+
}
234+
>
235+
<SmallToggleButton
236+
pressed={!isEnabled}
237+
onPressedChange={() => {
238+
const enabledMap = new Map(animation.enabled);
239+
enabledMap.set(selectedBreakpointId, !isEnabled);
240+
241+
const enabled = [...enabledMap];
242+
243+
const newAnimations = [...value.animations];
244+
const newAnimation = {
245+
...animation,
246+
enabled: enabled.every(([_, enabled]) => enabled)
247+
? undefined
248+
: [...enabledMap],
249+
};
250+
251+
newAnimations[index] = newAnimation;
252+
253+
const newValue = {
254+
...value,
255+
animations: newAnimations,
256+
};
257+
handleChange(newValue, false);
258+
}}
259+
variant="normal"
260+
tabIndex={-1}
261+
icon={isEnabled ? <EyeOpenIcon /> : <EyeClosedIcon />}
262+
/>
263+
</Tooltip>
264+
265+
<SmallIconButton
266+
variant="destructive"
267+
tabIndex={-1}
268+
icon={<MinusIcon />}
269+
onClick={() => {
270+
const newAnimations = [...value.animations];
271+
newAnimations.splice(index, 1);
272+
273+
const newValue = {
274+
...value,
275+
animations: newAnimations,
276+
};
277+
handleChange(newValue, false);
278+
}}
279+
/>
280+
</>
281+
}
282+
/>
283+
</FloatingPanel>
284+
);
285+
})}
248286
{placementIndicator}
249287
</Grid>
250288
</CssValueListArrowFocus>

apps/builder/app/builder/features/settings-panel/props-section/animation/keyframe-helpers.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ export const calcOffsets = (
1010
const offsets = keyframes.map((k) =>
1111
k.offset !== undefined ? k.offset * multiplier : undefined
1212
);
13+
14+
if (offsets.length === 1 && offsets[0] === undefined) {
15+
return [1];
16+
}
17+
1318
if (offsets[0] === undefined) {
1419
offsets[0] = 0;
1520
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from "vitest";
2+
import { matchMediaBreakpoints } from "./match-media-breakpoints";
3+
import type { IsEqual } from "type-fest";
4+
5+
describe("matchMediaBreakpoints", () => {
6+
it("returns undefined when values array is undefined", () => {
7+
const matchingBreakpointIds = ["mobile", "tablet", "desktop"];
8+
const matcher = matchMediaBreakpoints(matchingBreakpointIds);
9+
10+
expect(matcher(undefined)).toBeUndefined();
11+
});
12+
13+
it("returns undefined when no matching breakpoints are found", () => {
14+
const matchingBreakpointIds = ["mobile", "tablet", "desktop"];
15+
const values: Array<[string, number]> = [
16+
["other-breakpoint", 100],
17+
["another-breakpoint", 200],
18+
];
19+
20+
const matcher = matchMediaBreakpoints(matchingBreakpointIds);
21+
22+
expect(matcher(values)).toBeUndefined();
23+
});
24+
25+
it("returns the value of the last matching breakpoint", () => {
26+
const matchingBreakpointIds = ["mobile", "tablet"];
27+
const values: Array<[string, number]> = [
28+
["mobile", 320],
29+
["tablet", 768],
30+
["desktop", 1024],
31+
];
32+
33+
const matcher = matchMediaBreakpoints(matchingBreakpointIds);
34+
35+
expect(matcher(values)).toBe(768);
36+
});
37+
38+
it("preserves the value type", () => {
39+
const matchingBreakpointIds = ["mobile", "tablet", "desktop"];
40+
41+
const stringValues: Array<[string, string]> = [["mobile", "small"]];
42+
const stringMatcher = matchMediaBreakpoints(matchingBreakpointIds);
43+
const strResult = stringMatcher(stringValues);
44+
expect(strResult).toBe("small");
45+
true satisfies IsEqual<string | undefined, typeof strResult>;
46+
47+
const booleanValues: Array<[string, boolean]> = [["tablet", true]];
48+
const booleanMatcher = matchMediaBreakpoints(matchingBreakpointIds);
49+
const boolResult = booleanMatcher(booleanValues);
50+
expect(boolResult).toBe(true);
51+
true satisfies IsEqual<boolean | undefined, typeof boolResult>;
52+
53+
const objectValues: Array<[string, { width: number }]> = [
54+
["desktop", { width: 1024 }],
55+
];
56+
const objectMatcher = matchMediaBreakpoints(matchingBreakpointIds);
57+
const objResult = objectMatcher(objectValues);
58+
expect(objResult).toEqual({ width: 1024 });
59+
true satisfies IsEqual<{ width: number } | undefined, typeof objResult>;
60+
});
61+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Given an array of [breakpointId, value] tuples and an ordered list of breakpoint IDs,
3+
* returns the value associated with the last matching breakpoint found.
4+
* If none of the breakpoint IDs are present, returns undefined.
5+
* */
6+
export const matchMediaBreakpoints =
7+
(matchingBreakpointIds: string[]) =>
8+
<T extends [breakpointId: string, value: unknown]>(
9+
values: T[] | undefined
10+
): T[1] | undefined => {
11+
let lastValue: T[1] | undefined = undefined;
12+
13+
if (values === undefined) {
14+
return lastValue;
15+
}
16+
17+
const valuesMap = new Map<string, T[1]>(values);
18+
19+
for (const matchingBreakpointId of matchingBreakpointIds) {
20+
lastValue = valuesMap.get(matchingBreakpointId) ?? lastValue;
21+
}
22+
23+
return lastValue;
24+
};

0 commit comments

Comments
 (0)