phlex-reactive 0.11.6 → 0.12.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.
@@ -122,6 +122,72 @@ module Phlex
122
122
  Registry.resolve_scalar(self, :dirty, :reactive_dirty_config)
123
123
  end
124
124
 
125
+ # Effects (issue #215): declare how THIS component animates when a
126
+ # stream renders it — enter (append/prepend), exit (remove), update
127
+ # (replace/update, plain or morph). Refines the global
128
+ # Phlex::Reactive.effects set per hook (most specific wins); works
129
+ # standalone too — declaring on a component IS that component's
130
+ # opt-in, no global switch required.
131
+ #
132
+ # reactive_effects enter: :slide, exit: :fade # built-ins (shipped CSS)
133
+ # reactive_effects update: false # disable one hook
134
+ # reactive_effects false # opt out entirely
135
+ # reactive_effects enter: :random # random built-in per application
136
+ # reactive_effects enter: { during: %w[transition-all], from: %w[opacity-0],
137
+ # to: %w[opacity-100] } # custom #186 legs
138
+ #
139
+ # Validated HERE (class load), not at render time — a typo'd effect
140
+ # name raises immediately. Stored as pre-compiled wire strings.
141
+ def reactive_effects(opt_out = nil, **hooks)
142
+ if opt_out == false
143
+ unless hooks.empty?
144
+ raise ArgumentError,
145
+ "reactive_effects false opts the component out entirely — it takes no hooks " \
146
+ "(got #{hooks.keys.inspect}); use per-hook false to disable one: update: false"
147
+ end
148
+ return Registry.write_scalar(self, :effects, false)
149
+ end
150
+ unless opt_out.nil?
151
+ raise ArgumentError,
152
+ "reactive_effects takes hook keywords (enter:/exit:/update:) or false — got #{opt_out.inspect}"
153
+ end
154
+ if hooks.empty?
155
+ raise ArgumentError,
156
+ "reactive_effects needs at least one hook (enter:/exit:/update:) or false to opt out"
157
+ end
158
+
159
+ Registry.write_scalar(self, :effects, Phlex::Reactive::Effects.normalize!(hooks))
160
+ end
161
+
162
+ # The raw declaration ({ hook => wire | false }, false for a full
163
+ # opt-out, or nil when undeclared), inherited through the Registry
164
+ # like every other scalar.
165
+ def reactive_effects_config
166
+ Registry.resolve_scalar(self, :effects, :reactive_effects_config)
167
+ end
168
+
169
+ # The RESOLVED root effect attrs (global ⊕ component, false hooks
170
+ # dropped): a frozen { reactive_effect_enter: "fade", … } fragment
171
+ # reactive_attrs merges into the root's data:, or nil when off.
172
+ # Memoized per class against BOTH inputs that can change the answer
173
+ # — the effects config generation (a Phlex::Reactive.effects= write)
174
+ # and the registry generation (any declaration) — as two integer
175
+ # compares, because reactive_attrs is the token-signing hot path.
176
+ # The race on a concurrent first call is benign (idempotent rebuild).
177
+ def reactive_effect_attrs
178
+ effects_gen = Phlex::Reactive.effects_generation
179
+ registry_gen = Registry.generation
180
+ if @reactive_effect_attrs_effects_gen == effects_gen &&
181
+ @reactive_effect_attrs_registry_gen == registry_gen
182
+ return @reactive_effect_attrs
183
+ end
184
+
185
+ @reactive_effect_attrs = Phlex::Reactive::Effects.root_attrs_for(self)
186
+ @reactive_effect_attrs_effects_gen = effects_gen
187
+ @reactive_effect_attrs_registry_gen = registry_gen
188
+ @reactive_effect_attrs
189
+ end
190
+
125
191
  # Opt into signed STATE for record-less components only.
126
192
  # reactive_state :count, :open
127
193
  def reactive_state(*names)
