phlex-reactive 0.15.3 → 0.15.4

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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +168 -0
  3. data/README.md +81 -12
  4. data/app/javascript/phlex/reactive/core.js +1 -0
  5. data/app/javascript/phlex/reactive/core.min.js +6 -6
  6. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  7. data/app/javascript/phlex/reactive/features/bindings.js +129 -16
  8. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  9. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/countdown.js +140 -0
  11. data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
  12. data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
  13. data/app/javascript/phlex/reactive/features/persist.js +10 -6
  14. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  17. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  18. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  19. data/app/javascript/phlex/reactive/runtime.js +172 -30
  20. data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
  21. data/lib/phlex/reactive/component/field_references.rb +130 -0
  22. data/lib/phlex/reactive/component/helpers.rb +72 -14
  23. data/lib/phlex/reactive/component/image_fallback_binding.rb +18 -5
  24. data/lib/phlex/reactive/component/media_binding.rb +9 -1
  25. data/lib/phlex/reactive/component/media_pattern.rb +17 -17
  26. data/lib/phlex/reactive/component/selection.rb +24 -2
  27. data/lib/phlex/reactive/component/show_options.rb +91 -0
  28. data/lib/phlex/reactive/engine.rb +2 -2
  29. data/lib/phlex/reactive/js.rb +44 -2
  30. data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
  31. data/lib/phlex/reactive/show_conditions.rb +39 -6
  32. data/lib/phlex/reactive/streamable.rb +6 -3
  33. data/lib/phlex/reactive/version.rb +1 -1
  34. metadata +8 -1
@@ -37,6 +37,7 @@ import * as hintsFeature from "phlex/reactive/features/hints"
37
37
  import * as deferFeature from "phlex/reactive/features/defer"
38
38
  import * as sortableFeature from "phlex/reactive/features/sortable"
39
39
  import * as presenceFeature from "phlex/reactive/features/presence"
40
+ import * as countdownFeature from "phlex/reactive/features/countdown"
40
41
 
41
42
  // phlex/reactive/runtime — the reactive controller WITHOUT its feature
42
43
  // modules (issue #275): the code both client entries share. It is not an
@@ -436,6 +437,26 @@ const IMAGE_STATE = "data-reactive-image-state"
436
437
  const IMAGE_HIDDEN = "data-reactive-image-hidden"
437
438
  let imageFallbackListeners = null
438
439
 
