phlex-reactive 0.15.2 → 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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +269 -2
  3. data/README.md +92 -13
  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 +153 -21
  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/form.js +54 -22
  14. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/features/persist.js +41 -8
  17. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  18. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  19. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  20. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  21. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +9 -8
  22. data/app/javascript/phlex/reactive/runtime.js +313 -32
  23. data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
  24. data/lib/phlex/reactive/component/field_references.rb +130 -0
  25. data/lib/phlex/reactive/component/helpers.rb +81 -19
  26. data/lib/phlex/reactive/component/identity.rb +34 -1
  27. data/lib/phlex/reactive/component/image_fallback_binding.rb +67 -0
  28. data/lib/phlex/reactive/component/media_binding.rb +30 -4
  29. data/lib/phlex/reactive/component/media_pattern.rb +79 -0
  30. data/lib/phlex/reactive/component/selection.rb +24 -2
  31. data/lib/phlex/reactive/component/show_options.rb +91 -0
  32. data/lib/phlex/reactive/doctor.rb +20 -8
  33. data/lib/phlex/reactive/engine.rb +2 -2
  34. data/lib/phlex/reactive/js.rb +58 -5
  35. data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
  36. data/lib/phlex/reactive/show_conditions.rb +39 -6
  37. data/lib/phlex/reactive/streamable.rb +6 -3
  38. data/lib/phlex/reactive/version.rb +1 -1
  39. metadata +10 -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
@@ -419,6 +420,167 @@ export function __resetReactiveKeepForTest() {
419
420
  keepRegistered = false
420
421
  }
421
422
 
