phlex-reactive 0.15.3 → 0.15.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +184 -0
- data/README.md +92 -12
- 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 +129 -16
- 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/persist.js +10 -6
- 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 +8 -7
- data/app/javascript/phlex/reactive/runtime.js +255 -31
- 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 +72 -14
- data/lib/phlex/reactive/component/image_fallback_binding.rb +18 -5
- data/lib/phlex/reactive/component/media_binding.rb +9 -1
- data/lib/phlex/reactive/component/media_pattern.rb +17 -17
- data/lib/phlex/reactive/component/selection.rb +24 -2
- data/lib/phlex/reactive/component/show_options.rb +91 -0
- data/lib/phlex/reactive/engine.rb +2 -2
- data/lib/phlex/reactive/js.rb +44 -2
- data/lib/phlex/reactive/response.rb +3 -4
- 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 -4
- data/lib/phlex/reactive/version.rb +1 -1
- 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
|
|
455
|
-
if (typeof
|
|
475
|
+
function imageRootNode(img, selector) {
|
|
476
|
+
if (typeof selector !== "string" || !selector) return null
|
|
456
477
|
try {
|
|
457
|
-
|
|
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
|
|
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.
|
|
1443
|
-
|
|
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) {
|
|
@@ -1939,6 +2021,30 @@ function deferNeeded(root) {
|
|
|
1939
2021
|
)
|
|
1940
2022
|
}
|
|
1941
2023
|
|
|
2024
|
+
// Issue #410: a tokenless CLIENT-ONLY root (a ClientBindings component: filter,
|
|
2025
|
+
// show, on_client) has no action of its own. It is transparent to server round
|
|
2026
|
+
// trips: a server trigger inside it dispatches on the nearest token-bearing root
|
|
2027
|
+
// above, and that root's #collectFields looks through it. A lazy or deferred
|
|
2028
|
+
// shell is tokenless by design and is NOT client-only.
|
|
2029
|
+
const AWAKE_ROOT = '[data-controller~="reactive"]'
|
|
2030
|
+
const ANY_ROOT = '[data-controller~="reactive"],[data-reactive-dormant~="reactive"]'
|
|
2031
|
+
|
|
2032
|
+
const clientOnlyRoot = (root) => root.getAttribute?.("data-reactive-token-value") == null && !deferNeeded(root)
|
|
2033
|
+
|
|
2034
|
+
// The first `selector` root at or above `from` that is not client-only, or
|
|
2035
|
+
// `stop` when the walk reaches it first, or null.
|
|
2036
|
+
function serverRoot(from, selector, stop) {
|
|
2037
|
+
let root = from?.closest?.(selector)
|
|
2038
|
+
while (root && root !== stop && clientOnlyRoot(root)) root = root.parentElement?.closest?.(selector)
|
|
2039
|
+
return root ?? null
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
// Connected controllers by root element, so a client-only root can hand a
|
|
2043
|
+
// dispatch to the server root above it. A dispatch for a root that has not
|
|
2044
|
+
// connected yet (a dormant root being woken) waits here until it does.
|
|
2045
|
+
const rootControllers = new WeakMap()
|
|
2046
|
+
const heldForwards = new WeakMap()
|
|
2047
|
+
|
|
1942
2048
|
// Dirty tracking with its navigate-away guard, and the paste-trigger gate:
|
|
1943
2049
|
// the root or anything inside it carries one of the two markers. (A nested
|
|
1944
2050
|
// root's marker loads the module for its parent too; the module then checks
|
|
@@ -1988,6 +2094,11 @@ function presenceNeeded(root) {
|
|
|
1988
2094
|
return root.getAttribute?.("data-reactive-presence") != null
|
|
1989
2095
|
}
|
|
1990
2096
|
|
|
2097
|
+
// Countdowns (issue #400): the root is, or holds, a reactive_countdown.
|
|
2098
|
+
function countdownNeeded(root) {
|
|
2099
|
+
return Boolean(root.matches?.("[data-reactive-countdown]") || root.querySelector?.("[data-reactive-countdown]"))
|
|
2100
|
+
}
|
|
2101
|
+
|
|
1991
2102
|
const PRODUCTION_FEATURES = [
|
|
1992
2103
|
// (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
|
|
1993
2104
|
// writes what a request reads, so it gates.
|
|
@@ -2003,6 +2114,8 @@ const PRODUCTION_FEATURES = [
|
|
|
2003
2114
|
// Its announce is a request of its own; nothing it does at connect is read
|
|
2004
2115
|
// by another request: it never gates.
|
|
2005
2116
|
["presence", [presenceNeeded, null]],
|
|
2117
|
+
// A countdown writes only its own text and visibility: it never gates.
|
|
2118
|
+
["countdown", [countdownNeeded, null]],
|
|
2006
2119
|
[
|
|
2007
2120
|
// Stream effects and dismissing flashes are document-level: the module
|
|
2008
2121
|
// has no connect. A root that DECLARES an effect only gets it fetched
|
|
@@ -2166,7 +2279,7 @@ function featureTimeoutMs() {
|
|
|
2166
2279
|
export function reactiveFeatureNames() {
|
|
2167
2280
|
return __SPLIT__
|
|
2168
2281
|
? [...FEATURES.keys()]
|
|
2169
|
-
: ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
|
|
2282
|
+
: ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "countdown", "effects", "hints", "devtools"]
|
|
2170
2283
|
}
|
|
2171
2284
|
|
|
2172
2285
|
export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
|
|
@@ -2190,6 +2303,7 @@ export function __loadReactiveFeatureForTest(name) {
|
|
|
2190
2303
|
compute: computeFeature,
|
|
2191
2304
|
sortable: sortableFeature,
|
|
2192
2305
|
presence: presenceFeature,
|
|
2306
|
+
countdown: countdownFeature,
|
|
2193
2307
|
effects: effectsFeature,
|
|
2194
2308
|
hints: hintsFeature,
|
|
2195
2309
|
devtools: devtoolsFeature,
|
|
@@ -2244,6 +2358,7 @@ const BINDINGS = 8
|
|
|
2244
2358
|
const COMPUTE = 16
|
|
2245
2359
|
const SORTABLE = 32
|
|
2246
2360
|
const PRESENCE = 64
|
|
2361
|
+
const COUNTDOWN = 128
|
|
2247
2362
|
|
|
2248
2363
|
// The default entry's features are part of this file: install them now, as
|
|
2249
2364
|
// the opt-in entry does when one arrives.
|
|
@@ -2269,6 +2384,10 @@ const WINDOW_EARLY_TTL_MS = 1500
|
|
|
2269
2384
|
// which a replay built from its trigger element's document carries).
|
|
2270
2385
|
const isWindow = (target) => target != null && (target === globalThis.window || target.window === target)
|
|
2271
2386
|
|
|
2387
|
+
// Issue #393: the key on()'s dispatch debounce uses in #debounceTimers. An
|
|
2388
|
+
// on_client record keys on its JSON, which starts with "{", so never this.
|
|
2389
|
+
const DISPATCH_WAIT_KEY = "dispatch"
|
|
2390
|
+
|
|
2272
2391
|
function earlyState() {
|
|
2273
2392
|
globalThis[EARLY_KEY] ??= { queue: [], connected: new WeakSet() }
|
|
2274
2393
|
return globalThis[EARLY_KEY]
|
|
@@ -2425,8 +2544,8 @@ export default class extends Controller {
|
|
|
2425
2544
|
}
|
|
2426
2545
|
|
|
2427
2546
|
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
2428
|
-
#debounceTimers = new Map() // trigger element -> { timer, flush } pending
|
|
2429
|
-
#throttleTimers = new Map() // trigger element -> Map(action -> suppression timer)
|
|
2547
|
+
#debounceTimers = new Map() // trigger element -> Map(key -> { timer, flush }) pending run
|
|
2548
|
+
#throttleTimers = new Map() // trigger element -> Map(action or record key -> suppression timer)
|
|
2430
2549
|
#actionPathCache // page-stable action path, resolved once per controller
|
|
2431
2550
|
#timeoutMsCache // page-stable request timeout (ms), resolved once per controller (issue #101)
|
|
2432
2551
|
#tokenRegexCache // { id, token, self } — #extractToken's two per-id RegExps, rebuilt on id change (issue #118)
|
|
@@ -2465,7 +2584,7 @@ export default class extends Controller {
|
|
|
2465
2584
|
#featureTurns = new Map() // name -> settles once that feature has had its turn to connect
|
|
2466
2585
|
#featureEpoch = 0
|
|
2467
2586
|
#featureHandle // the `core` handle features receive, built on first use
|
|
2468
|
-
// The default entry (issue #305): one bit per feature, PERSIST …
|
|
2587
|
+
// The default entry (issue #305): one bit per feature, PERSIST … COUNTDOWN —
|
|
2469
2588
|
// asked for (its marker read true) and connected on this connection.
|
|
2470
2589
|
#featuresAsked = 0
|
|
2471
2590
|
#featuresOn = 0
|
|
@@ -2479,6 +2598,7 @@ export default class extends Controller {
|
|
|
2479
2598
|
// queueing them NOW — a connect-time seed below (a compute output write)
|
|
2480
2599
|
// dispatches real input events. The queue itself drains at the very end.
|
|
2481
2600
|
earlyState().connected.add(this.element)
|
|
2601
|
+
rootControllers.set(this.element, this)
|
|
2482
2602
|
|
|
2483
2603
|
// Root-id guard (issue #48). The token round trip assumes the reactive root
|
|
2484
2604
|
// element's id == component.id: the server targets component.id and the client
|
|
@@ -2568,6 +2688,7 @@ export default class extends Controller {
|
|
|
2568
2688
|
this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
|
|
2569
2689
|
this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
|
|
2570
2690
|
this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
|
|
2691
|
+
this.#connectFeatureNow(COUNTDOWN, "countdown", countdownNeeded, (core) => countdownFeature.connect(this, core))
|
|
2571
2692
|
}
|
|
2572
2693
|
}
|
|
2573
2694
|
|
|
@@ -2606,6 +2727,7 @@ export default class extends Controller {
|
|
|
2606
2727
|
if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
|
|
2607
2728
|
if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
|
|
2608
2729
|
if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
|
|
2730
|
+
if (on & COUNTDOWN) this.#disconnectFeatureNow("countdown", () => countdownFeature.disconnect(this, core))
|
|
2609
2731
|
}
|
|
2610
2732
|
}
|
|
2611
2733
|
|
|
@@ -2801,6 +2923,8 @@ export default class extends Controller {
|
|
|
2801
2923
|
served: serverText,
|
|
2802
2924
|
// Copy one <img> onto another (issue #373): the reactive_media binding.
|
|
2803
2925
|
copyMedia,
|
|
2926
|
+
// set_value's write (issue #374): reactive_show clear: (issue #394).
|
|
2927
|
+
setValue: setFieldValue,
|
|
2804
2928
|
// The confirm gate, as dispatch() runs it: the resolver inside the chain
|
|
2805
2929
|
// (a synchronous throw is a cancel), never a rejection out.
|
|
2806
2930
|
confirm: (message, context) =>
|
|
@@ -2900,6 +3024,14 @@ export default class extends Controller {
|
|
|
2900
3024
|
)
|
|
2901
3025
|
}
|
|
2902
3026
|
this.#drainEarly()
|
|
3027
|
+
// Issue #410: dispatches a client-only root inside handed over before this
|
|
3028
|
+
// root connected (a dormant root woken by that very trigger).
|
|
3029
|
+
// currentTarget is null once propagation ended: pin the trigger back on.
|
|
3030
|
+
for (const [event, target] of heldForwards.get(this.element) ?? []) {
|
|
3031
|
+
if (event.currentTarget !== target) Object.defineProperty(event, "currentTarget", { value: target, configurable: true })
|
|
3032
|
+
this.dispatch(event)
|
|
3033
|
+
}
|
|
3034
|
+
heldForwards.delete(this.element)
|
|
2903
3035
|
}
|
|
2904
3036
|
|
|
2905
3037
|
// The trigger elements whose :once descriptor THIS connection replayed —
|
|
@@ -3000,6 +3132,7 @@ export default class extends Controller {
|
|
|
3000
3132
|
}
|
|
3001
3133
|
// Early triggers (issue #273): a disconnected root records again.
|
|
3002
3134
|
earlyState().connected.delete(this.element)
|
|
3135
|
+
if (rootControllers.get(this.element) === this) rootControllers.delete(this.element)
|
|
3003
3136
|
this.element.removeAttribute?.("data-reactive-connected")
|
|
3004
3137
|
// A spent :once replay is remembered per element so a morph REPLY (root
|
|
3005
3138
|
// still connected) cannot re-arm it. A disconnect drops Stimulus's own
|
|
@@ -3017,6 +3150,12 @@ export default class extends Controller {
|
|
|
3017
3150
|
// a per-controller promise makes each dispatch wait for the previous one, so
|
|
3018
3151
|
// it always uses the freshest token.
|
|
3019
3152
|
dispatch(event) {
|
|
3153
|
+
// Issue #410: a client-only root has no action of its own — a server
|
|
3154
|
+
// trigger inside it belongs to the token-bearing root above. FIRST, so that
|
|
3155
|
+
// root runs every check below with its own element.
|
|
3156
|
+
if (event.params?.action && event.params.action !== LAZY_MATERIALIZE_ACTION && this.#clientOnly()) {
|
|
3157
|
+
return this.#forwardToServerRoot(event)
|
|
3158
|
+
}
|
|
3020
3159
|
// A shortcut (issue #357) that is the other platform's twin, or pressed
|
|
3021
3160
|
// in a field the user is typing in, is a complete no-op: the native
|
|
3022
3161
|
// default (text undo) proceeds and nothing is announced. Checked FIRST: a
|
|
@@ -3181,6 +3320,37 @@ export default class extends Controller {
|
|
|
3181
3320
|
}
|
|
3182
3321
|
|
|
3183
3322
|
|
|
3323
|
+
// A tokenless client-only root (issue #410). tokenValue is the Stimulus view
|
|
3324
|
+
// of data-reactive-token-value; the attribute check also rules out a shell.
|
|
3325
|
+
#clientOnly() {
|
|
3326
|
+
return !this.tokenValue && clientOnlyRoot(this.element)
|
|
3327
|
+
}
|
|
3328
|
+
|
|
3329
|
+
// Hand a server trigger to the nearest token-bearing root above (issue #410).
|
|
3330
|
+
// A dormant one is woken (as early.js wakes a root on its own trigger) and
|
|
3331
|
+
// runs the dispatch when it connects. With no such root nothing is sent.
|
|
3332
|
+
#forwardToServerRoot(event) {
|
|
3333
|
+
const root = serverRoot(this.element.parentElement, ANY_ROOT)
|
|
3334
|
+
if (!root) {
|
|
3335
|
+
if (this.element.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]'))
|
|
3336
|
+
console.warn(`[phlex-reactive] "${event.params.action}": no token on this root or above, nothing sent`)
|
|
3337
|
+
return
|
|
3338
|
+
}
|
|
3339
|
+
const controller = rootControllers.get(root)
|
|
3340
|
+
if (controller) return controller.dispatch(event)
|
|
3341
|
+
// Held past this event's dispatch: cancel its native default NOW, by
|
|
3342
|
+
// dispatch()'s own rule, or a submit/link would act before the request.
|
|
3343
|
+
const { shortcut, window: windowBound, optimistic } = event.params
|
|
3344
|
+
const target = event.currentTarget ?? event.target
|
|
3345
|
+
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
|
|
3346
|
+
heldForwards.set(root, [...(heldForwards.get(root) ?? []), [event, target]])
|
|
3347
|
+
const data = root.dataset
|
|
3348
|
+
if (data.reactiveDormant) {
|
|
3349
|
+
data.controller = `${data.controller ?? ""} ${data.reactiveDormant}`.trim()
|
|
3350
|
+
delete data.reactiveDormant
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3184
3354
|
// CLIENT-ONLY trigger entry point (issue #95) — the zero-round-trip sibling
|
|
3185
3355
|
// of dispatch(). Wired by on_client: applies the declared op chain
|
|
3186
3356
|
// (data-reactive-ops-param, built by Phlex::Reactive::JS) locally. NO token,
|
|
@@ -3233,13 +3403,21 @@ export default class extends Controller {
|
|
|
3233
3403
|
if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
|
|
3234
3404
|
continue
|
|
3235
3405
|
if (onceBindingSpent(this, event, record)) continue
|
|
3236
|
-
|
|
3406
|
+
// A legacy attr reads its flags element-wide — but never a mix-ed on()'s
|
|
3407
|
+
// debounce/throttle params (issue #393): those belong to the dispatch.
|
|
3408
|
+
this.#runBinding(
|
|
3409
|
+
record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined } : record,
|
|
3410
|
+
event,
|
|
3411
|
+
trigger,
|
|
3412
|
+
records.indexOf(record),
|
|
3413
|
+
)
|
|
3237
3414
|
}
|
|
3238
3415
|
}
|
|
3239
3416
|
|
|
3240
3417
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
3241
3418
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
3242
|
-
#runBinding(
|
|
3419
|
+
#runBinding(record, event, trigger, ordinal) {
|
|
3420
|
+
const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut } = record
|
|
3243
3421
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
3244
3422
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
3245
3423
|
// preventDefault and before any op runs.
|
|
@@ -3262,7 +3440,7 @@ export default class extends Controller {
|
|
|
3262
3440
|
// which must NEVER prompt. The same confirmResolver gate on(:action,
|
|
3263
3441
|
// confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
|
|
3264
3442
|
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
3265
|
-
const apply = () => this.#applyOps(this.#parseOps(ops))
|
|
3443
|
+
const apply = this.#waitedOps(record, ordinal, trigger, () => this.#applyOps(this.#parseOps(ops)))
|
|
3266
3444
|
if (!message) return apply()
|
|
3267
3445
|
if (message instanceof Promise) {
|
|
3268
3446
|
return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
|
|
@@ -3270,6 +3448,20 @@ export default class extends Controller {
|
|
|
3270
3448
|
this.#confirmThen(message, trigger, apply)
|
|
3271
3449
|
}
|
|
3272
3450
|
|
|
3451
|
+
// Issue #393: an on_client record's debounce:/throttle: — on()'s timers
|
|
3452
|
+
// (#debounce trailing, #throttle leading), keyed on the trigger + the
|
|
3453
|
+
// record's ordinal + JSON (two identical mix-ed records keep their own
|
|
3454
|
+
// timers), so disconnect clears them with the dispatch ones. The wait sits
|
|
3455
|
+
// after the outside guard, preventDefault and confirm, as on()'s does.
|
|
3456
|
+
#waitedOps(record, ordinal, trigger, run) {
|
|
3457
|
+
const debounce = Number(record.debounce) || 0
|
|
3458
|
+
const throttle = Number(record.throttle) || 0
|
|
3459
|
+
if (debounce <= 0 && throttle <= 0) return run
|
|
3460
|
+
const key = JSON.stringify([ordinal, record])
|
|
3461
|
+
if (debounce > 0) return () => this.#debounce(trigger, key, debounce, run)
|
|
3462
|
+
return () => this.#throttle(trigger, key, throttle, run)
|
|
3463
|
+
}
|
|
3464
|
+
|
|
3273
3465
|
// Client-side compute (issue #104) is the compute feature's (issue #275,
|
|
3274
3466
|
// features/compute.js). This is the method its descriptor names
|
|
3275
3467
|
// (input->reactive#recompute), so it stays. While the module is on its way
|
|
@@ -3554,30 +3746,45 @@ export default class extends Controller {
|
|
|
3554
3746
|
// Also flush immediately on blur so leaving the field never drops the last
|
|
3555
3747
|
// edit (a long debounce shouldn't swallow a value the user tabbed away from).
|
|
3556
3748
|
#debounceDispatch(target, ms, action, params, optimistic, busy) {
|
|
3557
|
-
this.#
|
|
3749
|
+
this.#debounce(target, DISPATCH_WAIT_KEY, ms, () => this.#enqueue(action, params, optimistic, target, busy))
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3752
|
+
// The one trailing-edge timer on() and on_client (issue #393) share. Timers
|
|
3753
|
+
// are keyed on target + key: on()'s dispatch uses DISPATCH_WAIT_KEY, an
|
|
3754
|
+
// on_client record its ordinal + JSON (it starts with "["), so an on() debounce and an
|
|
3755
|
+
// on_client debounce on one input never cancel each other. A window-bound
|
|
3756
|
+
// trigger gets no blur flush: a window blur is a tab switch, not an edit.
|
|
3757
|
+
#debounce(target, key, ms, run) {
|
|
3758
|
+
this.#clearDebounce(target, key)
|
|
3558
3759
|
|
|
3559
3760
|
const flush = () => {
|
|
3560
|
-
this.#clearDebounce(target)
|
|
3561
|
-
|
|
3761
|
+
this.#clearDebounce(target, key)
|
|
3762
|
+
run()
|
|
3562
3763
|
}
|
|
3563
3764
|
const timer = setTimeout(flush, ms)
|
|
3564
|
-
target?.addEventListener?.("blur", flush, { once: true })
|
|
3565
|
-
this.#debounceTimers.
|
|
3765
|
+
if (!isWindow(target)) target?.addEventListener?.("blur", flush, { once: true })
|
|
3766
|
+
let pending = this.#debounceTimers.get(target)
|
|
3767
|
+
if (!pending) this.#debounceTimers.set(target, (pending = new Map()))
|
|
3768
|
+
pending.set(key, { timer, flush })
|
|
3566
3769
|
}
|
|
3567
3770
|
|
|
3568
|
-
#clearDebounce(target) {
|
|
3569
|
-
const
|
|
3771
|
+
#clearDebounce(target, key) {
|
|
3772
|
+
const byKey = this.#debounceTimers.get(target)
|
|
3773
|
+
const pending = byKey?.get(key)
|
|
3570
3774
|
if (!pending) return
|
|
3571
3775
|
clearTimeout(pending.timer)
|
|
3572
3776
|
target?.removeEventListener?.("blur", pending.flush)
|
|
3573
|
-
|
|
3777
|
+
byKey.delete(key)
|
|
3778
|
+
if (byKey.size === 0) this.#debounceTimers.delete(target)
|
|
3574
3779
|
}
|
|
3575
3780
|
|
|
3576
3781
|
// Clear every pending debounce timer (used on disconnect). Reuses
|
|
3577
3782
|
// #clearDebounce so all timer/listener teardown stays in one place. Snapshot
|
|
3578
|
-
// the
|
|
3783
|
+
// the entries first — #clearDebounce mutates the maps as it goes.
|
|
3579
3784
|
#clearAllDebounces() {
|
|
3580
|
-
for (const target of [...this.#debounceTimers
|
|
3785
|
+
for (const [target, byKey] of [...this.#debounceTimers]) {
|
|
3786
|
+
for (const key of [...byKey.keys()]) this.#clearDebounce(target, key)
|
|
3787
|
+
}
|
|
3581
3788
|
}
|
|
3582
3789
|
|
|
3583
3790
|
// Leading-edge throttle (issue #80), mirroring #debounceDispatch: the FIRST
|
|
@@ -3587,16 +3794,22 @@ export default class extends Controller {
|
|
|
3587
3794
|
// events all share event.target === document, so two window-bound triggers
|
|
3588
3795
|
// on one component would otherwise collide on one timer.
|
|
3589
3796
|
#throttleDispatch(target, ms, action, params, optimistic, busy) {
|
|
3797
|
+
return this.#throttle(target, action, ms, () => this.#enqueue(action, params, optimistic, target, busy))
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
// The leading-edge window on() and on_client (issue #393) share; an
|
|
3801
|
+
// on_client record keys on its ordinal + JSON, which no action name can equal.
|
|
3802
|
+
#throttle(target, key, ms, run) {
|
|
3590
3803
|
const timers = this.#throttleTimers.get(target) ?? new Map()
|
|
3591
|
-
if (timers.has(
|
|
3804
|
+
if (timers.has(key)) return // inside the window — suppress
|
|
3592
3805
|
|
|
3593
3806
|
const timer = setTimeout(() => {
|
|
3594
|
-
timers.delete(
|
|
3807
|
+
timers.delete(key)
|
|
3595
3808
|
if (timers.size === 0) this.#throttleTimers.delete(target)
|
|
3596
3809
|
}, ms)
|
|
3597
|
-
timers.set(
|
|
3810
|
+
timers.set(key, timer)
|
|
3598
3811
|
this.#throttleTimers.set(target, timers)
|
|
3599
|
-
return
|
|
3812
|
+
return run() // leading edge: fire NOW
|
|
3600
3813
|
}
|
|
3601
3814
|
|
|
3602
3815
|
// Clear every throttle suppression timer (used on disconnect, alongside
|
|
@@ -4076,6 +4289,17 @@ export default class extends Controller {
|
|
|
4076
4289
|
return (el) => this.#ownsField(el)
|
|
4077
4290
|
}
|
|
4078
4291
|
|
|
4292
|
+
// #ownershipFilter for SERVER params (issue #410): nested client-only roots
|
|
4293
|
+
// are transparent, so a control under one is this root's when the first
|
|
4294
|
+
// token-bearing root above it is this.element. A nested token-bearing root
|
|
4295
|
+
// keeps its own fields (issue #15). Bindings, computes and listnav keep the
|
|
4296
|
+
// strict nearest-root #ownershipFilter.
|
|
4297
|
+
#serverFieldFilter() {
|
|
4298
|
+
const nested = this.element.querySelectorAll(AWAKE_ROOT)
|
|
4299
|
+
if (nested.length === 0 || [...nested].every(clientOnlyRoot)) return () => true
|
|
4300
|
+
return (el) => serverRoot(el, AWAKE_ROOT, this.element) === this.element
|
|
4301
|
+
}
|
|
4302
|
+
|
|
4079
4303
|
// One walk over THIS root's named controls (not a nested reactive root's),
|
|
4080
4304
|
// returning both the scalar `fields` and any chosen `files`. The ownership
|
|
4081
4305
|
// predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
|
|
@@ -4087,7 +4311,7 @@ export default class extends Controller {
|
|
|
4087
4311
|
#collectFields() {
|
|
4088
4312
|
const fields = {}
|
|
4089
4313
|
const files = []
|
|
4090
|
-
const owns = this.#
|
|
4314
|
+
const owns = this.#serverFieldFilter() // compute ONCE per dispatch (issue #117)
|
|
4091
4315
|
const controls = []
|
|
4092
4316
|
this.element.querySelectorAll("input[name], select[name], textarea[name]").forEach((field) => {
|
|
4093
4317
|
if (!owns(field)) return
|
|
@@ -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
|