phlex-reactive 0.14.2 → 0.14.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +48 -0
- data/README.md +73 -5
- 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/features/bindings.js +106 -2
- 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 +12 -5
- 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/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/reactive_controller.js +2 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +6 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +9 -8
- data/app/javascript/phlex/reactive/runtime.js +117 -10
- data/lib/phlex/reactive/component/editable.rb +78 -0
- data/lib/phlex/reactive/component/helpers.rb +2 -0
- data/lib/phlex/reactive/component/sortable.rb +212 -0
- data/lib/phlex/reactive/engine.rb +4 -4
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +6 -1
|
@@ -35,6 +35,7 @@ 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"
|
|
38
39
|
|
|
39
40
|
// phlex/reactive/runtime — the reactive controller WITHOUT its feature
|
|
40
41
|
// modules (issue #275): the code both client entries share. It is not an
|
|
@@ -238,10 +239,18 @@ export function __resetReactiveStreamRenderForTest() {
|
|
|
238
239
|
// data-reactive-morph-value on a field lets the morph write it while focused.
|
|
239
240
|
let morphFocusRegistered = false
|
|
240
241
|
|
|
242
|
+
//
|
|
243
|
+
// A reactive_editable (issue #351) is held the same way, but its "value" is its
|
|
244
|
+
// CHILDREN: a focused one keeps them (text and caret) while every attribute
|
|
245
|
+
// still morphs — copied here from the render, then the element's morph is
|
|
246
|
+
// cancelled (cancelling is all-or-nothing in Idiomorph). Its contenteditable
|
|
247
|
+
// stays as it is: the bindings may have downgraded it for an engine without
|
|
248
|
+
// plaintext-only. Every morph of an editable, held or not, records the
|
|
249
|
+
// render's text as its SERVER TEXT (the Escape revert, the dirty baseline).
|
|
241
250
|
function holdsTypedValue(el) {
|
|
242
251
|
return (
|
|
243
252
|
el === document.activeElement &&
|
|
244
|
-
(el.localName === "input" || el.localName === "textarea") &&
|
|
253
|
+
(el.localName === "input" || el.localName === "textarea" || el.hasAttribute("data-reactive-editable")) &&
|
|
245
254
|
!el.hasAttribute("data-reactive-morph-value") &&
|
|
246
255
|
!!el.closest('[data-controller~="reactive"]')
|
|
247
256
|
)
|
|
@@ -254,11 +263,26 @@ export function registerReactiveMorphFocus() {
|
|
|
254
263
|
document.addEventListener("turbo:before-morph-element", (event) => {
|
|
255
264
|
const el = event.target
|
|
256
265
|
const next = event.detail?.newElement // absent when Turbo asks before a removal
|
|
257
|
-
if (!next || event.defaultPrevented
|
|
266
|
+
if (!next || event.defaultPrevented) return
|
|
267
|
+
if (el.hasAttribute?.("data-reactive-editable")) {
|
|
268
|
+
serverTexts.set(el, editableValue(next))
|
|
269
|
+
if (!holdsTypedValue(el)) return
|
|
270
|
+
for (const { name } of [...el.attributes]) {
|
|
271
|
+
if (!next.hasAttribute(name) && name !== "contenteditable") el.removeAttribute(name)
|
|
272
|
+
}
|
|
273
|
+
for (const { name, value } of next.attributes) {
|
|
274
|
+
if (name !== "contenteditable" && el.getAttribute(name) !== value) el.setAttribute(name, value)
|
|
275
|
+
}
|
|
276
|
+
return event.preventDefault()
|
|
277
|
+
}
|
|
278
|
+
if (el.localName !== "input" || !holdsTypedValue(el)) return
|
|
258
279
|
const value = next.getAttribute("value")
|
|
259
280
|
if (value == null) el.removeAttribute("value")
|
|
260
281
|
else if (el.getAttribute("value") !== value) el.setAttribute("value", value)
|
|
261
282
|
})
|
|
283
|
+
// An editable a morph never reached (a stream append) records its server
|
|
284
|
+
// text when first focused — before anything can be typed into it.
|
|
285
|
+
document.addEventListener("focusin", (event) => seedServerText(event.target))
|
|
262
286
|
document.addEventListener("turbo:before-morph-attribute", (event) => {
|
|
263
287
|
if (event.detail?.attributeName === "value" && holdsTypedValue(event.target)) event.preventDefault()
|
|
264
288
|
})
|
|
@@ -623,6 +647,51 @@ export const EDITOR_SELECTOR =
|
|
|
623
647
|
":is(lexxy-editor, trix-editor, [contenteditable=''], [contenteditable=true], [contenteditable=plaintext-only])"
|
|
624
648
|
export const EDITOR_TAGS = new Set(["lexxy-editor", "trix-editor"])
|
|
625
649
|
|
|
650
|
+
// A reactive_editable's value (issue #351): its text with line breaks — a
|
|
651
|
+
// <br> is one, a block element (the <div> a contenteditable=true Enter
|
|
652
|
+
// inserts) starts and ends a line — "\r\n" normalised, ONE trailing newline
|
|
653
|
+
// dropped (the <br> browsers keep at the end), and folded to spaces when the
|
|
654
|
+
// field is single-line. Read from the DOM, not innerText: no layout, the same
|
|
655
|
+
// on a detached render and under any white-space.
|
|
656
|
+
export function editableValue(el) {
|
|
657
|
+
let text = ""
|
|
658
|
+
// A block's edge asks for a line break; the next text or <br> pays it,
|
|
659
|
+
// once, unless the text already ends in one.
|
|
660
|
+
let line = false
|
|
661
|
+
const add = (s) => {
|
|
662
|
+
if (line && text && !text.endsWith("\n")) text += "\n"
|
|
663
|
+
line = false
|
|
664
|
+
text += s
|
|
665
|
+
}
|
|
666
|
+
const walk = (node) => {
|
|
667
|
+
for (const child of node.childNodes) {
|
|
668
|
+
const block = /^(div|p|li)$/.test(child.localName)
|
|
669
|
+
if (block) line = true
|
|
670
|
+
if (child.nodeType === 3) child.data && add(child.data)
|
|
671
|
+
else if (child.localName === "br") add("\n")
|
|
672
|
+
else if (child.nodeType === 1) walk(child)
|
|
673
|
+
if (block) line = true
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
walk(el)
|
|
677
|
+
text = text.replace(/\r\n?/g, "\n").replace(/\n$/, "")
|
|
678
|
+
return el.getAttribute("data-reactive-editable") === "multi" ? text : text.replace(/\n/g, " ")
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// element -> the text of its last server render (issue #351): what Escape
|
|
682
|
+
// reverts to and what dirty tracking diffs against. Seeded when the root
|
|
683
|
+
// connects (or on first focus) and advanced by every morph of the element.
|
|
684
|
+
const serverTexts = new WeakMap()
|
|
685
|
+
|
|
686
|
+
function seedServerText(el) {
|
|
687
|
+
if (el?.hasAttribute?.("data-reactive-editable") && !serverTexts.has(el)) serverTexts.set(el, editableValue(el))
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export function serverText(el) {
|
|
691
|
+
seedServerText(el)
|
|
692
|
+
return serverTexts.get(el)
|
|
693
|
+
}
|
|
694
|
+
|
|
626
695
|
// A RICH editor (lexxy/trix) is only ready once its custom element upgraded —
|
|
627
696
|
// Trix defines its elements in a setTimeout after load — and exposes the
|
|
628
697
|
// string `value` accessor; reading it before that yields "", which is issue
|
|
@@ -1184,8 +1253,9 @@ function formNeeded(root) {
|
|
|
1184
1253
|
|
|
1185
1254
|
// Show bindings and cross-root show targets, completion bindings, option
|
|
1186
1255
|
// filtering, the bulk-selection group bindings (enable, select-all, count —
|
|
1187
|
-
// issue #319) and their cross-root targets (#343), the tag-chip input
|
|
1188
|
-
//
|
|
1256
|
+
// issue #319) and their cross-root targets (#343), the tag-chip input, draft
|
|
1257
|
+
// nested rows and editable text's keys and paste (#351): a form's client-only
|
|
1258
|
+
// bindings. The root declares one, or
|
|
1189
1259
|
// owns an element that does. (The probes the connect-time gates ran before
|
|
1190
1260
|
// the split, unchanged.)
|
|
1191
1261
|
function bindingsNeeded(root) {
|
|
@@ -1195,7 +1265,7 @@ function bindingsNeeded(root) {
|
|
|
1195
1265
|
) ||
|
|
1196
1266
|
(root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
|
|
1197
1267
|
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]",
|
|
1268
|
+
"[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
1269
|
) != null
|
|
1200
1270
|
)
|
|
1201
1271
|
}
|
|
@@ -1205,6 +1275,14 @@ function computeNeeded(root) {
|
|
|
1205
1275
|
return root.getAttribute?.("data-reactive-compute-inputs-param") != null
|
|
1206
1276
|
}
|
|
1207
1277
|
|
|
1278
|
+
// Sortable lists (issue #350): the root is, or holds, a sortable list or a
|
|
1279
|
+
// source list. (A nested root's list loads the module for its parent too; the
|
|
1280
|
+
// module then checks ownership before it picks a row up.)
|
|
1281
|
+
function sortableNeeded(root) {
|
|
1282
|
+
const lists = "[data-reactive-sortable],[data-reactive-sortable-source]"
|
|
1283
|
+
return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1208
1286
|
const PRODUCTION_FEATURES = [
|
|
1209
1287
|
// (Its `waiting` hook is the opt-in entry's: core.js.) The draft restore
|
|
1210
1288
|
// writes what a request reads, so it gates.
|
|
@@ -1215,6 +1293,8 @@ const PRODUCTION_FEATURES = [
|
|
|
1215
1293
|
["bindings", [bindingsNeeded, null, null, true]],
|
|
1216
1294
|
// The seed writes output fields a request collects, so it gates.
|
|
1217
1295
|
["compute", [computeNeeded, null, null, true]],
|
|
1296
|
+
// Nothing it does at connect is read by a request: it never gates.
|
|
1297
|
+
["sortable", [sortableNeeded, null]],
|
|
1218
1298
|
[
|
|
1219
1299
|
// Stream effects and dismissing flashes are document-level: the module
|
|
1220
1300
|
// has no connect. A root that DECLARES an effect only gets it fetched
|
|
@@ -1378,7 +1458,7 @@ function featureTimeoutMs() {
|
|
|
1378
1458
|
export function reactiveFeatureNames() {
|
|
1379
1459
|
return __SPLIT__
|
|
1380
1460
|
? [...FEATURES.keys()]
|
|
1381
|
-
: ["persist", "defer", "form", "bindings", "compute", "effects", "hints", "devtools"]
|
|
1461
|
+
: ["persist", "defer", "form", "bindings", "compute", "sortable", "effects", "hints", "devtools"]
|
|
1382
1462
|
}
|
|
1383
1463
|
|
|
1384
1464
|
export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
|
|
@@ -1400,6 +1480,7 @@ export function __loadReactiveFeatureForTest(name) {
|
|
|
1400
1480
|
form: formFeature,
|
|
1401
1481
|
bindings: bindingsFeature,
|
|
1402
1482
|
compute: computeFeature,
|
|
1483
|
+
sortable: sortableFeature,
|
|
1403
1484
|
effects: effectsFeature,
|
|
1404
1485
|
hints: hintsFeature,
|
|
1405
1486
|
devtools: devtoolsFeature,
|
|
@@ -1440,6 +1521,9 @@ const FEATURE_SHARED = {
|
|
|
1440
1521
|
enter: enterReactiveActivity,
|
|
1441
1522
|
exit: exitReactiveActivity,
|
|
1442
1523
|
count: countReactiveRequest,
|
|
1524
|
+
// The editable collector and server text (issue #351), for the draft.
|
|
1525
|
+
text: editableValue,
|
|
1526
|
+
served: serverText,
|
|
1443
1527
|
waiting: new WeakMap(),
|
|
1444
1528
|
}
|
|
1445
1529
|
|
|
@@ -1449,6 +1533,7 @@ const DEFER = 2
|
|
|
1449
1533
|
const FORM = 4
|
|
1450
1534
|
const BINDINGS = 8
|
|
1451
1535
|
const COMPUTE = 16
|
|
1536
|
+
const SORTABLE = 32
|
|
1452
1537
|
|
|
1453
1538
|
// The default entry's features are part of this file: install them now, as
|
|
1454
1539
|
// the opt-in entry does when one arrives.
|
|
@@ -1670,7 +1755,7 @@ export default class extends Controller {
|
|
|
1670
1755
|
#featureTurns = new Map() // name -> settles once that feature has had its turn to connect
|
|
1671
1756
|
#featureEpoch = 0
|
|
1672
1757
|
#featureHandle // the `core` handle features receive, built on first use
|
|
1673
|
-
// The default entry (issue #305): one bit per feature, PERSIST …
|
|
1758
|
+
// The default entry (issue #305): one bit per feature, PERSIST … SORTABLE —
|
|
1674
1759
|
// asked for (its marker read true) and connected on this connection.
|
|
1675
1760
|
#featuresAsked = 0
|
|
1676
1761
|
#featuresOn = 0
|
|
@@ -1714,13 +1799,18 @@ export default class extends Controller {
|
|
|
1714
1799
|
// without any marker, before and after the morph, a few attribute reads and
|
|
1715
1800
|
// never an import. (turbo:morph-element BUBBLES: only a morph of the root
|
|
1716
1801
|
// itself counts.) A tokenless, client-only root has no such listener.
|
|
1802
|
+
// The same listener hears an editable being focused (issue #351): a stream
|
|
1803
|
+
// can append a root's first reactive_editable without morphing the root,
|
|
1804
|
+
// and its keys and paste need the bindings feature before anything is
|
|
1805
|
+
// typed into it.
|
|
1717
1806
|
if (this.element.getAttribute?.("data-reactive-token-value") != null || deferNeeded(this.element)) {
|
|
1718
1807
|
this.#boundRootMorph = (event) => {
|
|
1719
|
-
if (event.target !== this.element) return
|
|
1808
|
+
if (event.type === "focusin" ? !event.target.hasAttribute?.("data-reactive-editable") : event.target !== this.element) return
|
|
1720
1809
|
if (__SPLIT__) this.#loadFeatures(true)
|
|
1721
1810
|
else this.#connectFeaturesNow(true)
|
|
1722
1811
|
}
|
|
1723
1812
|
this.element.addEventListener?.("turbo:morph-element", this.#boundRootMorph)
|
|
1813
|
+
this.element.addEventListener?.("focusin", this.#boundRootMorph)
|
|
1724
1814
|
}
|
|
1725
1815
|
|
|
1726
1816
|
// Feature modules (issue #275): connect the ones that are here and START
|
|
@@ -1731,6 +1821,10 @@ export default class extends Controller {
|
|
|
1731
1821
|
// re-runs the seeds itself (#reseed). Never awaited — connect() must reach
|
|
1732
1822
|
// the drain at the end in the same task (issue #274: an await before it
|
|
1733
1823
|
// runs a waking click twice).
|
|
1824
|
+
// The editables' server texts (issue #351), before a draft restore
|
|
1825
|
+
// writes into them.
|
|
1826
|
+
this.element.querySelectorAll?.("[data-reactive-editable]").forEach(seedServerText)
|
|
1827
|
+
|
|
1734
1828
|
this.#featureEpoch++
|
|
1735
1829
|
this.featuresReady = FEATURES_READY
|
|
1736
1830
|
if (__SPLIT__) this.#loadFeatures()
|
|
@@ -1754,6 +1848,7 @@ export default class extends Controller {
|
|
|
1754
1848
|
this.#connectFeatureNow(FORM, "form", formNeeded, (core) => formFeature.connect(this, core, morphed))
|
|
1755
1849
|
this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
|
|
1756
1850
|
this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
|
|
1851
|
+
this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
|
|
1757
1852
|
}
|
|
1758
1853
|
}
|
|
1759
1854
|
|
|
@@ -1790,6 +1885,7 @@ export default class extends Controller {
|
|
|
1790
1885
|
if (on & FORM) this.#disconnectFeatureNow("form", () => formFeature.disconnect(this, core))
|
|
1791
1886
|
if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
|
|
1792
1887
|
if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
|
|
1888
|
+
if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
|
|
1793
1889
|
}
|
|
1794
1890
|
}
|
|
1795
1891
|
|
|
@@ -1957,7 +2053,9 @@ export default class extends Controller {
|
|
|
1957
2053
|
this.#featureHandle ??= {
|
|
1958
2054
|
emit: (name, detail, options) => this.#emit(name, detail, options),
|
|
1959
2055
|
reseed: () => this.#reseed(),
|
|
1960
|
-
|
|
2056
|
+
// Resolves with the reply's HTML once the request has applied, or with
|
|
2057
|
+
// nothing when it failed or was vetoed. `busy` is on(busy:)'s hint.
|
|
2058
|
+
proceed: (target, action, params, busy) => this.#proceed(target, action, params, 0, 0, null, busy),
|
|
1961
2059
|
forgetToken: () => {
|
|
1962
2060
|
this.#tokenCache = undefined
|
|
1963
2061
|
},
|
|
@@ -1976,6 +2074,9 @@ export default class extends Controller {
|
|
|
1976
2074
|
),
|
|
1977
2075
|
listnavOptions: (event) => this.#listnavOptions(event),
|
|
1978
2076
|
collectFields: () => this.#collectFields(),
|
|
2077
|
+
// A reactive_editable's value and its last server-rendered text (#351).
|
|
2078
|
+
text: editableValue,
|
|
2079
|
+
served: serverText,
|
|
1979
2080
|
// The confirm gate, as dispatch() runs it: the resolver inside the chain
|
|
1980
2081
|
// (a synchronous throw is a cancel), never a rejection out.
|
|
1981
2082
|
confirm: (message, context) =>
|
|
@@ -2168,6 +2269,7 @@ export default class extends Controller {
|
|
|
2168
2269
|
this.#clearAllThrottles()
|
|
2169
2270
|
if (this.#boundRootMorph) {
|
|
2170
2271
|
this.element.removeEventListener?.("turbo:morph-element", this.#boundRootMorph)
|
|
2272
|
+
this.element.removeEventListener?.("focusin", this.#boundRootMorph)
|
|
2171
2273
|
}
|
|
2172
2274
|
// Early triggers (issue #273): a disconnected root records again.
|
|
2173
2275
|
earlyState().connected.delete(this.element)
|
|
@@ -3068,6 +3170,9 @@ export default class extends Controller {
|
|
|
3068
3170
|
// complete a tick later. Apps needing post-morph timing listen to Turbo's
|
|
3069
3171
|
// own events; this one is for "the action round trip succeeded".
|
|
3070
3172
|
this.#emit("reactive:applied", { action, params: allParams, html })
|
|
3173
|
+
// What core.proceed resolves with: a feature can tell a reply that
|
|
3174
|
+
// applied from a failure (every other exit returns nothing).
|
|
3175
|
+
return html
|
|
3071
3176
|
} catch (error) {
|
|
3072
3177
|
// The server already processed this action successfully (we're past the
|
|
3073
3178
|
// fetch) — a throw here is a CLIENT-side apply failure (a malformed
|
|
@@ -3290,7 +3395,9 @@ export default class extends Controller {
|
|
|
3290
3395
|
// property — only the attribute — so read getAttribute, not el.name.
|
|
3291
3396
|
const name = el.getAttribute("name")
|
|
3292
3397
|
if (!name) return
|
|
3293
|
-
const own = el.
|
|
3398
|
+
const own = el.getAttribute("data-reactive-editable") != null
|
|
3399
|
+
? editableValue(el)
|
|
3400
|
+
: (el.value ?? el.textContent ?? el.innerHTML ?? "")
|
|
3294
3401
|
// A `[]` name is a group here too: the editor APPENDS its value
|
|
3295
3402
|
// instead of replacing the slot. Assigning a scalar posted
|
|
3296
3403
|
// `{"notes[]": "<p>x</p>"}` while the draft snapshot pushed the same
|
|
@@ -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
|
|
@@ -13,6 +13,8 @@ module Phlex
|
|
|
13
13
|
module Helpers
|
|
14
14
|
extend ActiveSupport::Concern
|
|
15
15
|
include Selection
|
|
16
|
+
include Sortable
|
|
17
|
+
include Editable
|
|
16
18
|
|
|
17
19
|
# The acting client's SSE connection id during the current action (nil
|
|
18
20
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Sortable lists (issue #350): drag rows to reorder a list, or drag a row
|
|
7
|
+
# in from another list, and ONE declared action receives the row and its
|
|
8
|
+
# new position — through the normal action pipeline (the signed token,
|
|
9
|
+
# the request queue, busy:, the reply).
|
|
10
|
+
#
|
|
11
|
+
# ul(**mix(reactive_root, reactive_sortable(:move, handle: "[data-drag-handle]"))) do
|
|
12
|
+
# @list.tasks.each do |task|
|
|
13
|
+
# li(id: dom_id(task), **reactive_sortable_item(task.id)) do
|
|
14
|
+
# span(data: { drag_handle: true }) { "⠿" }
|
|
15
|
+
# plain task.title
|
|
16
|
+
# end
|
|
17
|
+
# end
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# On drop the client sends the action with these params (declare the
|
|
21
|
+
# ones you want; default-deny drops the rest):
|
|
22
|
+
#
|
|
23
|
+
# item the dragged row's key (reactive_sortable_item(key))
|
|
24
|
+
# position its 0-based index in the target list after the drop
|
|
25
|
+
# after the key of the row it now follows, nil when it is first
|
|
26
|
+
# from "self" for a reorder, else the name of the list it came from
|
|
27
|
+
#
|
|
28
|
+
# The row moves in the DOM at once and moves back when the action fails
|
|
29
|
+
# (or its reply does not render the list). Rows can be dragged with a
|
|
30
|
+
# mouse, a pen or a finger (a 200 ms press), and with the keyboard: a
|
|
31
|
+
# focused row picks up on Space/Enter, moves with the arrow keys, drops
|
|
32
|
+
# on Space/Enter and goes back on Escape, each step announced through a
|
|
33
|
+
# live region in the words of `messages:`.
|
|
34
|
+
#
|
|
35
|
+
# The drag itself is the client's sortable feature module
|
|
36
|
+
# (features/sortable.js); a page without a sortable loads none of it on
|
|
37
|
+
# the split client.
|
|
38
|
+
module Sortable
|
|
39
|
+
# The announcement words, {position} and {count} filled in by the
|
|
40
|
+
# client. Override any of them (a translation) with messages:.
|
|
41
|
+
SORTABLE_MESSAGES = {
|
|
42
|
+
picked: "Picked up item {position} of {count}",
|
|
43
|
+
moved: "Moved to position {position} of {count}",
|
|
44
|
+
dropped: "Dropped",
|
|
45
|
+
cancelled: "Cancelled"
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# The params the client fills in on drop: a static params: may not set them.
|
|
49
|
+
SORTABLE_DROP_PARAMS = %i[item position after from].freeze
|
|
50
|
+
|
|
51
|
+
# A sortable list. `action` is called on drop.
|
|
52
|
+
#
|
|
53
|
+
# group: links lists (drag between them) and sources to targets
|
|
54
|
+
# handle: a CSS selector, resolved inside each row; only it starts a
|
|
55
|
+
# pointer drag. Without it the whole row drags.
|
|
56
|
+
# name: what a row dragged OUT of this list into another reports
|
|
57
|
+
# as `from` (and what that list's from: map keys on)
|
|
58
|
+
# from: a drag-in calls another action: from: { "templates" => :insert }
|
|
59
|
+
# params: static params sent with every drop (e.g. the lane a list is)
|
|
60
|
+
# disabled: refuse every drag from or into this list
|
|
61
|
+
# busy: on(busy:)'s vocabulary, applied to the list while pending
|
|
62
|
+
# messages: the live-region words, merged over SORTABLE_MESSAGES
|
|
63
|
+
def reactive_sortable(action, group: nil, handle: nil, name: nil, from: nil, params: nil,
|
|
64
|
+
disabled: false, busy: nil, messages: nil)
|
|
65
|
+
sortable_requires_component!
|
|
66
|
+
action = sortable_action!(action)
|
|
67
|
+
data = { reactive_sortable: action }
|
|
68
|
+
data[:reactive_sortable_group] = sortable_name!(:group, group) unless group.nil?
|
|
69
|
+
data[:reactive_sortable_handle] = sortable_handle!(handle) unless handle.nil?
|
|
70
|
+
data[:reactive_sortable_name] = sortable_list_name!(name) unless name.nil?
|
|
71
|
+
data[:reactive_sortable_from] = sortable_from!(from) unless from.nil?
|
|
72
|
+
data[:reactive_sortable_params] = sortable_params!(params) unless params.nil?
|
|
73
|
+
data[:reactive_sortable_disabled] = "true" if disabled
|
|
74
|
+
data[:reactive_sortable_busy] = pending_hint_json(busy, action, :busy) if busy
|
|
75
|
+
data[:reactive_sortable_messages] = sortable_messages!(messages)
|
|
76
|
+
{ data: }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# A list whose rows are COPIED into any sortable of the same group,
|
|
80
|
+
# never moved. The target's action (or its from: mapping for `name`)
|
|
81
|
+
# decides what lands, and its reply renders the new row. Needs no
|
|
82
|
+
# token: it works on a ClientBindings root.
|
|
83
|
+
def reactive_sortable_source(group:, name: "source")
|
|
84
|
+
{ data: { reactive_sortable_source: sortable_list_name!(name),
|
|
85
|
+
reactive_sortable_group: sortable_name!(:group, group) } }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A row and its key — a String or an Integer, sent as-is (JSON-typed).
|
|
89
|
+
# The row is focusable, for the keyboard model (unless disabled).
|
|
90
|
+
def reactive_sortable_item(key, disabled: false)
|
|
91
|
+
unless (key.is_a?(String) && !key.strip.empty?) || key.is_a?(Integer)
|
|
92
|
+
raise ArgumentError,
|
|
93
|
+
"reactive_sortable_item key must be a String or an Integer (the row's id), got #{key.inspect}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
data = { reactive_sortable_item: key.to_json }
|
|
97
|
+
# A disabled row cannot be picked up, so it stays out of the tab order.
|
|
98
|
+
return { data: data.merge(reactive_sortable_disabled: "true") } if disabled
|
|
99
|
+
|
|
100
|
+
{ tabindex: 0, data: }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def sortable_requires_component!
|
|
106
|
+
return if self.class.include?(Phlex::Reactive::Component::Identity)
|
|
107
|
+
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"reactive_sortable needs the full Phlex::Reactive::Component — a drop calls a server action, " \
|
|
110
|
+
"which Phlex::Reactive::ClientBindings has no token for. A copy-only list on a client-only " \
|
|
111
|
+
"root is reactive_sortable_source(group:)."
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The on() courtesy (issue #105): an undeclared action fails at render
|
|
115
|
+
# under verbose_errors, not as a 403 at drop time.
|
|
116
|
+
def sortable_action!(action)
|
|
117
|
+
if action.nil? || action.to_s.strip.empty?
|
|
118
|
+
raise ArgumentError, "reactive_sortable needs an action to call on drop, e.g. reactive_sortable(:move)"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
if Phlex::Reactive.verbose_errors &&
|
|
122
|
+
(actions = self.class.reactive_actions).any? && !actions.key?(action.to_sym)
|
|
123
|
+
raise Phlex::Reactive::Error,
|
|
124
|
+
"#{self.class} has no declared action #{action.to_sym.inspect} (declared: #{actions.keys.inspect})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
action.to_s
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def sortable_name!(option, value)
|
|
131
|
+
unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
|
|
132
|
+
raise ArgumentError, "reactive_sortable #{option}: needs a name (a String or Symbol), got #{value.inspect}"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
value.to_s
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# "self" is what a reorder within one list reports as `from`.
|
|
139
|
+
def sortable_list_name!(value)
|
|
140
|
+
name = sortable_name!(:name, value)
|
|
141
|
+
if name == "self"
|
|
142
|
+
raise ArgumentError,
|
|
143
|
+
'reactive_sortable name: "self" is reserved — it is the `from` of a reorder within one list'
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
name
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def sortable_handle!(handle)
|
|
150
|
+
selector = handle.to_s
|
|
151
|
+
if handle == true || handle == false || selector.strip.empty?
|
|
152
|
+
raise ArgumentError,
|
|
153
|
+
"reactive_sortable handle: needs a CSS selector resolved inside each row " \
|
|
154
|
+
"(e.g. \"[data-drag-handle]\"), got #{handle.inspect}"
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
selector
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def sortable_from!(from)
|
|
161
|
+
valid = from.is_a?(Hash) && from.any? && from.keys.all? { !it.to_s.strip.empty? }
|
|
162
|
+
unless valid
|
|
163
|
+
raise ArgumentError,
|
|
164
|
+
"reactive_sortable from: maps a source list's name to the action a drag-in from it calls, " \
|
|
165
|
+
"e.g. from: { \"templates\" => :insert }, got #{from.inspect}"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
from.to_h { |list, action| [sortable_list_name!(list), sortable_action!(action)] }.to_json
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def sortable_params!(params)
|
|
172
|
+
raise ArgumentError, "reactive_sortable params: must be a Hash, got #{params.inspect}" unless params.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
taken = params.keys.map(&:to_sym) & SORTABLE_DROP_PARAMS
|
|
175
|
+
unless taken.empty?
|
|
176
|
+
raise ArgumentError,
|
|
177
|
+
"reactive_sortable params: cannot set #{taken.first.inspect} — the client fills in " \
|
|
178
|
+
"#{SORTABLE_DROP_PARAMS.map(&:inspect).join(", ")} on drop"
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
params.to_json
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def sortable_messages!(messages)
|
|
185
|
+
return SORTABLE_MESSAGES_JSON if messages.nil?
|
|
186
|
+
unless messages.is_a?(Hash)
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"reactive_sortable messages: must be a Hash, got #{messages.inspect}"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
unknown = messages.keys.map { it.to_s.to_sym } - SORTABLE_MESSAGES.keys
|
|
192
|
+
unless unknown.empty?
|
|
193
|
+
raise ArgumentError,
|
|
194
|
+
"reactive_sortable got an unknown messages: key #{unknown.first.inspect} — " \
|
|
195
|
+
"supported: #{SORTABLE_MESSAGES.keys.map(&:inspect).join(", ")}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
messages.each do |name, words|
|
|
199
|
+
next if words.is_a?(String)
|
|
200
|
+
|
|
201
|
+
raise ArgumentError, "reactive_sortable messages: #{name} must be a String, got #{words.inspect}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
SORTABLE_MESSAGES.merge(messages.transform_keys { it.to_s.to_sym }).to_json
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
SORTABLE_MESSAGES_JSON = SORTABLE_MESSAGES.to_json
|
|
208
|
+
private_constant :SORTABLE_MESSAGES_JSON, :SORTABLE_DROP_PARAMS
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -20,10 +20,10 @@ module Phlex
|
|
|
20
20
|
# build script (scripts/build_client.js), the core's own table and the
|
|
21
21
|
# default entry name the same features —
|
|
22
22
|
# spec/phlex/engine_client_pin_spec.rb fails when they disagree.
|
|
23
|
-
CLIENT_FEATURES = %w[persist defer form bindings compute effects hints devtools].freeze
|
|
24
|
-
# (Gzipped, bun zlib level 9: the default file
|
|
25
|
-
# persist 3.
|
|
26
|
-
# 1.7, hints 1.0, devtools 1.6 KB. spec/javascript/bundle_budget.test.js
|
|
23
|
+
CLIENT_FEATURES = %w[persist defer form bindings compute sortable effects hints devtools].freeze
|
|
24
|
+
# (Gzipped, bun zlib level 9: the default file 28.9 KB; the core 13.5 KB;
|
|
25
|
+
# persist 3.1, defer 3.0, form 1.0, bindings 6.2, compute 2.1, sortable
|
|
26
|
+
# 3.1, effects 1.7, hints 1.0, devtools 1.6 KB. spec/javascript/bundle_budget.test.js
|
|
27
27
|
# is the source of truth and holds a ratchet on each.)
|
|
28
28
|
|
|
29
29
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-reactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.14.
|
|
4
|
+
version: 0.14.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -158,6 +158,9 @@ files:
|
|
|
158
158
|
- app/javascript/phlex/reactive/features/persist.js
|
|
159
159
|
- app/javascript/phlex/reactive/features/persist.min.js
|
|
160
160
|
- app/javascript/phlex/reactive/features/persist.min.js.map
|
|
161
|
+
- app/javascript/phlex/reactive/features/sortable.js
|
|
162
|
+
- app/javascript/phlex/reactive/features/sortable.min.js
|
|
163
|
+
- app/javascript/phlex/reactive/features/sortable.min.js.map
|
|
161
164
|
- app/javascript/phlex/reactive/inspect.js
|
|
162
165
|
- app/javascript/phlex/reactive/inspect.min.js
|
|
163
166
|
- app/javascript/phlex/reactive/inspect.min.js.map
|
|
@@ -188,11 +191,13 @@ files:
|
|
|
188
191
|
- lib/phlex/reactive/collections.rb
|
|
189
192
|
- lib/phlex/reactive/component.rb
|
|
190
193
|
- lib/phlex/reactive/component/dsl.rb
|
|
194
|
+
- lib/phlex/reactive/component/editable.rb
|
|
191
195
|
- lib/phlex/reactive/component/helpers.rb
|
|
192
196
|
- lib/phlex/reactive/component/identity.rb
|
|
193
197
|
- lib/phlex/reactive/component/lazy.rb
|
|
194
198
|
- lib/phlex/reactive/component/registry.rb
|
|
195
199
|
- lib/phlex/reactive/component/selection.rb
|
|
200
|
+
- lib/phlex/reactive/component/sortable.rb
|
|
196
201
|
- lib/phlex/reactive/defer.rb
|
|
197
202
|
- lib/phlex/reactive/deferred_render_job.rb
|
|
198
203
|
- lib/phlex/reactive/doctor.rb
|