phlex-reactive 0.14.3 → 0.15.0
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 +107 -0
- data/README.md +258 -5
- data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
- data/app/javascript/phlex/reactive/core.js +7 -1
- data/app/javascript/phlex/reactive/core.min.js +6 -2
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/early.js +28 -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 +188 -107
- 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 +75 -46
- 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/persist.js +12 -3
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
- data/app/javascript/phlex/reactive/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/features/sortable.js +463 -0
- data/app/javascript/phlex/reactive/features/sortable.min.js +4 -0
- data/app/javascript/phlex/reactive/features/sortable.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 +5 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +6 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -8
- data/app/javascript/phlex/reactive/runtime.js +286 -18
- data/lib/phlex/reactive/component/dsl.rb +5 -0
- data/lib/phlex/reactive/component/editable.rb +78 -0
- data/lib/phlex/reactive/component/helpers.rb +81 -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/sortable.rb +212 -0
- 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 +24 -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 +105 -0
- data/lib/phlex/reactive/streamable.rb +7 -3
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +10 -0
- metadata +16 -1
|
@@ -35,6 +35,8 @@ import * as effectsFeature from "phlex/reactive/features/effects"
|
|
|
35
35
|
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
|
+
import * as sortableFeature from "phlex/reactive/features/sortable"
|
|
39
|
+
import * as presenceFeature from "phlex/reactive/features/presence"
|
|
38
40
|
|
|
39
41
|
// phlex/reactive/runtime — the reactive controller WITHOUT its feature
|
|
40
42
|
// modules (issue #275): the code both client entries share. It is not an
|
|
@@ -198,6 +200,7 @@ export function registerReactiveStreamRender() {
|
|
|
198
200
|
if (typeof document === "undefined" || typeof document.addEventListener !== "function") return
|
|
199
201
|
streamRenderRegistered = true
|
|
200
202
|
document.addEventListener("turbo:before-stream-render", (event) => {
|
|
203
|
+
streamIntoRoots(event)
|
|
201
204
|
// The opt-in entry sees every stream (it may hold one for a module the
|
|
202
205
|
// incoming content needs); with no entry hook, a loaded effects module
|
|
203
206
|
// wraps the stream directly — in the default entry, always.
|
|
@@ -209,6 +212,39 @@ export function registerReactiveStreamRender() {
|
|
|
209
212
|
// The names this registration had while it was two listeners.
|
|
210
213
|
export { registerReactiveStreamRender as registerReactiveDismiss, registerReactiveStreamRender as registerReactiveEffects }
|
|
211
214
|
|
|
215
|
+
// --- A stream into a connected root (issue #356) ---
|
|
216
|
+
// A morph of a root makes it re-read its feature markers (#boundRootMorph); a
|
|
217
|
+
// stream that renders INTO it — append, prepend or update of the root or a
|
|
218
|
+
// child, before, after or replace of a child — fires neither a lifecycle nor
|
|
219
|
+
// a morph of the root, so a marker it brings was never connected. The roots
|
|
220
|
+
// a stream renders into are resolved here, before anything (effects, the opt-in
|
|
221
|
+
// entry's hold) wraps Turbo's render; once that render has run, each one still
|
|
222
|
+
// connected is told (STREAMED, not bubbling: an outer root's content is not
|
|
223
|
+
// its own). The root re-runs its connected features' seeds and connects what
|
|
224
|
+
// the new content needs, in both entries.
|
|
225
|
+
const STREAMED = "reactive:streamed"
|
|
226
|
+
|
|
227
|
+
function streamIntoRoots(event) {
|
|
228
|
+
const detail = event.detail
|
|
229
|
+
const render = detail?.render
|
|
230
|
+
const stream = detail?.newStream ?? event.target
|
|
231
|
+
const action = stream?.getAttribute?.("action")
|
|
232
|
+
const into = /^(append|prepend|update)$/.test(action)
|
|
233
|
+
if (typeof render !== "function" || !(into || /^(before|after|replace)$/.test(action))) return
|
|
234
|
+
const id = stream.getAttribute("target")
|
|
235
|
+
const selector = stream.getAttribute("targets")
|
|
236
|
+
const roots = new Set()
|
|
237
|
+
for (const target of id ? [document.getElementById(id)] : selector ? document.querySelectorAll(selector) : []) {
|
|
238
|
+
const root = (into ? target : target?.parentElement)?.closest?.('[data-controller~="reactive"]')
|
|
239
|
+
if (root) roots.add(root)
|
|
240
|
+
}
|
|
241
|
+
if (roots.size === 0) return
|
|
242
|
+
detail.render = async (el) => {
|
|
243
|
+
await render(el)
|
|
244
|
+
for (const root of roots) if (root.isConnected) root.dispatchEvent(new Event(STREAMED))
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
212
248
|
// For the opt-in entry: what to do with a stream before its render — hold it
|
|
213
249
|
// for a module the incoming content needs. `handle(event, effects)` gets the
|
|
214
250
|
// loaded effects module (or undefined) and wraps the stream with it itself.
|
|
@@ -236,12 +272,80 @@ export function __resetReactiveStreamRenderForTest() {
|
|
|
236
272
|
// for the attribute AND the property. Nothing else
|
|
237
273
|
// (a root's token, a field's class) is held.
|
|
238
274
|
// data-reactive-morph-value on a field lets the morph write it while focused.
|
|
275
|
+
// --- An engine without contenteditable="plaintext-only" (issue #355) ---
|
|
276
|
+
// reactive_editable renders contenteditable="plaintext-only": the browser
|
|
277
|
+
// itself then pastes plain text and inserts "\n" for Enter. An engine without
|
|
278
|
+
// it reads the value as INVALID — the element is not editable, cannot even be
|
|
279
|
+
// focused — so there every reactive editable is switched to "true" (and the
|
|
280
|
+
// bindings feature does the plain-text paste itself). However it reaches the
|
|
281
|
+
// page: present at load, appended by a stream into a root that is already
|
|
282
|
+
// connected, loaded in a frame, written back by a morph of an unfocused
|
|
283
|
+
// editable, or made reactive by a morph. One observer, only in such an engine; elsewhere the probe is all
|
|
284
|
+
// that runs. Detected once per page.
|
|
285
|
+
let plaintextOnly
|
|
286
|
+
let plaintextObserver = null
|
|
287
|
+
const PLAINTEXT_EDITABLE = "[data-reactive-editable][contenteditable=plaintext-only]"
|
|
288
|
+
|
|
289
|
+
function plaintextSupported() {
|
|
290
|
+
if (plaintextOnly === undefined) {
|
|
291
|
+
try {
|
|
292
|
+
const probe = document.createElement("div")
|
|
293
|
+
probe.contentEditable = "plaintext-only"
|
|
294
|
+
plaintextOnly = probe.contentEditable === "plaintext-only"
|
|
295
|
+
} catch {
|
|
296
|
+
plaintextOnly = false
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return plaintextOnly
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function makeEditable(el) {
|
|
303
|
+
if (el.matches?.(PLAINTEXT_EDITABLE)) el.setAttribute("contenteditable", "true")
|
|
304
|
+
el.querySelectorAll?.(PLAINTEXT_EDITABLE).forEach(makeEditable)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function registerReactivePlaintext() {
|
|
308
|
+
// (No element factory, no page to observe: never probe a stub document.)
|
|
309
|
+
if (plaintextObserver || typeof document === "undefined" || typeof document.createElement !== "function") return
|
|
310
|
+
if (plaintextSupported()) return
|
|
311
|
+
makeEditable(document)
|
|
312
|
+
plaintextObserver = new MutationObserver((records) => {
|
|
313
|
+
for (const record of records) {
|
|
314
|
+
if (record.type === "attributes") makeEditable(record.target)
|
|
315
|
+
else record.addedNodes.forEach(makeEditable)
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
plaintextObserver.observe(document.documentElement, {
|
|
319
|
+
childList: true,
|
|
320
|
+
subtree: true,
|
|
321
|
+
attributes: true,
|
|
322
|
+
// (a morph can also make an existing plaintext-only element reactive)
|
|
323
|
+
attributeFilter: ["contenteditable", "data-reactive-editable"],
|
|
324
|
+
})
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// For tests: what the probe answers (undefined probes again), with no
|
|
328
|
+
// observer installed.
|
|
329
|
+
export function __setReactivePlaintextOnlyForTest(value) {
|
|
330
|
+
plaintextObserver?.disconnect()
|
|
331
|
+
plaintextObserver = null
|
|
332
|
+
plaintextOnly = value
|
|
333
|
+
}
|
|
334
|
+
|
|
239
335
|
let morphFocusRegistered = false
|
|
240
336
|
|
|
337
|
+
//
|
|
338
|
+
// A reactive_editable (issue #351) is held the same way, but its "value" is its
|
|
339
|
+
// CHILDREN: a focused one keeps them (text and caret) while every attribute
|
|
340
|
+
// still morphs — copied here from the render, then the element's morph is
|
|
341
|
+
// cancelled (cancelling is all-or-nothing in Idiomorph). Its contenteditable
|
|
342
|
+
// stays as it is: the runtime switches it to "true" in an engine without
|
|
343
|
+
// plaintext-only (registerReactivePlaintext, #355). Every morph of an editable, held or not, records the
|
|
344
|
+
// render's text as its SERVER TEXT (the Escape revert, the dirty baseline).
|
|
241
345
|
function holdsTypedValue(el) {
|
|
242
346
|
return (
|
|
243
347
|
el === document.activeElement &&
|
|
244
|
-
(el.localName === "input" || el.localName === "textarea") &&
|
|
348
|
+
(el.localName === "input" || el.localName === "textarea" || el.hasAttribute("data-reactive-editable")) &&
|
|
245
349
|
!el.hasAttribute("data-reactive-morph-value") &&
|
|
246
350
|
!!el.closest('[data-controller~="reactive"]')
|
|
247
351
|
)
|
|
@@ -254,11 +358,26 @@ export function registerReactiveMorphFocus() {
|
|
|
254
358
|
document.addEventListener("turbo:before-morph-element", (event) => {
|
|
255
359
|
const el = event.target
|
|
256
360
|
const next = event.detail?.newElement // absent when Turbo asks before a removal
|
|
257
|
-
if (!next || event.defaultPrevented
|
|
361
|
+
if (!next || event.defaultPrevented) return
|
|
362
|
+
if (el.hasAttribute?.("data-reactive-editable")) {
|
|
363
|
+
serverTexts.set(el, editableValue(next))
|
|
364
|
+
if (!holdsTypedValue(el)) return
|
|
365
|
+
for (const { name } of [...el.attributes]) {
|
|
366
|
+
if (!next.hasAttribute(name) && name !== "contenteditable") el.removeAttribute(name)
|
|
367
|
+
}
|
|
368
|
+
for (const { name, value } of next.attributes) {
|
|
369
|
+
if (name !== "contenteditable" && el.getAttribute(name) !== value) el.setAttribute(name, value)
|
|
370
|
+
}
|
|
371
|
+
return event.preventDefault()
|
|
372
|
+
}
|
|
373
|
+
if (el.localName !== "input" || !holdsTypedValue(el)) return
|
|
258
374
|
const value = next.getAttribute("value")
|
|
259
375
|
if (value == null) el.removeAttribute("value")
|
|
260
376
|
else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
|
|
261
377
|
})
|
|
378
|
+
// An editable a morph never reached (a stream append) records its server
|
|
379
|
+
// text when first focused — before anything can be typed into it.
|
|
380
|
+
document.addEventListener("focusin", (event) => seedServerText(event.target))
|
|
262
381
|
document.addEventListener("turbo:before-morph-attribute", (event) => {
|
|
263
382
|
if (event.detail?.attributeName === "value" && holdsTypedValue(event.target)) event.preventDefault()
|
|
264
383
|
})
|
|
@@ -462,6 +581,7 @@ export function registerReactiveActions() {
|
|
|
462
581
|
registerReactiveDefer()
|
|
463
582
|
registerReactiveStreamRender()
|
|
464
583
|
registerReactiveMorphFocus()
|
|
584
|
+
registerReactivePlaintext()
|
|
465
585
|
registerReactiveOffline()
|
|
466
586
|
registerReactiveDev()
|
|
467
587
|
}
|
|
@@ -623,6 +743,51 @@ export const EDITOR_SELECTOR =
|
|
|
623
743
|
":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])"
|
|
624
744
|
export const EDITOR_TAGS = new Set(["lexxy-editor", "trix-editor"])
|
|
625
745
|
|
|
746
|
+
// A reactive_editable's value (issue #351): its text with line breaks — a
|
|
747
|
+
// <br> is one, a block element (the <div> a contenteditable=true Enter
|
|
748
|
+
// inserts) starts and ends a line — "\r\n" normalised, ONE trailing newline
|
|
749
|
+
// dropped (the <br> browsers keep at the end), and folded to spaces when the
|
|
750
|
+
// field is single-line. Read from the DOM, not innerText: no layout, the same
|
|
751
|
+
// on a detached render and under any white-space.
|
|
752
|
+
export function editableValue(el) {
|
|
753
|
+
let text = ""
|
|
754
|
+
// A block's edge asks for a line break; the next text or <br> pays it,
|
|
755
|
+
// once, unless the text already ends in one.
|
|
756
|
+
let line = false
|
|
757
|
+
const add = (s) => {
|
|
758
|
+
if (line && text && !text.endsWith("\n")) text += "\n"
|
|
759
|
+
line = false
|
|
760
|
+
text += s
|
|
761
|
+
}
|
|
762
|
+
const walk = (node) => {
|
|
763
|
+
for (const child of node.childNodes) {
|
|
764
|
+
const block = /^(div|p|li)$/.test(child.localName)
|
|
765
|
+
if (block) line = true
|
|
766
|
+
if (child.nodeType === 3) child.data && add(child.data)
|
|
767
|
+
else if (child.localName === "br") add("\n")
|
|
768
|
+
else if (child.nodeType === 1) walk(child)
|
|
769
|
+
if (block) line = true
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
walk(el)
|
|
773
|
+
text = text.replace(/\r\n?/g, "\n").replace(/\n$/, "")
|
|
774
|
+
return el.getAttribute("data-reactive-editable") === "multi" ? text : text.replace(/\n/g, " ")
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// element -> the text of its last server render (issue #351): what Escape
|
|
778
|
+
// reverts to and what dirty tracking diffs against. Seeded when the root
|
|
779
|
+
// connects (or on first focus) and advanced by every morph of the element.
|
|
780
|
+
const serverTexts = new WeakMap()
|
|
781
|
+
|
|
782
|
+
function seedServerText(el) {
|
|
783
|
+
if (el?.hasAttribute?.("data-reactive-editable") && !serverTexts.has(el)) serverTexts.set(el, editableValue(el))
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export function serverText(el) {
|
|
787
|
+
seedServerText(el)
|
|
788
|
+
return serverTexts.get(el)
|
|
789
|
+
}
|
|
790
|
+
|
|
626
791
|
// A RICH editor (lexxy/trix) is only ready once its custom element upgraded —
|
|
627
792
|
// Trix defines its elements in a setTimeout after load — and exposes the
|
|
628
793
|
// string `value` accessor; reading it before that yields "", which is issue
|
|
@@ -697,6 +862,13 @@ const CLIENT_OPS = Object.freeze({
|
|
|
697
862
|
}
|
|
698
863
|
},
|
|
699
864
|
|
|
865
|
+
// Write this tab's presence state (issue #358) through the root's
|
|
866
|
+
// presence_set action. The feature warns outside a reactive_presence root.
|
|
867
|
+
presence: (el, args) =>
|
|
868
|
+
__SPLIT__
|
|
869
|
+
? withFeature("presence", (presence) => presence.set(el, args.state))
|
|
870
|
+
: presenceFeature.set(el, args.state),
|
|
871
|
+
|
|
700
872
|
// Dispatch a bubbling CustomEvent (issue #96). RAW element.dispatchEvent — the
|
|
701
873
|
// controller SHADOWS Stimulus's this.dispatch helper, so it must not be used.
|
|
702
874
|
dispatch: (el, args) => {
|
|
@@ -894,6 +1066,23 @@ function keyFilterMatches(filter, event, keyMappings) {
|
|
|
894
1066
|
return typeof key === "string" && key.toLowerCase() === event.key.toLowerCase()
|
|
895
1067
|
}
|
|
896
1068
|
|
|
1069
|
+
// Issue #357: a shortcut: ("mod+z", or { keys, inEditable }) is a window
|
|
1070
|
+
// keydown whose `mod` is meta on Apple platforms and ctrl elsewhere. Ruby
|
|
1071
|
+
// renders a meta twin and a ctrl twin; Stimulus filters the key, and this
|
|
1072
|
+
// decides whether the twin that fired is this platform's and whether the
|
|
1073
|
+
// target is one the user is typing in. Either miss is a complete no-op.
|
|
1074
|
+
// (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
|
|
1075
|
+
const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
|
|
1076
|
+
// A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
|
|
1077
|
+
// keydown through and this check, per record, picks the chord that fired.
|
|
1078
|
+
function shortcutApplies(spec, event, keyMappings) {
|
|
1079
|
+
const target = event.target
|
|
1080
|
+
if (spec?.inEditable !== true && (target?.isContentEditable || /^(input|textarea|select)$/.test(target?.localName)))
|
|
1081
|
+
return false
|
|
1082
|
+
const mod = /[Mm]ac|iP/.test(navigator.userAgentData?.platform ?? navigator.platform) ? "meta" : "ctrl"
|
|
1083
|
+
return keyFilterMatches(shortcutKeys(spec).replace(/^mod\+/, `${mod}+`), event, keyMappings)
|
|
1084
|
+
}
|
|
1085
|
+
|
|
897
1086
|
// Parse data-reactive-ops-param into binding records. Stimulus typecasts a
|
|
898
1087
|
// lone record to an object; several records stay a string (JSON.parse fails on
|
|
899
1088
|
// the join) and are split here. A LEGACY [[op, args]] list (array or JSON
|
|
@@ -1184,8 +1373,9 @@ function formNeeded(root) {
|
|
|
1184
1373
|
|
|
1185
1374
|
// Show bindings and cross-root show targets, completion bindings, option
|
|
1186
1375
|
// filtering, the bulk-selection group bindings (enable, select-all, count —
|
|
1187
|
-
// issue #319) and their cross-root targets (#343), the tag-chip input
|
|
1188
|
-
//
|
|
1376
|
+
// issue #319) and their cross-root targets (#343), the tag-chip input, draft
|
|
1377
|
+
// nested rows and editable text's keys and paste (#351): a form's client-only
|
|
1378
|
+
// bindings. The root declares one, or
|
|
1189
1379
|
// owns an element that does. (The probes the connect-time gates ran before
|
|
1190
1380
|
// the split, unchanged.)
|
|
1191
1381
|
function bindingsNeeded(root) {
|
|
@@ -1195,7 +1385,7 @@ function bindingsNeeded(root) {
|
|
|
1195
1385
|
) ||
|
|
1196
1386
|
(root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
|
|
1197
1387
|
root.querySelector?.(
|
|
1198
|
-
"[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count]",
|
|
1388
|
+
"[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable]",
|
|
1199
1389
|
) != null
|
|
1200
1390
|
)
|
|
1201
1391
|
}
|
|
@@ -1205,6 +1395,19 @@ function computeNeeded(root) {
|
|
|
1205
1395
|
return root.getAttribute?.("data-reactive-compute-inputs-param") != null
|
|
1206
1396
|
}
|
|
1207
1397
|
|
|
1398
|
+
// Sortable lists (issue #350): the root is, or holds, a sortable list or a
|
|
1399
|
+
// source list. (A nested root's list loads the module for its parent too; the
|
|
1400
|
+
// module then checks ownership before it picks a row up.)
|
|
1401
|
+
function sortableNeeded(root) {
|
|
1402
|
+
const lists = "[data-reactive-sortable],[data-reactive-sortable-source]"
|
|
1403
|
+
return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Presence (issue #358): the root is a reactive_presence roster.
|
|
1407
|
+
function presenceNeeded(root) {
|
|
1408
|
+
return root.getAttribute?.("data-reactive-presence") != null
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1208
1411
|
const PRODUCTION_FEATURES = [
|
|
1209
1412
|
// (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
|
|
1210
1413
|
// writes what a request reads, so it gates.
|
|
@@ -1215,6 +1418,11 @@ const PRODUCTION_FEATURES = [
|
|
|
1215
1418
|
["bindings", [bindingsNeeded, null, null, true]],
|
|
1216
1419
|
// The seed writes output fields a request collects, so it gates.
|
|
1217
1420
|
["compute", [computeNeeded, null, null, true]],
|
|
1421
|
+
// Nothing it does at connect is read by a request: it never gates.
|
|
1422
|
+
["sortable", [sortableNeeded, null]],
|
|
1423
|
+
// Its announce is a request of its own; nothing it does at connect is read
|
|
1424
|
+
// by another request: it never gates.
|
|
1425
|
+
["presence", [presenceNeeded, null]],
|
|
1218
1426
|
[
|
|
1219
1427
|
// Stream effects and dismissing flashes are document-level: the module
|
|
1220
1428
|
// has no connect. A root that DECLARES an effect only gets it fetched
|
|
@@ -1378,7 +1586,7 @@ function featureTimeoutMs() {
|
|
|
1378
1586
|
export function reactiveFeatureNames() {
|
|
1379
1587
|
return __SPLIT__
|
|
1380
1588
|
? [...FEATURES.keys()]
|
|
1381
|
-
: ["persist", "defer", "form", "bindings", "compute", "effects", "hints", "devtools"]
|
|
1589
|
+
: ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "effects", "hints", "devtools"]
|
|
1382
1590
|
}
|
|
1383
1591
|
|
|
1384
1592
|
export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
|
|
@@ -1400,6 +1608,8 @@ export function __loadReactiveFeatureForTest(name) {
|
|
|
1400
1608
|
form: formFeature,
|
|
1401
1609
|
bindings: bindingsFeature,
|
|
1402
1610
|
compute: computeFeature,
|
|
1611
|
+
sortable: sortableFeature,
|
|
1612
|
+
presence: presenceFeature,
|
|
1403
1613
|
effects: effectsFeature,
|
|
1404
1614
|
hints: hintsFeature,
|
|
1405
1615
|
devtools: devtoolsFeature,
|
|
@@ -1440,6 +1650,9 @@ const FEATURE_SHARED = {
|
|
|
1440
1650
|
enter: enterReactiveActivity,
|
|
1441
1651
|
exit: exitReactiveActivity,
|
|
1442
1652
|
count: countReactiveRequest,
|
|
1653
|
+
// The editable collector and server text (issue #351), for the draft.
|
|
1654
|
+
text: editableValue,
|
|
1655
|
+
served: serverText,
|
|
1443
1656
|
waiting: new WeakMap(),
|
|
1444
1657
|
}
|
|
1445
1658
|
|
|
@@ -1449,6 +1662,8 @@ const DEFER = 2
|
|
|
1449
1662
|
const FORM = 4
|
|
1450
1663
|
const BINDINGS = 8
|
|
1451
1664
|
const COMPUTE = 16
|
|
1665
|
+
const SORTABLE = 32
|
|
1666
|
+
const PRESENCE = 64
|
|
1452
1667
|
|
|
1453
1668
|
// The default entry's features are part of this file: install them now, as
|
|
1454
1669
|
// the opt-in entry does when one arrives.
|
|
@@ -1670,7 +1885,7 @@ export default class extends Controller {
|
|
|
1670
1885
|
#featureTurns = new Map() // name -> settles once that feature has had its turn to connect
|
|
1671
1886
|
#featureEpoch = 0
|
|
1672
1887
|
#featureHandle // the `core` handle features receive, built on first use
|
|
1673
|
-
// The default entry (issue #305): one bit per feature, PERSIST …
|
|
1888
|
+
// The default entry (issue #305): one bit per feature, PERSIST … PRESENCE —
|
|
1674
1889
|
// asked for (its marker read true) and connected on this connection.
|
|
1675
1890
|
#featuresAsked = 0
|
|
1676
1891
|
#featuresOn = 0
|
|
@@ -1714,13 +1929,23 @@ export default class extends Controller {
|
|
|
1714
1929
|
// without any marker, before and after the morph, a few attribute reads and
|
|
1715
1930
|
// never an import. (turbo:morph-element BUBBLES: only a morph of the root
|
|
1716
1931
|
// itself counts.) A tokenless, client-only root has no such listener.
|
|
1932
|
+
// The same listener hears an editable being focused (issue #351): a stream
|
|
1933
|
+
// can append a root's first reactive_editable without morphing the root,
|
|
1934
|
+
// and its keys and paste need the bindings feature before anything is
|
|
1935
|
+
// typed into it. And it hears a stream that rendered into the root
|
|
1936
|
+
// (STREAMED, issue #356): the features already connected re-run their
|
|
1937
|
+
// seeds first (a morph's own listeners do that for a morph), then what the
|
|
1938
|
+
// new content needs connects.
|
|
1717
1939
|
if (this.element.getAttribute?.("data-reactive-token-value") != null || deferNeeded(this.element)) {
|
|
1718
1940
|
this.#boundRootMorph = (event) => {
|
|
1719
|
-
if (event.target !== this.element) return
|
|
1941
|
+
if (event.type === "focusin" ? !event.target.hasAttribute?.("data-reactive-editable") : event.target !== this.element) return
|
|
1942
|
+
if (event.type === STREAMED) this.#reseed()
|
|
1720
1943
|
if (__SPLIT__) this.#loadFeatures(true)
|
|
1721
1944
|
else this.#connectFeaturesNow(true)
|
|
1722
1945
|
}
|
|
1723
1946
|
this.element.addEventListener?.("turbo:morph-element", this.#boundRootMorph)
|
|
1947
|
+
this.element.addEventListener?.("focusin", this.#boundRootMorph)
|
|
1948
|
+
this.element.addEventListener?.(STREAMED, this.#boundRootMorph)
|
|
1724
1949
|
}
|
|
1725
1950
|
|
|
1726
1951
|
// Feature modules (issue #275): connect the ones that are here and START
|
|
@@ -1731,6 +1956,10 @@ export default class extends Controller {
|
|
|
1731
1956
|
// re-runs the seeds itself (#reseed). Never awaited — connect() must reach
|
|
1732
1957
|
// the drain at the end in the same task (issue #274: an await before it
|
|
1733
1958
|
// runs a waking click twice).
|
|
1959
|
+
// The editables' server texts (issue #351), before a draft restore
|
|
1960
|
+
// writes into them.
|
|
1961
|
+
this.element.querySelectorAll?.("[data-reactive-editable]").forEach(seedServerText)
|
|
1962
|
+
|
|
1734
1963
|
this.#featureEpoch++
|
|
1735
1964
|
this.featuresReady = FEATURES_READY
|
|
1736
1965
|
if (__SPLIT__) this.#loadFeatures()
|
|
@@ -1754,6 +1983,8 @@ export default class extends Controller {
|
|
|
1754
1983
|
this.#connectFeatureNow(FORM, "form", formNeeded, (core) => formFeature.connect(this, core, morphed))
|
|
1755
1984
|
this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
|
|
1756
1985
|
this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
|
|
1986
|
+
this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
|
|
1987
|
+
this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
|
|
1757
1988
|
}
|
|
1758
1989
|
}
|
|
1759
1990
|
|
|
@@ -1790,6 +2021,8 @@ export default class extends Controller {
|
|
|
1790
2021
|
if (on & FORM) this.#disconnectFeatureNow("form", () => formFeature.disconnect(this, core))
|
|
1791
2022
|
if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
|
|
1792
2023
|
if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
|
|
2024
|
+
if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
|
|
2025
|
+
if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
|
|
1793
2026
|
}
|
|
1794
2027
|
}
|
|
1795
2028
|
|
|
@@ -1957,7 +2190,9 @@ export default class extends Controller {
|
|
|
1957
2190
|
this.#featureHandle ??= {
|
|
1958
2191
|
emit: (name, detail, options) => this.#emit(name, detail, options),
|
|
1959
2192
|
reseed: () => this.#reseed(),
|
|
1960
|
-
|
|
2193
|
+
// Resolves with the reply's HTML once the request has applied, or with
|
|
2194
|
+
// nothing when it failed or was vetoed. `busy` is on(busy:)'s hint.
|
|
2195
|
+
proceed: (target, action, params, busy) => this.#proceed(target, action, params, 0, 0, null, busy),
|
|
1961
2196
|
forgetToken: () => {
|
|
1962
2197
|
this.#tokenCache = undefined
|
|
1963
2198
|
},
|
|
@@ -1976,6 +2211,11 @@ export default class extends Controller {
|
|
|
1976
2211
|
),
|
|
1977
2212
|
listnavOptions: (event) => this.#listnavOptions(event),
|
|
1978
2213
|
collectFields: () => this.#collectFields(),
|
|
2214
|
+
// A reactive_editable's value and its last server-rendered text (#351),
|
|
2215
|
+
// and whether the engine has contenteditable="plaintext-only" (#355).
|
|
2216
|
+
plain: plaintextSupported,
|
|
2217
|
+
text: editableValue,
|
|
2218
|
+
served: serverText,
|
|
1979
2219
|
// The confirm gate, as dispatch() runs it: the resolver inside the chain
|
|
1980
2220
|
// (a synchronous throw is a cancel), never a rejection out.
|
|
1981
2221
|
confirm: (message, context) =>
|
|
@@ -1988,15 +2228,17 @@ export default class extends Controller {
|
|
|
1988
2228
|
|
|
1989
2229
|
// Re-run the connect-time seeds that read field values, in connect() order
|
|
1990
2230
|
// — for a feature that changed those values after connect() ran (the draft
|
|
1991
|
-
// restore)
|
|
1992
|
-
//
|
|
2231
|
+
// restore), or content a stream rendered into the root (issue #356). Each is
|
|
2232
|
+
// the same re-sync its feature runs after a morph; a root that did not opt
|
|
2233
|
+
// into one skips it. on-complete re-ARMS without firing. Form and bindings
|
|
2234
|
+
// also wire a binding whose gate reads true only now (a stream's content).
|
|
1993
2235
|
#reseed() {
|
|
1994
2236
|
if (!__SPLIT__) {
|
|
1995
|
-
if (this.#featuresOn & FORM) formFeature.
|
|
2237
|
+
if (this.#featuresOn & FORM) formFeature.reseed(this)
|
|
1996
2238
|
if (this.#featuresOn & BINDINGS) bindingsFeature.reseed(this)
|
|
1997
2239
|
if (this.#featuresOn & COMPUTE) computeFeature.seed(this)
|
|
1998
2240
|
} else {
|
|
1999
|
-
this.#features.get("form")?.
|
|
2241
|
+
this.#features.get("form")?.reseed(this)
|
|
2000
2242
|
this.#features.get("bindings")?.reseed(this)
|
|
2001
2243
|
this.#features.get("compute")?.seed(this)
|
|
2002
2244
|
}
|
|
@@ -2168,6 +2410,8 @@ export default class extends Controller {
|
|
|
2168
2410
|
this.#clearAllThrottles()
|
|
2169
2411
|
if (this.#boundRootMorph) {
|
|
2170
2412
|
this.element.removeEventListener?.("turbo:morph-element", this.#boundRootMorph)
|
|
2413
|
+
this.element.removeEventListener?.("focusin", this.#boundRootMorph)
|
|
2414
|
+
this.element.removeEventListener?.(STREAMED, this.#boundRootMorph)
|
|
2171
2415
|
}
|
|
2172
2416
|
// Early triggers (issue #273): a disconnected root records again.
|
|
2173
2417
|
earlyState().connected.delete(this.element)
|
|
@@ -2194,9 +2438,13 @@ export default class extends Controller {
|
|
|
2194
2438
|
// modifier params (issue #80). The client decides preventDefault behavior
|
|
2195
2439
|
// from event.params — set by the Ruby on() — never by sniffing the
|
|
2196
2440
|
// Stimulus descriptor.
|
|
2197
|
-
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
|
|
2441
|
+
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic, shortcut } =
|
|
2198
2442
|
event.params
|
|
2199
2443
|
if (!action) return
|
|
2444
|
+
// A shortcut (issue #357) that is the other platform's twin, or pressed
|
|
2445
|
+
// in a field the user is typing in, is a complete no-op: the native
|
|
2446
|
+
// default (text undo) proceeds and nothing is announced.
|
|
2447
|
+
if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
|
|
2200
2448
|
|
|
2201
2449
|
// The pending-state hint (issue #181): data-reactive-busy-param. During a
|
|
2202
2450
|
// deploy overlap a page rendered by the PREVIOUS gem still emits the old
|
|
@@ -2243,7 +2491,11 @@ export default class extends Controller {
|
|
|
2243
2491
|
// (which has no form-navigation default to lose) skips it and flips now, and
|
|
2244
2492
|
// the failure revert snaps it back. A `change`-bound trigger is unaffected —
|
|
2245
2493
|
// `change` isn't cancelable, so preventDefault was already a no-op there.
|
|
2246
|
-
|
|
2494
|
+
//
|
|
2495
|
+
// A shortcut (issue #357) IS prevented: its key filter has already narrowed
|
|
2496
|
+
// the window binding to one chord the page claims (mod+s must not also open
|
|
2497
|
+
// the browser's save dialog).
|
|
2498
|
+
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
|
|
2247
2499
|
|
|
2248
2500
|
// A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
|
|
2249
2501
|
// materialize entry point, which dedupes it against the observer, the
|
|
@@ -2382,6 +2634,10 @@ export default class extends Controller {
|
|
|
2382
2634
|
// A detail miss (issue #346) is a complete no-op — checked before the
|
|
2383
2635
|
// once record is spent, preventDefault and the confirm gate.
|
|
2384
2636
|
if (!detailMatches(record, event)) continue
|
|
2637
|
+
// So is a shortcut that is not this platform's twin or fired in an
|
|
2638
|
+
// editable target (issue #357).
|
|
2639
|
+
if (record.shortcut != null && !shortcutApplies(record.shortcut, event, this.application?.schema?.keyMappings))
|
|
2640
|
+
continue
|
|
2385
2641
|
if (onceBindingSpent(this, event, record)) continue
|
|
2386
2642
|
this.#runBinding(record.legacy ? { ...params, ops: record.ops } : record, event, trigger)
|
|
2387
2643
|
}
|
|
@@ -2389,7 +2645,7 @@ export default class extends Controller {
|
|
|
2389
2645
|
|
|
2390
2646
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
2391
2647
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
2392
|
-
#runBinding({ ops, confirm, confirmWhen, outside, window: windowBound }, event, trigger) {
|
|
2648
|
+
#runBinding({ ops, confirm, confirmWhen, outside, window: windowBound, shortcut }, event, trigger) {
|
|
2393
2649
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
2394
2650
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
2395
2651
|
// preventDefault and before any op runs.
|
|
@@ -2401,7 +2657,8 @@ export default class extends Controller {
|
|
|
2401
2657
|
// would kill native clicks site-wide (issue #80 rationale). Runs BEFORE the
|
|
2402
2658
|
// (possibly async) confirm gate below — a native default can't wait for a
|
|
2403
2659
|
// pending dialog (same ordering as dispatch()).
|
|
2404
|
-
|
|
2660
|
+
// A shortcut (issue #357) is prevented: it is one chord the page claims.
|
|
2661
|
+
if (shortcut != null || !windowBound) event.preventDefault()
|
|
2405
2662
|
|
|
2406
2663
|
// Resolve the effective confirm message — static string, or the conditional
|
|
2407
2664
|
// Hash form (issue #179) evaluated over collected fields (a promise of it
|
|
@@ -2942,6 +3199,12 @@ export default class extends Controller {
|
|
|
2942
3199
|
// already gets the action's HTTP response. Harmless without pgbus.
|
|
2943
3200
|
const connectionId = this.#connectionId()
|
|
2944
3201
|
if (connectionId) headers["X-Pgbus-Connection"] = connectionId
|
|
3202
|
+
// A presence root's own connection (issue #358): the row its presence
|
|
3203
|
+
// writes land on, whatever source comes first on the page.
|
|
3204
|
+
const presence =
|
|
3205
|
+
presenceNeeded(this.element) &&
|
|
3206
|
+
document.getElementById(`reactive-presence-src-${this.element.id}`)?.getAttribute("connection-id")
|
|
3207
|
+
if (presence) headers["X-Reactive-Presence"] = presence
|
|
2945
3208
|
|
|
2946
3209
|
// ONLY `fetch` itself is the network boundary — offline, DNS, a reset
|
|
2947
3210
|
// connection. Everything below this inner try (reading the body,
|
|
@@ -3068,6 +3331,9 @@ export default class extends Controller {
|
|
|
3068
3331
|
// complete a tick later. Apps needing post-morph timing listen to Turbo's
|
|
3069
3332
|
// own events; this one is for "the action round trip succeeded".
|
|
3070
3333
|
this.#emit("reactive:applied", { action, params: allParams, html })
|
|
3334
|
+
// What core.proceed resolves with: a feature can tell a reply that
|
|
3335
|
+
// applied from a failure (every other exit returns nothing).
|
|
3336
|
+
return html
|
|
3071
3337
|
} catch (error) {
|
|
3072
3338
|
// The server already processed this action successfully (we're past the
|
|
3073
3339
|
// fetch) — a throw here is a CLIENT-side apply failure (a malformed
|
|
@@ -3290,7 +3556,9 @@ export default class extends Controller {
|
|
|
3290
3556
|
// property — only the attribute — so read getAttribute, not el.name.
|
|
3291
3557
|
const name = el.getAttribute("name")
|
|
3292
3558
|
if (!name) return
|
|
3293
|
-
const own = el.
|
|
3559
|
+
const own = el.getAttribute("data-reactive-editable") != null
|
|
3560
|
+
? editableValue(el)
|
|
3561
|
+
: (el.value ?? el.textContent ?? el.innerHTML ?? "")
|
|
3294
3562
|
// A `[]` name is a group here too: the editor APPENDS its value
|
|
3295
3563
|
// instead of replacing the slot. Assigning a scalar posted
|
|
3296
3564
|
// `{"notes[]": "<p>x</p>"}` while the draft snapshot pushed the same
|
|
@@ -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)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Editable text (issue #351): a contenteditable element as a first-class,
|
|
7
|
+
# PLAIN-TEXT field — an inline title, a card label, a note edited where
|
|
8
|
+
# it is shown.
|
|
9
|
+
#
|
|
10
|
+
# h3(**reactive_editable(:title, placeholder: "Untitled")) { @card.title }
|
|
11
|
+
# p(**reactive_editable(:summary, multiline: true)) { @card.summary }
|
|
12
|
+
# h3(**mix(reactive_editable(:title), on(:rename, event: "input", debounce: 300))) { @card.title }
|
|
13
|
+
#
|
|
14
|
+
# The element is the caller's; this renders its attributes. The client
|
|
15
|
+
# then gives it what an input gets:
|
|
16
|
+
# * collect — its text, line breaks as "\n" (a single-line field folds
|
|
17
|
+
# them to spaces), posted under the (scope-aware) param name;
|
|
18
|
+
# * a morph while it is focused keeps its text and caret (attributes
|
|
19
|
+
# still morph); unfocused, the server's text wins;
|
|
20
|
+
# * paste and drop insert plain text; Enter runs on_enter: (a
|
|
21
|
+
# single-line field swallows it by default); Escape reverts;
|
|
22
|
+
# * reactive_dirty and reactive_persist treat it like an input.
|
|
23
|
+
#
|
|
24
|
+
# Plain text only — a rich editor (Lexxy, Trix) is collected by `.value`.
|
|
25
|
+
# The placeholder is CSS's to draw:
|
|
26
|
+
# [data-reactive-placeholder]:empty::before { content: attr(data-reactive-placeholder) }
|
|
27
|
+
module Editable
|
|
28
|
+
# on_enter: a Phlex::Reactive::JS chain (run on Enter, root-scoped like
|
|
29
|
+
# on_client) or :submit (request-submit the form the field is in).
|
|
30
|
+
# on_escape: :revert (restore the last server-rendered text and blur)
|
|
31
|
+
# or nil. spellcheck: false renders spellcheck="false".
|
|
32
|
+
def reactive_editable(name, multiline: false, placeholder: nil, on_enter: nil, on_escape: :revert,
|
|
33
|
+
spellcheck: true, **attrs)
|
|
34
|
+
raise ArgumentError, "reactive_editable needs a param name (e.g. reactive_editable(:title))" if name.to_s.empty?
|
|
35
|
+
|
|
36
|
+
data = { reactive_editable: multiline ? "multi" : "single" }
|
|
37
|
+
data[:reactive_editable_enter] = editable_enter(on_enter) unless on_enter.nil?
|
|
38
|
+
data[:reactive_editable_escape] = editable_escape(on_escape) unless on_escape.nil?
|
|
39
|
+
aria = { multiline: multiline ? "true" : "false" }
|
|
40
|
+
if placeholder
|
|
41
|
+
aria[:placeholder] = placeholder
|
|
42
|
+
data[:reactive_placeholder] = placeholder
|
|
43
|
+
end
|
|
44
|
+
binding = { name: scoped_field_name(name), contenteditable: "plaintext-only", role: "textbox", aria:, data: }
|
|
45
|
+
binding[:spellcheck] = "false" unless spellcheck
|
|
46
|
+
# Named by `reactive_dirty only:` → its own trackDirty descriptor, as
|
|
47
|
+
# reactive_field does (issue #184).
|
|
48
|
+
data[:action] = "input->reactive#trackDirty" if field_dirty_tracked?(name)
|
|
49
|
+
# An explicit scalar (name:, role:, …) REPLACES the binding's, as
|
|
50
|
+
# reactive_field's escape hatch does — mix would space-join two
|
|
51
|
+
# strings. data:/aria: hashes deep-merge.
|
|
52
|
+
mix(binding.reject { |key, _| attrs.key?(key) && !attrs[key].is_a?(Hash) }, attrs)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def editable_enter(on_enter)
|
|
58
|
+
return "submit" if on_enter == :submit
|
|
59
|
+
|
|
60
|
+
unless on_enter.is_a?(Phlex::Reactive::JS)
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"reactive_editable on_enter: takes a Phlex::Reactive::JS chain (e.g. js.focus(\"#next\")) " \
|
|
63
|
+
"or :submit, got #{on_enter.inspect}"
|
|
64
|
+
end
|
|
65
|
+
raise ArgumentError, "reactive_editable on_enter: got a JS chain with no ops — a dead Enter" if on_enter.empty?
|
|
66
|
+
|
|
67
|
+
on_enter.ops.to_json
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def editable_escape(on_escape)
|
|
71
|
+
return "revert" if on_escape == :revert
|
|
72
|
+
|
|
73
|
+
raise ArgumentError, "reactive_editable on_escape: takes :revert or nil, got #{on_escape.inspect}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|