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.
Files changed (28) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/README.md +73 -5
  4. data/app/javascript/phlex/reactive/core.js +7 -1
  5. data/app/javascript/phlex/reactive/core.min.js +6 -2
  6. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  7. data/app/javascript/phlex/reactive/features/bindings.js +106 -2
  8. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  9. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/form.js +12 -5
  11. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  12. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  13. data/app/javascript/phlex/reactive/features/persist.js +12 -3
  14. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/features/sortable.js +463 -0
  17. data/app/javascript/phlex/reactive/features/sortable.min.js +4 -0
  18. data/app/javascript/phlex/reactive/features/sortable.min.js.map +10 -0
  19. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  20. data/app/javascript/phlex/reactive/reactive_controller.min.js +6 -2
  21. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +9 -8
  22. data/app/javascript/phlex/reactive/runtime.js +117 -10
  23. data/lib/phlex/reactive/component/editable.rb +78 -0
  24. data/lib/phlex/reactive/component/helpers.rb +2 -0
  25. data/lib/phlex/reactive/component/sortable.rb +212 -0
  26. data/lib/phlex/reactive/engine.rb +4 -4
  27. data/lib/phlex/reactive/version.rb +1 -1
  28. 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 || el.localName !== "input" || !holdsTypedValue(el)) return
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 and
1188
- // draft nested rows: a form's client-only bindings. The root declares one, or
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 … COMPUTE —
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
- proceed: (target, action, params) => this.#proceed(target, action, params),
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.value ?? el.textContent ?? el.innerHTML ?? ""
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 26.1 KB; the core 12.4 KB;
25
- # persist 3.2, defer 2.8, form 1.0, bindings 5.4, compute 2.1, effects
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.14.2"
5
+ VERSION = "0.14.4"
6
6
  end
7
7
  end
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.2
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