@@ -91,6 +91,13 @@ module Phlex
91
91
  if self.class.respond_to?(:reactive_scope) && (scope = self.class.reactive_scope)
92
92
  data[:reactive_scope] = scope.to_s
93
93
  end
94
+ # Effects (issue #215): the resolved global ⊕ component hooks ride the
95
+ # root as data-reactive-effect-<hook> so the client's stream interceptor
96
+ # knows how to animate this component. nil when off — no keys, byte-
97
+ # stable wire. The fragment is a per-class memo (two integer compares).
98
+ if self.class.respond_to?(:reactive_effect_attrs) && (effects = self.class.reactive_effect_attrs)
99
+ data.merge!(effects)
100
+ end
94
101
  { data: }
95
102
  end
96
103
 
@@ -840,21 +847,80 @@ module Phlex
840
847
  # The add-a-row trigger — CLIENT-ONLY (no dispatch descriptor, no
841
848
  # POST). Forced type="button": a bare button inside the surrounding
842
849
  # real <form> would submit it.
843
- def reactive_nested_add(association)
844
- {
845
- type: "button",
846
- data: {
847
- action: "click->reactive#nestedAdd",
848
- reactive_association_param: nested_identifier!(:reactive_nested_add, :association, association)
849
- }
850
+ #
851
+ # FILL-THEN-ADD (issue #208 Scenario A). By default add clones the
852
+ # template and focuses the new row's first field — INLINE-EDIT (you
853
+ # type INTO the row). But a common form shape puts the add controls
854
+ # OUTSIDE the row (a preset <select>, a typeahead, plain inputs) and
855
+ # "Add" SNAPSHOTS those values into a new row, then clears them for the
856
+ # next entry. `from:` expresses that: a map of ROW FIELD name => SOURCE
857
+ # CONTROL selector. On click the client fills each cloned-row field
858
+ # from its source's current value (matching the field by the trailing
859
+ # bracket segment of its name — the SAME key inference JSON mode uses,
860
+ # so the two agree), keeps focus on the sources, and (with `clear:
861
+ # true`) resets the sources. It composes with BOTH wire modes: the
862
+ # seeded values ride the renumbered `…_attributes[i][field]` names on
863
+ # submit (:attributes), and the end-of-add JSON sync serializes them
864
+ # (as: :json) with no extra wiring.
865
+ #
866
+ # a(**reactive_nested_add(:items,
867
+ # from: { name: "#item-name", quantity: "#item-qty" }, clear: true))
868
+ #
869
+ # `from:` values are RAW CSS selectors (the escape-hatch posture of
870
+ # reactive_filter/reactive_tags) — the sources are author-owned markup,
871
+ # not reactive_field bindings — resolved root-scoped (#15 ownership).
872
+ def reactive_nested_add(association, from: nil, clear: false)
873
+ data = {
874
+ action: "click->reactive#nestedAdd",
875
+ reactive_association_param: nested_identifier!(:reactive_nested_add, :association, association)
850
876
  }
877
+ unless from.nil?
878
+ data[:reactive_nested_from_param] = nested_from_param!(from)
879
+ # STRING "true", not boolean: a valueless boolean attr reads "" (falsy)
880
+ # client-side — the on()/tags precedent.
881
+ data[:reactive_nested_clear_param] = "true" if clear
882
+ end
883
+ { type: "button", data: }
884
+ end
885
+
886
+ # Validate + compile a fill-then-add `from:` map into its JSON wire
887
+ # (issue #208). Each key is a ROW FIELD name (plain identifier, like the
888
+ # association); each value is a SOURCE CONTROL selector (non-blank). An
889
+ # empty map is a dead binding — fail at render (the reactive_show_targets
890
+ # posture), never a silent no-op.
891
+ def nested_from_param!(from)
892
+ unless from.is_a?(Hash) && from.any?
893
+ raise ArgumentError,
894
+ "reactive_nested_add from: needs at least one row-field => source-selector pair " \
895
+ "(e.g. from: { quantity: \"#item-qty\" }), got #{from.inspect}"
896
+ end
897
+
898
+ from.to_h do |field, selector|
899
+ [nested_identifier!(:reactive_nested_add, :field, field),
900
+ filter_selector!(:reactive_nested_add_from, selector)]
901
+ end.to_json
851
902
  end
852
903
 
853
904
  # A row's remove trigger — client-only. Draft rows leave the DOM;
854
905
  # persisted rows (a hidden [_destroy] input present) are marked and
855
906
  # hidden instead, so Rails destroys them on save.
856
- def reactive_nested_remove
857
- { type: "button", data: { action: "click->reactive#nestedRemove" } }
907
+ #
908
+ # CONFIRM (issue #218). Removing a row can be a real loss (line items
909
+ # with entered amounts), so this trigger accepts the SAME confirm: the
910
+ # other triggers (on/on_client) do — routed through the shared
911
+ # apply_confirm!, so the client gates the remove behind the overridable
912
+ # confirmResolver (the styled-modal seam, #52/#55/#178). A String is
913
+ # the static message; a Hash is the CONDITIONAL form (#179) — prompt
914
+ # only when { when: … } matches or a { predicate: … } fires. Per-row
915
+ # messages come for free: the app builds a different string per row at
916
+ # render time. No confirm: → the plain immediate-remove fast path,
917
+ # unchanged.
918
+ #
919
+ # button(**reactive_nested_remove(confirm: "Really delete #{row.name}?")) { "×" }
920
+ def reactive_nested_remove(confirm: nil)
921
+ data = { action: "click->reactive#nestedRemove" }
922
+ apply_confirm!(data, confirm) if confirm
923
+ { type: "button", data: }
858
924
  end
859
925
 
860
926
  # CROSS-ROOT value-conditional visibility (issue #164) — the visibility
@@ -57,6 +57,8 @@ module Phlex
57
57
  dirty: :@reactive_own_dirty,
58
58
  # Deferred reply segments — reactive_lazy (issue #165).
59
59
  lazy: :@reactive_own_lazy,
60
+ # Enter/exit/update effects — reactive_effects (issue #215).
61
+ effects: :@reactive_own_effects,
60
62
  # verify_authorized opt-out (issue #168): a scalar bare flag (skip the
61
63
  # WHOLE component) plus a list of named actions.
62
64
  skip_all: :@reactive_own_skip_all,
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The effects vocabulary (issue #215): animate a component's ENTER
6
+ # (append/prepend), EXIT (remove), and UPDATE (replace/update, plain or
7
+ # morph) when a Turbo Stream renders. Strictly OPT-IN at three levels,
8
+ # most specific wins:
9
+ #
10
+ # Phlex::Reactive.effects = true # global default set
11
+ # reactive_effects enter: :slide, update: false # per component
12
+ # reply.remove(effect: :shake) # per call
13
+ #
14
+ # An effect value is a BUILT-IN name (:fade/:slide/:scale/:highlight/
15
+ # :shake — shipped as CSS in app/assets/stylesheets/phlex/reactive/
16
+ # effects.css), :random (the client picks a built-in per application),
17
+ # false (disable the hook), or custom NAMED LEGS { during:, from:, to: }
18
+ # — the issue #186 class-list vocabulary, compiled to the same
19
+ # [during, from, to] wire array the client's runTransition consumes.
20
+ #
21
+ # This module owns normalization (declare-time validation — a typo'd
22
+ # effect raises at class load / config time, not at click time) and the
23
+ # WIRE forms:
24
+ # * root attrs: data-reactive-effect-enter="fade" (emitted by
25
+ # reactive_attrs; resolved global ⊕ component)
26
+ # * per call: data-reactive-effect="shake" on the <turbo-stream>
27
+ # element itself ("off" suppresses; beats the root attrs)
28
+ # Values are either a name or the legs JSON array — the client
29
+ # distinguishes by the leading "[".
30
+ module Effects
31
+ HOOKS = %i[enter exit update].freeze
32
+ BUILT_INS = %w[fade slide scale highlight shake].freeze
33
+ RANDOM = "random"
34
+ OFF = "off"
35
+ LEG_KEYS = %i[during from to].freeze
36
+
37
+ # The `effects = true` sugar — pre-normalized wire strings.
38
+ DEFAULTS = { enter: "fade", exit: "fade", update: "highlight" }.freeze
39
+
40
+ class << self
41
+ # Normalize the Phlex::Reactive.effects= value: nil/false → off (nil),
42
+ # true → the default set, a Hash → validated per-hook wire strings.
43
+ def normalize_config(value)
44
+ case value
45
+ when nil, false then nil
46
+ when true then DEFAULTS
47
+ when Hash then normalize!(value)
48
+ else
49
+ raise ArgumentError,
50
+ "Phlex::Reactive.effects takes nil/false (off), true (the default set), or a " \
51
+ "{ enter:/exit:/update: } hash — got #{value.inspect}"
52
+ end
53
+ end
54
+
55
+ # Validate + compile a per-hook declaration hash to its stored form:
56
+ # { hook => wire-string | false }. `false` survives normalization so a
57
+ # component-level `update: false` can OVERRIDE a global update effect
58
+ # at resolve time (the merge keeps it; attr emission skips it).
59
+ def normalize!(hooks)
60
+ hooks.each_with_object({}) do |(hook, value), out|
61
+ key = hook.to_sym
62
+ unless HOOKS.include?(key)
63
+ raise ArgumentError,
64
+ "unknown effects hook #{hook.inspect} — valid hooks: #{HOOKS.join(", ")}"
65
+ end
66
+ out[key] = value == false ? false : wire_value(value)
67
+ end.freeze
68
+ end
69
+
70
+ # The RESOLVED root attrs for a component class: global ⊕ component
71
+ # (Hash#merge — the component's per-hook values win), false hooks
72
+ # dropped at emission (they exist to cancel a global hook), the whole
73
+ # thing nil when everything is off. `reactive_effects false` opts the
74
+ # class out regardless of the global set. Returns a frozen
75
+ # { reactive_effect_<hook>: wire } fragment ready for data.merge!.
76
+ def root_attrs_for(klass)
77
+ component = klass.respond_to?(:reactive_effects_config) ? klass.reactive_effects_config : nil
78
+ return nil if component == false
79
+
80
+ global = Phlex::Reactive.effects
81
+ resolved =
82
+ if global && component then global.merge(component)
83
+ else global || component
84
+ end
85
+ return nil if resolved.nil? || resolved.empty?
86
+
87
+ attrs = {}
88
+ resolved.each do |hook, wire|
89
+ attrs[:"reactive_effect_#{hook}"] = wire unless wire == false
90
+ end
91
+ attrs.empty? ? nil : attrs.freeze
92
+ end
93
+
94
+ # Stamp a built turbo-stream tag with the PER-CALL effect override:
95
+ # `data-reactive-effect` on the <turbo-stream> element itself (it beats
96
+ # the root-declared attrs on the client; "off" suppresses them). String
97
+ # injection into the opening tag — turbo-rails' TagBuilder only takes
98
+ # `method:` as a tag attribute, and hand-built escaped attrs are the
99
+ # to_stream_token precedent. `.sub` hits the FIRST "<turbo-stream",
100
+ # which is always the opening tag (template content is escaped or
101
+ # nested later). nil = not given → the input passes through UNTOUCHED
102
+ # (byte-identical wire). Value validated + escaped — a legs JSON's
103
+ # quotes land as &quot;.
104
+ def annotate(html, effect)
105
+ return html if effect.nil?
106
+
107
+ attr = %( data-reactive-effect="#{ERB::Util.html_escape(wire_value(effect))}")
108
+ html.to_s.sub("<turbo-stream", "<turbo-stream#{attr}").html_safe
109
+ end
110
+
111
+ # ONE effect value → its wire string: a built-in/random name, "off"
112
+ # (per-call suppression), or the legs JSON array.
113
+ def wire_value(value)
114
+ case value
115
+ when false then OFF
116
+ when Symbol, String then named_wire(value.to_s)
117
+ when Hash then legs_wire(value)
118
+ when Array then raise_positional_legs(value)
119
+ else
120
+ raise ArgumentError,
121
+ "effect must be a built-in (#{BUILT_INS.join(", ")}), :random, false, or " \
122
+ "{ during:, from:, to: } legs — got #{value.inspect}"
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ def named_wire(name)
129
+ return name if name == RANDOM || BUILT_INS.include?(name)
130
+
131
+ raise ArgumentError,
132
+ "unknown effect #{name.inspect} — built-ins: #{BUILT_INS.join(", ")}, :random, " \
133
+ "or { during:, from:, to: } legs"
134
+ end
135
+
136
+ # Named legs (issue #186 vocabulary) → the [during, from, to] wire
137
+ # array runTransition consumes. Class lists may be a String or an
138
+ # Array of strings. A SINGLE blank leg is legitimate (an element whose
139
+ # own CSS carries the `transition` property needs no during: utilities
140
+ # — the same tolerance js.rb's normalize_transition gives), but ALL
141
+ # legs blank is a dead effect with nothing to animate — fail loudly,
142
+ # like the empty-ops-chain guard in js_ops_json.
143
+ def legs_wire(legs)
144
+ unless LEG_KEYS.all? { legs.key?(it) }
145
+ raise ArgumentError,
146
+ "effect legs must name during:, from:, to: class lists — got #{legs.inspect}"
147
+ end
148
+
149
+ compiled = LEG_KEYS.map { Array(legs[it]).join(" ") }
150
+ if compiled.all? { it.strip.empty? }
151
+ raise ArgumentError,
152
+ "effect legs are all blank — a dead effect with no classes to animate; got #{legs.inspect}"
153
+ end
154
+
155
+ JSON.generate(compiled)
156
+ end
157
+
158
+ # The old positional triple — reject with the named-legs rewrite, the
159
+ # same guidance js.rb's normalize_transition gives (issue #186).
160
+ def raise_positional_legs(value)
161
+ d, f, t = value
162
+ raise ArgumentError,
163
+ "effect legs take named keys (issue #186 vocabulary) — " \
164
+ "{ during: #{d.inspect}, from: #{f.inspect}, to: #{t.inspect} }"
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -30,6 +30,10 @@ module Phlex
30
30
  initializer "phlex_reactive.assets" do
31
31
  if it.config.respond_to?(:assets)
32
32
  it.config.assets.paths << root.join("app/javascript").to_s
33
+ # The opt-in effects stylesheet (issue #215): built-in enter/exit/
34
+ # update animations, linked by the app with
35
+ # stylesheet_link_tag "phlex/reactive/effects".
36
+ it.config.assets.paths << root.join("app/assets/stylesheets").to_s
33
37
  it.config.assets.precompile += %w[
34
38
  phlex/reactive/reactive_controller.min.js
35
39
  phlex/reactive/reactive_controller.min.js.map
@@ -41,6 +45,7 @@ module Phlex
41
45
  phlex/reactive/compute.min.js.map
42
46
  phlex/reactive/inspect.min.js
43
47
  phlex/reactive/inspect.min.js.map
48
+ phlex/reactive/effects.css
44
49
  ]
45
50
  end
46
51
  end
@@ -29,20 +29,23 @@ module Phlex
29
29
 
30
30
  # Re-render in place. `morph: true` morphs the subtree (preserves the
31
31
  # focused input + caret) instead of an outerHTML swap — see #morph.
32
- def replace(morph: false)
33
- Response.build_replace(@component, morph:)
32
+ # `effect:` (issue #215) — the per-call effect override for this one
33
+ # stream (a built-in name, :random, false to suppress, or #186 legs);
34
+ # same kwarg on every verb below.
35
+ def replace(morph: false, effect: nil)
36
+ Response.build_replace(@component, morph:, effect:)
34
37
  end
35
38
 
36
39
  # Re-render in place via Idiomorph (method="morph") — keeps focus + caret.
37
- def morph
38
- Response.build_morph(@component)
40
+ def morph(effect: nil)
41
+ Response.build_morph(@component, effect:)
39
42
  end
40
43
 
41
44
  # Update only inner HTML (preserves the root element + its token attr).
42
45
  # `morph: true` morphs the inner HTML in place (issue #113) so a
43
46
  # cross-tab/per-field update keeps a focused input's caret.
44
- def update(morph: false)
45
- Response.build_update(@component, morph:)
47
+ def update(morph: false, effect: nil)
48
+ Response.build_update(@component, morph:, effect:)
46
49
  end
47
50
 
48
51
  # Reactive collections (issue #35) — add/remove a row in a declared
@@ -59,12 +62,14 @@ module Phlex
59
62
  # Extra kwargs (issue #186) thread to the row component's new(model:, **row_kwargs)
60
63
  # — e.g. reply.append(item, to: :items, autofocus: true) → ItemRow.new(item:,
61
64
  # autofocus: true). Additive: a no-kwarg call is byte-identical to before.
62
- def append(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, **row_kwargs)
63
- collection_build!(:build_collection_append, :append, model, legacy_model, to, row_kwargs)
65
+ # `effect:` (issue #215) is reserved as the row's per-call effect — it is
66
+ # NOT forwarded to the row component's init like the other row_kwargs.
67
+ def append(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, effect: nil, **row_kwargs)
68
+ collection_build!(:build_collection_append, :append, model, legacy_model, to, effect, row_kwargs)
64
69
  end
65
70
 
66
- def prepend(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, **row_kwargs)
67
- collection_build!(:build_collection_prepend, :prepend, model, legacy_model, to, row_kwargs)
71
+ def prepend(model = UNSET_ARG, legacy_model = UNSET_ARG, to: UNSET_ARG, effect: nil, **row_kwargs)
72
+ collection_build!(:build_collection_prepend, :prepend, model, legacy_model, to, effect, row_kwargs)
68
73
  end
69
74
 
70
75
  # Two forms, dispatched on the PRESENCE of `from:` (issue #182 — no sentinel
@@ -74,17 +79,17 @@ module Phlex
74
79
  #
75
80
  # reply.remove # remove the bound component's element
76
81
  # reply.remove(model, from: :items) # remove a collection row
77
- def remove(model = UNSET_ARG, legacy_model = UNSET_ARG, from: UNSET_ARG)
82
+ def remove(model = UNSET_ARG, legacy_model = UNSET_ARG, from: UNSET_ARG, effect: nil)
78
83
  reject_legacy_form!(:remove, model, legacy_model)
79
84
  # Bare `reply.remove` (no positional, no from:) removes self.
80
- return Response.build_remove(@component) if from.equal?(UNSET_ARG) && model.equal?(UNSET_ARG)
85
+ return Response.build_remove(@component, effect:) if from.equal?(UNSET_ARG) && model.equal?(UNSET_ARG)
81
86
 
82
87
  if from.equal?(UNSET_ARG)
83
88
  raise ArgumentError,
84
89
  "reply.remove(model, …) needs a source collection — reply.remove(model, from: :name)"
85
90
  end
86
91
  reject_symbol_first!(:remove, model, :from, from)
87
- Response.build_collection_remove(@component, from, resolve_row_arg(model))
92
+ Response.build_collection_remove(@component, from, resolve_row_arg(model), effect:)
88
93
  end
89
94
 
90
95
  # Subject-free builders — pass straight through so they read naturally.
@@ -136,7 +141,7 @@ module Phlex
136
141
  # Shared append/prepend build: catch the old two-positional form, require the
137
142
  # `to:` keyword, reject a Symbol-first model, then delegate to the Response
138
143
  # builder (name first, model second — its internal order).
139
- def collection_build!(builder, verb, model, legacy_model, to, row_kwargs)
144
+ def collection_build!(builder, verb, model, legacy_model, to, effect, row_kwargs)
140
145
  # Old form `reply.append(:name, model)` passes a SECOND positional — the
141
146
  # arity would just error, so intercept it with the guided rewrite.
142
147
  reject_legacy_form!(verb, model, legacy_model)
@@ -145,7 +150,7 @@ module Phlex
145
150
  "reply.#{verb} needs a target collection — reply.#{verb}(model, to: :name)"
146
151
  end
147
152
  reject_symbol_first!(verb, model, :to, to)
148
- Response.public_send(builder, @component, to, resolve_row_arg(model), **row_kwargs)
153
+ Response.public_send(builder, @component, to, resolve_row_arg(model), effect:, **row_kwargs)
149
154
  end
150
155
 
151
156
  # The pre-#182 call `reply.<verb>(:name, model)` — a Symbol name first, the
@@ -58,8 +58,10 @@ module Phlex
58
58
  # Re-render the component in place (explicit form of today's default).
59
59
  # `morph: true` morphs the subtree (preserves the focused input + caret)
60
60
  # instead of an outerHTML swap — see .build_morph (issue #28).
61
- def build_replace(component, morph: false)
62
- new(streams: [component.to_stream_replace(morph:)],
61
+ # `effect:` (issue #215) stamps the per-call effect override on the
62
+ # emitted stream — same on every self-targeting builder below.
63
+ def build_replace(component, morph: false, effect: nil)
64
+ new(streams: [component.to_stream_replace(morph:, effect:)],
63
65
  subject_component: component)
64
66
  end
65
67
 
@@ -69,19 +71,23 @@ module Phlex
69
71
  # per-field reactive editing (a "spreadsheet" grid where a debounced save
70
72
  # fires while the user is still typing/tabbing). The morphed root still
71
73
  # carries the fresh signed token, so the next action verifies.
72
- def build_morph(component) = new(streams: [component.to_stream_replace(morph: true)], subject_component: component)
74
+ def build_morph(component, effect: nil)
75
+ new(streams: [component.to_stream_replace(morph: true, effect:)], subject_component: component)
76
+ end
73
77
 
74
78
  # Update only inner HTML (preserves the root element + its token attr).
75
79
  # `morph: true` morphs the inner HTML in place (issue #113) instead of
76
80
  # replacing it, so a cross-tab update keeps a peer's focus/caret.
77
- def build_update(component, morph: false)
78
- new(streams: [component.to_stream_update(morph:)], subject_component: component)
81
+ def build_update(component, morph: false, effect: nil)
82
+ new(streams: [component.to_stream_update(morph:, effect:)], subject_component: component)
79
83
  end
80
84
 
81
85
  # Remove the component's element from the DOM. Uses the instance
82
86
  # to_stream_remove (the component already knows its own #id — no
83
87
  # class-builder reconstruction; works for record- and state-backed).
84
- def build_remove(component) = new(streams: [component.to_stream_remove], render_self: false)
88
+ def build_remove(component, effect: nil)
89
+ new(streams: [component.to_stream_remove(effect:)], render_self: false)
90
+ end
85
91
 
86
92
  # Client-side full navigation (Turbo.visit). Use when the current URL
87
93
  # is dead (slug rename) or the outcome belongs on another page. Pass a
@@ -110,21 +116,24 @@ module Phlex
110
116
  # container owns the add/remove trigger, so the endpoint appends its inert
111
117
  # `reactive:token` stream (the same #30 machinery reply.streams uses) to roll
112
118
  # the token forward without re-rendering the rows.
113
- def build_collection_append(component, name, model, **row_kwargs)
119
+ # `effect:` (issue #215) stamps the ROW stream only — the count
120
+ # companion and the empty-state toggle are bookkeeping, not the thing
121
+ # entering/leaving.
122
+ def build_collection_append(component, name, model, effect: nil, **row_kwargs)
114
123
  definition = collection_def!(component, name)
115
- new(streams: collection_add_streams(definition, component, model, :append, row_kwargs),
124
+ new(streams: collection_add_streams(definition, component, model, :append, row_kwargs, effect:),
116
125
  render_self: false, token_component: component)
117
126
  end
118
127
 
119
- def build_collection_prepend(component, name, model, **row_kwargs)
128
+ def build_collection_prepend(component, name, model, effect: nil, **row_kwargs)
120
129
  definition = collection_def!(component, name)
121
- new(streams: collection_add_streams(definition, component, model, :prepend, row_kwargs),
130
+ new(streams: collection_add_streams(definition, component, model, :prepend, row_kwargs, effect:),
122
131
  render_self: false, token_component: component)
123
132
  end
124
133
 
125
- def build_collection_remove(component, name, model)
134
+ def build_collection_remove(component, name, model, effect: nil)
126
135
  definition = collection_def!(component, name)
127
- new(streams: collection_remove_streams(definition, component, model),
136
+ new(streams: collection_remove_streams(definition, component, model, effect:),
128
137
  render_self: false, token_component: component)
129
138
  end
130
139
 
@@ -141,10 +150,10 @@ module Phlex
141
150
  # Row add (append/prepend) + count + empty-state clear. The empty-state is
142
151
  # removed only when the list just crossed 0->1 (size == 1) — appending to
143
152
  # an already-populated list leaves it untouched.
144
- def collection_add_streams(definition, component, model, action, row_kwargs = {})
153
+ def collection_add_streams(definition, component, model, action, row_kwargs = {}, effect: nil)
145
154
  # row_kwargs (issue #186) thread to the row component's init via the class
146
155
  # stream builder's **options passthrough (ItemRow.new(model:, **row_kwargs)).
147
- streams = [definition.item.public_send(action, target: definition.container, model:, **row_kwargs)]
156
+ streams = [definition.item.public_send(action, target: definition.container, model:, effect:, **row_kwargs)]
148
157
  append_count_stream(streams, definition, component)
149
158
 
150
159
  size = definition.size_for(component)
@@ -154,8 +163,8 @@ module Phlex
154
163
 
155
164
  # Row remove + count + empty-state restore. The empty-state is appended
156
165
  # back into the container only when the list just emptied (size == 0).
157
- def collection_remove_streams(definition, component, model)
158
- streams = [collection_row_remove(definition, model)]
166
+ def collection_remove_streams(definition, component, model, effect: nil)
167
+ streams = [collection_row_remove(definition, model, effect)]
159
168
  append_count_stream(streams, definition, component)
160
169
 
161
170
  size = definition.size_for(component)
@@ -170,11 +179,11 @@ module Phlex
170
179
 
171
180
  # Remove the row by its DOM id. Accepts the record (so dom_id is derived)
172
181
  # or an already-built dom-id string (e.g. the value the row used as #id).
173
- def collection_row_remove(definition, model)
182
+ def collection_row_remove(definition, model, effect = nil)
174
183
  if model.is_a?(String)
175
- Phlex::Reactive.stream_builder.remove(model)
184
+ Phlex::Reactive::Effects.annotate(Phlex::Reactive.stream_builder.remove(model), effect)
176
185
  else
177
- definition.item.remove(model)
186
+ definition.item.remove(model, effect:)
178
187
  end
179
188
  end
180
189