phlex-reactive 0.15.3 → 0.15.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +168 -0
- data/README.md +81 -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 +172 -30
- 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/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 +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) {
|
|
@@ -1988,6 +2070,11 @@ function presenceNeeded(root) {
|
|
|
1988
2070
|
return root.getAttribute?.("data-reactive-presence") != null
|
|
1989
2071
|
}
|
|
1990
2072
|
|
|
2073
|
+
// Countdowns (issue #400): the root is, or holds, a reactive_countdown.
|
|
2074
|
+
function countdownNeeded(root) {
|
|
2075
|
+
return Boolean(root.matches?.("[data-reactive-countdown]") || root.querySelector?.("[data-reactive-countdown]"))
|
|
2076
|
+
}
|
|
2077
|
+
|
|
1991
2078
|
const PRODUCTION_FEATURES = [
|
|
1992
2079
|
// (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
|
|
1993
2080
|
// writes what a request reads, so it gates.
|
|
@@ -2003,6 +2090,8 @@ const PRODUCTION_FEATURES = [
|
|
|
2003
2090
|
// Its announce is a request of its own; nothing it does at connect is read
|
|
2004
2091
|
// by another request: it never gates.
|
|
2005
2092
|
["presence", [presenceNeeded, null]],
|
|
2093
|
+
// A countdown writes only its own text and visibility: it never gates.
|
|
2094
|
+
["countdown", [countdownNeeded, null]],
|
|
2006
2095
|
[
|
|
2007
2096
|
// Stream effects and dismissing flashes are document-level: the module
|
|
2008
2097
|
// has no connect. A root that DECLARES an effect only gets it fetched
|
|
@@ -2166,7 +2255,7 @@ function featureTimeoutMs() {
|
|
|
2166
2255
|
export function reactiveFeatureNames() {
|
|
2167
2256
|
return __SPLIT__
|
|
2168
2257
|
? [...FEATURES.keys()]
|
|
2169
|
-
: ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
|
|
2258
|
+
: ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "countdown", "effects", "hints", "devtools"]
|
|
2170
2259
|
}
|
|
2171
2260
|
|
|
2172
2261
|
export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
|
|
@@ -2190,6 +2279,7 @@ export function __loadReactiveFeatureForTest(name) {
|
|
|
2190
2279
|
compute: computeFeature,
|
|
2191
2280
|
sortable: sortableFeature,
|
|
2192
2281
|
presence: presenceFeature,
|
|
2282
|
+
countdown: countdownFeature,
|
|
2193
2283
|
effects: effectsFeature,
|
|
2194
2284
|
hints: hintsFeature,
|
|
2195
2285
|
devtools: devtoolsFeature,
|
|
@@ -2244,6 +2334,7 @@ const BINDINGS = 8
|
|
|
2244
2334
|
const COMPUTE = 16
|
|
2245
2335
|
const SORTABLE = 32
|
|
2246
2336
|
const PRESENCE = 64
|
|
2337
|
+
const COUNTDOWN = 128
|
|
2247
2338
|
|
|
2248
2339
|
// The default entry's features are part of this file: install them now, as
|
|
2249
2340
|
// the opt-in entry does when one arrives.
|
|
@@ -2269,6 +2360,10 @@ const WINDOW_EARLY_TTL_MS = 1500
|
|
|
2269
2360
|
// which a replay built from its trigger element's document carries).
|
|
2270
2361
|
const isWindow = (target) => target != null && (target === globalThis.window || target.window === target)
|
|
2271
2362
|
|
|
2363
|
+
// Issue #393: the key on()'s dispatch debounce uses in #debounceTimers. An
|
|
2364
|
+
// on_client record keys on its JSON, which starts with "{", so never this.
|
|
2365
|
+
const DISPATCH_WAIT_KEY = "dispatch"
|
|
2366
|
+
|
|
2272
2367
|
function earlyState() {
|
|
2273
2368
|
globalThis[EARLY_KEY] ??= { queue: [], connected: new WeakSet() }
|
|
2274
2369
|
return globalThis[EARLY_KEY]
|
|
@@ -2425,8 +2520,8 @@ export default class extends Controller {
|
|
|
2425
2520
|
}
|
|
2426
2521
|
|
|
2427
2522
|
#tokenCache // freshest token, threaded synchronously across queued requests
|
|
2428
|
-
#debounceTimers = new Map() // trigger element -> { timer, flush } pending
|
|
2429
|
-
#throttleTimers = new Map() // trigger element -> Map(action -> suppression timer)
|
|
2523
|
+
#debounceTimers = new Map() // trigger element -> Map(key -> { timer, flush }) pending run
|
|
2524
|
+
#throttleTimers = new Map() // trigger element -> Map(action or record key -> suppression timer)
|
|
2430
2525
|
#actionPathCache // page-stable action path, resolved once per controller
|
|
2431
2526
|
#timeoutMsCache // page-stable request timeout (ms), resolved once per controller (issue #101)
|
|
2432
2527
|
#tokenRegexCache // { id, token, self } — #extractToken's two per-id RegExps, rebuilt on id change (issue #118)
|
|
@@ -2465,7 +2560,7 @@ export default class extends Controller {
|
|
|
2465
2560
|
#featureTurns = new Map() // name -> settles once that feature has had its turn to connect
|
|
2466
2561
|
#featureEpoch = 0
|
|
2467
2562
|
#featureHandle // the `core` handle features receive, built on first use
|
|
2468
|
-
// The default entry (issue #305): one bit per feature, PERSIST …
|
|
2563
|
+
// The default entry (issue #305): one bit per feature, PERSIST … COUNTDOWN —
|
|
2469
2564
|
// asked for (its marker read true) and connected on this connection.
|
|
2470
2565
|
#featuresAsked = 0
|
|
2471
2566
|
#featuresOn = 0
|
|
@@ -2568,6 +2663,7 @@ export default class extends Controller {
|
|
|
2568
2663
|
this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
|
|
2569
2664
|
this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
|
|
2570
2665
|
this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
|
|
2666
|
+
this.#connectFeatureNow(COUNTDOWN, "countdown", countdownNeeded, (core) => countdownFeature.connect(this, core))
|
|
2571
2667
|
}
|
|
2572
2668
|
}
|
|
2573
2669
|
|
|
@@ -2606,6 +2702,7 @@ export default class extends Controller {
|
|
|
2606
2702
|
if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
|
|
2607
2703
|
if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
|
|
2608
2704
|
if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
|
|
2705
|
+
if (on & COUNTDOWN) this.#disconnectFeatureNow("countdown", () => countdownFeature.disconnect(this, core))
|
|
2609
2706
|
}
|
|
2610
2707
|
}
|
|
2611
2708
|
|
|
@@ -2801,6 +2898,8 @@ export default class extends Controller {
|
|
|
2801
2898
|
served: serverText,
|
|
2802
2899
|
// Copy one <img> onto another (issue #373): the reactive_media binding.
|
|
2803
2900
|
copyMedia,
|
|
2901
|
+
// set_value's write (issue #374): reactive_show clear: (issue #394).
|
|
2902
|
+
setValue: setFieldValue,
|
|
2804
2903
|
// The confirm gate, as dispatch() runs it: the resolver inside the chain
|
|
2805
2904
|
// (a synchronous throw is a cancel), never a rejection out.
|
|
2806
2905
|
confirm: (message, context) =>
|
|
@@ -3233,13 +3332,21 @@ export default class extends Controller {
|
|
|
3233
3332
|
if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
|
|
3234
3333
|
continue
|
|
3235
3334
|
if (onceBindingSpent(this, event, record)) continue
|
|
3236
|
-
|
|
3335
|
+
// A legacy attr reads its flags element-wide — but never a mix-ed on()'s
|
|
3336
|
+
// debounce/throttle params (issue #393): those belong to the dispatch.
|
|
3337
|
+
this.#runBinding(
|
|
3338
|
+
record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined } : record,
|
|
3339
|
+
event,
|
|
3340
|
+
trigger,
|
|
3341
|
+
records.indexOf(record),
|
|
3342
|
+
)
|
|
3237
3343
|
}
|
|
3238
3344
|
}
|
|
3239
3345
|
|
|
3240
3346
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
3241
3347
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
3242
|
-
#runBinding(
|
|
3348
|
+
#runBinding(record, event, trigger, ordinal) {
|
|
3349
|
+
const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut } = record
|
|
3243
3350
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
3244
3351
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
3245
3352
|
// preventDefault and before any op runs.
|
|
@@ -3262,7 +3369,7 @@ export default class extends Controller {
|
|
|
3262
3369
|
// which must NEVER prompt. The same confirmResolver gate on(:action,
|
|
3263
3370
|
// confirm:) uses (issues #52/#55/#178), so a themed dialog covers both.
|
|
3264
3371
|
const message = this.#effectiveConfirmMessage(confirm, confirmWhen)
|
|
3265
|
-
const apply = () => this.#applyOps(this.#parseOps(ops))
|
|
3372
|
+
const apply = this.#waitedOps(record, ordinal, trigger, () => this.#applyOps(this.#parseOps(ops)))
|
|
3266
3373
|
if (!message) return apply()
|
|
3267
3374
|
if (message instanceof Promise) {
|
|
3268
3375
|
return message.then((resolved) => (resolved ? this.#confirmThen(resolved, trigger, apply) : apply()))
|
|
@@ -3270,6 +3377,20 @@ export default class extends Controller {
|
|
|
3270
3377
|
this.#confirmThen(message, trigger, apply)
|
|
3271
3378
|
}
|
|
3272
3379
|
|
|
3380
|
+
// Issue #393: an on_client record's debounce:/throttle: — on()'s timers
|
|
3381
|
+
// (#debounce trailing, #throttle leading), keyed on the trigger + the
|
|
3382
|
+
// record's ordinal + JSON (two identical mix-ed records keep their own
|
|
3383
|
+
// timers), so disconnect clears them with the dispatch ones. The wait sits
|
|
3384
|
+
// after the outside guard, preventDefault and confirm, as on()'s does.
|
|
3385
|
+
#waitedOps(record, ordinal, trigger, run) {
|
|
3386
|
+
const debounce = Number(record.debounce) || 0
|
|
3387
|
+
const throttle = Number(record.throttle) || 0
|
|
3388
|
+
if (debounce <= 0 && throttle <= 0) return run
|
|
3389
|
+
const key = JSON.stringify([ordinal, record])
|
|
3390
|
+
if (debounce > 0) return () => this.#debounce(trigger, key, debounce, run)
|
|
3391
|
+
return () => this.#throttle(trigger, key, throttle, run)
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3273
3394
|
// Client-side compute (issue #104) is the compute feature's (issue #275,
|
|
3274
3395
|
// features/compute.js). This is the method its descriptor names
|
|
3275
3396
|
// (input->reactive#recompute), so it stays. While the module is on its way
|
|
@@ -3554,30 +3675,45 @@ export default class extends Controller {
|
|
|
3554
3675
|
// Also flush immediately on blur so leaving the field never drops the last
|
|
3555
3676
|
// edit (a long debounce shouldn't swallow a value the user tabbed away from).
|
|
3556
3677
|
#debounceDispatch(target, ms, action, params, optimistic, busy) {
|
|
3557
|
-
this.#
|
|
3678
|
+
this.#debounce(target, DISPATCH_WAIT_KEY, ms, () => this.#enqueue(action, params, optimistic, target, busy))
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
// The one trailing-edge timer on() and on_client (issue #393) share. Timers
|
|
3682
|
+
// are keyed on target + key: on()'s dispatch uses DISPATCH_WAIT_KEY, an
|
|
3683
|
+
// on_client record its ordinal + JSON (it starts with "["), so an on() debounce and an
|
|
3684
|
+
// on_client debounce on one input never cancel each other. A window-bound
|
|
3685
|
+
// trigger gets no blur flush: a window blur is a tab switch, not an edit.
|
|
3686
|
+
#debounce(target, key, ms, run) {
|
|
3687
|
+
this.#clearDebounce(target, key)
|
|
3558
3688
|
|
|
3559
3689
|
const flush = () => {
|
|
3560
|
-
this.#clearDebounce(target)
|
|
3561
|
-
|
|
3690
|
+
this.#clearDebounce(target, key)
|
|
3691
|
+
run()
|
|
3562
3692
|
}
|
|
3563
3693
|
const timer = setTimeout(flush, ms)
|
|
3564
|
-
target?.addEventListener?.("blur", flush, { once: true })
|
|
3565
|
-
this.#debounceTimers.
|
|
3694
|
+
if (!isWindow(target)) target?.addEventListener?.("blur", flush, { once: true })
|
|
3695
|
+
let pending = this.#debounceTimers.get(target)
|
|
3696
|
+
if (!pending) this.#debounceTimers.set(target, (pending = new Map()))
|
|
3697
|
+
pending.set(key, { timer, flush })
|
|
3566
3698
|
}
|
|
3567
3699
|
|
|
3568
|
-
#clearDebounce(target) {
|
|
3569
|
-
const
|
|
3700
|
+
#clearDebounce(target, key) {
|
|
3701
|
+
const byKey = this.#debounceTimers.get(target)
|
|
3702
|
+
const pending = byKey?.get(key)
|
|
3570
3703
|
if (!pending) return
|
|
3571
3704
|
clearTimeout(pending.timer)
|
|
3572
3705
|
target?.removeEventListener?.("blur", pending.flush)
|
|
3573
|
-
|
|
3706
|
+
byKey.delete(key)
|
|
3707
|
+
if (byKey.size === 0) this.#debounceTimers.delete(target)
|
|
3574
3708
|
}
|
|
3575
3709
|
|
|
3576
3710
|
// Clear every pending debounce timer (used on disconnect). Reuses
|
|
3577
3711
|
// #clearDebounce so all timer/listener teardown stays in one place. Snapshot
|
|
3578
|
-
// the
|
|
3712
|
+
// the entries first — #clearDebounce mutates the maps as it goes.
|
|
3579
3713
|
#clearAllDebounces() {
|
|
3580
|
-
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
|
+
}
|
|
3581
3717
|
}
|
|
3582
3718
|
|
|
3583
3719
|
// Leading-edge throttle (issue #80), mirroring #debounceDispatch: the FIRST
|
|
@@ -3587,16 +3723,22 @@ export default class extends Controller {
|
|
|
3587
3723
|
// events all share event.target === document, so two window-bound triggers
|
|
3588
3724
|
// on one component would otherwise collide on one timer.
|
|
3589
3725
|
#throttleDispatch(target, ms, action, params, optimistic, busy) {
|
|
3726
|
+
return this.#throttle(target, action, ms, () => this.#enqueue(action, params, optimistic, target, busy))
|
|
3727
|
+
}
|
|
3728
|
+
|
|
3729
|
+
// The leading-edge window on() and on_client (issue #393) share; an
|
|
3730
|
+
// on_client record keys on its ordinal + JSON, which no action name can equal.
|
|
3731
|
+
#throttle(target, key, ms, run) {
|
|
3590
3732
|
const timers = this.#throttleTimers.get(target) ?? new Map()
|
|
3591
|
-
if (timers.has(
|
|
3733
|
+
if (timers.has(key)) return // inside the window — suppress
|
|
3592
3734
|
|
|
3593
3735
|
const timer = setTimeout(() => {
|
|
3594
|
-
timers.delete(
|
|
3736
|
+
timers.delete(key)
|
|
3595
3737
|
if (timers.size === 0) this.#throttleTimers.delete(target)
|
|
3596
3738
|
}, ms)
|
|
3597
|
-
timers.set(
|
|
3739
|
+
timers.set(key, timer)
|
|
3598
3740
|
this.#throttleTimers.set(target, timers)
|
|
3599
|
-
return
|
|
3741
|
+
return run() // leading edge: fire NOW
|
|
3600
3742
|
}
|
|
3601
3743
|
|
|
3602
3744
|
// Clear every throttle suppression timer (used on disconnect, alongside
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Phlex
|
|
6
|
+
module Reactive
|
|
7
|
+
module Component
|
|
8
|
+
# A server deadline counted down on the client (issue #400).
|
|
9
|
+
#
|
|
10
|
+
# span(**reactive_countdown(until: deadline.iso8601, waiting: "#wait", done: "#done")) { "2:00" }
|
|
11
|
+
# div(id: "wait") { "Waiting" }
|
|
12
|
+
# div(id: "done", hidden: true) { "Finished" }
|
|
13
|
+
#
|
|
14
|
+
# The client paints the time left from until: and Date.now at connect and
|
|
15
|
+
# once a second ("1:30": unpadded minutes, a colon, padded seconds — the
|
|
16
|
+
# only format, :ms), as textContent only. A tab that wakes late jumps to
|
|
17
|
+
# the real time left. At zero, or with until: already past on connect, the
|
|
18
|
+
# text is "0:00", data-reactive-countdown-state is "done", waiting: hides,
|
|
19
|
+
# done: shows (CSS selectors inside the root) and the element dispatches
|
|
20
|
+
# reactive:countdown-done once; the timer stops. A morph that keeps until:
|
|
21
|
+
# does not dispatch again; one that changes it counts down again. No
|
|
22
|
+
# request and no frame reload: the event is the hook.
|
|
23
|
+
#
|
|
24
|
+
# The countdown's content is its text, so waiting: and done: name nodes
|
|
25
|
+
# beside it, never inside it: a reactive root that is itself the
|
|
26
|
+
# countdown has nowhere to put them.
|
|
27
|
+
#
|
|
28
|
+
# The server renders the initial text and visibility, so the page reads
|
|
29
|
+
# with JavaScript off. until: is an ISO8601 date-time WITH a zone (Z or an
|
|
30
|
+
# offset), or a Time, which is written as one: a zoneless string would
|
|
31
|
+
# read as the browser's local time.
|
|
32
|
+
module CountdownBinding
|
|
33
|
+
ISO8601_WITH_ZONE = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
|
|
34
|
+
|
|
35
|
+
# `until` is a keyword: the value is read off the binding.
|
|
36
|
+
def reactive_countdown(until:, format: :ms, waiting: nil, done: nil, **attrs)
|
|
37
|
+
wire = { "until" => countdown_until!(binding.local_variable_get(:until)), "format" => countdown_format!(format) }
|
|
38
|
+
wire["waiting"] = countdown_selector!(:waiting, waiting) unless waiting.nil?
|
|
39
|
+
wire["done"] = countdown_selector!(:done, done) unless done.nil?
|
|
40
|
+
mix({ data: { reactive_countdown: wire.to_json } }, attrs)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def countdown_until!(value)
|
|
46
|
+
value = value.utc.iso8601(value.subsec.zero? ? 0 : 3) if value.is_a?(Time) || value.respond_to?(:time_zone)
|
|
47
|
+
return value if countdown_iso8601?(value)
|
|
48
|
+
|
|
49
|
+
raise ArgumentError, "reactive_countdown until: must be an ISO8601 date-time with a zone " \
|
|
50
|
+
"(e.g. \"2026-10-14T12:02:00Z\") or a Time, got #{value.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The shape (a zone is required), then a real date (no month 13).
|
|
54
|
+
def countdown_iso8601?(value)
|
|
55
|
+
value.is_a?(String) && value.match?(ISO8601_WITH_ZONE) && Time.iso8601(value) && true
|
|
56
|
+
rescue ::ArgumentError
|
|
57
|
+
false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def countdown_format!(format)
|
|
61
|
+
return "ms" if format == :ms
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "reactive_countdown format: must be :ms (the only format), got #{format.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A selector the client passes to querySelector inside the root.
|
|
67
|
+
def countdown_selector!(name, selector)
|
|
68
|
+
if selector == JS::ROOT_SENTINEL
|
|
69
|
+
raise ArgumentError,
|
|
70
|
+
"reactive_countdown #{name}: #{selector} names the whole root — pass the selector of a node inside it"
|
|
71
|
+
end
|
|
72
|
+
return selector if selector.is_a?(String) && !selector.strip.empty?
|
|
73
|
+
|
|
74
|
+
raise ArgumentError,
|
|
75
|
+
"reactive_countdown #{name}: must be a CSS selector (e.g. \"##{name}\"), got #{selector.inspect}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Render-time checks for a binding that names ANOTHER field: same:
|
|
7
|
+
# (issue #395), after: (#396) and clear: (#394). The binding's attrs are
|
|
8
|
+
# built before its section's children render, so the check waits for the
|
|
9
|
+
# end of the OUTERMOST Phlex render in progress and reads the fields that
|
|
10
|
+
# render produced — a field below the binding, or inside a nested
|
|
11
|
+
# component, counts. An unknown name (or a file input named in clear:)
|
|
12
|
+
# raises ArgumentError at render, never silently in the browser.
|
|
13
|
+
#
|
|
14
|
+
# Nothing is recorded when no binding names a field, and a helper called
|
|
15
|
+
# outside a render (no buffer to read) is not checked. A fragment render
|
|
16
|
+
# (only part of the template reaches the buffer) is not checked either,
|
|
17
|
+
# nor is a render that calls `flush`. The scan accepts any element with
|
|
18
|
+
# that name, including one inside a nested reactive root the client will
|
|
19
|
+
# not read (ownership is the client's rule), so it catches typos, not
|
|
20
|
+
# misplaced fields.
|
|
21
|
+
module FieldReferences
|
|
22
|
+
REFS_KEY = :__phlex_reactive_field_refs
|
|
23
|
+
|
|
24
|
+
# A start tag and its attributes (quoted values may hold ">").
|
|
25
|
+
TAG = %r{<[a-zA-Z][\w-]*((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*/?>}
|
|
26
|
+
# One attribute of that list, walked in order so a "name=" inside
|
|
27
|
+
# another attribute's quoted value is never read as the name.
|
|
28
|
+
ATTR = %r{\s+([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?}
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def around_template(&)
|
|
33
|
+
# Thread#[] is fiber-local: a nested component, or a capture inside
|
|
34
|
+
# this render, shares the outermost render's list. `true` marks a
|
|
35
|
+
# render in progress with nothing recorded yet, so a render without
|
|
36
|
+
# such a binding allocates nothing here.
|
|
37
|
+
return super if Thread.current[REFS_KEY] || !defined?(@_state) || @_state.nil?
|
|
38
|
+
|
|
39
|
+
buffer = @_state.buffer
|
|
40
|
+
start = buffer.bytesize
|
|
41
|
+
Thread.current[REFS_KEY] = true
|
|
42
|
+
begin
|
|
43
|
+
super
|
|
44
|
+
refs = Thread.current[REFS_KEY]
|
|
45
|
+
ensure
|
|
46
|
+
Thread.current[REFS_KEY] = nil
|
|
47
|
+
end
|
|
48
|
+
check_field_references!(refs, buffer.byteslice(start..)) if refs.is_a?(Array) && !@_state.fragments
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
public
|
|
52
|
+
|
|
53
|
+
# A flush moves the buffer out mid-render, so the end-of-render scan
|
|
54
|
+
# would miss fields above it: record the references as unchecked.
|
|
55
|
+
def flush
|
|
56
|
+
Thread.current[REFS_KEY] = :flushed if Thread.current[REFS_KEY]
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Record a binding's field references for the end-of-render check.
|
|
63
|
+
# `names` are bare show names (scope-aware, like the client resolver);
|
|
64
|
+
# `no_file` refuses a file input under the name.
|
|
65
|
+
def reference_fields!(helper, key, names, no_file: false)
|
|
66
|
+
refs = Thread.current[REFS_KEY]
|
|
67
|
+
return unless refs
|
|
68
|
+
|
|
69
|
+
return if refs == :flushed
|
|
70
|
+
|
|
71
|
+
refs = Thread.current[REFS_KEY] = [] if refs == true
|
|
72
|
+
names.each { refs << [helper, key, it, field_wire_name(it), no_file] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Every same: name in a binding's compiled groups (issue #395).
|
|
76
|
+
def reference_same_fields!(helper, groups)
|
|
77
|
+
names = groups.flatten.filter_map { it["same"] }
|
|
78
|
+
reference_fields!(helper, :same, names.uniq) if names.any?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The name the client's resolver queries: scope[name] under
|
|
82
|
+
# reactive_scope, a bracketed name (an author's raw wire name) verbatim.
|
|
83
|
+
def field_wire_name(name)
|
|
84
|
+
name.include?("[") ? name : scoped_field_name(name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_field_references!(refs, html)
|
|
88
|
+
names, files = rendered_fields(html)
|
|
89
|
+
refs.each do |helper, key, name, wire, no_file|
|
|
90
|
+
unless names.include?(wire)
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"#{helper} #{key}: #{name.to_sym.inspect} names no field this render produced " \
|
|
93
|
+
"(no element with name=\"#{wire}\")"
|
|
94
|
+
end
|
|
95
|
+
next unless no_file && files.include?(wire)
|
|
96
|
+
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"#{helper} #{key}: #{name.to_sym.inspect} is a file input — a file field cannot be cleared"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The name of every element the HTML renders, and those that are
|
|
103
|
+
# file inputs.
|
|
104
|
+
def rendered_fields(html)
|
|
105
|
+
names = Set.new
|
|
106
|
+
files = Set.new
|
|
107
|
+
html.scan(TAG) do
|
|
108
|
+
attrs = tag_attributes(Regexp.last_match(1).to_s)
|
|
109
|
+
next unless (name = attrs["name"])
|
|
110
|
+
|
|
111
|
+
names << name
|
|
112
|
+
files << name if attrs["type"]&.casecmp?("file")
|
|
113
|
+
end
|
|
114
|
+
[names, files]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A tag's attributes by lowercased name (HTML attribute names are
|
|
118
|
+
# case-insensitive); the first occurrence wins, as in the browser.
|
|
119
|
+
def tag_attributes(text)
|
|
120
|
+
attrs = {}
|
|
121
|
+
text.scan(ATTR) do |key, double, single, bare|
|
|
122
|
+
value = double || single || bare
|
|
123
|
+
attrs[key.downcase] ||= value.nil? ? "" : CGI.unescapeHTML(value)
|
|
124
|
+
end
|
|
125
|
+
attrs
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|