phlex-reactive 0.13.3 → 0.13.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +47 -0
- data/README.md +78 -3
- 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 +259 -18
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/helpers.rb +65 -19
- data/lib/phlex/reactive/engine.rb +1 -0
- data/lib/phlex/reactive/js.rb +44 -10
- data/lib/phlex/reactive/pending/markup.rb +128 -0
- data/lib/phlex/reactive/pending.rb +25 -21
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +2 -1
|
@@ -225,6 +225,16 @@ module Phlex
|
|
|
225
225
|
"keydown.esc->reactive#listnavClose"
|
|
226
226
|
].freeze
|
|
227
227
|
|
|
228
|
+
# Roving-focus mode (issue #271): Enter/Space are the focused item's
|
|
229
|
+
# native activation and Escape is the caller's own on_client binding,
|
|
230
|
+
# so only the movement keys are bound.
|
|
231
|
+
LISTNAV_FOCUS_ACTIONS = [
|
|
232
|
+
"keydown.down->reactive#listnavNext",
|
|
233
|
+
"keydown.up->reactive#listnavPrev",
|
|
234
|
+
"keydown.home->reactive#listnavFirst",
|
|
235
|
+
"keydown.end->reactive#listnavLast"
|
|
236
|
+
].freeze
|
|
237
|
+
|
|
228
238
|
# Event modifiers (issue #80) — window:, once:, outside:, throttle: are
|
|
229
239
|
# RESERVED keyword names on on() (no longer usable as free action params):
|
|
230
240
|
#
|
|
@@ -376,6 +386,17 @@ module Phlex
|
|
|
376
386
|
# Window-bound triggers are never preventDefault-ed by the client and skip
|
|
377
387
|
# the forced type="button".
|
|
378
388
|
#
|
|
389
|
+
# Issue #271: each call emits ONE self-describing binding record —
|
|
390
|
+
# data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
|
|
391
|
+
# carrying its own descriptor, flags and confirm, so several bindings on
|
|
392
|
+
# one element compose through mix: the data-action descriptors token-join,
|
|
393
|
+
# the records space-join (every literal space inside a record is written
|
|
394
|
+
# as \u0020, so the join is unambiguous), and the client runs only the
|
|
395
|
+
# record whose descriptor matches the firing event:
|
|
396
|
+
# div(**mix(reactive_root,
|
|
397
|
+
# on_client(:click, js.hide("#menu"), outside: true),
|
|
398
|
+
# on_client("keydown.esc", js.hide("#menu"))))
|
|
399
|
+
#
|
|
379
400
|
# Ops are EPHEMERAL UI: any server re-render of the component (an action
|
|
380
401
|
# reply, a broadcast, a morph) rebuilds from server state and resets
|
|
381
402
|
# whatever they toggled — by design (the LiveView JS-commands caveat). Use
|
|
@@ -402,29 +423,21 @@ module Phlex
|
|
|
402
423
|
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
403
424
|
end
|
|
404
425
|
window_bound = window || outside
|
|
426
|
+
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
|
|
405
427
|
attrs = {
|
|
406
428
|
data: {
|
|
407
429
|
action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
|
|
408
|
-
|
|
430
|
+
# Space-free (issue #271): compact JSON has no structural
|
|
431
|
+
# whitespace, so every space is inside a string, where \u0020
|
|
432
|
+
# parses to the same string — and mix's space-join stays splittable.
|
|
433
|
+
reactive_ops_param: record.to_json.gsub(" ", '\u0020')
|
|
409
434
|
}
|
|
410
435
|
}
|
|
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
436
|
# Issue #228: mark a clipboard-reading trigger so the client can gate
|
|
416
437
|
# its visibility on availability — on connect the controller sets
|
|
417
438
|
# hidden = !navigator.clipboard.readText. Render the trigger `hidden`
|
|
418
439
|
# for reveal-when-available (a dead paste button never shows).
|
|
419
440
|
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
441
|
attrs[:type] = "button" if event == "click" && !window_bound
|
|
429
442
|
attrs
|
|
430
443
|
end
|
|
@@ -678,13 +691,23 @@ module Phlex
|
|
|
678
691
|
# Enter picks the highlighted one by clicking its own reactive trigger
|
|
679
692
|
# (selection stays a signed action), Escape clears. Combine with other
|
|
680
693
|
# attrs via mix so a caller's data-action token-joins, not clobbers.
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
694
|
+
#
|
|
695
|
+
# `focus: true` (issue #271) is ROVING-FOCUS mode for a menu container:
|
|
696
|
+
# Arrow Down/Up move real focus among the items (wrapping), Home/End jump
|
|
697
|
+
# to the first/last; no highlight attr is written. The item selector
|
|
698
|
+
# defaults to [role=menuitem]. Enter/Space stay native activation and
|
|
699
|
+
# Escape is yours to bind (on_client("keydown.esc", ...) composes via mix):
|
|
700
|
+
#
|
|
701
|
+
# ul(role: "menu", **mix(reactive_listnav(focus: true), data: { testid: "menu" }))
|
|
702
|
+
def reactive_listnav(option_selector = nil, focus: false)
|
|
703
|
+
option_selector ||= focus ? "[role=menuitem]" : "[role=option]"
|
|
704
|
+
data = {
|
|
705
|
+
action: (focus ? LISTNAV_FOCUS_ACTIONS : LISTNAV_ACTIONS).join(" "),
|
|
706
|
+
reactive_listnav_option_param: filter_selector!(:selector, option_selector)
|
|
687
707
|
}
|
|
708
|
+
# STRING "true" — Phlex renders a boolean true as a valueless attribute.
|
|
709
|
+
data[:reactive_listnav_focus_param] = "true" if focus
|
|
710
|
+
{ data: }
|
|
688
711
|
end
|
|
689
712
|
|
|
690
713
|
# Tag-chip input (issue #203) — the composed combobox/tags primitive.
|
|
@@ -1391,6 +1414,29 @@ module Phlex
|
|
|
1391
1414
|
# Shared by on and on_client so both paths speak the same three forms. The
|
|
1392
1415
|
# predicate is soft-validation UX, NOT authorization — a user can bypass it;
|
|
1393
1416
|
# the action still hits the endpoint's real authorize/default-deny.
|
|
1417
|
+
# One on_client binding record (issue #271). Flags and confirm live IN
|
|
1418
|
+
# the record — never as element-wide params, which a sibling binding
|
|
1419
|
+
# would inherit. Issue #178: confirm: gates the chain behind the SAME
|
|
1420
|
+
# overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
|
|
1421
|
+
# client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
|
|
1422
|
+
# CONDITIONAL, compiled by the shared compile_conditional_confirm.
|
|
1423
|
+
def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:)
|
|
1424
|
+
record = { "on" => event }
|
|
1425
|
+
record["window"] = true if window_bound
|
|
1426
|
+
record["outside"] = true if outside
|
|
1427
|
+
# A mix-ed regular sibling keeps calling runOps after Stimulus drops
|
|
1428
|
+
# this binding's :once listener — the client skips a spent once record.
|
|
1429
|
+
record["once"] = true if once
|
|
1430
|
+
case confirm
|
|
1431
|
+
when nil, false then nil
|
|
1432
|
+
when String then record["confirm"] = confirm
|
|
1433
|
+
when Hash then record["confirmWhen"] = compile_conditional_confirm(confirm)
|
|
1434
|
+
else apply_confirm!({}, confirm) # raises the shared guided error
|
|
1435
|
+
end
|
|
1436
|
+
record["ops"] = ops.ops
|
|
1437
|
+
record
|
|
1438
|
+
end
|
|
1439
|
+
|
|
1394
1440
|
def apply_confirm!(data, confirm)
|
|
1395
1441
|
case confirm
|
|
1396
1442
|
when String
|
|
@@ -112,6 +112,7 @@ module Phlex
|
|
|
112
112
|
config.to_prepare do
|
|
113
113
|
Phlex::Reactive::Streamable.reset_all_view_contexts!
|
|
114
114
|
Phlex::Reactive.reset_stream_builder!
|
|
115
|
+
Phlex::Reactive::Pending::Markup.reset!
|
|
115
116
|
end
|
|
116
117
|
|
|
117
118
|
# Boot-time guard (issue #26): warn if the action path doesn't resolve to
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -110,17 +110,25 @@ module Phlex
|
|
|
110
110
|
# the next frame `from`→`to` swaps, and the whole set is awaited via
|
|
111
111
|
# `animationend` (with a setTimeout fallback so a non-animated element never
|
|
112
112
|
# hangs the op chain). Omit it for the instant flip.
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
#
|
|
114
|
+
# `expanded:` (issue #271) — a disclosure trigger to keep honest: the
|
|
115
|
+
# client sets its aria-expanded from the op's INTENDED state (show →
|
|
116
|
+
# "true", hide → "false", toggle → the pre-flip hidden state), before any
|
|
117
|
+
# transition runs. Resolved with the op's own scoping (`:root` or a
|
|
118
|
+
# selector, document-wide under global: true). Pair it with ONE disclosure
|
|
119
|
+
# target — with several, the last one wins.
|
|
120
|
+
# js.toggle("#menu", expanded: "#menu-trigger")
|
|
121
|
+
|
|
122
|
+
def show(to, global: false, transition: nil, expanded: nil)
|
|
123
|
+
append("show", target_args(to, global:, transition:, expanded:))
|
|
116
124
|
end
|
|
117
125
|
|
|
118
|
-
def hide(to, global: false, transition: nil)
|
|
119
|
-
append("hide", target_args(to, global:, transition:))
|
|
126
|
+
def hide(to, global: false, transition: nil, expanded: nil)
|
|
127
|
+
append("hide", target_args(to, global:, transition:, expanded:))
|
|
120
128
|
end
|
|
121
129
|
|
|
122
|
-
def toggle(to, global: false, transition: nil)
|
|
123
|
-
append("toggle", target_args(to, global:, transition:))
|
|
130
|
+
def toggle(to, global: false, transition: nil, expanded: nil)
|
|
131
|
+
append("toggle", target_args(to, global:, transition:, expanded:))
|
|
124
132
|
end
|
|
125
133
|
|
|
126
134
|
# --- Classes ---
|
|
@@ -143,6 +151,11 @@ module Phlex
|
|
|
143
151
|
# The value is stringified (a Phlex-style flag rides as the string "true",
|
|
144
152
|
# never a valueless attribute). The name is checked against the allowlist at
|
|
145
153
|
# build time — an event-handler, URL-bearing, or style name raises here.
|
|
154
|
+
#
|
|
155
|
+
# toggle_attr(to, name, on, off) (issue #271) flips BETWEEN two values
|
|
156
|
+
# instead of toggling presence — current == on ? off : on, so an absent
|
|
157
|
+
# attribute becomes `on`. For aria-expanded/aria-pressed/data-state:
|
|
158
|
+
# js.toggle_attr("#trigger", "aria-expanded", "true", "false")
|
|
146
159
|
|
|
147
160
|
def set_attr(to, name, value, global: false)
|
|
148
161
|
append("set_attr", attr_args(to, name, global:, value:))
|
|
@@ -152,8 +165,11 @@ module Phlex
|
|
|
152
165
|
append("remove_attr", attr_args(to, name, global:))
|
|
153
166
|
end
|
|
154
167
|
|
|
155
|
-
def toggle_attr(to, name, global: false)
|
|
156
|
-
|
|
168
|
+
def toggle_attr(to, name, *values, global: false)
|
|
169
|
+
args = attr_args(to, name, global:)
|
|
170
|
+
return append("toggle_attr", args) if values.empty?
|
|
171
|
+
|
|
172
|
+
append("toggle_attr", args.merge("values" => toggle_values(name, values)).freeze)
|
|
157
173
|
end
|
|
158
174
|
|
|
159
175
|
# --- Focus (issue #96) ---
|
|
@@ -290,13 +306,31 @@ module Phlex
|
|
|
290
306
|
self.class.new([*@ops, [name, args].freeze].freeze)
|
|
291
307
|
end
|
|
292
308
|
|
|
293
|
-
def target_args(to, global:, transition: nil)
|
|
309
|
+
def target_args(to, global:, transition: nil, expanded: nil)
|
|
294
310
|
args = { "to" => normalize_target(to) }
|
|
295
311
|
args["global"] = true if global
|
|
296
312
|
args["transition"] = normalize_transition(transition) if transition
|
|
313
|
+
args["expanded"] = normalize_target(expanded) unless expanded.nil?
|
|
297
314
|
args.freeze
|
|
298
315
|
end
|
|
299
316
|
|
|
317
|
+
# The [on, off] pair of a two-value toggle_attr, stringified. Loud on a
|
|
318
|
+
# half-specified pair or one that could never change anything.
|
|
319
|
+
def toggle_values(name, values)
|
|
320
|
+
unless values.size == 2
|
|
321
|
+
raise ArgumentError,
|
|
322
|
+
"#{self.class}: toggle_attr(#{name.to_s.inspect}) takes no values (presence toggle) " \
|
|
323
|
+
"or two values to flip between, got #{values.size}: #{values.inspect}"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
on, off = values.map(&:to_s)
|
|
327
|
+
if on == off
|
|
328
|
+
raise ArgumentError, "#{self.class}: toggle_attr(#{name.to_s.inspect}) values must differ, got #{on.inspect} twice"
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
[on, off].freeze
|
|
332
|
+
end
|
|
333
|
+
|
|
300
334
|
# An attr op's args: the target, the allowlisted name, an optional
|
|
301
335
|
# (stringified) value. The name is validated LOUDLY at build time.
|
|
302
336
|
def attr_args(to, name, global:, value: :__none)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Pending
|
|
6
|
+
# The opt-in pending MARKUP hook (issue #249). The #248 markers dim a live
|
|
7
|
+
# row; some apps want the pending state to be honest markup instead — a
|
|
8
|
+
# real "Queued" badge, a button that is genuinely gone. A row component
|
|
9
|
+
# opts in by defining `pending_template` (private is fine), and
|
|
10
|
+
# reply.pending then ALSO replaces the row with that markup:
|
|
11
|
+
#
|
|
12
|
+
# class TransferRow < ApplicationComponent
|
|
13
|
+
# def view_template = li(id:, **reactive_attrs) { ...; button(**on(:retry)) { "Retry" } }
|
|
14
|
+
#
|
|
15
|
+
# private
|
|
16
|
+
#
|
|
17
|
+
# def pending_template = li(id:, **reactive_attrs) { ...; span(class: "badge") { "Queued" } }
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# It is a TEMPLATE method, not a content method like `deferred_placeholder`
|
|
21
|
+
# (#165): there the gem owns the shell div and the component supplies what
|
|
22
|
+
# goes inside; here the row's own root is swapped, so the hook renders it.
|
|
23
|
+
#
|
|
24
|
+
# ## Why a subclass
|
|
25
|
+
#
|
|
26
|
+
# Phlex 2 compiles templates per class, so the swap cannot be a per-instance
|
|
27
|
+
# method. Each opted-in row class gets ONE memoized anonymous subclass whose
|
|
28
|
+
# view_template is the pending template. Two contracts make that safe:
|
|
29
|
+
#
|
|
30
|
+
# * The variant's `.name` is the PARENT's. A row that includes Component
|
|
31
|
+
# signs its class name into its token; an anonymous name cannot
|
|
32
|
+
# constantize, so the row's next action would 400.
|
|
33
|
+
# * The memo holds classes derived from reloadable ones, so the engine
|
|
34
|
+
# resets it on Rails code reload (config.to_prepare), exactly like the
|
|
35
|
+
# view-context and stream-builder memos.
|
|
36
|
+
#
|
|
37
|
+
# ## Un-pending is the settle's job
|
|
38
|
+
#
|
|
39
|
+
# The markers are attributes, so any settle can strip them. Swapped markup
|
|
40
|
+
# is not: only a settle that REPLACES or REMOVES the row puts it back. A
|
|
41
|
+
# flash-only settle, `finish: true` or a failed job clears the markers but
|
|
42
|
+
# leaves the pending markup in place.
|
|
43
|
+
module Markup
|
|
44
|
+
HOOK = :pending_template
|
|
45
|
+
|
|
46
|
+
# The root start tag of the rendered markup, walked quote-aware: Phlex
|
|
47
|
+
# double-quotes every attribute value and leaves `>` raw inside it
|
|
48
|
+
# (`data-action="click->reactive#dispatch"`), so the tag must not end at
|
|
49
|
+
# the first `>`.
|
|
50
|
+
ROOT_TAG = %r{\A\s*<[^\s>/]+(?:\s+[^\s=>/]+(?:="[^"]*")?)*}
|
|
51
|
+
|
|
52
|
+
# Keyed weakly on the row class, so a class Zeitwerk replaced is not
|
|
53
|
+
# pinned between resets. A WeakKeyMap, not a WeakMap: nothing else holds
|
|
54
|
+
# the variant, so a weak VALUE would let GC drop the memo.
|
|
55
|
+
@variants = ObjectSpace::WeakKeyMap.new
|
|
56
|
+
@mutex = Mutex.new
|
|
57
|
+
|
|
58
|
+
class << self
|
|
59
|
+
# The row's pending markup as HTML, or nil when the row has no hook
|
|
60
|
+
# (or the target is a bare DOM id String — there is no component to
|
|
61
|
+
# render). Rendered through the PARENT's render_component: the variant
|
|
62
|
+
# is never registered with Streamable, so this reuses the parent's
|
|
63
|
+
# memoized, reload-reset view context and its instrumentation name.
|
|
64
|
+
def render(row)
|
|
65
|
+
return nil unless row.is_a?(Phlex::Reactive::Streamable) && row.respond_to?(HOOK, true)
|
|
66
|
+
|
|
67
|
+
html = row.class.render_component(variant_instance(row))
|
|
68
|
+
assert_keeps_id!(row, html)
|
|
69
|
+
html
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The replace stream that swaps one row to its pending markup.
|
|
73
|
+
def stream(target_id, html)
|
|
74
|
+
Phlex::Reactive::Stream.wrap(
|
|
75
|
+
Phlex::Reactive.stream_builder.replace(target_id, html: html.html_safe),
|
|
76
|
+
action: "replace", target: target_id, renders_root: true
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def variant_for(row_class)
|
|
81
|
+
@mutex.synchronize { @variants[row_class] ||= build_variant(row_class) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Called from the engine's config.to_prepare (Rails code reload).
|
|
85
|
+
def reset!
|
|
86
|
+
@mutex.synchronize { @variants = ObjectSpace::WeakKeyMap.new }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def build_variant(row_class)
|
|
92
|
+
Class.new(row_class) do
|
|
93
|
+
def self.name = superclass.name
|
|
94
|
+
def self.inspect = "#{superclass.name}(pending)"
|
|
95
|
+
def self.to_s = inspect
|
|
96
|
+
|
|
97
|
+
def view_template = pending_template
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# A variant instance carrying the built row's state. allocate + copy
|
|
102
|
+
# rather than re-running initialize: the row may have been handed to
|
|
103
|
+
# reply.pending already built, with arguments the gem never saw.
|
|
104
|
+
def variant_instance(row)
|
|
105
|
+
variant = variant_for(row.class).allocate
|
|
106
|
+
row.instance_variables.each { variant.instance_variable_set(it, row.instance_variable_get(it)) }
|
|
107
|
+
variant
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# A pending template that drops the row's id leaves the settle nothing
|
|
111
|
+
# to target: its replace/remove would miss and the row would say
|
|
112
|
+
# "Queued" forever. Fail at the action, before anything is enqueued.
|
|
113
|
+
# Only the ROOT start tag counts — the id on a data- attribute or a
|
|
114
|
+
# nested child is not a target the settle can swap (see ROOT_TAG).
|
|
115
|
+
def assert_keeps_id!(row, html)
|
|
116
|
+
root_tag = html[ROOT_TAG].to_s
|
|
117
|
+
return if root_tag.match?(/\sid="#{Regexp.escape(ERB::Util.html_escape(row.id))}"/)
|
|
118
|
+
|
|
119
|
+
raise Phlex::Reactive::Error,
|
|
120
|
+
"#{row.class.name}#pending_template must render the row's root with its id " \
|
|
121
|
+
"(id=\"#{row.id}\") — the settle targets that id to replace or remove the row. " \
|
|
122
|
+
"Render the root as in view_template, e.g. li(id:, **reactive_attrs) { ... }."
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -92,8 +92,10 @@ module Phlex
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
# One recorded pending segment: the handle plus the DOM ids that were
|
|
95
|
-
# marked pending (the endpoint emits one marker stream per id)
|
|
96
|
-
|
|
95
|
+
# marked pending (the endpoint emits one marker stream per id), and — in
|
|
96
|
+
# step with target_ids — each row's pending markup (issue #249), nil for
|
|
97
|
+
# a row without the pending_template hook.
|
|
98
|
+
Segment = Data.define(:handle, :target_ids, :markup)
|
|
97
99
|
|
|
98
100
|
# The attribute apps style. Set alongside aria-busy on every pending
|
|
99
101
|
# target AND on the container, so one CSS rule covers both:
|
|
@@ -126,7 +128,8 @@ module Phlex
|
|
|
126
128
|
# or re-queries, so a concurrent write could make the marked rows and
|
|
127
129
|
# the enqueued jobs disagree.
|
|
128
130
|
list = materialize(records)
|
|
129
|
-
|
|
131
|
+
rows = resolve_rows(container, list, collection)
|
|
132
|
+
targets = rows.map { it.is_a?(String) ? it : it.id }
|
|
130
133
|
|
|
131
134
|
unless Phlex::Reactive.settle_capable?
|
|
132
135
|
warn_no_lane
|
|
@@ -134,9 +137,12 @@ module Phlex
|
|
|
134
137
|
return nil
|
|
135
138
|
end
|
|
136
139
|
|
|
140
|
+
# Rendered BEFORE the enqueue, so a broken pending_template fails the
|
|
141
|
+
# action (and rolls it back) before any job exists.
|
|
142
|
+
markup = rows.map { Markup.render(it) }
|
|
137
143
|
handle = build_handle(container, collection, targets, peers)
|
|
138
144
|
with_handle(handle) { run_enqueue(list, job, args, enqueue, targets) }
|
|
139
|
-
Segment.new(handle:, target_ids: targets)
|
|
145
|
+
Segment.new(handle:, target_ids: targets, markup:)
|
|
140
146
|
end
|
|
141
147
|
|
|
142
148
|
# One record, or an enumerable of them, as an Array — never re-walked.
|
|
@@ -149,9 +155,13 @@ module Phlex
|
|
|
149
155
|
|
|
150
156
|
# The wire streams for one segment, in apply order: the per-target
|
|
151
157
|
# pending markers FIRST (so the UI stops lying immediately), then the
|
|
152
|
-
# single subscription directive.
|
|
158
|
+
# single subscription directive. A row with pending markup is replaced
|
|
159
|
+
# just before its marker, so the marker lands on the swapped node.
|
|
153
160
|
def streams_for(segment)
|
|
154
|
-
streams = segment.target_ids.
|
|
161
|
+
streams = segment.target_ids.each_with_index.flat_map do |id, index|
|
|
162
|
+
html = segment.markup[index]
|
|
163
|
+
html ? [Markup.stream(id, html), marker_stream(id)] : [marker_stream(id)]
|
|
164
|
+
end
|
|
155
165
|
streams << marker_stream(segment.handle.anchor)
|
|
156
166
|
streams << directive_stream(segment.handle)
|
|
157
167
|
streams
|
|
@@ -162,24 +172,18 @@ module Phlex
|
|
|
162
172
|
# exact id to tear the subscription down.
|
|
163
173
|
def source_id(anchor) = "reactive-defer-src-#{anchor}"
|
|
164
174
|
|
|
165
|
-
# Resolve the DOM id a row component would render for `model`, WITHOUT
|
|
166
|
-
# rendering it (build is cheap — #id must be render-context-free, that
|
|
167
|
-
# is the Streamable#id contract).
|
|
168
|
-
def row_dom_id(definition, model)
|
|
169
|
-
return model if model.is_a?(String)
|
|
170
|
-
|
|
171
|
-
definition.item.send(:build, model, {}).id
|
|
172
|
-
end
|
|
173
|
-
|
|
174
175
|
private
|
|
175
176
|
|
|
176
|
-
# Each pending target, as a
|
|
177
|
-
# the
|
|
178
|
-
#
|
|
179
|
-
|
|
177
|
+
# Each pending target, as a row component (its #id is the target, and
|
|
178
|
+
# it renders the pending markup when it has the hook) or a bare DOM id
|
|
179
|
+
# String. With `in:` the rows resolve through the collection declaration
|
|
180
|
+
# (build is cheap — #id must be render-context-free, the Streamable#id
|
|
181
|
+
# contract); without it every entry must already be a Streamable
|
|
182
|
+
# component.
|
|
183
|
+
def resolve_rows(container, list, collection)
|
|
180
184
|
if collection
|
|
181
185
|
definition = Phlex::Reactive::Collections.definition!(container, collection)
|
|
182
|
-
return list.map {
|
|
186
|
+
return list.map { it.is_a?(String) ? it : definition.item.send(:build, it, {}) }
|
|
183
187
|
end
|
|
184
188
|
|
|
185
189
|
list.map do
|
|
@@ -190,7 +194,7 @@ module Phlex
|
|
|
190
194
|
"Streamable component instance (its #id is the target)"
|
|
191
195
|
end
|
|
192
196
|
|
|
193
|
-
it
|
|
197
|
+
it
|
|
194
198
|
end
|
|
195
199
|
end
|
|
196
200
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phlex-reactive
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.13.
|
|
4
|
+
version: 0.13.4
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -180,6 +180,7 @@ files:
|
|
|
180
180
|
- lib/phlex/reactive/mcp/tools/find_tool.rb
|
|
181
181
|
- lib/phlex/reactive/param_schema.rb
|
|
182
182
|
- lib/phlex/reactive/pending.rb
|
|
183
|
+
- lib/phlex/reactive/pending/markup.rb
|
|
183
184
|
- lib/phlex/reactive/reply.rb
|
|
184
185
|
- lib/phlex/reactive/response.rb
|
|
185
186
|
- lib/phlex/reactive/settle.rb
|