Files
buzz/desktop/src/features/channels/ui/FocusThreadDrawer.tsx
Bradley Axen 9fb1a90b66 fix(desktop): close focused threads on Escape (#2154)
Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co>
2026-07-20 08:58:28 -07:00

239 lines
9.2 KiB
TypeScript

import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import {
THREAD_FOCUS_DRAWER_TRAVEL_PX,
THREAD_FOCUS_SLIVER_WIDTH_PX,
} from "@/features/channels/lib/threadFocusLayout";
import { cn } from "@/shared/lib/cn";
type FocusThreadDrawerProps = {
channelName: string;
children: React.ReactNode;
onClose: () => void;
};
/**
* Scrim over the channel content area behind the focus drawer.
*
* Veil, not shadow, and no blur: the channel fades toward the surface colour
* rather than being darkened. A black wash is a multiply — it scales text and
* background down together, so dark-on-light text keeps its contrast ratio and
* stays readable at any opacity short of a solid bar. Fading toward
* `background` instead compresses text against the surface in both themes,
* which is what pushes the sliver back to colour and shape. Matches the shared
* header backdrop's `bg-background/80` vocabulary, a touch heavier because this
* one has to defeat body text rather than sit over a gap.
*/
const FOCUS_SCRIM_CLASS = "bg-background/75 dark:bg-background/80";
/**
* Hover eases the veil one step in both themes.
*
* Feedback that the sliver is a target — deliberately not a peek: one step is
* enough to register as interactive without making the channel readable.
*/
const FOCUS_SCRIM_HOVER_CLASS =
"hover:bg-background/65 dark:hover:bg-background/70";
/** Arrive and settle. The iOS sheet curve, shared with `buzz-side-panel-enter`. */
const ENTER_EASE = [0.32, 0.72, 0, 1] as const;
/**
* Leave immediately. Shares the enter's fast-start shape rather than the
* conventional accelerating ease-in for exits.
*
* The "exits accelerate away" rule assumes the whole travel is visible; an
* ease-in spends its opening frames barely moving and pays that back at the end.
* Here the tail is hidden under the opacity fade, so acceleration buys nothing
* and those opening frames are the entire perception of responsiveness — a
* dismissal that hasn't visibly moved 40ms in reads as hesitation regardless of
* its total duration. Decisiveness comes from the duration below instead.
*/
const EXIT_EASE = ENTER_EASE;
const SCRIM_ENTER_SECONDS = 0.2;
/**
* Slightly ahead of the drawer's exit, and deliberately so.
*
* A scrim that outlasts the drawer leaves the channel dimmed with nothing on top
* of it, which reads as lag at the exact moment the user has committed to
* leaving. Undimming first hands the channel back the instant it is asked for.
*/
const SCRIM_EXIT_SECONDS = 0.12;
/**
* Enter: opacity front-loaded, transform long.
*
* The two channels animate over deliberately different windows, and that
* asymmetry is the whole point. Short travel *requires* an opacity fade — an
* opaque surface this large appearing 120px off its mark with no fade is a hard
* cut, not a slide. But pairing both properties on one timing function (as a
* single CSS keyframe must) welds them together for the full duration, and since
* opacity covers 100% of its range while transform covers ~3% of the drawer's
* width, the fade is what the eye reads. Resolving opacity in the first ~90ms
* leaves the remaining ~190ms as pure travel: the fade is over before it
* registers, and what's perceived is sliding.
*
* It also keeps the drawer's own entrance from exposing its contents' load
* order. Anything arriving late (replies resolving, media decoding) lands on an
* already-opaque surface and reads as "the thread is loading" rather than the UI
* assembling itself.
*/
const ENTER_TRANSITION = {
opacity: { duration: 0.09, ease: "linear" },
x: { duration: 0.28, ease: ENTER_EASE },
} as const;
/**
* Exit: half the enter's duration, opacity barely back-loaded.
*
* Opening and closing are not symmetric tasks. The enter has something to say —
* it establishes where the thread came from and that the channel is still behind
* it. The exit has nothing to say: attention has already left for the channel,
* so its only job is to get out of the way without popping. That makes duration
* the thing to spend, and 140ms is about the floor before the drawer reads as
* vanishing rather than leaving.
*
* The opacity hold shrinks with it. Its purpose is to let the drawer commit to
* moving before it dissolves, so it reads as sliding out — but at this duration a
* hold proportional to the old one would eat half the animation. 20ms is enough
* to register solidity in the first frame or two.
*/
const EXIT_TRANSITION = {
opacity: { delay: 0.02, duration: 0.12, ease: "linear" },
x: { duration: 0.14, ease: EXIT_EASE },
} as const;
/**
* Reduced motion keeps a crossfade and drops the travel.
*
* Travel is the part that's motion; the fade is what makes appearing and
* disappearing legible. With `x` pinned to 0 the front/back-loaded opacity
* timings would read as dead air on a stationary surface, so both collapse to
* one short symmetric fade.
*/
const REDUCED_MOTION_TRANSITION = { duration: 0.12, ease: "linear" } as const;
/**
* Right-anchored thread drawer that overlays the channel content area.
*
* Must be rendered inside `ChannelPane`'s relative layout root, and beneath an
* `AnimatePresence` so the exit animation can run: everything here is absolutely
* positioned against the channel content area, so the app sidebar is never
* covered. The channel stays mounted underneath — a narrow scrim-dimmed sliver
* of it remains visible for depth, and the whole scrim (sliver included) is one
* tall click target back to the channel. Orientation lives in the drawer
* header's breadcrumb, where the eye already is — the sliver carries no label of
* its own.
*
* `z-41` puts the overlay above the channel timeline, its `z-40` composer
* overlay and the `z-30` shared header backdrop, while staying below the global
* `z-45` top chrome. Setting z-index on the positioned container also gives the
* drawer its own stacking context, so the panel chrome inside it is isolated.
*/
export function FocusThreadDrawer({
channelName,
children,
onClose,
}: FocusThreadDrawerProps) {
const prefersReducedMotion = useReducedMotion();
const travelPx = prefersReducedMotion ? 0 : THREAD_FOCUS_DRAWER_TRAVEL_PX;
const drawerRef = React.useRef<HTMLDivElement>(null);
const previousFocusRef = React.useRef<HTMLElement | null>(null);
React.useEffect(() => {
function handleEscape(event: KeyboardEvent) {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopImmediatePropagation();
onClose();
}
window.addEventListener("keydown", handleEscape, { capture: true });
return () => {
window.removeEventListener("keydown", handleEscape, { capture: true });
};
}, [onClose]);
React.useLayoutEffect(() => {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
drawerRef.current?.focus({ preventScroll: true });
return () => {
const previousFocus = previousFocusRef.current;
requestAnimationFrame(() => {
previousFocus?.focus({ preventScroll: true });
});
};
}, []);
return (
<div
className="absolute inset-0 z-41"
data-testid="focus-thread-drawer-overlay"
>
<motion.button
animate={{ opacity: 1 }}
aria-label={`Back to #${channelName}`}
className={cn(
"absolute inset-0 cursor-pointer transition-colors duration-150",
FOCUS_SCRIM_CLASS,
FOCUS_SCRIM_HOVER_CLASS,
)}
data-testid="focus-thread-drawer-scrim"
exit={{
opacity: 0,
transition: prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: { duration: SCRIM_EXIT_SECONDS, ease: "linear" },
}}
initial={{ opacity: 0 }}
onClick={onClose}
transition={
prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: { duration: SCRIM_ENTER_SECONDS, ease: "linear" }
}
type="button"
/>
<motion.div
animate={{ opacity: 1, x: 0 }}
className={cn(
// Left corners only, at the app content surface's own `rounded-2xl`:
// the drawer is flush to that surface's right edge, so it is *clipped*
// to its right corners rather than nesting inside them. Flush edges
// share a radius — a smaller one here would put two radii on one
// element. `shadow-panel-left` draws the left edge and its corners;
// see the token for why a `border-l` cannot.
"absolute inset-y-0 right-0 flex flex-col overflow-hidden rounded-l-2xl bg-background shadow-panel-left",
)}
aria-label="Thread"
data-testid="focus-thread-drawer"
ref={drawerRef}
role="complementary"
tabIndex={-1}
exit={{
opacity: 0,
transition: prefersReducedMotion
? REDUCED_MOTION_TRANSITION
: EXIT_TRANSITION,
x: travelPx,
}}
initial={{ opacity: 0, x: travelPx }}
style={{ left: THREAD_FOCUS_SLIVER_WIDTH_PX }}
transition={
prefersReducedMotion ? REDUCED_MOTION_TRANSITION : ENTER_TRANSITION
}
>
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</motion.div>
</div>
);
}