@zerotal/flow 1.3.0 → 1.4.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/CHANGELOG.md CHANGED
@@ -10,6 +10,45 @@ between minor versions.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [1.4.0] — 2026-08-10
14
+
15
+ ### Fixed
16
+
17
+ - **`focusOnError` did nothing on a page that rendered through the runtime.** The
18
+ JSX runtime rewrites `-` to `.` inside a `flow:` attribute — right for a
19
+ hand-written `flow:loading-class`, wrong for a name the directive map already
20
+ spells out. `focusOnError` reached the DOM as `flow:focus.error` while the
21
+ bridge only ever queries `[flow\:focus-error]`, so focus-on-error worked on a
22
+ compiled page and silently did not on one the compiler bailed out of (a
23
+ branching `render()`, say). Mapped names now go out verbatim; the kebab→dot
24
+ rewrite still applies to attributes written by hand. `sortGroupId` was affected
25
+ the same way.
26
+
27
+ - **`flow:navigate` now scrolls, instead of leaving you wherever the last page
28
+ was.** The SPA swap replaced the page under a stationary viewport and never
29
+ touched the scroll offset, so following a link from near the bottom of a long
30
+ list landed you halfway down the next page. Nothing visible changed, which
31
+ reads as the page having failed to load rather than as a scroll problem.
32
+
33
+ A navigation now lands at the top of the new page, or at the fragment when the
34
+ href names one (`/docs#install`) — which is what the same link does without the
35
+ SPA swap. Back and Forward restore the position the entry was left at.
36
+
37
+ The scroll happens inside the swap, after the root is replaced: the document
38
+ only has its new height (and its fragment targets) at that point, and inside a
39
+ View Transition it is also what the "after" snapshot captures, so the animation
40
+ ends at the right offset rather than sliding to it. It is issued with
41
+ `behavior: "instant"`, so a page whose CSS sets `scroll-behavior: smooth` does
42
+ not animate the whole way up on every visit.
43
+
44
+ ### Added
45
+
46
+ - **`preserveScroll`** — `<Link href="…" preserveScroll>` and
47
+ `this.navigateCurrent({ query: …, preserveScroll: true })` leave the viewport
48
+ where it is. For a control partway down a page — a sort header, a filter, a tab
49
+ strip — going to the top loses the thing the user was just looking at. Compiles
50
+ to `flow:navigate.preserve` on the anchor.
51
+
13
52
  ## [1.3.0] — 2026-08-09
14
53
 
15
54
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/flow",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "license": "MIT",
5
5
  "maturity": "experimental",
6
6
  "private": false,
@@ -45,14 +45,14 @@
45
45
  "@alpinejs/morph": "^3.14.9",
46
46
  "@alpinejs/persist": "^3.14.9",
47
47
  "@alpinejs/resize": "^3.14.9",
48
- "@zerotal/core": "1.3.0",
49
- "@zerotal/orm": "1.3.0",
50
- "@zerotal/validator": "1.3.0",
48
+ "@zerotal/core": "1.4.0",
49
+ "@zerotal/orm": "1.4.0",
50
+ "@zerotal/validator": "1.4.0",
51
51
  "alpinejs": "^3.14.9",
52
52
  "typescript": "^5.8.0"
53
53
  },
