phlex-reactive 0.9.3 → 0.9.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 +212 -0
- data/README.md +282 -2
- 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 +659 -10
- 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 +348 -21
- 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()
|
|
@@ -469,10 +766,139 @@ function showBindingMatches(el, value) {
|
|
|
469
766
|
console.warn(`[phlex-reactive] malformed reactive_show in: list ${JSON.stringify(inRaw)} — skipped`)
|
|
470
767
|
return null
|
|
471
768
|
}
|
|
769
|
+
// Numeric threshold predicates (issue #176 part B): gte/gt/lte/lt read the
|
|
770
|
+
// literal off its own flat attr and compare Number(value) against it. Any
|
|
771
|
+
// present numeric attr decides the binding — a non-numeric field value (NaN)
|
|
772
|
+
// is false (hidden), and a non-numeric LITERAL warn-skips (null).
|
|
773
|
+
for (const key of SHOW_NUMERIC_KEYS) {
|
|
774
|
+
const raw = el.getAttribute(`data-reactive-show-${key}`)
|
|
775
|
+
if (raw !== null) return numericPredicateMatches(key, raw, value)
|
|
776
|
+
}
|
|
472
777
|
console.warn("[phlex-reactive] a reactive_show binding declares no predicate — skipped")
|
|
473
778
|
return null
|
|
474
779
|
}
|
|
475
780
|
|
|
781
|
+
// The numeric threshold keys (issue #176 part B) — the client half of the Ruby
|
|
782
|
+
// SHOW_NUMERIC_KEYS. Order-independent; the evaluator reads the one that's
|
|
783
|
+
// present. Each coerces BOTH sides to Number and compares.
|
|
784
|
+
const SHOW_NUMERIC_KEYS = ["gte", "gt", "lte", "lt"]
|
|
785
|
+
|
|
786
|
+
// Evaluate one numeric threshold predicate against a field value. Returns
|
|
787
|
+
// true/false for a decidable comparison, or null when the LITERAL itself is
|
|
788
|
+
// non-numeric (a malformed binding — warn-skip, default-deny). A non-numeric
|
|
789
|
+
// FIELD value (empty/blank/garbage) is treated as NaN → false: the
|
|
790
|
+
// reveal-on-threshold notice stays hidden, the safe default. Shared by the
|
|
791
|
+
// owned-binding evaluator (raw string literal off an attr) and the
|
|
792
|
+
// cross-root/compound evaluator (a literal that arrived as a JSON number or
|
|
793
|
+
// string).
|
|
794
|
+
function numericPredicateMatches(key, literal, value) {
|
|
795
|
+
const rhs = Number(literal)
|
|
796
|
+
if (Number.isNaN(rhs)) {
|
|
797
|
+
console.warn(`[phlex-reactive] reactive_show ${key}: needs a numeric literal, got ${JSON.stringify(literal)} — skipped`)
|
|
798
|
+
return null
|
|
799
|
+
}
|
|
800
|
+
// A blank/whitespace field value must fail closed. Number("") and
|
|
801
|
+
// Number(" ") are 0 (NOT NaN), so a bare Number()+isNaN check would wrongly
|
|
802
|
+
// reveal a `lte:`/`lt:`/`gte: 0` binding on an EMPTY field. Force the
|
|
803
|
+
// empty/blank case to NaN so the "blank → hidden" contract holds for every
|
|
804
|
+
// operator, not just the ones where 0 happens to fail the comparison.
|
|
805
|
+
const trimmed = value == null ? "" : String(value).trim()
|
|
806
|
+
const n = trimmed === "" ? NaN : Number(trimmed)
|
|
807
|
+
if (Number.isNaN(n)) return false
|
|
808
|
+
switch (key) {
|
|
809
|
+
case "gte":
|
|
810
|
+
return n >= rhs
|
|
811
|
+
case "gt":
|
|
812
|
+
return n > rhs
|
|
813
|
+
case "lte":
|
|
814
|
+
return n <= rhs
|
|
815
|
+
case "lt":
|
|
816
|
+
return n < rhs
|
|
817
|
+
default:
|
|
818
|
+
return null
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// Evaluate an ALREADY-PARSED show predicate object (issue #164) — the
|
|
823
|
+
// reactive_show_targets map embeds { equals/not/in } directly in its JSON, so
|
|
824
|
+
// unlike showBindingMatches there are no attrs to read or re-parse. The same
|
|
825
|
+
// literal-only vocabulary; anything else (empty, unknown keys, a non-array
|
|
826
|
+
// in:) returns null and the caller warn-skips that target (default-deny — a
|
|
827
|
+
// hand-built map entry must never flip visibility it doesn't declare).
|
|
828
|
+
function showPredicateMatches(pred, value) {
|
|
829
|
+
if (!pred || typeof pred !== "object") return null
|
|
830
|
+
if (typeof pred.equals === "string") return value === pred.equals
|
|
831
|
+
if (typeof pred.not === "string") return value !== pred.not
|
|
832
|
+
if (Array.isArray(pred.in)) return pred.in.includes(value)
|
|
833
|
+
// Numeric threshold predicates (issue #176 part B): the literal arrives as a
|
|
834
|
+
// JSON number (or a numeric string) embedded in the predicate object — one
|
|
835
|
+
// shared numericPredicateMatches with the owned-binding evaluator.
|
|
836
|
+
for (const key of SHOW_NUMERIC_KEYS) {
|
|
837
|
+
if (key in pred) return numericPredicateMatches(key, pred[key], value)
|
|
838
|
+
}
|
|
839
|
+
return null
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// The selector matching every OWNED-element show binding: single-field
|
|
843
|
+
// (data-reactive-show-field, issue #161) OR compound all:/any:
|
|
844
|
+
// (data-reactive-show, issue #176). Both the connect() gate and the sync walk
|
|
845
|
+
// use it so a compound-only root still enables the sync.
|
|
846
|
+
const SHOW_BINDING_SELECTOR = "[data-reactive-show-field], [data-reactive-show]"
|
|
847
|
+
|
|
848
|
+
// Parse a compound show binding's JSON payload (issue #176 part A). Malformed
|
|
849
|
+
// JSON degrades to null WITH a warn — a bad binding must never throw or blank
|
|
850
|
+
// the page (client-side default-deny), but a collision (two bindings' JSON
|
|
851
|
+
// mix-joined) is worth surfacing.
|
|
852
|
+
function parseShowCompound(raw) {
|
|
853
|
+
try {
|
|
854
|
+
const parsed = JSON.parse(raw)
|
|
855
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
|
|
856
|
+
} catch {
|
|
857
|
+
// fall through to the warn
|
|
858
|
+
}
|
|
859
|
+
console.warn(`[phlex-reactive] malformed compound reactive_show payload ${JSON.stringify(raw)} — skipped`)
|
|
860
|
+
return null
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// Evaluate a COMPOUND show binding (issue #176 part A): a parsed
|
|
864
|
+
// { all: [term, …] } or { any: [term, …] } payload, where each term is
|
|
865
|
+
// { field, <predicate> }. `fieldValue(name)` resolves the OWNED field's current
|
|
866
|
+
// value (memoized by the caller). all: ANDs every term, any: ORs them. A term
|
|
867
|
+
// whose field is missing (null) or whose predicate is malformed/unknown folds
|
|
868
|
+
// as FALSE — fail-closed (the issue's default-deny lean): a broken AND term can
|
|
869
|
+
// never pass, a broken OR term can never reveal. Returns true/false for a
|
|
870
|
+
// decidable payload, or null for a malformed payload (no all:/any: array) so
|
|
871
|
+
// the caller warn-skips and leaves visibility alone.
|
|
872
|
+
function compoundShowMatches(payload, fieldValue) {
|
|
873
|
+
if (!payload || typeof payload !== "object") return null
|
|
874
|
+
const connective = Array.isArray(payload.all) ? "all" : Array.isArray(payload.any) ? "any" : null
|
|
875
|
+
if (!connective) return null
|
|
876
|
+
const terms = payload[connective]
|
|
877
|
+
if (terms.length === 0) return null // an empty compound decides nothing — skip
|
|
878
|
+
|
|
879
|
+
const results = terms.map((term) => {
|
|
880
|
+
if (!term || typeof term !== "object" || typeof term.field !== "string") return false
|
|
881
|
+
const value = fieldValue(term.field)
|
|
882
|
+
if (value === null) return false // no owned field with that name → fail-closed
|
|
883
|
+
const match = showPredicateMatches(term, value)
|
|
884
|
+
return match === true // null (malformed term) or false both fold to false
|
|
885
|
+
})
|
|
886
|
+
|
|
887
|
+
return connective === "all" ? results.every(Boolean) : results.some(Boolean)
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// A cross-root show target must be a single ID selector (issue #164) — the
|
|
891
|
+
// SAME shape the #159 mirror enforces (one shared regex), with its own warn so
|
|
892
|
+
// a refused show target is distinguishable in the console. The client half of
|
|
893
|
+
// the two-sided default-deny: reactive_show_targets raises at declare time; a
|
|
894
|
+
// hand-built wire attr must not widen the escape to class/compound selectors.
|
|
895
|
+
// A refused selector warns + skips — its siblings still apply.
|
|
896
|
+
function guardShowTargetSelector(selector) {
|
|
897
|
+
if (typeof selector === "string" && MIRROR_ID_SELECTOR.test(selector)) return true
|
|
898
|
+
console.warn(`[phlex-reactive] refused cross-root show target ${JSON.stringify(selector)} — skipped`)
|
|
899
|
+
return false
|
|
900
|
+
}
|
|
901
|
+
|
|
476
902
|
// The first focusable descendant of `el`, in document order — the natural
|
|
477
903
|
// keyboard target inside an opened menu/dialog. Covers the standard focusable
|
|
478
904
|
// set; :not([tabindex="-1"]) drops explicitly-removed nodes. Returns null when
|
|
@@ -568,6 +994,12 @@ export default class extends Controller {
|
|
|
568
994
|
// Show bindings (issue #161): the ONE delegated sync handler shared by the
|
|
569
995
|
// root's input/change/turbo:morph-element listeners, held for teardown.
|
|
570
996
|
#boundSyncShow
|
|
997
|
+
// Option filtering (issue #163): the ONE delegated sync handler shared by the
|
|
998
|
+
// root's input/turbo:morph-element listeners, held for teardown.
|
|
999
|
+
#boundSyncFilter
|
|
1000
|
+
// Lazy initial mount (issue #165): the bound re-probe attached to
|
|
1001
|
+
// turbo:morph-element so a Turbo page-refresh morph re-fires the defer fetch.
|
|
1002
|
+
#boundProbeLazyDefer
|
|
571
1003
|
|
|
572
1004
|
// Mark that a reactive controller actually connected, so the registration
|
|
573
1005
|
// guard above knows the controller was registered (issue #26 part 2).
|
|
@@ -590,6 +1022,25 @@ export default class extends Controller {
|
|
|
590
1022
|
)
|
|
591
1023
|
}
|
|
592
1024
|
|
|
1025
|
+
// Lazy initial mount (issue #165): a reactive_lazy shell carries its defer
|
|
1026
|
+
// token as a ROOT attribute — enter the SAME module-level fetch path a
|
|
1027
|
+
// reply directive uses (supersession, pending markers, error handling
|
|
1028
|
+
// included). Probe on connect (a plain replace / cache restoration
|
|
1029
|
+
// re-connects) AND on turbo:morph-element: a Turbo page-refresh MORPH
|
|
1030
|
+
// re-shows the shell while keeping the element CONNECTED and firing no
|
|
1031
|
+
// Stimulus lifecycle, so a connect-only probe would leave the morphed-in
|
|
1032
|
+
// shell shimmering forever. The supersession registry makes a duplicate
|
|
1033
|
+
// probe a no-op (same target id), so re-probing is safe. The attribute
|
|
1034
|
+
// stays on the shell precisely so a re-appearance re-fires.
|
|
1035
|
+
// Only wire the morph re-probe for a root that IS a lazy shell (carries the
|
|
1036
|
+
// token) — a component that never uses reactive_lazy pays nothing (no
|
|
1037
|
+
// listener), matching the dirty-tracking / show-sync gating precedent.
|
|
1038
|
+
if (this.element.getAttribute?.("data-reactive-defer-token")) {
|
|
1039
|
+
this.#probeLazyDefer()
|
|
1040
|
+
this.#boundProbeLazyDefer = () => this.#probeLazyDefer()
|
|
1041
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
|
|
1042
|
+
}
|
|
1043
|
+
|
|
593
1044
|
// Dirty tracking (issue #103) — ONLY when this root opts in (track_dirty: or a
|
|
594
1045
|
// reactive_field(dirty:)), so a component that never uses it pays nothing (no
|
|
595
1046
|
// baseline scan, no morph listener on every broadcast). A plain (outerHTML)
|
|
@@ -631,6 +1082,26 @@ export default class extends Controller {
|
|
|
631
1082
|
this.element.addEventListener?.("turbo:morph-element", this.#boundSyncShow)
|
|
632
1083
|
this.#syncShow()
|
|
633
1084
|
}
|
|
1085
|
+
|
|
1086
|
+
// Option filtering (issue #163) — ONLY when the root declares the binding
|
|
1087
|
+
// (reactive_filter emits both attrs together), so a component without one
|
|
1088
|
+
// pays two attribute reads. ONE delegated input listener on the root — the
|
|
1089
|
+
// handler re-filters only for events from the NAMED input, so keystrokes in
|
|
1090
|
+
// unrelated fields on a wide form never pay a filter pass. The connect sync
|
|
1091
|
+
// seeds from the input's current value (a plain replace re-connects; back
|
|
1092
|
+
// navigation may restore typed text), and turbo:morph-element re-applies
|
|
1093
|
+
// after an in-place morph (which keeps the element connected, fires no
|
|
1094
|
+
// Stimulus lifecycle, and may preserve the user's typed query while the
|
|
1095
|
+
// server re-rendered every option visible).
|
|
1096
|
+
if (this.#filterEnabled()) {
|
|
1097
|
+
this.#boundSyncFilter = (event) => {
|
|
1098
|
+
if (event?.type === "input" && !this.#filterInputEvent(event)) return
|
|
1099
|
+
this.#syncFilter()
|
|
1100
|
+
}
|
|
1101
|
+
this.element.addEventListener?.("input", this.#boundSyncFilter)
|
|
1102
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundSyncFilter)
|
|
1103
|
+
this.#syncFilter()
|
|
1104
|
+
}
|
|
634
1105
|
}
|
|
635
1106
|
|
|
636
1107
|
// Whether this root opts into dirty tracking (issue #103): track_dirty: puts the
|
|
@@ -656,6 +1127,26 @@ export default class extends Controller {
|
|
|
656
1127
|
this.#clearAllThrottles()
|
|
657
1128
|
this.#teardownDirtyTracking()
|
|
658
1129
|
this.#teardownShowSync()
|
|
1130
|
+
this.#teardownFilterSync()
|
|
1131
|
+
if (this.#boundProbeLazyDefer) {
|
|
1132
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
// Lazy initial mount probe (issue #165): fetch the real content when THIS
|
|
1137
|
+
// root is a reactive_lazy shell that still carries its defer token AND the
|
|
1138
|
+
// pending marker. Gating on the pending marker is what makes a re-probe (a
|
|
1139
|
+
// Turbo morph re-showing the shell) fire while a re-probe of an already
|
|
1140
|
+
// RESOLVED root (real content, no token, no marker) is a no-op. The
|
|
1141
|
+
// module-level supersession registry dedupes a duplicate in-flight fetch for
|
|
1142
|
+
// the same id, so calling this on both connect and every morph is safe.
|
|
1143
|
+
#probeLazyDefer() {
|
|
1144
|
+
const el = this.element
|
|
1145
|
+
if (!el?.id) return
|
|
1146
|
+
const token = el.getAttribute?.("data-reactive-defer-token")
|
|
1147
|
+
if (!token) return
|
|
1148
|
+
if (el.getAttribute?.("data-reactive-defer-pending") !== "true") return
|
|
1149
|
+
startFetchDefer(el.id, token)
|
|
659
1150
|
}
|
|
660
1151
|
|
|
661
1152
|
// Serialize requests per component. Each round trip rewrites the signed
|
|
@@ -951,7 +1442,9 @@ export default class extends Controller {
|
|
|
951
1442
|
// unset. Falls back to the root for a directly-invoked call (unit tests). The
|
|
952
1443
|
// ownership predicate is hoisted ONCE per keypress (issue #117) — in the common
|
|
953
1444
|
// no-nested-root case it is a constant true, skipping a closest() walk per
|
|
954
|
-
// option.
|
|
1445
|
+
// option. Hidden options are excluded (issue #163): a reactive_filter (or any
|
|
1446
|
+
// `hidden` toggle) removes a row from the keyboard path too, so an Arrow can't
|
|
1447
|
+
// highlight — and Enter can't pick — an invisible option.
|
|
955
1448
|
#listnavOptions(event) {
|
|
956
1449
|
const trigger = event?.currentTarget ?? event?.target ?? this.element
|
|
957
1450
|
const selector =
|
|
@@ -959,7 +1452,7 @@ export default class extends Controller {
|
|
|
959
1452
|
this.element.getAttribute("data-reactive-listnav-option-param")
|
|
960
1453
|
if (!selector) return []
|
|
961
1454
|
const owns = this.#ownershipFilter()
|
|
962
|
-
return Array.from(this.element.querySelectorAll(selector)).filter(owns)
|
|
1455
|
+
return Array.from(this.element.querySelectorAll(selector)).filter((el) => !el.hidden && owns(el))
|
|
963
1456
|
}
|
|
964
1457
|
|
|
965
1458
|
// Parse a JSON string list from a root data attr; [] on absence/parse error so
|
|
@@ -1824,12 +2317,18 @@ export default class extends Controller {
|
|
|
1824
2317
|
this.#boundBeforeVisit = undefined
|
|
1825
2318
|
}
|
|
1826
2319
|
|
|
1827
|
-
// Whether this root owns a show binding (issue #161)
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
// instance syncs them
|
|
2320
|
+
// Whether this root owns a show binding (issue #161) or declares cross-root
|
|
2321
|
+
// show targets (issue #164) — the connect() gate, so a component with
|
|
2322
|
+
// neither pays only this probe (the #dirtyTrackingEnabled precedent). A
|
|
2323
|
+
// NESTED root's bindings don't count: its own controller instance syncs them
|
|
2324
|
+
// (issue #15 ownership). The targets attr is checked FIRST — one
|
|
2325
|
+
// getAttribute, cheaper than the binding walk.
|
|
1831
2326
|
#showSyncEnabled() {
|
|
1832
|
-
|
|
2327
|
+
if (this.element.getAttribute?.("data-reactive-show-targets")) return true
|
|
2328
|
+
// Single-field bindings carry -field; compound all:/any: bindings (issue
|
|
2329
|
+
// #176) carry data-reactive-show and have NO single controlling field, so
|
|
2330
|
+
// both selectors gate the sync.
|
|
2331
|
+
const nodes = this.element.querySelectorAll?.(SHOW_BINDING_SELECTOR) ?? []
|
|
1833
2332
|
for (const el of nodes) if (this.#ownsField(el)) return true
|
|
1834
2333
|
return false
|
|
1835
2334
|
}
|
|
@@ -1847,17 +2346,89 @@ export default class extends Controller {
|
|
|
1847
2346
|
|
|
1848
2347
|
const owns = this.#ownershipFilter()
|
|
1849
2348
|
const values = new Map()
|
|
1850
|
-
|
|
2349
|
+
// A memoized resolver shared by every binding in this pass — a field driving
|
|
2350
|
+
// several bindings (and several terms of a compound) reads exactly once.
|
|
2351
|
+
const fieldValue = (name) => {
|
|
2352
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2353
|
+
return values.get(name)
|
|
2354
|
+
}
|
|
2355
|
+
for (const el of this.element.querySelectorAll(SHOW_BINDING_SELECTOR)) {
|
|
1851
2356
|
if (!owns(el)) continue // a nested root's binding is its own controller's job
|
|
2357
|
+
|
|
2358
|
+
// Compound all:/any: (issue #176 part A): no single -field attr; parse the
|
|
2359
|
+
// JSON payload and fold its per-field terms.
|
|
2360
|
+
const compoundRaw = el.getAttribute("data-reactive-show")
|
|
2361
|
+
if (compoundRaw !== null) {
|
|
2362
|
+
const match = compoundShowMatches(parseShowCompound(compoundRaw), fieldValue)
|
|
2363
|
+
if (match !== null) el.hidden = !match
|
|
2364
|
+
continue
|
|
2365
|
+
}
|
|
2366
|
+
|
|
1852
2367
|
const name = el.getAttribute("data-reactive-show-field")
|
|
1853
2368
|
if (!name) continue
|
|
1854
|
-
|
|
1855
|
-
const value = values.get(name)
|
|
2369
|
+
const value = fieldValue(name)
|
|
1856
2370
|
if (value === null) continue // no owned field with that name — leave it be
|
|
1857
2371
|
const match = showBindingMatches(el, value)
|
|
1858
2372
|
if (match === null) continue // malformed predicate — warned + skipped
|
|
1859
2373
|
el.hidden = !match
|
|
1860
2374
|
}
|
|
2375
|
+
|
|
2376
|
+
// The cross-root pass (issue #164) shares the same owned-field memo, so a
|
|
2377
|
+
// field driving both an owned binding and an outside target reads once.
|
|
2378
|
+
this.#syncShowTargets(owns, values)
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// Apply the declared cross-root show targets (issue #164) — the visibility
|
|
2382
|
+
// parallel of #applyComputeMirrors. For each declared field: read the OWNED
|
|
2383
|
+
// field's current value (never a nested root's — you can only drive outside
|
|
2384
|
+
// visibility from a field this root owns), then for each "#id" → predicate
|
|
2385
|
+
// entry: guard the selector id-only (warn-and-skip; the Ruby helper raised
|
|
2386
|
+
// at declare time — two-sided default-deny), resolve it DOCUMENT-WIDE, and
|
|
2387
|
+
// toggle `hidden`. A target id not on the page is silently skipped (an
|
|
2388
|
+
// unrendered tab pane is normal); a malformed predicate warn-skips its one
|
|
2389
|
+
// target while siblings still apply. With no map declared this is one
|
|
2390
|
+
// getAttribute and out.
|
|
2391
|
+
#syncShowTargets(owns, values) {
|
|
2392
|
+
const map = this.#parseShowTargets()
|
|
2393
|
+
for (const [name, targets] of Object.entries(map)) {
|
|
2394
|
+
if (!targets || typeof targets !== "object" || Array.isArray(targets)) continue
|
|
2395
|
+
if (!values.has(name)) values.set(name, this.#showFieldValue(name, owns))
|
|
2396
|
+
const value = values.get(name)
|
|
2397
|
+
if (value === null) continue // no owned field with that name — leave them be
|
|
2398
|
+
for (const [selector, pred] of Object.entries(targets)) {
|
|
2399
|
+
if (!guardShowTargetSelector(selector)) continue
|
|
2400
|
+
const match = showPredicateMatches(pred, value)
|
|
2401
|
+
if (match === null) {
|
|
2402
|
+
console.warn(`[phlex-reactive] malformed reactive_show_targets predicate for ${selector} — skipped`)
|
|
2403
|
+
continue
|
|
2404
|
+
}
|
|
2405
|
+
for (const node of document.querySelectorAll(selector)) node.hidden = !match
|
|
2406
|
+
}
|
|
2407
|
+
}
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
// The declared cross-root show-target map (issue #164): a JSON object of
|
|
2411
|
+
// { field: { "#id": predicate } } from data-reactive-show-targets (emitted
|
|
2412
|
+
// by reactive_show_targets on the root). Absent degrades to {}; malformed
|
|
2413
|
+
// degrades to {} WITH a warn — never a throw (the #parseComputeMirror
|
|
2414
|
+
// contract), but never silent either: the likeliest cause is TWO
|
|
2415
|
+
// reactive_show_targets calls on one root, whose JSON strings Phlex `mix`
|
|
2416
|
+
// space-joined into an unparseable attr. The warn names the fix.
|
|
2417
|
+
#parseShowTargets() {
|
|
2418
|
+
const raw = this.element.getAttribute?.("data-reactive-show-targets")
|
|
2419
|
+
if (!raw) return {}
|
|
2420
|
+
try {
|
|
2421
|
+
const parsed = JSON.parse(raw)
|
|
2422
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed
|
|
2423
|
+
} catch {
|
|
2424
|
+
// fall through to the shared warn below
|
|
2425
|
+
}
|
|
2426
|
+
console.warn(
|
|
2427
|
+
"[phlex-reactive] malformed data-reactive-show-targets — ignored. " +
|
|
2428
|
+
"Did two reactive_show_targets calls collide on one root? Declare every field in ONE call: " +
|
|
2429
|
+
"reactive_show_targets(mode: { ... }, kind: { ... })"
|
|
2430
|
+
)
|
|
2431
|
+
return {}
|
|
1861
2432
|
}
|
|
1862
2433
|
|
|
1863
2434
|
// The current value of the OWNED field controlling a show binding, as the
|
|
@@ -1894,6 +2465,84 @@ export default class extends Controller {
|
|
|
1894
2465
|
this.#boundSyncShow = undefined
|
|
1895
2466
|
}
|
|
1896
2467
|
|
|
2468
|
+
// Whether this root declares an option filter (issue #163) — the connect()
|
|
2469
|
+
// gate. reactive_filter always emits input + option together, so requiring
|
|
2470
|
+
// BOTH also default-denies a half-built hand-authored binding.
|
|
2471
|
+
#filterEnabled() {
|
|
2472
|
+
return !!(
|
|
2473
|
+
this.element.getAttribute?.("data-reactive-filter-input") &&
|
|
2474
|
+
this.element.getAttribute?.("data-reactive-filter-option")
|
|
2475
|
+
)
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
// Whether a delegated input event came from the NAMED filter input (issue
|
|
2479
|
+
// #163). Anything else — another field's keystroke, a target without
|
|
2480
|
+
// matches() — skips the filter pass (the morph re-sync path bypasses this).
|
|
2481
|
+
#filterInputEvent(event) {
|
|
2482
|
+
const selector = this.element.getAttribute("data-reactive-filter-input")
|
|
2483
|
+
return !!selector && typeof event.target?.matches === "function" && event.target.matches(selector)
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// Re-apply the filter in one pass (issue #163): lowercase the named input's
|
|
2487
|
+
// current value, toggle `hidden` on every OWNED option by a substring match
|
|
2488
|
+
// against its haystack (data-reactive-filter-text, falling back to the
|
|
2489
|
+
// option's own text), collapse any group whose every contained option is
|
|
2490
|
+
// hidden, and reveal the empty target at 0 visible. A filtered-out option
|
|
2491
|
+
// also loses its listnav highlight so Enter can never pick an invisible row.
|
|
2492
|
+
// No owned input → leave visibility ALONE — a binding that can't resolve
|
|
2493
|
+
// must never break or blank the page (client-side default-deny). All
|
|
2494
|
+
// selectors resolve within this root, skipping nested reactive roots'
|
|
2495
|
+
// elements (issue #15 ownership; the predicate is hoisted once per pass).
|
|
2496
|
+
#syncFilter() {
|
|
2497
|
+
if (typeof this.element?.querySelectorAll !== "function") return
|
|
2498
|
+
const inputSelector = this.element.getAttribute("data-reactive-filter-input")
|
|
2499
|
+
const optionSelector = this.element.getAttribute("data-reactive-filter-option")
|
|
2500
|
+
if (!inputSelector || !optionSelector) return
|
|
2501
|
+
|
|
2502
|
+
const owns = this.#ownershipFilter()
|
|
2503
|
+
const input = [...this.element.querySelectorAll(inputSelector)].find(owns)
|
|
2504
|
+
if (!input) return
|
|
2505
|
+
|
|
2506
|
+
const query = (input.value ?? "").trim().toLowerCase()
|
|
2507
|
+
let visible = 0
|
|
2508
|
+
for (const el of this.element.querySelectorAll(optionSelector)) {
|
|
2509
|
+
if (!owns(el)) continue // a nested root's option is its own controller's job
|
|
2510
|
+
const haystack = (el.getAttribute("data-reactive-filter-text") ?? el.textContent ?? "").toLowerCase()
|
|
2511
|
+
const hidden = query !== "" && !haystack.includes(query)
|
|
2512
|
+
el.hidden = hidden
|
|
2513
|
+
if (hidden) el.removeAttribute("data-reactive-highlighted")
|
|
2514
|
+
else visible++
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
const groupSelector = this.element.getAttribute("data-reactive-filter-group")
|
|
2518
|
+
if (groupSelector) {
|
|
2519
|
+
for (const group of this.element.querySelectorAll(groupSelector)) {
|
|
2520
|
+
if (!owns(group)) continue
|
|
2521
|
+
const contained = [...group.querySelectorAll(optionSelector)].filter(owns)
|
|
2522
|
+
// A group with no options isn't this filter's to decide — server state
|
|
2523
|
+
// stands (it may be a header the app toggles by other means).
|
|
2524
|
+
if (contained.length === 0) continue
|
|
2525
|
+
group.hidden = contained.every((el) => el.hidden)
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
const emptySelector = this.element.getAttribute("data-reactive-filter-empty")
|
|
2530
|
+
if (emptySelector) {
|
|
2531
|
+
for (const el of this.element.querySelectorAll(emptySelector)) {
|
|
2532
|
+
if (owns(el)) el.hidden = visible > 0
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
}
|
|
2536
|
+
|
|
2537
|
+
// Remove the filter-sync listeners on disconnect, so a stray event after a
|
|
2538
|
+
// Turbo morph/navigation never re-filters against a detached root.
|
|
2539
|
+
#teardownFilterSync() {
|
|
2540
|
+
if (!this.#boundSyncFilter) return
|
|
2541
|
+
this.element.removeEventListener?.("input", this.#boundSyncFilter)
|
|
2542
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncFilter)
|
|
2543
|
+
this.#boundSyncFilter = undefined
|
|
2544
|
+
}
|
|
2545
|
+
|
|
1897
2546
|
// Build the multipart body (issue #34). `token`/`act` are flat fields the
|
|
1898
2547
|
// endpoint reads from params[:token]/params[:act]; scalar params nest under
|
|
1899
2548
|
// params[<key>] (Rails parses the bracket into params[:params]); each file is
|