Files
HealthLog/src/components/layout/shell-dock.tsx
T
Marc-André Bombeck 39fc4e4da2 feat(shell): run the docked strips the full height and make them the only fold
The docked panels' strips now run from the top edge of the window to the
bottom, and the top bar ends left of them. The panel header no longer
carries a fold button in docked mode: the strip opens and closes the day
and, on /coach, the conversations; opening moves focus to the panel's
heading and closing returns it to the strip. The sheets keep a close
button, the day's now an X at the end of its header.

The command palette's door is a magnifier at every width, named Search,
with the shortcut in its tooltip. g p opens or closes the day like a
click on its strip, and below the docking width opens the sheet on the
last day or closes it; the shortcuts list shows it under Day.
2026-10-10 20:04:19 +02:00

146 lines
5.1 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { createShellSlot } from "./shell-slot";
/**
* The docked panels' strips (v1.42): from 1280 px every docked panel (the
* day on every page, the Coach's conversations on `/coach`) keeps its own
* narrow vertical strip at the right edge of the window, visible whether the
* panel is open or not. The strips stand side by side in one column of the
* shell, right of the panels (`ShellSidePanel`), in the same order as their
* panels: conversations (`order-1`), then the day (`order-2`). A panel opens
* to the left of the strips; the strips never move or change shape.
*
* Each strip runs the full height of the shell row, from the top of the
* window to the bottom. The strip column sits beside the content column, so
* the top bar (and the command palette's magnifier at its trailing end) ends
* at the strips' left edge and nothing in a strip can cover it. Docked, the
* strip is the panel's only open and close control: the panel's header
* carries no fold button of its own.
*/
const strips = createShellSlot("shell-strips");
/** Where the shell paints the strips. Empty unless a docked panel fills it. */
export const ShellStripOutlet = strips.Outlet;
/** Render a strip into the shell's strip column. */
export const ShellStrip = strips.Portal;
/**
* The slide every docked panel shares: the panel's box animates its width
* between the open column and nothing, and its content keeps the open width,
* pinned to the box's left edge, so it slides out under the strip instead of
* squeezing. The Coach's conversations introduced it; the day takes the same
* duration and easing. No motion under reduced motion.
*/
export const DOCK_SLIDE =
"relative h-full shrink-0 overflow-hidden transition-[width] duration-200 ease-linear motion-reduce:transition-none";
/** The slide's length in milliseconds, for content that waits it out. */
export const DOCK_SLIDE_MS = 200;
function prefersReducedMotion(): boolean {
if (typeof window === "undefined" || !window.matchMedia) return true;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
/**
* `value` while it is set, and for the length of the slide after it is
* cleared: a closing panel keeps painting what it showed while its width
* runs to nothing. Cleared at once under reduced motion, where nothing
* slides.
*/
export function useLingering<T>(value: T | null): T | null {
const [held, setHeld] = useState<T | null>(value);
if (value !== null && held !== value) setHeld(value);
useEffect(() => {
if (value !== null || held === null) return;
const id = window.setTimeout(
() => setHeld(null),
prefersReducedMotion() ? 0 : DOCK_SLIDE_MS,
);
return () => window.clearTimeout(id);
}, [value, held]);
return value ?? held;
}
export interface DockStripProps {
ref?: React.Ref<HTMLButtonElement>;
/** `data-slot` of the strip; its button is `<slot>-toggle`. */
slot: string;
/** Left to right: conversations 1, day 2. */
order: 1 | 2;
/** The id of the panel the strip opens and closes. */
controls: string;
expanded: boolean;
/** The strip's visible, vertical label. */
label: string;
/** The button's accessible name (what a click does). */
actionLabel: string;
icon: LucideIcon;
onToggle: () => void;
/** Called when the pointer or focus reaches the strip (load ahead). */
onPreload?: () => void;
/** Extra attributes for the strip (`data-day`). */
data?: Record<`data-${string}`, string>;
}
/**
* One strip: a button the full height of the window that opens and closes
* its panel. The label reads top to bottom; the strip is marked while its
* panel is open.
*/
export function DockStrip({
ref,
slot,
order,
controls,
expanded,
label,
actionLabel,
icon: Icon,
onToggle,
onPreload,
data,
}: DockStripProps) {
return (
<div
data-slot={slot}
data-state={expanded ? "open" : "closed"}
{...data}
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-10 shrink-0 flex-col",
order === 1 ? "order-1" : "order-2",
)}
>
<button
ref={ref}
type="button"
data-slot={`${slot}-toggle`}
aria-expanded={expanded}
aria-controls={controls}
aria-label={actionLabel}
title={actionLabel}
onClick={onToggle}
onPointerEnter={onPreload}
onFocus={onPreload}
className={cn(
"border-sidebar-border flex min-h-0 flex-1 cursor-pointer flex-col items-center gap-2 border-l py-3 text-xs font-medium transition-colors outline-none",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:ring-inset",
expanded
? "bg-sidebar-accent text-sidebar-accent-foreground"
: "text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)}
>
<Icon className="size-4 shrink-0" aria-hidden="true" />
<span className="truncate [writing-mode:vertical-rl]">{label}</span>
</button>
</div>
);
}