mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
feat(shell): give each docked panel a fixed strip at the right edge
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.
This commit is contained in:
1 parent
41a7f4daea
commit
d8fb922228
17 files changed
+860
-320
No files matched your search
@@ -0,0 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import type { DateKey } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* "Saturday, 3 January 2026", for the day's header, its spoken name and its
|
||||
* strip. Its own module, so the shell's day layer can name a day without
|
||||
* loading the day view.
|
||||
*/
|
||||
export function useLongDayLabel(
|
||||
length: "long" | "short" = "long",
|
||||
): (date: DateKey) => string {
|
||||
const { locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
return (date: DateKey) =>
|
||||
new Intl.DateTimeFormat(intl, {
|
||||
weekday: length,
|
||||
day: "numeric",
|
||||
month: length,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T12:00:00.000Z`));
|
||||
}
|
||||
+179
-113
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Suspense,
|
||||
@@ -12,11 +13,17 @@ import {
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
|
||||
import { PanelRightOpen } from "lucide-react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
import {
|
||||
DOCK_SLIDE,
|
||||
DOCK_SLIDE_MS,
|
||||
DockStrip,
|
||||
ShellStrip,
|
||||
useLingering,
|
||||
} from "@/components/layout/shell-dock";
|
||||
import { ShellSidePanel } from "@/components/layout/shell-side-panel";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -48,13 +55,14 @@ import {
|
||||
takeDayYield,
|
||||
useDayFocus,
|
||||
} from "./day-layer-controller";
|
||||
import { parseDayParam, shiftDateKey, withDayHref } from "./day-url";
|
||||
import {
|
||||
DayView,
|
||||
PANEL_TOGGLE,
|
||||
PANEL_TOGGLE_ICON,
|
||||
useLongDayLabel,
|
||||
} from "./day-view";
|
||||
parseDayParam,
|
||||
shiftDateKey,
|
||||
stripDayOf,
|
||||
withDayHref,
|
||||
} from "./day-url";
|
||||
import { useLongDayLabel } from "./day-label";
|
||||
import type { DayViewProps } from "./day-view";
|
||||
import { usePrefetchDay } from "./use-day";
|
||||
import { useTodayKey } from "./use-today-key";
|
||||
|
||||
@@ -67,10 +75,12 @@ import { useTodayKey } from "./use-today-key";
|
||||
* - from 1280 px, a column docked beside the page, the same place and the
|
||||
* same breakpoint as the Coach's conversations panel. Not modal: the
|
||||
* chart beside it stays usable, and another point swaps the day. It is a
|
||||
* landmark (`complementary`) named by the date. It collapses like a
|
||||
* sidebar instead of going away: hidden, a narrow edge stays on the
|
||||
* right with a button that brings the last day back, on this page or any
|
||||
* other (`last-day.ts` remembers it per browser and account).
|
||||
* landmark (`complementary`) named by the date. Its strip stays at the
|
||||
* right edge of the window whether the day is open or not
|
||||
* (`shell-dock.tsx`): it names the day and opens or closes it, on this
|
||||
* page or any other (`last-day.ts` remembers the day per browser and
|
||||
* account; with nothing remembered the strip holds today). The column
|
||||
* slides open and shut left of the strip the way the conversations do.
|
||||
* - from 768 px, a sheet from the right, modal.
|
||||
* - on a phone, a sheet from the bottom at a bit over half the height, so
|
||||
* the chart stays in view above it; dragging the handle up (or tapping
|
||||
@@ -78,14 +88,36 @@ import { useTodayKey } from "./use-today-key";
|
||||
*
|
||||
* On `/coach` the day docks too, right of the Coach's conversations: page,
|
||||
* conversations, day. Below 1600 px only one of the two is open at a time;
|
||||
* opening the day folds the conversations to their edge, and opening the
|
||||
* conversations folds the day to its edge (`yieldDay`).
|
||||
* opening the day closes the conversations, and opening the conversations
|
||||
* closes the day (`yieldDay`).
|
||||
*
|
||||
* The parameter is the state. A date that is not a calendar date, or lies in
|
||||
* the future, is removed from the URL without a word. Collapsing the docked
|
||||
* day removes the parameter like closing does; only the remembered date
|
||||
* stays, and expanding opens it again.
|
||||
*/
|
||||
export const DAY_PANEL_ID = "day-docked-panel";
|
||||
|
||||
/**
|
||||
* The day's content loads when a day first opens (or when the pointer
|
||||
* reaches the strip), not with the shell: the scores, the calendar and the
|
||||
* sections are no part of a page until a day is open. Until it arrives the
|
||||
* frame shows its header band and nothing else.
|
||||
*/
|
||||
const loadDayView = () => import("./day-view").then((m) => m.DayView);
|
||||
function preloadDayView() {
|
||||
void loadDayView();
|
||||
}
|
||||
const DayView = dynamic(loadDayView, {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
data-slot="day-view-loading"
|
||||
className={cn(SHELL_HEADER_BAND, "border-border shrink-0")}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
export function DayLayerMount() {
|
||||
// `useSearchParams` suspends a statically rendered route until the client
|
||||
// has the URL; the boundary keeps that from blanking the page around it.
|
||||
@@ -205,19 +237,48 @@ function DayLayer() {
|
||||
const shortLabel = useLongDayLabel("short");
|
||||
const { t } = useTranslations();
|
||||
const titleId = useId();
|
||||
const titleRef = useRef<HTMLHeadingElement>(null);
|
||||
const titleRef = useRef<HTMLHeadingElement | null>(null);
|
||||
// Focus asked for before the day's content had loaded: the heading takes
|
||||
// it the moment it mounts.
|
||||
const titleFocusPending = useRef(false);
|
||||
const attachTitle = useCallback((el: HTMLHeadingElement | null) => {
|
||||
titleRef.current = el;
|
||||
if (el && titleFocusPending.current) {
|
||||
titleFocusPending.current = false;
|
||||
el.focus({ preventScroll: true });
|
||||
}
|
||||
}, []);
|
||||
const focusTitle = useCallback(() => {
|
||||
const el = titleRef.current;
|
||||
if (el) el.focus({ preventScroll: true });
|
||||
else titleFocusPending.current = true;
|
||||
}, []);
|
||||
const panelRef = useRef<HTMLElement>(null);
|
||||
const expandRef = useRef<HTMLButtonElement>(null);
|
||||
// Set by the collapse control: focus then lands on the control that
|
||||
// brings the day back, in the same place, as a sidebar's toggle does.
|
||||
const stripRef = useRef<HTMLButtonElement>(null);
|
||||
// Set by a fold (the header's control, the strip): focus then lands on the
|
||||
// strip, which brings the day back from the same place.
|
||||
const collapsing = useRef(false);
|
||||
|
||||
// The collapsed edge: the docked frame only, and only with a day to bring
|
||||
// back that is still a day the layer would open.
|
||||
// The remembered day, while the docked day is shut, if it is still a day
|
||||
// the layer would open.
|
||||
const railDay =
|
||||
shell === "docked" && date === null && lastDay !== null
|
||||
? parseDayParam(lastDay, today)
|
||||
: null;
|
||||
// A page change drops `?day=` before the new page puts the remembered day
|
||||
// back (below); a day left open stays open through that gap instead of
|
||||
// sliding shut and open again.
|
||||
const heldOpen = shell === "docked" && date === null && leftOpen;
|
||||
const dockedDay =
|
||||
shell === "docked" ? (date ?? (heldOpen ? railDay : null)) : null;
|
||||
// The docked column keeps painting the day it showed while it slides shut.
|
||||
const shownDay = useLingering(dockedDay);
|
||||
// No slide on arrival: a day restored with the page is simply there.
|
||||
const [settled, setSettled] = useState(false);
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setSettled(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, []);
|
||||
|
||||
const collapse = useCallback(() => {
|
||||
collapsing.current = true;
|
||||
@@ -253,7 +314,7 @@ function DayLayer() {
|
||||
// (on the arrow the person pressed), and so does a day restored on a
|
||||
// new page.
|
||||
if (before === null && shell === "docked" && !restoring.current) {
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
focusTitle();
|
||||
}
|
||||
restoring.current = false;
|
||||
return;
|
||||
@@ -272,7 +333,7 @@ function DayLayer() {
|
||||
collapsing.current = false;
|
||||
return;
|
||||
}
|
||||
const rail = expandRef.current;
|
||||
const rail = stripRef.current;
|
||||
if (collapsing.current && rail) {
|
||||
takeDayTrigger();
|
||||
rail.focus({ preventScroll: true });
|
||||
@@ -281,7 +342,7 @@ function DayLayer() {
|
||||
}
|
||||
}
|
||||
collapsing.current = false;
|
||||
}, [date, shell, owner]);
|
||||
}, [date, shell, owner, focusTitle]);
|
||||
|
||||
const onStep = useCallback(
|
||||
(delta: number) => {
|
||||
@@ -347,71 +408,13 @@ function DayLayer() {
|
||||
</p>
|
||||
);
|
||||
|
||||
if (date === null) {
|
||||
if (railDay === null) return live;
|
||||
const label = t("day.showPanel", { date: longLabel(railDay) });
|
||||
const expand = () =>
|
||||
openDay(railDay, { trigger: expandRef.current ?? undefined });
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
<ShellSidePanel>
|
||||
<div
|
||||
data-slot="day-rail"
|
||||
data-day={railDay}
|
||||
className="bg-card text-card-foreground border-border order-2 flex h-full w-12 shrink-0 flex-col border-l"
|
||||
>
|
||||
{/* The top bar's band, so the two bottom borders draw one line;
|
||||
the control sits where the open day keeps its own. */}
|
||||
<div
|
||||
className={cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-border flex shrink-0 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ref={expandRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-slot="day-expand"
|
||||
aria-expanded={false}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={expand}
|
||||
className={PANEL_TOGGLE}
|
||||
>
|
||||
<PanelRightOpen
|
||||
className={cn(PANEL_TOGGLE_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/* The rest of the edge takes a click too and names the day it
|
||||
holds; the keyboard has the button above. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={expand}
|
||||
className="hover:bg-muted/60 text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
|
||||
>
|
||||
<span className="[writing-mode:vertical-rl]">
|
||||
{shortLabel(railDay)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</ShellSidePanel>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const view = (
|
||||
Title: Parameters<typeof DayView>[0]["Title"],
|
||||
extra?: Partial<Parameters<typeof DayView>[0]>,
|
||||
shown: DateKey,
|
||||
Title: DayViewProps["Title"],
|
||||
extra?: Partial<DayViewProps>,
|
||||
) => (
|
||||
<DayView
|
||||
date={date}
|
||||
date={shown}
|
||||
today={today}
|
||||
focus={focus}
|
||||
shell={shell}
|
||||
@@ -420,46 +423,92 @@ function DayLayer() {
|
||||
onPick={onPick}
|
||||
Title={Title}
|
||||
titleId={titleId}
|
||||
titleRef={titleRef}
|
||||
titleRef={attachTitle}
|
||||
{...extra}
|
||||
/>
|
||||
);
|
||||
|
||||
if (shell === "docked") {
|
||||
// What the strip holds: the open day, else the remembered one, else today.
|
||||
const stripDay = stripDayOf(date, railDay, today);
|
||||
const open = dockedDay !== null;
|
||||
const toggle = () => {
|
||||
if (open) {
|
||||
collapse();
|
||||
return;
|
||||
}
|
||||
openDay(stripDay, { trigger: stripRef.current ?? undefined });
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
<ShellSidePanel>
|
||||
<aside
|
||||
ref={panelRef}
|
||||
aria-labelledby={titleId}
|
||||
data-slot="day-panel"
|
||||
data-shell="docked"
|
||||
// A short fade, not a slide: the column takes its width at once
|
||||
// (the page beside it reflows in the same frame either way), so
|
||||
// only its content eases in. None under reduced motion.
|
||||
className="bg-card text-card-foreground border-border motion-safe:animate-in motion-safe:fade-in-0 order-2 flex h-full w-105 shrink-0 flex-col border-l motion-safe:duration-150"
|
||||
<div
|
||||
id={DAY_PANEL_ID}
|
||||
data-slot="day-dock"
|
||||
data-state={open ? "open" : "closed"}
|
||||
className={cn(
|
||||
DOCK_SLIDE,
|
||||
"order-2",
|
||||
open ? "w-105" : "w-0",
|
||||
!settled && "transition-none",
|
||||
)}
|
||||
>
|
||||
{view(DockedTitle, {
|
||||
// No status-bar inset here: the docked column sits inside
|
||||
// the app shell, which takes it (`shell-safe-area`).
|
||||
headerClassName: cn(SHELL_HEADER_BAND, "border-border"),
|
||||
})}
|
||||
</aside>
|
||||
{shownDay !== null ? (
|
||||
<aside
|
||||
ref={panelRef}
|
||||
aria-labelledby={titleId}
|
||||
data-slot="day-panel"
|
||||
data-shell="docked"
|
||||
data-state={open ? "open" : "closing"}
|
||||
// Sliding shut, it paints the day it showed and takes no
|
||||
// input.
|
||||
inert={!open}
|
||||
className="bg-card text-card-foreground border-border absolute inset-y-0 left-0 flex w-105 flex-col border-l"
|
||||
>
|
||||
{view(shownDay, DockedTitle, {
|
||||
// No status-bar inset here: the docked column sits inside
|
||||
// the app shell, which takes it (`shell-safe-area`).
|
||||
headerClassName: cn(SHELL_HEADER_BAND, "border-border"),
|
||||
})}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</ShellSidePanel>
|
||||
<ShellStrip>
|
||||
<DockStrip
|
||||
ref={stripRef}
|
||||
slot="day-strip"
|
||||
order={2}
|
||||
controls={DAY_PANEL_ID}
|
||||
expanded={open}
|
||||
label={shortLabel(stripDay)}
|
||||
actionLabel={
|
||||
open
|
||||
? t("day.hidePanel")
|
||||
: t("day.showPanel", { date: longLabel(stripDay) })
|
||||
}
|
||||
icon={CalendarDays}
|
||||
onToggle={toggle}
|
||||
onPreload={preloadDayView}
|
||||
data={{ "data-day": stripDay }}
|
||||
/>
|
||||
</ShellStrip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (date === null) return live;
|
||||
|
||||
return (
|
||||
<>
|
||||
{live}
|
||||
<DaySheet
|
||||
shell={shell}
|
||||
panelRef={panelRef}
|
||||
titleRef={titleRef}
|
||||
focusTitle={focusTitle}
|
||||
onDismiss={closeByPerson}
|
||||
render={(extra) => view(SheetTitle, extra)}
|
||||
render={(extra) => view(date, SheetTitle, extra)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -472,10 +521,13 @@ function DayLayer() {
|
||||
* and there the chart the person clicked slid away under the pointer by the
|
||||
* height the reflow added above it. Measured after layout, so where the
|
||||
* browser already anchored, the correction is zero and nothing moves twice.
|
||||
* The column slides, so the page reflows on every frame of the slide; the
|
||||
* correction follows it frame by frame until the slide has run.
|
||||
*/
|
||||
function useKeepAnchorInView(active: boolean) {
|
||||
const anchor = useRef<{ el: HTMLElement; top: number } | null>(null);
|
||||
const wasActive = useRef(false);
|
||||
const frame = useRef(0);
|
||||
|
||||
// Before paint, so the corrected position is the first one drawn.
|
||||
useLayoutEffect(() => {
|
||||
@@ -491,15 +543,28 @@ function useKeepAnchorInView(active: boolean) {
|
||||
? { el: opened.el, top: opened.top }
|
||||
: null;
|
||||
}
|
||||
const a = anchor.current;
|
||||
if (a && a.el.isConnected) {
|
||||
const delta = a.el.getBoundingClientRect().top - a.top;
|
||||
if (Math.abs(delta) > 1) main.scrollTop += delta;
|
||||
a.top = a.el.getBoundingClientRect().top;
|
||||
}
|
||||
const held = anchor.current;
|
||||
if (!active) anchor.current = null;
|
||||
if (!held) return;
|
||||
const correct = () => {
|
||||
if (!held.el.isConnected) return;
|
||||
const delta = held.el.getBoundingClientRect().top - held.top;
|
||||
if (Math.abs(delta) > 1) main.scrollTop += delta;
|
||||
held.top = held.el.getBoundingClientRect().top;
|
||||
};
|
||||
correct();
|
||||
cancelAnimationFrame(frame.current);
|
||||
const until = performance.now() + DOCK_SLIDE_MS + 50;
|
||||
const tick = () => {
|
||||
correct();
|
||||
if (performance.now() < until)
|
||||
frame.current = requestAnimationFrame(tick);
|
||||
};
|
||||
frame.current = requestAnimationFrame(tick);
|
||||
}, [active]);
|
||||
|
||||
useEffect(() => () => cancelAnimationFrame(frame.current), []);
|
||||
|
||||
// While the column is open, the page may scroll; the anchor follows.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
@@ -552,16 +617,17 @@ function returnFocus(fallback?: HTMLElement | null) {
|
||||
function DaySheet({
|
||||
shell,
|
||||
panelRef,
|
||||
titleRef,
|
||||
focusTitle,
|
||||
onDismiss,
|
||||
render,
|
||||
}: {
|
||||
shell: "sheet" | "bottom";
|
||||
panelRef: React.RefObject<HTMLElement | null>;
|
||||
titleRef: React.RefObject<HTMLHeadingElement | null>;
|
||||
/** Focus the day's heading, now or as soon as it has loaded. */
|
||||
focusTitle: () => void;
|
||||
/** The person closed the sheet (a swipe down, Escape, the overlay). */
|
||||
onDismiss: () => void;
|
||||
render: (extra: Partial<Parameters<typeof DayView>[0]>) => React.ReactNode;
|
||||
render: (extra: Partial<DayViewProps>) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const [full, setFull] = useState(false);
|
||||
@@ -625,7 +691,7 @@ function DaySheet({
|
||||
data-full={bottom && full ? "true" : undefined}
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
titleRef.current?.focus({ preventScroll: true });
|
||||
focusTitle();
|
||||
}}
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -55,6 +55,19 @@ export function parseDayParam(
|
||||
return isOpenableDay(raw, todayKey) ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The day the docked day's strip holds: the open day, else the remembered
|
||||
* one while the layer would still open it, else today. The strip always
|
||||
* names a day, so it always has something to open.
|
||||
*/
|
||||
export function stripDayOf(
|
||||
open: DateKey | null,
|
||||
remembered: string | null,
|
||||
todayKey: DateKey,
|
||||
): DateKey {
|
||||
return open ?? parseDayParam(remembered, todayKey) ?? todayKey;
|
||||
}
|
||||
|
||||
/** `key` moved by `delta` calendar days. */
|
||||
export function shiftDateKey(key: DateKey, delta: number): DateKey {
|
||||
const anchor = new Date(`${key}T12:00:00.000Z`);
|
||||
|
||||
@@ -26,10 +26,10 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { useModuleEnabled } from "@/hooks/use-module-enabled";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { DAY_QUERY_PARAM, type DateKey } from "@/lib/day/contract";
|
||||
import { resolveIntlLocale } from "@/lib/format-locale";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useLongDayLabel } from "./day-label";
|
||||
import { DayDatePicker } from "./day-date-picker";
|
||||
import type { DayFocus } from "./day-layer-controller";
|
||||
import {
|
||||
@@ -55,22 +55,6 @@ import { useDayValueFormat } from "./use-day-value-format";
|
||||
* three frames cannot drift apart.
|
||||
*/
|
||||
|
||||
/** "Saturday, 3 January 2026", for the header and the spoken name. */
|
||||
export function useLongDayLabel(
|
||||
length: "long" | "short" = "long",
|
||||
): (date: DateKey) => string {
|
||||
const { locale } = useTranslations();
|
||||
const intl = resolveIntlLocale(locale);
|
||||
return (date: DateKey) =>
|
||||
new Intl.DateTimeFormat(intl, {
|
||||
weekday: length,
|
||||
day: "numeric",
|
||||
month: length,
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T12:00:00.000Z`));
|
||||
}
|
||||
|
||||
export interface DayViewProps {
|
||||
date: DateKey;
|
||||
today: DateKey;
|
||||
|
||||
@@ -61,7 +61,7 @@ function VerbLabel({ children }: { children: string }) {
|
||||
// Icon-only again while a day is docked: the bar then shares the width
|
||||
// with the day panel and the labelled row would no longer fit one line.
|
||||
return (
|
||||
<span className="hidden lg:inline [body:has([data-slot=day-panel][data-shell=docked])_&]:hidden">
|
||||
<span className="hidden lg:inline [body:has([data-slot=day-strip][data-state=open])_&]:hidden">
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
@@ -116,10 +116,11 @@ export function DocumentBulkBar({
|
||||
// the bar shows, which is the shell's call, not a width's: a phone
|
||||
// held sideways is wider than `md` and still has the bar.
|
||||
"bg-card border-border shell-desktop:bottom-6 fixed bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] left-1/2 z-40 -translate-x-1/2",
|
||||
// v1.42 — while a day is docked on the right, the bar sits against
|
||||
// the page's right edge instead of the window's centre, clear of the
|
||||
// day's footer actions and of an expanded sidebar.
|
||||
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+1rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:left-auto [body:has([data-slot=day-panel][data-shell=docked])_&]:max-w-[calc(100vw-44.25rem)] [body:has([data-slot=day-panel][data-shell=docked])_&]:translate-x-0",
|
||||
// v1.42 — while a day is docked on the right (its strip open), the
|
||||
// bar sits against the page's right edge instead of the window's
|
||||
// centre, clear of the day's footer actions, its strip and an
|
||||
// expanded sidebar.
|
||||
"[body:has([data-slot=day-strip][data-state=open])_&]:right-[calc(26.25rem+2.5rem+1rem)] [body:has([data-slot=day-strip][data-state=open])_&]:left-auto [body:has([data-slot=day-strip][data-state=open])_&]:max-w-[calc(100vw-46.75rem)] [body:has([data-slot=day-strip][data-state=open])_&]:translate-x-0",
|
||||
"flex w-[calc(100%-2rem)] max-w-3xl flex-col gap-2 rounded-xl border p-3 shadow-lg",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -67,10 +67,11 @@ describe("the folded list", () => {
|
||||
"utf8",
|
||||
);
|
||||
|
||||
it("keeps an edge with its button, and sits left of the day", () => {
|
||||
expect(source).toContain('data-slot="coach-panel-rail"');
|
||||
expect(source).toContain('data-slot="coach-panel-expand"');
|
||||
expect(source).toContain('dockedOpen ? "w-72" : "w-12"');
|
||||
it("slides to nothing, keeps its own strip, and sits left of the day", () => {
|
||||
expect(source).toContain('slot="coach-panel-strip"');
|
||||
expect(source).toContain("order={1}");
|
||||
expect(source).toContain('dockedOpen ? "w-72" : "w-0"');
|
||||
expect(source).toContain("DOCK_SLIDE");
|
||||
expect(source).toMatch(/order-1/);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
|
||||
import { useEffect, useRef, useState, useSyncExternalStore } from "react";
|
||||
import Link from "next/link";
|
||||
import { PanelRightClose, PanelRightOpen, Target, X } from "lucide-react";
|
||||
import {
|
||||
MessagesSquare,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Target,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useOpenDay, yieldDay } from "@/components/day/day-layer-controller";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +19,12 @@ import {
|
||||
SheetDescription,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
DOCK_SLIDE,
|
||||
DockStrip,
|
||||
ShellStrip,
|
||||
useLingering,
|
||||
} from "@/components/layout/shell-dock";
|
||||
import { ShellSidePanel } from "@/components/layout/shell-side-panel";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import { TopBarActions } from "@/components/layout/top-bar-actions";
|
||||
@@ -43,25 +55,27 @@ import { useDeleteCoachConversationWithUndo } from "./use-coach";
|
||||
* Docked, the panel is a column of the shell beside the content column
|
||||
* (top bar and page): it runs the full height of the window, and the top bar
|
||||
* ends at its left edge. The panel's header row is the top bar's band, so
|
||||
* the two bottom borders draw one line. It folds like the day beside it: the
|
||||
* header's first control folds it to a narrow edge, and the edge's button
|
||||
* (in the same place) opens it again. Folded, the edge names the list.
|
||||
* the two bottom borders draw one line. Its strip stays at the right edge of
|
||||
* the window, left of the day's (`shell-dock.tsx`), open or not: a click
|
||||
* on the strip opens the panel left of the strips and a second click shuts
|
||||
* it; the header's first control shuts it too. The panel slides open and
|
||||
* shut (a 200 ms width transition, none under reduced motion).
|
||||
* The header carries the title, a link to Plans, the settings gear and, in
|
||||
* the sheet, a close button. New conversation is not here: it floats in the
|
||||
* conversation itself (`NewChatFab`).
|
||||
*
|
||||
* The day docks right of the panel on this page too (page, conversations,
|
||||
* day). Below 1600 px only one of the two is open: a day opening folds the
|
||||
* list to its edge without touching the remembered choice, and opening the
|
||||
* list folds the day to its edge (`yieldDay`). From 1600 px both stay open.
|
||||
* list without touching the remembered choice, and opening the list closes
|
||||
* the day (`yieldDay`). From 1600 px both stay open.
|
||||
*
|
||||
* Below 1280 px the panel is a sheet, opened from the toggle at the end of
|
||||
* the top bar.
|
||||
*
|
||||
* Keyboard: Escape folds the docked panel when focus is inside it and
|
||||
* nothing inside (a row menu, the rename field, the settings popover) has
|
||||
* already claimed the key; focus moves to the edge's button, and opening
|
||||
* from there lands on the fold control. In the sheet Radix owns Escape, the
|
||||
* already claimed the key; focus moves to the strip, and opening from the
|
||||
* strip lands on the fold control. In the sheet Radix owns Escape, the
|
||||
* focus trap and the return.
|
||||
*/
|
||||
export const COACH_PANEL_ID = "coach-conversations-panel";
|
||||
@@ -121,6 +135,17 @@ export function listYields(state: {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether opening the docked list closes an open day: below 1600 px only one
|
||||
* of the two is open, and the one the person just opened wins.
|
||||
*/
|
||||
export function dayYields(state: {
|
||||
fitsBoth: boolean;
|
||||
dayOpen: boolean;
|
||||
}): boolean {
|
||||
return !state.fitsBoth && state.dayOpen;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the phone sheet is open. A settings deep link opens it only once
|
||||
* the viewport is known: the server and hydration renders take the narrow
|
||||
@@ -263,6 +288,8 @@ export function ConversationsPanel({
|
||||
const expanded = docked ? dockedOpen : sheetOpen;
|
||||
const collapseRef = useRef<HTMLButtonElement>(null);
|
||||
const expandRef = useRef<HTMLButtonElement>(null);
|
||||
// The list keeps painting while it slides shut.
|
||||
const shownOpen = useLingering(docked && dockedOpen ? true : null) ?? false;
|
||||
// Where focus goes once the docked panel has folded or opened.
|
||||
const focusAfter = useRef<"collapse" | "expand" | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -282,7 +309,7 @@ export function ConversationsPanel({
|
||||
setRevealed(false);
|
||||
setOpen(true);
|
||||
// Below 1600 px the day makes room: it folds to its edge.
|
||||
if (!fitsBoth && openDay !== null) yieldDay();
|
||||
if (dayYields({ fitsBoth, dayOpen: openDay !== null })) yieldDay();
|
||||
}
|
||||
|
||||
function collapseDocked() {
|
||||
@@ -425,89 +452,65 @@ export function ConversationsPanel({
|
||||
)}
|
||||
|
||||
{docked ? (
|
||||
<ShellSidePanel>
|
||||
<aside
|
||||
id={COACH_PANEL_ID}
|
||||
aria-label={t("insights.coach.historyTitle")}
|
||||
data-slot="coach-conversations-panel"
|
||||
data-state={dockedOpen ? "open" : "closed"}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!dockedOpen ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented
|
||||
) {
|
||||
return;
|
||||
<>
|
||||
<ShellSidePanel>
|
||||
<div
|
||||
id={COACH_PANEL_ID}
|
||||
data-slot="coach-conversations-panel"
|
||||
data-state={dockedOpen ? "open" : "closed"}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
!dockedOpen ||
|
||||
event.key !== "Escape" ||
|
||||
event.defaultPrevented
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
collapseDocked();
|
||||
}}
|
||||
className={cn(
|
||||
// A column of the shell's content row (`ShellSidePanel`), so
|
||||
// it runs from the top of the window to the bottom beside the
|
||||
// top bar, left of a docked day (`order-1`, the day is
|
||||
// `order-2`). The width slides between the open column and
|
||||
// nothing; the content keeps 18rem, pinned left, so it slides
|
||||
// instead of squeezing (`DOCK_SLIDE`).
|
||||
DOCK_SLIDE,
|
||||
"bg-sidebar text-sidebar-foreground order-1",
|
||||
dockedOpen ? "w-72" : "w-0",
|
||||
)}
|
||||
>
|
||||
{shownOpen ? (
|
||||
<aside
|
||||
aria-label={t("insights.coach.historyTitle")}
|
||||
// Sliding shut, it paints the list and takes no input.
|
||||
inert={!dockedOpen}
|
||||
className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l"
|
||||
>
|
||||
{body(false)}
|
||||
</aside>
|
||||
) : null}
|
||||
</div>
|
||||
</ShellSidePanel>
|
||||
<ShellStrip>
|
||||
<DockStrip
|
||||
ref={expandRef}
|
||||
slot="coach-panel-strip"
|
||||
order={1}
|
||||
controls={COACH_PANEL_ID}
|
||||
expanded={dockedOpen}
|
||||
label={t("insights.coach.historyTitle")}
|
||||
actionLabel={
|
||||
dockedOpen
|
||||
? t("insights.coach.frame.hidePanel")
|
||||
: t("insights.coach.frame.showPanel")
|
||||
}
|
||||
event.preventDefault();
|
||||
collapseDocked();
|
||||
}}
|
||||
className={cn(
|
||||
// A column of the shell's content row (`ShellSidePanel`), so it
|
||||
// runs from the top of the window to the bottom beside the top
|
||||
// bar, left of a docked day (`order-1`, the day is `order-2`).
|
||||
// The width animates between the open column and the folded
|
||||
// edge; the open column keeps 18rem and is pinned to the
|
||||
// panel's left edge, so it slides instead of squeezing.
|
||||
"bg-sidebar text-sidebar-foreground relative order-1 h-full shrink-0 overflow-hidden",
|
||||
"transition-[width] duration-200 ease-linear motion-reduce:transition-none",
|
||||
dockedOpen ? "w-72" : "w-12",
|
||||
)}
|
||||
>
|
||||
{dockedOpen ? (
|
||||
<div className="border-sidebar-border absolute inset-y-0 left-0 flex w-72 flex-col border-l">
|
||||
{body(false)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-slot="coach-panel-rail"
|
||||
className="border-sidebar-border absolute inset-y-0 left-0 flex w-12 flex-col border-l"
|
||||
>
|
||||
{/* The top bar's band, so the bottom borders draw one line;
|
||||
the button sits where the open panel keeps its fold
|
||||
control. */}
|
||||
<div
|
||||
className={cn(
|
||||
SHELL_HEADER_BAND,
|
||||
"border-sidebar-border flex shrink-0 items-center justify-center",
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
ref={expandRef}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={expandDocked}
|
||||
data-slot="coach-panel-expand"
|
||||
aria-controls={COACH_PANEL_ID}
|
||||
aria-expanded={false}
|
||||
aria-label={t("insights.coach.frame.showPanel")}
|
||||
title={t("insights.coach.frame.showPanel")}
|
||||
className={PANEL_HEADER_BUTTON}
|
||||
>
|
||||
<PanelRightOpen
|
||||
className={cn(PANEL_HEADER_ICON, "-scale-x-100")}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
{/* The rest of the edge takes a click too and names the
|
||||
list; the keyboard has the button above. */}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={expandDocked}
|
||||
className="hover:bg-sidebar-accent text-muted-foreground flex min-h-0 flex-1 cursor-pointer flex-col items-center justify-start pt-4 text-xs transition-colors"
|
||||
>
|
||||
<span className="[writing-mode:vertical-rl]">
|
||||
{t("insights.coach.historyTitle")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</ShellSidePanel>
|
||||
icon={MessagesSquare}
|
||||
onToggle={dockedOpen ? collapseDocked : expandDocked}
|
||||
/>
|
||||
</ShellStrip>
|
||||
</>
|
||||
) : (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<SheetContent
|
||||
|
||||
@@ -256,14 +256,13 @@ export function LayoutCoachFab() {
|
||||
// the offset ring draws a clear halo around the circle.
|
||||
"focus-visible:ring-offset-background focus-visible:ring-offset-2",
|
||||
"transition-[opacity,right] duration-150 motion-reduce:transition-none",
|
||||
// v1.42 — while a day is docked on the right (from 1280 px), the
|
||||
// v1.42 — from 1280 px the day's 2.5 rem strip stays at the right
|
||||
// edge; the launcher stays clear of it. While the day is open, the
|
||||
// launcher moves left with the page's own edge, so it stays over
|
||||
// the page and never over the day's footer actions. The `:has()`
|
||||
// gate keys off the docked panel's slot, like the yields below.
|
||||
"[body:has([data-slot=day-panel][data-shell=docked])_&]:right-[calc(26.25rem+2rem)]",
|
||||
// Collapsed, the day keeps a 3 rem edge on the right; the launcher
|
||||
// stays clear of it the same way.
|
||||
"[body:has([data-slot=day-rail])_&]:right-[calc(3rem+2rem)]",
|
||||
// gates key off the strip's state, so exactly one of them holds.
|
||||
"[body:has([data-slot=day-strip][data-state=closed])_&]:right-[calc(2.5rem+2rem)]",
|
||||
"[body:has([data-slot=day-strip][data-state=open])_&]:right-[calc(26.25rem+2.5rem+2rem)]",
|
||||
// Yield to the data-list selection bar: its delete action lands
|
||||
// in the same lower-right band, and the destructive control
|
||||
// wins. The `:has()` gate keys off the bar's `data-slot`.
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
|
||||
import { stripDayOf } from "@/components/day/day-url";
|
||||
import {
|
||||
dayYields,
|
||||
listYields,
|
||||
BOTH_PANELS_QUERY,
|
||||
} from "@/components/insights/coach-panel/conversations-panel";
|
||||
|
||||
import { DOCK_SLIDE, DockStrip } from "../shell-dock";
|
||||
|
||||
/**
|
||||
* The docked panels' strips (v1.42). Each docked panel keeps its own strip
|
||||
* at the right edge; the strip is a toggle for its panel, never moves, and
|
||||
* below 1600 px only one panel is open. The click paths, focus and the
|
||||
* geometry run in `e2e/dock-strips.spec.ts`.
|
||||
*/
|
||||
function strip(expanded: boolean) {
|
||||
return renderToStaticMarkup(
|
||||
<DockStrip
|
||||
slot="day-strip"
|
||||
order={2}
|
||||
controls="day-docked-panel"
|
||||
expanded={expanded}
|
||||
label="Thu, 4 Jun 2026"
|
||||
actionLabel={expanded ? "Hide day" : "Show Thursday, 4 June 2026"}
|
||||
icon={CalendarDays}
|
||||
onToggle={() => {}}
|
||||
data={{ "data-day": "2026-06-04" }}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("DockStrip", () => {
|
||||
it("is a toggle button for its panel, closed", () => {
|
||||
const html = strip(false);
|
||||
expect(html).toContain('data-slot="day-strip"');
|
||||
expect(html).toContain('data-state="closed"');
|
||||
expect(html).toContain('data-day="2026-06-04"');
|
||||
expect(html).toMatch(/<button[^>]*type="button"/);
|
||||
expect(html).toContain('data-slot="day-strip-toggle"');
|
||||
expect(html).toContain('aria-expanded="false"');
|
||||
expect(html).toContain('aria-controls="day-docked-panel"');
|
||||
expect(html).toContain('aria-label="Show Thursday, 4 June 2026"');
|
||||
// The label reads top to bottom.
|
||||
expect(html).toContain("[writing-mode:vertical-rl]");
|
||||
expect(html).toContain("Thu, 4 Jun 2026");
|
||||
});
|
||||
|
||||
it("says it is open and what a click does then", () => {
|
||||
const html = strip(true);
|
||||
expect(html).toContain('data-state="open"');
|
||||
expect(html).toContain('aria-expanded="true"');
|
||||
expect(html).toContain('aria-label="Hide day"');
|
||||
});
|
||||
|
||||
it("keeps the same shape open and closed, below the top bar's band", () => {
|
||||
const shape = (html: string) => html.match(/^<div[^>]*class="([^"]*)"/)![1];
|
||||
expect(shape(strip(true))).toBe(shape(strip(false)));
|
||||
expect(shape(strip(false))).toContain("w-10");
|
||||
// The first cell is the band (`SHELL_HEADER_BAND`), empty and hidden.
|
||||
expect(strip(false)).toMatch(
|
||||
/^<div[^>]*><div aria-hidden="true" class="h-16 border-b/,
|
||||
);
|
||||
});
|
||||
|
||||
it("orders the strips like their panels: conversations, then the day", () => {
|
||||
const list = renderToStaticMarkup(
|
||||
<DockStrip
|
||||
slot="coach-panel-strip"
|
||||
order={1}
|
||||
controls="coach-conversations-panel"
|
||||
expanded={false}
|
||||
label="Conversations"
|
||||
actionLabel="Show conversations"
|
||||
icon={CalendarDays}
|
||||
onToggle={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(list).toMatch(/^<div[^>]*class="[^"]*\border-1\b/);
|
||||
expect(strip(false)).toMatch(/^<div[^>]*class="[^"]*\border-2\b/);
|
||||
});
|
||||
|
||||
it("slides by width, with no motion under reduced motion", () => {
|
||||
expect(DOCK_SLIDE).toContain("transition-[width]");
|
||||
expect(DOCK_SLIDE).toContain("duration-200");
|
||||
expect(DOCK_SLIDE).toContain("motion-reduce:transition-none");
|
||||
expect(DOCK_SLIDE).toContain("overflow-hidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("one panel open below 1600 px", () => {
|
||||
it("closes the day when the list opens below 1600 px", () => {
|
||||
expect(dayYields({ fitsBoth: false, dayOpen: true })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the day open beside the list from 1600 px", () => {
|
||||
expect(dayYields({ fitsBoth: true, dayOpen: true })).toBe(false);
|
||||
});
|
||||
|
||||
it("has nothing to close without an open day", () => {
|
||||
expect(dayYields({ fitsBoth: false, dayOpen: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("closes the list when a day opens below 1600 px, and not from 1600 px", () => {
|
||||
const base = {
|
||||
docked: true,
|
||||
listOpen: true,
|
||||
dayOpen: true,
|
||||
dayOpened: true,
|
||||
narrowed: false,
|
||||
};
|
||||
expect(listYields({ ...base, fitsBoth: false })).toBe(true);
|
||||
expect(listYields({ ...base, fitsBoth: true })).toBe(false);
|
||||
expect(BOTH_PANELS_QUERY).toBe("(min-width: 1600px)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the day strip's day", () => {
|
||||
const today = "2026-06-04";
|
||||
|
||||
it("is the open day while one is open", () => {
|
||||
expect(stripDayOf("2026-05-30", "2026-05-01", today)).toBe("2026-05-30");
|
||||
});
|
||||
|
||||
it("is the remembered day while the day is shut, across pages", () => {
|
||||
expect(stripDayOf(null, "2026-05-01", today)).toBe("2026-05-01");
|
||||
});
|
||||
|
||||
it("is today when nothing is remembered, or the memory is not a day to open", () => {
|
||||
expect(stripDayOf(null, null, today)).toBe(today);
|
||||
expect(stripDayOf(null, "2999-01-01", today)).toBe(today);
|
||||
expect(stripDayOf(null, "garbage", today)).toBe(today);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import { CommandPaletteMount } from "@/components/command-palette/command-palett
|
||||
import { DemoBanner } from "./demo-banner";
|
||||
import { OfflineBanner } from "./offline-banner";
|
||||
import { SharedRecordBanner } from "./shared-record-banner";
|
||||
import { ShellStripOutlet } from "./shell-dock";
|
||||
import { ShellSidePanelOutlet } from "./shell-side-panel";
|
||||
import { SharedRecordUnavailable } from "./shared-record-unavailable";
|
||||
import {
|
||||
@@ -540,6 +541,9 @@ export function AuthShell({
|
||||
bottom, and the top bar ends at its left edge. Empty and out of
|
||||
the row on every other page. */}
|
||||
<ShellSidePanelOutlet className="flex min-h-0 shrink-0 empty:hidden" />
|
||||
{/* The docked panels' strips, right of the panels: one per panel,
|
||||
always shown from 1280 px (`shell-dock.tsx`). */}
|
||||
<ShellStripOutlet className="flex min-h-0 shrink-0 empty:hidden" />
|
||||
<BottomNav />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -7,8 +7,9 @@ import { createShellSlot } from "./shell-slot";
|
||||
* `<main>` rather than inside `<main>`. A panel portalled here runs the full
|
||||
* height of the content row, from the top of the window (below any banner
|
||||
* strip) to the bottom, and the top bar ends at its left edge instead of
|
||||
* running underneath it. The Coach's docked conversations panel is the one
|
||||
* user. See `shell-slot.tsx`.
|
||||
* running underneath it. The docked day and the Coach's docked
|
||||
* conversations live here; their strips sit right of this column
|
||||
* (`shell-dock.tsx`). See `shell-slot.tsx`.
|
||||
*/
|
||||
const slot = createShellSlot("shell-side-panel");
|
||||
|
||||
|
||||
Reference in new issue
Block a user