246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
// The Kjøl JS Web shell: top bar (wordmark + Layers menu), sidebar, content.
|
|
//
|
|
// It is deliberately a near-copy of the Go/WASM section's AppLayout. Two front-ends,
|
|
// one site: if the chrome drifted, crossing from /wasm to /js would feel like leaving
|
|
// for somebody else's website. The components underneath are completely different —
|
|
// these are Solid components from the kit, those are Go functions returning a VNode —
|
|
// and the page should not betray that.
|
|
|
|
import { For, Show } from "solid-js";
|
|
import { A, useLocation, useNavigate } from "@solidjs/router";
|
|
import { Icon } from "@ui/Icons";
|
|
import { Menu, MenuTrigger, MenuContent } from "@ui/Menu";
|
|
import { ThemeToggle, initTheme } from "@ui/Theme";
|
|
import { LANGUAGES, COMPOSITIONS, currentLayer, Layer } from "../layers.ts";
|
|
import { COMPONENT_GROUPS } from "../componentGroups.ts";
|
|
|
|
interface NavItem {
|
|
path: string;
|
|
label: string;
|
|
icon: string;
|
|
}
|
|
|
|
// The section's own pages. Paths are relative to the router base (/js).
|
|
const NAV: NavItem[] = [
|
|
{ path: "/", label: "Overview", icon: "circle-info" },
|
|
{ path: "/components", label: "Components", icon: "table-columns" },
|
|
];
|
|
|
|
// jumpTo scrolls a section into view, routing there first if we are somewhere else.
|
|
//
|
|
// A plain <a href="#forms"> would work if the reader were already on the components
|
|
// page, and would do nothing useful from anywhere else. The router's <A> is no good
|
|
// either — it would try to navigate to a route called "#forms".
|
|
//
|
|
// The queueMicrotask is not superstition: after navigate() the target section does not
|
|
// exist yet, because the page it lives on has not rendered. Scrolling on the next tick
|
|
// is the earliest moment the element is actually there to scroll to.
|
|
function jumpTo(navigate: (to: string) => void, onComponentsPage: boolean, id: string) {
|
|
const scroll = () => document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
|
|
if (onComponentsPage) {
|
|
scroll();
|
|
return;
|
|
}
|
|
navigate("/components");
|
|
queueMicrotask(scroll);
|
|
}
|
|
|
|
// The site's primary navigation, as TWO dropdowns: Layers (the languages) and
|
|
// Compositions (the frameworks assembled out of them — see layers.ts).
|
|
//
|
|
// Two menus, not one with two headings inside it. They answer different questions —
|
|
// "what is this written in" and "what can I read" — and a reader who wants the second
|
|
// should not have to scroll past the first to find it. The kit's single-open manager
|
|
// means opening one closes the other, so they behave like one control with two halves.
|
|
//
|
|
// Anything not `live` still appears, greyed, with the reason. A menu that silently omits
|
|
// half the library teaches the reader that the library is half the size it is.
|
|
//
|
|
// Same shape as the Go side (app/layers.go: layersMenu / compositionsMenu).
|
|
function Dropdown(props: { label: string; rows: Layer[] }) {
|
|
const location = useLocation();
|
|
const current = () => currentLayer(location.pathname);
|
|
|
|
return (
|
|
// bottom-end, because the triggers sit at the right-hand end of the bar and a 24rem
|
|
// panel hanging off the left edge of one would run past the window.
|
|
<Menu placement="bottom-end">
|
|
<MenuTrigger>
|
|
<span class="inline-flex items-center gap-1.5 rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft hover:bg-surface-raised hover:text-ink">
|
|
{props.label}
|
|
<Icon icon="chevron-down" size={11} class="text-ink-faint" />
|
|
</span>
|
|
</MenuTrigger>
|
|
|
|
<MenuContent class="w-96">
|
|
<For each={props.rows}>{(layer) => <LayerItem layer={layer} current={current()} />}</For>
|
|
</MenuContent>
|
|
</Menu>
|
|
);
|
|
}
|
|
|
|
// LayerItem is one row of the menu.
|
|
//
|
|
// The markup is deliberately the same shape and the same classes as app/layers.go's
|
|
// layerItem. Two front-ends, one menu: if they drifted, this is where it would show,
|
|
// because it is the one component a reader sees on both sides within seconds of each
|
|
// other.
|
|
function LayerItem(props: { layer: Layer; current?: Layer }) {
|
|
return (
|
|
<Show
|
|
when={props.layer.live}
|
|
fallback={
|
|
<div class="flex cursor-default flex-col gap-0.5 px-3 py-2 opacity-55">
|
|
<span class="flex items-center gap-2 text-sm font-medium text-ink-muted">
|
|
{props.layer.name}
|
|
<span class="rounded-full bg-surface-raised px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-ink-muted">
|
|
reference
|
|
</span>
|
|
</span>
|
|
<span class="text-xs text-ink-muted">{props.layer.tagline}</span>
|
|
</div>
|
|
}
|
|
>
|
|
{/* A plain <a href>, not the router's <A>: the other side is served by a
|
|
different binary, so crossing to it has to be a real navigation, not a
|
|
client-side route the router would try to handle itself. */}
|
|
<a
|
|
href={props.layer.href}
|
|
class={
|
|
"flex flex-col gap-0.5 px-3 py-2 no-underline hover:bg-surface-raised " +
|
|
(props.current?.href === props.layer.href ? "bg-primary-subtle" : "")
|
|
}
|
|
>
|
|
<span class="text-sm font-medium text-ink">{props.layer.name}</span>
|
|
<span class="text-xs text-ink-muted">{props.layer.tagline}</span>
|
|
</a>
|
|
</Show>
|
|
);
|
|
}
|
|
|
|
function Wordmark() {
|
|
// A plain <a href>, not a router <A>: "/" is the front page, which belongs to the
|
|
// Go/WASM binary. Routing to it inside this SPA would resolve to /js and land you
|
|
// back where you started.
|
|
return (
|
|
<a href="/" class="flex items-center gap-2.5 no-underline">
|
|
{/* text-white, not a theme token: the flag tile is the same in both themes, so
|
|
the boat on top of it has to be too. The flag carries a dark scrim (.flag-no)
|
|
so the plain white boat reads without a shadow of its own. */}
|
|
<span class="inline-flex h-8 w-8 items-center justify-center rounded-default flag-no text-white">
|
|
<Icon icon="sailboat" size={17} />
|
|
</span>
|
|
<span class="flex items-baseline gap-1.5">
|
|
<span class="text-lg font-semibold tracking-tight text-ink">Kjøl JS Web</span>
|
|
<span class="text-sm text-ink-faint">Solid + Go toolchain</span>
|
|
</span>
|
|
</a>
|
|
);
|
|
}
|
|
|
|
function Sidebar() {
|
|
const location = useLocation();
|
|
const navigate = useNavigate();
|
|
|
|
// The router's pathname is absolute (/js/components); NAV paths are base-relative.
|
|
const active = (path: string) => location.pathname === "/js" + (path === "/" ? "" : path);
|
|
const onComponents = () => location.pathname === "/js/components";
|
|
|
|
// The same active treatment the Go sidebar uses (app/pages.go: sidebarLink) — a
|
|
// tinted panel and accent text, not a grey fill. Now that the Solid theme carries the
|
|
// primary-subtle / accent tokens, the two sidebars are the same sidebar.
|
|
//
|
|
// No icons, also matching the Go sidebar: the sidebar is a list of words, and a glyph on
|
|
// every row is noise to read past. So `block`, not `flex items-center gap-2`.
|
|
const linkCls = (on: boolean) =>
|
|
on
|
|
? "block rounded-default bg-primary-subtle px-2 py-1.5 text-sm font-medium text-accent no-underline"
|
|
: "block rounded-default px-2 py-1.5 text-sm text-ink-soft no-underline hover:bg-surface-raised hover:text-ink";
|
|
|
|
return (
|
|
<aside class="sticky top-[3.75rem] hidden h-[calc(100vh-3.75rem)] w-56 shrink-0 overflow-y-auto py-10 lg:block">
|
|
<p class="px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">Kjøl JS Web</p>
|
|
<ul class="mt-2 space-y-0.5">
|
|
<For each={NAV}>
|
|
{(item) => (
|
|
<li>
|
|
<A href={item.path} end={item.path === "/"} class={linkCls(active(item.path))}>
|
|
{item.label}
|
|
</A>
|
|
</li>
|
|
)}
|
|
</For>
|
|
</ul>
|
|
|
|
{/* The component groups are not pages — they are anchors into the one components
|
|
page, and clicking one scrolls you there.
|
|
|
|
They are not highlighted by which section you have scrolled to. Finding that
|
|
out means measuring all fifteen of them on every scroll frame, and the only
|
|
way to act on the answer is a state write, which re-renders. Sixty times a
|
|
second, to move a highlight. The highlight is not worth the page. */}
|
|
<p class="mt-8 px-2 text-[11px] font-semibold uppercase tracking-widest text-ink-faint">
|
|
Components
|
|
</p>
|
|
<ul class="mt-2 space-y-0.5">
|
|
<For each={COMPONENT_GROUPS}>
|
|
{(g) => (
|
|
<li>
|
|
<a
|
|
href={"/js/components#" + g.id}
|
|
class={linkCls(false)}
|
|
onclick={(e) => {
|
|
e.preventDefault();
|
|
jumpTo(navigate, onComponents(), g.id);
|
|
}}
|
|
>
|
|
{g.label}
|
|
</a>
|
|
</li>
|
|
)}
|
|
</For>
|
|
</ul>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export function Shell(props: { children?: any }) {
|
|
// Once, at the root. The boot script in the document head has ALREADY put the right
|
|
// class on <html> — this only syncs the toggle's signals with it and starts
|
|
// following the OS while the mode is "system". Calling it late is harmless; not
|
|
// calling it just leaves the button showing the wrong icon.
|
|
initTheme();
|
|
|
|
return (
|
|
<div class="min-h-screen bg-surface">
|
|
{/* bg-surface/90, not bg-white/90: the translucent sticky bar has to be
|
|
translucent over whatever the surface currently IS. */}
|
|
<nav class="sticky top-0 z-20 border-b border-line bg-surface/90 backdrop-blur">
|
|
<div class="mx-auto flex max-w-[110rem] items-center gap-3 px-6 py-3">
|
|
<Wordmark />
|
|
<span class="rounded-full border border-line px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
|
|
Docs
|
|
</span>
|
|
<div class="ml-auto flex items-center gap-2">
|
|
<Dropdown label="Layers" rows={LANGUAGES} />
|
|
<Dropdown label="Compositions" rows={COMPOSITIONS} />
|
|
<a
|
|
href="/"
|
|
class="rounded-default px-3 py-1.5 text-sm font-medium text-ink-soft no-underline hover:bg-surface-raised hover:text-ink"
|
|
>
|
|
Home
|
|
</a>
|
|
<ThemeToggle small />
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div class="mx-auto flex max-w-[110rem] gap-8 px-6">
|
|
<Sidebar />
|
|
<main class="min-w-0 flex-1 py-10">{props.children}</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|