@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 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 | { ttl?: string; scope?: 'user' | 'session';}
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 = (string & {}) | ((self: Record<string, any>) => string)
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",
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.3",
51
- "@zerotal/core": "1.7.3",
52
- "@zerotal/orm": "1.7.3",
53
- "@zerotal/validator": "1.7.3",
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.3",
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",
@@ -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.setAttribute("data-current", "");
2643
+ _mark(el, true);
2627
2644
  return;
2628
2645
  }
2629
2646
  const exact = el.hasAttribute("flow:current.exact");
2630
- if (_linkActive(el.getAttribute("href") ?? "", path, exact))
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
- overlay["x-trap"] = `$flow.${name}`; // trap focus while open (@alpinejs/focus)
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 focus while open
1488
+ panelOut["x-trap.noscroll"] = shownExpr; // focus trap + scroll lock, as Modal
1486
1489
  if (title) panelOut["aria-labelledby"] = titleId;
1487
1490
  }
1488
1491