Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
20 changes: 16 additions & 4 deletions webviews/src/ui/motion/SlidingIndicator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
// Stop conditions: no timer and no frame loop. The slide is one finite Web Animation (at most
// --cmux-motion-large, 240 ms) that ends on its own; the next change and unmount cancel it. One
// ResizeObserver on the host and its items re-places the highlight when a size changes (a label, a
// font load); it is disconnected on unmount.
// font load); it is disconnected on unmount. A size change during a slide is held until the slide
// finishes, then re-placed with no motion, so the highlight never ends off its item.
import { type CSSProperties, type RefObject, useLayoutEffect, useRef } from "react";
import { MOTION_EASE_OUT, MOTION_MS, MOTION_SPRING, MOTION_SPRING_STEP } from "../../motion/scale.generated";
import { cx } from "../cx";
Expand Down Expand Up @@ -73,6 +74,8 @@ export function useSlidingIndicator(
) {
const rest = useRef<Box | null>(null);
const slide = useRef<Animation | null>(null);
// Re-places it on the selected item with no motion, if its box changed; set while mounted.
const settle = useRef<(() => void) | null>(null);

// Place it on every selection change, sliding from where it is drawn.
// perf-hot: one read and one write per selection change; no per-frame work.
Expand Down Expand Up @@ -117,6 +120,7 @@ export function useSlidingIndicator(
);
slide.current.onfinish = () => {
slide.current = null;
settle.current?.();
};
}, [mark, selected, travel, selector]);

Expand All @@ -125,19 +129,27 @@ export function useSlidingIndicator(
const element = mark.current;
const parent = element?.parentElement;
if (!parent || !element || typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
const place = () => {
const item = parent.querySelector<HTMLElement>(selector);
if (!item || slide.current) return;
if (!item) return;
const target = boxIn(parent, item);
if (rest.current && same(rest.current, target)) return;
rest.current = target;
element.style.width = `${target.w}px`;
element.style.height = `${target.h}px`;
element.style.transform = `translate(${target.x}px, ${target.y}px)`;
};
settle.current = place;
// During a slide the box is read again when it finishes (onfinish calls settle).
const observer = new ResizeObserver(() => {
if (!slide.current) place();
});
observer.observe(parent);
for (const child of parent.children) if (child !== element) observer.observe(child);
return () => observer.disconnect();
return () => {
observer.disconnect();
settle.current = null;
};
}, [mark, selector]);

useLayoutEffect(
Expand Down
40 changes: 39 additions & 1 deletion webviews/src/ui/motion/motion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const saved = new Map<string, unknown>();

type Played = { element: Element; frames: Keyframe[]; timing: KeyframeAnimationOptions };
let played: Played[] = [];
let animations: Animation[] = [];

function installDom(options: { reduced?: boolean; noLinear?: boolean } = {}): HTMLElement {
dom = new JSDOM("<!doctype html><html><body><main id=root></main></body></html>", { url: "http://localhost/" });
Expand All @@ -37,7 +38,9 @@ function installDom(options: { reduced?: boolean; noLinear?: boolean } = {}): HT
timing: KeyframeAnimationOptions,
) {
played.push({ element: this, frames, timing });
return { cancel() {}, playState: "running", onfinish: null } as unknown as Animation;
const animation = { cancel() {}, playState: "running", onfinish: null } as unknown as Animation;
animations.push(animation);
return animation;
} as never;
for (const key of ["window", "document", "navigator", "Node", "HTMLElement", "Element", "IS_REACT_ACT_ENVIRONMENT"])
if (!saved.has(key)) saved.set(key, scope[key]);
Expand All @@ -57,6 +60,7 @@ afterEach(async () => {
if (root) await act(async () => root!.unmount());
root = undefined;
played = [];
animations = [];
dom?.window.close();
dom = undefined;
for (const [key, value] of saved) {
Expand Down Expand Up @@ -121,6 +125,40 @@ test("a selection change places it on the new tab at once and slides on transfor
expect(timing).toEqual({ duration: MOTION_MS.large, easing: MOTION_SPRING });
});

test("a size change during a slide re-places the highlight when the slide finishes", async () => {
const mount = installDom();
const observed: (() => void)[] = [];
const before = scope.ResizeObserver;
scope.ResizeObserver = class {
constructor(callback: () => void) {
observed.push(callback);
}
observe() {}
disconnect() {}
};
try {
await act(async () => {
root = createRoot(mount);
root.render(<Tabs />);
});
await act(async () => select(1));
const mark = mount.querySelector<HTMLElement>(".ui-slide-indicator")!;
// A font load widens the new tab mid-slide; the slide keeps its course.
mount.querySelectorAll<HTMLElement>(".ui-slide-item")[1]!.dataset.box = "48,4,96,24";
for (const callback of observed) callback();
expect(mark.style.width).toBe("80px");
// It lands on the tab's new box, with no second slide.
const [slide] = animations as [Animation];
await act(async () => void slide.onfinish?.call(slide, new dom!.window.Event("finish") as AnimationPlaybackEvent));
expect(mark.style.width).toBe("96px");
expect(mark.style.transform).toBe("translate(48px, 4px)");
expect(played).toHaveLength(1);
} finally {
if (before === undefined) delete scope.ResizeObserver;
else scope.ResizeObserver = before;
}
});

test("an engine without linear() easing slides on the ease-out instead of throwing", async () => {
const mount = installDom({ noLinear: true });
await act(async () => {
Expand Down
Loading