Skip to content

Commit f1121fa

Browse files
authored
Merge pull request #142 from httpsphl/perf/compositor-motion
perf(app): smoother panel, list and dialog motion
2 parents dd458a7 + 26d61f4 commit f1121fa

13 files changed

Lines changed: 208 additions & 43 deletions

File tree

‎app/src/dialog.css‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/* A dialog closing (ui/dialogExit.ts): its still copy fades out with the
2+
backdrop, shrinking a little, the way it came in backwards and faster.
3+
Reduced motion makes it instant (motion.css, motion-less.css). */
4+
5+
.dialog-exit {
6+
pointer-events: none;
7+
animation: dialog-fade-out 150ms ease-in both;
8+
}
9+
10+
.dialog-exit > [role="dialog"] {
11+
animation: dialog-pop-out 150ms ease-in both;
12+
}
13+
14+
@keyframes dialog-fade-out {
15+
to {
16+
opacity: 0;
17+
}
18+
}
19+
20+
@keyframes dialog-pop-out {
21+
to {
22+
opacity: 0;
23+
transform: scale(0.97) translateY(4px);
24+
}
25+
}

‎app/src/features/bots/BotView.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
181181
/>
182182
<Notices bot={bot} crew={crew} view={view} />
183183
<Tabs<Pane> label={bot.name} tabs={tabs} value={pane} onChange={setPane} />
184-
<div className="flex min-h-0 flex-1">
184+
{/* A panel slides out over the chat, and is cut at the window's edge. */}
185+
<div className="relative flex min-h-0 flex-1 overflow-x-clip">
185186
<div role="tabpanel" aria-labelledby={tabId(pane)} className="flex min-h-0 min-w-0 flex-1">
186187
{pane === "chat" ? (
187188
<ShowFile.Provider value={showFile}>

‎app/src/index.css‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
@import "./mascot-flame.css";
1010
@import "./panels.css";
1111
@import "./sidebar.css";
12+
@import "./dialog.css";
1213
@import "./effort.css";
1314
@import "./color.css";
1415
@import "./files.css";

‎app/src/panels.css‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
1-
/* Side panels slide (spec 15.1, panelMotion.ts): the panel widens from the
2-
window's right edge, or from the width of the panel it replaces, while
3-
its content, already at its full width, comes in with it, and the chat
4-
narrows smoothly instead of jumping. Closing runs it back to the edge.
5-
Reduced motion makes both instant (motion.css). */
1+
/* Side panels slide (spec 15.1, panelMotion.ts): the chat takes its new
2+
width at once, and the panel, already at its full width, comes in from
3+
the window's right edge, or from the width of the panel it replaces.
4+
Closing, it leaves the chat its room at once and slides back out over
5+
it. Only a transform moves, so the chat is laid out once, not on every
6+
frame. Reduced motion makes both instant (motion.css). */
67

78
@keyframes panel-open {
89
from {
9-
width: var(--panel-from, 0px);
10-
min-width: var(--panel-from, 0px);
10+
transform: translateX(max(0px, calc(100% - var(--panel-from, 0px))));
1111
}
1212
}
1313

1414
@keyframes panel-close {
1515
to {
16-
width: 0;
17-
min-width: 0;
18-
border-left-width: 0;
16+
transform: translateX(100%);
1917
}
2018
}
2119

2220
.panel-opening {
23-
overflow: hidden;
2421
animation: panel-open 460ms cubic-bezier(0.4, 0, 0.2, 1) both;
2522
}
2623

24+
/* Out of the flow, on the right edge of the chat and its panel. */
2725
.panel-closing {
28-
overflow: hidden;
26+
position: absolute;
27+
inset-block: 0;
28+
right: 0;
29+
z-index: 20;
2930
pointer-events: none;
3031
animation: panel-close 360ms cubic-bezier(0.4, 0, 0.6, 1) both;
3132
}

‎app/src/shell/sidebarToggle.tsx‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,8 @@ export function SidebarSlot({ children }: { children: ReactNode }) {
4949
const open = usePref(prefs.sidebar);
5050
useSidebarKey();
5151
return (
52-
<div
53-
inert={!open}
54-
aria-hidden={!open}
55-
data-open={open}
56-
className="sidebar-slot flex shrink-0 overflow-hidden"
57-
>
58-
{children}
52+
<div inert={!open} aria-hidden={!open} data-open={open} className="sidebar-slot shrink-0">
53+
<div className="sidebar-slide">{children}</div>
5954
</div>
6055
);
6156
}

‎app/src/sidebar.css‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,32 @@
1-
/* Hiding the crews and bots (spec 15.1, sidebarToggle.tsx): the list keeps
2-
its width and slides out under the left edge while the chat widens.
3-
Reduced motion makes it instant (motion.css). */
1+
/* Hiding the crews and bots (spec 15.1, sidebarToggle.tsx): the room for
2+
the list changes at once, and the list slides out under the left edge
3+
(or back in) above the chat. Only a transform moves, so the chat is laid
4+
out once, not on every frame. Reduced motion makes it instant
5+
(motion.css). */
46

57
.sidebar-slot {
8+
position: relative;
69
width: 18rem;
7-
transition: width 320ms cubic-bezier(0.4, 0, 0.2, 1);
810
}
911

1012
.sidebar-slot[data-open="false"] {
1113
width: 0;
1214
}
15+
16+
.sidebar-slide {
17+
position: absolute;
18+
inset-block: 0;
19+
left: 0;
20+
z-index: 20;
21+
display: flex;
22+
transition: transform 320ms cubic-bezier(0.4, 0, 0.2, 1);
23+
}
24+
25+
/* Hidden once it is out of sight, so it is not drawn off screen. */
26+
.sidebar-slot[data-open="false"] > .sidebar-slide {
27+
transform: translateX(-100%);
28+
visibility: hidden;
29+
transition:
30+
transform 320ms cubic-bezier(0.4, 0, 0.2, 1),
31+
visibility 0s 320ms;
32+
}

‎app/src/ui/Dialog.tsx‎

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { X } from "lucide-react";
2-
import { type ReactNode, useEffect, useId } from "react";
2+
import { type ReactNode, useEffect, useId, useLayoutEffect, useRef } from "react";
33
import { useT } from "../i18n";
44
import { Button } from "./Button";
5+
import { exitDialog } from "./dialogExit";
56

67
interface DialogProps {
78
title: string;
@@ -12,10 +13,18 @@ interface DialogProps {
1213
width?: "md" | "lg";
1314
}
1415

15-
/** A modal panel. Escape and the close button call `onClose`. */
16+
/**
17+
* A modal panel. Escape and the close button call `onClose`. It pops in,
18+
* and fades out however its owner closes it (dialogExit.ts).
19+
*/
1620
export function Dialog({ title, onClose, children, footer, width = "md" }: DialogProps) {
1721
const t = useT();
1822
const titleId = useId();
23+
const backdrop = useRef<HTMLDivElement>(null);
24+
useLayoutEffect(() => {
25+
const node = backdrop.current;
26+
return () => exitDialog(node);
27+
}, []);
1928
useEffect(() => {
2029
const onKey = (event: KeyboardEvent) => {
2130
if (event.key === "Escape") {
@@ -28,7 +37,10 @@ export function Dialog({ title, onClose, children, footer, width = "md" }: Dialo
2837
}, [onClose]);
2938

3039
return (
31-
<div className="fixed inset-0 z-40 grid animate-fade place-items-center bg-scrim p-6">
40+
<div
41+
ref={backdrop}
42+
className="fixed inset-0 z-40 grid animate-fade place-items-center bg-scrim p-6"
43+
>
3244
<div
3345
role="dialog"
3446
aria-modal="true"

‎app/src/ui/SidePanel.tsx‎

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,6 @@ export function SidePanel({
6060
const panel = useRef<HTMLElement>(null);
6161
const drag = useRef<{ x: number; width: number; scale: number } | null>(null);
6262
const { motion, from, ended } = usePanelMotion(panel, width);
63-
// While it slides, the content keeps its full width and is cut at the
64-
// moving edge, so it comes and goes without reflowing.
65-
const sliding = motion !== "open";
6663

6764
const set = (next: number, keep = false) => {
6865
const clamped = clamp(next);
@@ -149,12 +146,7 @@ export function SidePanel({
149146
>
150147
<span className="absolute inset-y-0 left-[3px] w-0.5 bg-transparent transition-colors group-hover:bg-line-strong group-focus-visible:bg-work group-active:bg-work" />
151148
</div>
152-
<div
153-
className="flex min-h-0 flex-1 flex-col"
154-
style={sliding && !expanded ? { width, alignSelf: "flex-start" } : undefined}
155-
>
156-
{children}
157-
</div>
149+
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
158150
</aside>
159151
);
160152
}

‎app/src/ui/dialog.test.tsx‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
2+
import { StrictMode, useState } from "react";
3+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
4+
import { Dialog } from "./Dialog";
5+
6+
// jsdom runs no animations; a stand-in says this one does.
7+
const proto = HTMLElement.prototype as { getAnimations?: () => Animation[] };
8+
beforeEach(() => {
9+
proto.getAnimations = () => [];
10+
});
11+
afterEach(() => {
12+
cleanup();
13+
delete proto.getAnimations;
14+
for (const copy of document.querySelectorAll(".dialog-exit")) {
15+
copy.remove();
16+
}
17+
});
18+
19+
function Owner() {
20+
const [open, setOpen] = useState(true);
21+
return open ? (
22+
<Dialog title="Edit Scout" onClose={() => setOpen(false)}>
23+
<input aria-label="Name" defaultValue="Scout" />
24+
</Dialog>
25+
) : null;
26+
}
27+
28+
const settle = () => act(() => Promise.resolve());
29+
30+
describe("dialog motion", () => {
31+
test("closing leaves a still copy that fades out, then goes", async () => {
32+
render(<Owner />);
33+
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), {
34+
target: { value: "Scout 2" },
35+
});
36+
fireEvent.click(screen.getByRole("button", { name: "Close" }));
37+
await settle();
38+
expect(screen.queryByRole("dialog")).toBeNull();
39+
const copy = document.querySelector(".dialog-exit") as HTMLElement;
40+
expect(copy.getAttribute("aria-hidden")).toBe("true");
41+
expect(copy.inert).toBe(true);
42+
// What the owner typed stays while it fades.
43+
expect(copy.querySelector("input")?.value).toBe("Scout 2");
44+
const end = new Event("animationend");
45+
fireEvent(copy, end);
46+
expect(document.querySelector(".dialog-exit")).toBeNull();
47+
});
48+
49+
test("being mounted again in strict mode leaves no copy", async () => {
50+
render(
51+
<StrictMode>
52+
<Owner />
53+
</StrictMode>,
54+
);
55+
await settle();
56+
expect(screen.getByRole("dialog")).toBeDefined();
57+
expect(document.querySelector(".dialog-exit")).toBeNull();
58+
});
59+
});

‎app/src/ui/dialogExit.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// A dialog leaves the page at once when its owner closes it, whatever
2+
// closed it (its X, Escape, Cancel, Save). So it does not just vanish, a
3+
// still copy of it stays a moment and fades out (dialog.css, spec 15.3).
4+
5+
/** Longest the copy stays, if its animation never reports its end. */
6+
const EXIT_MS = 400;
7+
8+
/**
9+
* Leaves a fading copy of `node`, a dialog's backdrop, in its place, once
10+
* it is out of the page. One still in it was only mounted again (React's
11+
* strict mode in dev).
12+
*/
13+
export function exitDialog(node: HTMLElement | null): void {
14+
// Where nothing animates (tests), there is nothing to wait for.
15+
if (!node || typeof node.getAnimations !== "function") {
16+
return;
17+
}
18+
queueMicrotask(() => {
19+
if (!node.isConnected) {
20+
leave(node);
21+
}
22+
});
23+
}
24+
25+
function leave(node: HTMLElement): void {
26+
const copy = node.cloneNode(true) as HTMLElement;
27+
// What the owner typed lives in the fields, not in their markup.
28+
const fields = node.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
29+
"input, textarea, select",
30+
);
31+
const copies = copy.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
32+
"input, textarea, select",
33+
);
34+
fields.forEach((field, index) => {
35+
const twin = copies[index];
36+
if (twin) {
37+
twin.value = field.value;
38+
}
39+
});
40+
copy.removeAttribute("id");
41+
copy.setAttribute("aria-hidden", "true");
42+
copy.inert = true;
43+
copy.classList.add("dialog-exit");
44+
document.body.append(copy);
45+
const remove = () => copy.remove();
46+
copy.addEventListener("animationend", (event) => event.target === copy && remove());
47+
setTimeout(remove, EXIT_MS);
48+
}

0 commit comments

Comments
 (0)