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
25 changes: 25 additions & 0 deletions app/src/dialog.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/* A dialog closing (ui/dialogExit.ts): its still copy fades out with the
backdrop, shrinking a little, the way it came in backwards and faster.
Reduced motion makes it instant (motion.css, motion-less.css). */

.dialog-exit {
pointer-events: none;
animation: dialog-fade-out 150ms ease-in both;
}

.dialog-exit > [role="dialog"] {
animation: dialog-pop-out 150ms ease-in both;
}

@keyframes dialog-fade-out {
to {
opacity: 0;
}
}

@keyframes dialog-pop-out {
to {
opacity: 0;
transform: scale(0.97) translateY(4px);
}
}
3 changes: 2 additions & 1 deletion app/src/features/bots/BotView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,8 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) {
/>
<Notices bot={bot} crew={crew} view={view} />
<Tabs<Pane> label={bot.name} tabs={tabs} value={pane} onChange={setPane} />
<div className="flex min-h-0 flex-1">
{/* A panel slides out over the chat, and is cut at the window's edge. */}
<div className="relative flex min-h-0 flex-1 overflow-x-clip">
<div role="tabpanel" aria-labelledby={tabId(pane)} className="flex min-h-0 min-w-0 flex-1">
{pane === "chat" ? (
<ShowFile.Provider value={showFile}>
Expand Down
1 change: 1 addition & 0 deletions app/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
@import "./mascot-flame.css";
@import "./panels.css";
@import "./sidebar.css";
@import "./dialog.css";
@import "./effort.css";
@import "./color.css";
@import "./files.css";
Expand Down
25 changes: 13 additions & 12 deletions app/src/panels.css
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
/* Side panels slide (spec 15.1, panelMotion.ts): the panel widens from the
window's right edge, or from the width of the panel it replaces, while
its content, already at its full width, comes in with it, and the chat
narrows smoothly instead of jumping. Closing runs it back to the edge.
Reduced motion makes both instant (motion.css). */
/* Side panels slide (spec 15.1, panelMotion.ts): the chat takes its new
width at once, and the panel, already at its full width, comes in from
the window's right edge, or from the width of the panel it replaces.
Closing, it leaves the chat its room at once and slides back out over
it. Only a transform moves, so the chat is laid out once, not on every
frame. Reduced motion makes both instant (motion.css). */

@keyframes panel-open {
from {
width: var(--panel-from, 0px);
min-width: var(--panel-from, 0px);
transform: translateX(max(0px, calc(100% - var(--panel-from, 0px))));
}
}

@keyframes panel-close {
to {
width: 0;
min-width: 0;
border-left-width: 0;
transform: translateX(100%);
}
}

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

/* Out of the flow, on the right edge of the chat and its panel. */
.panel-closing {
overflow: hidden;
position: absolute;
inset-block: 0;
right: 0;
z-index: 20;
pointer-events: none;
animation: panel-close 360ms cubic-bezier(0.4, 0, 0.6, 1) both;
}
9 changes: 2 additions & 7 deletions app/src/shell/sidebarToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,8 @@ export function SidebarSlot({ children }: { children: ReactNode }) {
const open = usePref(prefs.sidebar);
useSidebarKey();
return (
<div
inert={!open}
aria-hidden={!open}
data-open={open}
className="sidebar-slot flex shrink-0 overflow-hidden"
>
{children}
<div inert={!open} aria-hidden={!open} data-open={open} className="sidebar-slot shrink-0">
<div className="sidebar-slide">{children}</div>
</div>
);
}
Expand Down
28 changes: 24 additions & 4 deletions app/src/sidebar.css
Original file line number Diff line number Diff line change
@@ -1,12 +1,32 @@
/* Hiding the crews and bots (spec 15.1, sidebarToggle.tsx): the list keeps
its width and slides out under the left edge while the chat widens.
Reduced motion makes it instant (motion.css). */
/* Hiding the crews and bots (spec 15.1, sidebarToggle.tsx): the room for
the list changes at once, and the list slides out under the left edge
(or back in) above the chat. Only a transform moves, so the chat is laid
out once, not on every frame. Reduced motion makes it instant
(motion.css). */

.sidebar-slot {
position: relative;
width: 18rem;
transition: width 320ms cubic-bezier(0.4, 0, 0.2, 1);
}

.sidebar-slot[data-open="false"] {
width: 0;
}

.sidebar-slide {
position: absolute;
inset-block: 0;
left: 0;
z-index: 20;
display: flex;
transition: transform 320ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* Hidden once it is out of sight, so it is not drawn off screen. */
.sidebar-slot[data-open="false"] > .sidebar-slide {
transform: translateX(-100%);
visibility: hidden;
transition:
transform 320ms cubic-bezier(0.4, 0, 0.2, 1),
visibility 0s 320ms;
}
18 changes: 15 additions & 3 deletions app/src/ui/Dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { X } from "lucide-react";
import { type ReactNode, useEffect, useId } from "react";
import { type ReactNode, useEffect, useId, useLayoutEffect, useRef } from "react";
import { useT } from "../i18n";
import { Button } from "./Button";
import { exitDialog } from "./dialogExit";

interface DialogProps {
title: string;
Expand All @@ -12,10 +13,18 @@ interface DialogProps {
width?: "md" | "lg";
}

/** A modal panel. Escape and the close button call `onClose`. */
/**
* A modal panel. Escape and the close button call `onClose`. It pops in,
* and fades out however its owner closes it (dialogExit.ts).
*/
export function Dialog({ title, onClose, children, footer, width = "md" }: DialogProps) {
const t = useT();
const titleId = useId();
const backdrop = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const node = backdrop.current;
return () => exitDialog(node);
}, []);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
Expand All @@ -28,7 +37,10 @@ export function Dialog({ title, onClose, children, footer, width = "md" }: Dialo
}, [onClose]);

return (
<div className="fixed inset-0 z-40 grid animate-fade place-items-center bg-scrim p-6">
<div
ref={backdrop}
className="fixed inset-0 z-40 grid animate-fade place-items-center bg-scrim p-6"
>
<div
role="dialog"
aria-modal="true"
Expand Down
10 changes: 1 addition & 9 deletions app/src/ui/SidePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,6 @@ export function SidePanel({
const panel = useRef<HTMLElement>(null);
const drag = useRef<{ x: number; width: number; scale: number } | null>(null);
const { motion, from, ended } = usePanelMotion(panel, width);
// While it slides, the content keeps its full width and is cut at the
// moving edge, so it comes and goes without reflowing.
const sliding = motion !== "open";

const set = (next: number, keep = false) => {
const clamped = clamp(next);
Expand Down Expand Up @@ -149,12 +146,7 @@ export function SidePanel({
>
<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" />
</div>
<div
className="flex min-h-0 flex-1 flex-col"
style={sliding && !expanded ? { width, alignSelf: "flex-start" } : undefined}
>
{children}
</div>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</aside>
);
}
59 changes: 59 additions & 0 deletions app/src/ui/dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { StrictMode, useState } from "react";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { Dialog } from "./Dialog";

// jsdom runs no animations; a stand-in says this one does.
const proto = HTMLElement.prototype as { getAnimations?: () => Animation[] };
beforeEach(() => {
proto.getAnimations = () => [];
});
afterEach(() => {
cleanup();
delete proto.getAnimations;
for (const copy of document.querySelectorAll(".dialog-exit")) {
copy.remove();
}
});

function Owner() {
const [open, setOpen] = useState(true);
return open ? (
<Dialog title="Edit Scout" onClose={() => setOpen(false)}>
<input aria-label="Name" defaultValue="Scout" />
</Dialog>
) : null;
}

const settle = () => act(() => Promise.resolve());

describe("dialog motion", () => {
test("closing leaves a still copy that fades out, then goes", async () => {
render(<Owner />);
fireEvent.change(screen.getByRole("textbox", { name: "Name" }), {
target: { value: "Scout 2" },
});
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await settle();
expect(screen.queryByRole("dialog")).toBeNull();
const copy = document.querySelector(".dialog-exit") as HTMLElement;
expect(copy.getAttribute("aria-hidden")).toBe("true");
expect(copy.inert).toBe(true);
// What the owner typed stays while it fades.
expect(copy.querySelector("input")?.value).toBe("Scout 2");
const end = new Event("animationend");
fireEvent(copy, end);
expect(document.querySelector(".dialog-exit")).toBeNull();
});

test("being mounted again in strict mode leaves no copy", async () => {
render(
<StrictMode>
<Owner />
</StrictMode>,
);
await settle();
expect(screen.getByRole("dialog")).toBeDefined();
expect(document.querySelector(".dialog-exit")).toBeNull();
});
});
48 changes: 48 additions & 0 deletions app/src/ui/dialogExit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// A dialog leaves the page at once when its owner closes it, whatever
// closed it (its X, Escape, Cancel, Save). So it does not just vanish, a
// still copy of it stays a moment and fades out (dialog.css, spec 15.3).

/** Longest the copy stays, if its animation never reports its end. */
const EXIT_MS = 400;

/**
* Leaves a fading copy of `node`, a dialog's backdrop, in its place, once
* it is out of the page. One still in it was only mounted again (React's
* strict mode in dev).
*/
export function exitDialog(node: HTMLElement | null): void {
// Where nothing animates (tests), there is nothing to wait for.
if (!node || typeof node.getAnimations !== "function") {
return;
}
queueMicrotask(() => {
if (!node.isConnected) {
leave(node);
}
});
}

function leave(node: HTMLElement): void {
const copy = node.cloneNode(true) as HTMLElement;
// What the owner typed lives in the fields, not in their markup.
const fields = node.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
"input, textarea, select",
);
const copies = copy.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
"input, textarea, select",
);
fields.forEach((field, index) => {
const twin = copies[index];
if (twin) {
twin.value = field.value;
}
});
copy.removeAttribute("id");
copy.setAttribute("aria-hidden", "true");
copy.inert = true;
copy.classList.add("dialog-exit");
document.body.append(copy);
const remove = () => copy.remove();
copy.addEventListener("animationend", (event) => event.target === copy && remove());
setTimeout(remove, EXIT_MS);
}
11 changes: 7 additions & 4 deletions app/src/ui/panelMotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ export const PanelClosing = createContext<{ closed(): void } | null>(null);
*/
export const PanelRestored = createContext(false);

/** The panel that just went away without sliding: another replaces it. */
let replaced: { width: number; at: number } | null = null;
/**
* The panel that just went away without sliding: another replaces it. Not
* the same one mounted again (React's strict mode in dev does that).
*/
let replaced: { width: number; at: number; panel: RefObject<HTMLElement | null> } | null = null;

export type Motion = "opening" | "open" | "closing";

Expand All @@ -46,15 +49,15 @@ export function usePanelMotion(panel: RefObject<HTMLElement | null>, width: numb
// Before the first paint: start from the panel this one replaces, and
// leave this one's width for the next when it goes the same way.
useLayoutEffect(() => {
if (replaced && performance.now() - replaced.at < SWITCH_MS) {
if (replaced && replaced.panel !== panel && performance.now() - replaced.at < SWITCH_MS) {
setFrom(replaced.width);
}
replaced = null;
const element = panel;
return () => {
if (now.current.motion !== "closing") {
const shown = element.current?.offsetWidth || now.current.width;
replaced = { width: shown, at: performance.now() };
replaced = { width: shown, at: performance.now(), panel: element };
}
};
}, [panel]);
Expand Down
8 changes: 8 additions & 0 deletions app/src/ui/sidepanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { StrictMode } from "react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { PanelClosing, PanelRestored } from "./panelMotion";
import { SidePanel } from "./SidePanel";
Expand Down Expand Up @@ -103,6 +104,13 @@ describe("side panel motion", () => {
expect(closed).toHaveBeenCalled();
});

test("mounted again in strict mode, it still slides from the edge", () => {
const { container } = render(<StrictMode>{panel()}</StrictMode>);
const aside = container.querySelector("aside") as HTMLElement;
expect(aside.className).toContain("panel-opening");
expect(aside.style.getPropertyValue("--panel-from")).toBe("0px");
});

test("a panel replacing another starts from that one's width", () => {
localStorage.setItem("botloft.panel.first", "480");
const view = render(
Expand Down
Loading
Loading