@lesto/ui 0.1.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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * The soft-nav CONTRACT — the DOM-free constants and types both halves share.
3
+ *
4
+ * `<Link>` (isomorphic, `link.tsx`) and the runtime (`enableSoftNav`, `softnav.ts`,
5
+ * browser-only) both need the opt-out attribute name and the shape of an eligible
6
+ * anchor/click. Putting them here — a module that imports NOTHING from the DOM or
7
+ * `react-dom` — lets `Link` stay in `@lesto/ui`'s isomorphic core without dragging
8
+ * the client hydration runtime (`react-dom/client`) into a server build. The
9
+ * runtime re-exports these alongside its own seams, so a caller sees one surface.
10
+ */
11
+
12
+ /** The attribute that opts a link OUT of soft nav — forcing a full document reload. */
13
+ export const RELOAD_ATTR = "data-lesto-reload";
14
+
15
+ /**
16
+ * The attribute that opts a link INTO prefetch — warming the soft-nav fetch of its
17
+ * destination before the click, so the navigation feels instant. `<Link prefetch>`
18
+ * stamps it; the browser runtime ({@link PrefetchStrategy}-aware) reads it.
19
+ *
20
+ * Its VALUE is the {@link PrefetchStrategy}: `"viewport"` (prefetch when the link
21
+ * scrolls into view) or `"hover"` (prefetch on pointer-enter / focus). A bare
22
+ * `prefetch` (boolean `true`) renders the value `"viewport"` — the cheaper-by-default
23
+ * eager strategy peer routers use, since a hover is already late for an instant feel.
24
+ *
25
+ * DOM-free like {@link RELOAD_ATTR}: it is just an attribute name + value contract
26
+ * `<Link>` (isomorphic) writes and the runtime (browser) reads, so neither half
27
+ * dictates the other's environment.
28
+ */
29
+ export const PREFETCH_ATTR = "data-lesto-prefetch";
30
+
31
+ /**
32
+ * When a prefetch fires for a link. `"viewport"` warms the fetch when the anchor
33
+ * scrolls into view (eager, IntersectionObserver-driven); `"hover"` warms it on
34
+ * pointer-enter or keyboard focus (lazy, intent-driven). A bare `<Link prefetch>`
35
+ * is `"viewport"`.
36
+ */
37
+ export type PrefetchStrategy = "viewport" | "hover";
38
+
39
+ /**
40
+ * The attribute that marks a layout-boundary element in the streamed document, so a
41
+ * soft navigation can do a LAYOUT-PRESERVING PARTIAL SWAP: replace only the inner
42
+ * subtree that changed, keeping the outer layout DOM (and its island state) mounted.
43
+ *
44
+ * Its VALUE is the layout's depth as a decimal string (`"0"` outermost, growing
45
+ * inward), the `layoutDepth` the router already computes — so the runtime can align
46
+ * the live and fetched layout chains by a shared prefix of depths and swap only
47
+ * below the deepest common layout.
48
+ *
49
+ * The runtime treats this as ENTIRELY OPTIONAL: a document with no such markers
50
+ * falls back to the full-body swap (today's behavior), so this never becomes
51
+ * load-bearing. EMITTING the marker is the server renderer's job (`@lesto/web`'s
52
+ * `render-page.tsx` / the file-route layout composer) — until it does, the partial
53
+ * swap is dormant and the full swap runs, with no regression.
54
+ */
55
+ export const LAYOUT_ATTR = "data-lesto-layout";
56
+
57
+ /**
58
+ * Resolve a `<Link prefetch>` prop to the attribute VALUE to render, or `undefined`
59
+ * when the link does not prefetch.
60
+ *
61
+ * `true` → `"viewport"` (the eager default); an explicit `"viewport"`/`"hover"`
62
+ * passes through; `false`/`undefined` → `undefined` (no attribute). Pure, shared by
63
+ * `<Link>` (which writes the attribute) so the boolean-sugar rule lives in ONE place
64
+ * the runtime can trust.
65
+ */
66
+ export function prefetchAttrValue(
67
+ prefetch: boolean | PrefetchStrategy | undefined,
68
+ ): PrefetchStrategy | undefined {
69
+ if (prefetch === undefined || prefetch === false) return undefined;
70
+
71
+ return prefetch === true ? "viewport" : prefetch;
72
+ }
73
+
74
+ /**
75
+ * The minimal anchor surface soft nav reads — `HTMLAnchorElement` satisfies it.
76
+ * Kept narrow so a test can hand a plain object and the eligibility logic is
77
+ * exercised with no real DOM node.
78
+ */
79
+ export interface SoftNavAnchor {
80
+ /** The fully-resolved destination (`a.href`), absolute. */
81
+ href: string;
82
+
83
+ /** The link's `target` (`""` for same-frame). A named target declines soft nav. */
84
+ target: string;
85
+
86
+ /** Whether the link carries a `download` attribute — a download declines soft nav. */
87
+ hasDownload: boolean;
88
+
89
+ /** Whether the link opted out with {@link RELOAD_ATTR}. */
90
+ reload: boolean;
91
+ }
92
+
93
+ /**
94
+ * The slice of a click event soft nav inspects. A modifier click, a non-primary
95
+ * button, or an already-defaulted event all decline soft nav (the user asked for
96
+ * a new tab, or another handler already took the event).
97
+ */
98
+ export interface SoftNavClick {
99
+ button: number;
100
+ metaKey: boolean;
101
+ ctrlKey: boolean;
102
+ shiftKey: boolean;
103
+ altKey: boolean;
104
+ defaultPrevented: boolean;
105
+
106
+ /** Resolve the eligible anchor this click targets, or `undefined` if none. */
107
+ anchor: () => SoftNavAnchor | undefined;
108
+
109
+ /** Suppress the browser's default full navigation — called only when soft nav takes over. */
110
+ preventDefault: () => void;
111
+ }
112
+
113
+ /**
114
+ * Is this click one soft nav should handle? Every decline falls through to the
115
+ * browser's normal navigation, so the floor (a working link) always holds.
116
+ *
117
+ * Declined when: the event was already defaulted by another handler; it is not a
118
+ * plain primary-button click (a middle/right click or any modifier means "new
119
+ * tab / new window / save", the user's explicit ask); or no eligible anchor is in
120
+ * the target's ancestry. An eligible anchor is same-frame, not a download, not
121
+ * opted out — the cross-origin check is the caller's (it needs the page origin).
122
+ *
123
+ * Pure over its inputs (no DOM), so every decline branch is unit-testable with a
124
+ * plain object click.
125
+ */
126
+ export function eligibleAnchor(click: SoftNavClick): SoftNavAnchor | undefined {
127
+ // Another handler already took this event (a framework menu, a confirm dialog).
128
+ if (click.defaultPrevented) return undefined;
129
+
130
+ // Only a plain left click is a navigation; anything else is the user asking for
131
+ // a new tab/window or a context menu, which a soft swap must not steal.
132
+ if (click.button !== 0) return undefined;
133
+ if (click.metaKey || click.ctrlKey || click.shiftKey || click.altKey) return undefined;
134
+
135
+ const anchor = click.anchor();
136
+
137
+ if (anchor === undefined) return undefined;
138
+
139
+ // A named target (new frame/tab), a download, or an explicit opt-out all want
140
+ // the real navigation, not a same-document swap.
141
+ if (anchor.target !== "" || anchor.hasDownload || anchor.reload) return undefined;
142
+
143
+ return anchor;
144
+ }