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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +344 -142
- data/README.md +100 -3
- data/app/controllers/phlex/reactive/actions_controller.rb +10 -0
- data/app/controllers/phlex/reactive/fragments_controller.rb +4 -3
- data/app/javascript/phlex/reactive/core.js +1 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -4
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/features/bindings.js +81 -4
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/drag.js +522 -0
- data/app/javascript/phlex/reactive/features/drag.min.js +4 -0
- data/app/javascript/phlex/reactive/features/drag.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +7 -6
- data/app/javascript/phlex/reactive/runtime.js +57 -23
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +2 -1
- data/lib/phlex/reactive/apm/subscriber.rb +5 -0
- data/lib/phlex/reactive/apm.rb +5 -6
- data/lib/phlex/reactive/authorization.rb +31 -5
- data/lib/phlex/reactive/client_bindings.rb +19 -0
- data/lib/phlex/reactive/component/css_vars_binding.rb +118 -0
- data/lib/phlex/reactive/component/drag.rb +270 -0
- data/lib/phlex/reactive/component/dsl.rb +15 -1
- data/lib/phlex/reactive/component/helpers.rb +9 -1
- data/lib/phlex/reactive/component/identity.rb +17 -6
- data/lib/phlex/reactive/component/lazy.rb +4 -0
- data/lib/phlex/reactive/component/presence_declaration.rb +4 -0
- data/lib/phlex/reactive/component/registry.rb +76 -6
- data/lib/phlex/reactive/component/sortable.rb +3 -0
- data/lib/phlex/reactive/component.rb +4 -0
- data/lib/phlex/reactive/defer.rb +4 -4
- data/lib/phlex/reactive/doctor/ractor_readiness.rb +65 -0
- data/lib/phlex/reactive/doctor.rb +2 -0
- data/lib/phlex/reactive/engine.rb +31 -4
- data/lib/phlex/reactive/fragment.rb +5 -4
- data/lib/phlex/reactive/held_fields.rb +21 -1
- data/lib/phlex/reactive/param_schema.rb +3 -2
- data/lib/phlex/reactive/pending/markup.rb +27 -9
- data/lib/phlex/reactive/pending.rb +4 -4
- data/lib/phlex/reactive/response.rb +55 -4
- data/lib/phlex/reactive/shareable.rb +87 -0
- data/lib/phlex/reactive/store.rb +73 -0
- data/lib/phlex/reactive/streamable.rb +49 -19
- data/lib/phlex/reactive/test_helpers.rb +6 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +293 -122
- data/sig/phlex/reactive/component.rbs +108 -0
- data/sig/phlex/reactive/param_schema.rbs +23 -0
- data/sig/phlex/reactive/response.rbs +73 -0
- data/sig/phlex/reactive/streamable.rbs +62 -0
- data/sig/phlex/reactive.rbs +231 -0
- 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
|
|
739
|
-
//
|
|
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
|
-
|
|
785
|
-
|
|
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
|
|
790
|
-
// each stream synchronously inside renderStreamMessage
|
|
791
|
-
// its render, innermost); a reply none of whose streams
|
|
792
|
-
// An error reply (
|
|
793
|
-
// server refused those values rather than answering them.
|
|
794
|
-
function renderOwnReply(root, html,
|
|
795
|
-
const own = { root, streams: 0,
|
|
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 (
|
|
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
|
-
|
|
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)
|
|
2064
|
-
// (#373)
|
|
2065
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
data/lib/phlex/reactive/apm.rb
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
88
|
+
return if instrumented?(component_class)
|
|
78
89
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|