423
+ // --- An image fallback that recovers (issue #383) ---------------------------
424
+ // data-reactive-image-fallback='{"hide":true,"alt":"#name"}' on an <img>. On
425
+ // `error` it gets data-reactive-image-state="error" (plus `hidden` with hide,
426
+ // and its alt shown in the `alt` node of its own reactive root); on `load`,
427
+ // "loaded". A later src clears the error: copy_media and preview_file reset
428
+ // it as they write (resetImage), any src that loads clears it on `load`, and
429
+ // a morph that changes the src lets the browser try again. A morph that keeps
430
+ // the src puts the state back. An error with no src is not an error (a
431
+ // removed or emptied src). One document listener, capture phase (neither
432
+ // event bubbles); nothing per root at connect.
433
+ const IMAGE_FALLBACK = "data-reactive-image-fallback"
434
+ const IMAGE_STATE = "data-reactive-image-state"
435
+ // Marks the `hidden` the fallback set, so a reset removes only that one. On
436
+ // the element, not in a WeakSet: a Turbo snapshot restores a NEW node.
437
+ const IMAGE_HIDDEN = "data-reactive-image-hidden"
438
+ let imageFallbackListeners = null
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
+
460
+ function imageFallback(img) {
461
+ if (img?.localName !== "img") return null
462
+ const raw = img.getAttribute(IMAGE_FALLBACK)
463
+ if (raw == null) return null
464
+ try {
465
+ return JSON.parse(raw) || {}
466
+ } catch {
467
+ return {}
468
+ }
469
+ }
470
+
471
+ const hasImageSrc = (img) => img.getAttribute("src") || img.getAttribute("srcset")
472
+
473
+ // The node that shows the alt text: a selector resolved inside the img's own
474
+ // root only (a document listener has no op scope).
475
+ function imageRootNode(img, selector) {
476
+ if (typeof selector !== "string" || !selector) return null
477
+ try {
478
+ return img.closest('[data-controller~="reactive"]')?.querySelector(selector) ?? null
479
+ } catch {
480
+ return null
481
+ }
482
+ }
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
+
490
+ function setImageState(img, wire, value) {
491
+ if (value === "error") {
492
+ if (wire.hide && !img.hasAttribute("hidden")) {
493
+ img.setAttribute("hidden", "")
494
+ img.setAttribute(IMAGE_HIDDEN, "")
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
+ }
502
+ const node = imageAltNode(img, wire)
503
+ if (node) {
504
+ node.textContent = img.getAttribute("alt") ?? ""
505
+ node.hidden = false
506
+ }
507
+ } else resetImage(img, wire)
508
+ img.setAttribute(IMAGE_STATE, value)
509
+ }
510
+
511
+ // Undo what an error did — only what it did: `hidden` stays when the author
512
+ // set it. Called by copy_media and preview_file when they change the src.
513
+ function resetImage(img, wire = imageFallback(img)) {
514
+ if (!wire || img.getAttribute(IMAGE_STATE) == null) return
515
+ if (img.getAttribute(IMAGE_STATE) === "error") {
516
+ if (img.hasAttribute(IMAGE_HIDDEN)) img.removeAttribute("hidden")
517
+ const wrap = imageRootNode(img, wire.wrap)
518
+ if (wrap) showWrap(wrap)
519
+ const node = imageAltNode(img, wire)
520
+ if (node) node.hidden = true
521
+ }
522
+ img.removeAttribute(IMAGE_STATE)
523
+ img.removeAttribute(IMAGE_HIDDEN)
524
+ }
525
+
526
+ function onImageEvent(event) {
527
+ const img = event.target
528
+ const wire = imageFallback(img)
529
+ if (!wire) return
530
+ if (event.type === "load") setImageState(img, wire, "loaded")
531
+ else if (hasImageSrc(img)) setImageState(img, wire, "error")
532
+ else resetImage(img, wire)
533
+ }
534
+
535
+ export function registerReactiveImageFallback() {
536
+ if (imageFallbackListeners) return
537
+ if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
538
+ // A morph that keeps the src keeps the state: the render carries none, and
539
+ // the browser loads nothing new to tell us again. Put it back once the
540
+ // whole morph is done, so a later-morphed alt node is shown again too.
541
+ const kept = new WeakMap()
542
+ imageFallbackListeners = {
543
+ error: onImageEvent,
544
+ load: onImageEvent,
545
+ "turbo:before-morph-element": (event) => {
546
+ const img = event.target
547
+ const next = event.detail?.newElement
548
+ const value = img?.getAttribute?.(IMAGE_STATE)
549
+ if (value == null || !next || !imageFallback(img)) return
550
+ const same = (name) => img.getAttribute(name) === next.getAttribute?.(name)
551
+ if (same("src") && same("srcset")) kept.set(img, value)
552
+ },
553
+ "turbo:morph-element": (event) => {
554
+ const img = event.target
555
+ const value = kept.get(img)
556
+ if (value === undefined) return
557
+ kept.delete(img)
558
+ queueMicrotask(() => {
559
+ const wire = imageFallback(img)
560
+ if (wire) setImageState(img, wire, value)
561
+ })
562
+ },
563
+ }
564
+ for (const [name, fn] of Object.entries(imageFallbackListeners)) document.addEventListener(name, fn, true)
565
+ // An image that finished before this ran (a cached 404) fired its event
566
+ // unheard: read it off the element once. naturalWidth 0 is also a loaded
567
+ // SVG with only a viewBox, so decode() decides (it rejects a broken image).
568
+ for (const img of document.querySelectorAll?.(`img[${IMAGE_FALLBACK}]`) ?? []) {
569
+ if (!img.complete || !hasImageSrc(img)) continue
570
+ // A decode() that settles after the src changed decoded the OLD one: ignore it.
571
+ const was = `${img.getAttribute("src")} ${img.getAttribute("srcset")}`
572
+ const mark = (value) => `${img.getAttribute("src")} ${img.getAttribute("srcset")}` === was && setImageState(img, imageFallback(img) ?? {}, value)
573
+ if (img.naturalWidth > 0) mark("loaded")
574
+ else if (typeof img.decode === "function") img.decode().then(() => mark("loaded"), () => mark("error"))
575
+ else mark("error")
576
+ }
577
+ }
578
+
579
+ export function __resetReactiveImageFallbackForTest() {
580
+ for (const [name, fn] of Object.entries(imageFallbackListeners ?? {})) document.removeEventListener(name, fn, true)
581
+ imageFallbackListeners = null
582
+ }
583
+
422
584
  // For the opt-in entry: what to do with a stream before its render — hold it
423
585
  // for a module the incoming content needs. `handle(event, effects)` gets the
424
586
  // loaded effects module (or undefined) and wraps the stream with it itself.