54
54
  "devDependencies": {
55
- "@zerotal/broadcasting": "1.3.0",
55
+ "@zerotal/broadcasting": "1.4.0",
56
56
  "@types/alpinejs": "^3.13.10",
57
57
  "@types/alpinejs__anchor": "^3.13.1",
58
58
  "@types/alpinejs__collapse": "^3.13.4",
@@ -865,7 +865,11 @@ function _syncUrlParams(comp: FlowComponent): void {
865
865
 
866
866
  if (newUrl !== location.pathname + location.search + location.hash) {
867
867
  if (pushed) {
868
- history.pushState({ flowNavigate: true }, "", newUrl);
868
+ // Bank the scroll offset first: this entry is about to become a Back
869
+ // target, and the viewport has not moved, so where the user is now is
870
+ // where Back should return them.
871
+ _rememberScroll();
872
+ _pushHistoryEntry(newUrl);
869
873
  } else {
870
874
  history.replaceState(history.state, "", newUrl);
871
875
  }
@@ -2357,7 +2361,127 @@ function _takePrefetched(href: string): string | null {
2357
2361
  return Date.now() - cached.at < PREFETCH_TTL_MS ? cached.html : null;
2358
2362
  }
2359
2363
 
2360
- async function _navigateTo(href: string, pushState = true): Promise<void> {
2364
+ // ── Scroll position across SPA navigations ────────────────────────────────────
2365
+ //
2366
+ // A real navigation puts you at the top of the new page, and Back puts you back
2367
+ // where you were. Swapping the DOM under a stationary viewport does neither: you
2368
+ // click a link near the bottom of a long list and land halfway down the next
2369
+ // page, which reads as the page having failed to load.
2370
+ //
2371
+ // Positions live in a map keyed by history entry rather than inside
2372
+ // `history.state`, because the entry you are *leaving* on a Back cannot be
2373
+ // stamped — by the time `popstate` fires, `history.state` is already the
2374
+ // destination's. A key per entry lets both directions be recorded.
2375
+
2376
+ let _nextHistoryKey = 1;
2377
+ let _currentHistoryKey = 0;
2378
+ const _scrollByHistoryKey = new Map<number, [number, number]>();
2379
+
2380
+ /**
2381
+ * The live scroll offset, tracked passively.
2382
+ *
2383
+ * Read from here rather than from `window.scrollY` at navigation time: on a
2384
+ * `popstate` the browser may already have applied its own scroll restoration
2385
+ * before our handler runs, and by then the outgoing page's real position is gone.
2386
+ */
2387
+ let _liveScroll: [number, number] = [0, 0];
2388
+
2389
+ function _trackScroll(): void {
2390
+ const update = (): void => {
2391
+ _liveScroll = [window.scrollX, window.scrollY];
2392
+ };
2393
+ update();
2394
+ window.addEventListener("scroll", update, { passive: true });
2395
+ }
2396
+
2397
+ /** Record where the current history entry was left, so Back can return to it. */
2398
+ function _rememberScroll(): void {
2399
+ _scrollByHistoryKey.set(_currentHistoryKey, _liveScroll);
2400
+ }
2401
+
2402
+ /** Start a new history entry, keyed so its scroll position can be found again. */
2403
+ function _pushHistoryEntry(url: string): void {
2404
+ _currentHistoryKey = _nextHistoryKey++;
2405
+ history.pushState({ flowNavigate: true, flowKey: _currentHistoryKey }, "", url);
2406
+ }
2407
+
2408
+ /** Where a navigation should leave the viewport. */
2409
+ export type ScrollIntent = "top" | "preserve" | readonly [number, number];
2410
+
2411
+ /** The fragment of an href, without the `#`. Empty when it has none. */
2412
+ export function _hashOf(href: string): string {
2413
+ const at = href.indexOf("#");
2414
+ return at === -1 ? "" : href.slice(at + 1);
2415
+ }
2416
+
2417
+ /**
2418
+ * Resolve what a navigation should do to the viewport.
2419
+ *
2420
+ * A recorded position wins outright: on Back/Forward the whole point is to land
2421
+ * where the user was, and `preserveScroll` is passed alongside it only as the
2422
+ * fallback for an entry with nothing recorded.
2423
+ */
2424
+ export function _scrollIntent(options: {
2425
+ preserveScroll?: boolean;
2426
+ restoreScroll?: readonly [number, number] | undefined;
2427
+ }): ScrollIntent {
2428
+ return options.restoreScroll ?? (options.preserveScroll ? "preserve" : "top");
2429
+ }
2430
+
2431
+ /** What {@link _applyScroll} will do, separated out so the decision is testable. */
2432
+ export type ScrollAction =
2433
+ | { kind: "none" }
2434
+ | { kind: "offset"; left: number; top: number }
2435
+ | { kind: "fragment"; id: string };
2436
+
2437
+ export function _scrollAction(intent: ScrollIntent, hash: string): ScrollAction {
2438
+ if (intent === "preserve") return { kind: "none" };
2439
+ if (intent !== "top") return { kind: "offset", left: intent[0], top: intent[1] };
2440
+
2441
+ // A fragment in the URL beats the top of the page — that is what the browser
2442
+ // would have done for the same link without the SPA swap.
2443
+ let id = hash;
2444
+ if (id) {
2445
+ try {
2446
+ id = decodeURIComponent(id);
2447
+ } catch {
2448
+ /* malformed %-escape — take the fragment literally, as browsers do */
2449
+ }
2450
+ }
2451
+ return id ? { kind: "fragment", id } : { kind: "offset", left: 0, top: 0 };
2452
+ }
2453
+
2454
+ function _applyScroll(intent: ScrollIntent, hash: string): void {
2455
+ const action = _scrollAction(intent, hash);
2456
+ if (action.kind === "none") return;
2457
+
2458
+ // `behavior: "instant"` on purpose: a page with `scroll-behavior: smooth` in
2459
+ // its CSS would otherwise animate the whole way up on every navigation, which
2460
+ // on a long page is a visible scroll-back the user did not ask for.
2461
+ if (action.kind === "offset") {
2462
+ window.scrollTo({ left: action.left, top: action.top, behavior: "instant" });
2463
+ return;
2464
+ }
2465
+
2466
+ const target = document.getElementById(action.id);
2467
+ // A fragment naming nothing on the page lands at the top, same as a real load.
2468
+ if (target) target.scrollIntoView({ behavior: "instant", block: "start" });
2469
+ else window.scrollTo({ left: 0, top: 0, behavior: "instant" });
2470
+ }
2471
+
2472
+ /** Options for {@link _navigateTo}. */
2473
+ interface NavigateOptions {
2474
+ /** Add a history entry. False on Back/Forward, where the entry already exists. */
2475
+ push?: boolean;
2476
+ /** Leave the viewport where it is instead of going to the top of the new page. */
2477
+ preserveScroll?: boolean;
2478
+ /** Offsets to restore, for Back/Forward. Takes precedence over `preserveScroll`. */
2479
+ restoreScroll?: readonly [number, number] | undefined;
2480
+ }
2481
+
2482
+ async function _navigateTo(href: string, options: NavigateOptions = {}): Promise<void> {
2483
+ const { push = true } = options;
2484
+ const scrollIntent = _scrollIntent(options);
2361
2485
  let html: string;
2362
2486
  const prefetched = _takePrefetched(href);
2363
2487
  if (prefetched !== null) {
@@ -2391,8 +2515,9 @@ async function _navigateTo(href: string, pushState = true): Promise<void> {
2391
2515
  // document.write() here would re-inject the classic runtime <script> into the
2392
2516
  // *current* realm and redeclare its top-level bindings (e.g. `class
2393
2517
  // FlowComponent` → "already declared"). A hard navigation also re-bootstraps
2394
- // the client cleanly for the new layout.
2395
- if (pushState) window.location.assign(href);
2518
+ // the client cleanly for the new layout — including the scroll position,
2519
+ // which the browser owns on a real document load.
2520
+ if (push) window.location.assign(href);
2396
2521
  else window.location.reload();
2397
2522
  return;
2398
2523
  }
@@ -2417,6 +2542,12 @@ async function _navigateTo(href: string, pushState = true): Promise<void> {
2417
2542
  const incomingClone = incomingRoot.cloneNode(true) as HTMLElement;
2418
2543
  _carryPersisted(currentRoot, incomingClone);
2419
2544
 
2545
+ // Where this page is sitting, banked before the swap. Replacing the root
2546
+ // changes the document height, and the browser clamps the scroll offset
2547
+ // against it — read afterwards, a position near the bottom of a long page
2548
+ // comes back as somewhere near the bottom of the short one.
2549
+ if (push) _rememberScroll();
2550
+
2420
2551
  // Swap state script + component root. Wrapped in a View Transition when the
2421
2552
  // browser supports it, for a smooth cross-page animation; falls back to an
2422
2553
  // instant swap otherwise.
@@ -2427,6 +2558,11 @@ async function _navigateTo(href: string, pushState = true): Promise<void> {
2427
2558
  _cleanupDisconnected();
2428
2559
  _scanComponents();
2429
2560
  _processHead(doc); // re-apply head from the incoming page (shell stylesheet + page meta)
2561
+ // Inside the swap, not after it: the document only has its new height (and
2562
+ // its fragment targets) once the root is in place. Inside the View
2563
+ // Transition callback this is also what the "after" snapshot captures, so
2564
+ // the animation ends at the right offset instead of sliding there.
2565
+ _applyScroll(scrollIntent, _hashOf(href));
2430
2566
  };
2431
2567
  const _startVT = (document as unknown as { startViewTransition?: (cb: () => void) => unknown })
2432
2568
  .startViewTransition;
@@ -2435,7 +2571,7 @@ async function _navigateTo(href: string, pushState = true): Promise<void> {
2435
2571
  if (typeof _startVT === "function" && !_reduceMotion) _startVT.call(document, _swap);
2436
2572
  else _swap(); // reduced-motion (or unsupported): swap instantly, no cross-page animation
2437
2573
 
2438
- if (pushState) history.pushState({ flowNavigate: true }, "", href);
2574
+ if (push) _pushHistoryEntry(href);
2439
2575
  const incomingTitle = doc.querySelector("title");
2440
2576
  if (incomingTitle) document.title = incomingTitle.textContent ?? "";
2441
2577
  _updateCurrentLinks();
@@ -2542,14 +2678,22 @@ function _setupPrefetch(): void {
2542
2678
  function _setupNavigationLinks(): void {
2543
2679
  _updateCurrentLinks();
2544
2680
  _setupPrefetch();
2681
+ _trackScroll();
2545
2682
 
2546
2683
  // Stamp the initial history entry as ours. A fresh page load has `history.state === null`,
2547
2684
  // so without this, pressing Back to the first page fires `popstate` with a null state — the
2548
2685
  // handler below ignores it and the URL changes while the page stays put. Replacing (not
2549
- // pushing) keeps the entry in place; it just gains the flowNavigate marker.
2550
- if (!(history.state as { flowNavigate?: boolean } | null)?.flowNavigate) {
2686
+ // pushing) keeps the entry in place; it just gains the flowNavigate marker and its scroll key.
2687
+ const initial = history.state as { flowNavigate?: boolean; flowKey?: number } | null;
2688
+ if (typeof initial?.flowKey === "number") {
2689
+ // Restored from bfcache or a reload — keep the key so the entry's recorded
2690
+ // position still matches, and mint above it so a new entry can't collide.
2691
+ _currentHistoryKey = initial.flowKey;
2692
+ _nextHistoryKey = Math.max(_nextHistoryKey, initial.flowKey + 1);
2693
+ } else {
2694
+ _currentHistoryKey = _nextHistoryKey++;
2551
2695
  history.replaceState(
2552
- { ...(history.state as object | null), flowNavigate: true },
2696
+ { ...(initial as object | null), flowNavigate: true, flowKey: _currentHistoryKey },
2553
2697
  "",
2554
2698
  location.href,
2555
2699
  );
@@ -2566,13 +2710,31 @@ function _setupNavigationLinks(): void {
2566
2710
  return;
2567
2711
  }
2568
2712
  e.preventDefault();
2569
- void _navigateTo(href);
2713
+ void _navigateTo(href, {
2714
+ preserveScroll: anchor.hasAttribute("flow:navigate.preserve"),
2715
+ });
2570
2716
  });
2571
2717
 
2572
2718
  window.addEventListener("popstate", (e) => {
2573
- if ((e.state as { flowNavigate?: boolean } | null)?.flowNavigate) {
2574
- void _navigateTo(window.location.pathname + window.location.search, false);
2575
- }
2719
+ const state = e.state as { flowNavigate?: boolean; flowKey?: number } | null;
2720
+ if (!state?.flowNavigate) return;
2721
+
2722
+ // Bank the outgoing entry before the key moves on, so Forward lands correctly
2723
+ // too — not just Back. This is the half that cannot be done from
2724
+ // `history.state`, which by now already describes the destination.
2725
+ _rememberScroll();
2726
+ const destination = state.flowKey;
2727
+ const restore = destination === undefined ? undefined : _scrollByHistoryKey.get(destination);
2728
+ if (destination !== undefined) _currentHistoryKey = destination;
2729
+
2730
+ void _navigateTo(location.pathname + location.search + location.hash, {
2731
+ push: false,
2732
+ restoreScroll: restore,
2733
+ // Nothing recorded for this entry — it predates the runtime, or a reload
2734
+ // cleared the map. Leave the viewport alone rather than jumping to the
2735
+ // top, which is not what Back does.
2736
+ preserveScroll: restore === undefined,
2737
+ });
2576
2738
  });
2577
2739
  }
2578
2740
 
@@ -2584,12 +2746,21 @@ function _setupNavigationLinks(): void {
2584
2746
  * Both read the live URL from window.location (kept in sync by flow:navigate).
2585
2747
  * Ideal for instant filters, e.g.:
2586
2748
  * <select @change="$flow.navigateCurrent({ query: { status: $event.target.value || null } })">
2749
+ *
2750
+ * Like any navigation this lands at the top of the page. For a control sitting
2751
+ * partway down — a filter row the user is looking at while they change it —
2752
+ * pass `preserveScroll: true` to leave the viewport alone:
2753
+ * $flow.navigateCurrent({ query: { status: … }, preserveScroll: true })
2587
2754
  */
2588
2755
  function _currentUrl(options: CurrentUrlOptions = {}): string {
2589
2756
  return buildUrlWithQuery(window.location.href, options);
2590
2757
  }
2591
- function _navigateCurrent(options: CurrentUrlOptions = {}): Promise<void> {
2592
- return _navigateTo(buildUrlWithQuery(window.location.href, options));
2758
+ function _navigateCurrent(
2759
+ options: CurrentUrlOptions & { preserveScroll?: boolean } = {},
2760
+ ): Promise<void> {
2761
+ return _navigateTo(buildUrlWithQuery(window.location.href, options), {
2762
+ preserveScroll: options.preserveScroll ?? false,
2763
+ });
2593
2764
  }
2594
2765
 
2595
2766
  const _debouncedModelSend = (() => {
@@ -22,6 +22,7 @@ export const BOOLEAN_DIRECTIVES: Record<string, string> = {
22
22
  hideOnDirty: "flow:dirty.remove",
23
23
  navigate: "flow:navigate",
24
24
  navigateHover: "flow:navigate.hover",
25
+ navigatePreserveScroll: "flow:navigate.preserve",
25
26
  currentExact: "flow:current.exact",
26
27
  currentStrict: "flow:current.strict",
27
28
  cloak: "flow:cloak",
@@ -1634,6 +1634,10 @@ function _compileRenderMethod(
1634
1634
  emitStatic(" flow:navigate.hover");
1635
1635
  continue;
1636
1636
  }
1637
+ if (ln === "preserveScroll" && attr.initializer === undefined) {
1638
+ emitStatic(" flow:navigate.preserve");
1639
+ continue;
1640
+ }
1637
1641
  if (ln === "current") {
1638
1642
  const ci = attr.initializer;
1639
1643
  // current={false} → opt out of the auto active class; current={true} → default (skip).
package/src/components.ts CHANGED
@@ -22,6 +22,7 @@ export interface LinkProps {
22
22
  hover?: boolean;
23
23
  current?: boolean;
24
24
  exact?: boolean;
25
+ preserveScroll?: boolean;
25
26
  [key: string]: unknown;
26
27
  }
27
28
 
@@ -35,18 +36,26 @@ export interface LinkProps {
35
36
  * the automatic active state to an exact path match. Any other props pass through to
36
37
  * the `<a>`.
37
38
  *
39
+ * Following a link lands at the top of the new page, as a full navigation would —
40
+ * or at the fragment, if the href names one. `preserveScroll` leaves the viewport
41
+ * where it is instead, which is what you want for a control the user is looking at
42
+ * partway down a page (a filter, a sort header, a tab strip) rather than a link
43
+ * that takes them somewhere else.
44
+ *
38
45
  * @example
39
46
  * ```tsx
40
47
  * <Link href="/posts" hover>Posts</Link>
41
48
  * <Link href="/dashboard" exact>Dashboard</Link>
49
+ * <Link href="/posts?sort=title" preserveScroll>Title</Link>
42
50
  * ```
43
51
  *
44
52
  * @category Navigation & data
45
53
  */
46
54
  export function Link(props: LinkProps & { children?: unknown }): HtmlNode {
47
- const { href, hover, current, exact, children, ...rest } = props;
55
+ const { href, hover, current, exact, preserveScroll, children, ...rest } = props;
48
56
  const out: Record<string, unknown> = { ...rest, href, navigate: true, children };
49
57
  if (hover) out["navigateHover"] = true;
58
+ if (preserveScroll) out["navigatePreserveScroll"] = true;
50
59
  // `current` overrides the automatic URL-based active state:
51
60
  // false → never active (flow:current.ignore)
52
61
  // true → always active, and stays active across SPA navigations (flow:current.force).
@@ -59,6 +59,7 @@ interface FlowDirectives {
59
59
 
60
60
  navigate?: boolean;
61
61
  navigateHover?: boolean;
62
+ navigatePreserveScroll?: boolean;
62
63
  current?: string;
63
64
  currentExact?: boolean;
64
65
  currentStrict?: boolean;
@@ -704,6 +705,10 @@ const PULSE_PROP_MAP: Record<string, string> = {
704
705
  dirtyTarget: "flow:target",
705
706
  navigate: "flow:navigate",
706
707
  navigateHover: "flow:navigate.hover",
708
+ // One word on purpose: toFlowHtmlAttr() rewrites every `-` in a flow: attribute
709
+ // to a `.`, so a hyphenated modifier would reach the DOM under a different name
710
+ // here than the compiler emits.
711
+ navigatePreserveScroll: "flow:navigate.preserve",
707
712
  current: "flow:current",
708
713
  currentExact: "flow:current.exact",
709
714
  currentStrict: "flow:current.strict",
@@ -1154,8 +1159,14 @@ export function jsx(
1154
1159
  continue;
1155
1160
  }
1156
1161
 
1157
- let htmlKey = PULSE_PROP_MAP[k] || k;
1158
- htmlKey = toFlowHtmlAttr(htmlKey);
1162
+ // A mapped value is the attribute name the compiler emits and the bridge queries
1163
+ // for, so it goes out verbatim. Running it through toFlowHtmlAttr() rewrote the
1164
+ // hyphen in `flow:focus-error` to a dot, and the runtime path then emitted an
1165
+ // attribute nothing listens for: `focusOnError` worked on a compiled page and did
1166
+ // nothing at all on one that fell back to the runtime, with no error either way.
1167
+ // The kebab→dot rewrite is for hand-written `flow:*` attributes only.
1168
+ const mapped = PULSE_PROP_MAP[k];
1169
+ const htmlKey = mapped ?? toFlowHtmlAttr(k);
1159
1170
 
1160
1171
  // Special object formatting for `confirm={{ message, title?, confirm?, cancel?,
1161
1172
  // variant?, danger?, icon?, prompt? }}`. The bare {message} and {message,prompt}