mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
From 1280 px the day and, on /coach, the conversations each keep a narrow strip at the right edge, open or not. A click on the strip opens its panel left of the strips and a second click closes it; the strip never moves. Strips start below the top bar's band, side by side, conversations first. Below 1600 px one panel is open at a time. The day slides like the conversations (width, 200 ms, none under reduced motion), stays open through a page change instead of folding and reopening, and does not slide in on arrival. Its content loads when a day first opens, so the scores, sections and calendar leave the shell's first load: every watched route falls back under its unchanged ceiling. The aggregate cap is re-measured for the chunk double count this causes.
148 lines
5.1 KiB
TypeScript
148 lines
5.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import type { LucideIcon } from "lucide-react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
import { SHELL_HEADER_BAND } from "./shell-metrics";
|
|
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 starts below the top bar's band. The band above them is empty
|
|
* and draws the top bar's bottom line on through, so nothing in the strips
|
|
* meets the command palette at the end of the top bar.
|
|
*/
|
|
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: an empty cell of the top bar's band, then a button the rest of
|
|
* the window's height 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",
|
|
)}
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
className={cn(SHELL_HEADER_BAND, "border-sidebar-border shrink-0")}
|
|
/>
|
|
<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>
|
|
);
|
|
}
|