phlex-reactive 0.9.2 → 0.9.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 +213 -0
- data/README.md +333 -1
- data/app/controllers/phlex/reactive/actions_controller.rb +120 -10
- data/app/javascript/phlex/reactive/inspect.js +225 -0
- data/app/javascript/phlex/reactive/inspect.min.js +4 -0
- data/app/javascript/phlex/reactive/inspect.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +651 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/generators/phlex/reactive/claude/USAGE +15 -0
- data/lib/generators/phlex/reactive/claude/claude_generator.rb +86 -0
- data/lib/generators/phlex/reactive/install/install_generator.rb +3 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +56 -0
- data/lib/phlex/reactive/authorization.rb +118 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +105 -0
- data/lib/phlex/reactive/component/dsl.rb +70 -0
- data/lib/phlex/reactive/component/helpers.rb +240 -0
- data/lib/phlex/reactive/component/identity.rb +10 -1
- data/lib/phlex/reactive/component/lazy.rb +79 -0
- data/lib/phlex/reactive/component/registry.rb +7 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +363 -0
- data/lib/phlex/reactive/deferred_render_job.rb +116 -0
- data/lib/phlex/reactive/doctor.rb +79 -11
- data/lib/phlex/reactive/engine.rb +16 -2
- data/lib/phlex/reactive/inspector/report.rb +140 -0
- data/lib/phlex/reactive/inspector.rb +253 -0
- data/lib/phlex/reactive/log_subscriber.rb +10 -0
- data/lib/phlex/reactive/mcp/base_tool.rb +57 -0
- data/lib/phlex/reactive/mcp/runner.rb +24 -0
- data/lib/phlex/reactive/mcp/server.rb +47 -0
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +43 -0
- data/lib/phlex/reactive/mcp/tools/components_tool.rb +39 -0
- data/lib/phlex/reactive/mcp/tools/config_tool.rb +74 -0
- data/lib/phlex/reactive/mcp/tools/doctor_tool.rb +36 -0
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +66 -0
- data/lib/phlex/reactive/mcp.rb +58 -0
- data/lib/phlex/reactive/reply.rb +18 -0
- data/lib/phlex/reactive/response.rb +47 -2
- data/lib/phlex/reactive/streamable.rb +5 -1
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +253 -3
- data/lib/tasks/phlex_reactive.rake +28 -0
- metadata +22 -1
|
@@ -107,6 +107,302 @@ export function registerReactiveJs() {
|
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
// --- Deferred reply segments (issue #165) ----------------------------------
|
|
111
|
+
// The client half of reply.defer: the server's reply carries a
|
|
112
|
+
// `<turbo-stream action="reactive:defer" target="<id>">` directive and the
|
|
113
|
+
// real render reaches the SAME actor later — via a parallel fetch (pull) or a
|
|
114
|
+
// pgbus one-shot stream (push). Everything here is MODULE-level, deliberately
|
|
115
|
+
// OFF the per-controller request queue: the whole point is that the expensive
|
|
116
|
+
// segment never blocks the actor's next action.
|
|
117
|
+
//
|
|
118
|
+
// Supersession is the correctness core: pendingDefers keys one in-flight
|
|
119
|
+
// delivery per target id. A newer directive for the same target aborts the
|
|
120
|
+
// older fetch (or removes the older stream source), and an arrival applies
|
|
121
|
+
// ONLY while its entry is still current — so a fast typist's debounced
|
|
122
|
+
// keystrokes can never paint stale totals over fresh ones.
|
|
123
|
+
const pendingDefers = new Map()
|
|
124
|
+
|
|
125
|
+
// Test seam: clear the module-level registry between unit tests. Also resets
|
|
126
|
+
// the one-shot settle-listener guard so a test's fresh document re-registers
|
|
127
|
+
// the turbo:before-stream-render settler.
|
|
128
|
+
export function resetReactiveDefers() {
|
|
129
|
+
pendingDefers.clear()
|
|
130
|
+
deferStreamSettleRegistered = false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Test seam: the `via` of a target's pending defer entry (or undefined) — lets
|
|
134
|
+
// tests assert an entry was SETTLED (dropped) on arrival without exposing the
|
|
135
|
+
// Map. Not used by the runtime.
|
|
136
|
+
export function pendingDeferVia(targetId) {
|
|
137
|
+
return pendingDefers.get(targetId)?.via
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let deferStreamSettleRegistered = false
|
|
141
|
+
|
|
142
|
+
export function registerReactiveDefer() {
|
|
143
|
+
const actions = window.Turbo?.StreamActions
|
|
144
|
+
if (!actions || actions["reactive:defer"]) return
|
|
145
|
+
actions["reactive:defer"] = function () {
|
|
146
|
+
const target = this.getAttribute("target")
|
|
147
|
+
if (!target) return
|
|
148
|
+
if (this.getAttribute("data-reactive-defer-via") === "stream") {
|
|
149
|
+
startStreamDefer(target, this)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
const token = this.getAttribute("data-reactive-defer-token")
|
|
153
|
+
if (!token) return
|
|
154
|
+
startFetchDefer(target, token)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Settle a STREAM-lane pendingDefers entry when its arrival lands: the job's
|
|
158
|
+
// broadcast is a turbo-stream that replaces the target (and removes the
|
|
159
|
+
// source). A document-level turbo:before-stream-render hook drops the Map
|
|
160
|
+
// entry for a stream target the moment a stream renders against it — so the
|
|
161
|
+
// entry never outlives the delivery (the fetch lane settles inline; the
|
|
162
|
+
// stream lane's arrival is a broadcast this controller doesn't await, so it
|
|
163
|
+
// needs this hook). Registered once; a no-op without document.
|
|
164
|
+
if (!deferStreamSettleRegistered && typeof document !== "undefined" && document.addEventListener) {
|
|
165
|
+
deferStreamSettleRegistered = true
|
|
166
|
+
document.addEventListener("turbo:before-stream-render", settleStreamDeferOnRender)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Drop a stream-lane pendingDefers entry when a turbo-stream renders against
|
|
171
|
+
// its target id (the job's replace) OR removes its source element. Keyed by the
|
|
172
|
+
// stream's target so an unrelated stream never settles a defer. Pure Map
|
|
173
|
+
// cleanup — the DOM apply is Turbo's; this only releases our bookkeeping.
|
|
174
|
+
function settleStreamDeferOnRender(event) {
|
|
175
|
+
const streamEl = event.target
|
|
176
|
+
const target = streamEl?.getAttribute?.("target")
|
|
177
|
+
if (!target) return
|
|
178
|
+
// The arrival replaces #<target>; the source removal targets
|
|
179
|
+
// reactive-defer-src-<target>. Either signals the stream delivered.
|
|
180
|
+
const targetId = target.startsWith("reactive-defer-src-")
|
|
181
|
+
? target.slice("reactive-defer-src-".length)
|
|
182
|
+
: target
|
|
183
|
+
const entry = pendingDefers.get(targetId)
|
|
184
|
+
if (entry?.via === "stream") pendingDefers.delete(targetId)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The pull lane: mark the target pending and POST the signed defer token to
|
|
188
|
+
// the defer endpoint, in parallel with everything else the page is doing.
|
|
189
|
+
function startFetchDefer(targetId, token) {
|
|
190
|
+
const el = document.getElementById(targetId)
|
|
191
|
+
if (!el) {
|
|
192
|
+
console.warn(`[phlex-reactive] reactive:defer target #${targetId} is not on the page — skipped`)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
supersedeDefer(targetId)
|
|
196
|
+
markDeferPending(el)
|
|
197
|
+
const entry = { via: "fetch", abort: new AbortController(), timedOut: false }
|
|
198
|
+
pendingDefers.set(targetId, entry)
|
|
199
|
+
performDeferFetch(targetId, entry, token)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The push lane: subscribe a <pgbus-stream-source> to the server-signed
|
|
203
|
+
// one-shot stream. Arrival + teardown need no client logic — the job's
|
|
204
|
+
// broadcast carries the replace AND a remove of this source element (its
|
|
205
|
+
// disconnectedCallback closes the SSE connection). since-id=0 on a fresh key
|
|
206
|
+
// replays a broadcast that beat the subscription (the durable-lane guarantee).
|
|
207
|
+
function startStreamDefer(targetId, directive) {
|
|
208
|
+
const el = document.getElementById(targetId)
|
|
209
|
+
if (!el) {
|
|
210
|
+
console.warn(`[phlex-reactive] reactive:defer target #${targetId} is not on the page — skipped`)
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
const src = directive.getAttribute("data-reactive-defer-src")
|
|
214
|
+
if (!src) return
|
|
215
|
+
if (!globalThis.customElements?.get?.("pgbus-stream-source")) {
|
|
216
|
+
// The server chose push on server-side capability, but this page has no
|
|
217
|
+
// pgbus client. Degrade to the fetch lane using the fallback token the push
|
|
218
|
+
// directive carries — rather than dead-end the shimmer. No token (an app on
|
|
219
|
+
// a bespoke transport) is a loud no-op.
|
|
220
|
+
const fallbackToken = directive.getAttribute("data-reactive-defer-token")
|
|
221
|
+
if (fallbackToken) {
|
|
222
|
+
startFetchDefer(targetId, fallbackToken)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
console.error(
|
|
226
|
+
"[phlex-reactive] reactive:defer via=stream but <pgbus-stream-source> is not registered " +
|
|
227
|
+
"and no fallback token was provided — is the pgbus client loaded on this page?",
|
|
228
|
+
)
|
|
229
|
+
return
|
|
230
|
+
}
|
|
231
|
+
supersedeDefer(targetId)
|
|
232
|
+
markDeferPending(el)
|
|
233
|
+
const source = document.createElement("pgbus-stream-source")
|
|
234
|
+
// Deterministic id: the JOB's broadcast removes it by this exact id — the
|
|
235
|
+
// subscription tears itself down with the payload it delivered.
|
|
236
|
+
source.id = deferSourceId(targetId)
|
|
237
|
+
source.setAttribute("src", src)
|
|
238
|
+
source.setAttribute("since-id", directive.getAttribute("data-reactive-defer-since-id") ?? "0")
|
|
239
|
+
source.setAttribute("hidden", "")
|
|
240
|
+
document.body.appendChild(source)
|
|
241
|
+
// Record only { via } — NOT a strong ref to the source element. The job's
|
|
242
|
+
// broadcast removes the source by its deterministic id (its
|
|
243
|
+
// disconnectedCallback closes the SSE), so holding srcEl here would pin the
|
|
244
|
+
// detached node in this module-level Map forever (a leak). Supersession
|
|
245
|
+
// re-finds the element by id instead. The entry is dropped on
|
|
246
|
+
// supersession or when the arriving broadcast replaces the target (a
|
|
247
|
+
// turbo:before-stream-render hook, below).
|
|
248
|
+
pendingDefers.set(targetId, { via: "stream" })
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// The deterministic id of a target's one-shot <pgbus-stream-source>.
|
|
252
|
+
function deferSourceId(targetId) {
|
|
253
|
+
return `reactive-defer-src-${targetId}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function performDeferFetch(targetId, entry, token) {
|
|
257
|
+
// Bound the wait like the action fetch (issue #101) — a hung defer must not
|
|
258
|
+
// shimmer forever. A manual timer (not AbortSignal.timeout) so the catch can
|
|
259
|
+
// tell a TIMEOUT (fail loudly) from a SUPERSEDED abort (stay silent).
|
|
260
|
+
const timer = setTimeout(() => {
|
|
261
|
+
entry.timedOut = true
|
|
262
|
+
entry.abort.abort()
|
|
263
|
+
}, deferTimeoutMs())
|
|
264
|
+
|
|
265
|
+
// The timeout is cleared ONLY after the body is fully read (below), not the
|
|
266
|
+
// moment headers arrive — a server that streams headers then stalls the body
|
|
267
|
+
// must still abort, or the shimmer hangs forever (the abort signal covers the
|
|
268
|
+
// whole fetch + body read, mirroring #perform's AbortSignal.timeout).
|
|
269
|
+
let response
|
|
270
|
+
try {
|
|
271
|
+
response = await fetch(deferPath(), {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: {
|
|
274
|
+
Accept: "text/vnd.turbo-stream.html",
|
|
275
|
+
"Content-Type": "application/json",
|
|
276
|
+
"X-CSRF-Token": deferCsrfToken(),
|
|
277
|
+
},
|
|
278
|
+
body: JSON.stringify({ token }),
|
|
279
|
+
credentials: "same-origin",
|
|
280
|
+
signal: entry.abort.signal,
|
|
281
|
+
})
|
|
282
|
+
} catch (error) {
|
|
283
|
+
clearTimeout(timer)
|
|
284
|
+
if (pendingDefers.get(targetId) !== entry) return // superseded — silent
|
|
285
|
+
console.error("[phlex-reactive] deferred render failed", error)
|
|
286
|
+
failDefer(targetId, token)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
289
|
+
if (pendingDefers.get(targetId) !== entry) {
|
|
290
|
+
clearTimeout(timer)
|
|
291
|
+
return // superseded mid-flight
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (response.status === 204) {
|
|
295
|
+
clearTimeout(timer)
|
|
296
|
+
// render? false — keep the current content, just clear the pending state.
|
|
297
|
+
settleDefer(targetId)
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
if (!response.ok) {
|
|
301
|
+
clearTimeout(timer)
|
|
302
|
+
console.error(`[phlex-reactive] deferred render failed: HTTP ${response.status}`)
|
|
303
|
+
failDefer(targetId, token, response.status)
|
|
304
|
+
return
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let html
|
|
308
|
+
try {
|
|
309
|
+
html = await response.text()
|
|
310
|
+
} catch (error) {
|
|
311
|
+
clearTimeout(timer)
|
|
312
|
+
if (pendingDefers.get(targetId) !== entry) return
|
|
313
|
+
console.error("[phlex-reactive] deferred render failed reading the body", error)
|
|
314
|
+
failDefer(targetId, token)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
clearTimeout(timer)
|
|
318
|
+
if (pendingDefers.get(targetId) !== entry) return // superseded during read
|
|
319
|
+
|
|
320
|
+
settleDefer(targetId)
|
|
321
|
+
// A normal replace/morph of the target — the fresh root carries no pending
|
|
322
|
+
// markers and a fresh action token, so the component lands interactive.
|
|
323
|
+
window.Turbo.renderStreamMessage(html)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Abort/unsubscribe whatever delivery is in flight for this target. The
|
|
327
|
+
// deleted entry makes every late arrival fail its identity check — stale
|
|
328
|
+
// content can never paint.
|
|
329
|
+
function supersedeDefer(targetId) {
|
|
330
|
+
const existing = pendingDefers.get(targetId)
|
|
331
|
+
if (!existing) return
|
|
332
|
+
pendingDefers.delete(targetId)
|
|
333
|
+
if (existing.via === "fetch") existing.abort.abort()
|
|
334
|
+
// Stream lane: re-find the old source by its deterministic id and remove it
|
|
335
|
+
// (unsubscribe) — we deliberately don't hold a strong ref to the detached
|
|
336
|
+
// node. Its disconnectedCallback closes the SSE.
|
|
337
|
+
else document.getElementById(deferSourceId(targetId))?.remove?.()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function markDeferPending(el) {
|
|
341
|
+
el.setAttribute("data-reactive-defer-pending", "true")
|
|
342
|
+
el.setAttribute("aria-busy", "true")
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function clearDeferPending(el) {
|
|
346
|
+
el.removeAttribute("data-reactive-defer-pending")
|
|
347
|
+
el.removeAttribute("aria-busy")
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Success/204: drop the registry entry, clear pending, and clear any prior
|
|
351
|
+
// defer failure marker (recovery resets error-driven CSS, issue #100 style).
|
|
352
|
+
function settleDefer(targetId) {
|
|
353
|
+
pendingDefers.delete(targetId)
|
|
354
|
+
const el = document.getElementById(targetId)
|
|
355
|
+
if (!el) return
|
|
356
|
+
clearDeferPending(el)
|
|
357
|
+
el.removeAttribute("data-reactive-error")
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Failure: clear pending (the shimmer must not lie), mark the root
|
|
361
|
+
// (data-reactive-error="defer" — style it in pure CSS), and emit a bubbling
|
|
362
|
+
// reactive:error whose retry() re-enters the defer fetch with the SAME token
|
|
363
|
+
// (still valid inside the TTL; an expired token 400s into this same path).
|
|
364
|
+
function failDefer(targetId, token, status) {
|
|
365
|
+
pendingDefers.delete(targetId)
|
|
366
|
+
const el = document.getElementById(targetId)
|
|
367
|
+
if (!el) return
|
|
368
|
+
clearDeferPending(el)
|
|
369
|
+
el.setAttribute("data-reactive-error", "defer")
|
|
370
|
+
const retry = () => {
|
|
371
|
+
const fresh = document.getElementById(targetId)
|
|
372
|
+
if (!fresh) {
|
|
373
|
+
console.warn("[phlex-reactive] defer retry() ignored — the target left the DOM")
|
|
374
|
+
return
|
|
375
|
+
}
|
|
376
|
+
fresh.removeAttribute("data-reactive-error")
|
|
377
|
+
startFetchDefer(targetId, token)
|
|
378
|
+
}
|
|
379
|
+
el.dispatchEvent(
|
|
380
|
+
new CustomEvent("reactive:error", {
|
|
381
|
+
bubbles: true,
|
|
382
|
+
composed: true,
|
|
383
|
+
detail: { kind: "defer", target: targetId, status, retry },
|
|
384
|
+
}),
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function deferPath() {
|
|
389
|
+
return document.querySelector('meta[name="phlex-reactive-defer-path"]')?.content || "/reactive/defer"
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// CSRF is read LIVE per request (Rails can rotate it) — same contract as the
|
|
393
|
+
// controller's #csrfToken.
|
|
394
|
+
function deferCsrfToken() {
|
|
395
|
+
return document.querySelector('meta[name="csrf-token"]')?.content ?? ""
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Same page-stable meta + default as the controller's #timeoutMs (issue #101),
|
|
399
|
+
// parsed defensively so a typo'd meta can never disable the bound.
|
|
400
|
+
function deferTimeoutMs() {
|
|
401
|
+
const raw = document.querySelector('meta[name="phlex-reactive-timeout"]')?.content
|
|
402
|
+
const ms = Number(raw)
|
|
403
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 30000
|
|
404
|
+
}
|
|
405
|
+
|
|
110
406
|
// Document-level self-dismissing flashes (issue #100). A flash rendered with
|
|
111
407
|
// dismiss_after: carries data-reactive-dismiss-after="<ms>"; after the timeout
|
|
112
408
|
// it removes itself. This is deliberately NOT a Stimulus controller — the flash
|
|
@@ -265,6 +561,7 @@ export function registerReactiveActions() {
|
|
|
265
561
|
registerReactiveVisit()
|
|
266
562
|
registerReactiveToken()
|
|
267
563
|
registerReactiveJs()
|
|
564
|
+
registerReactiveDefer()
|
|
268
565
|
registerReactiveDismiss()
|
|
269
566
|
registerReactiveOffline()
|
|
270
567
|
attachLatencyHandle()
|
|
@@ -443,6 +740,62 @@ function guardMirrorSelector(selector) {
|
|
|
443
740
|
return false
|
|
444
741
|
}
|
|
445
742
|
|
|
743
|
+
// Evaluate a show binding's declared literal predicate (issue #161) against
|
|
744
|
+
// the controlling field's current value. Exactly one of the three predicate
|
|
745
|
+
// attrs decides: equals (value === literal), not (value !== literal), in
|
|
746
|
+
// (value ∈ a JSON string list). The vocabulary is fixed and literal-only —
|
|
747
|
+
// never an expression, so there is no eval surface (the reactive_show helper
|
|
748
|
+
// enforces the same shape loudly at render; this is the client half of the
|
|
749
|
+
// two-sided posture). Returns true/false for a decidable binding, or null for
|
|
750
|
+
// a malformed/missing predicate — the caller SKIPS a null so a hand-built or
|
|
751
|
+
// stale binding never flips visibility it doesn't understand (default-deny,
|
|
752
|
+
// like the op whitelist).
|
|
753
|
+
function showBindingMatches(el, value) {
|
|
754
|
+
const equals = el.getAttribute("data-reactive-show-equals")
|
|
755
|
+
if (equals !== null) return value === equals
|
|
756
|
+
const not = el.getAttribute("data-reactive-show-not")
|
|
757
|
+
if (not !== null) return value !== not
|
|
758
|
+
const inRaw = el.getAttribute("data-reactive-show-in")
|
|
759
|
+
if (inRaw !== null) {
|
|
760
|
+
try {
|
|
761
|
+
const list = JSON.parse(inRaw)
|
|
762
|
+
if (Array.isArray(list)) return list.includes(value)
|
|
763
|
+
} catch {
|
|
764
|
+
// fall through to the warn below — malformed JSON and a non-array both skip
|
|
765
|
+
}
|
|
766
|
+
console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(inRaw)} — skipped`)
|
|
767
|
+
return null
|
|
768
|
+
}
|
|
769
|
+
console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped")
|
|
770
|
+
return null
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// Evaluate an ALREADY-PARSED show predicate object (issue #164) — the
|
|
774
|
+
// reactive_show_targets map embeds { equals/not/in } directly in its JSON, so
|
|
775
|
+
// unlike showBindingMatches there are no attrs to read or re-parse. The same
|
|
776
|
+
// literal-only vocabulary; anything else (empty, unknown keys, a non-array
|
|
777
|
+
// in:) returns null and the caller warn-skips that target (default-deny — a
|
|
778
|
+
// hand-built map entry must never flip visibility it doesn't declare).
|
|
779
|
+
function showPredicateMatches(pred, value) {
|
|
780
|
+
if (!pred || typeof pred !== "object") return null
|
|
781
|
+
if (typeof pred.equals === "string") return value === pred.equals
|
|
782
|
+
if (typeof pred.not === "string") return value !== pred.not
|
|
783
|
+
if (Array.isArray(pred.in)) return pred.in.includes(value)
|
|
784
|
+
return null
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// A cross-root show target must be a single ID selector (issue #164) — the
|
|
788
|
+
// SAME shape the #159 mirror enforces (one shared regex), with its own warn so
|
|
789
|
+
// a refused show target is distinguishable in the console. The client half of
|
|
790
|
+
// the two-sided default-deny: reactive_show_targets raises at declare time; a
|
|
791
|
+
// hand-built wire attr must not widen the escape to class/compound selectors.
|
|
792
|
+
// A refused selector warns + skips — its siblings still apply.
|
|
793
|
+
function guardShowTargetSelector(selector) {
|
|
794
|
+
if (typeof selector === "string" && MIRROR_ID_SELECTOR.test(selector)) return true
|
|
795
|
+
console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(selector)} — skipped`)
|
|
796
|
+
return false
|
|
797
|
+
}
|
|
798
|
+
|
|
446
799
|
// The first focusable descendant of `el`, in document order — the natural
|
|
447
800
|
// keyboard target inside an opened menu/dialog. Covers the standard focusable
|
|
448
801
|
// set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
|
|
@@ -535,6 +888,15 @@ export default class extends Controller {
|
|
|
535
888
|
#boundScanDirty
|
|
536
889
|
#boundBeforeUnload
|
|
537
890
|
#boundBeforeVisit
|
|
891
|
+
// Show bindings (issue #161): the ONE delegated sync handler shared by the
|
|
892
|
+
// root's input/change/turbo:morph-element listeners, held for teardown.
|
|
893
|
+
#boundSyncShow
|
|
894
|
+
// Option filtering (issue #163): the ONE delegated sync handler shared by the
|
|
895
|
+
// root's input/turbo:morph-element listeners, held for teardown.
|
|
896
|
+
#boundSyncFilter
|
|
897
|
+
// Lazy initial mount (issue #165): the bound re-probe attached to
|
|
898
|
+
// turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
|
|
899
|
+
#boundProbeLazyDefer
|
|
538
900
|
|
|
539
901
|
// Mark that a reactive controller actually connected, so the registration
|
|
540
902
|
// guard above knows the controller was registered (issue #26 part 2).
|
|
@@ -557,6 +919,25 @@ export default class extends Controller {
|
|
|
557
919
|
)
|
|
558
920
|
}
|
|
559
921
|
|
|
922
|
+
// Lazy initial mount (issue #165): a reactive_lazy shell carries its defer
|
|
923
|
+
// token as a ROOT attribute — enter the SAME module-level fetch path a
|
|
924
|
+
// reply directive uses (supersession, pending markers, error handling
|
|
925
|
+
// included). Probe on connect (a plain replace / cache restoration
|
|
926
|
+
// re-connects) AND on turbo:morph-element: a Turbo page-refresh MORPH
|
|
927
|
+
// re-shows the shell while keeping the element CONNECTED and firing no
|
|
928
|
+
// Stimulus lifecycle, so a connect-only probe would leave the morphed-in
|
|
929
|
+
// shell shimmering forever. The supersession registry makes a duplicate
|
|
930
|
+
// probe a no-op (same target id), so re-probing is safe. The attribute
|
|
931
|
+
// stays on the shell precisely so a re-appearance re-fires.
|
|
932
|
+
// Only wire the morph re-probe for a root that IS a lazy shell (carries the
|
|
933
|
+
// token) — a component that never uses reactive_lazy pays nothing (no
|
|
934
|
+
// listener), matching the dirty-tracking / show-sync gating precedent.
|
|
935
|
+
if (this.element.getAttribute?.("data-reactive-defer-token")) {
|
|
936
|
+
this.#probeLazyDefer()
|
|
937
|
+
this.#boundProbeLazyDefer = () => this.#probeLazyDefer()
|
|
938
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
|
|
939
|
+
}
|
|
940
|
+
|
|
560
941
|
// Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
|
|
561
942
|
// reactive_field(dirty:)), so a component that never uses it pays nothing (no
|
|
562
943
|
// baseline scan, no morph listener on every broadcast). A plain (outerHTML)
|
|
@@ -581,6 +962,43 @@ export default class extends Controller {
|
|
|
581
962
|
this.#armUnsavedGuard()
|
|
582
963
|
}
|
|
583
964
|
}
|
|
965
|
+
|
|
966
|
+
// Show bindings (issue #161) — ONLY when this root owns one, so a component
|
|
967
|
+
// without any pays a single probe (the dirty-tracking gate precedent). ONE
|
|
968
|
+
// delegated listener pair on the root (input + change bubble from every
|
|
969
|
+
// owned field — no per-field wiring, and a reactive_compute output write
|
|
970
|
+
// dispatches a real input event, so computed values drive visibility too).
|
|
971
|
+
// The connect sync seeds the initial state — a plain replace re-connects —
|
|
972
|
+
// and turbo:morph-element re-syncs after an in-place morph (which keeps the
|
|
973
|
+
// element connected, fires no Stimulus lifecycle, and may preserve a
|
|
974
|
+
// user-edited field value the server's hidden attrs don't reflect).
|
|
975
|
+
if (this.#showSyncEnabled()) {
|
|
976
|
+
this.#boundSyncShow = () => this.#syncShow()
|
|
977
|
+
this.element.addEventListener?.("input", this.#boundSyncShow)
|
|
978
|
+
this.element.addEventListener?.("change", this.#boundSyncShow)
|
|
979
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundSyncShow)
|
|
980
|
+
this.#syncShow()
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Option filtering (issue #163) — ONLY when the root declares the binding
|
|
984
|
+
// (reactive_filter emits both attrs together), so a component without one
|
|
985
|
+
// pays two attribute reads. ONE delegated input listener on the root — the
|
|
986
|
+
// handler re-filters only for events from the NAMED input, so keystrokes in
|
|
987
|
+
// unrelated fields on a wide form never pay a filter pass. The connect sync
|
|
988
|
+
// seeds from the input's current value (a plain replace re-connects; back
|
|
989
|
+
// navigation may restore typed text), and turbo:morph-element re-applies
|
|
990
|
+
// after an in-place morph (which keeps the element connected, fires no
|
|
991
|
+
// Stimulus lifecycle, and may preserve the user's typed query while the
|
|
992
|
+
// server re-rendered every option visible).
|
|
993
|
+
if (this.#filterEnabled()) {
|
|
994
|
+
this.#boundSyncFilter = (event) => {
|
|
995
|
+
if (event?.type === "input" && !this.#filterInputEvent(event)) return
|
|
996
|
+
this.#syncFilter()
|
|
997
|
+
}
|
|
998
|
+
this.element.addEventListener?.("input", this.#boundSyncFilter)
|
|
999
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundSyncFilter)
|
|
1000
|
+
this.#syncFilter()
|
|
1001
|
+
}
|
|
584
1002
|
}
|
|
585
1003
|
|
|
586
1004
|
// Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
|
|
@@ -605,6 +1023,27 @@ export default class extends Controller {
|
|
|
605
1023
|
this.#clearAllDebounces()
|
|
606
1024
|
this.#clearAllThrottles()
|
|
607
1025
|
this.#teardownDirtyTracking()
|
|
1026
|
+
this.#teardownShowSync()
|
|
1027
|
+
this.#teardownFilterSync()
|
|
1028
|
+
if (this.#boundProbeLazyDefer) {
|
|
1029
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
// Lazy initial mount probe (issue #165): fetch the real content when THIS
|
|
1034
|
+
// root is a reactive_lazy shell that still carries its defer token AND the
|
|
1035
|
+
// pending marker. Gating on the pending marker is what makes a re-probe (a
|
|
1036
|
+
// Turbo morph re-showing the shell) fire while a re-probe of an already
|
|
1037
|
+
// RESOLVED root (real content, no token, no marker) is a no-op. The
|
|
1038
|
+
// module-level supersession registry dedupes a duplicate in-flight fetch for
|
|
1039
|
+
// the same id, so calling this on both connect and every morph is safe.
|
|
1040
|
+
#probeLazyDefer() {
|
|
1041
|
+
const el = this.element
|
|
1042
|
+
if (!el?.id) return
|
|
1043
|
+
const token = el.getAttribute?.("data-reactive-defer-token")
|
|
1044
|
+
if (!token) return
|
|
1045
|
+
if (el.getAttribute?.("data-reactive-defer-pending") !== "true") return
|
|
1046
|
+
startFetchDefer(el.id, token)
|
|
608
1047
|
}
|
|
609
1048
|
|
|
610
1049
|
// Serialize requests per component. Each round trip rewrites the signed
|
|
@@ -900,7 +1339,9 @@ export default class extends Controller {
|
|
|
900
1339
|
// unset. Falls back to the root for a directly-invoked call (unit tests). The
|
|
901
1340
|
// ownership predicate is hoisted ONCE per keypress (issue #117) — in the common
|
|
902
1341
|
// no-nested-root case it is a constant true, skipping a closest() walk per
|
|
903
|
-
// option.
|
|
1342
|
+
// option. Hidden options are excluded (issue #163): a reactive_filter (or any
|
|
1343
|
+
// `hidden` toggle) removes a row from the keyboard path too, so an Arrow can't
|
|
1344
|
+
// highlight — and Enter can't pick — an invisible option.
|
|
904
1345
|
#listnavOptions(event) {
|
|
905
1346
|
const trigger = event?.currentTarget ?? event?.target ?? this.element
|
|
906
1347
|
const selector =
|
|
@@ -908,7 +1349,7 @@ export default class extends Controller {
|
|
|
908
1349
|
this.element.getAttribute("data-reactive-listnav-option-param")
|
|
909
1350
|
if (!selector) return []
|
|
910
1351
|
const owns = this.#ownershipFilter()
|
|
911
|
-
return Array.from(this.element.querySelectorAll(selector)).filter(owns)
|
|
1352
|
+
return Array.from(this.element.querySelectorAll(selector)).filter((el) => !el.hidden && owns(el))
|
|
912
1353
|
}
|
|
913
1354
|
|
|
914
1355
|
// Parse a JSON string list from a root data attr; [] on absence/parse error so
|
|
@@ -1773,6 +2214,214 @@ export default class extends Controller {
|
|
|
1773
2214
|
this.#boundBeforeVisit = undefined
|
|
1774
2215
|
}
|
|
1775
2216
|
|
|
2217
|
+
// Whether this root owns a show binding (issue #161) or declares cross-root
|
|
2218
|
+
// show targets (issue #164) — the connect() gate, so a component with
|
|
2219
|
+
// neither pays only this probe (the #dirtyTrackingEnabled precedent). A
|
|
2220
|
+
// NESTED root's bindings don't count: its own controller instance syncs them
|
|
2221
|
+
// (issue #15 ownership). The targets attr is checked FIRST — one
|
|
2222
|
+
// getAttribute, cheaper than the binding walk.
|
|
2223
|
+
#showSyncEnabled() {
|
|
2224
|
+
if (this.element.getAttribute?.("data-reactive-show-targets")) return true
|
|
2225
|
+
const nodes = this.element.querySelectorAll?.("[data-reactive-show-field]") ?? []
|
|
2226
|
+
for (const el of nodes) if (this.#ownsField(el)) return true
|
|
2227
|
+
return false
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
// Re-evaluate every OWNED show binding in one pass (issue #161): read the
|
|
2231
|
+
// controlling field's current value, evaluate the declared literal predicate,
|
|
2232
|
+
// toggle `hidden`. A full pass (not per-target) for the same reason as
|
|
2233
|
+
// #scanDirty — a radio group's deselected radio fires no event — and because
|
|
2234
|
+
// several bindings can hang off one field (the value read is memoized per
|
|
2235
|
+
// pass). A binding whose field can't be resolved, or whose predicate is
|
|
2236
|
+
// malformed, leaves visibility ALONE — a bad binding must never break or
|
|
2237
|
+
// blank the page (client-side default-deny).
|
|
2238
|
+
#syncShow() {
|
|
2239
|
+
if (typeof this.element?.querySelectorAll !== "function") return
|
|
2240
|
+
|
|
2241
|
+
const owns = this.#ownershipFilter()
|
|
2242
|
+
const values = new Map()
|
|
2243
|
+
for (const el of this.element.querySelectorAll("[data-reactive-show-field]")) {
|
|
2244
|
+
if (!owns(el)) continue // a nested root's binding is its own controller's job
|
|
2245
|
+
const name = el.getAttribute("data-reactive-show-field")
|
|
2246
|
+
if (!name) continue
|
|
2247
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2248
|
+
const value = values.get(name)
|
|
2249
|
+
if (value === null) continue // no owned field with that name — leave it be
|
|
2250
|
+
const match = showBindingMatches(el, value)
|
|
2251
|
+
if (match === null) continue // malformed predicate — warned + skipped
|
|
2252
|
+
el.hidden = !match
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
// The cross-root pass (issue #164) shares the same owned-field memo, so a
|
|
2256
|
+
// field driving both an owned binding and an outside target reads once.
|
|
2257
|
+
this.#syncShowTargets(owns, values)
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
// Apply the declared cross-root show targets (issue #164) — the visibility
|
|
2261
|
+
// parallel of #applyComputeMirrors. For each declared field: read the OWNED
|
|
2262
|
+
// field's current value (never a nested root's — you can only drive outside
|
|
2263
|
+
// visibility from a field this root owns), then for each "#id" → predicate
|
|
2264
|
+
// entry: guard the selector id-only (warn-and-skip; the Ruby helper raised
|
|
2265
|
+
// at declare time — two-sided default-deny), resolve it DOCUMENT-WIDE, and
|
|
2266
|
+
// toggle `hidden`. A target id not on the page is silently skipped (an
|
|
2267
|
+
// unrendered tab pane is normal); a malformed predicate warn-skips its one
|
|
2268
|
+
// target while siblings still apply. With no map declared this is one
|
|
2269
|
+
// getAttribute and out.
|
|
2270
|
+
#syncShowTargets(owns, values) {
|
|
2271
|
+
const map = this.#parseShowTargets()
|
|
2272
|
+
for (const [name, targets] of Object.entries(map)) {
|
|
2273
|
+
if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
|
|
2274
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2275
|
+
const value = values.get(name)
|
|
2276
|
+
if (value === null) continue // no owned field with that name — leave them be
|
|
2277
|
+
for (const [selector, pred] of Object.entries(targets)) {
|
|
2278
|
+
if (!guardShowTargetSelector(selector)) continue
|
|
2279
|
+
const match = showPredicateMatches(pred, value)
|
|
2280
|
+
if (match === null) {
|
|
2281
|
+
console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} — skipped`)
|
|
2282
|
+
continue
|
|
2283
|
+
}
|
|
2284
|
+
for (const node of document.querySelectorAll(selector)) node.hidden = !match
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
}
|
|
2288
|
+
|
|
2289
|
+
// The declared cross-root show-target map (issue #164): a JSON object of
|
|
2290
|
+
// { field: { "#id": predicate } } from data-reactive-show-targets (emitted
|
|
2291
|
+
// by reactive_show_targets on the root). Absent degrades to {}; malformed
|
|
2292
|
+
// degrades to {} WITH a warn — never a throw (the #parseComputeMirror
|
|
2293
|
+
// contract), but never silent either: the likeliest cause is TWO
|
|
2294
|
+
// reactive_show_targets calls on one root, whose JSON strings Phlex `mix`
|
|
2295
|
+
// space-joined into an unparseable attr. The warn names the fix.
|
|
2296
|
+
#parseShowTargets() {
|
|
2297
|
+
const raw = this.element.getAttribute?.("data-reactive-show-targets")
|
|
2298
|
+
if (!raw) return {}
|
|
2299
|
+
try {
|
|
2300
|
+
const parsed = JSON.parse(raw)
|
|
2301
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
|
|
2302
|
+
} catch {
|
|
2303
|
+
// fall through to the shared warn below
|
|
2304
|
+
}
|
|
2305
|
+
console.warn(
|
|
2306
|
+
"[phlex-reactive] malformed data-reactive-show-targets — ignored. " +
|
|
2307
|
+
"Did two reactive_show_targets calls collide on one root? Declare every field in ONE call: " +
|
|
2308
|
+
"reactive_show_targets(mode: { ... }, kind: { ... })"
|
|
2309
|
+
)
|
|
2310
|
+
return {}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
// The current value of the OWNED field controlling a show binding, as the
|
|
2314
|
+
// string the literal predicate compares against. Mirrors #collectFields'
|
|
2315
|
+
// per-kind reads: a checkbox reports its checked state ("true"/"false" — its
|
|
2316
|
+
// .value is the constant "on", and the checkbox wins over the hidden input
|
|
2317
|
+
// Rails pairs with it); a radio group reports the CHECKED radio's value (""
|
|
2318
|
+
// when none is); anything else reports .value first-wins. Returns null when
|
|
2319
|
+
// no owned field carries the name — the caller then leaves visibility alone.
|
|
2320
|
+
#showFieldValue(name, owns) {
|
|
2321
|
+
let sawRadio = false
|
|
2322
|
+
let first = null
|
|
2323
|
+
for (const el of this.element.querySelectorAll(`[name="${name}"]`)) {
|
|
2324
|
+
if (!owns(el)) continue
|
|
2325
|
+
if (el.type === "checkbox") return el.checked ? "true" : "false"
|
|
2326
|
+
if (el.type === "radio") {
|
|
2327
|
+
if (el.checked) return el.value ?? ""
|
|
2328
|
+
sawRadio = true
|
|
2329
|
+
continue
|
|
2330
|
+
}
|
|
2331
|
+
first ??= el
|
|
2332
|
+
}
|
|
2333
|
+
if (first) return first.value ?? ""
|
|
2334
|
+
return sawRadio ? "" : null
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2337
|
+
// Remove the show-sync listeners on disconnect, so a stray event after a
|
|
2338
|
+
// Turbo morph/navigation never re-evaluates against a detached root.
|
|
2339
|
+
#teardownShowSync() {
|
|
2340
|
+
if (!this.#boundSyncShow) return
|
|
2341
|
+
this.element.removeEventListener?.("input", this.#boundSyncShow)
|
|
2342
|
+
this.element.removeEventListener?.("change", this.#boundSyncShow)
|
|
2343
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncShow)
|
|
2344
|
+
this.#boundSyncShow = undefined
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
// Whether this root declares an option filter (issue #163) — the connect()
|
|
2348
|
+
// gate. reactive_filter always emits input + option together, so requiring
|
|
2349
|
+
// BOTH also default-denies a half-built hand-authored binding.
|
|
2350
|
+
#filterEnabled() {
|
|
2351
|
+
return !!(
|
|
2352
|
+
this.element.getAttribute?.("data-reactive-filter-input") &&
|
|
2353
|
+
this.element.getAttribute?.("data-reactive-filter-option")
|
|
2354
|
+
)
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
// Whether a delegated input event came from the NAMED filter input (issue
|
|
2358
|
+
// #163). Anything else — another field's keystroke, a target without
|
|
2359
|
+
// matches() — skips the filter pass (the morph re-sync path bypasses this).
|
|
2360
|
+
#filterInputEvent(event) {
|
|
2361
|
+
const selector = this.element.getAttribute("data-reactive-filter-input")
|
|
2362
|
+
return !!selector && typeof event.target?.matches === "function" && event.target.matches(selector)
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// Re-apply the filter in one pass (issue #163): lowercase the named input's
|
|
2366
|
+
// current value, toggle `hidden` on every OWNED option by a substring match
|
|
2367
|
+
// against its haystack (data-reactive-filter-text, falling back to the
|
|
2368
|
+
// option's own text), collapse any group whose every contained option is
|
|
2369
|
+
// hidden, and reveal the empty target at 0 visible. A filtered-out option
|
|
2370
|
+
// also loses its listnav highlight so Enter can never pick an invisible row.
|
|
2371
|
+
// No owned input → leave visibility ALONE — a binding that can't resolve
|
|
2372
|
+
// must never break or blank the page (client-side default-deny). All
|
|
2373
|
+
// selectors resolve within this root, skipping nested reactive roots'
|
|
2374
|
+
// elements (issue #15 ownership; the predicate is hoisted once per pass).
|
|
2375
|
+
#syncFilter() {
|
|
2376
|
+
if (typeof this.element?.querySelectorAll !== "function") return
|
|
2377
|
+
const inputSelector = this.element.getAttribute("data-reactive-filter-input")
|
|
2378
|
+
const optionSelector = this.element.getAttribute("data-reactive-filter-option")
|
|
2379
|
+
if (!inputSelector || !optionSelector) return
|
|
2380
|
+
|
|
2381
|
+
const owns = this.#ownershipFilter()
|
|
2382
|
+
const input = [...this.element.querySelectorAll(inputSelector)].find(owns)
|
|
2383
|
+
if (!input) return
|
|
2384
|
+
|
|
2385
|
+
const query = (input.value ?? "").trim().toLowerCase()
|
|
2386
|
+
let visible = 0
|
|
2387
|
+
for (const el of this.element.querySelectorAll(optionSelector)) {
|
|
2388
|
+
if (!owns(el)) continue // a nested root's option is its own controller's job
|
|
2389
|
+
const haystack = (el.getAttribute("data-reactive-filter-text") ?? el.textContent ?? "").toLowerCase()
|
|
2390
|
+
const hidden = query !== "" && !haystack.includes(query)
|
|
2391
|
+
el.hidden = hidden
|
|
2392
|
+
if (hidden) el.removeAttribute("data-reactive-highlighted")
|
|
2393
|
+
else visible++
|
|
2394
|
+
}
|
|
2395
|
+
|
|
2396
|
+
const groupSelector = this.element.getAttribute("data-reactive-filter-group")
|
|
2397
|
+
if (groupSelector) {
|
|
2398
|
+
for (const group of this.element.querySelectorAll(groupSelector)) {
|
|
2399
|
+
if (!owns(group)) continue
|
|
2400
|
+
const contained = [...group.querySelectorAll(optionSelector)].filter(owns)
|
|
2401
|
+
// A group with no options isn't this filter's to decide — server state
|
|
2402
|
+
// stands (it may be a header the app toggles by other means).
|
|
2403
|
+
if (contained.length === 0) continue
|
|
2404
|
+
group.hidden = contained.every((el) => el.hidden)
|
|
2405
|
+
}
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
const emptySelector = this.element.getAttribute("data-reactive-filter-empty")
|
|
2409
|
+
if (emptySelector) {
|
|
2410
|
+
for (const el of this.element.querySelectorAll(emptySelector)) {
|
|
2411
|
+
if (owns(el)) el.hidden = visible > 0
|
|
2412
|
+
}
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
// Remove the filter-sync listeners on disconnect, so a stray event after a
|
|
2417
|
+
// Turbo morph/navigation never re-filters against a detached root.
|
|
2418
|
+
#teardownFilterSync() {
|
|
2419
|
+
if (!this.#boundSyncFilter) return
|
|
2420
|
+
this.element.removeEventListener?.("input", this.#boundSyncFilter)
|
|
2421
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncFilter)
|
|
2422
|
+
this.#boundSyncFilter = undefined
|
|
2423
|
+
}
|
|
2424
|
+
|
|
1776
2425
|
// Build the multipart body (issue #34). `token`/`act` are flat fields the
|
|
1777
2426
|
// endpoint reads from params[:token]/params[:act]; scalar params nest under
|
|
1778
2427
|
// params[<key>] (Rails parses the bracket into params[:params]); each file is
|