phlex-reactive 0.14.4 → 0.15.1
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 +95 -0
- data/README.md +211 -4
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +5 -5
- data/app/javascript/phlex/reactive/core.min.js +3 -3
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +36 -9
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +125 -140
- 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/form.js +63 -41
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/presence.js +205 -0
- data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
- data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +5 -1
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +3 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
- data/app/javascript/phlex/reactive/runtime.js +310 -14
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/helpers.rb +94 -8
- data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
- data/lib/phlex/reactive/component/registry.rb +3 -1
- data/lib/phlex/reactive/component.rb +1 -0
- data/lib/phlex/reactive/defer.rb +5 -0
- data/lib/phlex/reactive/engine.rb +9 -4
- data/lib/phlex/reactive/js.rb +44 -0
- data/lib/phlex/reactive/presence/component_methods.rb +130 -0
- data/lib/phlex/reactive/presence/roster.rb +90 -0
- data/lib/phlex/reactive/presence/transport.rb +154 -0
- data/lib/phlex/reactive/presence.rb +162 -0
- data/lib/phlex/reactive/presence_render_job.rb +26 -0
- data/lib/phlex/reactive/shortcut.rb +118 -0
- data/lib/phlex/reactive/streamable.rb +12 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- metadata +11 -1
|
@@ -36,6 +36,7 @@ import * as devtoolsFeature from "phlex/reactive/features/devtools"
|
|
|
36
36
|
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
|
+
import * as presenceFeature from "phlex/reactive/features/presence"
|
|
39
40
|
|
|
40
41
|
// phlex/reactive/runtime — the reactive controller WITHOUT its feature
|
|
41
42
|
// modules (issue #275): the code both client entries share. It is not an
|
|
@@ -199,6 +200,8 @@ export function registerReactiveStreamRender() {
|
|
|
199
200
|
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
200
201
|
streamRenderRegistered = true
|
|
201
202
|
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
203
|
+
streamIntoRoots(event)
|
|
204
|
+
streamModals(event)
|
|
202
205
|
// The opt-in entry sees every stream (it may hold one for a module the
|
|
203
206
|
// incoming content needs); with no entry hook, a loaded effects module
|
|
204
207
|
// wraps the stream directly — in the default entry, always.
|
|
@@ -210,6 +213,149 @@ export function registerReactiveStreamRender() {
|
|
|
210
213
|
// The names this registration had while it was two listeners.
|
|
211
214
|
export { registerReactiveStreamRender as registerReactiveDismiss, registerReactiveStreamRender as registerReactiveEffects }
|
|
212
215
|
|
|
216
|
+
// --- A stream into a connected root (issue #356) ---
|
|
217
|
+
// A morph of a root makes it re-read its feature markers (#boundRootMorph); a
|
|
218
|
+
// stream that renders INTO it — append, prepend or update of the root or a
|
|
219
|
+
// child, before, after or replace of a child — fires neither a lifecycle nor
|
|
220
|
+
// a morph of the root, so a marker it brings was never connected. The roots
|
|
221
|
+
// a stream renders into are resolved here, before anything (effects, the opt-in
|
|
222
|
+
// entry's hold) wraps Turbo's render; once that render has run, each one still
|
|
223
|
+
// connected is told (STREAMED, not bubbling: an outer root's content is not
|
|
224
|
+
// its own). The root re-runs its connected features' seeds and connects what
|
|
225
|
+
// the new content needs, in both entries.
|
|
226
|
+
const STREAMED = "reactive:streamed"
|
|
227
|
+
|
|
228
|
+
function streamIntoRoots(event) {
|
|
229
|
+
const detail = event.detail
|
|
230
|
+
const render = detail?.render
|
|
231
|
+
const stream = detail?.newStream ?? event.target
|
|
232
|
+
const action = stream?.getAttribute?.("action")
|
|
233
|
+
const into = /^(append|prepend|update)$/.test(action)
|
|
234
|
+
if (typeof render !== "function" || !(into || /^(before|after|replace)$/.test(action))) return
|
|
235
|
+
const id = stream.getAttribute("target")
|
|
236
|
+
const selector = stream.getAttribute("targets")
|
|
237
|
+
const roots = new Set()
|
|
238
|
+
for (const target of id ? [document.getElementById(id)] : selector ? document.querySelectorAll(selector) : []) {
|
|
239
|
+
const root = (into ? target : target?.parentElement)?.closest?.('[data-controller~="reactive"]')
|
|
240
|
+
if (root) roots.add(root)
|
|
241
|
+
}
|
|
242
|
+
if (roots.size === 0) return
|
|
243
|
+
detail.render = async (el) => {
|
|
244
|
+
await render(el)
|
|
245
|
+
for (const root of roots) if (root.isConnected) root.dispatchEvent(new Event(STREAMED))
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// --- Modal dialogs (issue #365) ---
|
|
250
|
+
// show_modal / close_dialog reach the browser's own modal dialog: the top
|
|
251
|
+
// layer, the focus trap, ::backdrop, Escape and focus restored to the opener.
|
|
252
|
+
// A dialog the SERVER renders open with data-reactive-modal becomes modal as
|
|
253
|
+
// soon as the client sees it: at a root's connect (a full page, a replaced
|
|
254
|
+
// root), after a morph of a root or of the page, and after a stream whose
|
|
255
|
+
// content holds a dialog. Duck-typed on tagName (the submitFormFor precedent:
|
|
256
|
+
// fake-node and test friendly).
|
|
257
|
+
function isDialog(el) {
|
|
258
|
+
return el?.tagName === "DIALOG" && typeof el.showModal === "function"
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// `:modal` is the only way to tell a modal dialog from one opened with the
|
|
262
|
+
// `open` attribute. An engine that does not know the pseudo-class throws a
|
|
263
|
+
// SyntaxError; it then counts as not modal (show_modal reopens it modally).
|
|
264
|
+
function isModal(dialog) {
|
|
265
|
+
try {
|
|
266
|
+
return dialog.matches(":modal")
|
|
267
|
+
} catch {
|
|
268
|
+
return false
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function showModal(el) {
|
|
273
|
+
if (!isDialog(el)) {
|
|
274
|
+
// Said where a zero-target op is (issue #237): under verbose_errors or debug.
|
|
275
|
+
if (el?.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]')) {
|
|
276
|
+
console.warn(`[phlex-reactive] show_modal targets a <dialog>, not <${el.localName}> — skipped`)
|
|
277
|
+
}
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
if (!el.isConnected || isModal(el)) return
|
|
281
|
+
// showModal() throws on a dialog already open non-modally: close it first,
|
|
282
|
+
// in the same task, so nothing paints in between.
|
|
283
|
+
if (el.open) el.close()
|
|
284
|
+
el.showModal()
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function closeDialog(el) {
|
|
288
|
+
if (isDialog(el) && el.open) el.close()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Make every open dialog[data-reactive-modal] in `scope` modal — `scope` itself
|
|
292
|
+
// too, for a root that is the dialog.
|
|
293
|
+
function upgradeModals(scope) {
|
|
294
|
+
const sel = "dialog[data-reactive-modal]"
|
|
295
|
+
const dialogs = [...(scope?.querySelectorAll?.(sel) ?? [])]
|
|
296
|
+
if (scope?.matches?.(sel)) dialogs.push(scope)
|
|
297
|
+
for (const dialog of dialogs) {
|
|
298
|
+
if (dialog.open && !isModal(dialog)) showModal(dialog)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// After a stream whose content holds a dialog has rendered, upgrade the page's
|
|
303
|
+
// marked dialogs. Document-wide: a replace's target is gone by then.
|
|
304
|
+
function streamModals(event) {
|
|
305
|
+
const detail = event.detail
|
|
306
|
+
const render = detail?.render
|
|
307
|
+
const stream = detail?.newStream ?? event.target
|
|
308
|
+
if (typeof render !== "function" || !stream?.querySelector?.("template")?.content?.querySelector?.("dialog")) return
|
|
309
|
+
detail.render = async (el) => {
|
|
310
|
+
await render(el)
|
|
311
|
+
upgradeModals(document)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
let modalsRegistered = false
|
|
316
|
+
|
|
317
|
+
export function registerReactiveModals() {
|
|
318
|
+
if (modalsRegistered) return
|
|
319
|
+
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
320
|
+
modalsRegistered = true
|
|
321
|
+
// A morph of a root (a reply or broadcast morph, a page refresh) may have
|
|
322
|
+
// inserted an open marked dialog or opened one; a page morph covers dialogs
|
|
323
|
+
// outside any root.
|
|
324
|
+
document.addEventListener("turbo:morph-element", (event) => {
|
|
325
|
+
if (event.target?.matches?.('[data-controller~="reactive"]')) upgradeModals(event.target)
|
|
326
|
+
})
|
|
327
|
+
document.addEventListener("turbo:morph", () => upgradeModals(document))
|
|
328
|
+
// A morph that removes `open` from a modal dialog (the server now renders it
|
|
329
|
+
// closed): close() it instead, which leaves the top layer, ends the page's
|
|
330
|
+
// inertness and restores focus. Removing the attribute alone left all that
|
|
331
|
+
// in place under the older spec text, and close() is a no-op once `open` is
|
|
332
|
+
// gone — so the removal is cancelled and close() removes it. Idiomorph asks
|
|
333
|
+
// "remove" for EVERY attribute of the old element, kept or not, and that ask
|
|
334
|
+
// carries no new element: whether `open` is really leaving is read from the
|
|
335
|
+
// element morph's new render, which comes first.
|
|
336
|
+
const closing = new WeakSet()
|
|
337
|
+
document.addEventListener("turbo:before-morph-element", (event) => {
|
|
338
|
+
const el = event.target
|
|
339
|
+
const next = event.detail?.newElement
|
|
340
|
+
if (!isDialog(el) || !next) return
|
|
341
|
+
if (next.hasAttribute?.("open")) closing.delete(el)
|
|
342
|
+
else closing.add(el)
|
|
343
|
+
})
|
|
344
|
+
document.addEventListener("turbo:before-morph-attribute", (event) => {
|
|
345
|
+
const el = event.target
|
|
346
|
+
const detail = event.detail
|
|
347
|
+
if (detail?.attributeName !== "open" || detail.mutationType !== "remove") return
|
|
348
|
+
if (!closing.has(el) || !el.open || !isModal(el)) return
|
|
349
|
+
closing.delete(el)
|
|
350
|
+
event.preventDefault()
|
|
351
|
+
el.close()
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function __resetReactiveModalsForTest() {
|
|
356
|
+
modalsRegistered = false
|
|
357
|
+
}
|
|
358
|
+
|
|
213
359
|
// For the opt-in entry: what to do with a stream before its render — hold it
|
|
214
360
|
// for a module the incoming content needs. `handle(event, effects)` gets the
|
|
215
361
|
// loaded effects module (or undefined) and wraps the stream with it itself.
|
|
@@ -237,6 +383,66 @@ export function __resetReactiveStreamRenderForTest() {
|
|
|
237
383
|
// for the attribute AND the property. Nothing else
|
|
238
384
|
// (a root's token, a field's class) is held.
|
|
239
385
|
// data-reactive-morph-value on a field lets the morph write it while focused.
|
|
386
|
+
// --- An engine without contenteditable="plaintext-only" (issue #355) ---
|
|
387
|
+
// reactive_editable renders contenteditable="plaintext-only": the browser
|
|
388
|
+
// itself then pastes plain text and inserts "\n" for Enter. An engine without
|
|
389
|
+
// it reads the value as INVALID — the element is not editable, cannot even be
|
|
390
|
+
// focused — so there every reactive editable is switched to "true" (and the
|
|
391
|
+
// bindings feature does the plain-text paste itself). However it reaches the
|
|
392
|
+
// page: present at load, appended by a stream into a root that is already
|
|
393
|
+
// connected, loaded in a frame, written back by a morph of an unfocused
|
|
394
|
+
// editable, or made reactive by a morph. One observer, only in such an engine; elsewhere the probe is all
|
|
395
|
+
// that runs. Detected once per page.
|
|
396
|
+
let plaintextOnly
|
|
397
|
+
let plaintextObserver = null
|
|
398
|
+
const PLAINTEXT_EDITABLE = "[data-reactive-editable][contenteditable=plaintext-only]"
|
|
399
|
+
|
|
400
|
+
function plaintextSupported() {
|
|
401
|
+
if (plaintextOnly === undefined) {
|
|
402
|
+
try {
|
|
403
|
+
const probe = document.createElement("div")
|
|
404
|
+
probe.contentEditable = "plaintext-only"
|
|
405
|
+
plaintextOnly = probe.contentEditable === "plaintext-only"
|
|
406
|
+
} catch {
|
|
407
|
+
plaintextOnly = false
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return plaintextOnly
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function makeEditable(el) {
|
|
414
|
+
if (el.matches?.(PLAINTEXT_EDITABLE)) el.setAttribute("contenteditable", "true")
|
|
415
|
+
el.querySelectorAll?.(PLAINTEXT_EDITABLE).forEach(makeEditable)
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export function registerReactivePlaintext() {
|
|
419
|
+
// (No element factory, no page to observe: never probe a stub document.)
|
|
420
|
+
if (plaintextObserver || typeof document === "undefined" || typeof document.createElement !== "function") return
|
|
421
|
+
if (plaintextSupported()) return
|
|
422
|
+
makeEditable(document)
|
|
423
|
+
plaintextObserver = new MutationObserver((records) => {
|
|
424
|
+
for (const record of records) {
|
|
425
|
+
if (record.type === "attributes") makeEditable(record.target)
|
|
426
|
+
else record.addedNodes.forEach(makeEditable)
|
|
427
|
+
}
|
|
428
|
+
})
|
|
429
|
+
plaintextObserver.observe(document.documentElement, {
|
|
430
|
+
childList: true,
|
|
431
|
+
subtree: true,
|
|
432
|
+
attributes: true,
|
|
433
|
+
// (a morph can also make an existing plaintext-only element reactive)
|
|
434
|
+
attributeFilter: ["contenteditable", "data-reactive-editable"],
|
|
435
|
+
})
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// For tests: what the probe answers (undefined probes again), with no
|
|
439
|
+
// observer installed.
|
|
440
|
+
export function __setReactivePlaintextOnlyForTest(value) {
|
|
441
|
+
plaintextObserver?.disconnect()
|
|
442
|
+
plaintextObserver = null
|
|
443
|
+
plaintextOnly = value
|
|
444
|
+
}
|
|
445
|
+
|
|
240
446
|
let morphFocusRegistered = false
|
|
241
447
|
|
|
242
448
|
//
|
|
@@ -244,8 +450,8 @@ let morphFocusRegistered = false
|
|
|
244
450
|
// CHILDREN: a focused one keeps them (text and caret) while every attribute
|
|
245
451
|
// still morphs — copied here from the render, then the element's morph is
|
|
246
452
|
// cancelled (cancelling is all-or-nothing in Idiomorph). Its contenteditable
|
|
247
|
-
// stays as it is: the
|
|
248
|
-
// plaintext-only. Every morph of an editable, held or not, records the
|
|
453
|
+
// stays as it is: the runtime switches it to "true" in an engine without
|
|
454
|
+
// plaintext-only (registerReactivePlaintext, #355). Every morph of an editable, held or not, records the
|
|
249
455
|
// render's text as its SERVER TEXT (the Escape revert, the dirty baseline).
|
|
250
456
|
function holdsTypedValue(el) {
|
|
251
457
|
return (
|
|
@@ -486,6 +692,8 @@ export function registerReactiveActions() {
|
|
|
486
692
|
registerReactiveDefer()
|
|
487
693
|
registerReactiveStreamRender()
|
|
488
694
|
registerReactiveMorphFocus()
|
|
695
|
+
registerReactiveModals()
|
|
696
|
+
registerReactivePlaintext()
|
|
489
697
|
registerReactiveOffline()
|
|
490
698
|
registerReactiveDev()
|
|
491
699
|
}
|
|
@@ -742,6 +950,11 @@ const CLIENT_OPS = Object.freeze({
|
|
|
742
950
|
focus: (el) => el.focus?.(),
|
|
743
951
|
focus_first: (el) => firstFocusable(el)?.focus?.(),
|
|
744
952
|
|
|
953
|
+
// Modal dialog ops (issue #365): HTMLDialogElement#showModal / #close.
|
|
954
|
+
// ACTOR-ONLY like focus (BROADCAST_REFUSED_OPS server-side).
|
|
955
|
+
show_modal: (el) => showModal(el),
|
|
956
|
+
close_dialog: (el) => closeDialog(el),
|
|
957
|
+
|
|
745
958
|
// Text op (issue #159): set textContent — XSS-safe by construction (never
|
|
746
959
|
// innerHTML), strictly less powerful than set_attr. Change-guarded like
|
|
747
960
|
// #mirrorText. With global: true it is the cross-root text escape: paint a
|
|
@@ -766,6 +979,13 @@ const CLIENT_OPS = Object.freeze({
|
|
|
766
979
|
}
|
|
767
980
|
},
|
|
768
981
|
|
|
982
|
+
// Write this tab's presence state (issue #358) through the root's
|
|
983
|
+
// presence_set action. The feature warns outside a reactive_presence root.
|
|
984
|
+
presence: (el, args) =>
|
|
985
|
+
__SPLIT__
|
|
986
|
+
? withFeature("presence", (presence) => presence.set(el, args.state))
|
|
987
|
+
: presenceFeature.set(el, args.state),
|
|
988
|
+
|
|
769
989
|
// Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
|
|
770
990
|
// controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
|
|
771
991
|
dispatch: (el, args) => {
|
|
@@ -922,9 +1142,13 @@ function parseOps(raw) {
|
|
|
922
1142
|
// record(s) whose descriptor matches the firing event: event.type, the key
|
|
923
1143
|
// filter, and window-boundness.
|
|
924
1144
|
|
|
925
|
-
// Stimulus's default keyMappings (letters and digits map to themselves)
|
|
1145
|
+
// Stimulus's default keyMappings (letters and digits map to themselves), plus
|
|
1146
|
+
// delete and backspace (issue #366): Stimulus lacks those two, so a shortcut
|
|
1147
|
+
// naming one binds a bare keydown and only shortcutApplies matches it. A
|
|
926
1148
|
// custom Stimulus schema is not mirrored — an unknown filter never matches.
|
|
927
1149
|
const KEY_FILTER_MAP = Object.freeze({
|
|
1150
|
+
backspace: "Backspace",
|
|
1151
|
+
delete: "Delete",
|
|
928
1152
|
enter: "Enter",
|
|
929
1153
|
tab: "Tab",
|
|
930
1154
|
esc: "Escape",
|
|
@@ -963,6 +1187,25 @@ function keyFilterMatches(filter, event, keyMappings) {
|
|
|
963
1187
|
return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
|
|
964
1188
|
}
|
|
965
1189
|
|
|
1190
|
+
// Issue #357: a shortcut: ("mod+z", or { keys, inEditable }) is a window
|
|
1191
|
+
// keydown whose `mod` is meta on Apple platforms and ctrl elsewhere. Ruby
|
|
1192
|
+
// renders a meta twin and a ctrl twin; Stimulus filters the key, and this
|
|
1193
|
+
// decides whether the twin that fired is this platform's and whether the
|
|
1194
|
+
// target is one the user is typing in. Either miss is a complete no-op. A
|
|
1195
|
+
// delete/backspace chord (issue #366) has no twins and no Stimulus filter:
|
|
1196
|
+
// its bare keydown hears every key, and this check alone picks the chord.
|
|
1197
|
+
// (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
|
|
1198
|
+
const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
|
|
1199
|
+
// A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
|
|
1200
|
+
// keydown through and this check, per record, picks the chord that fired.
|
|
1201
|
+
function shortcutApplies(spec, event, keyMappings) {
|
|
1202
|
+
const target = event.target
|
|
1203
|
+
if (spec?.inEditable !== true && (target?.isContentEditable || /^(input|textarea|select)$/.test(target?.localName)))
|
|
1204
|
+
return false
|
|
1205
|
+
const mod = /[Mm]ac|iP/.test(navigator.userAgentData?.platform ?? navigator.platform) ? "meta" : "ctrl"
|
|
1206
|
+
return keyFilterMatches(shortcutKeys(spec).replace(/^mod\+/, `${mod}+`), event, keyMappings)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
966
1209
|
// Parse data-reactive-ops-param into binding records. Stimulus typecasts a
|
|
967
1210
|
// lone record to an object; several records stay a string (JSON.parse fails on
|
|
968
1211
|
// the join) and are split here. A LEGACY [[op, args]] list (array or JSON
|
|
@@ -1283,6 +1526,11 @@ function sortableNeeded(root) {
|
|
|
1283
1526
|
return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
|
|
1284
1527
|
}
|
|
1285
1528
|
|
|
1529
|
+
// Presence (issue #358): the root is a reactive_presence roster.
|
|
1530
|
+
function presenceNeeded(root) {
|
|
1531
|
+
return root.getAttribute?.("data-reactive-presence") != null
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1286
1534
|
const PRODUCTION_FEATURES = [
|
|
1287
1535
|
// (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
|
|
1288
1536
|
// writes what a request reads, so it gates.
|
|
@@ -1295,6 +1543,9 @@ const PRODUCTION_FEATURES = [
|
|
|
1295
1543
|
["compute", [computeNeeded, null, null, true]],
|
|
1296
1544
|
// Nothing it does at connect is read by a request: it never gates.
|
|
1297
1545
|
["sortable", [sortableNeeded, null]],
|
|
1546
|
+
// Its announce is a request of its own; nothing it does at connect is read
|
|
1547
|
+
// by another request: it never gates.
|
|
1548
|
+
["presence", [presenceNeeded, null]],
|
|
1298
1549
|
[
|
|
1299
1550
|
// Stream effects and dismissing flashes are document-level: the module
|
|
1300
1551
|
// has no connect. A root that DECLARES an effect only gets it fetched
|
|
@@ -1458,7 +1709,7 @@ function featureTimeoutMs() {
|
|
|
1458
1709
|
export function reactiveFeatureNames() {
|
|
1459
1710
|
return __SPLIT__
|
|
1460
1711
|
? [...FEATURES.keys()]
|
|
1461
|
-
: ["persist", "defer", "form", "bindings", "compute", "sortable", "effects", "hints", "devtools"]
|
|
1712
|
+
: ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
|
|
1462
1713
|
}
|
|
1463
1714
|
|
|
1464
1715
|
export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
|
|
@@ -1481,6 +1732,7 @@ export function __loadReactiveFeatureForTest(name) {
|
|
|
1481
1732
|
bindings: bindingsFeature,
|
|
1482
1733
|
compute: computeFeature,
|
|
1483
1734
|
sortable: sortableFeature,
|
|
1735
|
+
presence: presenceFeature,
|
|
1484
1736
|
effects: effectsFeature,
|
|
1485
1737
|
hints: hintsFeature,
|
|
1486
1738
|
devtools: devtoolsFeature,
|
|
@@ -1534,6 +1786,7 @@ const FORM = 4
|
|
|
1534
1786
|
const BINDINGS = 8
|
|
1535
1787
|
const COMPUTE = 16
|
|
1536
1788
|
const SORTABLE = 32
|
|
1789
|
+
const PRESENCE = 64
|
|
1537
1790
|
|
|
1538
1791
|
// The default entry's features are part of this file: install them now, as
|
|
1539
1792
|
// the opt-in entry does when one arrives.
|
|
@@ -1755,7 +2008,7 @@ export default class extends Controller {
|
|
|
1755
2008
|
#featureTurns = new Map() // name -> settles once that feature has had its turn to connect
|
|
1756
2009
|
#featureEpoch = 0
|
|
1757
2010
|
#featureHandle // the `core` handle features receive, built on first use
|
|
1758
|
-
// The default entry (issue #305): one bit per feature, PERSIST …
|
|
2011
|
+
// The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
|
|
1759
2012
|
// asked for (its marker read true) and connected on this connection.
|
|
1760
2013
|
#featuresAsked = 0
|
|
1761
2014
|
#featuresOn = 0
|
|
@@ -1802,15 +2055,20 @@ export default class extends Controller {
|
|
|
1802
2055
|
// The same listener hears an editable being focused (issue #351): a stream
|
|
1803
2056
|
// can append a root's first reactive_editable without morphing the root,
|
|
1804
2057
|
// and its keys and paste need the bindings feature before anything is
|
|
1805
|
-
// typed into it.
|
|
2058
|
+
// typed into it. And it hears a stream that rendered into the root
|
|
2059
|
+
// (STREAMED, issue #356): the features already connected re-run their
|
|
2060
|
+
// seeds first (a morph's own listeners do that for a morph), then what the
|
|
2061
|
+
// new content needs connects.
|
|
1806
2062
|
if (this.element.getAttribute?.("data-reactive-token-value") != null || deferNeeded(this.element)) {
|
|
1807
2063
|
this.#boundRootMorph = (event) => {
|
|
1808
2064
|
if (event.type === "focusin" ? !event.target.hasAttribute?.("data-reactive-editable") : event.target !== this.element) return
|
|
2065
|
+
if (event.type === STREAMED) this.#reseed()
|
|
1809
2066
|
if (__SPLIT__) this.#loadFeatures(true)
|
|
1810
2067
|
else this.#connectFeaturesNow(true)
|
|
1811
2068
|
}
|
|
1812
2069
|
this.element.addEventListener?.("turbo:morph-element", this.#boundRootMorph)
|
|
1813
2070
|
this.element.addEventListener?.("focusin", this.#boundRootMorph)
|
|
2071
|
+
this.element.addEventListener?.(STREAMED, this.#boundRootMorph)
|
|
1814
2072
|
}
|
|
1815
2073
|
|
|
1816
2074
|
// Feature modules (issue #275): connect the ones that are here and START
|
|
@@ -1830,6 +2088,9 @@ export default class extends Controller {
|
|
|
1830
2088
|
if (__SPLIT__) this.#loadFeatures()
|
|
1831
2089
|
else this.#connectFeaturesNow()
|
|
1832
2090
|
|
|
2091
|
+
// A dialog the server rendered open with data-reactive-modal (issue #365).
|
|
2092
|
+
upgradeModals(this.element)
|
|
2093
|
+
|
|
1833
2094
|
// LAST, after every feature above is wired: a replayed trigger must find
|
|
1834
2095
|
// the controller exactly as a live event after connect would.
|
|
1835
2096
|
this.#announceConnected()
|
|
@@ -1849,6 +2110,7 @@ export default class extends Controller {
|
|
|
1849
2110
|
this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
|
|
1850
2111
|
this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
|
|
1851
2112
|
this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
|
|
2113
|
+
this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
|
|
1852
2114
|
}
|
|
1853
2115
|
}
|
|
1854
2116
|
|
|
@@ -1886,6 +2148,7 @@ export default class extends Controller {
|
|
|
1886
2148
|
if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
|
|
1887
2149
|
if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
|
|
1888
2150
|
if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
|
|
2151
|
+
if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
|
|
1889
2152
|
}
|
|
1890
2153
|
}
|
|
1891
2154
|
|
|
@@ -2074,7 +2337,9 @@ export default class extends Controller {
|
|
|
2074
2337
|
),
|
|
2075
2338
|
listnavOptions: (event) => this.#listnavOptions(event),
|
|
2076
2339
|
collectFields: () => this.#collectFields(),
|
|
2077
|
-
// A reactive_editable's value and its last server-rendered text (#351)
|
|
2340
|
+
// A reactive_editable's value and its last server-rendered text (#351),
|
|
2341
|
+
// and whether the engine has contenteditable="plaintext-only" (#355).
|
|
2342
|
+
plain: plaintextSupported,
|
|
2078
2343
|
text: editableValue,
|
|
2079
2344
|
served: serverText,
|
|
2080
2345
|
// The confirm gate, as dispatch() runs it: the resolver inside the chain
|
|
@@ -2089,15 +2354,17 @@ export default class extends Controller {
|
|
|
2089
2354
|
|
|
2090
2355
|
// Re-run the connect-time seeds that read field values, in connect() order
|
|
2091
2356
|
// — for a feature that changed those values after connect() ran (the draft
|
|
2092
|
-
// restore)
|
|
2093
|
-
//
|
|
2357
|
+
// restore), or content a stream rendered into the root (issue #356). Each is
|
|
2358
|
+
// the same re-sync its feature runs after a morph; a root that did not opt
|
|
2359
|
+
// into one skips it. on-complete re-ARMS without firing. Form and bindings
|
|
2360
|
+
// also wire a binding whose gate reads true only now (a stream's content).
|
|
2094
2361
|
#reseed() {
|
|
2095
2362
|
if (!__SPLIT__) {
|
|
2096
|
-
if (this.#featuresOn & FORM) formFeature.
|
|
2363
|
+
if (this.#featuresOn & FORM) formFeature.reseed(this)
|
|
2097
2364
|
if (this.#featuresOn & BINDINGS) bindingsFeature.reseed(this)
|
|
2098
2365
|
if (this.#featuresOn & COMPUTE) computeFeature.seed(this)
|
|
2099
2366
|
} else {
|
|
2100
|
-
this.#features.get("form")?.
|
|
2367
|
+
this.#features.get("form")?.reseed(this)
|
|
2101
2368
|
this.#features.get("bindings")?.reseed(this)
|
|
2102
2369
|
this.#features.get("compute")?.seed(this)
|
|
2103
2370
|
}
|
|
@@ -2270,6 +2537,7 @@ export default class extends Controller {
|
|
|
2270
2537
|
if (this.#boundRootMorph) {
|
|
2271
2538
|
this.element.removeEventListener?.("turbo:morph-element", this.#boundRootMorph)
|
|
2272
2539
|
this.element.removeEventListener?.("focusin", this.#boundRootMorph)
|
|
2540
|
+
this.element.removeEventListener?.(STREAMED, this.#boundRootMorph)
|
|
2273
2541
|
}
|
|
2274
2542
|
// Early triggers (issue #273): a disconnected root records again.
|
|
2275
2543
|
earlyState().connected.delete(this.element)
|
|
@@ -2290,6 +2558,13 @@ export default class extends Controller {
|
|
|
2290
2558
|
// a per-controller promise makes each dispatch wait for the previous one, so
|
|
2291
2559
|
// it always uses the freshest token.
|
|
2292
2560
|
dispatch(event) {
|
|
2561
|
+
// A shortcut (issue #357) that is the other platform's twin, or pressed
|
|
2562
|
+
// in a field the user is typing in, is a complete no-op: the native
|
|
2563
|
+
// default (text undo) proceeds and nothing is announced. Checked FIRST: a
|
|
2564
|
+
// delete/backspace shortcut (issue #366) binds a bare window keydown, so
|
|
2565
|
+
// every key the user presses lands here.
|
|
2566
|
+
const shortcut = event.params?.shortcut
|
|
2567
|
+
if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
|
|
2293
2568
|
// A :once trigger already replayed on connect (issue #273) is spent.
|
|
2294
2569
|
if (earlyOnceSwallows(event, "dispatch", this.application?.schema?.keyMappings, this.element)) return
|
|
2295
2570
|
// `window` (renamed: never shadow the global) and `outside` are the event-
|
|
@@ -2345,7 +2620,11 @@ export default class extends Controller {
|
|
|
2345
2620
|
// (which has no form-navigation default to lose) skips it and flips now, and
|
|
2346
2621
|
// the failure revert snaps it back. A `change`-bound trigger is unaffected —
|
|
2347
2622
|
// `change` isn't cancelable, so preventDefault was already a no-op there.
|
|
2348
|
-
|
|
2623
|
+
//
|
|
2624
|
+
// A shortcut (issue #357) IS prevented: its key filter has already narrowed
|
|
2625
|
+
// the window binding to one chord the page claims (mod+s must not also open
|
|
2626
|
+
// the browser's save dialog).
|
|
2627
|
+
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
|
|
2349
2628
|
|
|
2350
2629
|
// A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
|
|
2351
2630
|
// materialize entry point, which dedupes it against the observer, the
|
|
@@ -2450,6 +2729,12 @@ export default class extends Controller {
|
|
|
2450
2729
|
// the component resets whatever they toggled (by design — a signed action
|
|
2451
2730
|
// owns state that must survive re-renders).
|
|
2452
2731
|
runOps(event) {
|
|
2732
|
+
// A lone shortcut record (Stimulus typecasts it to an object) whose chord
|
|
2733
|
+
// did not fire bails before any bookkeeping: a delete/backspace shortcut
|
|
2734
|
+
// (issue #366) binds a bare window keydown that hears every key. Several
|
|
2735
|
+
// records are checked one by one below.
|
|
2736
|
+
const lone = event.params?.ops
|
|
2737
|
+
if (lone?.shortcut != null && !shortcutApplies(lone.shortcut, event, this.application?.schema?.keyMappings)) return
|
|
2453
2738
|
if (earlyOnceSwallows(event, "runOps", this.application?.schema?.keyMappings, this.element)) return
|
|
2454
2739
|
if (bindingsAlreadyRan(event, this)) return
|
|
2455
2740
|
const params = event.params ?? {}
|
|
@@ -2484,6 +2769,10 @@ export default class extends Controller {
|
|
|
2484
2769
|
// A detail miss (issue #346) is a complete no-op — checked before the
|
|
2485
2770
|
// once record is spent, preventDefault and the confirm gate.
|
|
2486
2771
|
if (!detailMatches(record, event)) continue
|
|
2772
|
+
// So is a shortcut that is not this platform's twin or fired in an
|
|
2773
|
+
// editable target (issue #357).
|
|
2774
|
+
if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
|
|
2775
|
+
continue
|
|
2487
2776
|
if (onceBindingSpent(this, event, record)) continue
|
|
2488
2777
|
this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
|
|
2489
2778
|
}
|
|
@@ -2491,7 +2780,7 @@ export default class extends Controller {
|
|
|
2491
2780
|
|
|
2492
2781
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
2493
2782
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
2494
|
-
#runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
|
|
2783
|
+
#runBinding({ ops, confirm, confirmWhen, outside, window: windowBound, shortcut }, event, trigger) {
|
|
2495
2784
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
2496
2785
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
2497
2786
|
// preventDefault and before any op runs.
|
|
@@ -2503,7 +2792,8 @@ export default class extends Controller {
|
|
|
2503
2792
|
// would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
|
|
2504
2793
|
// (possibly async) confirm gate below — a native default can't wait for a
|
|
2505
2794
|
// pending dialog (same ordering as dispatch()).
|
|
2506
|
-
|
|
2795
|
+
// A shortcut (issue #357) is prevented: it is one chord the page claims.
|
|
2796
|
+
if (shortcut != null || !windowBound) event.preventDefault()
|
|
2507
2797
|
|
|
2508
2798
|
// Resolve the effective confirm message — static string, or the conditional
|
|
2509
2799
|
// Hash form (issue #179) evaluated over collected fields (a promise of it
|
|
@@ -3044,6 +3334,12 @@ export default class extends Controller {
|
|
|
3044
3334
|
// already gets the action's HTTP response. Harmless without pgbus.
|
|
3045
3335
|
const connectionId = this.#connectionId()
|
|
3046
3336
|
if (connectionId) headers["X-Pgbus-Connection"] = connectionId
|
|
3337
|
+
// A presence root's own connection (issue #358): the row its presence
|
|
3338
|
+
// writes land on, whatever source comes first on the page.
|
|
3339
|
+
const presence =
|
|
3340
|
+
presenceNeeded(this.element) &&
|
|
3341
|
+
document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
|
|
3342
|
+
if (presence) headers["X-Reactive-Presence"] = presence
|
|
3047
3343
|
|
|
3048
3344
|
// ONLY `fetch` itself is the network boundary — offline, DNS, a reset
|
|
3049
3345
|
// connection. Everything below this inner try (reading the body,
|
|
@@ -356,6 +356,11 @@ module Phlex
|
|
|
356
356
|
raise ArgumentError, "#{self}: reactive_dormant takes true or false, got #{dormant.inspect}"
|
|
357
357
|
end
|
|
358
358
|
|
|
359
|
+
# Issue #358: a dormant root never connects, so it could never join.
|
|
360
|
+
# A ClientBindings-only class has no presence declaration to refuse.
|
|
361
|
+
refuse_dormant_presence! if dormant && respond_to?(:reactive_presence_declaration) &&
|
|
362
|
+
reactive_presence_declaration
|
|
363
|
+
|
|
359
364
|
Phlex::Reactive::Dormant.reject_visible_lazy!(self) if dormant
|
|
360
365
|
|
|
361
366
|
Registry.write_scalar(self, :dormant, dormant)
|