phlex-reactive 0.16.0 → 0.16.2

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +282 -145
  3. data/README.md +85 -1
  4. data/app/controllers/phlex/reactive/actions_controller.rb +10 -0
  5. data/app/javascript/phlex/reactive/core.js +1 -0
  6. data/app/javascript/phlex/reactive/core.min.js +4 -4
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/features/bindings.js +81 -4
  9. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  10. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/drag.js +522 -0
  12. data/app/javascript/phlex/reactive/features/drag.min.js +4 -0
  13. data/app/javascript/phlex/reactive/features/drag.min.js.map +10 -0
  14. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  15. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  16. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +7 -6
  17. data/app/javascript/phlex/reactive/runtime.js +57 -23
  18. data/lib/phlex/reactive/component/css_vars_binding.rb +118 -0
  19. data/lib/phlex/reactive/component/drag.rb +270 -0
  20. data/lib/phlex/reactive/component/dsl.rb +3 -0
  21. data/lib/phlex/reactive/component/helpers.rb +5 -1
  22. data/lib/phlex/reactive/component/registry.rb +15 -5
  23. data/lib/phlex/reactive/engine.rb +14 -5
  24. data/lib/phlex/reactive/held_fields.rb +21 -1
  25. data/lib/phlex/reactive/param_schema.rb +5 -1
  26. data/lib/phlex/reactive/response.rb +55 -4
  27. data/lib/phlex/reactive/shareable.rb +18 -3
  28. data/lib/phlex/reactive/test_helpers.rb +6 -0
  29. data/lib/phlex/reactive/version.rb +1 -1
  30. data/sig/phlex/reactive/response.rbs +3 -0
  31. metadata +6 -1
@@ -36,6 +36,7 @@ import * as devtoolsFeature from "phlex/reactive/features/devtools"
36
36
  import * as hintsFeature from "phlex/reactive/features/hints"
37
37
  import * as deferFeature from "phlex/reactive/features/defer"
38
38
  import * as sortableFeature from "phlex/reactive/features/sortable"
39
+ import * as dragFeature from "phlex/reactive/features/drag"
39
40
  import * as presenceFeature from "phlex/reactive/features/presence"
40
41
  import * as countdownFeature from "phlex/reactive/features/countdown"
41
42
 
