@ashley-shrok/viewmodel-shell 3.6.2 → 3.7.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/dist/browser.js +42 -0
- package/dist/index.d.ts +2 -0
- package/package.json +1 -1
package/dist/browser.js
CHANGED
|
@@ -36,6 +36,12 @@ const noopStateAccess = {
|
|
|
36
36
|
read: () => undefined,
|
|
37
37
|
write: () => { },
|
|
38
38
|
};
|
|
39
|
+
// SectionNode.followTail — a `[data-follow-tail]` element counts as "at the
|
|
40
|
+
// bottom" (and should stay pinned to the newest content on re-render) when it
|
|
41
|
+
// is within this many pixels of the bottom. A small tolerance so sub-pixel
|
|
42
|
+
// rounding and being ~a line off the bottom still counts as "following"; scroll
|
|
43
|
+
// up past it and the adapter respects the user reading history instead.
|
|
44
|
+
const FOLLOW_TAIL_STICK_THRESHOLD_PX = 40;
|
|
39
45
|
export class BrowserAdapter {
|
|
40
46
|
container;
|
|
41
47
|
fileRegistry = new Map();
|
|
@@ -74,9 +80,26 @@ export class BrowserAdapter {
|
|
|
74
80
|
const winScrollY = window.scrollY;
|
|
75
81
|
const scrollMap = new Map();
|
|
76
82
|
this.container.querySelectorAll("[id]").forEach(el => {
|
|
83
|
+
// follow-tail elements own their own restore (see below) — the generic
|
|
84
|
+
// preserve-the-prior-scrollTop contract is exactly what they must NOT do.
|
|
85
|
+
if (el.hasAttribute("data-follow-tail"))
|
|
86
|
+
return;
|
|
77
87
|
if (el.scrollTop !== 0 || el.scrollLeft !== 0)
|
|
78
88
|
scrollMap.set(el.id, { top: el.scrollTop, left: el.scrollLeft });
|
|
79
89
|
});
|
|
90
|
+
// SectionNode.followTail — snapshot, in document order, whether each
|
|
91
|
+
// append-only feed was scrolled near its bottom (and its prior scrollTop
|
|
92
|
+
// for the scrolled-up case). Ordinal-matched to the post-render walk below,
|
|
93
|
+
// the same stable-order approach as the collapsible-section snapshot; a
|
|
94
|
+
// brand-new feed has no entry at its ordinal and is pinned to the bottom.
|
|
95
|
+
const followTail = [];
|
|
96
|
+
this.container.querySelectorAll("[data-follow-tail]").forEach(el => {
|
|
97
|
+
const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
98
|
+
followTail.push({
|
|
99
|
+
nearBottom: distanceFromBottom <= FOLLOW_TAIL_STICK_THRESHOLD_PX,
|
|
100
|
+
top: el.scrollTop,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
80
103
|
// 1.2.0 — snapshot collapsible-section open state by stable key. Same
|
|
81
104
|
// pattern as focusId/scrollMap above: capture before innerHTML wipe, walk
|
|
82
105
|
// the rebuilt tree after node() returns, restore matching keys. Reset
|
|
@@ -116,6 +139,19 @@ export class BrowserAdapter {
|
|
|
116
139
|
el.scrollLeft = left;
|
|
117
140
|
}
|
|
118
141
|
});
|
|
142
|
+
// SectionNode.followTail restore — runs AFTER the generic scrollMap restore
|
|
143
|
+
// so it wins on any element carrying both an id and data-follow-tail. Walk
|
|
144
|
+
// the rebuilt feeds in document order and match them to the pre-render
|
|
145
|
+
// snapshot by ordinal: a feed that WAS near the bottom (or is brand new, no
|
|
146
|
+
// snapshot at its ordinal) is pinned to the NEW bottom so freshly appended
|
|
147
|
+
// content is visible; a feed the user had scrolled up in keeps its place.
|
|
148
|
+
this.container.querySelectorAll("[data-follow-tail]").forEach((el, i) => {
|
|
149
|
+
const snap = followTail[i];
|
|
150
|
+
if (!snap || snap.nearBottom)
|
|
151
|
+
el.scrollTop = el.scrollHeight;
|
|
152
|
+
else
|
|
153
|
+
el.scrollTop = snap.top;
|
|
154
|
+
});
|
|
119
155
|
// Only restore window scroll when the page was actually scrolled — restoring
|
|
120
156
|
// to (0,0) is a no-op, and skipping it avoids jsdom's noisy "Not implemented:
|
|
121
157
|
// window.scrollTo" virtual-console log in unit tests (jsdom never scrolls, so
|
|
@@ -474,6 +510,12 @@ export class BrowserAdapter {
|
|
|
474
510
|
}
|
|
475
511
|
const el = document.createElement("section");
|
|
476
512
|
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
513
|
+
// SectionNode.followTail — mark this as an append-only feed so render()'s
|
|
514
|
+
// snapshot/restore keeps its newest content in view (see render() + the
|
|
515
|
+
// FOLLOW_TAIL_STICK_THRESHOLD_PX constant). No CSS/class — the scroll comes
|
|
516
|
+
// from the element already being an overflow region (pair with fill).
|
|
517
|
+
if (n.followTail === true)
|
|
518
|
+
el.dataset.followTail = "";
|
|
477
519
|
if (n.heading) {
|
|
478
520
|
const h = document.createElement("h2");
|
|
479
521
|
h.className = "vms-section__heading";
|
package/dist/index.d.ts
CHANGED
|
@@ -110,6 +110,8 @@ export interface SectionNode {
|
|
|
110
110
|
layout?: "stack" | "split" | "cards" | "sidebar" | "row" | "switcher";
|
|
111
111
|
/** When true (and inside a `fill` page) this section takes the remaining column height and scrolls internally (`flex:1 1 auto; min-height:0; overflow-y:auto`) — the body region of a full-height app shell (e.g. a chat transcript above a pinned composer). Orthogonal to `layout` — a fill section still arranges its own children via `layout`. Outside a `fill` page it's a harmless no-op (the modifier class is inert without a `100dvh` parent). Absent/false = byte-identical to today. */
|
|
112
112
|
fill?: boolean;
|
|
113
|
+
/** When true, this section is a "follow the tail" scroll region — an append-only feed that keeps its NEWEST content in view across re-renders, unless the user has scrolled up to read history. The general primitive for a growing transcript: a chat log above a pinned composer, a live `tail -f` log view, an activity/audit stream, streamed job output. It exists because the default scroll-preservation contract (0.7.1/#7 — restore the prior `scrollTop`) is INVERTED for a growing feed: the old bottom becomes mid-scroll once taller content is appended, so the newest content silently ends up off-screen. Pure client-side render behavior (the server stays stateless — scroll position never rides the wire): the BrowserAdapter records, before each re-render, whether this element was within a small threshold of the bottom; after the re-render it pins a near-bottom element to the NEW bottom (`scrollTop = scrollHeight`) and leaves a scrolled-up element exactly where the user parked it. The decision is a pure function of the feed's scroll position at render time — `render()` doesn't know (or care) what triggered it, so a background poll, an SSE push, and the user's own form submit all follow the same rule; a genuinely scrolled-up feed is never hijacked. (When a chat visibly jumps to the bottom on the user's OWN send, that's the send interaction scrolling to the bottom before the re-render — the standard "your message pulls you down" UX — which the feed then correctly reads as at-bottom, not a special case here.) A brand-new follow-tail section starts pinned to the bottom (opens at the latest message). Emits `data-follow-tail` (no CSS — the scroll comes from the element already being an overflow region), so it's meant to pair with `fill` (which provides the internal `overflow-y:auto`) or any app that makes the section scroll; on a non-scrolling element it's an inert no-op. Orthogonal to `fill` and `layout`. The TUI ignores it (terminals follow naturally). Absent/false = byte-identical to today's preserve-my-place restore. */
|
|
114
|
+
followTail?: boolean;
|
|
113
115
|
/** Main-axis arrangement for `layout:"row"` (the cluster primitive) — maps to `justify-content`. Omitted = no class → the row default (`flex-start`, left-pack) holds = byte-identical to today. Closed union copied verbatim from Jetpack Compose `Arrangement` ∩ Flutter `MainAxisAlignment` (ALIGN-01). Emits .vms-arrange--{value}. */
|
|
114
116
|
arrange?: "start" | "center" | "end" | "space-between" | "space-around" | "space-evenly";
|
|
115
117
|
/** Cross-axis alignment for `layout:"row"` (the cluster primitive) — maps to `align-items`. Omitted = no class → the row default (`center`) holds = byte-identical to today. Closed union copied verbatim from Flutter `CrossAxisAlignment` (ALIGN-02). Emits .vms-align--{value}. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.0",
|
|
4
4
|
"description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|