mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
56 lines
1.9 KiB
TypeScript
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 };
|
|
}
|