440
+ // --- A wrapper an op hides (issues #391, #392) -------------------------------
441
+ // preview_file's wrap: and reactive_image_fallback's wrap: hide the box around
442
+ // an img. The mark says the runtime set that `hidden`, so a show removes only
443
+ // a hide of its own, never one the author wrote. On the element, for the
444
+ // IMAGE_HIDDEN reason. Visibility only: a wrapper usually holds the img, so
445
+ // nothing here writes its text.
446
+ const WRAP_HIDDEN = "data-reactive-wrap-hidden"
447
+
448
+ function hideWrap(el) {
449
+ if (el.hasAttribute("hidden")) return
450
+ el.setAttribute("hidden", "")
451
+ el.setAttribute(WRAP_HIDDEN, "")
452
+ }
453
+
454
+ function showWrap(el) {
455
+ if (!el.hasAttribute(WRAP_HIDDEN)) return
456
+ el.removeAttribute("hidden")
457
+ el.removeAttribute(WRAP_HIDDEN)
458
+ }
459
+
439
460
  function imageFallback(img) {
440
461
  if (img?.localName !== "img") return null
441
462
  const raw = img.getAttribute(IMAGE_FALLBACK)
@@ -451,23 +472,33 @@ const hasImageSrc = (img) => img.getAttribute("src") || img.getAttribute("srcset
451
472
 
452
473
  // The node that shows the alt text: a selector resolved inside the img's own
453
474
  // root only (a document listener has no op scope).
454
- function imageAltNode(img, wire) {
455
- if (typeof wire.alt !== "string" || !wire.alt) return null
475
+ function imageRootNode(img, selector) {
476
+ if (typeof selector !== "string" || !selector) return null
456
477
  try {
457
- const node = img.closest('[data-controller~="reactive"]')?.querySelector(wire.alt)
458
- // Never a node that holds the img: its textContent would wipe the img.
459
- return node && !node.contains(img) ? node : null
478
+ return img.closest('[data-controller~="reactive"]')?.querySelector(selector) ?? null
460
479
  } catch {
461
480
  return null
462
481
  }
463
482
  }
464
483
 
484
+ function imageAltNode(img, wire) {
485
+ const node = imageRootNode(img, wire.alt)
486
+ // Never a node that holds the img: its textContent would wipe the img.
487
+ return node && !node.contains(img) ? node : null
488
+ }
489
+
465
490
  function setImageState(img, wire, value) {
466
491
  if (value === "error") {
467
492
  if (wire.hide && !img.hasAttribute("hidden")) {
468
493
  img.setAttribute("hidden", "")
469
494
  img.setAttribute(IMAGE_HIDDEN, "")
470
495
  }
496
+ // wrap: (issue #392) may hold the img: only its visibility changes.
497
+ if (wire.wrap) {
498
+ const wrap = imageRootNode(img, wire.wrap)
499
+ if (wrap) hideWrap(wrap)
500
+ else console.warn(`[phlex-reactive] reactive_image_fallback wrap: ${JSON.stringify(wire.wrap)} is not in the root — skipped`)
501
+ }
471
502
  const node = imageAltNode(img, wire)
472
503
  if (node) {
473
504
  node.textContent = img.getAttribute("alt") ?? ""
@@ -483,6 +514,8 @@ function resetImage(img, wire = imageFallback(img)) {
483
514
  if (!wire || img.getAttribute(IMAGE_STATE) == null) return
484
515
  if (img.getAttribute(IMAGE_STATE) === "error") {
485
516
  if (img.hasAttribute(IMAGE_HIDDEN)) img.removeAttribute("hidden")
517
+ const wrap = imageRootNode(img, wire.wrap)
518
+ if (wrap) showWrap(wrap)
486
519
  const node = imageAltNode(img, wire)
487
520
  if (node) node.hidden = true
488
521
  }
@@ -1247,6 +1280,13 @@ const CLIENT_OPS = Object.freeze({
1247
1280
  // its first focusable descendant (opened-menu → first menuitem).
1248
1281
  focus: (el) => el.focus?.(),
1249
1282
  focus_first: (el) => firstFocusable(el)?.focus?.(),
1283
+ // Issue #399: blur the focused element when it is the target or inside it
1284
+ // (Escape ends a menu's :focus-within); focus elsewhere is left alone.
1285
+ // ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
1286
+ blur: (el) => {
1287
+ const active = el.ownerDocument?.activeElement ?? globalThis.document?.activeElement
1288
+ if (active && (active === el || el.contains?.(active))) active.blur?.()
1289
+ },
1250
1290
 
1251
1291
  // Modal dialog ops (issue #365): HTMLDialogElement#showModal / #close.
1252
1292
  // ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
@@ -1429,8 +1469,18 @@ function previewFile(input, args, resolveTargets) {
1429
1469
  // valid: true (issue #382): an input the app marked invalid (setCustomValidity,
1430
1470
  // required) previews as empty. validity.valid, not checkValidity(): no
1431
1471
  // `invalid` event fired into the app's own listeners.
1432
- const files = args.valid === true && input.validity?.valid === false ? [] : (input.files ?? [])
1433
- const file = [...files].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
1472
+ const files = [...(args.valid === true && input.validity?.valid === false ? [] : (input.files ?? []))]
1473
+ const accepts = previewAccepts(input, args.accept)
1474
+ const listed = files.filter((f) => typeof f?.type === "string" && accepts(f))
1475
+ // Only an image is painted: a listed PDF clears the preview, unrefused.
1476
+ let file = listed.find((f) => f.type.startsWith("image/"))
1477
+ // A refused file (issue #391) clears like an empty pick and says why. The
1478
+ // input keeps its files and its validity: the page owns the reset. Size is
1479
+ // read BEFORE an object URL exists, so an over-size file never gets one.
1480
+ let reason = files.length > 0 && listed.length === 0 ? "type" : null
1481
+ if (file && Number.isInteger(args.max_bytes) && file.size > args.max_bytes) reason = "size"
1482
+ const refused = reason === "size" ? file : files[0]
1483
+ if (reason) file = null
1434
1484
  forgetPreview(img)
1435
1485
  if (file) {
1436
1486
  const url = URL.createObjectURL(file)
@@ -1439,8 +1489,40 @@ function previewFile(input, args, resolveTargets) {
1439
1489
  watchPreviews()
1440
1490
  } else img.removeAttribute("src")
1441
1491
  resetImage(img)
1442
- if (args.name == null) return
1443
- for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1492
+ if (args.wrap != null) {
1493
+ const wraps = resolveTargets({ ...args, to: args.wrap })
1494
+ if (wraps.length === 0) warnPreview(`wrap: ${JSON.stringify(args.wrap)} is not on the page`)
1495
+ for (const wrap of wraps) file ? showWrap(wrap) : hideWrap(wrap)
1496
+ }
1497
+ if (args.name != null) {
1498
+ for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1499
+ }
1500
+ if (reason) {
1501
+ input.dispatchEvent(new CustomEvent("reactive:file-rejected", { bubbles: true, detail: { reason, file: refused } }))
1502
+ }
1503
+ }
1504
+
1505
+ // Which files the page takes: an image by default. accept: true reads the
1506
+ // input's own accept attribute; a string is that list for this op. A token is
1507
+ // an extension (".png"), a MIME type or a type/* wildcard, the browser's own
1508
+ // accept grammar. An absent or empty list keeps the image rule.
1509
+ function previewAccepts(input, accept) {
1510
+ const tokens = String((accept === true ? input.getAttribute("accept") : typeof accept === "string" && accept) || "")
1511
+ .toLowerCase()
1512
+ .split(",")
1513
+ .map((token) => token.trim())
1514
+ .filter(Boolean)
1515
+ if (tokens.length === 0) return (f) => f.type.startsWith("image/")
1516
+ return (f) => {
1517
+ const type = f.type.toLowerCase()
1518
+ return tokens.some((token) =>
1519
+ token[0] === "."
1520
+ ? String(f.name).toLowerCase().endsWith(token)
1521
+ : token.endsWith("/*")
1522
+ ? type.startsWith(token.slice(0, -1))
1523
+ : type === token,
1524
+ )
1525
+ }
1444
1526
  }
1445
1527
 
1446
1528
  function warnPreview(why) {
@@ -1988,6 +2070,11 @@ function presenceNeeded(root) {
1988
2070
  return root.getAttribute?.("data-reactive-presence") != null
1989
2071
  }
1990
2072
 
2073
+ // Countdowns (issue #400): the root is, or holds, a reactive_countdown.
2074
+ function countdownNeeded(root) {
2075
+ return Boolean(root.matches?.("[data-reactive-countdown]") || root.querySelector?.("[data-reactive-countdown]"))
2076
+ }
2077
+
1991
2078
  const PRODUCTION_FEATURES = [
1992
2079
  // (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
1993
2080
  // writes what a request reads, so it gates.
@@ -2003,6 +2090,8 @@ const PRODUCTION_FEATURES = [
2003
2090
  // Its announce is a request of its own; nothing it does at connect is read
2004
2091
  // by another request: it never gates.
2005
2092
  ["presence", [presenceNeeded, null]],
2093
+ // A countdown writes only its own text and visibility: it never gates.
2094
+ ["countdown", [countdownNeeded, null]],
2006
2095
  [
2007
2096
  // Stream effects and dismissing flashes are document-level: the module
2008
2097
  // has no connect. A root that DECLARES an effect only gets it fetched
@@ -2166,7 +2255,7 @@ function featureTimeoutMs() {
2166
2255
  export function reactiveFeatureNames() {
2167
2256
  return __SPLIT__
2168
2257
  ? [...FEATURES.keys()]
2169
- : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
2258
+ : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "countdown", "effects", "hints", "devtools"]
2170
2259
  }
2171
2260
 
2172
2261
  export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
@@ -2190,6 +2279,7 @@ export function __loadReactiveFeatureForTest(name) {
2190
2279
  compute: computeFeature,
2191
2280
  sortable: sortableFeature,
2192
2281
  presence: presenceFeature,
2282
+ countdown: countdownFeature,
2193
2283
  effects: effectsFeature,
2194
2284
  hints: hintsFeature,
2195
2285
  devtools: devtoolsFeature,
@@ -2244,6 +2334,7 @@ const BINDINGS = 8
2244
2334
  const COMPUTE = 16
2245
2335
  const SORTABLE = 32
2246
2336
  const PRESENCE = 64
2337
+ const COUNTDOWN = 128
2247
2338
 
2248
2339
  // The default entry's features are part of this file: install them now, as
2249
2340
  // the opt-in entry does when one arrives.
@@ -2269,6 +2360,10 @@ const WINDOW_EARLY_TTL_MS = 1500
2269
2360
  // which a replay built from its trigger element's document carries).
2270
2361
  const isWindow = (target) => target != null && (target === globalThis.window || target.window === target)
2271
2362
 
2363
+ // Issue #393: the key on()'s dispatch debounce uses in #debounceTimers. An
2364
+ // on_client record keys on its JSON, which starts with "{", so never this.
2365
+ const DISPATCH_WAIT_KEY = "dispatch"
2366
+
2272
2367
  function earlyState() {
2273
2368
  globalThis[EARLY_KEY] ??= { queue: [], connected: new WeakSet() }
2274
2369
  return globalThis[EARLY_KEY]
@@ -2425,8 +2520,8 @@ export default class extends Controller {
2425
2520
  }
2426
2521
 
2427
2522
  #tokenCache // freshest token, threaded synchronously across queued requests
2428
- #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
2429
- #throttleTimers = new Map() // trigger element -> Map(action -> suppression timer)
2523
+ #debounceTimers = new Map() // trigger element -> Map(key -> { timer, flush }) pending run
2524
+ #throttleTimers = new Map() // trigger element -> Map(action or record key -> suppression timer)
2430
2525
  #actionPathCache // page-stable action path, resolved once per controller
2431
2526
  #timeoutMsCache // page-stable request timeout (ms), resolved once per controller (issue #101)
2432
2527
  #tokenRegexCache // { id, token, self } — #extractToken's two per-id RegExps, rebuilt on id change (issue #118)
@@ -2465,7 +2560,7 @@ export default class extends Controller {
2465
2560
  #featureTurns = new Map() // name -> settles once that feature has had its turn to connect
2466
2561
  #featureEpoch = 0
2467
2562
  #featureHandle // the `core` handle features receive, built on first use
2468
- // The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
2563
+ // The default entry (issue #305): one bit per feature, PERSIST … COUNTDOWN —
2469
2564
  // asked for (its marker read true) and connected on this connection.
2470
2565
  #featuresAsked = 0
2471
2566
  #featuresOn = 0
@@ -2568,6 +2663,7 @@ export default class extends Controller {
2568
2663
  this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
2569
2664
  this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
2570
2665
  this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
2666
+ this.#connectFeatureNow(COUNTDOWN, "countdown", countdownNeeded, (core) => countdownFeature.connect(this, core))
2571
2667
  }
2572
2668
  }
2573
2669
 
@@ -2606,6 +2702,7 @@ export default class extends Controller {
2606
2702
  if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
2607
2703
  if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
2608
2704
  if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
2705
+ if (on & COUNTDOWN) this.#disconnectFeatureNow("countdown", () => countdownFeature.disconnect(this, core))
2609
2706
  }
2610
2707
  }
2611
2708
 
@@ -2801,6 +2898,8 @@ export default class extends Controller {
2801
2898
  served: serverText,
2802
2899
  // Copy one <img> onto another (issue #373): the reactive_media binding.
2803
2900
  copyMedia,
2901
+ // set_value's write (issue #374): reactive_show clear: (issue #394).
2902
+ setValue: setFieldValue,
2804
2903
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
2805
2904
  // (a synchronous throw is a cancel), never a rejection out.
2806
2905
  confirm: (message, context) =>
@@ -3233,13 +3332,21 @@ export default class extends Controller {
3233
3332
  if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
3234
3333
  continue
3235
3334
  if (onceBindingSpent(this, event, record)) continue
3236
- this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
3335
+ // A legacy attr reads its flags element-wide — but never a mix-ed on()'s
3336
+ // debounce/throttle params (issue #393): those belong to the dispatch.
3337
+ this.#runBinding(
3338
+ record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined } : record,
3339
+ event,
3340
+ trigger,
3341
+ records.indexOf(record),
3342
+ )
3237
3343
  }
3238
3344
  }
3239
3345
 
3240
3346
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
3241
3347
  // confirm gate, then its ops — every flag read from the binding itself.
3242
- #runBinding({ ops, confirm, confirmWhen, outside, window: windowBound, shortcut }, event, trigger) {
3348
+ #runBinding(record, event, trigger, ordinal) {
3349
+ const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut } = record
3243
3350
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
3244
3351
  // outside: trigger is a COMPLETE no-op for events inside this root, before
3245
3352
  // preventDefault and before any op runs.
@@ -3262,7 +3369,7 @@ export default class extends Controller {
3262
3369
  // which must NEVER prompt. The same confirmResolver gate on(:action,
3263
3370
  // confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
3264
3371
  const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
3265
- const apply = () => this.#applyOps(this.#parseOps(ops))
3372
+ const apply = this.#waitedOps(record, ordinal, trigger, () => this.#applyOps(this.#parseOps(ops)))
3266
3373
  if (!message) return apply()
3267
3374
  if (message instanceof Promise) {
3268
3375
  return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
@@ -3270,6 +3377,20 @@ export default class extends Controller {
3270
3377
  this.#confirmThen(message, trigger, apply)
3271
3378
  }
3272
3379
 
3380
+ // Issue #393: an on_client record's debounce:/throttle: — on()'s timers
3381
+ // (#debounce trailing, #throttle leading), keyed on the trigger + the
3382
+ // record's ordinal + JSON (two identical mix-ed records keep their own
3383
+ // timers), so disconnect clears them with the dispatch ones. The wait sits
3384
+ // after the outside guard, preventDefault and confirm, as on()'s does.
3385
+ #waitedOps(record, ordinal, trigger, run) {
3386
+ const debounce = Number(record.debounce) || 0
3387
+ const throttle = Number(record.throttle) || 0
3388
+ if (debounce <= 0 && throttle <= 0) return run
3389
+ const key = JSON.stringify([ordinal, record])
3390
+ if (debounce > 0) return () => this.#debounce(trigger, key, debounce, run)
3391
+ return () => this.#throttle(trigger, key, throttle, run)
3392
+ }
3393
+
3273
3394
  // Client-side compute (issue #104) is the compute feature's (issue #275,
3274
3395
  // features/compute.js). This is the method its descriptor names
3275
3396
  // (input->reactive#recompute), so it stays. While the module is on its way
@@ -3554,30 +3675,45 @@ export default class extends Controller {
3554
3675
  // Also flush immediately on blur so leaving the field never drops the last
3555
3676
  // edit (a long debounce shouldn't swallow a value the user tabbed away from).
3556
3677
  #debounceDispatch(target, ms, action, params, optimistic, busy) {
3557
- this.#clearDebounce(target)
3678
+ this.#debounce(target, DISPATCH_WAIT_KEY, ms, () => this.#enqueue(action, params, optimistic, target, busy))
3679
+ }
3680
+
3681
+ // The one trailing-edge timer on() and on_client (issue #393) share. Timers
3682
+ // are keyed on target + key: on()'s dispatch uses DISPATCH_WAIT_KEY, an
3683
+ // on_client record its ordinal + JSON (it starts with "["), so an on() debounce and an
3684
+ // on_client debounce on one input never cancel each other. A window-bound
3685
+ // trigger gets no blur flush: a window blur is a tab switch, not an edit.
3686
+ #debounce(target, key, ms, run) {
3687
+ this.#clearDebounce(target, key)
3558
3688
 
3559
3689
  const flush = () => {
3560
- this.#clearDebounce(target)
3561
- this.#enqueue(action, params, optimistic, target, busy)
3690
+ this.#clearDebounce(target, key)
3691
+ run()
3562
3692
  }
3563
3693
  const timer = setTimeout(flush, ms)
3564
- target?.addEventListener?.("blur", flush, { once: true })
3565
- this.#debounceTimers.set(target, { timer, flush })
3694
+ if (!isWindow(target)) target?.addEventListener?.("blur", flush, { once: true })
3695
+ let pending = this.#debounceTimers.get(target)
3696
+ if (!pending) this.#debounceTimers.set(target, (pending = new Map()))
3697
+ pending.set(key, { timer, flush })
3566
3698
  }
3567
3699
 
3568
- #clearDebounce(target) {
3569
- const pending = this.#debounceTimers.get(target)
3700
+ #clearDebounce(target, key) {
3701
+ const byKey = this.#debounceTimers.get(target)
3702
+ const pending = byKey?.get(key)
3570
3703
  if (!pending) return
3571
3704
  clearTimeout(pending.timer)
3572
3705
  target?.removeEventListener?.("blur", pending.flush)
3573
- this.#debounceTimers.delete(target)
3706
+ byKey.delete(key)
3707
+ if (byKey.size === 0) this.#debounceTimers.delete(target)
3574
3708
  }
3575
3709
 
3576
3710
  // Clear every pending debounce timer (used on disconnect). Reuses
3577
3711
  // #clearDebounce so all timer/listener teardown stays in one place. Snapshot
3578
- // the keys first — #clearDebounce mutates the map as it goes.
3712
+ // the entries first — #clearDebounce mutates the maps as it goes.
3579
3713
  #clearAllDebounces() {
3580
- for (const target of [...this.#debounceTimers.keys()]) this.#clearDebounce(target)
3714
+ for (const [target, byKey] of [...this.#debounceTimers]) {
3715
+ for (const key of [...byKey.keys()]) this.#clearDebounce(target, key)
3716
+ }
3581
3717
  }
3582
3718
 
3583
3719
  // Leading-edge throttle (issue #80), mirroring #debounceDispatch: the FIRST
@@ -3587,16 +3723,22 @@ export default class extends Controller {
3587
3723
  // events all share event.target === document, so two window-bound triggers
3588
3724
  // on one component would otherwise collide on one timer.
3589
3725
  #throttleDispatch(target, ms, action, params, optimistic, busy) {
3726
+ return this.#throttle(target, action, ms, () => this.#enqueue(action, params, optimistic, target, busy))
3727
+ }
3728
+
3729
+ // The leading-edge window on() and on_client (issue #393) share; an
3730
+ // on_client record keys on its ordinal + JSON, which no action name can equal.
3731
+ #throttle(target, key, ms, run) {
3590
3732
  const timers = this.#throttleTimers.get(target) ?? new Map()
3591
- if (timers.has(action)) return // inside the window — suppress
3733
+ if (timers.has(key)) return // inside the window — suppress
3592
3734
 
3593
3735
  const timer = setTimeout(() => {
3594
- timers.delete(action)
3736
+ timers.delete(key)
3595
3737
  if (timers.size === 0) this.#throttleTimers.delete(target)
3596
3738
  }, ms)
3597
- timers.set(action, timer)
3739
+ timers.set(key, timer)
3598
3740
  this.#throttleTimers.set(target, timers)
3599
- return this.#enqueue(action, params, optimistic, target, busy) // leading edge: fire NOW
3741
+ return run() // leading edge: fire NOW
3600
3742
  }
3601
3743
 
3602
3744
  // Clear every throttle suppression timer (used on disconnect, alongside
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phlex
6
+ module Reactive
7
+ module Component
8
+ # A server deadline counted down on the client (issue #400).
9
+ #
10
+ # span(**reactive_countdown(until: deadline.iso8601, waiting: "#wait", done: "#done")) { "2:00" }
11
+ # div(id: "wait") { "Waiting" }
12
+ # div(id: "done", hidden: true) { "Finished" }
13
+ #
14
+ # The client paints the time left from until: and Date.now at connect and
15
+ # once a second ("1:30": unpadded minutes, a colon, padded seconds — the
16
+ # only format, :ms), as textContent only. A tab that wakes late jumps to
17
+ # the real time left. At zero, or with until: already past on connect, the
18
+ # text is "0:00", data-reactive-countdown-state is "done", waiting: hides,
19
+ # done: shows (CSS selectors inside the root) and the element dispatches
20
+ # reactive:countdown-done once; the timer stops. A morph that keeps until:
21
+ # does not dispatch again; one that changes it counts down again. No
22
+ # request and no frame reload: the event is the hook.
23
+ #
24
+ # The countdown's content is its text, so waiting: and done: name nodes
25
+ # beside it, never inside it: a reactive root that is itself the
26
+ # countdown has nowhere to put them.
27
+ #
28
+ # The server renders the initial text and visibility, so the page reads
29
+ # with JavaScript off. until: is an ISO8601 date-time WITH a zone (Z or an
30
+ # offset), or a Time, which is written as one: a zoneless string would
31
+ # read as the browser's local time.
32
+ module CountdownBinding
33
+ ISO8601_WITH_ZONE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
34
+
35
+ # `until` is a keyword: the value is read off the binding.
36
+ def reactive_countdown(until:, format: :ms, waiting: nil, done: nil, **attrs)
37
+ wire = { "until" => countdown_until!(binding.local_variable_get(:until)), "format" => countdown_format!(format) }
38
+ wire["waiting"] = countdown_selector!(:waiting, waiting) unless waiting.nil?
39
+ wire["done"] = countdown_selector!(:done, done) unless done.nil?
40
+ mix({ data: { reactive_countdown: wire.to_json } }, attrs)
41
+ end
42
+
43
+ private
44
+
45
+ def countdown_until!(value)
46
+ value = value.utc.iso8601(value.subsec.zero? ? 0 : 3) if value.is_a?(Time) || value.respond_to?(:time_zone)
47
+ return value if countdown_iso8601?(value)
48
+
49
+ raise ArgumentError, "reactive_countdown until: must be an ISO8601 date-time with a zone " \
50
+ "(e.g. \"2026-10-14T12:02:00Z\") or a Time, got #{value.inspect}"
51
+ end
52
+
53
+ # The shape (a zone is required), then a real date (no month 13).
54
+ def countdown_iso8601?(value)
55
+ value.is_a?(String) && value.match?(ISO8601_WITH_ZONE) && Time.iso8601(value) && true
56
+ rescue ::ArgumentError
57
+ false
58
+ end
59
+
60
+ def countdown_format!(format)
61
+ return "ms" if format == :ms
62
+
63
+ raise ArgumentError, "reactive_countdown format: must be :ms (the only format), got #{format.inspect}"
64
+ end
65
+
66
+ # A selector the client passes to querySelector inside the root.
67
+ def countdown_selector!(name, selector)
68
+ if selector == JS::ROOT_SENTINEL
69
+ raise ArgumentError,
70
+ "reactive_countdown #{name}: #{selector} names the whole root — pass the selector of a node inside it"
71
+ end
72
+ return selector if selector.is_a?(String) && !selector.strip.empty?
73
+
74
+ raise ArgumentError,
75
+ "reactive_countdown #{name}: must be a CSS selector (e.g. \"##{name}\"), got #{selector.inspect}"
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # Render-time checks for a binding that names ANOTHER field: same:
7
+ # (issue #395), after: (#396) and clear: (#394). The binding's attrs are
8
+ # built before its section's children render, so the check waits for the
9
+ # end of the OUTERMOST Phlex render in progress and reads the fields that
10
+ # render produced — a field below the binding, or inside a nested
11
+ # component, counts. An unknown name (or a file input named in clear:)
12
+ # raises ArgumentError at render, never silently in the browser.
13
+ #
14
+ # Nothing is recorded when no binding names a field, and a helper called
15
+ # outside a render (no buffer to read) is not checked. A fragment render
16
+ # (only part of the template reaches the buffer) is not checked either,
17
+ # nor is a render that calls `flush`. The scan accepts any element with
18
+ # that name, including one inside a nested reactive root the client will
19
+ # not read (ownership is the client's rule), so it catches typos, not
20
+ # misplaced fields.
21
+ module FieldReferences
22
+ REFS_KEY = :__phlex_reactive_field_refs
23
+
24
+ # A start tag and its attributes (quoted values may hold ">").
25
+ TAG = %r{<[a-zA-Z][\w-]*((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*/?>}
26
+ # One attribute of that list, walked in order so a "name=" inside
27
+ # another attribute's quoted value is never read as the name.
28
+ ATTR = %r{\s+([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?}
29
+
30
+ private
31
+
32
+ def around_template(&)
33
+ # Thread#[] is fiber-local: a nested component, or a capture inside
34
+ # this render, shares the outermost render's list. `true` marks a
35
+ # render in progress with nothing recorded yet, so a render without
36
+ # such a binding allocates nothing here.
37
+ return super if Thread.current[REFS_KEY] || !defined?(@_state) || @_state.nil?
38
+
39
+ buffer = @_state.buffer
40
+ start = buffer.bytesize
41
+ Thread.current[REFS_KEY] = true
42
+ begin
43
+ super
44
+ refs = Thread.current[REFS_KEY]
45
+ ensure
46
+ Thread.current[REFS_KEY] = nil
47
+ end
48
+ check_field_references!(refs, buffer.byteslice(start..)) if refs.is_a?(Array) && !@_state.fragments
49
+ end
50
+
51
+ public
52
+
53
+ # A flush moves the buffer out mid-render, so the end-of-render scan
54
+ # would miss fields above it: record the references as unchecked.
55
+ def flush
56
+ Thread.current[REFS_KEY] = :flushed if Thread.current[REFS_KEY]
57
+ super
58
+ end
59
+
60
+ private
61
+
62
+ # Record a binding's field references for the end-of-render check.
63
+ # `names` are bare show names (scope-aware, like the client resolver);
64
+ # `no_file` refuses a file input under the name.
65
+ def reference_fields!(helper, key, names, no_file: false)
66
+ refs = Thread.current[REFS_KEY]
67
+ return unless refs
68
+
69
+ return if refs == :flushed
70
+
71
+ refs = Thread.current[REFS_KEY] = [] if refs == true
72
+ names.each { refs << [helper, key, it, field_wire_name(it), no_file] }
73
+ end
74
+
75
+ # Every same: name in a binding's compiled groups (issue #395).
76
+ def reference_same_fields!(helper, groups)
77
+ names = groups.flatten.filter_map { it["same"] }
78
+ reference_fields!(helper, :same, names.uniq) if names.any?
79
+ end
80
+
81
+ # The name the client's resolver queries: scope[name] under
82
+ # reactive_scope, a bracketed name (an author's raw wire name) verbatim.
83
+ def field_wire_name(name)
84
+ name.include?("[") ? name : scoped_field_name(name)
85
+ end
86
+
87
+ def check_field_references!(refs, html)
88
+ names, files = rendered_fields(html)
89
+ refs.each do |helper, key, name, wire, no_file|
90
+ unless names.include?(wire)
91
+ raise ArgumentError,
92
+ "#{helper} #{key}: #{name.to_sym.inspect} names no field this render produced " \
93
+ "(no element with name=\"#{wire}\")"
94
+ end
95
+ next unless no_file && files.include?(wire)
96
+
97
+ raise ArgumentError,
98
+ "#{helper} #{key}: #{name.to_sym.inspect} is a file input — a file field cannot be cleared"
99
+ end
100
+ end
101
+
102
+ # The name of every element the HTML renders, and those that are
103
+ # file inputs.
104
+ def rendered_fields(html)
105
+ names = Set.new
106
+ files = Set.new
107
+ html.scan(TAG) do
108
+ attrs = tag_attributes(Regexp.last_match(1).to_s)
109
+ next unless (name = attrs["name"])
110
+
111
+ names << name
112
+ files << name if attrs["type"]&.casecmp?("file")
113
+ end
114
+ [names, files]
115
+ end
116
+
117
+ # A tag's attributes by lowercased name (HTML attribute names are
118
+ # case-insensitive); the first occurrence wins, as in the browser.
119
+ def tag_attributes(text)
120
+ attrs = {}
121
+ text.scan(ATTR) do |key, double, single, bare|
122
+ value = double || single || bare
123
+ attrs[key.downcase] ||= value.nil? ? "" : CGI.unescapeHTML(value)
124
+ end
125
+ attrs
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end