shelving 1.244.1 → 1.245.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/ui/README.md +17 -17
- package/ui/layout/CenteredLayout.js +4 -3
- package/ui/layout/CenteredLayout.tsx +10 -7
- package/ui/layout/SidebarLayout.d.ts +1 -0
- package/ui/layout/SidebarLayout.js +7 -1
- package/ui/layout/SidebarLayout.tsx +15 -7
- package/ui/router/RouteCache.d.ts +42 -0
- package/ui/router/RouteCache.js +66 -0
- package/ui/router/RouteCache.md +31 -0
- package/ui/router/RouteCache.tsx +97 -0
- package/ui/router/Router.md +25 -0
- package/ui/router/Router.test.tsx +28 -0
- package/ui/router/index.d.ts +1 -0
- package/ui/router/index.js +1 -0
- package/ui/router/index.ts +1 -0
package/package.json
CHANGED
package/ui/README.md
CHANGED
|
@@ -11,7 +11,7 @@ The `ui` module exists so an app never hand-rolls the same form field, card, or
|
|
|
11
11
|
A few conventions run through every component:
|
|
12
12
|
|
|
13
13
|
- **Styling props are for one-off overrides.** Visual options are props on the component — enumerated props for the scales (`color="red"`, `size="large"`, `space="none"`, `width="narrow"`) and boolean props for on/off variants (`<Button strong>`, `<Title center>`, `<Flex wrap>`). Each maps to a class in a CSS Module. Reach for them when a component needs to look different in *one place* — the way the docs site tints its accents purple — not as the way to dress a whole app. You never pass `style` or raw `className`.
|
|
14
|
-
- **Composition.** Higher-level components — a `*Page`, a `*Card` — take their identity from library components like [
|
|
14
|
+
- **Composition.** Higher-level components — a `*Page`, a `*Card` — take their identity from library components like [`<Card>`](/ui/Card), [`<Section>`](/ui/Section), [`<Button>`](/ui/Button), and [`<Tag>`](/ui/Tag) rather than shipping their own styling.
|
|
15
15
|
- **Sentence case.** Titles, headings, and button labels capitalise only the first word.
|
|
16
16
|
- **Theme with CSS.** An app-wide custom look is a CSS file, not a wall of props. Write a `theme.css` that overrides the base design-token variables (and, where needed, per-component hooks) at `:root`, and import it after the library styles. The recommended workflow is to spend time tuning those variables to match your design — see [Theming](#theming) below.
|
|
17
17
|
|
|
@@ -19,15 +19,15 @@ A few conventions run through every component:
|
|
|
19
19
|
|
|
20
20
|
The styling system lives in `style/` and has four moving parts: design tokens, the tint scale, cascade layers, and the styling props. Components compose them in a predictable shape; consumers theme by overriding CSS custom properties at `:root`.
|
|
21
21
|
|
|
22
|
-
**Design tokens.** Every design-token constant is defined at `:root`, split across the themed token modules in `style/` — each module owns one domain, documents the variables it defines, and is the page a theme author overrides. `style/layers.css` is the cascade-layer anchor; every `*.module.css` `@import`s it plus the specific token modules it references, so the tokens and the layer order reach every component regardless of bundle order. The domains are: colours ([`getColorClass`](/ui/getColorClass)), font sizes ([`getSizeClass`](/ui/getSizeClass)), font weights ([`getWeightClass`](/ui/getWeightClass)), font faces ([`getFontClass`](/ui/getFontClass)), spacing ([`getSpaceClass`](/ui/getSpaceClass)), widths ([`getWidthClass`](/ui/getWidthClass)), radii ([`getRadiusClass`](/ui/getRadiusClass)), strokes ([`getStrokeClass`](/ui/getStrokeClass)), shadows ([`getShadowClass`](/ui/getShadowClass)), and durations ([`getDurationClass`](/ui/getDurationClass)). Each also defines the semantic aliases a theme usually targets (`--color-primary`, `--color-link`, `--space-paragraph`, …). Components read tokens via `var(--token)`.
|
|
22
|
+
**Design tokens.** Every design-token constant is defined at `:root`, split across the themed token modules in `style/` — each module owns one domain, documents the variables it defines, and is the page a theme author overrides. `style/layers.css` is the cascade-layer anchor; every `*.module.css` `@import`s it plus the specific token modules it references, so the tokens and the layer order reach every component regardless of bundle order. The domains are: colours ([`getColorClass()`](/ui/getColorClass)), font sizes ([`getSizeClass()`](/ui/getSizeClass)), font weights ([`getWeightClass()`](/ui/getWeightClass)), font faces ([`getFontClass()`](/ui/getFontClass)), spacing ([`getSpaceClass()`](/ui/getSpaceClass)), widths ([`getWidthClass()`](/ui/getWidthClass)), radii ([`getRadiusClass()`](/ui/getRadiusClass)), strokes ([`getStrokeClass()`](/ui/getStrokeClass)), shadows ([`getShadowClass()`](/ui/getShadowClass)), and durations ([`getDurationClass()`](/ui/getDurationClass)). Each also defines the semantic aliases a theme usually targets (`--color-primary`, `--color-link`, `--space-paragraph`, …). Components read tokens via `var(--token)`.
|
|
23
23
|
|
|
24
|
-
**The tint scale.** All colour flows from one anchor variable, `--tint-50`, from which a 21-step ladder is computed and *recomputed* under [`TINT_CLASS`](/ui/TINT_CLASS) — the heart of how `color=` and `status=` retint a whole subtree. The ladder, the recompute trick, the painting conventions, and the theming guide all live on the
|
|
24
|
+
**The tint scale.** All colour flows from one anchor variable, `--tint-50`, from which a 21-step ladder is computed and *recomputed* under [`TINT_CLASS`](/ui/TINT_CLASS) — the heart of how `color=` and `status=` retint a whole subtree. The ladder, the recompute trick, the painting conventions, and the theming guide all live on the `TINT_CLASS` page.
|
|
25
25
|
|
|
26
26
|
**Cascade layers.** Styles are ordered by `@layer`, lowest to highest priority: `defaults` (`:root` tokens, the tint ladder, body baseline) → `components` (the bulk of the CSS: `.card`, `.button`, …) → `variants` (cross-cutting opt-in modifiers, which always beat components) → `overrides` (top-priority structural fixes like `:first-child` / `:last-child` margin collapses). Unlayered rules beat all layered rules, so a theme should set tokens at `:root` or wrap its rules in `@layer`.
|
|
27
27
|
|
|
28
|
-
**Styling props.** The cross-cutting visual options are props, each backed by a helper in `style/` that maps the prop to a class. Colour and status move the tint anchor —
|
|
28
|
+
**Styling props.** The cross-cutting visual options are props, each backed by a helper in `style/` that maps the prop to a class. Colour and status move the tint anchor — `getColorClass()` and [`getStatusClass()`](/ui/getStatusClass); font size, weight, and family come from `getSizeClass()`, `getWeightClass()`, and `getFontClass()`, which [`getTypographyClass()`](/ui/getTypographyClass) combines with text alignment and tint; spacing, padding, and gap from `getSpaceClass()`, [`getPaddingClass()`](/ui/getPaddingClass), and [`getGapClass()`](/ui/getGapClass); width constraints from `getWidthClass()`; flex layout from [`getFlexClass()`](/ui/getFlexClass); and opt-in scrolling from [`getScrollClass()`](/ui/getScrollClass). Each helper's page lists its exact prop values and what they set. A component opts into the props it wants by extending the matching `*Props` interfaces and composing the `getXxxClass(props)` calls.
|
|
29
29
|
|
|
30
|
-
Each painting component also exposes its own theme hooks — a single tint hook (`--card-tint`) to recolour the whole component, plus per-property hooks (`--card-background`, `--card-radius`, …) for surgical overrides. Those are documented in each component's own **Styling** section (see [
|
|
30
|
+
Each painting component also exposes its own theme hooks — a single tint hook (`--card-tint`) to recolour the whole component, plus per-property hooks (`--card-background`, `--card-radius`, …) for surgical overrides. Those are documented in each component's own **Styling** section (see [`<Card>`](/ui/Card) for the precedent).
|
|
31
31
|
|
|
32
32
|
## Theming
|
|
33
33
|
|
|
@@ -45,10 +45,10 @@ The recommended way to give an app its own look is a **theme stylesheet**, not s
|
|
|
45
45
|
|
|
46
46
|
Each base token lives in a themed module that documents the variables it defines and which ones a theme usually overrides. Work from broadest (a palette colour or scale root) to narrowest (a single semantic alias):
|
|
47
47
|
|
|
48
|
-
- [weight](/ui/getWeightClass) · [size](/ui/getSizeClass) · [font](/ui/getFontClass) — typography (`--weight-*`, `--size-*`, `--font-*`, `--case-label`).
|
|
49
|
-
- [color](/ui/getColorClass) — palette, semantic, and brand colours (`--color-*`).
|
|
50
|
-
- [space](/ui/getSpaceClass) · [width](/ui/getWidthClass) — layout spacing and widths (`--space-*`, `--width-*`).
|
|
51
|
-
- [radius](/ui/getRadiusClass) · [stroke](/ui/getStrokeClass) · [shadow](/ui/getShadowClass) · [duration](/ui/getDurationClass) — surface tokens (`--radius-*`, `--stroke-*`, `--shadow-*`, `--duration-*`).
|
|
48
|
+
- [`weight`](/ui/getWeightClass) · [`size`](/ui/getSizeClass) · [`font`](/ui/getFontClass) — typography (`--weight-*`, `--size-*`, `--font-*`, `--case-label`).
|
|
49
|
+
- [`color`](/ui/getColorClass) — palette, semantic, and brand colours (`--color-*`).
|
|
50
|
+
- [`space`](/ui/getSpaceClass) · [`width`](/ui/getWidthClass) — layout spacing and widths (`--space-*`, `--width-*`).
|
|
51
|
+
- [`radius`](/ui/getRadiusClass) · [`stroke`](/ui/getStrokeClass) · [`shadow`](/ui/getShadowClass) · [`duration`](/ui/getDurationClass) — surface tokens (`--radius-*`, `--stroke-*`, `--shadow-*`, `--duration-*`).
|
|
52
52
|
|
|
53
53
|
The **tint ladder** is the one exception that doesn't follow the override-a-variable pattern: its 21 steps are *recomputed* from a single anchor inside every tinted scope, so you move the anchor rather than overriding individual steps. See [`TINT_CLASS`](/ui/TINT_CLASS) for the full theming guide, and each component's **Styling** section for its per-component hooks.
|
|
54
54
|
|
|
@@ -56,19 +56,19 @@ The **tint ladder** is the one exception that doesn't follow the override-a-vari
|
|
|
56
56
|
|
|
57
57
|
The components below are listed in the index following this page; this is the short version of where to start reading.
|
|
58
58
|
|
|
59
|
-
**Content.** Block-level structure starts with [
|
|
59
|
+
**Content.** Block-level structure starts with [`<Card>`](/ui/Card), [`<Section>`](/ui/Section), and the [`<Heading>`](/ui/Heading) / [`<Title>`](/ui/Title) family, with [`<Table>`](/ui/Table), [`<List>`](/ui/List), and [`<Figure>`](/ui/Figure) for specific shapes; wrap longform copy in [`<Prose>`](/ui/Prose). Inline pieces — [`<Link>`](/ui/Link), [`<Code>`](/ui/Code), [`<Strong>`](/ui/Strong), [`<Mark>`](/ui/Mark) — live inside that block content. To render a Markdown string as components, use [`<Markup>`](/ui/Markup).
|
|
60
60
|
|
|
61
|
-
**Structure.** Mount a client app with [
|
|
61
|
+
**Structure.** Mount a client app with [`<App>`](/ui/App), or render a full server document with [`<HTML>`](/ui/HTML) and [`<Page>`](/ui/Page). Arrange the screen with [`<CenteredLayout>`](/ui/CenteredLayout) or [`<SidebarLayout>`](/ui/SidebarLayout), and drive URLs with [`<Navigation>`](/ui/Navigation) and [`<Router>`](/ui/Router).
|
|
62
62
|
|
|
63
|
-
**Interaction.** Forms start at [
|
|
63
|
+
**Interaction.** Forms start at [`<Form>`](/ui/Form), which wires [`<Field>`](/ui/Field) and the typed inputs to a [`FormStore`](/ui/FormStore); [`<Button>`](/ui/Button) is the standalone action. Overlays are [`<Dialog>`](/ui/Dialog) and [`<Modal>`](/ui/Modal); navigation menus are [`<Menu>`](/ui/Menu) and [`<MenuItem>`](/ui/MenuItem); transient feedback is [`<Notice>`](/ui/Notice) (and the global [`<Notices>`](/ui/Notices) list); animate enter/leave with [`<Transition>`](/ui/Transition).
|
|
64
64
|
|
|
65
|
-
**Documentation site.** Hand an extracted tree to [
|
|
65
|
+
**Documentation site.** Hand an extracted tree to [`<TreeApp>`](/ui/TreeApp) and you get a complete site — sidebar, routing, and a rendered page per element — using the renderers in `docs/`.
|
|
66
66
|
|
|
67
67
|
## See also
|
|
68
68
|
|
|
69
|
-
- [extract](/extract) — builds the tree that the documentation components render
|
|
70
|
-
- [markup](/markup) — Markdown rendering used by [
|
|
71
|
-
- [store](/store) — reactive state behind [`FormStore`](/ui/FormStore), `NavigationStore
|
|
72
|
-
- [react](/react) — store and provider hooks used alongside these components
|
|
69
|
+
- [`extract`](/extract) — builds the tree that the documentation components render
|
|
70
|
+
- [`markup`](/markup) — Markdown rendering used by [`<Markup>`](/ui/Markup) and [`<Prose>`](/ui/Prose)
|
|
71
|
+
- [`store`](/store) — reactive state behind [`FormStore`](/ui/FormStore), [`NavigationStore`](/ui/NavigationStore), and notices
|
|
72
|
+
- [`react`](/react) — store and provider hooks used alongside these components
|
|
73
73
|
|
|
74
74
|
> Building or extending a component? The contributor walkthrough (file layout, the tint-anchor + per-property-hook pattern, `:first-child` / `:last-child` overrides, and the checklist) lives in the **React Components** section of `AGENTS.md`.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
2
|
+
import { RouteCache } from "../router/RouteCache.js";
|
|
3
3
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
4
4
|
import CENTERED_LAYOUT_CSS from "./CenteredLayout.module.css";
|
|
5
5
|
import { LAYOUT_CLASS } from "./Layout.js";
|
|
@@ -15,6 +15,7 @@ import { LAYOUT_CLASS } from "./Layout.js";
|
|
|
15
15
|
* @see https://dhoulb.github.io/shelving/ui/layout/CenteredLayout/CenteredLayout
|
|
16
16
|
*/
|
|
17
17
|
export function CenteredLayout({ children, fullWidth = false }) {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// Wrap the scrolling `<main>` in `<RouteCache>` so recently-visited pages stay mounted but hidden,
|
|
19
|
+
// keeping their scroll position and state intact across back/forward navigation.
|
|
20
|
+
return (_jsx(RouteCache, { children: _jsx("main", { className: getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS), children: _jsx("div", { className: getModuleClass(CENTERED_LAYOUT_CSS, "mainInner"), style: fullWidth ? { maxWidth: "none" } : undefined, children: children }) }) }));
|
|
20
21
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReactElement } from "react";
|
|
2
|
-
import {
|
|
2
|
+
import { RouteCache } from "../router/RouteCache.js";
|
|
3
3
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
4
4
|
import type { OptionalChildProps } from "../util/props.js";
|
|
5
5
|
import CENTERED_LAYOUT_CSS from "./CenteredLayout.module.css";
|
|
@@ -26,12 +26,15 @@ export interface CenteredLayoutProps extends OptionalChildProps {
|
|
|
26
26
|
* @see https://dhoulb.github.io/shelving/ui/layout/CenteredLayout/CenteredLayout
|
|
27
27
|
*/
|
|
28
28
|
export function CenteredLayout({ children, fullWidth = false }: CenteredLayoutProps): ReactElement {
|
|
29
|
-
|
|
29
|
+
// Wrap the scrolling `<main>` in `<RouteCache>` so recently-visited pages stay mounted but hidden,
|
|
30
|
+
// keeping their scroll position and state intact across back/forward navigation.
|
|
30
31
|
return (
|
|
31
|
-
<
|
|
32
|
-
<
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
<RouteCache>
|
|
33
|
+
<main className={getClass(getModuleClass(CENTERED_LAYOUT_CSS, "main"), LAYOUT_CLASS)}>
|
|
34
|
+
<div className={getModuleClass(CENTERED_LAYOUT_CSS, "mainInner")} style={fullWidth ? { maxWidth: "none" } : undefined}>
|
|
35
|
+
{children}
|
|
36
|
+
</div>
|
|
37
|
+
</main>
|
|
38
|
+
</RouteCache>
|
|
36
39
|
);
|
|
37
40
|
}
|
|
@@ -17,6 +17,7 @@ export interface SidebarLayoutProps extends OptionalChildProps {
|
|
|
17
17
|
* - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
|
|
18
18
|
* - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
|
|
19
19
|
* - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
|
|
20
|
+
* - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
|
|
20
21
|
* - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
|
|
21
22
|
*
|
|
22
23
|
* @kind component
|
|
@@ -3,6 +3,7 @@ import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
|
|
|
3
3
|
import { useEffect, useState } from "react";
|
|
4
4
|
import { Button } from "../form/Button.js";
|
|
5
5
|
import { requireMetaURL } from "../misc/MetaContext.js";
|
|
6
|
+
import { RouteCache } from "../router/RouteCache.js";
|
|
6
7
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
7
8
|
import { LAYOUT_CLASS } from "./Layout.js";
|
|
8
9
|
import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
|
|
@@ -12,6 +13,7 @@ import SIDEBAR_LAYOUT_CSS from "./SidebarLayout.module.css";
|
|
|
12
13
|
* - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
|
|
13
14
|
* - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
|
|
14
15
|
* - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
|
|
16
|
+
* - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
|
|
15
17
|
* - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
|
|
16
18
|
*
|
|
17
19
|
* @kind component
|
|
@@ -31,7 +33,11 @@ export function SidebarLayout({ sidebar, children, right = false }) {
|
|
|
31
33
|
setOpen(false);
|
|
32
34
|
}, [path]);
|
|
33
35
|
const sidebarEl = (_jsx("nav", { className: getClass(getModuleClass(SIDEBAR_LAYOUT_CSS, "sidebar"), open && getModuleClass(SIDEBAR_LAYOUT_CSS, "open")), children: sidebar }, "sidebar"));
|
|
34
|
-
|
|
36
|
+
// Wrap the scrolling content column in `<RouteCache>` so recently-visited pages stay mounted but hidden
|
|
37
|
+
// — keeping the scroll position of this `.content` container (and all page state) intact across
|
|
38
|
+
// back/forward navigation. The sidebar and drawer state stay outside the cache, so they are neither
|
|
39
|
+
// duplicated nor remounted as the URL changes.
|
|
40
|
+
const contentEl = (_jsx(RouteCache, { children: _jsxs("div", { className: getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content")), children: [_jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle"), children: _jsx(Button, { title: open ? "Close menu" : "Show menu", onClick: () => setOpen(o => !o), children: open ? _jsx(XMarkIcon, {}) : _jsx(Bars3Icon, {}) }) }), _jsx("div", { className: getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner"), children: children })] }) }, "content"));
|
|
35
41
|
const overlayEl = open && (_jsx("button", { type: "button", className: getModuleClass(SIDEBAR_LAYOUT_CSS, "overlay"), "aria-label": "Close menu", onClick: () => setOpen(false) }, "overlay"));
|
|
36
42
|
return (_jsx("main", { className: getClass(getModuleClass(SIDEBAR_LAYOUT_CSS, "main"), LAYOUT_CLASS), children: right ? [contentEl, sidebarEl, overlayEl] : [sidebarEl, contentEl, overlayEl] }));
|
|
37
43
|
}
|
|
@@ -2,6 +2,7 @@ import { Bars3Icon, XMarkIcon } from "@heroicons/react/24/solid";
|
|
|
2
2
|
import { type ReactElement, type ReactNode, useEffect, useState } from "react";
|
|
3
3
|
import { Button } from "../form/Button.js";
|
|
4
4
|
import { requireMetaURL } from "../misc/MetaContext.js";
|
|
5
|
+
import { RouteCache } from "../router/RouteCache.js";
|
|
5
6
|
import { getClass, getModuleClass } from "../util/css.js";
|
|
6
7
|
import type { OptionalChildProps } from "../util/props.js";
|
|
7
8
|
import { LAYOUT_CLASS } from "./Layout.js";
|
|
@@ -25,6 +26,7 @@ export interface SidebarLayoutProps extends OptionalChildProps {
|
|
|
25
26
|
* - On narrow viewports the sidebar becomes an off-canvas drawer toggled by a single menu button that switches between a burger and a close icon.
|
|
26
27
|
* - While the drawer is open an overlay dims the rest of the page; clicking the overlay closes the drawer.
|
|
27
28
|
* - Inside a `<Navigation>` the drawer closes itself whenever the route changes (e.g. tapping a sidebar link).
|
|
29
|
+
* - The scrollable content column is kept alive across navigation via `<RouteCache>`, so returning to a recently-visited page restores its scroll position and state; the sidebar stays mounted throughout.
|
|
28
30
|
* - Use the `--sidebar-layout-width`, `--sidebar-layout-bg`, `--sidebar-layout-border`, and `--sidebar-layout-color-border` custom properties to override defaults.
|
|
29
31
|
*
|
|
30
32
|
* @kind component
|
|
@@ -52,15 +54,21 @@ export function SidebarLayout({ sidebar, children, right = false }: SidebarLayou
|
|
|
52
54
|
{sidebar}
|
|
53
55
|
</nav>
|
|
54
56
|
);
|
|
57
|
+
// Wrap the scrolling content column in `<RouteCache>` so recently-visited pages stay mounted but hidden
|
|
58
|
+
// — keeping the scroll position of this `.content` container (and all page state) intact across
|
|
59
|
+
// back/forward navigation. The sidebar and drawer state stay outside the cache, so they are neither
|
|
60
|
+
// duplicated nor remounted as the URL changes.
|
|
55
61
|
const contentEl = (
|
|
56
|
-
<
|
|
57
|
-
<div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "
|
|
58
|
-
<
|
|
59
|
-
{open ?
|
|
60
|
-
|
|
62
|
+
<RouteCache key="content">
|
|
63
|
+
<div className={getClass(LAYOUT_CLASS, getModuleClass(SIDEBAR_LAYOUT_CSS, "content"))}>
|
|
64
|
+
<div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "toggle")}>
|
|
65
|
+
<Button title={open ? "Close menu" : "Show menu"} onClick={() => setOpen(o => !o)}>
|
|
66
|
+
{open ? <XMarkIcon /> : <Bars3Icon />}
|
|
67
|
+
</Button>
|
|
68
|
+
</div>
|
|
69
|
+
<div className={getModuleClass(SIDEBAR_LAYOUT_CSS, "contentInner")}>{children}</div>
|
|
61
70
|
</div>
|
|
62
|
-
|
|
63
|
-
</div>
|
|
71
|
+
</RouteCache>
|
|
64
72
|
);
|
|
65
73
|
const overlayEl = open && (
|
|
66
74
|
<button
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Props for `<RouteCache>` — the `maxCached` size and the content `children` to keep alive per URL.
|
|
4
|
+
*
|
|
5
|
+
* @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCacheProps
|
|
6
|
+
*/
|
|
7
|
+
export interface RouteCacheProps {
|
|
8
|
+
/**
|
|
9
|
+
* Number of recently-visited URLs to keep mounted (but hidden) so the entire state of their subtree —
|
|
10
|
+
* scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs,
|
|
11
|
+
* focus — is restored intact when navigating back or forward to them.
|
|
12
|
+
* - Defaults to `10`. Once the limit is reached the least-recently-visited entry is unmounted.
|
|
13
|
+
* - Set to `0` (or less) to disable caching and unmount the subtree as you leave each URL.
|
|
14
|
+
*/
|
|
15
|
+
readonly maxCached?: number | undefined;
|
|
16
|
+
/** The content to render for the current URL and keep alive (hidden) for recently-visited URLs. */
|
|
17
|
+
readonly children: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
|
|
21
|
+
*
|
|
22
|
+
* - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
|
|
23
|
+
* pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
|
|
24
|
+
* entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
|
|
25
|
+
* - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
|
|
26
|
+
* - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
|
|
27
|
+
* different page per path and a hidden page never re-renders for someone else's URL.
|
|
28
|
+
* - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
|
|
29
|
+
* subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
|
|
30
|
+
* - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
|
|
31
|
+
* scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
|
|
32
|
+
* outside the cache, so it is neither duplicated nor remounted on navigation.
|
|
33
|
+
* - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
|
|
34
|
+
*
|
|
35
|
+
* @kind component
|
|
36
|
+
* @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
|
|
37
|
+
* @param children The content region to render and cache (typically a layout's scrollable column).
|
|
38
|
+
* @returns The visible current page plus any cached pages kept alive but hidden.
|
|
39
|
+
* @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
|
|
40
|
+
* @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
|
|
41
|
+
*/
|
|
42
|
+
export declare function RouteCache({ maxCached, children }: RouteCacheProps): ReactNode;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Activity, useRef } from "react";
|
|
3
|
+
import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
|
|
4
|
+
/**
|
|
5
|
+
* Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
|
|
6
|
+
*
|
|
7
|
+
* - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
|
|
8
|
+
* pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
|
|
9
|
+
* entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
|
|
10
|
+
* - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
|
|
11
|
+
* - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
|
|
12
|
+
* different page per path and a hidden page never re-renders for someone else's URL.
|
|
13
|
+
* - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
|
|
14
|
+
* subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
|
|
15
|
+
* - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
|
|
16
|
+
* scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
|
|
17
|
+
* outside the cache, so it is neither duplicated nor remounted on navigation.
|
|
18
|
+
* - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
|
|
19
|
+
*
|
|
20
|
+
* @kind component
|
|
21
|
+
* @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
|
|
22
|
+
* @param children The content region to render and cache (typically a layout's scrollable column).
|
|
23
|
+
* @returns The visible current page plus any cached pages kept alive but hidden.
|
|
24
|
+
* @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
|
|
25
|
+
* @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
|
|
26
|
+
*/
|
|
27
|
+
export function RouteCache({ maxCached = 10, children }) {
|
|
28
|
+
// Read the live URL from context; each navigation re-renders this with the new path.
|
|
29
|
+
const meta = requireMetaURL();
|
|
30
|
+
const mapRef = useRef(undefined);
|
|
31
|
+
const usedRef = useRef(0);
|
|
32
|
+
// Snapshot the children under the live URL (frozen) so each kept-alive copy keeps rendering for the URL
|
|
33
|
+
// it was captured at — the same `children` element resolves a different page per path.
|
|
34
|
+
const node = _jsx(MetaContext, { value: meta, children: children });
|
|
35
|
+
// Caching disabled — render the page directly so it unmounts as soon as you leave it.
|
|
36
|
+
if (maxCached <= 0)
|
|
37
|
+
return node;
|
|
38
|
+
// Insert or refresh the current page, then evict the least-recently-used pages beyond the limit.
|
|
39
|
+
const { path } = meta;
|
|
40
|
+
const map = (mapRef.current ??= new Map());
|
|
41
|
+
const used = ++usedRef.current;
|
|
42
|
+
const entry = map.get(path);
|
|
43
|
+
if (entry) {
|
|
44
|
+
entry.node = node;
|
|
45
|
+
entry.used = used;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
map.set(path, { node, used });
|
|
49
|
+
}
|
|
50
|
+
while (map.size > maxCached)
|
|
51
|
+
map.delete(_findLeastRecentlyUsed(map));
|
|
52
|
+
// Render every cached page; only the current `path` is visible, the rest are kept alive but hidden.
|
|
53
|
+
return Array.from(map, ([key, cached]) => (_jsx(Activity, { mode: key === path ? "visible" : "hidden", children: cached.node }, key)));
|
|
54
|
+
}
|
|
55
|
+
/** Find the key of the least-recently-used (lowest `used` tick) entry in the cache map. */
|
|
56
|
+
function _findLeastRecentlyUsed(map) {
|
|
57
|
+
let lruKey;
|
|
58
|
+
let lruUsed = Number.POSITIVE_INFINITY;
|
|
59
|
+
for (const [key, entry] of map) {
|
|
60
|
+
if (entry.used < lruUsed) {
|
|
61
|
+
lruUsed = entry.used;
|
|
62
|
+
lruKey = key;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return lruKey;
|
|
66
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# RouteCache
|
|
2
|
+
|
|
3
|
+
A keep-alive page cache. Drop it into a layout around its scrolling content region: it reads the current URL from the surrounding [`<Meta>`](/ui/MetaContext) context and keeps a handful of recently-visited pages mounted but *hidden* (using React's [`<Activity>`](https://react.dev/reference/react/Activity)), so navigating back or forward to a page restores its entire DOM and component state — scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs, focus — instead of remounting it fresh at the top.
|
|
4
|
+
|
|
5
|
+
Shelving's [`SidebarLayout`](/ui/SidebarLayout) and [`CenteredLayout`](/ui/CenteredLayout) already wrap their scrollable content column in one for you, so pages rendered inside them keep their state across navigation automatically. Reach for it by hand only when building a custom layout.
|
|
6
|
+
|
|
7
|
+
**Things to know:**
|
|
8
|
+
|
|
9
|
+
- Pages are kept in a least-recently-used map keyed by `path`. Once `maxCached` pages are retained the least-recently-visited one is unmounted (and loses its state). A never-seen or evicted page mounts fresh at the top.
|
|
10
|
+
- Pass `maxCached={0}` (or less) to disable caching entirely — the page renders directly and unmounts as soon as you leave it.
|
|
11
|
+
- `<Activity mode="hidden">` preserves a hidden page's *state* while unmounting its *effects*, so subscriptions, observers (e.g. infinite-scroll), and timers pause and resume cleanly as the page is hidden and shown.
|
|
12
|
+
- Each snapshot is frozen under its own `<Meta>` context, so the same single `children` element resolves a different page per path and a hidden page never re-renders for someone else's URL.
|
|
13
|
+
- For per-page **scroll** to be preserved the cache has to wrap the scroll container itself — which is why it lives in the layout (around the scrollable column) rather than below it inside the router. Surrounding chrome (sidebar, drawer state) stays outside the cache, so it is neither duplicated nor remounted on navigation.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
import { RouteCache } from "shelving/ui";
|
|
19
|
+
|
|
20
|
+
<SidebarLayout sidebar={<Menu/>}>
|
|
21
|
+
<RouteCache>
|
|
22
|
+
<Router routes={routes}/>
|
|
23
|
+
</RouteCache>
|
|
24
|
+
</SidebarLayout>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## See also
|
|
28
|
+
|
|
29
|
+
- [`SidebarLayout`](/ui/SidebarLayout) / [`CenteredLayout`](/ui/CenteredLayout) — wrap their scrollable content column in a `<RouteCache>` for you
|
|
30
|
+
- [`Router`](/ui/Router) — matches a URL to a route; render it inside a `<RouteCache>` to keep pages alive
|
|
31
|
+
- [`Navigation`](/ui/Navigation) — drives the URL changes that move between cached pages
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { Activity, type ReactNode, useRef } from "react";
|
|
2
|
+
import type { AbsolutePath } from "../../util/path.js";
|
|
3
|
+
import { MetaContext, requireMetaURL } from "../misc/MetaContext.js";
|
|
4
|
+
|
|
5
|
+
/** A cached page: the rendered node plus a monotonic `used` tick for least-recently-used eviction. */
|
|
6
|
+
interface CacheEntry {
|
|
7
|
+
node: ReactNode;
|
|
8
|
+
used: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Props for `<RouteCache>` — the `maxCached` size and the content `children` to keep alive per URL.
|
|
13
|
+
*
|
|
14
|
+
* @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCacheProps
|
|
15
|
+
*/
|
|
16
|
+
export interface RouteCacheProps {
|
|
17
|
+
/**
|
|
18
|
+
* Number of recently-visited URLs to keep mounted (but hidden) so the entire state of their subtree —
|
|
19
|
+
* scroll position of every scroll container, open/closed toggles, in-progress searches, form inputs,
|
|
20
|
+
* focus — is restored intact when navigating back or forward to them.
|
|
21
|
+
* - Defaults to `10`. Once the limit is reached the least-recently-visited entry is unmounted.
|
|
22
|
+
* - Set to `0` (or less) to disable caching and unmount the subtree as you leave each URL.
|
|
23
|
+
*/
|
|
24
|
+
readonly maxCached?: number | undefined;
|
|
25
|
+
/** The content to render for the current URL and keep alive (hidden) for recently-visited URLs. */
|
|
26
|
+
readonly children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Keep-alive page cache keyed by the current URL — drop it into a layout around its scrolling content region.
|
|
31
|
+
*
|
|
32
|
+
* - Reads the live URL from the surrounding `<Meta>` context and keeps up to `maxCached` recently-visited
|
|
33
|
+
* pages mounted but hidden (via React's `<Activity>`), so navigating back/forward to a page restores its
|
|
34
|
+
* entire DOM and component state — scroll position, toggles, searches, inputs, focus — untouched.
|
|
35
|
+
* - Pages are kept in a least-recently-used map keyed by `path`; the oldest is unmounted past the limit.
|
|
36
|
+
* - Each snapshot is frozen under its own `<MetaContext>`, so the same single `children` element resolves a
|
|
37
|
+
* different page per path and a hidden page never re-renders for someone else's URL.
|
|
38
|
+
* - `<Activity mode="hidden">` preserves a hidden page's state while unmounting its effects, so its
|
|
39
|
+
* subscriptions/observers/timers pause and resume cleanly as it is hidden and shown.
|
|
40
|
+
* - Because it wraps the scroll container itself (rather than sitting below it inside the router), the
|
|
41
|
+
* scroll position of every cached page is preserved — surrounding chrome (sidebar, drawer state) stays
|
|
42
|
+
* outside the cache, so it is neither duplicated nor remounted on navigation.
|
|
43
|
+
* - When `maxCached <= 0` the page is rendered directly with no caching (it unmounts as you leave it).
|
|
44
|
+
*
|
|
45
|
+
* @kind component
|
|
46
|
+
* @param maxCached Number of recently-visited URLs to keep alive (defaults to `10`; `0` disables caching).
|
|
47
|
+
* @param children The content region to render and cache (typically a layout's scrollable column).
|
|
48
|
+
* @returns The visible current page plus any cached pages kept alive but hidden.
|
|
49
|
+
* @example <SidebarLayout sidebar={<Menu />}><RouteCache><Router … /></RouteCache></SidebarLayout>
|
|
50
|
+
* @see https://dhoulb.github.io/shelving/ui/router/RouteCache/RouteCache
|
|
51
|
+
*/
|
|
52
|
+
export function RouteCache({ maxCached = 10, children }: RouteCacheProps): ReactNode {
|
|
53
|
+
// Read the live URL from context; each navigation re-renders this with the new path.
|
|
54
|
+
const meta = requireMetaURL();
|
|
55
|
+
const mapRef = useRef<Map<AbsolutePath, CacheEntry>>(undefined);
|
|
56
|
+
const usedRef = useRef(0);
|
|
57
|
+
|
|
58
|
+
// Snapshot the children under the live URL (frozen) so each kept-alive copy keeps rendering for the URL
|
|
59
|
+
// it was captured at — the same `children` element resolves a different page per path.
|
|
60
|
+
const node = <MetaContext value={meta}>{children}</MetaContext>;
|
|
61
|
+
|
|
62
|
+
// Caching disabled — render the page directly so it unmounts as soon as you leave it.
|
|
63
|
+
if (maxCached <= 0) return node;
|
|
64
|
+
|
|
65
|
+
// Insert or refresh the current page, then evict the least-recently-used pages beyond the limit.
|
|
66
|
+
const { path } = meta;
|
|
67
|
+
const map = (mapRef.current ??= new Map());
|
|
68
|
+
const used = ++usedRef.current;
|
|
69
|
+
const entry = map.get(path);
|
|
70
|
+
if (entry) {
|
|
71
|
+
entry.node = node;
|
|
72
|
+
entry.used = used;
|
|
73
|
+
} else {
|
|
74
|
+
map.set(path, { node, used });
|
|
75
|
+
}
|
|
76
|
+
while (map.size > maxCached) map.delete(_findLeastRecentlyUsed(map));
|
|
77
|
+
|
|
78
|
+
// Render every cached page; only the current `path` is visible, the rest are kept alive but hidden.
|
|
79
|
+
return Array.from(map, ([key, cached]) => (
|
|
80
|
+
<Activity key={key} mode={key === path ? "visible" : "hidden"}>
|
|
81
|
+
{cached.node}
|
|
82
|
+
</Activity>
|
|
83
|
+
));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Find the key of the least-recently-used (lowest `used` tick) entry in the cache map. */
|
|
87
|
+
function _findLeastRecentlyUsed(map: Map<AbsolutePath, CacheEntry>): AbsolutePath {
|
|
88
|
+
let lruKey!: AbsolutePath;
|
|
89
|
+
let lruUsed = Number.POSITIVE_INFINITY;
|
|
90
|
+
for (const [key, entry] of map) {
|
|
91
|
+
if (entry.used < lruUsed) {
|
|
92
|
+
lruUsed = entry.used;
|
|
93
|
+
lruKey = key;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return lruKey;
|
|
97
|
+
}
|
package/ui/router/Router.md
CHANGED
|
@@ -8,6 +8,7 @@ A pure URL matcher: it reads the current URL from the surrounding `<Meta>` conte
|
|
|
8
8
|
- `<Router>` accepts `PossibleMeta` props (`url`, `base`, etc.) to override the surrounding context — this is how nested routers scope themselves.
|
|
9
9
|
- With a `base` set, the path used for matching is the URL after `matchURLPrefix` strips the base prefix; URLs outside the base render as `null`.
|
|
10
10
|
- Pass `fallback` to control no-match behaviour. An explicit `null` renders nothing; leaving it `undefined` throws a `NotFoundError`.
|
|
11
|
+
- `cache` (default `10`) keeps recently-visited pages mounted but hidden so back/forward navigation restores their state — see [Keeping page state](#keeping-page-state).
|
|
11
12
|
|
|
12
13
|
## Usage
|
|
13
14
|
|
|
@@ -119,6 +120,30 @@ const SIDEBARRED_ROUTES = {
|
|
|
119
120
|
}}/>
|
|
120
121
|
```
|
|
121
122
|
|
|
123
|
+
### Keeping page state
|
|
124
|
+
|
|
125
|
+
By default `<Router>` unmounts a page when you navigate away and mounts a fresh one when you return — so scroll position, open/closed toggles, in-progress searches, form inputs, and focus are all lost, and you land back at the top.
|
|
126
|
+
|
|
127
|
+
The `cache` prop keeps recently-visited pages mounted but hidden (using React's [`<Activity>`](https://react.dev/reference/react/Activity)), so navigating back or forward to a page restores its entire DOM and component state untouched — no per-feature scroll-capturing or state serialisation required.
|
|
128
|
+
|
|
129
|
+
```tsx
|
|
130
|
+
// Keep the last 10 visited pages alive (the default).
|
|
131
|
+
<Router routes={ROUTES}/>
|
|
132
|
+
|
|
133
|
+
// Keep more pages, at the cost of more retained DOM/memory.
|
|
134
|
+
<Router routes={ROUTES} cache={25}/>
|
|
135
|
+
|
|
136
|
+
// Opt out — unmount each page as you leave it (original behaviour).
|
|
137
|
+
<Router routes={ROUTES} cache={0}/>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**Things to know:**
|
|
141
|
+
|
|
142
|
+
- Pages are keyed by their matched `path`; once `cache` pages are retained the least-recently-visited one is unmounted (and so loses its state). Visiting a never-seen or evicted page mounts it fresh at the top.
|
|
143
|
+
- Each cached page is wrapped in its own frozen `<Meta>` context, so hidden pages never re-render for the current URL.
|
|
144
|
+
- `<Activity mode="hidden">` unmounts a hidden page's _effects_ while preserving its state, so subscriptions, observers (e.g. infinite-scroll), and timers pause politely and resume when the page is shown again.
|
|
145
|
+
- For per-page scroll to be preserved, each page must own its scroll container (the scrollable element must live _inside_ the route, not in a shared layout wrapper that all routes render into).
|
|
146
|
+
|
|
122
147
|
### SSR / static rendering
|
|
123
148
|
|
|
124
149
|
`<Router>` re-renders when context changes and needs no client. For static rendering set `url` and `root` on the outer wrapper and skip `<Navigation>`:
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
3
|
+
import { MetaContext } from "../misc/MetaContext.js";
|
|
4
|
+
import { createMeta } from "../util/meta.js";
|
|
5
|
+
import { Router } from "./Router.js";
|
|
6
|
+
|
|
7
|
+
const ROUTES = {
|
|
8
|
+
"/": () => <main>Home</main>,
|
|
9
|
+
"/about": () => <main>About</main>,
|
|
10
|
+
} as const;
|
|
11
|
+
|
|
12
|
+
function render(url: string) {
|
|
13
|
+
return renderToStaticMarkup(
|
|
14
|
+
<MetaContext value={createMeta({ root: "http://x.com/", url })}>
|
|
15
|
+
<Router routes={ROUTES} />
|
|
16
|
+
</MetaContext>,
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("Router", () => {
|
|
21
|
+
test("renders the matched route", () => {
|
|
22
|
+
expect(render("./about")).toContain("About");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("throws when no route matches and no fallback is given", () => {
|
|
26
|
+
expect(() => render("./missing")).toThrow();
|
|
27
|
+
});
|
|
28
|
+
});
|
package/ui/router/index.d.ts
CHANGED
package/ui/router/index.js
CHANGED