Skip to content

experimental: Enable/disable animations per breakpoints (Builder UI only) #5010

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Mar 18, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,16 @@ const animationSources = Object.keys(
export const AnimateSection = ({
animationAction,
onChange,
isAnimationEnabled,
selectedBreakpointId,
}: {
animationAction: PropAndMeta;
onChange: ((value: undefined, isEphemeral: true) => void) &
((value: AnimationAction, isEphemeral: boolean) => void);
isAnimationEnabled: (
enabled: [breakpointId: string, enabled: boolean][] | undefined
) => boolean | undefined;
selectedBreakpointId: string;
}) => {
const fieldIds = useIds([
"type",
Expand Down Expand Up @@ -416,7 +422,12 @@ export const AnimateSection = ({
</Grid>
)}

<AnimationsSelect value={value} onChange={handleChange} />
<AnimationsSelect
value={value}
onChange={handleChange}
isAnimationEnabled={isAnimationEnabled}
selectedBreakpointId={selectedBreakpointId}
/>
</Grid>
</Grid>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
FloatingPanel,
InputField,
DialogTitle,
Tooltip,
} from "@webstudio-is/design-system";
import {
EyeClosedIcon,
Expand All @@ -45,15 +46,24 @@ const newAnimationsPerType: {
view: newViewAnimations,
};

type Props = {
type AnimationsSelectProps = {
value: AnimationAction;
onChange: ((value: unknown, isEphemeral: boolean) => void) &
((value: undefined, isEphemeral: true) => void);
isAnimationEnabled: (
enabled: [breakpointId: string, enabled: boolean][] | undefined
) => boolean | undefined;
selectedBreakpointId: string;
};

const floatingPanelOffset = { alignmentAxis: -100 };

export const AnimationsSelect = ({ value, onChange }: Props) => {
export const AnimationsSelect = ({
value,
onChange,
isAnimationEnabled,
selectedBreakpointId,
}: AnimationsSelectProps) => {
const fieldIds = useIds(["addAnimation"] as const);

const [newAnimationHint, setNewAnimationHint] = useState<string | undefined>(
Expand Down Expand Up @@ -145,106 +155,134 @@ export const AnimationsSelect = ({ value, onChange }: Props) => {
</DropdownMenu>
<CssValueListArrowFocus dragItemId={dragItemId}>
<Grid gap={1} css={{ gridColumn: "span 2" }} ref={sortableRefCallback}>
{value.animations.map((animation, index) => (
<FloatingPanel
key={index}
title={
<DialogTitle css={{ paddingLeft: theme.spacing[6] }}>
<InputField
css={{
width: "100%",
fontWeight: `inherit`,
}}
variant="chromeless"
value={animation.name}
autoFocus={true}
placeholder="Enter animation name"
onChange={(event) => {
const name = event.currentTarget.value;
const newAnimations = [...value.animations];
newAnimations[index] = { ...animation, name };
{value.animations.map((animation, index) => {
const isEnabled = isAnimationEnabled(animation.enabled) ?? true;

const newValue = {
...value,
animations: newAnimations,
};

handleChange(newValue, false);
}}
/>
</DialogTitle>
}
content={
<AnimationPanelContent
type={value.type}
value={animation}
onChange={(animation, isEphemeral) => {
if (animation === undefined) {
// Reset ephemeral state
handleChange(undefined, true);
return;
}

const newAnimations = [...value.animations];
newAnimations[index] = animation;
const newValue = {
...value,
animations: newAnimations,
};
handleChange(newValue, isEphemeral);
}}
/>
}
offset={floatingPanelOffset}
>
<CssValueListItem
return (
<FloatingPanel
key={index}
label={
<Label disabled={false} truncate>
{animation.name ?? "Unnamed"}
</Label>
}
hidden={false}
draggable
active={dragItemId === String(index)}
state={undefined}
index={index}
id={String(index)}
buttons={
<>
<SmallToggleButton
pressed={false}
onPressedChange={() => {
alert("Not implemented");
title={
<DialogTitle css={{ paddingLeft: theme.spacing[6] }}>
<InputField
css={{
width: "100%",
fontWeight: `inherit`,
}}
variant="normal"
tabIndex={-1}
icon={
// eslint-disable-next-line no-constant-condition
false ? <EyeClosedIcon /> : <EyeOpenIcon />
}
/>

<SmallIconButton
variant="destructive"
tabIndex={-1}
icon={<MinusIcon />}
onClick={() => {
variant="chromeless"
value={animation.name}
autoFocus={true}
placeholder="Enter animation name"
onChange={(event) => {
const name = event.currentTarget.value;
const newAnimations = [...value.animations];
newAnimations.splice(index, 1);
newAnimations[index] = { ...animation, name };

const newValue = {
...value,
animations: newAnimations,
};

handleChange(newValue, false);
}}
/>
</>
</DialogTitle>
}
/>
</FloatingPanel>
))}
content={
<AnimationPanelContent
type={value.type}
value={animation}
onChange={(animation, isEphemeral) => {
if (animation === undefined) {
// Reset ephemeral state
handleChange(undefined, true);
return;
}

const newAnimations = [...value.animations];
newAnimations[index] = animation;
const newValue = {
...value,
animations: newAnimations,
};
handleChange(newValue, isEphemeral);
}}
/>
}
offset={floatingPanelOffset}
>
<CssValueListItem
key={index}
label={
<Label disabled={false} truncate>
{animation.name ?? "Unnamed"}
</Label>
}
hidden={!isEnabled}
draggable
active={dragItemId === String(index)}
state={undefined}
index={index}
id={String(index)}
buttons={
<>
<Tooltip
content={
isEnabled
? "Disable animation at breakpoint"
: "Enable animation at breakpoint"
}
>
<SmallToggleButton
pressed={!isEnabled}
onPressedChange={() => {
const enabledMap = new Map(animation.enabled);
enabledMap.set(selectedBreakpointId, !isEnabled);

const enabled = [...enabledMap];

const newAnimations = [...value.animations];
const newAnimation = {
...animation,
enabled: enabled.every(([_, enabled]) => enabled)
? undefined
: [...enabledMap],
};

newAnimations[index] = newAnimation;

const newValue = {
...value,
animations: newAnimations,
};
handleChange(newValue, false);
}}
variant="normal"
tabIndex={-1}
icon={isEnabled ? <EyeOpenIcon /> : <EyeClosedIcon />}
/>
</Tooltip>

<SmallIconButton
variant="destructive"
tabIndex={-1}
icon={<MinusIcon />}
onClick={() => {
const newAnimations = [...value.animations];
newAnimations.splice(index, 1);

const newValue = {
...value,
animations: newAnimations,
};
handleChange(newValue, false);
}}
/>
</>
}
/>
</FloatingPanel>
);
})}
{placementIndicator}
</Grid>
</CssValueListArrowFocus>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ export const calcOffsets = (
const offsets = keyframes.map((k) =>
k.offset !== undefined ? k.offset * multiplier : undefined
);

if (offsets.length === 1 && offsets[0] === undefined) {
return [1];
}

if (offsets[0] === undefined) {
offsets[0] = 0;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from "vitest";
import { matchMediaBreakpoints } from "./match-media-breakpoints";
import type { IsEqual } from "type-fest";

describe("matchMediaBreakpoints", () => {
it("returns undefined when values array is undefined", () => {
const matchingBreakpointIds = ["mobile", "tablet", "desktop"];
const matcher = matchMediaBreakpoints(matchingBreakpointIds);

expect(matcher(undefined)).toBeUndefined();
});

it("returns undefined when no matching breakpoints are found", () => {
const matchingBreakpointIds = ["mobile", "tablet", "desktop"];
const values: Array<[string, number]> = [
["other-breakpoint", 100],
["another-breakpoint", 200],
];

const matcher = matchMediaBreakpoints(matchingBreakpointIds);

expect(matcher(values)).toBeUndefined();
});

it("returns the value of the last matching breakpoint", () => {
const matchingBreakpointIds = ["mobile", "tablet"];
const values: Array<[string, number]> = [
["mobile", 320],
["tablet", 768],
["desktop", 1024],
];

const matcher = matchMediaBreakpoints(matchingBreakpointIds);

expect(matcher(values)).toBe(768);
});

it("preserves the value type", () => {
const matchingBreakpointIds = ["mobile", "tablet", "desktop"];

const stringValues: Array<[string, string]> = [["mobile", "small"]];
const stringMatcher = matchMediaBreakpoints(matchingBreakpointIds);
const strResult = stringMatcher(stringValues);
expect(strResult).toBe("small");
true satisfies IsEqual<string | undefined, typeof strResult>;

const booleanValues: Array<[string, boolean]> = [["tablet", true]];
const booleanMatcher = matchMediaBreakpoints(matchingBreakpointIds);
const boolResult = booleanMatcher(booleanValues);
expect(boolResult).toBe(true);
true satisfies IsEqual<boolean | undefined, typeof boolResult>;

const objectValues: Array<[string, { width: number }]> = [
["desktop", { width: 1024 }],
];
const objectMatcher = matchMediaBreakpoints(matchingBreakpointIds);
const objResult = objectMatcher(objectValues);
expect(objResult).toEqual({ width: 1024 });
true satisfies IsEqual<{ width: number } | undefined, typeof objResult>;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Given an array of [breakpointId, value] tuples and an ordered list of breakpoint IDs,
* returns the value associated with the last matching breakpoint found.
* If none of the breakpoint IDs are present, returns undefined.
* */
export const matchMediaBreakpoints =
(matchingBreakpointIds: string[]) =>
<T extends [breakpointId: string, value: unknown]>(
values: T[] | undefined
): T[1] | undefined => {
let lastValue: T[1] | undefined = undefined;

if (values === undefined) {
return lastValue;
}

const valuesMap = new Map<string, T[1]>(values);

for (const matchingBreakpointId of matchingBreakpointIds) {
lastValue = valuesMap.get(matchingBreakpointId) ?? lastValue;
}

return lastValue;
};
Loading