e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
306 lines
11 KiB
TypeScript
306 lines
11 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
|
import { peekPickerOrigin } from "@/lib/picker-origin";
|
|
|
|
// Ref-count of currently-open PickerShells. While any are open we mark the
|
|
// <body> so global CSS can freeze ambient background animations (the sidebar
|
|
// "running session" pulse / breathing titles). Those repaints were being
|
|
// re-sampled by the modal's `backdrop-blur`, which read as a constant flicker
|
|
// behind the frosted scrim. Freezing them keeps the backdrop rock-steady.
|
|
let openShellCount = 0;
|
|
function acquireBodyFlag() {
|
|
openShellCount += 1;
|
|
if (typeof document !== "undefined") {
|
|
document.body.setAttribute("data-picker-open", "");
|
|
}
|
|
}
|
|
function releaseBodyFlag() {
|
|
openShellCount = Math.max(0, openShellCount - 1);
|
|
if (openShellCount === 0 && typeof document !== "undefined") {
|
|
document.body.removeAttribute("data-picker-open");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Behavioral wrapper for fullscreen pickers (NotebookRecordPicker,
|
|
* HistorySessionPicker, MemoryPicker, QuestionBankPicker, PersonaPicker,
|
|
* BookReferencePicker, SaveToNotebookModal, …). Each of those was rolled
|
|
* by hand and skipped the basic dialog-behavior contract — Escape, backdrop
|
|
* click, body scroll lock, focus trap, ARIA roles. This component lifts
|
|
* that contract into one place so the pickers themselves only have to
|
|
* render their *content*.
|
|
*
|
|
* Design notes:
|
|
* - The shell renders the centered backdrop. Callers render the picker
|
|
* card as `children` — they keep full control over the card's chrome,
|
|
* width, padding, etc. (see existing pickers).
|
|
* - `onClose` fires on Escape, on backdrop click, and is reachable
|
|
* programmatically (e.g. a Cancel button inside the card).
|
|
* - Focus management: on open we move focus to the first focusable element
|
|
* inside the dialog (typically the search input). On close we restore
|
|
* focus to wherever it was when the picker opened. This is what users
|
|
* expect from a modal but every hand-rolled picker had skipped it.
|
|
* - Focus trap: Tab from the last focusable element wraps to the first,
|
|
* Shift+Tab from the first wraps to the last. The dialog is the only
|
|
* thing screen-reader / keyboard users can interact with while open.
|
|
*/
|
|
export interface PickerShellProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
children: React.ReactNode;
|
|
/**
|
|
* id of an element inside `children` that names the dialog. Required for
|
|
* proper `aria-labelledby`. If the picker has no visible title, pass an
|
|
* `aria-label` instead via `ariaLabel`.
|
|
*/
|
|
labelledBy?: string;
|
|
ariaLabel?: string;
|
|
/**
|
|
* Z-index for the backdrop. Defaults to 85 to match the existing
|
|
* picker convention; raise it if stacking under another picker.
|
|
*/
|
|
zIndex?: number;
|
|
/**
|
|
* Outer container layout. Most pickers center their card; some use a
|
|
* full-bleed layout. Defaults to centered.
|
|
*/
|
|
align?: "center" | "start";
|
|
/**
|
|
* Extra classes applied to the inner backdrop wrapper. Allows pickers
|
|
* to opt out of the default `flex items-center justify-center` for
|
|
* bespoke layouts.
|
|
*/
|
|
className?: string;
|
|
/**
|
|
* Override the default `bg-[var(--overlay)]` backdrop (a theme-aware scrim:
|
|
* warm/cool tint matches the active palette). Pass any opacity / blur /
|
|
* tint combo for pickers that want a frosted-glass look or a lighter scrim.
|
|
*/
|
|
backdropClass?: string;
|
|
}
|
|
|
|
const FOCUSABLE_SELECTOR =
|
|
'a[href], area[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), button:not([disabled]), iframe, [tabindex]:not([tabindex="-1"]), [contenteditable="true"]';
|
|
|
|
export default function PickerShell({
|
|
open,
|
|
onClose,
|
|
children,
|
|
labelledBy,
|
|
ariaLabel,
|
|
zIndex = 85,
|
|
align = "center",
|
|
className,
|
|
backdropClass = "bg-[var(--overlay)]",
|
|
}: PickerShellProps) {
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
const reduceMotion = useReducedMotion();
|
|
|
|
// Capture the trigger's rect at the moment `open` flips true so the card can
|
|
// expand outward from it. Derived during render (React's documented
|
|
// adjust-state-on-prop-change pattern) so it is available for the very first
|
|
// animated frame — an effect would run a frame too late.
|
|
const [originRect, setOriginRect] = useState<DOMRect | null>(null);
|
|
const [wasOpen, setWasOpen] = useState(false);
|
|
if (open !== wasOpen) {
|
|
setWasOpen(open);
|
|
setOriginRect(open ? peekPickerOrigin() : null);
|
|
}
|
|
|
|
// Freeze ambient background animations while this shell is open.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
acquireBodyFlag();
|
|
return () => releaseBodyFlag();
|
|
}, [open]);
|
|
|
|
// Stash the focused element when the picker opens so we can return focus
|
|
// there on close. Reset on every open so reopening returns to the latest
|
|
// trigger.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
previouslyFocusedRef.current =
|
|
(document.activeElement as HTMLElement | null) ?? null;
|
|
}, [open]);
|
|
|
|
// Body scroll lock. Preserve the user's prior overflow so we don't trample
|
|
// a parent that already had a lock in place.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => {
|
|
document.body.style.overflow = prev;
|
|
};
|
|
}, [open]);
|
|
|
|
// Move initial focus into the dialog on open. We defer to the next frame
|
|
// so the rendered card has measured + focusable elements are in the DOM.
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const id = window.requestAnimationFrame(() => {
|
|
const node = dialogRef.current;
|
|
if (!node) return;
|
|
// Prefer an explicit autofocus marker, then the first focusable child.
|
|
const explicit = node.querySelector<HTMLElement>("[data-autofocus]");
|
|
const target =
|
|
explicit ?? node.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
target?.focus();
|
|
});
|
|
return () => window.cancelAnimationFrame(id);
|
|
}, [open]);
|
|
|
|
// Restore focus to the trigger on close. Guarded by document.contains so
|
|
// we never call focus() on a detached element (e.g. the trigger was
|
|
// unmounted while the picker was open).
|
|
useEffect(() => {
|
|
if (open) return;
|
|
const trigger = previouslyFocusedRef.current;
|
|
if (trigger && document.contains(trigger)) {
|
|
trigger.focus();
|
|
}
|
|
previouslyFocusedRef.current = null;
|
|
}, [open]);
|
|
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
if (e.key === "Escape") {
|
|
e.stopPropagation();
|
|
onClose();
|
|
return;
|
|
}
|
|
if (e.key !== "Tab") return;
|
|
// Focus trap. Cycle within the dialog so Tab can never escape into
|
|
// the page chrome behind us.
|
|
const node = dialogRef.current;
|
|
if (!node) return;
|
|
const focusables = Array.from(
|
|
node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
|
|
).filter((el) => !el.hasAttribute("data-focus-skip"));
|
|
if (focusables.length === 0) return;
|
|
const first = focusables[0];
|
|
const last = focusables[focusables.length - 1];
|
|
const active = document.activeElement as HTMLElement | null;
|
|
if (e.shiftKey && active === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && active === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
},
|
|
[onClose],
|
|
);
|
|
|
|
const handleBackdropMouseDown = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (e.target === e.currentTarget) {
|
|
onClose();
|
|
}
|
|
},
|
|
[onClose],
|
|
);
|
|
|
|
const alignmentClass =
|
|
align === "center" ? "items-center justify-center" : "items-start";
|
|
|
|
// Motion. When we captured the trigger rect, the card *expands outward from
|
|
// it* — it starts small, centered on the clicked row, and grows + glides to
|
|
// the screen center. That sells the "this box unfolded into the picker"
|
|
// feeling. Without an origin (picker opened from elsewhere) it falls back to
|
|
// a quiet placed-from-below settle. The scrim always cross-fades, masking
|
|
// the menu's own exit so the handoff reads as one continuous motion.
|
|
// reduced-motion keeps presence (AnimatePresence still gates mount) but drops
|
|
// transforms to a plain fade.
|
|
const originExpand =
|
|
!reduceMotion && originRect && typeof window !== "undefined"
|
|
? {
|
|
x: originRect.x + originRect.width / 2 - window.innerWidth / 2,
|
|
y: originRect.y + originRect.height / 2 - window.innerHeight / 2,
|
|
}
|
|
: null;
|
|
|
|
const scrimTransition = reduceMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.2, ease: [0.22, 1, 0.36, 1] as const };
|
|
const cardInitial = reduceMotion
|
|
? { opacity: 0 }
|
|
: originExpand
|
|
? { opacity: 0, scale: 0.5, x: originExpand.x, y: originExpand.y }
|
|
: { opacity: 0, y: 10, scale: 0.97, x: 0 };
|
|
const cardAnimate = reduceMotion
|
|
? { opacity: 1 }
|
|
: { opacity: 1, y: 0, scale: 1, x: 0 };
|
|
const cardExit = reduceMotion
|
|
? { opacity: 0 }
|
|
: {
|
|
opacity: 0,
|
|
y: 6,
|
|
scale: 0.985,
|
|
x: 0,
|
|
// Closing stays a quick, clean collapse — no bounce on the way out.
|
|
transition: { duration: 0.16, ease: [0.4, 0, 1, 1] as const },
|
|
};
|
|
const cardTransition = reduceMotion
|
|
? { duration: 0 }
|
|
: {
|
|
// A gently under-damped spring gives the expand some life: it eases out
|
|
// and settles with a barely-there overshoot, instead of the flat,
|
|
// mechanical glide a fixed cubic-bezier produces. Opacity rides a quick
|
|
// separate fade so only the size/position carry the spring.
|
|
type: "spring" as const,
|
|
bounce: 0.28,
|
|
duration: 0.44,
|
|
opacity: { duration: 0.2, ease: [0.22, 1, 0.36, 1] as const },
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{open && (
|
|
<motion.div
|
|
// Backdrop. mousedown rather than click so dragging out of a search
|
|
// input doesn't dismiss the picker on the eventual mouseup.
|
|
key="picker-backdrop"
|
|
onMouseDown={handleBackdropMouseDown}
|
|
className={`fixed inset-0 flex ${backdropClass} ${alignmentClass} ${
|
|
className ?? ""
|
|
}`}
|
|
style={{ zIndex }}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={scrimTransition}
|
|
>
|
|
<motion.div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby={labelledBy}
|
|
aria-label={labelledBy ? undefined : ariaLabel}
|
|
onKeyDown={handleKeyDown}
|
|
// Stop propagation so a click *inside* the dialog never reaches the
|
|
// backdrop's mousedown handler above. Width/layout decisions stay
|
|
// with the picker that renders the card inside.
|
|
onMouseDown={(e) => e.stopPropagation()}
|
|
// Own GPU layer keeps the expand transform crisp (no sub-pixel
|
|
// shimmer) and isolates it from the blurred backdrop's compositing.
|
|
style={{
|
|
willChange: "transform, opacity",
|
|
backfaceVisibility: "hidden",
|
|
}}
|
|
initial={cardInitial}
|
|
animate={cardAnimate}
|
|
exit={cardExit}
|
|
transition={cardTransition}
|
|
>
|
|
{children}
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
);
|
|
}
|