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
140 changes: 92 additions & 48 deletions app/src/components/layout/detail-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { IconX } from "@tabler/icons-react";
import { motion, useReducedMotion } from "motion/react";
import type { ReactNode } from "react";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { EASE_OUT } from "@/lib/motion";

/**
* A main pane with a detail pane that slides in beside it.
* A main pane with details beside it when there is room, or in a sheet on narrow windows.
*
* The open/closed state belongs to the caller, in practice a search parameter, so opening a detail
* is a real navigation: it survives a reload, it can be linked to, and Back closes it.
Expand All @@ -16,6 +17,7 @@ import { EASE_OUT } from "@/lib/motion";

const ANIMATION_DURATION_SECONDS = 0.3;
const DEFAULT_DETAIL_WIDTH = 400;
const MIN_MAIN_WIDTH = 400;

/**
* The content overlaps the tail of the pane rather than following it.
Expand Down Expand Up @@ -45,59 +47,101 @@ export function DetailPanel({
}) {
// Reduced motion keeps the fade, which explains the change, and drops the movement.
const shouldReduceMotion = useReducedMotion();
const container = useRef<HTMLDivElement>(null);
const [overlay, setOverlay] = useState(true);
useLayoutEffect(() => {
const element = container.current;
if (!element) return;
const measure = (width: number) =>
setOverlay(width < detailWidth + MIN_MAIN_WIDTH);
measure(element.getBoundingClientRect().width);
const observer = new ResizeObserver(([entry]) => {
if (entry) measure(entry.contentRect.width);
});
observer.observe(element);
return () => observer.disconnect();
}, [detailWidth]);

return (
<div className="flex h-full min-h-0">
<div ref={container} className="flex h-full min-h-0">
<div className="flex flex-1 min-w-0 flex-col">{children}</div>
<motion.div
animate={{ width: open ? detailWidth : 0 }}
className="shrink-0 overflow-hidden"
// No entry animation on first paint: URL-opened panels should appear as initial state.
initial={false}
transition={{
duration: shouldReduceMotion ? 0 : ANIMATION_DURATION_SECONDS,
ease: EASE_OUT,
}}
>
<div
className="flex h-full flex-col bg-sidebar border-l border-border"
style={{ width: detailWidth }}
{overlay ? (
<Sheet
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
{/* Rendered for the whole animation, so the way out is available immediately. */}
<div className="h-12 shrink-0 sticky top-0 flex flex-row items-center justify-between px-2 gap-2">
<div className="flex min-w-0 w-full items-center gap-1.5">
{title}
<SheetContent
className="gap-0"
style={{ width: `min(${detailWidth}px, 100vw)`, maxWidth: "100vw" }}
>
<SheetTitle className="h-12 shrink-0 px-4 pr-12 flex items-center">
{title ?? "Details"}
</SheetTitle>
<div className="flex-1 min-h-0 overflow-y-auto">
{open ? detail : null}
</div>
<div className="flex flex-row gap-1.5">
<Button onClick={onClose} variant="ghost" size="icon">
<IconX className="size-4.5" />
</Button>
</SheetContent>
</Sheet>
) : (
<motion.div
animate={{ width: open ? detailWidth : 0 }}
className="shrink-0 overflow-hidden"
// No entry animation on first paint: URL-opened panels should appear as initial state.
initial={false}
transition={{
duration: shouldReduceMotion ? 0 : ANIMATION_DURATION_SECONDS,
ease: EASE_OUT,
}}
>
<div
className="flex h-full flex-col bg-sidebar border-l border-border"
style={{ width: detailWidth }}
>
{/* Rendered for the whole animation, so the way out is available immediately. */}
<div className="h-12 shrink-0 sticky top-0 flex flex-row items-center justify-between px-2 gap-2">
<div className="flex min-w-0 w-full items-center gap-1.5">
{title}
</div>
<div className="flex flex-row gap-1.5">
<Button
aria-label="Close details"
onClick={onClose}
variant="ghost"
size="icon"
>
<IconX className="size-4.5" />
</Button>
</div>
</div>
{/*
* Unmount while closed so dismissed form state and detail queries do not remain active.
*/}
{open ? (
<motion.div
animate={{ opacity: 1, transform: "translateY(0px)" }}
className="flex-1 min-h-0 overflow-y-auto"
initial={{
opacity: 0,
transform: shouldReduceMotion
? "none"
: CONTENT_ENTRANCE_OFFSET,
}}
transition={{
delay: shouldReduceMotion
? 0
: CONTENT_ENTRANCE_DELAY_SECONDS,
duration: CONTENT_ENTRANCE_SECONDS,
ease: EASE_OUT,
}}
>
{detail}
</motion.div>
) : null}
</div>
{/*
* Unmount while closed so dismissed form state and detail queries do not remain active.
*/}
{open ? (
<motion.div
animate={{ opacity: 1, transform: "translateY(0px)" }}
className="flex-1 min-h-0 overflow-y-auto"
initial={{
opacity: 0,
transform: shouldReduceMotion
? "none"
: CONTENT_ENTRANCE_OFFSET,
}}
transition={{
delay: shouldReduceMotion ? 0 : CONTENT_ENTRANCE_DELAY_SECONDS,
duration: CONTENT_ENTRANCE_SECONDS,
ease: EASE_OUT,
}}
>
{detail}
</motion.div>
) : null}
</div>
</motion.div>
</motion.div>
)}
</div>
);
}
3 changes: 3 additions & 0 deletions app/src/routes/_authed/admin/boundaries.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ function BoundariesPage() {
<div className="mt-3 flex gap-2">
<Input
aria-label="A rule, written in CEL"
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
className="min-w-0 flex-1 font-mono text-xs"
onChange={(event) => {
setDraft(event.target.value);
Expand Down
3 changes: 3 additions & 0 deletions app/tests/composing-enter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ test("a boundary rule is not saved by the Enter that confirms a composed charact
);

const field = await view.findByLabelText("A rule, written in CEL");
expect(field.getAttribute("spellcheck")).toBe("false");
expect(field.getAttribute("autocorrect")).toBe("off");
expect(field.getAttribute("autocapitalize")).toBe("off");
const rule = 'contains(element.name, "送信")';
await type(field, rule);

Expand Down
112 changes: 112 additions & 0 deletions app/tests/detail-panel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
import { GlobalRegistrator } from "@happy-dom/global-registrator";
import {
act,
cleanup,
fireEvent,
render,
waitFor,
} from "@testing-library/react";
import { useEffect } from "react";
import { DetailPanel } from "@/components/layout/detail-panel";

class Observer implements ResizeObserver {
static targets = new Map<Element, Observer>();
constructor(private readonly callback: ResizeObserverCallback) {}
observe(target: Element) {
Observer.targets.set(target, this);
}
unobserve(target: Element) {
Observer.targets.delete(target);
}
disconnect() {
for (const [target, observer] of Observer.targets) {
if (observer === this) Observer.targets.delete(target);
}
}
resize(target: Element, width: number) {
this.callback(
[
{
target,
contentRect: new DOMRect(0, 0, width, 640),
borderBoxSize: [],
contentBoxSize: [],
devicePixelContentBoxSize: [],
},
],
this,
);
}
}
let originalObserver: typeof ResizeObserver;
beforeAll(() => {
GlobalRegistrator.register();
originalObserver = globalThis.ResizeObserver;
globalThis.ResizeObserver = Observer;
});
afterEach(() => {
cleanup();
Observer.targets.clear();
});
afterAll(() => {
globalThis.ResizeObserver = originalObserver;
GlobalRegistrator.unregister();
});

async function resize(container: HTMLElement, width: number) {
const target = container.firstElementChild;
if (!target) throw new Error("Panel root missing");
await act(async () => {
Observer.targets.get(target)?.resize(target, width);
});
}

test("narrow container overlays details without remounting the chat draft", async () => {
let mounts = 0;
function Chat() {
useEffect(() => {
mounts += 1;
}, []);
return <textarea aria-label="Draft" defaultValue="Keep this draft" />;
}
const view = render(
<DetailPanel
open
title="Computer"
onClose={() => {}}
detail={<p>Browser preview</p>}
>
<Chat />
</DetailPanel>,
);
await resize(view.container, 560);
expect(await view.findByRole("dialog", { name: "Computer" })).toBeTruthy();
const draft = view.getByDisplayValue("Keep this draft");
await resize(view.container, 1000);
await waitFor(() => expect(view.queryByRole("dialog")).toBeNull());
expect(view.getByText("Browser preview")).toBeTruthy();
expect(view.getByDisplayValue("Keep this draft")).toBe(draft);
await resize(view.container, 560);
expect(await view.findByRole("dialog", { name: "Computer" })).toBeTruthy();
expect(mounts).toBe(1);
});

test("closing narrow details invokes the owner and unmounts detail work", async () => {
let closed = 0;
const props = {
title: "Computer",
onClose: () => {
closed += 1;
},
detail: <p>Browser preview</p>,
children: <p>Chat</p>,
};
const view = render(<DetailPanel {...props} open />);
await resize(view.container, 380);
expect(await view.findByRole("dialog", { name: "Computer" })).toBeTruthy();
fireEvent.click(view.getByRole("button", { name: "Close" }));
expect(closed).toBe(1);
view.rerender(<DetailPanel {...props} open={false} />);
await waitFor(() => expect(view.queryByText("Browser preview")).toBeNull());
});
Loading