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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +269 -2
- data/README.md +92 -13
- data/app/javascript/phlex/reactive/core.js +1 -0
- data/app/javascript/phlex/reactive/core.min.js +6 -6
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/features/bindings.js +153 -21
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/countdown.js +140 -0
- data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
- data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +54 -22
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/persist.js +41 -8
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +9 -8
- data/app/javascript/phlex/reactive/runtime.js +313 -32
- data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
- data/lib/phlex/reactive/component/field_references.rb +130 -0
- data/lib/phlex/reactive/component/helpers.rb +81 -19
- data/lib/phlex/reactive/component/identity.rb +34 -1
- data/lib/phlex/reactive/component/image_fallback_binding.rb +67 -0
- data/lib/phlex/reactive/component/media_binding.rb +30 -4
- data/lib/phlex/reactive/component/media_pattern.rb +79 -0
- data/lib/phlex/reactive/component/selection.rb +24 -2
- data/lib/phlex/reactive/component/show_options.rb +91 -0
- data/lib/phlex/reactive/doctor.rb +20 -8
- data/lib/phlex/reactive/engine.rb +2 -2
- data/lib/phlex/reactive/js.rb +58 -5
- data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
- data/lib/phlex/reactive/show_conditions.rb +39 -6
- data/lib/phlex/reactive/streamable.rb +6 -3
- data/lib/phlex/reactive/version.rb +1 -1
- 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
|
-
|
|
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
|
-
|
|
1308
|
-
|
|
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
|
|
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
|
|
1416
|
-
const
|
|
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 =
|
|
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
|
-
|
|
1643
|
+
if (changed) resetImage(target)
|
|
1644
|
+
const alt = img?.getAttribute("alt") ?? ""
|
|
1424
1645
|
if (target.getAttribute("alt") !== alt) target.setAttribute("alt", alt)
|
|
1425
|
-
return
|
|
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
|
|
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 …
|
|
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
|
-
|
|
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(
|
|
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.#
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
3712
|
+
// the entries first — #clearDebounce mutates the maps as it goes.
|
|
3440
3713
|
#clearAllDebounces() {
|
|
3441
|
-
for (const target of [...this.#debounceTimers
|
|
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(
|
|
3733
|
+
if (timers.has(key)) return // inside the window — suppress
|
|
3453
3734
|
|
|
3454
3735
|
const timer = setTimeout(() => {
|
|
3455
|
-
timers.delete(
|
|
3736
|
+
timers.delete(key)
|
|
3456
3737
|
if (timers.size === 0) this.#throttleTimers.delete(target)
|
|
3457
3738
|
}, ms)
|
|
3458
|
-
timers.set(
|
|
3739
|
+
timers.set(key, timer)
|
|
3459
3740
|
this.#throttleTimers.set(target, timers)
|
|
3460
|
-
return
|
|
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
|