@zerotal/flow 1.1.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 +89 -0
- package/README.md +1 -1
- package/package.json +5 -5
- package/src/Component.ts +50 -0
- package/src/client/bridge.ts +185 -14
- package/src/compiler/directives.ts +1 -0
- package/src/compiler/transform.ts +4 -0
- package/src/components.ts +10 -1
- package/src/decorators.ts +99 -5
- package/src/index.ts +5 -5
- package/src/jsx-runtime.ts +13 -2
- package/src/mixins.ts +121 -151
- package/src/pagination.ts +2 -2
- package/src/uploads/FileUploads.ts +2 -2
- package/src/uploads/TemporaryUploadedFile.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,95 @@ 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
|
+
|
|
52
|
+
## [1.3.0] — 2026-08-09
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **An `@expose`d action on a shared page base no longer disappears from the allowlist.** Bun
|
|
57
|
+
1.3.x does not reliably run a method decorator's `addInitializer` callbacks: when a subclass
|
|
58
|
+
declared a decorated field, the base class's method initializers never ran at all. So a page
|
|
59
|
+
base carrying `@expose guard()` lost that action the moment any subclass added one `@expose`
|
|
60
|
+
field — and since the un-`@expose`d action check is fatal, clicking a perfectly legitimate
|
|
61
|
+
button was rejected at runtime with a message pointing at the wrong cause. It reproduced with a
|
|
62
|
+
bare `class Sub extends Base` and no mixin anywhere. `@expose` (methods), `@task`,
|
|
63
|
+
`@renderless`, `@on` and `@computed` were all affected.
|
|
64
|
+
|
|
65
|
+
Method and getter decorators now tag the function in the decorator body — which always runs,
|
|
66
|
+
with the right name — and the readers scan the prototype chain for tagged members, registering
|
|
67
|
+
against the **declaring** prototype so inheritance resolves through the existing chain walk.
|
|
68
|
+
Registration no longer requires an instance to have been constructed, so `getExposedMethods()`
|
|
69
|
+
is correct on a class that has never been instantiated.
|
|
70
|
+
|
|
71
|
+
### Changed — BREAKING
|
|
72
|
+
|
|
73
|
+
- **`ComponentWith(...)` is replaced by the `Component.using(...)` static.** Mixin composition is
|
|
74
|
+
now a property of the base class rather than a helper shipped alongside it, so there is one
|
|
75
|
+
idiom to learn and nothing extra to import.
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
// before
|
|
79
|
+
import { ComponentWith, Pagination } from "@zerotal/flow";
|
|
80
|
+
class PostsPage extends ComponentWith(Pagination, FileUploads) {}
|
|
81
|
+
|
|
82
|
+
// after
|
|
83
|
+
import { Component, Pagination } from "@zerotal/flow";
|
|
84
|
+
class PostsPage extends Component.using(Pagination, FileUploads) {}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run `bun run scripts/codemod-mixin-composition.ts` to rewrite call sites and imports.
|
|
88
|
+
|
|
89
|
+
How mixins are **authored** is unchanged — `<T extends Constructor<Component>>(Base: T) => …`
|
|
90
|
+
still works exactly as before, and every shipped mixin keeps its signature. The `Constructor`
|
|
91
|
+
and `Mixin` types are still exported; `Compose` (the type of `Component.using`) joins them.
|
|
92
|
+
|
|
93
|
+
### Added
|
|
94
|
+
|
|
95
|
+
- **`using` composes onto any class in the chain, not just `Component`.** An app-level base can
|
|
96
|
+
now carry mixins without being flattened out of the prototype chain — `AdminPage.using(Pagination)`
|
|
97
|
+
keeps `AdminPage` in the lineage. `ComponentWith` hardcoded `Component`, so this previously
|
|
98
|
+
required hand-nesting (`Pagination(AdminPage)`).
|
|
99
|
+
- **Composition chains.** The composed class carries `using` itself, so
|
|
100
|
+
`Component.using(a, b).using(c, d)` works past the 8-mixin overload set.
|
|
101
|
+
|
|
13
102
|
## [1.1.0] — 2026-08-08
|
|
14
103
|
|
|
15
104
|
### Fixed
|
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ Action helpers available inside any exposed method include `this.flash()`, `this
|
|
|
122
122
|
|
|
123
123
|
**Headless (unstyled, accessible) primitives:** `Switch`, `Checkbox`, `Select`, `RadioGroup`, `Listbox`, `Combobox`, `Disclosure`, `Accordion`, `Popover`, `Field`, `Label`, `Description`, `Fieldset`, `Legend`.
|
|
124
124
|
|
|
125
|
-
**Mixins & helpers:** compose features with `
|
|
125
|
+
**Mixins & helpers:** compose features with `Component.using(Pagination, FileUploads)` — the `Pagination` mixin (`page` state + `nextPage`/`gotoPage`/…, paired with `Model.paginate()` and rendered by `<Pager>`) and the `FileUploads` mixin (`removeUpload`, paired with `<FileUpload>` + `TemporaryUploadedFile`). Also `Form` + `registerForm`, the `paginate` helper + `Paginator` type, and `Layout` for shared page shells.
|
|
126
126
|
|
|
127
127
|
**Slots:** a child component's plain children are its default slot; a `slots={{ header, footer }}` prop supplies named slots. The child places them with `this.slot(name)` / `this.slot()` and branches on `this.hasSlot(name)` — ideal for reusable shells (cards, modals, panels). Slot markup renders in the parent's scope and is carried, signed, in the child's snapshot.
|
|
128
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/flow",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
49
|
-
"@zerotal/orm": "1.
|
|
50
|
-
"@zerotal/validator": "1.
|
|
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.
|
|
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",
|
package/src/Component.ts
CHANGED
|
@@ -23,6 +23,8 @@ import { ValidationError } from "./validation.ts";
|
|
|
23
23
|
import type { ValidationRules } from "./validation.ts";
|
|
24
24
|
import { resolveUploadValue } from "./uploads/TemporaryUploadedFile.ts";
|
|
25
25
|
import { toScriptJson } from "./utils.ts";
|
|
26
|
+
import { _compose } from "./mixins.ts";
|
|
27
|
+
import type { Compose } from "./mixins.ts";
|
|
26
28
|
import { _validateEventPayload } from "./events.ts";
|
|
27
29
|
import type { FlowEvents, EventName, EventArgs } from "./events.ts";
|
|
28
30
|
import { route, request, safeRedirectPath, HttpContext } from "@zerotal/core";
|
|
@@ -343,6 +345,54 @@ export abstract class Component {
|
|
|
343
345
|
*/
|
|
344
346
|
static readonly __isFlowPage = true as const;
|
|
345
347
|
|
|
348
|
+
/**
|
|
349
|
+
* Compose one or more feature mixins onto this class, folding them left-to-right, so a page can
|
|
350
|
+
* be built from focused, reusable behaviours instead of one bloated class.
|
|
351
|
+
*
|
|
352
|
+
* @remarks
|
|
353
|
+
* Each mixin receives the accumulated base and returns an extended class, so `Component`'s full
|
|
354
|
+
* surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's
|
|
355
|
+
* `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props
|
|
356
|
+
* register on the mixin prototype, which sits in the page's prototype chain, so snapshot,
|
|
357
|
+
* reactivity, client writes, and `@url` sync all work exactly as for props declared on the page.
|
|
358
|
+
*
|
|
359
|
+
* `using` composes onto whatever class it is called on, so it also works on an intermediate base
|
|
360
|
+
* (`AdminPage.using(Pagination)` keeps `AdminPage` in the chain), and the composed class carries
|
|
361
|
+
* `using` itself, so `Component.using(a, b).using(c)` chains past the 8-mixin overload set.
|
|
362
|
+
*
|
|
363
|
+
* Pages composed this way render via the runtime path rather than the AOT compiler (which only
|
|
364
|
+
* statically sees a page's own `extends Component` plus locally-declared members) — the same
|
|
365
|
+
* fallback complex pages already use; behaviour is identical, you just don't get the
|
|
366
|
+
* ahead-of-time compile step for that page.
|
|
367
|
+
*
|
|
368
|
+
* @param mixins - Mixin factories applied left-to-right (each takes the prior result as its base).
|
|
369
|
+
* @returns A class extending this one with every mixin's members mixed in.
|
|
370
|
+
*
|
|
371
|
+
* @example
|
|
372
|
+
* ```tsx
|
|
373
|
+
* // app/flow/mixins/pagination.ts
|
|
374
|
+
* import { Component, expose, type Constructor } from "@zerotal/flow";
|
|
375
|
+
*
|
|
376
|
+
* export function Pagination<T extends Constructor<Component>>(Base: T) {
|
|
377
|
+
* abstract class WithPagination extends Base {
|
|
378
|
+
* @expose page = 1;
|
|
379
|
+
* @expose next() { this.page++; }
|
|
380
|
+
* }
|
|
381
|
+
* return WithPagination;
|
|
382
|
+
* }
|
|
383
|
+
*
|
|
384
|
+
* // app/flow/UsersPage.tsx
|
|
385
|
+
* class UsersPage extends Component.using(Pagination) {
|
|
386
|
+
* override async render() {
|
|
387
|
+
* return <div data-flow-root>Page {this.page}</div>;
|
|
388
|
+
* }
|
|
389
|
+
* }
|
|
390
|
+
* ```
|
|
391
|
+
*
|
|
392
|
+
* @category Composition
|
|
393
|
+
*/
|
|
394
|
+
static using: Compose = _compose;
|
|
395
|
+
|
|
346
396
|
// ── @internal: framework bookkeeping, not part of the developer API ──────────
|
|
347
397
|
|
|
348
398
|
/** Set to true by hydrate() so the framework skips calling onMount(). @internal */
|
package/src/client/bridge.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
{ ...(
|
|
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
|
-
|
|
2574
|
-
|
|
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(
|
|
2592
|
-
|
|
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).
|
package/src/decorators.ts
CHANGED
|
@@ -3,8 +3,20 @@
|
|
|
3
3
|
// Standard TC39 decorators, keyed by the class prototype; the `get*` readers walk the
|
|
4
4
|
// prototype chain so subclasses inherit.
|
|
5
5
|
//
|
|
6
|
-
// METHOD / GETTER decorators (@computed, @expose on a method, @renderless, @on)
|
|
7
|
-
//
|
|
6
|
+
// METHOD / GETTER decorators (@computed, @expose on a method, @renderless, @task, @on) CANNOT
|
|
7
|
+
// rely on `addInitializer` either. Bun 1.3.x does not reliably run a method decorator's
|
|
8
|
+
// initializers on construction: a class whose SUBCLASS declares a decorated field never runs the
|
|
9
|
+
// base's method initializers at all, so a shared page base's `@expose` action silently vanished
|
|
10
|
+
// from the allowlist the moment a subclass added one `@expose` field — and since the un-@exposed
|
|
11
|
+
// action check is fatal, a legitimate action was then rejected at runtime. It reproduces with no
|
|
12
|
+
// mixin and no framework code (a bare `class Sub extends Base` on Bun 1.3.14).
|
|
13
|
+
//
|
|
14
|
+
// So method/getter decorators TAG the function object itself in the decorator body — which runs
|
|
15
|
+
// synchronously, with the right name, always — and the `get*` readers scan the prototype chain
|
|
16
|
+
// for tagged members on first read (`_scanProto`). Registration lands on the DECLARING prototype,
|
|
17
|
+
// which is what makes inheritance work through `_collect`'s existing chain walk. `addInitializer`
|
|
18
|
+
// is still called as a belt-and-braces fallback: it is harmless when it fires (the registries are
|
|
19
|
+
// Sets keyed by prototype) and it covers members a prototype scan cannot see.
|
|
8
20
|
//
|
|
9
21
|
// FIELD decorators (@expose on a field, @locked, @reactive, @modelable, @transient,
|
|
10
22
|
// @validate, @url, @session) CANNOT defer to a field initializer or to `addInitializer`:
|
|
@@ -106,6 +118,72 @@ function _registerOn(map: WeakMap<object, Set<string>>, self: unknown, key: stri
|
|
|
106
118
|
_register(map, Object.getPrototypeOf(self as object) as object, key);
|
|
107
119
|
}
|
|
108
120
|
|
|
121
|
+
// ── Method/getter tagging (Bun addInitializer workaround) ─────────────────────
|
|
122
|
+
//
|
|
123
|
+
// See the header note. A method or getter decorator marks the function object it is handed
|
|
124
|
+
// (or, for @computed, the wrapper it returns); `_scanProto` later reads those marks off the
|
|
125
|
+
// prototype and populates the registries. Symbols so a mark can never collide with a user
|
|
126
|
+
// property, and non-enumerable so nothing serializes or copies them.
|
|
127
|
+
|
|
128
|
+
/** Which registry a tagged member belongs in. `on` also carries its event name. */
|
|
129
|
+
interface MemberTag {
|
|
130
|
+
exposed?: boolean;
|
|
131
|
+
renderless?: boolean;
|
|
132
|
+
task?: boolean;
|
|
133
|
+
computed?: boolean;
|
|
134
|
+
on?: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// `unique symbol` (not a plain `symbol`) so the computed-key types below resolve.
|
|
138
|
+
const TAG: unique symbol = Symbol.for("zerotal.flow.memberTag");
|
|
139
|
+
const SCANNED: unique symbol = Symbol.for("zerotal.flow.scanned");
|
|
140
|
+
|
|
141
|
+
/** Attach/merge a tag on a decorated method or getter function. */
|
|
142
|
+
function _tag(fn: unknown, patch: MemberTag): void {
|
|
143
|
+
if (typeof fn !== "function") return;
|
|
144
|
+
const target = fn as { [TAG]?: MemberTag };
|
|
145
|
+
if (!Object.prototype.hasOwnProperty.call(fn, TAG)) {
|
|
146
|
+
// Own, non-enumerable slot — an inherited tag from an overridden base method must not leak.
|
|
147
|
+
Object.defineProperty(fn, TAG, { value: {}, writable: true, configurable: true });
|
|
148
|
+
}
|
|
149
|
+
Object.assign(target[TAG] as MemberTag, patch);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Populate the method/getter registries for one prototype from the tags its own members carry.
|
|
154
|
+
* Idempotent and cheap: marked with a non-enumerable flag so it runs once per prototype.
|
|
155
|
+
*/
|
|
156
|
+
function _scanProto(proto: object): void {
|
|
157
|
+
if (!proto || proto === Object.prototype) return;
|
|
158
|
+
if (Object.prototype.hasOwnProperty.call(proto, SCANNED)) return;
|
|
159
|
+
Object.defineProperty(proto, SCANNED, { value: true, configurable: true });
|
|
160
|
+
|
|
161
|
+
for (const name of Object.getOwnPropertyNames(proto)) {
|
|
162
|
+
if (name === "constructor") continue;
|
|
163
|
+
const desc = Object.getOwnPropertyDescriptor(proto, name);
|
|
164
|
+
if (!desc) continue;
|
|
165
|
+
// A method lives on `value`; a @computed getter on `get`.
|
|
166
|
+
const fn: unknown = desc.value ?? desc.get;
|
|
167
|
+
if (typeof fn !== "function") continue;
|
|
168
|
+
if (!Object.prototype.hasOwnProperty.call(fn, TAG)) continue;
|
|
169
|
+
const tag = (fn as { [TAG]?: MemberTag })[TAG];
|
|
170
|
+
if (!tag) continue;
|
|
171
|
+
|
|
172
|
+
if (tag.exposed) _register(_exposedMethods, proto, name);
|
|
173
|
+
if (tag.renderless) _register(_renderlessMethods, proto, name);
|
|
174
|
+
if (tag.task) _register(_taskMethods, proto, name);
|
|
175
|
+
if (tag.computed) _register(_computedProtos, proto, name);
|
|
176
|
+
if (tag.on !== undefined) _registerMap(_onListeners, proto, tag.on, name);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Scan `startProto` and every prototype above it, so inherited members register too. */
|
|
181
|
+
function _scanChain(startProto: object | null): void {
|
|
182
|
+
for (let p = startProto; p && p !== Object.prototype; p = Object.getPrototypeOf(p) as object) {
|
|
183
|
+
_scanProto(p);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
109
187
|
// ── Field-decorator buffer (Bun standard-decorator workaround) ────────────────
|
|
110
188
|
|
|
111
189
|
interface PendingField {
|
|
@@ -235,7 +313,9 @@ function _assertField(name: string, context: { kind: string }): void {
|
|
|
235
313
|
}
|
|
236
314
|
|
|
237
315
|
export function getComputedKeys(instance: object): Set<string> {
|
|
238
|
-
|
|
316
|
+
const proto = Object.getPrototypeOf(instance) as object;
|
|
317
|
+
_scanChain(proto);
|
|
318
|
+
return _collect(_computedProtos, proto);
|
|
239
319
|
}
|
|
240
320
|
export function getTransientKeys(instance: object): Set<string> {
|
|
241
321
|
_ensureDrained(instance);
|
|
@@ -250,13 +330,16 @@ export function getLockedProps(instance: object): Set<string> {
|
|
|
250
330
|
return _collect(_lockedProps, Object.getPrototypeOf(instance) as object);
|
|
251
331
|
}
|
|
252
332
|
export function getExposedMethods(PageClass: { prototype: object }): Set<string> {
|
|
333
|
+
_scanChain(PageClass.prototype as object);
|
|
253
334
|
return _collect(_exposedMethods, PageClass.prototype as object);
|
|
254
335
|
}
|
|
255
336
|
/** `@task` method names — streaming, cancellable long-running actions. */
|
|
256
337
|
export function getTaskMethods(PageClass: { prototype: object }): Set<string> {
|
|
338
|
+
_scanChain(PageClass.prototype as object);
|
|
257
339
|
return _collect(_taskMethods, PageClass.prototype as object);
|
|
258
340
|
}
|
|
259
341
|
export function getRenderlessMethods(PageClass: { prototype: object }): Set<string> {
|
|
342
|
+
_scanChain(PageClass.prototype as object);
|
|
260
343
|
return _collect(_renderlessMethods, PageClass.prototype as object);
|
|
261
344
|
}
|
|
262
345
|
export function getValidateRules(instance: object): Map<string, ValidateBuilder> {
|
|
@@ -264,6 +347,7 @@ export function getValidateRules(instance: object): Map<string, ValidateBuilder>
|
|
|
264
347
|
return _collectMap(_validateRules, Object.getPrototypeOf(instance) as object);
|
|
265
348
|
}
|
|
266
349
|
export function getListeners(PageClass: { prototype: object }): Map<string, string> {
|
|
350
|
+
_scanChain(PageClass.prototype as object);
|
|
267
351
|
return _collectMap(_onListeners, PageClass.prototype as object);
|
|
268
352
|
}
|
|
269
353
|
export function getUrlProps(instance: object): Map<string, UrlOptions> {
|
|
@@ -358,7 +442,10 @@ export function computed<This, Return>(
|
|
|
358
442
|
context.addInitializer(function (this: This) {
|
|
359
443
|
_registerOn(_computedProtos, this, name);
|
|
360
444
|
});
|
|
361
|
-
|
|
445
|
+
const memoized = _memoizeGetter(getter, name);
|
|
446
|
+
// Tag the wrapper we return — that is what lands on the prototype, not `getter`.
|
|
447
|
+
_tag(memoized, { computed: true });
|
|
448
|
+
return memoized as (this: This) => Return;
|
|
362
449
|
}
|
|
363
450
|
|
|
364
451
|
// ── @transient ────────────────────────────────────────────────────────────────
|
|
@@ -428,7 +515,9 @@ export function expose(_value: unknown, context: ExposeContext): void {
|
|
|
428
515
|
_enqueueField(name, (proto) => _register(_exposedProps, proto, name));
|
|
429
516
|
return;
|
|
430
517
|
}
|
|
431
|
-
// Method:
|
|
518
|
+
// Method: tag the function so `_scanProto` finds it on the DECLARING prototype (see header);
|
|
519
|
+
// addInitializer stays as a fallback for anything a prototype scan cannot reach.
|
|
520
|
+
_tag(_value, { exposed: true });
|
|
432
521
|
context.addInitializer(function (this: unknown) {
|
|
433
522
|
_registerOn(_exposedMethods, this, name);
|
|
434
523
|
});
|
|
@@ -664,6 +753,7 @@ export const modelable = _fieldDecorator((proto, name) => {
|
|
|
664
753
|
* @category Actions
|
|
665
754
|
*/
|
|
666
755
|
export function renderless(_fn: unknown, context: ClassMethodDecoratorContext): void {
|
|
756
|
+
_tag(_fn, { renderless: true });
|
|
667
757
|
context.addInitializer(function (this: unknown) {
|
|
668
758
|
_registerOn(_renderlessMethods, this, String(context.name));
|
|
669
759
|
});
|
|
@@ -703,6 +793,8 @@ export function renderless(_fn: unknown, context: ClassMethodDecoratorContext):
|
|
|
703
793
|
* @category Actions
|
|
704
794
|
*/
|
|
705
795
|
export function task(_fn: unknown, context: ClassMethodDecoratorContext): void {
|
|
796
|
+
// `@task` implies `@expose` — it registers into the same allowlist.
|
|
797
|
+
_tag(_fn, { exposed: true, task: true });
|
|
706
798
|
context.addInitializer(function (this: unknown) {
|
|
707
799
|
const proto = Object.getPrototypeOf(this as object) as object;
|
|
708
800
|
const name = String(context.name);
|
|
@@ -776,6 +868,8 @@ export function on(
|
|
|
776
868
|
eventName: LooseEventName,
|
|
777
869
|
): (fn: unknown, context: ClassMethodDecoratorContext) => void {
|
|
778
870
|
return (_fn: unknown, context: ClassMethodDecoratorContext): void => {
|
|
871
|
+
// `@on` implies `@expose`; the tag carries the event name so the scan can rebuild the map.
|
|
872
|
+
_tag(_fn, { exposed: true, on: eventName });
|
|
779
873
|
context.addInitializer(function (this: unknown) {
|
|
780
874
|
const proto = Object.getPrototypeOf(this as object) as object;
|
|
781
875
|
const name = String(context.name);
|
package/src/index.ts
CHANGED
|
@@ -70,9 +70,9 @@ export type {
|
|
|
70
70
|
FlashBuilder,
|
|
71
71
|
FlashOptions,
|
|
72
72
|
} from "./Component.ts";
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
export type { Constructor, Mixin } from "./mixins.ts";
|
|
73
|
+
// Mixin authoring types. Compose them onto a page with the `Component.using(...)` static —
|
|
74
|
+
// `class PostsPage extends Component.using(Pagination, …)`.
|
|
75
|
+
export type { Constructor, Mixin, Compose } from "./mixins.ts";
|
|
76
76
|
export { Layout } from "./Layout.ts";
|
|
77
77
|
export { Form, registerForm } from "./Form.ts";
|
|
78
78
|
// Global client store — app-wide, client-only reactive UI state, read in JSX as
|
|
@@ -80,7 +80,7 @@ export { Form, registerForm } from "./Form.ts";
|
|
|
80
80
|
// `FlowStore` to type it.
|
|
81
81
|
export { defineStore } from "./store.ts";
|
|
82
82
|
export type { FlowStore } from "./store.ts";
|
|
83
|
-
// Pagination state mixin (compose with `
|
|
83
|
+
// Pagination state mixin (compose with `Component.using(Pagination)`); `paginate`/`Paginator`
|
|
84
84
|
// are the in-memory paginator.
|
|
85
85
|
export { paginate, Pagination } from "./pagination.ts";
|
|
86
86
|
export type { Paginator } from "./pagination.ts";
|
|
@@ -227,7 +227,7 @@ import type { PageClassWithMeta } from "./registry.ts";
|
|
|
227
227
|
// File uploads
|
|
228
228
|
export { TemporaryUploadedFile } from "./uploads/TemporaryUploadedFile.ts";
|
|
229
229
|
export type { UploadRef } from "./uploads/TemporaryUploadedFile.ts";
|
|
230
|
-
// File-upload management mixin (compose with `
|
|
230
|
+
// File-upload management mixin (compose with `Component.using(FileUploads)`).
|
|
231
231
|
export { FileUploads } from "./uploads/FileUploads.ts";
|
|
232
232
|
|
|
233
233
|
// Validation
|
package/src/jsx-runtime.ts
CHANGED
|
@@ -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
|
-
|
|
1158
|
-
|
|
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}
|
package/src/mixins.ts
CHANGED
|
@@ -1,17 +1,9 @@
|
|
|
1
1
|
// ── Component mixins ────────────────────────────────────────────────────────
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// // before — one class carrying every feature inline, base repeated per mixin
|
|
9
|
-
// class UsersPage extends FileUploads(Pagination(Component)) { … }
|
|
10
|
-
//
|
|
11
|
-
// // after — flat, left-to-right, base baked in
|
|
12
|
-
// class UsersPage extends ComponentWith(Pagination, FileUploads) {
|
|
13
|
-
// async render() { … } // the page only owns its own markup + state
|
|
14
|
-
// }
|
|
3
|
+
// Mixin composition machinery for Flow. The entry point is `Component.using(...)`
|
|
4
|
+
// (see Component.ts) — this module owns the types it is built from, and the fold
|
|
5
|
+
// itself. Nothing here imports `Component`: the fold seeds from its receiver, so
|
|
6
|
+
// this is a leaf module.
|
|
15
7
|
//
|
|
16
8
|
// A Component mixin is the canonical generic form below — note it extends an ABSTRACT
|
|
17
9
|
// base and returns an `abstract class`, so it doesn't have to implement Component's
|
|
@@ -28,21 +20,7 @@
|
|
|
28
20
|
// return WithUpload;
|
|
29
21
|
// }
|
|
30
22
|
//
|
|
31
|
-
//
|
|
32
|
-
// `redirect()`, `validate()`, the `this.set`/`refresh`/… client magics, …) and every other
|
|
33
|
-
// mixin's members flow through to the final page — fully type-checked. The shipped
|
|
34
|
-
// `Pagination` mixin (see ./pagination.ts) is written exactly this way and composes here:
|
|
35
|
-
// `ComponentWith(Pagination)`.
|
|
36
|
-
//
|
|
37
|
-
// Mixin `@expose`/`@locked` props register on the mixin's prototype, which sits in the page's
|
|
38
|
-
// prototype chain — so they're discovered at runtime exactly like props declared on the page
|
|
39
|
-
// itself (snapshot, reactivity, client writes, `@url` sync all work). Note: pages composed
|
|
40
|
-
// with `ComponentWith(...)` render via the runtime path rather than the AOT compiler (which
|
|
41
|
-
// only statically sees a page's own `extends Component` + locally-declared members) — the same
|
|
42
|
-
// fallback complex pages already use; behaviour is identical, you just don't get the
|
|
43
|
-
// ahead-of-time compile step for that page.
|
|
44
|
-
|
|
45
|
-
import { Component } from "./Component.ts";
|
|
23
|
+
// Compose them onto a page with `Component.using(FileUpload, …)`.
|
|
46
24
|
|
|
47
25
|
/**
|
|
48
26
|
* A class constructor. Abstract-aware (`abstract new …`) so the abstract `Component` base —
|
|
@@ -65,135 +43,127 @@ export type Mixin<TIn extends Constructor = Constructor, TOut extends Constructo
|
|
|
65
43
|
Base: TIn,
|
|
66
44
|
) => TOut;
|
|
67
45
|
|
|
68
|
-
type Base = typeof Component;
|
|
69
|
-
|
|
70
46
|
/**
|
|
71
|
-
*
|
|
72
|
-
* so a page can be built from focused, reusable behaviours instead of one bloated class.
|
|
73
|
-
*
|
|
74
|
-
* @remarks
|
|
75
|
-
* Each mixin receives the accumulated base and returns an extended class, so `Component`'s full
|
|
76
|
-
* surface (`flash()`, `redirect()`, `validate()`, the client magics, …) and every mixin's
|
|
77
|
-
* `@expose`/`@locked` members flow through to the final page — fully type-checked. Mixin props
|
|
78
|
-
* register on the mixin prototype, which sits in the page's prototype chain, so snapshot,
|
|
79
|
-
* reactivity, and client writes all work. Overloads cover 1–8 mixins; nest another
|
|
80
|
-
* `ComponentWith(...)` call for more. Pages composed this way render via the runtime path rather
|
|
81
|
-
* than the AOT compiler.
|
|
82
|
-
*
|
|
83
|
-
* @param mixins mixin factories applied left-to-right (each takes the prior result as its base).
|
|
84
|
-
* @returns a class extending `Component` with every mixin's members mixed in.
|
|
47
|
+
* The call signatures behind `Component.using(...)`.
|
|
85
48
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
49
|
+
* Each overload threads the accumulated type through every step, so the composed class carries
|
|
50
|
+
* the base's full surface plus every mixin's members — fully type-checked. The `this: TBase`
|
|
51
|
+
* parameter is what makes the base polymorphic: `using` composes onto whatever class it is
|
|
52
|
+
* called on, not onto a hardcoded `Component`.
|
|
90
53
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* @expose page = 1;
|
|
94
|
-
* @expose next() { this.page++; }
|
|
95
|
-
* }
|
|
96
|
-
* return WithPagination;
|
|
97
|
-
* }
|
|
98
|
-
*
|
|
99
|
-
* // app/flow/UsersPage.tsx
|
|
100
|
-
* class UsersPage extends ComponentWith(Pagination) {
|
|
101
|
-
* override async render() {
|
|
102
|
-
* return <div data-flow-root>Page {this.page}</div>;
|
|
103
|
-
* }
|
|
104
|
-
* }
|
|
105
|
-
* ```
|
|
54
|
+
* Overloads cover 1–8 mixins. There is no ceiling: the composed class carries `using` too, so
|
|
55
|
+
* `Base.using(a, b).using(c, d)` chains.
|
|
106
56
|
*/
|
|
107
|
-
export
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
57
|
+
export interface Compose {
|
|
58
|
+
<TBase extends Constructor, A extends Constructor>(this: TBase, a: (base: TBase) => A): A;
|
|
59
|
+
<TBase extends Constructor, A extends Constructor, B extends Constructor>(
|
|
60
|
+
this: TBase,
|
|
61
|
+
a: (base: TBase) => A,
|
|
62
|
+
b: (base: A) => B,
|
|
63
|
+
): B;
|
|
64
|
+
<TBase extends Constructor, A extends Constructor, B extends Constructor, C extends Constructor>(
|
|
65
|
+
this: TBase,
|
|
66
|
+
a: (base: TBase) => A,
|
|
67
|
+
b: (base: A) => B,
|
|
68
|
+
c: (base: B) => C,
|
|
69
|
+
): C;
|
|
70
|
+
<
|
|
71
|
+
TBase extends Constructor,
|
|
72
|
+
A extends Constructor,
|
|
73
|
+
B extends Constructor,
|
|
74
|
+
C extends Constructor,
|
|
75
|
+
D extends Constructor,
|
|
76
|
+
>(
|
|
77
|
+
this: TBase,
|
|
78
|
+
a: (base: TBase) => A,
|
|
79
|
+
b: (base: A) => B,
|
|
80
|
+
c: (base: B) => C,
|
|
81
|
+
d: (base: C) => D,
|
|
82
|
+
): D;
|
|
83
|
+
<
|
|
84
|
+
TBase extends Constructor,
|
|
85
|
+
A extends Constructor,
|
|
86
|
+
B extends Constructor,
|
|
87
|
+
C extends Constructor,
|
|
88
|
+
D extends Constructor,
|
|
89
|
+
E extends Constructor,
|
|
90
|
+
>(
|
|
91
|
+
this: TBase,
|
|
92
|
+
a: (base: TBase) => A,
|
|
93
|
+
b: (base: A) => B,
|
|
94
|
+
c: (base: B) => C,
|
|
95
|
+
d: (base: C) => D,
|
|
96
|
+
e: (base: D) => E,
|
|
97
|
+
): E;
|
|
98
|
+
<
|
|
99
|
+
TBase extends Constructor,
|
|
100
|
+
A extends Constructor,
|
|
101
|
+
B extends Constructor,
|
|
102
|
+
C extends Constructor,
|
|
103
|
+
D extends Constructor,
|
|
104
|
+
E extends Constructor,
|
|
105
|
+
F extends Constructor,
|
|
106
|
+
>(
|
|
107
|
+
this: TBase,
|
|
108
|
+
a: (base: TBase) => A,
|
|
109
|
+
b: (base: A) => B,
|
|
110
|
+
c: (base: B) => C,
|
|
111
|
+
d: (base: C) => D,
|
|
112
|
+
e: (base: D) => E,
|
|
113
|
+
f: (base: E) => F,
|
|
114
|
+
): F;
|
|
115
|
+
<
|
|
116
|
+
TBase extends Constructor,
|
|
117
|
+
A extends Constructor,
|
|
118
|
+
B extends Constructor,
|
|
119
|
+
C extends Constructor,
|
|
120
|
+
D extends Constructor,
|
|
121
|
+
E extends Constructor,
|
|
122
|
+
F extends Constructor,
|
|
123
|
+
G extends Constructor,
|
|
124
|
+
>(
|
|
125
|
+
this: TBase,
|
|
126
|
+
a: (base: TBase) => A,
|
|
127
|
+
b: (base: A) => B,
|
|
128
|
+
c: (base: B) => C,
|
|
129
|
+
d: (base: C) => D,
|
|
130
|
+
e: (base: D) => E,
|
|
131
|
+
f: (base: E) => F,
|
|
132
|
+
g: (base: F) => G,
|
|
133
|
+
): G;
|
|
134
|
+
<
|
|
135
|
+
TBase extends Constructor,
|
|
136
|
+
A extends Constructor,
|
|
137
|
+
B extends Constructor,
|
|
138
|
+
C extends Constructor,
|
|
139
|
+
D extends Constructor,
|
|
140
|
+
E extends Constructor,
|
|
141
|
+
F extends Constructor,
|
|
142
|
+
G extends Constructor,
|
|
143
|
+
H extends Constructor,
|
|
144
|
+
>(
|
|
145
|
+
this: TBase,
|
|
146
|
+
a: (base: TBase) => A,
|
|
147
|
+
b: (base: A) => B,
|
|
148
|
+
c: (base: B) => C,
|
|
149
|
+
d: (base: C) => D,
|
|
150
|
+
e: (base: D) => E,
|
|
151
|
+
f: (base: E) => F,
|
|
152
|
+
g: (base: F) => G,
|
|
153
|
+
h: (base: G) => H,
|
|
154
|
+
): H;
|
|
155
|
+
}
|
|
187
156
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The fold behind {@link Compose}: apply each mixin left-to-right, seeding from `this` (the
|
|
159
|
+
* class `using` was called on). Assigned to `Component.using` — not exported publicly.
|
|
160
|
+
*
|
|
161
|
+
* @internal
|
|
162
|
+
*/
|
|
163
|
+
export const _compose = function (
|
|
164
|
+
this: Constructor,
|
|
192
165
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- variadic mixin folder
|
|
193
166
|
...mixins: Array<(base: any) => Constructor>
|
|
194
167
|
): Constructor {
|
|
195
|
-
return mixins.reduce<Constructor>(
|
|
196
|
-
|
|
197
|
-
Component as unknown as Constructor,
|
|
198
|
-
);
|
|
199
|
-
}
|
|
168
|
+
return mixins.reduce<Constructor>((acc, mixin) => mixin(acc), this);
|
|
169
|
+
} as Compose;
|
package/src/pagination.ts
CHANGED
|
@@ -133,7 +133,7 @@ export function paginate<T>(items: T[], page = 1, perPage = 15): Paginator<T> {
|
|
|
133
133
|
// `previousPage`, `gotoPage`, `resetPage`), WITHOUT putting any of that on
|
|
134
134
|
// the base Component.
|
|
135
135
|
//
|
|
136
|
-
// class PostsPage extends
|
|
136
|
+
// class PostsPage extends Component.using(Pagination) {
|
|
137
137
|
// @locked posts: Post[] = [];
|
|
138
138
|
// async onMount() { this.posts = await Post.all(); }
|
|
139
139
|
// async render() {
|
|
@@ -200,7 +200,7 @@ const DEFAULT_PAGE = "page";
|
|
|
200
200
|
*
|
|
201
201
|
* @example
|
|
202
202
|
* ```tsx
|
|
203
|
-
* class PostsPage extends
|
|
203
|
+
* class PostsPage extends Component.using(Pagination) {
|
|
204
204
|
* @locked posts: Post[] = [];
|
|
205
205
|
* async onMount() { this.posts = await Post.all(); }
|
|
206
206
|
* async render() {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// This mixin adds the ergonomic server-side actions for managing those pending uploads —
|
|
8
8
|
// chiefly removing a chosen file before it's stored — without bloating the base class.
|
|
9
9
|
//
|
|
10
|
-
// class Avatar extends
|
|
10
|
+
// class Avatar extends Component.using(FileUploads) {
|
|
11
11
|
// @expose photo: TemporaryUploadedFile | null = null;
|
|
12
12
|
// @expose async save() {
|
|
13
13
|
// this.path = await this.photo?.store("avatars"); // persist permanently
|
|
@@ -48,7 +48,7 @@ type AbstractComponentCtor = abstract new (...args: any[]) => Component;
|
|
|
48
48
|
*
|
|
49
49
|
* @example
|
|
50
50
|
* ```tsx
|
|
51
|
-
* class Avatar extends
|
|
51
|
+
* class Avatar extends Component.using(FileUploads) {
|
|
52
52
|
* @expose photo: TemporaryUploadedFile | null = null;
|
|
53
53
|
* @expose path = "";
|
|
54
54
|
*
|
|
@@ -98,7 +98,7 @@ async function disk(name?: string): Promise<Disk> {
|
|
|
98
98
|
*
|
|
99
99
|
* @example
|
|
100
100
|
* ```tsx
|
|
101
|
-
* class Avatar extends
|
|
101
|
+
* class Avatar extends Component.using(FileUploads) {
|
|
102
102
|
* @expose photo: TemporaryUploadedFile | null = null;
|
|
103
103
|
* @expose path = "";
|
|
104
104
|
*
|