@@ -735,8 +736,9 @@ export function registerReactiveMorphFocus() {
735
736
  // through later morphs while focused, like `value`), a bubbling reactive:held
736
737
  // { name, theirs } is dispatched, and the root's next action sends the names as
737
738
  // X-Reactive-Held. The root's own reply never marks (it answers exactly those
738
- // values), and once applied the root holds nothing. A morph of the field while
739
- // unfocused writes the server's value and drops the mark with it.
739
+ // values), and once applied the root holds nothing — unless it refused them
740
+ // (X-Reactive-Refused, issue #413): then the marks it names stay. A morph of
741
+ // the field while unfocused writes the server's value and drops the mark with it.
740
742
  const HELD = "data-reactive-held"
741
743
  const ROOT = '[data-controller~="reactive"]'
742
744
  let replyRoot = null
@@ -781,18 +783,35 @@ function heldHeader(root) {
781
783
  return JSON.stringify(names).replace(/[\u007f-￿]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
782
784
  }
783
785
 
784
- function releaseHeld(root) {
785
- for (const el of root.querySelectorAll(`[${HELD}]`)) if (el.closest(ROOT) === root) el.removeAttribute(HELD)
786
+ // `keep`: true keeps every mark, a list keeps the marks of those names.
787
+ function releaseHeld(root, keep) {
788
+ if (keep === true) return
789
+ for (const el of root.querySelectorAll(`[${HELD}]`)) {
790
+ if (el.closest(ROOT) === root && !keep?.includes(el.getAttribute("name"))) el.removeAttribute(HELD)
791
+ }
792
+ }
793
+
794
+ // What a reply refused (issue #413): "*" keeps every mark, a JSON list the
795
+ // marks of those names. Anything else reads as a plain reply.
796
+ function refusedHeader(response) {
797
+ const raw = response.headers.get("X-Reactive-Refused")
798
+ if (raw === "*") return true
799
+ try {
800
+ const names = JSON.parse(raw)
801
+ return Array.isArray(names) ? names : null
802
+ } catch {
803
+ return null
804
+ }
786
805
  }
787
806
 
788
807
  // Hand a root's own reply to Turbo: while its streams render nothing in the
789
- // root is marked, and once they have the root holds nothing. Turbo announces
790
- // each stream synchronously inside renderStreamMessage (streamOwnReply wraps
791
- // its render, innermost); a reply none of whose streams rendered releases now.
792
- // An error reply (release false) never marks either, but releases nothing: the
793
- // server refused those values rather than answering them.
794
- function renderOwnReply(root, html, release = true) {
795
- const own = { root, streams: 0, release }
808
+ // root is marked, and once they have the root holds nothing but what `keep`
809
+ // names. Turbo announces each stream synchronously inside renderStreamMessage
810
+ // (streamOwnReply wraps its render, innermost); a reply none of whose streams
811
+ // rendered releases now. An error reply (keep true) never marks either, but
812
+ // releases nothing: the server refused those values rather than answering them.
813
+ function renderOwnReply(root, html, keep = null) {
814
+ const own = { root, streams: 0, keep }
796
815
  const previous = replyRoot
797
816
  replyRoot = own
798
817
  try {
@@ -800,7 +819,7 @@ function renderOwnReply(root, html, release = true) {
800
819
  } finally {
801
820
  replyRoot = previous
802
821
  }
803
- if (release && own.streams === 0) releaseHeld(root)
822
+ if (own.streams === 0) releaseHeld(root, keep)
804
823
  }
805
824
 
806
825
  function streamOwnReply(event) {
@@ -817,7 +836,7 @@ function streamOwnReply(event) {
817
836
  const left = replying.get(own.root) - 1
818
837
  if (left) replying.set(own.root, left)
819
838
  else replying.delete(own.root)
820
- if (own.release) releaseHeld(own.root)
839
+ releaseHeld(own.root, own.keep)
821
840
  }
822
841
  }
823
842
  }
@@ -2060,18 +2079,18 @@ function formNeeded(root) {
2060
2079
  // Show bindings and cross-root show targets, completion bindings, option
2061
2080
  // filtering, the bulk-selection group bindings (enable, select-all, count —
2062
2081
  // issue #319) and their cross-root targets (#343), the tag-chip input, draft
2063
- // nested rows, editable text's keys and paste (#351) and media previews
2064
- // (#373): a form's client-only bindings. The root declares one, or
2065
- // owns an element that does. (The probes the connect-time gates ran before
2066
- // the split, unchanged.)
2082
+ // nested rows, editable text's keys and paste (#351), media previews
2083
+ // (#373) and fields fed into CSS custom properties (#417): a form's
2084
+ // client-only bindings. The root declares one, or owns an element that does.
2085
+ // (The probes the connect-time gates ran before the split, unchanged.)
2067
2086
  function bindingsNeeded(root) {
2068
2087
  return (
2069
- ["show-targets", "group-targets", "on-complete", "filter-input", "tags-field"].some(
2088
+ ["show-targets", "group-targets", "on-complete", "filter-input", "tags-field", "css-vars"].some(
2070
2089
  (marker) => root.getAttribute?.(`data-reactive-${marker}`) != null,
2071
2090
  ) ||
2072
2091
  (root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
2073
2092
  root.querySelector?.(
2074
- "[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], [data-reactive-media]",
2093
+ "[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], [data-reactive-media], [data-reactive-css-vars]",
2075
2094
  ) != null
2076
2095
  )
2077
2096
  }
@@ -2089,6 +2108,14 @@ function sortableNeeded(root) {
2089
2108
  return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
2090
2109
  }
2091
2110
 
2111
+ // Draggable regions (issue #416): the root is, or holds, a frame, a box or a
2112
+ // point. (A nested root's frame loads the module for its parent too; the
2113
+ // module then checks ownership before it drags anything.)
2114
+ function dragNeeded(root) {
2115
+ const marks = "[data-reactive-frame],[data-reactive-box],[data-reactive-point]"
2116
+ return Boolean(root.matches?.(marks) || root.querySelector?.(marks))
2117
+ }
2118
+
2092
2119
  // Presence (issue #358): the root is a reactive_presence roster.
2093
2120
  function presenceNeeded(root) {
2094
2121
  return root.getAttribute?.("data-reactive-presence") != null
@@ -2111,6 +2138,8 @@ const PRODUCTION_FEATURES = [
2111
2138
  ["compute", [computeNeeded, null, null, true]],
2112
2139
  // Nothing it does at connect is read by a request: it never gates.
2113
2140
  ["sortable", [sortableNeeded, null]],
2141
+ // A drag writes fields only when the user drags: it never gates.
2142
+ ["drag", [dragNeeded, null]],
2114
2143
  // Its announce is a request of its own; nothing it does at connect is read
2115
2144
  // by another request: it never gates.
2116
2145
  ["presence", [presenceNeeded, null]],
@@ -2279,7 +2308,7 @@ function featureTimeoutMs() {
2279
2308
  export function reactiveFeatureNames() {
2280
2309
  return __SPLIT__
2281
2310
  ? [...FEATURES.keys()]
2282
- : ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "countdown", "effects", "hints", "devtools"]
2311
+ : ["persist", "defer", "form", "bindings", "compute", "sortable", "drag", "presence", "countdown", "effects", "hints", "devtools"]
2283
2312
  }
2284
2313
 
2285
2314
  export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
@@ -2302,6 +2331,7 @@ export function __loadReactiveFeatureForTest(name) {
2302
2331
  bindings: bindingsFeature,
2303
2332
  compute: computeFeature,
2304
2333
  sortable: sortableFeature,
2334
+ drag: dragFeature,
2305
2335
  presence: presenceFeature,
2306
2336
  countdown: countdownFeature,
2307
2337
  effects: effectsFeature,
@@ -2359,6 +2389,7 @@ const COMPUTE = 16
2359
2389
  const SORTABLE = 32
2360
2390
  const PRESENCE = 64
2361
2391
  const COUNTDOWN = 128
2392
+ const DRAG = 256
2362
2393
 
2363
2394
  // The default entry's features are part of this file: install them now, as
2364
2395
  // the opt-in entry does when one arrives.
@@ -2687,6 +2718,7 @@ export default class extends Controller {
2687
2718
  this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
2688
2719
  this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
2689
2720
  this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
2721
+ this.#connectFeatureNow(DRAG, "drag", dragNeeded, (core) => dragFeature.connect(this, core))
2690
2722
  this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
2691
2723
  this.#connectFeatureNow(COUNTDOWN, "countdown", countdownNeeded, (core) => countdownFeature.connect(this, core))
2692
2724
  }
@@ -2726,6 +2758,7 @@ export default class extends Controller {
2726
2758
  if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
2727
2759
  if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
2728
2760
  if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
2761
+ if (on & DRAG) this.#disconnectFeatureNow("drag", () => dragFeature.disconnect(this, core))
2729
2762
  if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
2730
2763
  if (on & COUNTDOWN) this.#disconnectFeatureNow("countdown", () => countdownFeature.disconnect(this, core))
2731
2764
  }
@@ -4096,7 +4129,7 @@ export default class extends Controller {
4096
4129
  if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
4097
4130
  else devtoolsFeature.recordBody(debug, errorBody, fresh)
4098
4131
  }
4099
- renderOwnReply(this.element, errorBody, false)
4132
+ renderOwnReply(this.element, errorBody, true)
4100
4133
  }
4101
4134
  this.#markError("http")
4102
4135
  this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
@@ -4128,8 +4161,9 @@ export default class extends Controller {
4128
4161
  // outerHTML swap (focus on the replaced subtree is lost); a method="morph"
4129
4162
  // replace (Response.morph) or an update morphs in place, preserving the
4130
4163
  // focused input + caret on unchanged nodes — see issue #28. As the root's
4131
- // own reply, it clears what the root held (issue #370).
4132
- renderOwnReply(this.element, html)
4164
+ // own reply, it clears what the root held (issue #370), but what it
4165
+ // refused (issue #413).
4166
+ renderOwnReply(this.element, html, refusedHeader(response))
4133
4167
  // Debug-only (issue #181): the morph may apply a microtask later, so check
4134
4168
  // the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
4135
4169
  // null — zero cost.
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # Fields that feed CSS custom properties on an element (issue #417): a
7
+ # preview that follows a control, with no request.
8
+ #
9
+ # div(**mix(reactive_root(id: "light"), reactive_css_vars(to: "#sample", vars: {
10
+ # "--light" => "light", # field name -> custom property
11
+ # "--turn" => { field: "turn", unit: "deg" } # with a unit from UNITS
12
+ # }))) do
13
+ # input(type: "range", name: "light", min: "0.5", max: "1.5", step: "0.01", value: "1")
14
+ # img(id: "sample", alt: "", style: "--light: 1; filter: brightness(var(--light, 1))")
15
+ # end
16
+ #
17
+ # The client reads each field the way reactive_show does (owned fields, a
18
+ # radio group's checked value, reactive_scope applied; a checkbox gives 1
19
+ # or 0), parses it as a finite number and writes
20
+ # style.setProperty(name, number + unit) on every `to:` element. Anything
21
+ # else (empty, text, NaN) removes the property, so the author's var()
22
+ # fallback applies. No string from a field ever reaches CSS: the wire
23
+ # carries names and units, never a value. It runs on connect, on the
24
+ # fields' input/change, and after a morph. First paint is the author's:
25
+ # render the same properties inline.
26
+ #
27
+ # `to:` resolves like a client op's: inside the root (nested roots
28
+ # excluded), `:root` for the root itself, document-wide with
29
+ # `global: true`; an Array names several. The attribute goes on the root
30
+ # or on any element inside it; `to:` is resolved from the root either way.
31
+ # Client-only: no token, no round trip, no broadcast.
32
+ module CssVarsBinding
33
+ # Prefixed: this module sits in every reactive component's ancestry, so
34
+ # a bare NAME or UNITS would shadow a host app's top-level constant.
35
+ CSS_VAR_NAME = /\A--[a-z0-9-]+\z/i
36
+ CSS_VAR_UNITS = ["", "px", "%", "deg", "turn", "ms", "s", "em", "rem", "fr"].freeze
37
+ private_constant :CSS_VAR_NAME, :CSS_VAR_UNITS
38
+
39
+ def reactive_css_vars(to:, vars:, global: false, **attrs)
40
+ wire = { "to" => css_vars_targets!(to), "vars" => css_vars_map!(vars) }
41
+ wire["global"] = true if global
42
+ mix({ data: { reactive_css_vars: wire.to_json } }, attrs)
43
+ end
44
+
45
+ private
46
+
47
+ def css_vars_targets!(to)
48
+ targets = Array(to)
49
+ if targets.empty? || targets.any? { !it.equal?(:root) && (!it.is_a?(String) || it.strip.empty?) }
50
+ raise ArgumentError, "reactive_css_vars to: takes :root, a CSS selector String (e.g. \"#sample\") " \
51
+ "or an Array of them, got #{to.inspect}"
52
+ end
53
+
54
+ targets.map { JS.normalize_target(it) }
55
+ end
56
+
57
+ def css_vars_map!(vars)
58
+ unless vars.is_a?(Hash) && !vars.empty?
59
+ raise ArgumentError, "reactive_css_vars vars: takes a non-empty Hash of custom property => field " \
60
+ "(e.g. vars: { \"--light\" => \"light\" }), got #{vars.inspect}"
61
+ end
62
+
63
+ vars.each_with_object({}) do |(name, spec), map|
64
+ name = css_var_name!(name)
65
+ raise ArgumentError, "reactive_css_vars vars: names #{name} twice" if map.key?(name)
66
+
67
+ map[name] = css_var_spec!(name, spec)
68
+ end
69
+ end
70
+
71
+ def css_var_name!(name)
72
+ string = name.to_s if name.is_a?(String) || name.is_a?(Symbol)
73
+ return string if string&.match?(CSS_VAR_NAME)
74
+
75
+ raise ArgumentError, "reactive_css_vars vars: #{name.inspect} is not a custom property name: it starts " \
76
+ "with -- and holds only letters, digits and dashes (e.g. \"--light\")"
77
+ end
78
+
79
+ def css_var_spec!(name, spec)
80
+ return { "field" => css_var_field!(name, spec) } unless spec.is_a?(Hash)
81
+
82
+ unknown = spec.keys - %i[field unit]
83
+ unless unknown.empty?
84
+ raise ArgumentError, "reactive_css_vars vars: #{name} takes only field: and unit:, got #{unknown.inspect}"
85
+ end
86
+
87
+ wire = { "field" => css_var_field!(name, spec.fetch(:field, nil)) }
88
+ unit = css_var_unit!(name, spec.fetch(:unit, ""))
89
+ wire["unit"] = unit unless unit.empty?
90
+ wire
91
+ end
92
+
93
+ # The client reads the field through a [name="…"] selector, which a
94
+ # double quote, backslash or control character would break (the
95
+ # verbatim_name_selector! rule).
96
+ def css_var_field!(name, field)
97
+ string = field.to_s if field.is_a?(String) || field.is_a?(Symbol)
98
+ if string&.match?(/["\\\x00-\x1f]/)
99
+ raise ArgumentError, "reactive_css_vars vars: #{name} field can't contain a double quote, backslash, " \
100
+ "or control character (it is read through a [name=\"…\"] selector), got #{field.inspect}"
101
+ end
102
+ return string unless string.nil? || string.strip.empty?
103
+
104
+ raise ArgumentError, "reactive_css_vars vars: #{name} needs the field name to read " \
105
+ "(e.g. \"#{name}\" => \"light\" or { field: \"light\", unit: \"px\" }), got #{field.inspect}"
106
+ end
107
+
108
+ def css_var_unit!(name, unit)
109
+ string = unit.to_s if unit.is_a?(String) || unit.is_a?(Symbol)
110
+ return string if CSS_VAR_UNITS.include?(string)
111
+
112
+ raise ArgumentError, "reactive_css_vars vars: #{name} unit: must be one of " \
113
+ "#{CSS_VAR_UNITS.map(&:inspect).join(", ")}, got #{unit.inspect}"
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ # DRAG_MESSAGES_JSON is built with Hash#to_json while this file loads.
4
+ require "json"
5
+
6
+ module Phlex
7
+ module Reactive
8
+ module Component
9
+ # Draggable regions (issue #416): a box that moves and resizes inside a
10
+ # frame, or a point that moves inside it, with the result in fields as
11
+ # fractions of the frame — 0..1 on each axis, so a resize, a responsive
12
+ # layout or a morph changes nothing about them.
13
+ #
14
+ # div(**mix(reactive_frame, class: "relative")) do
15
+ # img(src: @photo.url, alt: "", class: "block w-full")
16
+ # div(**reactive_box(fields: { x: "crop[x]", y: "crop[y]", width: "crop[width]", height: "crop[height]" },
17
+ # value: @crop.to_h, aspect: "crop[aspect]")) do
18
+ # %i[nw n ne e se s sw w].each { span(**reactive_box_handle(it)) }
19
+ # span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: @focus.to_h, within: "[data-reactive-box]"))
20
+ # end
21
+ # end
22
+ #
23
+ # The box, its handles and the point render their position as inline
24
+ # percentages, so the first paint is right with no JavaScript and a
25
+ # re-render with new values moves them. The frame's padding box is the
26
+ # coordinate space; the frame must be positioned (relative or absolute),
27
+ # which the helper leaves to the author. Style the look with classes, and
28
+ # add any inline style AFTER the helper's: mix(reactive_box(...), style: "…").
29
+ #
30
+ # The client (features/drag.js) drags them with a mouse, a pen or a
31
+ # finger, and the keyboard: arrows move 1% (Shift: 10%), Alt+arrows
32
+ # resize from the bottom-right corner or from the focused handle, Escape
33
+ # puts the drag back, each step announced in the words of `messages:`.
34
+ # The fields get `input` as the box moves and one `change` on release.
35
+ # `action:` is sent once on release through the root's pipeline; without
36
+ # it the binding is client-only and works on a ClientBindings root.
37
+ module Drag
38
+ # The announcement words, {x}, {y}, {width} and {height} filled in by
39
+ # the client as whole percents. Override any of them with messages:.
40
+ DRAG_MESSAGES = {
41
+ moved: "Moved to {x}%, {y}%",
42
+ resized: "Resized to {width}% by {height}%",
43
+ cancelled: "Cancelled"
44
+ }.freeze
45
+
46
+ # Where each handle sits on the box (left, top) and the cursor it shows.
47
+ DRAG_HANDLES = {
48
+ nw: ["0%", "0%", "nwse"], n: ["50%", "0%", "ns"], ne: ["100%", "0%", "nesw"], e: ["100%", "50%", "ew"],
49
+ se: ["100%", "100%", "nwse"], s: ["50%", "100%", "ns"], sw: ["0%", "100%", "nesw"], w: ["0%", "50%", "ew"]
50
+ }.freeze
51
+
52
+ BOX_KEYS = %i[x y width height].freeze
53
+ POINT_KEYS = %i[x y].freeze
54
+ # A ratio written as a String: "16:9", "1.5".
55
+ RATIO_STRING = /\A\s*\d*\.?\d+\s*(:\s*\d*\.?\d+\s*)?\z/
56
+ # A box may end at the frame's edge as the fields round it: x and width
57
+ # rounded apart to 4 decimals can sum to 1.0001 (0.45545 + 0.54455).
58
+ EDGE_SLACK = 1e-4 + 1e-9
59
+
60
+ # The coordinate space: its padding box is 0..1 on each axis.
61
+ def reactive_frame = { data: { reactive_frame: true } }
62
+
63
+ # A box that moves and resizes inside the frame.
64
+ #
65
+ # fields: { x:, y:, width:, height: } — the names of the fields that hold it
66
+ # value: { x:, y:, width:, height: } — where it is, fractions of the frame
67
+ # aspect: a Numeric ratio (16/9r), or the name of a field holding one
68
+ # ("16:9", "1.5", or "" for free), read at drag start
69
+ # min: the smallest width/height, a fraction of the frame
70
+ # action: sent once on release, with x/y/width/height params
71
+ # busy: on(busy:)'s vocabulary, applied while that action is pending
72
+ # messages: the live-region words, merged over DRAG_MESSAGES
73
+ def reactive_box(fields:, value:, aspect: nil, min: 0.05, action: nil, busy: nil, messages: nil)
74
+ fields = drag_fields!(:reactive_box, fields, BOX_KEYS)
75
+ value = drag_value!(:reactive_box, value, BOX_KEYS)
76
+ drag_inside_frame!(value)
77
+ data = { reactive_box: fields.to_json, reactive_box_min: drag_min!(min) }
78
+ drag_aspect!(aspect, data) unless aspect.nil?
79
+ drag_action!(:reactive_box, action, busy, data)
80
+ data[:reactive_box_messages] = drag_messages!(:reactive_box, messages)
81
+ style = "position: absolute; box-sizing: border-box; left: #{drag_percent(value[:x])}; " \
82
+ "top: #{drag_percent(value[:y])}; width: #{drag_percent(value[:width])}; " \
83
+ "height: #{drag_percent(value[:height])}; touch-action: none; cursor: move;"
84
+ { tabindex: 0, style:, data: }
85
+ end
86
+
87
+ # An edge or corner of the box (inside it) to resize from: nw n ne e se s sw w.
88
+ def reactive_box_handle(edge)
89
+ left, top, cursor = DRAG_HANDLES.fetch(edge.to_s.to_sym) do
90
+ raise ArgumentError,
91
+ "reactive_box_handle got an unknown edge #{edge.inspect} — one of " \
92
+ "#{DRAG_HANDLES.keys.map(&:inspect).join(", ")}"
93
+ end
94
+ { tabindex: -1,
95
+ style: "position: absolute; left: #{left}; top: #{top}; translate: -50% -50%; touch-action: none; " \
96
+ "cursor: #{cursor}-resize;",
97
+ data: { reactive_box_handle: edge.to_s } }
98
+ end
99
+
100
+ # A point (its centre) that moves inside the frame — or, with within:,
101
+ # inside the element it sits in that matches the selector (a box): its
102
+ # fractions are then of that element, and it moves with it.
103
+ def reactive_point(fields:, value:, within: nil, action: nil, busy: nil, messages: nil)
104
+ fields = drag_fields!(:reactive_point, fields, POINT_KEYS)
105
+ value = drag_value!(:reactive_point, value, POINT_KEYS)
106
+ data = { reactive_point: fields.to_json }
107
+ data[:reactive_point_within] = drag_within!(within) unless within.nil?
108
+ drag_action!(:reactive_point, action, busy, data)
109
+ data[:reactive_point_messages] = drag_messages!(:reactive_point, messages)
110
+ style = "position: absolute; left: #{drag_percent(value[:x])}; top: #{drag_percent(value[:y])}; " \
111
+ "translate: -50% -50%; touch-action: none; cursor: move;"
112
+ { tabindex: 0, style:, data: }
113
+ end
114
+
115
+ private
116
+
117
+ # A fraction as a percentage with at most 2 decimals, rounded as the
118
+ # client rounds (Math.round(v * 1e4) / 100): 0.1 -> "10%".
119
+ def drag_percent(fraction) = "#{((fraction * 10_000).round / 100.0).to_s.delete_suffix(".0")}%"
120
+
121
+ def drag_fields!(helper, fields, keys)
122
+ example = "fields: { #{keys.map { "#{it}: \"region[#{it}]\"" }.join(", ")} }"
123
+ raise ArgumentError, "#{helper} fields: must be a Hash, e.g. #{example}" unless fields.is_a?(Hash)
124
+
125
+ fields = fields.transform_keys { it.to_s.to_sym }
126
+ drag_keys!(helper, :fields, fields, keys, example)
127
+ fields.each do |key, name|
128
+ next if (name.is_a?(String) || name.is_a?(Symbol)) && !name.to_s.strip.empty?
129
+
130
+ raise ArgumentError, "#{helper} fields: #{key} needs a field name, got #{name.inspect}"
131
+ end
132
+ fields.slice(*keys).transform_values(&:to_s)
133
+ end
134
+
135
+ def drag_keys!(helper, option, hash, keys, example)
136
+ missing = keys - hash.keys
137
+ unless missing.empty?
138
+ raise ArgumentError,
139
+ "#{helper} #{option}: is missing #{missing.map(&:inspect).join(", ")} — e.g. #{example}"
140
+ end
141
+
142
+ unknown = hash.keys - keys
143
+ return if unknown.empty?
144
+
145
+ raise ArgumentError,
146
+ "#{helper} got an unknown #{option}: key #{unknown.first.inspect} — it takes #{keys.map(&:inspect).join(", ")}"
147
+ end
148
+
149
+ def drag_value!(helper, value, keys)
150
+ raise ArgumentError, "#{helper} value: must be a Hash of fractions, got #{value.inspect}" unless value.is_a?(Hash)
151
+
152
+ value = value.transform_keys { it.to_s.to_sym }
153
+ drag_keys!(helper, :value, value, keys, "value: { #{keys.map { "#{it}: 0.5" }.join(", ")} }")
154
+ value.each do |key, number|
155
+ next if number.is_a?(Numeric) && number.real? && number.between?(0, 1)
156
+
157
+ raise ArgumentError, "#{helper} value: #{key} must be a number from 0 to 1 (a fraction), got #{number.inspect}"
158
+ end
159
+ value
160
+ end
161
+
162
+ def drag_inside_frame!(value)
163
+ %i[width height].each do
164
+ raise ArgumentError, "reactive_box value: #{it} must be greater than 0" unless value[it].positive?
165
+ end
166
+ { x: :width, y: :height }.each do |at, size|
167
+ sum = value[at] + value[size]
168
+ next if sum <= 1 + EDGE_SLACK
169
+
170
+ raise ArgumentError,
171
+ "reactive_box value: #{at} + #{size} is #{sum.round(4)} — the box must stay inside the frame (at most 1)"
172
+ end
173
+ end
174
+
175
+ def drag_min!(min)
176
+ # (A Float: a Rational would render as "1/10", which the browser cannot read.)
177
+ return min.to_f if min.is_a?(Numeric) && min.real? && min.positive? && min <= 1
178
+
179
+ raise ArgumentError,
180
+ "reactive_box min: must be a fraction of the frame above 0 and at most 1 (e.g. 0.05), got #{min.inspect}"
181
+ end
182
+
183
+ def drag_aspect!(aspect, data)
184
+ if aspect.is_a?(Numeric) && aspect.real? && aspect.positive? && aspect.finite?
185
+ data[:reactive_box_ratio] = aspect.to_f
186
+ elsif aspect.is_a?(String) && aspect.match?(RATIO_STRING)
187
+ raise ArgumentError,
188
+ "reactive_box aspect: #{aspect.inspect} is a ratio, not a field name — pass 16/9r or 1.5 " \
189
+ "(a Numeric), or the name of a field whose value is \"16:9\""
190
+ elsif (aspect.is_a?(String) || aspect.is_a?(Symbol)) && !aspect.to_s.strip.empty?
191
+ data[:reactive_box_aspect] = aspect.to_s
192
+ else
193
+ raise ArgumentError,
194
+ "reactive_box aspect: must be a positive Numeric (16/9r) or the name of a field holding the ratio, " \
195
+ "got #{aspect.inspect}"
196
+ end
197
+ end
198
+
199
+ def drag_within!(within)
200
+ return within if within.is_a?(String) && !within.strip.empty?
201
+
202
+ raise ArgumentError,
203
+ "reactive_point within: needs a CSS selector matching the element the point sits inside, " \
204
+ "e.g. \"[data-reactive-box]\", got #{within.inspect}"
205
+ end
206
+
207
+ def drag_action!(helper, action, busy, data)
208
+ if action.nil?
209
+ raise ArgumentError, "#{helper} busy: needs an action: — it is applied while that action is pending" if busy
210
+
211
+ return
212
+ end
213
+
214
+ drag_requires_component!(helper)
215
+ name = drag_action_name!(helper, action)
216
+ data[:"#{helper}_action"] = name
217
+ data[:"#{helper}_busy"] = pending_hint_json(busy, name, :busy) if busy
218
+ end
219
+
220
+ def drag_requires_component!(helper)
221
+ return if self.class.include?(Phlex::Reactive::Component::Identity)
222
+
223
+ raise ArgumentError,
224
+ "#{helper} action: needs the full Phlex::Reactive::Component — a release calls a server action, " \
225
+ "which Phlex::Reactive::ClientBindings has no token for. Without action: the fields alone hold it."
226
+ end
227
+
228
+ # The on() courtesy (issue #105): an undeclared action fails at render
229
+ # under verbose_errors, not as a 403 on release.
230
+ def drag_action_name!(helper, action)
231
+ if action.to_s.strip.empty?
232
+ raise ArgumentError, "#{helper} action: needs the name of a declared action, got #{action.inspect}"
233
+ end
234
+
235
+ if Phlex::Reactive.verbose_errors &&
236
+ (actions = self.class.reactive_actions).any? && !actions.key?(action.to_sym)
237
+ raise Phlex::Reactive::Error,
238
+ "#{self.class} has no declared action #{action.to_sym.inspect} (declared: #{actions.keys.inspect})"
239
+ end
240
+
241
+ action.to_s
242
+ end
243
+
244
+ def drag_messages!(helper, messages)
245
+ return DRAG_MESSAGES_JSON if messages.nil?
246
+ raise ArgumentError, "#{helper} messages: must be a Hash, got #{messages.inspect}" unless messages.is_a?(Hash)
247
+
248
+ unknown = messages.keys.map { it.to_s.to_sym } - DRAG_MESSAGES.keys
249
+ unless unknown.empty?
250
+ raise ArgumentError,
251
+ "#{helper} got an unknown messages: key #{unknown.first.inspect} — " \
252
+ "supported: #{DRAG_MESSAGES.keys.map(&:inspect).join(", ")}"
253
+ end
254
+
255
+ messages.each do |name, words|
256
+ unless words.is_a?(String)
257
+ raise ArgumentError,
258
+ "#{helper} messages: #{name} must be a String, got #{words.inspect}"
259
+ end
260
+ end
261
+
262
+ DRAG_MESSAGES.merge(messages.transform_keys { it.to_s.to_sym }).to_json
263
+ end
264
+
265
+ DRAG_MESSAGES_JSON = DRAG_MESSAGES.to_json
266
+ private_constant :DRAG_MESSAGES_JSON, :DRAG_HANDLES, :BOX_KEYS, :POINT_KEYS, :RATIO_STRING, :EDGE_SLACK
267
+ end
268
+ end
269
+ end
270
+ end
@@ -311,6 +311,9 @@ module Phlex
311
311
 
312
312
  # Issue #184: params: :symbol resolves a registered named schema.
313
313
  params = Phlex::Reactive.param_schema(params) if params.is_a?(Symbol)
314
+ # A deep-frozen copy, shared with the compiled schema below: the
315
+ # definition stays readable from a non-main Ractor once warmed.
316
+ params = Phlex::Reactive::Shareable.share(params)
314
317
  # If a scope is already declared, reject a schema nested under the scope
315
318
  # key here (the scope-first declaration order). The action-first order is
316
319
  # caught in reactive_scope.
@@ -14,10 +14,12 @@ module Phlex
14
14
  extend ActiveSupport::Concern
15
15
  include Selection
16
16
  include Sortable
17
+ include Drag
17
18
  include Editable
18
19
  include MediaBinding
19
20
  include ImageFallbackBinding
20
21
  include CountdownBinding
22
+ include CssVarsBinding
21
23
  include FieldReferences
22
24
  include ShowOptions
23
25
 
@@ -42,8 +44,10 @@ module Phlex
42
44
  # nothing was held, and outside an action. Filtered to the action's
43
45
  # declared params; a hint about what the user saw, never authorization.
44
46
  #
47
+ # action :save, params: { title: :string } # only declared fields are reported
48
+ #
45
49
  # def save(title: nil)
46
- # return conflict!(title) if reactive_held_fields.include?("title") && title != @note.title
50
+ # return reply.morph.refused("title") if reactive_held_fields.include?("title") && title != @note.title
47
51
  # @note.update!(title:)
48
52
  # end
49
53
  def reactive_held_fields