@zerotal/flow 1.7.3 → 1.7.5
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 +37 -16
- package/api-surface.md +2 -8
- package/package.json +6 -6
- package/src/client/bridge.ts +19 -4
- package/src/components.ts +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -11,6 +11,38 @@ change.
|
|
|
11
11
|
|
|
12
12
|
## [Unreleased]
|
|
13
13
|
|
|
14
|
+
## [1.7.3] — 2026-08-20
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- **BREAKING — `this.title(…)` is removed.** Declare `static title` instead — a string, or a
|
|
19
|
+
function of the component: `static title = (c: SearchPage) => \`Search: ${c.query}\``. It is
|
|
20
|
+
resolved on the server for every render and every patch, so a title that depends on state
|
|
21
|
+
follows it without an action remembering to update it. `_titleValue` and the per-frame reset
|
|
22
|
+
go with it.
|
|
23
|
+
|
|
24
|
+
A call to `this.title(…)` on a component that declares its own `title` field now sets that
|
|
25
|
+
field rather than the document title, which is silent — search for `this.title(` before
|
|
26
|
+
upgrading.
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **A bound password field silently discarded every keystroke.** The client-writable set was
|
|
31
|
+
`fillable` minus `hidden`, which conflates two allow-lists that answer different questions:
|
|
32
|
+
`fillable`/`guarded` govern what may be _written_, `visible`/`hidden` govern what may be
|
|
33
|
+
_shown_. A password is in both — fillable because a user sets it, hidden because the stored
|
|
34
|
+
hash must never reach a page — so subtracting made it unwritable, and
|
|
35
|
+
`<input type="password" value={this.user.password} blur />` accepted typing and dropped it
|
|
36
|
+
on arrival. `hidden` is no longer subtracted. It is still never _sent_: the hash does not
|
|
37
|
+
leave the server, and the field arrives empty as it should. A hidden value the client
|
|
38
|
+
supplied now survives until save, and one the _server_ produced is never echoed back.
|
|
39
|
+
|
|
40
|
+
A half-typed secret is also stripped from the [durable snapshot](/docs/flow/lifecycle)
|
|
41
|
+
before it is persisted — that store is written server-side after every request and may be
|
|
42
|
+
backed by Redis, which is no place for a password in progress.
|
|
43
|
+
|
|
44
|
+
## [1.7.2] — 2026-08-18
|
|
45
|
+
|
|
14
46
|
### Added
|
|
15
47
|
|
|
16
48
|
- **`@on` can name a channel per instance.** A listener's channel is read off the _class_, so
|
|
@@ -52,20 +84,6 @@ change.
|
|
|
52
84
|
|
|
53
85
|
### Fixed
|
|
54
86
|
|
|
55
|
-
- **A bound password field silently discarded every keystroke.** The client-writable set was
|
|
56
|
-
`fillable` minus `hidden`, which conflates two allow-lists that answer different questions:
|
|
57
|
-
`fillable`/`guarded` govern what may be _written_, `visible`/`hidden` govern what may be
|
|
58
|
-
_shown_. A password is in both — fillable because a user sets it, hidden because the stored
|
|
59
|
-
hash must never reach a page — so subtracting made it unwritable, and
|
|
60
|
-
`<input type="password" value={this.user.password} blur />` accepted typing and dropped it
|
|
61
|
-
on arrival. `hidden` is no longer subtracted. It is still never _sent_: the hash does not
|
|
62
|
-
leave the server, and the field arrives empty as it should. A hidden value the client
|
|
63
|
-
supplied now survives until save, and one the _server_ produced is never echoed back.
|
|
64
|
-
|
|
65
|
-
A half-typed secret is also stripped from the [durable snapshot](/docs/flow/lifecycle)
|
|
66
|
-
before it is persisted — that store is written server-side after every request and may be
|
|
67
|
-
backed by Redis, which is no place for a password in progress.
|
|
68
|
-
|
|
69
87
|
- **`<FileUpload>` never stopped saying "Uploading… 100%".** A successful upload left the
|
|
70
88
|
dropzone reporting progress forever. The bytes had gone up, the signed reference had come
|
|
71
89
|
back, and the bound property had been set — the only thing that failed was the component
|
|
@@ -115,20 +133,23 @@ change.
|
|
|
115
133
|
|
|
116
134
|
### Documented
|
|
117
135
|
|
|
118
|
-
Four behaviours that were true but written down nowhere, each found by hitting it:
|
|
119
|
-
|
|
120
136
|
- **AOT compilation and `__()` are mutually exclusive** — a function call in a text child forces
|
|
121
137
|
the runtime fallback, so a translated app compiles no pages at all. Normally that is a speed
|
|
122
138
|
cost; under `cspSafe`, where every page must compile, it is a build failure. `performance.md`
|
|
123
139
|
now lists what blocks compilation and what to do about it.
|
|
140
|
+
|
|
124
141
|
- **`FlowTest` opens no request scope**, so `Auth.user()` and `Auth.attempt()` throw and no
|
|
125
142
|
action behind a sign-in can be driven without wrapping it. `testing.md` carries the wrapper.
|
|
143
|
+
|
|
126
144
|
- **A layout cannot set the document's `lang`** — Flow emits `<html lang="en">` and offers no
|
|
127
145
|
hook, so a localised app marks up its wrapper instead and the root element stays wrong.
|
|
146
|
+
|
|
128
147
|
- **`<Tabs>` selection is client-only and its strip is not themeable** — no bindable prop, so no
|
|
129
148
|
`?tab=`, no back button, and hardcoded gray/indigo that is invisible on a light surface.
|
|
149
|
+
|
|
130
150
|
- **Flow and `zerotal/view` components do not interoperate** — the two JSX runtimes produce
|
|
131
151
|
different element types (`HtmlNode` vs `SafeHtml`), so a view `FC` in a Flow page is `TS2786`.
|
|
152
|
+
|
|
132
153
|
- **`onSort` does not say which container took the drop** — the destination is encoded in which
|
|
133
154
|
method runs, so dragging between containers needs one action per container.
|
|
134
155
|
|
package/api-surface.md
CHANGED
|
@@ -281,8 +281,6 @@ function registerComponent = (PageClass: typeof Component, path?: string) => voi
|
|
|
281
281
|
|
|
282
282
|
function registerFlowEvent = <K extends EventName>(name: K, guard: (payload: unknown) => payload is FlowEvents[K]) => void
|
|
283
283
|
|
|
284
|
-
function registerFlowModel = (name: string, ModelClass: typeof BaseModel) => void
|
|
285
|
-
|
|
286
284
|
function registerForm = (FormClass: new () => Form) => void
|
|
287
285
|
|
|
288
286
|
function registerSynth = (synth: Synth) => void
|
|
@@ -1026,7 +1024,7 @@ interface VirtualizeProps = {
|
|
|
1026
1024
|
|
|
1027
1025
|
type Constructor = abstract new (...args: any[]) => T
|
|
1028
1026
|
|
|
1029
|
-
type DurableOption = boolean | {
|
|
1027
|
+
type DurableOption = boolean | { ttl?: string; scope?: 'user' | 'session' }
|
|
1030
1028
|
|
|
1031
1029
|
type EventName = never
|
|
1032
1030
|
|
|
@@ -1040,7 +1038,7 @@ type FlashPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' |
|
|
|
1040
1038
|
|
|
1041
1039
|
type HtmlNode = { html: string;}
|
|
1042
1040
|
|
|
1043
|
-
type ListenerName =
|
|
1041
|
+
type ListenerName = LooseEventName | ((self: Record<string, any>) => string)
|
|
1044
1042
|
|
|
1045
1043
|
type Mixin = (Base: TIn) => TOut
|
|
1046
1044
|
|
|
@@ -1089,12 +1087,8 @@ function __escAttr = (v: unknown) => string
|
|
|
1089
1087
|
|
|
1090
1088
|
## ./store `(./src/store.ts)`
|
|
1091
1089
|
|
|
1092
|
-
function clientStore = () => Record<string, unknown>
|
|
1093
|
-
|
|
1094
1090
|
function defineStore = (initial: Record<string, unknown>) => void
|
|
1095
1091
|
|
|
1096
|
-
function initClientStore = (alpine: AlpineStores) => Record<string, unknown>
|
|
1097
|
-
|
|
1098
1092
|
interface FlowStore = {}
|
|
1099
1093
|
|
|
1100
1094
|
## ./testing `(./src/testing.ts)`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/flow",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -47,15 +47,15 @@
|
|
|
47
47
|
"@alpinejs/morph": "^3.14.9",
|
|
48
48
|
"@alpinejs/persist": "^3.14.9",
|
|
49
49
|
"@alpinejs/resize": "^3.14.9",
|
|
50
|
-
"@zerotal/client": "1.7.
|
|
51
|
-
"@zerotal/core": "1.7.
|
|
52
|
-
"@zerotal/orm": "1.7.
|
|
53
|
-
"@zerotal/validator": "1.7.
|
|
50
|
+
"@zerotal/client": "1.7.5",
|
|
51
|
+
"@zerotal/core": "1.7.5",
|
|
52
|
+
"@zerotal/orm": "1.7.5",
|
|
53
|
+
"@zerotal/validator": "1.7.5",
|
|
54
54
|
"alpinejs": "^3.14.9",
|
|
55
55
|
"typescript": "^5.8.0"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
|
-
"@zerotal/broadcasting": "1.7.
|
|
58
|
+
"@zerotal/broadcasting": "1.7.5",
|
|
59
59
|
"@types/alpinejs": "^3.13.10",
|
|
60
60
|
"@types/alpinejs__anchor": "^3.13.1",
|
|
61
61
|
"@types/alpinejs__collapse": "^3.13.4",
|
package/src/client/bridge.ts
CHANGED
|
@@ -2612,6 +2612,23 @@ function _linkActive(href: string, path: string, exact = false): boolean {
|
|
|
2612
2612
|
return h === path || (h.length > 1 && path.startsWith(h + "/"));
|
|
2613
2613
|
}
|
|
2614
2614
|
|
|
2615
|
+
/**
|
|
2616
|
+
* Mark a navigate link as the current page, for both kinds of reader.
|
|
2617
|
+
*
|
|
2618
|
+
* `data-current` styles it; `aria-current` is the only way anyone using a screen
|
|
2619
|
+
* reader learns which of thirty nav items is the page they are on. Setting one
|
|
2620
|
+
* without the other marks the link only for people who can see it.
|
|
2621
|
+
*/
|
|
2622
|
+
function _mark(el: Element, active: boolean): void {
|
|
2623
|
+
if (active) {
|
|
2624
|
+
el.setAttribute("data-current", "");
|
|
2625
|
+
el.setAttribute("aria-current", "page");
|
|
2626
|
+
} else {
|
|
2627
|
+
el.removeAttribute("data-current");
|
|
2628
|
+
el.removeAttribute("aria-current");
|
|
2629
|
+
}
|
|
2630
|
+
}
|
|
2631
|
+
|
|
2615
2632
|
function _updateCurrentLinks(): void {
|
|
2616
2633
|
const path = window.location.pathname;
|
|
2617
2634
|
|
|
@@ -2623,13 +2640,11 @@ function _updateCurrentLinks(): void {
|
|
|
2623
2640
|
document.querySelectorAll("a[flow\\:navigate]").forEach((el) => {
|
|
2624
2641
|
if (el.hasAttribute("flow:current.ignore")) return;
|
|
2625
2642
|
if (el.hasAttribute("flow:current.force")) {
|
|
2626
|
-
el
|
|
2643
|
+
_mark(el, true);
|
|
2627
2644
|
return;
|
|
2628
2645
|
}
|
|
2629
2646
|
const exact = el.hasAttribute("flow:current.exact");
|
|
2630
|
-
|
|
2631
|
-
el.setAttribute("data-current", "");
|
|
2632
|
-
else el.removeAttribute("data-current");
|
|
2647
|
+
_mark(el, _linkActive(el.getAttribute("href") ?? "", path, exact));
|
|
2633
2648
|
});
|
|
2634
2649
|
|
|
2635
2650
|
document.querySelectorAll("a[flow\\:navigate][flow\\:current]").forEach((el) => {
|
package/src/components.ts
CHANGED
|
@@ -250,7 +250,10 @@ export function Modal(props: ModalProps): HtmlNode {
|
|
|
250
250
|
if (name) {
|
|
251
251
|
overlay["flow:show"] = name;
|
|
252
252
|
overlay["data-flow-modal"] = name;
|
|
253
|
-
|
|
253
|
+
// `.noscroll` locks the body while the dialog is open. Without it focus is
|
|
254
|
+
// trapped correctly and the page behind still scrolls under a finger or a
|
|
255
|
+
// wheel, which on a phone reads as the dialog having broken the page.
|
|
256
|
+
overlay["x-trap.noscroll"] = `$flow.${name}`; // focus trap + scroll lock (@alpinejs/focus)
|
|
254
257
|
if (title) overlay["aria-labelledby"] = titleId;
|
|
255
258
|
}
|
|
256
259
|
if (!open) overlay["style"] = { display: "none" };
|
|
@@ -1482,7 +1485,7 @@ export function Drawer(props: DrawerProps): HtmlNode {
|
|
|
1482
1485
|
};
|
|
1483
1486
|
if (name) {
|
|
1484
1487
|
panelOut["data-flow-modal"] = name; // Escape-to-close (shared bridge handler)
|
|
1485
|
-
panelOut["x-trap"] = shownExpr; // trap
|
|
1488
|
+
panelOut["x-trap.noscroll"] = shownExpr; // focus trap + scroll lock, as Modal
|
|
1486
1489
|
if (title) panelOut["aria-labelledby"] = titleId;
|
|
1487
1490
|
}
|
|
1488
1491
|
|