phlex-reactive 0.15.5 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +344 -142
  3. data/README.md +100 -3
  4. data/app/controllers/phlex/reactive/actions_controller.rb +10 -0
  5. data/app/controllers/phlex/reactive/fragments_controller.rb +4 -3
  6. data/app/javascript/phlex/reactive/core.js +1 -0
  7. data/app/javascript/phlex/reactive/core.min.js +4 -4
  8. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  9. data/app/javascript/phlex/reactive/features/bindings.js +81 -4
  10. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  11. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  12. data/app/javascript/phlex/reactive/features/drag.js +522 -0
  13. data/app/javascript/phlex/reactive/features/drag.min.js +4 -0
  14. data/app/javascript/phlex/reactive/features/drag.min.js.map +10 -0
  15. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  16. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  17. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +7 -6
  18. data/app/javascript/phlex/reactive/runtime.js +57 -23
  19. data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +2 -1
  20. data/lib/phlex/reactive/apm/subscriber.rb +5 -0
  21. data/lib/phlex/reactive/apm.rb +5 -6
  22. data/lib/phlex/reactive/authorization.rb +31 -5
  23. data/lib/phlex/reactive/client_bindings.rb +19 -0
  24. data/lib/phlex/reactive/component/css_vars_binding.rb +118 -0
  25. data/lib/phlex/reactive/component/drag.rb +270 -0
  26. data/lib/phlex/reactive/component/dsl.rb +15 -1
  27. data/lib/phlex/reactive/component/helpers.rb +9 -1
  28. data/lib/phlex/reactive/component/identity.rb +17 -6
  29. data/lib/phlex/reactive/component/lazy.rb +4 -0
  30. data/lib/phlex/reactive/component/presence_declaration.rb +4 -0
  31. data/lib/phlex/reactive/component/registry.rb +76 -6
  32. data/lib/phlex/reactive/component/sortable.rb +3 -0
  33. data/lib/phlex/reactive/component.rb +4 -0
  34. data/lib/phlex/reactive/defer.rb +4 -4
  35. data/lib/phlex/reactive/doctor/ractor_readiness.rb +65 -0
  36. data/lib/phlex/reactive/doctor.rb +2 -0
  37. data/lib/phlex/reactive/engine.rb +31 -4
  38. data/lib/phlex/reactive/fragment.rb +5 -4
  39. data/lib/phlex/reactive/held_fields.rb +21 -1
  40. data/lib/phlex/reactive/param_schema.rb +3 -2
  41. data/lib/phlex/reactive/pending/markup.rb +27 -9
  42. data/lib/phlex/reactive/pending.rb +4 -4
  43. data/lib/phlex/reactive/response.rb +55 -4
  44. data/lib/phlex/reactive/shareable.rb +87 -0
  45. data/lib/phlex/reactive/store.rb +73 -0
  46. data/lib/phlex/reactive/streamable.rb +49 -19
  47. data/lib/phlex/reactive/test_helpers.rb +6 -0
  48. data/lib/phlex/reactive/version.rb +1 -1
  49. data/lib/phlex/reactive.rb +293 -122
  50. data/sig/phlex/reactive/component.rbs +108 -0
  51. data/sig/phlex/reactive/param_schema.rbs +23 -0
  52. data/sig/phlex/reactive/response.rbs +73 -0
  53. data/sig/phlex/reactive/streamable.rbs +62 -0
  54. data/sig/phlex/reactive.rbs +231 -0
  55. metadata +14 -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.
@@ -9,7 +9,8 @@
9
9
  # Phlex::Reactive.base_controller_name = "ApplicationController"
10
10
 
11
11
  # Render components with your app's view context (helpers, Current, etc.).
12
- # Phlex::Reactive.renderer = ApplicationController
12
+ # By name, so it follows code reloading in development.
13
+ # Phlex::Reactive.renderer = "ApplicationController"
13
14
 
14
15
  # Render your authorization library's error as 403 from a reactive action.
15
16
  # Phlex::Reactive.authorization_errors = [Pundit::NotAuthorizedError]
@@ -19,6 +19,11 @@ module Phlex
19
19
  # adapter can subscribe to them itself) — the action IS the transaction.
20
20
  EVENTS = /\A(?:action|defer)\.phlex_reactive\z/
21
21
 
22
+ # The one subscription and its adapter, class ivars on purpose and
23
+ # safe: install/uninstall run at boot, from the engine's
24
+ # after_initialize on the main Ractor (APM.attach!), and between
25
+ # examples in a test suite; the request path never touches them.
26
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
22
27
  class << self
23
28
  # Install a single bus subscription for `adapter`. Idempotent for the
24
29
  # SAME adapter: a repeat call is a no-op. A DIFFERENT adapter replaces
@@ -41,7 +41,7 @@ module Phlex
41
41
  # Memoize the instance PER SYMBOL so repeated detect/attach! calls return
42
42
  # the SAME object — Subscriber.install keys idempotency on adapter
43
43
  # identity (equal?), so a fresh klass.new each time would double-subscribe.
44
- return (built_in_instances[apm] ||= klass.new) if klass.available?
44
+ return built_in_instances.fetch(apm) { klass.new } if klass.available?
45
45
 
