phlex-reactive 0.13.3 → 0.14.0
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 +1097 -630
- data/README.md +638 -16
- data/app/controllers/phlex/reactive/actions_controller.rb +85 -16
- data/app/controllers/phlex/reactive/fragments_controller.rb +310 -0
- data/app/javascript/phlex/reactive/core.js +181 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -0
- data/app/javascript/phlex/reactive/core.min.js.map +11 -0
- data/app/javascript/phlex/reactive/early.js +175 -0
- data/app/javascript/phlex/reactive/early.min.js +4 -0
- data/app/javascript/phlex/reactive/early.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/bindings.js +1511 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +4 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/compute.js +541 -0
- data/app/javascript/phlex/reactive/features/compute.min.js +4 -0
- data/app/javascript/phlex/reactive/features/compute.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/defer.js +726 -0
- data/app/javascript/phlex/reactive/features/defer.min.js +4 -0
- data/app/javascript/phlex/reactive/features/defer.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/devtools.js +242 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js +4 -0
- data/app/javascript/phlex/reactive/features/devtools.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/effects.js +362 -0
- data/app/javascript/phlex/reactive/features/effects.min.js +4 -0
- data/app/javascript/phlex/reactive/features/effects.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +216 -0
- data/app/javascript/phlex/reactive/features/form.min.js +4 -0
- data/app/javascript/phlex/reactive/features/form.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/hints.js +193 -0
- data/app/javascript/phlex/reactive/features/hints.min.js +4 -0
- data/app/javascript/phlex/reactive/features/hints.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/persist.js +663 -0
- data/app/javascript/phlex/reactive/features/persist.min.js +4 -0
- data/app/javascript/phlex/reactive/features/persist.min.js.map +10 -0
- data/app/javascript/phlex/reactive/inspect.js +21 -9
- data/app/javascript/phlex/reactive/inspect.min.js +2 -2
- data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +85 -5580
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +13 -4
- data/app/javascript/phlex/reactive/runtime.js +3690 -0
- data/lib/generators/phlex/reactive/install/templates/phlex_reactive.rb.erb +18 -0
- data/lib/phlex/reactive/claude/skills/phlex-reactive-debugging/SKILL.md +5 -3
- data/lib/phlex/reactive/component/dsl.rb +74 -2
- data/lib/phlex/reactive/component/helpers.rb +104 -28
- data/lib/phlex/reactive/component/lazy.rb +138 -2
- data/lib/phlex/reactive/component/registry.rb +2 -0
- data/lib/phlex/reactive/component/selection.rb +71 -0
- data/lib/phlex/reactive/doctor/fiber_isolation.rb +86 -0
- data/lib/phlex/reactive/doctor.rb +166 -8
- data/lib/phlex/reactive/dormant.rb +66 -0
- data/lib/phlex/reactive/engine.rb +64 -3
- data/lib/phlex/reactive/fragment.rb +233 -0
- data/lib/phlex/reactive/inspector/report.rb +24 -3
- data/lib/phlex/reactive/inspector.rb +23 -2
- data/lib/phlex/reactive/js.rb +61 -12
- data/lib/phlex/reactive/mcp/tools/actions_tool.rb +3 -1
- data/lib/phlex/reactive/mcp/tools/find_tool.rb +4 -1
- data/lib/phlex/reactive/param_schema.rb +68 -54
- data/lib/phlex/reactive/pending/markup.rb +128 -0
- data/lib/phlex/reactive/pending.rb +25 -21
- data/lib/phlex/reactive/show_conditions.rb +84 -56
- data/lib/phlex/reactive/streamable.rb +20 -2
- data/lib/phlex/reactive/test_helpers/system/reactive_requests_matcher.rb +120 -0
- data/lib/phlex/reactive/test_helpers/system.rb +62 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/lib/phlex/reactive.rb +84 -0
- data/lib/tasks/phlex_reactive.rake +6 -2
- metadata +39 -1
|
@@ -117,6 +117,24 @@
|
|
|
117
117
|
# <meta name="phlex-reactive-defer-path" content="<%%= Phlex::Reactive.defer_path %>">
|
|
118
118
|
# Phlex::Reactive.defer_path = "/_r/defer"
|
|
119
119
|
|
|
120
|
+
# Cacheable lazy fragments (reactive_lazy cache: { max_age: … }): the GET
|
|
121
|
+
# endpoint's path prefix (default "/reactive/fragment") and the longest max-age
|
|
122
|
+
# a component may be answered with (default 1 hour — within it the browser does
|
|
123
|
+
# not ask the server, so it also bounds how long a revoked permission can keep
|
|
124
|
+
# showing a cached fragment). If you change the path, expose it to the client
|
|
125
|
+
# in the layout <head> (the client refuses fragment URLs outside the path it
|
|
126
|
+
# knows; phlex_reactive:doctor checks for the tag):
|
|
127
|
+
# <meta name="phlex-reactive-fragment-path" content="<%%= Phlex::Reactive.fragment_path %>">
|
|
128
|
+
# Phlex::Reactive.fragment_path = "/_r/fragment"
|
|
129
|
+
# Phlex::Reactive.fragment_cache_max_age_limit = 10.minutes
|
|
130
|
+
|
|
131
|
+
# Early triggers (issue #273): with `import "phlex/reactive/early"` in your
|
|
132
|
+
# entry point, a trigger that fires before a lazily loaded controller connects
|
|
133
|
+
# is replayed on connect — unless it is older than this (ms, default 10s).
|
|
134
|
+
# Render it into your layout head for the client to read:
|
|
135
|
+
# <meta name="phlex-reactive-early-ttl" content="<%%= Phlex::Reactive.early_event_ttl_ms %>">
|
|
136
|
+
# Phlex::Reactive.early_event_ttl_ms = 10_000
|
|
137
|
+
|
|
120
138
|
# Client request timeout (default 30s). There is NO server-side setting — a hung
|
|
121
139
|
# request aborts client-side after this window (reactive:error kind "timeout"),
|
|
122
140
|
# so the per-component queue never wedges. Override in your layout head:
|
|
@@ -33,13 +33,15 @@ Run this FIRST — most "nothing happens" reports are a red ✗ here.
|
|
|
33
33
|
```bash
|
|
34
34
|
bin/rails phlex_reactive:actions # every component × action: params, file:line, auth
|
|
35
35
|
bin/rails phlex_reactive:actions FORMAT=json
|
|
36
|
+
bin/rails phlex_reactive:actions UNVERIFIED=1 # only the unverified rows (the review queue)
|
|
36
37
|
bin/rails "phlex_reactive:find[counter]" # fuzzy-find one; prints each action's method source
|
|
37
38
|
```
|
|
38
39
|
|
|
39
40
|
Answers "what reactive actions exist, where are they defined, and is each
|
|
40
|
-
authorized?" without grepping. The `auth` column is a heuristic
|
|
41
|
-
an authorization call was detected in the body; `
|
|
42
|
-
|
|
41
|
+
authorized?" without grepping. The `auth` column is a heuristic: `authorized*` =
|
|
42
|
+
an authorization call was detected in the body; `skipped` / `skipped (class)` =
|
|
43
|
+
the author opted out with `skip_verify_authorized` (for that action / for the
|
|
44
|
+
whole class); `unverified` = neither — a helper may still authorize indirectly.
|
|
43
45
|
|
|
44
46
|
## 3. Scan the live page (browser inspector)
|
|
45
47
|
|
|
@@ -213,8 +213,29 @@ module Phlex
|
|
|
213
213
|
# root's element for content that can't legally hold a <div> — a
|
|
214
214
|
# `reactive_lazy tag: :tr` component rooting at <tr> inside <tbody>
|
|
215
215
|
# ships a <tr> shell, not a <div> the HTML parser would hoist away.
|
|
216
|
-
|
|
217
|
-
|
|
216
|
+
#
|
|
217
|
+
# `on:` (issue #276) defers the REQUEST itself, not just server time:
|
|
218
|
+
# reactive_lazy on: "panel:opened" # a DOM event on (or bubbling into) the shell
|
|
219
|
+
# reactive_lazy on: :visible # the shell first scrolls into view
|
|
220
|
+
# reactive_lazy on: { visible: "200px" } # …with an IntersectionObserver rootMargin
|
|
221
|
+
# The shell then carries the identity token (no TTL) and materializes
|
|
222
|
+
# once through the action endpoint. See Component::Lazy.
|
|
223
|
+
#
|
|
224
|
+
# `cache:` (issue #277) makes the real render a privately cacheable GET:
|
|
225
|
+
# reactive_lazy cache: { max_age: 10.minutes } # combine with on:/tag: freely
|
|
226
|
+
# The shell carries a stable signed URL instead of a per-render token,
|
|
227
|
+
# and the endpoint answers `Cache-Control: private, max-age=…`, so the
|
|
228
|
+
# browser reuses the fragment across page views. Define
|
|
229
|
+
# `reactive_cache_version` to bust the URL when the content changes,
|
|
230
|
+
# and `reactive_cache_viewer` (e.g. Current.user&.id) to key the URL
|
|
231
|
+
# on the viewer instead of `Vary: Cookie` — needed for reuse ACROSS
|
|
232
|
+
# page views when the session cookie changes on every response
|
|
233
|
+
# (Rails' cookie store does).
|
|
234
|
+
def reactive_lazy(tag: :div, on: nil, cache: nil)
|
|
235
|
+
declaration = { tag: tag.to_sym }
|
|
236
|
+
declaration[:trigger] = Lazy.normalize_trigger(on) unless on.nil?
|
|
237
|
+
declaration[:cache] = Lazy.normalize_cache(cache) unless cache.nil?
|
|
238
|
+
Registry.write_scalar(self, :lazy, declaration)
|
|
218
239
|
end
|
|
219
240
|
|
|
220
241
|
# The RAW resolved lazy declaration ({ tag: } or nil) — the reader the
|
|
@@ -238,6 +259,22 @@ module Phlex
|
|
|
238
259
|
value.is_a?(::Hash) ? value.fetch(:tag, :div) : :div
|
|
239
260
|
end
|
|
240
261
|
|
|
262
|
+
# The normalized `on:` trigger of a lazy component — `{ event: "x" }`
|
|
263
|
+
# or `{ visible: "<rootMargin>" }` — or nil (plain reactive_lazy, or
|
|
264
|
+
# not lazy at all). The endpoint's `__materialize` gate reads it.
|
|
265
|
+
def reactive_lazy_trigger
|
|
266
|
+
value = reactive_lazy_declaration
|
|
267
|
+
value[:trigger] if value.is_a?(::Hash)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# The normalized `cache:` of a lazy component — `{ max_age: <seconds> }`
|
|
271
|
+
# — or nil. The fragment endpoint's opt-in gate reads it: only a
|
|
272
|
+
# component that declared `cache:` is reachable over GET.
|
|
273
|
+
def reactive_lazy_cache
|
|
274
|
+
value = reactive_lazy_declaration
|
|
275
|
+
value[:cache] if value.is_a?(::Hash)
|
|
276
|
+
end
|
|
277
|
+
|
|
241
278
|
# Declare a client-invokable action with an optional param schema.
|
|
242
279
|
# action :increment
|
|
243
280
|
# action :rename, params: { title: :string }
|
|
@@ -258,6 +295,13 @@ module Phlex
|
|
|
258
295
|
# ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
|
|
259
296
|
def action(name, params: {})
|
|
260
297
|
require_server_actions!(:action)
|
|
298
|
+
# Issue #276: the endpoint routes `__materialize` to the lazy render
|
|
299
|
+
# before the action registry is consulted — a declared one would be
|
|
300
|
+
# dead code that looks invokable.
|
|
301
|
+
if name.to_sym == Lazy::MATERIALIZE_ACTION
|
|
302
|
+
raise ArgumentError, "action #{name.inspect} is reserved for reactive_lazy(on:)"
|
|
303
|
+
end
|
|
304
|
+
|
|
261
305
|
# Issue #184: params: :symbol resolves a registered named schema.
|
|
262
306
|
params = Phlex::Reactive.param_schema(params) if params.is_a?(Symbol)
|
|
263
307
|
# If a scope is already declared, reject a schema nested under the scope
|
|
@@ -293,6 +337,34 @@ module Phlex
|
|
|
293
337
|
"use reactive_actions.key?(#{name.inspect})"
|
|
294
338
|
end
|
|
295
339
|
|
|
340
|
+
# Dormant root (issue #274): every root of this component renders
|
|
341
|
+
# data-reactive-dormant="reactive" instead of data-controller=
|
|
342
|
+
# "reactive", so the client is neither mounted nor (when loaded
|
|
343
|
+
# lazily) fetched until one of the root's triggers fires — for a root
|
|
344
|
+
# that only matters after a gesture (a closed dialog, a menu that
|
|
345
|
+
# loads on open). The app must import "phlex/reactive/early", which
|
|
346
|
+
# wakes the root. Inherited; `reactive_dormant false` turns it off
|
|
347
|
+
# again in a subclass, and reactive_root(dormant:) overrides per
|
|
348
|
+
# render. See Phlex::Reactive::Dormant.
|
|
349
|
+
#
|
|
350
|
+
# Not with reactive_lazy(on: :visible): that shell has no trigger to
|
|
351
|
+
# wake on (refused here, or at render for the other declaration order).
|
|
352
|
+
# A plain reactive_lazy shell ignores it: it fetches on connect, so it
|
|
353
|
+
# always mounts. reactive_lazy(on: "event") shells do go dormant.
|
|
354
|
+
def reactive_dormant(dormant = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
355
|
+
unless [true, false].include?(dormant)
|
|
356
|
+
raise ArgumentError, "#{self}: reactive_dormant takes true or false, got #{dormant.inspect}"
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
Phlex::Reactive::Dormant.reject_visible_lazy!(self) if dormant
|
|
360
|
+
|
|
361
|
+
Registry.write_scalar(self, :dormant, dormant)
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def reactive_dormant?
|
|
365
|
+
Registry.resolve_scalar(self, :dormant, :reactive_dormant?) == true
|
|
366
|
+
end
|
|
367
|
+
|
|
296
368
|
# Opt out of the default-ON verify_authorized guard (issue #168).
|
|
297
369
|
# Bare skips the WHOLE component (a public counter, a client-only
|
|
298
370
|
# filter); with names skips just those actions:
|
|
@@ -12,6 +12,7 @@ module Phlex
|
|
|
12
12
|
# no signing (those live in DSL and Identity).
|
|
13
13
|
module Helpers
|
|
14
14
|
extend ActiveSupport::Concern
|
|
15
|
+
include Selection
|
|
15
16
|
|
|
16
17
|
# The acting client's SSE connection id during the current action (nil
|
|
17
18
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -72,8 +73,22 @@ module Phlex
|
|
|
72
73
|
# Root-element attributes: marks the element reactive and carries the
|
|
73
74
|
# signed identity token. Spread onto the root:
|
|
74
75
|
# div(id:, **reactive_attrs) { ... }
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
#
|
|
77
|
+
# `dormant: true` (issue #274; default: the class's reactive_dormant)
|
|
78
|
+
# renders data-reactive-dormant="reactive" IN PLACE of the controller
|
|
79
|
+
# attribute: nothing mounts until a trigger fires and phlex/reactive/early
|
|
80
|
+
# wakes the root. The actor's own reply renders awake (Dormant.awake).
|
|
81
|
+
def reactive_attrs(dormant: nil)
|
|
82
|
+
dormant = self.class.reactive_dormant? if dormant.nil? && self.class.respond_to?(:reactive_dormant?)
|
|
83
|
+
data =
|
|
84
|
+
if dormant && !Phlex::Reactive::Dormant.awake?
|
|
85
|
+
# The lazy SHELL (not the real render) of an on: :visible component
|
|
86
|
+
# can never wake — refuse it rather than ship a dead placeholder.
|
|
87
|
+
Phlex::Reactive::Dormant.reject_visible_lazy!(self.class) unless Phlex::Reactive::Defer.real_render?
|
|
88
|
+
{ reactive_dormant: "reactive" }
|
|
89
|
+
else
|
|
90
|
+
{ controller: "reactive" }
|
|
91
|
+
end
|
|
77
92
|
# A CLIENT-ONLY component (Phlex::Reactive::ClientBindings, issue #180)
|
|
78
93
|
# has no Identity, so no token — the root is tokenless (show/filter/
|
|
79
94
|
# compute need no signed round trip). reactive_token is private, so the
|
|
@@ -137,7 +152,12 @@ module Phlex
|
|
|
137
152
|
# fields, and — for warn_unsaved: true — the navigate-away marker (STRING
|
|
138
153
|
# "true", since a boolean-true attr renders valueless → "" → falsy client-
|
|
139
154
|
# side). The removed track_dirty:/warn_unsaved: kwargs raise a guided error.
|
|
140
|
-
|
|
155
|
+
#
|
|
156
|
+
# `dormant: true` (issue #274) is handed to reactive_attrs: the root mounts
|
|
157
|
+
# on its first trigger instead of on page load. On a reactive_lazy
|
|
158
|
+
# component declare `reactive_dormant` on the class instead: the lazy
|
|
159
|
+
# SHELL is rendered by the framework and reads only the class declaration.
|
|
160
|
+
def reactive_root(dormant: nil, **overrides)
|
|
141
161
|
# A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id —
|
|
142
162
|
# there's no token to self-match by id. Use an explicit override, else
|
|
143
163
|
# #id when the component defines one, else nothing (no id attr).
|
|
@@ -151,7 +171,7 @@ module Phlex
|
|
|
151
171
|
# Issue #184: dirty tracking is a class-level reactive_dirty declaration.
|
|
152
172
|
dirty = self.class.reactive_dirty_config if self.class.respond_to?(:reactive_dirty_config)
|
|
153
173
|
|
|
154
|
-
attrs = mix({ **reactive_attrs }, overrides)
|
|
174
|
+
attrs = mix({ **reactive_attrs(dormant:) }, overrides)
|
|
155
175
|
attrs = mix(attrs, { id: root_id }) unless root_id.nil?
|
|
156
176
|
# Root-level delegation tracks the whole subtree UNLESS only: scoped it to
|
|
157
177
|
# named fields (those carry their own descriptor via reactive_field).
|
|
@@ -225,6 +245,16 @@ module Phlex
|
|
|
225
245
|
"keydown.esc->reactive#listnavClose"
|
|
226
246
|
].freeze
|
|
227
247
|
|
|
248
|
+
# Roving-focus mode (issue #271): Enter/Space are the focused item's
|
|
249
|
+
# native activation and Escape is the caller's own on_client binding,
|
|
250
|
+
# so only the movement keys are bound.
|
|
251
|
+
LISTNAV_FOCUS_ACTIONS = [
|
|
252
|
+
"keydown.down->reactive#listnavNext",
|
|
253
|
+
"keydown.up->reactive#listnavPrev",
|
|
254
|
+
"keydown.home->reactive#listnavFirst",
|
|
255
|
+
"keydown.end->reactive#listnavLast"
|
|
256
|
+
].freeze
|
|
257
|
+
|
|
228
258
|
# Event modifiers (issue #80) — window:, once:, outside:, throttle: are
|
|
229
259
|
# RESERVED keyword names on on() (no longer usable as free action params):
|
|
230
260
|
#
|
|
@@ -376,6 +406,17 @@ module Phlex
|
|
|
376
406
|
# Window-bound triggers are never preventDefault-ed by the client and skip
|
|
377
407
|
# the forced type="button".
|
|
378
408
|
#
|
|
409
|
+
# Issue #271: each call emits ONE self-describing binding record —
|
|
410
|
+
# data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
|
|
411
|
+
# carrying its own descriptor, flags and confirm, so several bindings on
|
|
412
|
+
# one element compose through mix: the data-action descriptors token-join,
|
|
413
|
+
# the records space-join (every literal space inside a record is written
|
|
414
|
+
# as \u0020, so the join is unambiguous), and the client runs only the
|
|
415
|
+
# record whose descriptor matches the firing event:
|
|
416
|
+
# div(**mix(reactive_root,
|
|
417
|
+
# on_client(:click, js.hide("#menu"), outside: true),
|
|
418
|
+
# on_client("keydown.esc", js.hide("#menu"))))
|
|
419
|
+
#
|
|
379
420
|
# Ops are EPHEMERAL UI: any server re-render of the component (an action
|
|
380
421
|
# reply, a broadcast, a morph) rebuilds from server state and resets
|
|
381
422
|
# whatever they toggled — by design (the LiveView JS-commands caveat). Use
|
|
@@ -402,29 +443,21 @@ module Phlex
|
|
|
402
443
|
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
403
444
|
end
|
|
404
445
|
window_bound = window || outside
|
|
446
|
+
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
|
|
405
447
|
attrs = {
|
|
406
448
|
data: {
|
|
407
449
|
action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
|
|
408
|
-
|
|
450
|
+
# Space-free (issue #271): compact JSON has no structural
|
|
451
|
+
# whitespace, so every space is inside a string, where \u0020
|
|
452
|
+
# parses to the same string — and mix's space-join stays splittable.
|
|
453
|
+
reactive_ops_param: record.to_json.gsub(" ", '\u0020')
|
|
409
454
|
}
|
|
410
455
|
}
|
|
411
|
-
# STRING "true", not boolean — same Phlex valueless-attribute trap as
|
|
412
|
-
# on()'s flags above.
|
|
413
|
-
attrs[:data][:reactive_outside_param] = "true" if outside
|
|
414
|
-
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
415
456
|
# Issue #228: mark a clipboard-reading trigger so the client can gate
|
|
416
457
|
# its visibility on availability — on connect the controller sets
|
|
417
458
|
# hidden = !navigator.clipboard.readText. Render the trigger `hidden`
|
|
418
459
|
# for reveal-when-available (a dead paste button never shows).
|
|
419
460
|
attrs[:data][:reactive_clipboard] = "true" if ops.ops.any? { |name, _| name == "paste_into" }
|
|
420
|
-
# Issue #178: confirm: gates the client-op chain behind the SAME
|
|
421
|
-
# overridable confirmResolver on(:action, confirm:) uses (#52/#55). Emits
|
|
422
|
-
# the identical data-reactive-confirm-param; the client's runOps prompts
|
|
423
|
-
# via confirmResolver BEFORE applying the ops (a falsy resolve cancels
|
|
424
|
-
# the chain), so a destructive client op gets the themed dialog with no
|
|
425
|
-
# round trip. Issue #179: a Hash confirm: is CONDITIONAL — same shared
|
|
426
|
-
# apply_confirm! branches String vs Hash for both on and on_client.
|
|
427
|
-
apply_confirm!(attrs[:data], confirm) if confirm
|
|
428
461
|
attrs[:type] = "button" if event == "click" && !window_bound
|
|
429
462
|
attrs
|
|
430
463
|
end
|
|
@@ -678,13 +711,23 @@ module Phlex
|
|
|
678
711
|
# Enter picks the highlighted one by clicking its own reactive trigger
|
|
679
712
|
# (selection stays a signed action), Escape clears. Combine with other
|
|
680
713
|
# attrs via mix so a caller's data-action token-joins, not clobbers.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
714
|
+
#
|
|
715
|
+
# `focus: true` (issue #271) is ROVING-FOCUS mode for a menu container:
|
|
716
|
+
# Arrow Down/Up move real focus among the items (wrapping), Home/End jump
|
|
717
|
+
# to the first/last; no highlight attr is written. The item selector
|
|
718
|
+
# defaults to [role=menuitem]. Enter/Space stay native activation and
|
|
719
|
+
# Escape is yours to bind (on_client("keydown.esc", ...) composes via mix):
|
|
720
|
+
#
|
|
721
|
+
# ul(role: "menu", **mix(reactive_listnav(focus: true), data: { testid: "menu" }))
|
|
722
|
+
def reactive_listnav(option_selector = nil, focus: false)
|
|
723
|
+
option_selector ||= focus ? "[role=menuitem]" : "[role=option]"
|
|
724
|
+
data = {
|
|
725
|
+
action: (focus ? LISTNAV_FOCUS_ACTIONS : LISTNAV_ACTIONS).join(" "),
|
|
726
|
+
reactive_listnav_option_param: filter_selector!(:selector, option_selector)
|
|
687
727
|
}
|
|
728
|
+
# STRING "true" — Phlex renders a boolean true as a valueless attribute.
|
|
729
|
+
data[:reactive_listnav_focus_param] = "true" if focus
|
|
730
|
+
{ data: }
|
|
688
731
|
end
|
|
689
732
|
|
|
690
733
|
# Tag-chip input (issue #203) — the composed combobox/tags primitive.
|
|
@@ -1391,6 +1434,29 @@ module Phlex
|
|
|
1391
1434
|
# Shared by on and on_client so both paths speak the same three forms. The
|
|
1392
1435
|
# predicate is soft-validation UX, NOT authorization — a user can bypass it;
|
|
1393
1436
|
# the action still hits the endpoint's real authorize/default-deny.
|
|
1437
|
+
# One on_client binding record (issue #271). Flags and confirm live IN
|
|
1438
|
+
# the record — never as element-wide params, which a sibling binding
|
|
1439
|
+
# would inherit. Issue #178: confirm: gates the chain behind the SAME
|
|
1440
|
+
# overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
|
|
1441
|
+
# client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
|
|
1442
|
+
# CONDITIONAL, compiled by the shared compile_conditional_confirm.
|
|
1443
|
+
def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
|
|
1444
|
+
record = { "on" => event }
|
|
1445
|
+
record["window"] = true if window_bound
|
|
1446
|
+
record["outside"] = true if outside
|
|
1447
|
+
# A mix-ed regular sibling keeps calling runOps after Stimulus drops
|
|
1448
|
+
# this binding's :once listener — the client skips a spent once record.
|
|
1449
|
+
record["once"] = true if once
|
|
1450
|
+
case confirm
|
|
1451
|
+
when nil, false then nil
|
|
1452
|
+
when String then record["confirm"] = confirm
|
|
1453
|
+
when Hash then record["confirmWhen"] = compile_conditional_confirm(confirm)
|
|
1454
|
+
else apply_confirm!({}, confirm) # raises the shared guided error
|
|
1455
|
+
end
|
|
1456
|
+
record["ops"] = ops.ops
|
|
1457
|
+
record
|
|
1458
|
+
end
|
|
1459
|
+
|
|
1394
1460
|
def apply_confirm!(data, confirm)
|
|
1395
1461
|
case confirm
|
|
1396
1462
|
when String
|
|
@@ -1529,14 +1595,22 @@ module Phlex
|
|
|
1529
1595
|
def apply_first_paint_hidden(attrs, groups, values_override)
|
|
1530
1596
|
return attrs if attrs.key?(:hidden)
|
|
1531
1597
|
|
|
1598
|
+
visible = first_paint_match(groups, values_override)
|
|
1599
|
+
visible.nil? ? attrs : attrs.merge(hidden: !visible)
|
|
1600
|
+
end
|
|
1601
|
+
|
|
1602
|
+
# The server-side evaluation of a binding's conditions for first paint
|
|
1603
|
+
# (reactive_show's hidden:, reactive_enable's disabled:): true/false
|
|
1604
|
+
# when reactive_values (merged under values:) covers EVERY referenced
|
|
1605
|
+
# field, else nil — the client seeds it at connect.
|
|
1606
|
+
def first_paint_match(groups, values_override)
|
|
1532
1607
|
provided = show_values(values_override)
|
|
1533
|
-
return
|
|
1608
|
+
return nil if provided.nil?
|
|
1534
1609
|
|
|
1535
1610
|
referenced = Phlex::Reactive::ShowConditions.fields(groups)
|
|
1536
|
-
return
|
|
1611
|
+
return nil unless referenced.all? { provided.key?(it) }
|
|
1537
1612
|
|
|
1538
|
-
|
|
1539
|
-
attrs.merge(hidden: !visible)
|
|
1613
|
+
Phlex::Reactive::ShowConditions.match?(groups, provided)
|
|
1540
1614
|
end
|
|
1541
1615
|
|
|
1542
1616
|
# The { field => current-string-value } map the first-paint evaluator
|
|
@@ -1552,7 +1626,9 @@ module Phlex
|
|
|
1552
1626
|
merged = {}
|
|
1553
1627
|
merged.merge!(base) if base
|
|
1554
1628
|
merged.merge!(values_override) if values_override
|
|
1555
|
-
|
|
1629
|
+
# An Array (a checkbox group's checked values, issue #319) stays an
|
|
1630
|
+
# Array so a checked: term can count it.
|
|
1631
|
+
merged.to_h { |name, value| [name.to_s, value.is_a?(Array) ? value.map(&:to_s) : show_value_string(value)] }
|
|
1556
1632
|
end
|
|
1557
1633
|
|
|
1558
1634
|
# Stringify a reactive_values entry the way the client's #showFieldValue
|
|
@@ -14,9 +14,66 @@ module Phlex
|
|
|
14
14
|
# the defer endpoint/job, the class stream builders — runs inside
|
|
15
15
|
# Defer.with_real_render, so it renders the REAL template. Anything else
|
|
16
16
|
# would make an action on a lazy component cost two round trips.
|
|
17
|
+
#
|
|
18
|
+
# `reactive_lazy(on:)` (issue #276) defers the REQUEST, not just the
|
|
19
|
+
# server time: the shell carries the identity token (no TTL, unlike the
|
|
20
|
+
# defer token). An event shell binds a `__materialize` trigger `once` to
|
|
21
|
+
# its event; a visibility shell has no binding — the client's
|
|
22
|
+
# IntersectionObserver starts the same `__materialize` request itself.
|
|
23
|
+
# The action endpoint routes `__materialize` to the same real render the
|
|
24
|
+
# defer endpoint does. The real render never contains the shell or its
|
|
25
|
+
# trigger, so `once` cannot re-arm.
|
|
17
26
|
module Lazy
|
|
18
27
|
extend ActiveSupport::Concern
|
|
19
28
|
|
|
29
|
+
# The framework-owned act the on: shell dispatches. Reserved: `action`
|
|
30
|
+
# refuses it, and the endpoint answers it only for reactive_lazy(on:).
|
|
31
|
+
MATERIALIZE_ACTION = :__materialize
|
|
32
|
+
# A DOM event name usable in a Stimulus descriptor: no key filter (.),
|
|
33
|
+
# no target (@), no descriptor syntax (->, #) — `panel:opened` is fine.
|
|
34
|
+
EVENT_NAME = /\A[A-Za-z][\w:-]*\z/
|
|
35
|
+
# An IntersectionObserver rootMargin: one to four px/% lengths.
|
|
36
|
+
# Any CSS whitespace between lengths; normalized to single spaces.
|
|
37
|
+
ROOT_MARGIN = /\A\s*-?\d+(?:\.\d+)?(?:px|%)(?:\s+-?\d+(?:\.\d+)?(?:px|%)){0,3}\s*\z/
|
|
38
|
+
|
|
39
|
+
# `on:` → { event: "x" } | { visible: "<rootMargin>" }, or raise.
|
|
40
|
+
def self.normalize_trigger(on)
|
|
41
|
+
case on
|
|
42
|
+
when String
|
|
43
|
+
return { event: on } if on.match?(EVENT_NAME)
|
|
44
|
+
when :visible
|
|
45
|
+
return { visible: "0px" }
|
|
46
|
+
when ::Hash
|
|
47
|
+
margin = on[:visible]
|
|
48
|
+
if on.keys == [:visible] && margin.is_a?(String) && margin.match?(ROOT_MARGIN)
|
|
49
|
+
return { visible: margin.split.join(" ") }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
raise ArgumentError,
|
|
53
|
+
"reactive_lazy on: expects a DOM event name (\"panel:opened\"), :visible, or " \
|
|
54
|
+
"{ visible: \"200px\" } (a px/% rootMargin) — got #{on.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# `cache:` → { max_age: <seconds> }, or raise. Only a whole, positive
|
|
58
|
+
# number of seconds (an Integer or an ActiveSupport::Duration) is a
|
|
59
|
+
# lifetime; the endpoint caps it at fragment_cache_max_age_limit.
|
|
60
|
+
def self.normalize_cache(cache)
|
|
61
|
+
unless cache.is_a?(::Hash) && cache.keys == [:max_age]
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"reactive_lazy cache: expects { max_age: <seconds> } (e.g. { max_age: 10.minutes }) — got #{cache.inspect}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
max_age = cache[:max_age]
|
|
67
|
+
# Duration#to_i truncates (1.5.seconds → 1): only an exact one converts.
|
|
68
|
+
seconds = max_age.is_a?(ActiveSupport::Duration) && max_age.to_i == max_age ? max_age.to_i : max_age
|
|
69
|
+
unless seconds.is_a?(::Integer) && seconds.positive?
|
|
70
|
+
raise ArgumentError,
|
|
71
|
+
"reactive_lazy cache: max_age must be a positive whole number of seconds — got #{max_age.inspect}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
{ max_age: seconds }
|
|
75
|
+
end
|
|
76
|
+
|
|
20
77
|
private
|
|
21
78
|
|
|
22
79
|
# Phlex 2's render hook: the template runs inside the block. Overridden
|
|
@@ -26,18 +83,92 @@ module Phlex
|
|
|
26
83
|
def around_template(&)
|
|
27
84
|
klass = self.class
|
|
28
85
|
if klass.respond_to?(:reactive_lazy?) && klass.reactive_lazy? && !Phlex::Reactive::Defer.real_render?
|
|
29
|
-
render_defer_shell
|
|
86
|
+
(trigger = klass.reactive_lazy_trigger) ? render_trigger_shell(trigger) : render_defer_shell
|
|
30
87
|
else
|
|
31
88
|
super
|
|
32
89
|
end
|
|
33
90
|
end
|
|
34
91
|
|
|
92
|
+
# The on: shell (issue #276): the same placeholder contract as the
|
|
93
|
+
# defer shell (id, class, aria-busy, deferred_placeholder), but mounted
|
|
94
|
+
# like a reactive root — reactive_attrs' identity token. No defer token
|
|
95
|
+
# and no pending marker: the client's lazy probe gates on the token, so
|
|
96
|
+
# it skips this shell, and nothing is in flight until the trigger fires.
|
|
97
|
+
#
|
|
98
|
+
# The marker tells the client which shell this is, on connect AND after
|
|
99
|
+
# a Turbo morph re-shows it on a connected root (no Stimulus lifecycle):
|
|
100
|
+
# * data-reactive-lazy-on="<event>" — plus the once-bound Stimulus
|
|
101
|
+
# descriptor for `__materialize`, so the first event takes the
|
|
102
|
+
# ordinary action path. The client re-arms the event itself after a
|
|
103
|
+
# morph, because a spent `once` binding never fires again.
|
|
104
|
+
# * data-reactive-lazy-visible="<rootMargin>" — no descriptor at all;
|
|
105
|
+
# the client's IntersectionObserver materializes directly.
|
|
106
|
+
#
|
|
107
|
+
# A `cache:` component (issue #277) adds its fragment URL: the client
|
|
108
|
+
# then loads with a cacheable GET instead of the __materialize POST.
|
|
109
|
+
# Such a shell carries NO identity token (issue #306): every load, and
|
|
110
|
+
# the fallback for a URL the client refuses (a GET under the fragment
|
|
111
|
+
# path it knows), goes through the URL — a second signature of the same
|
|
112
|
+
# identity would only make the shell heavier than what it defers.
|
|
113
|
+
def render_trigger_shell(trigger)
|
|
114
|
+
src = fragment_src
|
|
115
|
+
root = reactive_attrs
|
|
116
|
+
root[:data].delete(:reactive_token_value) if src
|
|
117
|
+
attrs = mix(
|
|
118
|
+
{ id:, class: "reactive-defer-placeholder", aria: { busy: "true" } },
|
|
119
|
+
root,
|
|
120
|
+
{ data: trigger_shell_data(trigger) }
|
|
121
|
+
)
|
|
122
|
+
# Mixed in only when present: a non-cached shell allocates nothing extra.
|
|
123
|
+
attrs = mix(attrs, { data: { reactive_defer_src: src } }) if src
|
|
124
|
+
public_send(self.class.reactive_lazy_tag, **attrs) { render_deferred_placeholder_content }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The cacheable fragment URL (issue #277) for a `cache:` component, else
|
|
128
|
+
# nil (Phlex omits a nil attribute, so a non-cached shell is unchanged).
|
|
129
|
+
# Deterministic: the same identity (+ version) renders the same URL on
|
|
130
|
+
# every page view, which is what lets the browser's HTTP cache hit.
|
|
131
|
+
def fragment_src
|
|
132
|
+
return unless self.class.reactive_lazy_cache
|
|
133
|
+
|
|
134
|
+
version = respond_to?(:reactive_cache_version, true) ? send(:reactive_cache_version) : nil
|
|
135
|
+
Phlex::Reactive::Fragment.src(reactive_identity_payload, version:, viewer: fragment_viewer_param)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The `u` of the fragment URL: the keyed digest of reactive_cache_viewer
|
|
139
|
+
# when the component declares who its render is for AND that value names
|
|
140
|
+
# someone, else nil (the default, `Vary: Cookie` mode). The shell renders
|
|
141
|
+
# it; the endpoint recomputes it in the requesting session and compares
|
|
142
|
+
# (see FragmentsController).
|
|
143
|
+
def fragment_viewer_param
|
|
144
|
+
return unless respond_to?(:reactive_cache_viewer, true)
|
|
145
|
+
|
|
146
|
+
Phlex::Reactive::Fragment.viewer_param(send(:reactive_cache_viewer), owner: self.class)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def trigger_shell_data(trigger)
|
|
150
|
+
return { reactive_lazy_visible: trigger[:visible] } if trigger[:visible]
|
|
151
|
+
|
|
152
|
+
event = trigger[:event]
|
|
153
|
+
{
|
|
154
|
+
action: "#{event}->reactive#dispatch:once",
|
|
155
|
+
reactive_action_param: MATERIALIZE_ACTION.to_s,
|
|
156
|
+
reactive_params_param: Helpers::EMPTY_PARAMS_JSON,
|
|
157
|
+
reactive_lazy_on: event
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
35
161
|
# The shell: owns the component's id (the arrival replaces it by that
|
|
36
162
|
# id), mounts the generic controller, and carries the defer token as a
|
|
37
163
|
# ROOT attribute — the controller's connect() probes it and enters the
|
|
38
164
|
# same module-level fetch path a reply directive uses. Pending markers
|
|
39
165
|
# + the .reactive-defer-placeholder class are the CSS hooks.
|
|
166
|
+
#
|
|
167
|
+
# A `cache:` component (issue #277) carries its stable fragment URL in
|
|
168
|
+
# data-reactive-defer-src INSTEAD of the token: the client GETs it, and
|
|
169
|
+
# the browser's private cache can answer.
|
|
40
170
|
def render_defer_shell
|
|
171
|
+
src = fragment_src
|
|
41
172
|
public_send(
|
|
42
173
|
self.class.reactive_lazy_tag,
|
|
43
174
|
id:,
|
|
@@ -46,6 +177,10 @@ module Phlex
|
|
|
46
177
|
data: {
|
|
47
178
|
controller: "reactive",
|
|
48
179
|
reactive_defer_pending: "true",
|
|
180
|
+
# The same verbose stamp reactive_attrs writes (nil is omitted):
|
|
181
|
+
# the shell may be the page's only reactive root when its mount
|
|
182
|
+
# fetch runs, and the client's verbose gate reads it (issue #279).
|
|
183
|
+
reactive_verbose: (Phlex::Reactive.verbose_errors ? "true" : nil),
|
|
49
184
|
# UNBOUND (issue #165 security): a lazy shell renders during the
|
|
50
185
|
# page render — on a fresh visit the session doesn't exist yet, so
|
|
51
186
|
# the token can't be actor-bound (it would 400 at the endpoint,
|
|
@@ -53,7 +188,8 @@ module Phlex
|
|
|
53
188
|
# small leak surface); the TTL + `authorize!` are its bound. Only
|
|
54
189
|
# reply.defer tokens (in action responses that can transit proxies)
|
|
55
190
|
# are actor-bound.
|
|
56
|
-
reactive_defer_token: Phlex::Reactive.sign_defer(reactive_identity_payload, unbound: true)
|
|
191
|
+
reactive_defer_token: (Phlex::Reactive.sign_defer(reactive_identity_payload, unbound: true) unless src),
|
|
192
|
+
reactive_defer_src: src
|
|
57
193
|
}
|
|
58
194
|
) { render_deferred_placeholder_content }
|
|
59
195
|
end
|
|
@@ -59,6 +59,8 @@ module Phlex
|
|
|
59
59
|
dirty: :@reactive_own_dirty,
|
|
60
60
|
# Deferred reply segments — reactive_lazy (issue #165).
|
|
61
61
|
lazy: :@reactive_own_lazy,
|
|
62
|
+
# Mount on first use — reactive_dormant (issue #274).
|
|
63
|
+
dormant: :@reactive_own_dormant,
|
|
62
64
|
# Enter/exit/update effects — reactive_effects (issue #215).
|
|
63
65
|
effects: :@reactive_own_effects,
|
|
64
66
|
# verify_authorized opt-out (issue #168): a scalar bare flag (skip the
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Bulk-selection bindings (issue #319): a list with a row checkbox per
|
|
7
|
+
# item, a "select all" header, a "Delete (n)" count, and actions that stay
|
|
8
|
+
# disabled until something is ticked — with no per-list JavaScript.
|
|
9
|
+
#
|
|
10
|
+
# form(id: "bulk", action: "/posts/bulk", method: "post") do
|
|
11
|
+
# input(type: "checkbox", **reactive_select_all("ids[]")) # header
|
|
12
|
+
# @posts.each { input(type: "checkbox", name: "ids[]", value: it.id) }
|
|
13
|
+
# span(**reactive_count("ids[]")) { "0" } # ticked count
|
|
14
|
+
# button(**reactive_enable(if: { "ids[]" => { checked: 1.. } })) { "Delete" }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# The group is a checkbox NAME, resolved like any reactive_show field
|
|
18
|
+
# (owned boxes only — a nested reactive root's boxes are its own, #15;
|
|
19
|
+
# a bare name takes reactive_scope, a bracketed one like "ids[]" is used
|
|
20
|
+
# verbatim). The client re-syncs the header, the count and every
|
|
21
|
+
# binding when a box changes and when boxes are added or removed (a
|
|
22
|
+
# stream append, a morph, a removal). All client-only: no token, no
|
|
23
|
+
# round trip.
|
|
24
|
+
module Selection
|
|
25
|
+
# The sibling of reactive_show: the same if:/if_any:/unless: conditions
|
|
26
|
+
# (including the { checked: n } count term), but it flips the
|
|
27
|
+
# element's OWN `disabled` — a button, a fieldset — instead of
|
|
28
|
+
# `hidden`. First paint is computed from reactive_values exactly like
|
|
29
|
+
# reactive_show's `hidden:` (an explicit `disabled:` wins; `values:`
|
|
30
|
+
# merges over reactive_values).
|
|
31
|
+
def reactive_enable(**options)
|
|
32
|
+
conditions = options.slice(*Helpers::SHOW_CONDITION_KEYS)
|
|
33
|
+
values_override = options.delete(:values)
|
|
34
|
+
attrs = options.except(*Helpers::SHOW_CONDITION_KEYS)
|
|
35
|
+
|
|
36
|
+
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
37
|
+
result = mix({ data: { reactive_enable: { "any" => groups }.to_json } }, attrs)
|
|
38
|
+
return result if result.key?(:disabled)
|
|
39
|
+
|
|
40
|
+
match = first_paint_match(groups, values_override)
|
|
41
|
+
match.nil? ? result : result.merge(disabled: !match)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The header box of a checkbox group. Its `change` ticks or unticks
|
|
45
|
+
# every owned box of the group (dispatching `input` + `change` on each one it
|
|
46
|
+
# flips, so computes, shows and counts re-run); its own checked /
|
|
47
|
+
# indeterminate state follows the group. Give the header NO `name` —
|
|
48
|
+
# it would post, and count as a member.
|
|
49
|
+
def reactive_select_all(group, **attrs)
|
|
50
|
+
mix({ data: { reactive_select_all: selection_group!(:reactive_select_all, group) } }, attrs)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A text binding that shows how many owned boxes of the group are
|
|
54
|
+
# ticked (written via textContent, change-guarded). Seed the first
|
|
55
|
+
# paint in the element's own content: span(**reactive_count("ids[]")) { "0" }.
|
|
56
|
+
def reactive_count(group, **attrs)
|
|
57
|
+
mix({ data: { reactive_count: selection_group!(:reactive_count, group) } }, attrs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def selection_group!(helper, group)
|
|
63
|
+
name = group.to_s
|
|
64
|
+
return name unless name.strip.empty?
|
|
65
|
+
|
|
66
|
+
raise ArgumentError, "#{helper} needs a checkbox group name (e.g. \"ids[]\"), got #{group.inspect}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|