@@ -860,6 +1022,7 @@ export function registerReactiveActions() {
860
1022
  registerReactiveMorphFocus()
861
1023
  registerReactiveModals()
862
1024
  registerReactiveKeep()
1025
+ registerReactiveImageFallback()
863
1026
  registerReactivePlaintext()
864
1027
  registerReactiveOffline()
865
1028
  registerReactiveDev()
@@ -1117,6 +1280,13 @@ const CLIENT_OPS = Object.freeze({
1117
1280
  // its first focusable descendant (opened-menu → first menuitem).
1118
1281
  focus: (el) => el.focus?.(),
1119
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
+ },
1120
1290
 
1121
1291
  // Modal dialog ops (issue #365): HTMLDialogElement#showModal / #close.
1122
1292
  // ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
@@ -1296,7 +1466,21 @@ function previewFile(input, args, resolveTargets) {
1296
1466
  if (img?.tagName !== "IMG") {
1297
1467
  return warnPreview(`paints an <img>, and ${JSON.stringify(args.into)} is ${img ? `a <${img.localName}>` : "not on the page"}`)
1298
1468
  }
1299
- const file = [...(input.files ?? [])].find((f) => typeof f?.type === "string" && f.type.startsWith("image/"))
1469
+ // valid: true (issue #382): an input the app marked invalid (setCustomValidity,
1470
+ // required) previews as empty. validity.valid, not checkValidity(): no
1471
+ // `invalid` event fired into the app's own listeners.
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
1300
1484
  forgetPreview(img)
1301
1485
  if (file) {
1302
1486
  const url = URL.createObjectURL(file)
@@ -1304,8 +1488,41 @@ function previewFile(input, args, resolveTargets) {
1304
1488
  img.setAttribute("src", url)
1305
1489
  watchPreviews()
1306
1490
  } else img.removeAttribute("src")
1307
- if (args.name == null) return
1308
- for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1491
+ resetImage(img)
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
+ }
1309
1526
  }
1310
1527
 
1311
1528
  function warnPreview(why) {
@@ -1404,7 +1621,9 @@ function guardAttr(name) {
1404
1621
  // copy_media op and the reactive_media binding (the bindings feature reaches it
1405
1622
  // through the core handle). Only an <img> target is written, so an iframe,
1406
1623
  // script, object, embed or frame never receives a src from here. A source that
1407
- // is missing, not an <img> or has no src clears the target. Each attribute is
1624
+ // is missing or not an <img> clears the target. An <img> source without src is
1625
+ // a hit with no image (issue #381): the target loses src and srcset but takes
1626
+ // the alt, so a known entry without a logo keeps its name. Each attribute is
1408
1627
  // written only when it changes, so the same URL twice does not reload. Returns
1409
1628
  // the alt copied, false when it cleared, or null when the target was skipped.
1410
1629
  function copyMedia(target, source) {
@@ -1412,17 +1631,19 @@ function copyMedia(target, source) {
1412
1631
  console.warn(`[phlex-reactive] copy_media targets an <img>, not <${target?.localName}> — skipped`)
1413
1632
  return null
1414
1633
  }
1415
- const src = source?.localName === "img" ? source.getAttribute("src") : null
1416
- const pick = (name) => (src ? source.getAttribute(name) : null)
1634
+ const img = source?.localName === "img" ? source : null
1635
+ const src = img?.getAttribute("src")
1636
+ let changed = false
1417
1637
  for (const name of ["srcset", "src"]) {
1418
- const value = pick(name)
1638
+ const value = src ? img.getAttribute(name) : null
1419
1639
  if (value == null) {
1420
- if (target.hasAttribute(name)) target.removeAttribute(name)
1421
- } else if (target.getAttribute(name) !== value) target.setAttribute(name, value)
1640
+ if (target.hasAttribute(name)) changed = target.removeAttribute(name) ?? true
1641
+ } else if (target.getAttribute(name) !== value) changed = target.setAttribute(name, value) ?? true
1422
1642
  }
1423
- const alt = pick("alt") ?? ""
1643
+ if (changed) resetImage(target)
1644
+ const alt = img?.getAttribute("alt") ?? ""
1424
1645
  if (target.getAttribute("alt") !== alt) target.setAttribute("alt", alt)
1425
- return src ? alt : false
1646
+ return img ? alt : false
1426
1647
  }
1427
1648
 
1428
1649
  // The first focusable descendant of `el`, in document order — the natural
@@ -1849,6 +2070,11 @@ function presenceNeeded(root) {
1849
2070
  return root.getAttribute?.("data-reactive-presence") != null
1850
2071
  }
1851
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
+
1852
2078
  const PRODUCTION_FEATURES = [
1853
2079
  // (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
1854
2080
  // writes what a request reads, so it gates.
@@ -1864,6 +2090,8 @@ const PRODUCTION_FEATURES = [
1864
2090
  // Its announce is a request of its own; nothing it does at connect is read
1865
2091
  // by another request: it never gates.
1866
2092
  ["presence", [presenceNeeded, null]],
2093
+ // A countdown writes only its own text and visibility: it never gates.
2094
+ ["countdown", [countdownNeeded, null]],
1867
2095
  [
1868
2096
  // Stream effects and dismissing flashes are document-level: the module
1869
2097
  // has no connect. A root that DECLARES an effect only gets it fetched
@@ -2027,7 +2255,7 @@ function featureTimeoutMs() {
2027
2255
  export function reactiveFeatureNames() {
2028
2256
  return __SPLIT__
2029
2257
  ? [...FEATURES.keys()]
2030
- : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
2258
+ : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "countdown", "effects", "hints", "devtools"]
2031
2259
  }
2032
2260
 
2033
2261
  export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
@@ -2051,6 +2279,7 @@ export function __loadReactiveFeatureForTest(name) {
2051
2279
  compute: computeFeature,
2052
2280
  sortable: sortableFeature,
2053
2281
  presence: presenceFeature,
2282
+ countdown: countdownFeature,
2054
2283
  effects: effectsFeature,
2055
2284
  hints: hintsFeature,
2056
2285
  devtools: devtoolsFeature,
@@ -2105,6 +2334,7 @@ const BINDINGS = 8
2105
2334
  const COMPUTE = 16
2106
2335
  const SORTABLE = 32
2107
2336
  const PRESENCE = 64
2337
+ const COUNTDOWN = 128
2108
2338
 
2109
2339
  // The default entry's features are part of this file: install them now, as
2110
2340
  // the opt-in entry does when one arrives.
@@ -2130,6 +2360,10 @@ const WINDOW_EARLY_TTL_MS = 1500
2130
2360
  // which a replay built from its trigger element's document carries).
2131
2361
  const isWindow = (target) => target != null && (target === globalThis.window || target.window === target)
2132
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
+
2133
2367
  function earlyState() {
2134
2368
  globalThis[EARLY_KEY] ??= { queue: [], connected: new WeakSet() }
2135
2369
  return globalThis[EARLY_KEY]
@@ -2286,8 +2520,8 @@ export default class extends Controller {
2286
2520
  }
2287
2521
 
2288
2522
  #tokenCache // freshest token, threaded synchronously across queued requests
2289
- #debounceTimers = new Map() // trigger element -> { timer, flush } pending dispatch
2290
- #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)
2291
2525
  #actionPathCache // page-stable action path, resolved once per controller
2292
2526
  #timeoutMsCache // page-stable request timeout (ms), resolved once per controller (issue #101)
2293
2527
  #tokenRegexCache // { id, token, self } — #extractToken's two per-id RegExps, rebuilt on id change (issue #118)
@@ -2326,7 +2560,7 @@ export default class extends Controller {
2326
2560
  #featureTurns = new Map() // name -> settles once that feature has had its turn to connect
2327
2561
  #featureEpoch = 0
2328
2562
  #featureHandle // the `core` handle features receive, built on first use
2329
- // The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
2563
+ // The default entry (issue #305): one bit per feature, PERSIST … COUNTDOWN —
2330
2564
  // asked for (its marker read true) and connected on this connection.
2331
2565
  #featuresAsked = 0
2332
2566
  #featuresOn = 0
@@ -2429,6 +2663,7 @@ export default class extends Controller {
2429
2663
  this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
2430
2664
  this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
2431
2665
  this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
2666
+ this.#connectFeatureNow(COUNTDOWN, "countdown", countdownNeeded, (core) => countdownFeature.connect(this, core))
2432
2667
  }
2433
2668
  }
2434
2669
 
@@ -2467,6 +2702,7 @@ export default class extends Controller {
2467
2702
  if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
2468
2703
  if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
2469
2704
  if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
2705
+ if (on & COUNTDOWN) this.#disconnectFeatureNow("countdown", () => countdownFeature.disconnect(this, core))
2470
2706
  }
2471
2707
  }
2472
2708
 
@@ -2662,6 +2898,8 @@ export default class extends Controller {
2662
2898
  served: serverText,
2663
2899
  // Copy one <img> onto another (issue #373): the reactive_media binding.
2664
2900
  copyMedia,
2901
+ // set_value's write (issue #374): reactive_show clear: (issue #394).
2902
+ setValue: setFieldValue,
2665
2903
  // The confirm gate, as dispatch() runs it: the resolver inside the chain
2666
2904
  // (a synchronous throw is a cancel), never a rejection out.
2667
2905
  confirm: (message, context) =>
@@ -3094,13 +3332,21 @@ export default class extends Controller {
3094
3332
  if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
3095
3333
  continue
3096
3334
  if (onceBindingSpent(this, event, record)) continue
3097
- 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
+ )
3098
3343
  }
3099
3344
  }
3100
3345
 
3101
3346
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
3102
3347
  // confirm gate, then its ops — every flag read from the binding itself.
3103
- #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
3104
3350
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
3105
3351
  // outside: trigger is a COMPLETE no-op for events inside this root, before
3106
3352
  // preventDefault and before any op runs.
@@ -3123,7 +3369,7 @@ export default class extends Controller {
3123
3369
  // which must NEVER prompt. The same confirmResolver gate on(:action,
3124
3370
  // confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
3125
3371
  const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
3126
- const apply = () => this.#applyOps(this.#parseOps(ops))
3372
+ const apply = this.#waitedOps(record, ordinal, trigger, () => this.#applyOps(this.#parseOps(ops)))
3127
3373
  if (!message) return apply()
3128
3374
  if (message instanceof Promise) {
3129
3375
  return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
@@ -3131,6 +3377,20 @@ export default class extends Controller {
3131
3377
  this.#confirmThen(message, trigger, apply)
3132
3378
  }
3133
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
+
3134
3394
  // Client-side compute (issue #104) is the compute feature's (issue #275,
3135
3395
  // features/compute.js). This is the method its descriptor names
3136
3396
  // (input->reactive#recompute), so it stays. While the module is on its way
@@ -3415,30 +3675,45 @@ export default class extends Controller {
3415
3675
  // Also flush immediately on blur so leaving the field never drops the last
3416
3676
  // edit (a long debounce shouldn't swallow a value the user tabbed away from).
3417
3677
  #debounceDispatch(target, ms, action, params, optimistic, busy) {
3418
- 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)
3419
3688
 
3420
3689
  const flush = () => {
3421
- this.#clearDebounce(target)
3422
- this.#enqueue(action, params, optimistic, target, busy)
3690
+ this.#clearDebounce(target, key)
3691
+ run()
3423
3692
  }
3424
3693
  const timer = setTimeout(flush, ms)
3425
- target?.addEventListener?.("blur", flush, { once: true })
3426
- 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 })
3427
3698
  }
3428
3699
 
3429
- #clearDebounce(target) {
3430
- const pending = this.#debounceTimers.get(target)
3700
+ #clearDebounce(target, key) {
3701
+ const byKey = this.#debounceTimers.get(target)
3702
+ const pending = byKey?.get(key)
3431
3703
  if (!pending) return
3432
3704
  clearTimeout(pending.timer)
3433
3705
  target?.removeEventListener?.("blur", pending.flush)
3434
- this.#debounceTimers.delete(target)
3706
+ byKey.delete(key)
3707
+ if (byKey.size === 0) this.#debounceTimers.delete(target)
3435
3708
  }
3436
3709
 
3437
3710
  // Clear every pending debounce timer (used on disconnect). Reuses
3438
3711
  // #clearDebounce so all timer/listener teardown stays in one place. Snapshot
3439
- // the keys first — #clearDebounce mutates the map as it goes.
3712
+ // the entries first — #clearDebounce mutates the maps as it goes.
3440
3713
  #clearAllDebounces() {
3441
- 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
+ }
3442
3717
  }
3443
3718
 
3444
3719
  // Leading-edge throttle (issue #80), mirroring #debounceDispatch: the FIRST
@@ -3448,16 +3723,22 @@ export default class extends Controller {
3448
3723
  // events all share event.target === document, so two window-bound triggers
3449
3724
  // on one component would otherwise collide on one timer.
3450
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) {
3451
3732
  const timers = this.#throttleTimers.get(target) ?? new Map()
3452
- if (timers.has(action)) return // inside the window — suppress
3733
+ if (timers.has(key)) return // inside the window — suppress
3453
3734
 
3454
3735
  const timer = setTimeout(() => {
3455
- timers.delete(action)
3736
+ timers.delete(key)
3456
3737
  if (timers.size === 0) this.#throttleTimers.delete(target)
3457
3738
  }, ms)
3458
- timers.set(action, timer)
3739
+ timers.set(key, timer)
3459
3740
  this.#throttleTimers.set(target, timers)
3460
- return this.#enqueue(action, params, optimistic, target, busy) // leading edge: fire NOW
3741
+ return run() // leading edge: fire NOW
3461
3742
  }
3462
3743
 
3463
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