46
46
  warn_and_nil("apm = #{apm.inspect} set but #{apm} is not loaded — no-op. " \
47
47
  "Require the SDK (or remove the setting).")
@@ -68,17 +68,16 @@ module Phlex
68
68
  def reset!
69
69
  Subscriber.uninstall
70
70
  Phlex::Reactive.resolved_apm_adapter = nil
71
- @built_in_instances = nil
71
+ built_in_instances.clear
72
72
  end
73
73
 
74
74
  private
75
75
 
76
76
  # Symbol => memoized built-in adapter instance. One instance per flavour for
77
77
  # the life of the process (cleared only by reset! in tests), so detect is
78
- # identity-stable and attach! stays idempotent.
79
- def built_in_instances
80
- @built_in_instances ||= {}
81
- end
78
+ # identity-stable and attach! stays idempotent. Per-Ractor state
79
+ # (Phlex::Reactive::Store), not a module ivar (zoolutions/phlex-reactive#364).
80
+ def built_in_instances = Phlex::Reactive::Store.table(:phlex_reactive_apm_adapters)
82
81
 
83
82
  def warn_and_nil(message)
84
83
  logger = Phlex::Reactive.send(:default_logger)
@@ -25,6 +25,10 @@ module Phlex
25
25
  MODULE_PREPEND = ::Module.instance_method(:prepend)
26
26
  private_constant :MODULE_PREPEND
27
27
 
28
+ # The per-Ractor lock (Phlex::Reactive::Store) the lazy instrument! path
29
+ # holds: one per Ractor, never shared across Ractors.
30
+ INSTRUMENT_LOCK_KEY = :phlex_reactive_authorization_instrument_lock
31
+
28
32
  class << self
29
33
  # Open a fresh tracking window, run the block, and restore the previous
30
34
  # cell — exactly the with_connection_id save/restore shape, so nesting is
@@ -73,14 +77,36 @@ module Phlex
73
77
  # a Zeitwerk reload's fresh class re-instruments naturally and a repeated
74
78
  # call in one process is a no-op. Re-reads authorization_methods each first
75
79
  # call, so an initializer that widened the list is honored.
80
+ #
81
+ # When the app eager-loads, the engine instruments every component class
82
+ # at boot (instrument_all!, zoolutions/phlex-reactive#364), so the
83
+ # endpoint's call is a bare "already instrumented" read and a request
84
+ # never mutates a class. The lazy path (development, or a class defined
85
+ # after boot) holds a lock across the check and the prepend, so two
86
+ # threads first-calling it for one class prepend ONE interceptor.
76
87
  def instrument!(component_class)
77
- return if component_class.instance_variable_get(:@reactive_authorization_instrumented)
88
+ return if instrumented?(component_class)
78
89
 
79
- names = Phlex::Reactive.authorization_methods.select do
80
- component_class.method_defined?(it) || component_class.private_method_defined?(it)
90
+ Phlex::Reactive::Store.fetch(INSTRUMENT_LOCK_KEY) { Mutex.new }.synchronize do
91
+ next if instrumented?(component_class)
92
+
93
+ names = Phlex::Reactive.authorization_methods.select do
94
+ component_class.method_defined?(it) || component_class.private_method_defined?(it)
95
+ end
96
+ MODULE_PREPEND.bind_call(component_class, interceptor_for(names)) if names.any?
97
+ component_class.instance_variable_set(:@reactive_authorization_instrumented, true)
81
98
  end
82
- MODULE_PREPEND.bind_call(component_class, interceptor_for(names)) if names.any?
83
- component_class.instance_variable_set(:@reactive_authorization_instrumented, true)
99
+ end
100
+
101
+ # Instrument every reactive component class among `classes` (the
102
+ # engine passes Streamable.registered_classes at boot, after eager
103
+ # loading). Plain Streamable classes have no actions to guard.
104
+ def instrument_all!(classes)
105
+ classes.each { instrument!(it) if it.include?(Phlex::Reactive::Component) }
106
+ end
107
+
108
+ def instrumented?(component_class)
109
+ component_class.instance_variable_get(:@reactive_authorization_instrumented) ? true : false
84
110
  end
85
111
 
86
112
  private
@@ -52,6 +52,25 @@ module Phlex
52
52
 
53
53
  include Component::DSL
54
54
  include Component::Helpers
55
+
56
+ # Tracked so the engine warms every class with the DSL at boot, a
57
+ # client-only one too (zoolutions/phlex-reactive#364). A subclass
58
+ # inherits the module rather than including it, so `included` never
59
+ # fires for it.
60
+ included do
61
+ Phlex::Reactive::Component::Registry.track(self)
62
+ end
63
+
64
+ class_methods do
65
+ def inherited(subclass)
66
+ super
67
+ Phlex::Reactive::Component::Registry.track(subclass)
68
+ end
69
+ end
70
+
71
+ # ActiveSupport::Concern's bookkeeping on this module, sealed now that
72
+ # the body has loaded (zoolutions/phlex-reactive#364).
73
+ Phlex::Reactive::Shareable.seal_concern(self)
55
74
  end
56
75
  end
57
76
  end
@@ -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