Files
HealthLog/src/components/layout/shell-slot.tsx
T
Marc-André Bombeck 61f065c651 feat(coach): run the conversations panel the full height of the window
The docked panel was part of the page, below the top bar. It is now a
column of the shell beside the content column, portalled through a new
side-panel slot: it runs from the top of the window to the bottom, flush
right, and the top bar ends at its left edge. Shut, the panel is gone
entirely and the top bar runs to the window's edge. The panel's header row
is the top bar's band, so the two bottom borders draw one line; the panel
takes the sidebar surface and border, like the rail on the left.

The top bar says where the reader is on desktop: "Coach", then the open
conversation's title, truncated. The panel toggle is its last item, 12 px
from its trailing edge, so it stands against the open panel and at the
window's edge when the panel is shut; it carries a tooltip and is 28 px
beside a fine pointer, 44 px with touch.

New chat is a round 48 px primary button with a plus, 24 px in from the
panel's corner (16 px in the phone sheet), with a tooltip. The top bar's
action slot and the new context and side-panel slots share one portal
helper.
2026-10-05 18:46:27 +02:00

56 lines
1.9 KiB
TypeScript

"use client";
import { useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
/**
* A page-owned slot in the shell's chrome.
*
* The shell knows nothing about pages; a page that needs to paint into the
* chrome (the Coach's panel toggle in the top bar, its docked conversations
* panel beside the content column) renders the slot's `Portal` anywhere in
* its own tree and the content is portalled into the slot's `Outlet`.
* Portalling keeps the content inside the page's React tree, so it reads
* the page's state and context directly and no node has to be handed across
* a context on every render.
*
* The slot empties itself when the page unmounts: the portal goes with the
* component that rendered it, so no other route ever shows stale content.
*
* The outlet's element is held in a module-level store rather than a context
* provider. The shell mounts each outlet exactly once, so a store keeps the
* shell's tree untouched. On the server, and before the outlet has mounted,
* there is no target and the portal renders nothing.
*/
export function createShellSlot(slot: string) {
let outlet: HTMLElement | null = null;
const listeners = new Set<() => void>();
function setOutlet(node: HTMLElement | null) {
if (outlet === node) return;
outlet = node;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
function Outlet({ className }: { className?: string }) {
return <div ref={setOutlet} data-slot={slot} className={className} />;
}
function Portal({ children }: { children: React.ReactNode }) {
const target = useSyncExternalStore(
subscribe,
() => outlet,
() => null,
);
if (!target) return null;
return createPortal(children, target);
}
return { Outlet, Portal };
}