phlex-reactive 0.12.1 → 0.12.3

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.
@@ -450,6 +450,71 @@ module Phlex
450
450
  Registry.resolve_hash(self, :computes, :reactive_computes)
451
451
  end
452
452
 
453
+ # A fresh client-op chain at CLASS level (issue #226), so a
454
+ # reactive_on_complete declaration reads like its on_client sibling:
455
+ #
456
+ # reactive_on_complete if: { code: { length: 6 } },
457
+ # run: js.dispatch("code:complete")
458
+ #
459
+ # The instance helper (Component::Helpers#js) is unchanged.
460
+ def js
461
+ Phlex::Reactive::JS.new
462
+ end
463
+
464
+ # Declarative completion binding (issue #226): when the declared
465
+ # conditions FIRST become true — evaluated client-side over the owned
466
+ # fields on input/change — run a chain of client DOM ops. The
467
+ # conditions are the SAME if:/if_any:/unless: kwargs reactive_show
468
+ # takes (the ONE ShowConditions language, including the length: form);
469
+ # `run:` is a js chain or a raw [[op, args], …] list (allowlist
470
+ # re-checked, like every raw-ops escape hatch).
471
+ #
472
+ # reactive_on_complete if: { code: { length: 6 } },
473
+ # run: js.dispatch("code:complete")
474
+ # reactive_on_complete :commit, if: { code: { length: 6 } }, run: js.submit
475
+ #
476
+ # RISING-EDGE, actor-gesture semantics on the client: ops fire once on
477
+ # the flip to true, re-arm when the conditions go false, and the
478
+ # connect/morph pass arms WITHOUT firing (a re-render with already-
479
+ # satisfied conditions never self-fires). The optional NAME (default
480
+ # :default) keys the registry so a component can declare several
481
+ # independent bindings; redeclaring a name overrides it (inheritance
482
+ # follows the Registry rules, like reactive_compute).
483
+ def reactive_on_complete(name = :default, run:, **conditions)
484
+ groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
485
+ Registry.write_entry(
486
+ self, :on_completes, name.to_sym,
487
+ OnCompleteDefinition.new(
488
+ name: name.to_sym, conditions: groups,
489
+ ops: normalize_on_complete_ops(name, run)
490
+ )
491
+ )
492
+ end
493
+
494
+ def reactive_on_completes
495
+ Registry.resolve_hash(self, :on_completes, :reactive_on_completes)
496
+ end
497
+
498
+ # The wire JSON for the root's data-reactive-on-complete attr — a
499
+ # frozen array of { any:, ops: } bindings, or nil when undeclared
500
+ # (byte-stable wire). Memoized per class against the registry
501
+ # generation (one integer compare) because reactive_attrs is the
502
+ # token-signing hot path — the reactive_effect_attrs precedent.
503
+ def reactive_on_complete_attr
504
+ registry_gen = Registry.generation
505
+ return @reactive_on_complete_attr if @reactive_on_complete_attr_gen == registry_gen
506
+
507
+ bindings = reactive_on_completes
508
+ @reactive_on_complete_attr =
509
+ if bindings.empty?
510
+ nil
511
+ else
512
+ bindings.values.map { { "any" => it.conditions, "ops" => it.ops } }.to_json.freeze
513
+ end
514
+ @reactive_on_complete_attr_gen = registry_gen
515
+ @reactive_on_complete_attr
516
+ end
517
+
453
518
  # Fetch one compute definition — the reader form matching
454
519
  # reactive_collection_def (issue #115). The bare reactive_compute(name)
455
520
  # getter above is a PERMANENT documented alias (it shipped in the same
@@ -480,6 +545,25 @@ module Phlex
480
545
  # nil types, so the array-form wire stays byte-identical and the client
481
546
  # keeps its numeric coercion.
482
547
  #
548
+ # Validate + normalize a reactive_on_complete run: chain (issue #226)
549
+ # to the raw [[op, args], …] pairs. A js chain is already validated at
550
+ # build time; a raw list re-runs the attr allowlist — the same
551
+ # defense-in-depth every raw-ops escape hatch gets (js([...]) /
552
+ # broadcast_to(js: [...])). Loud on anything else — a dead or hostile
553
+ # binding must fail at class load, never no-op in the browser.
554
+ def normalize_on_complete_ops(name, run)
555
+ pairs = run.is_a?(Phlex::Reactive::JS) ? run.ops : run
556
+ unless pairs.is_a?(Array)
557
+ raise ArgumentError,
558
+ "reactive_on_complete #{name.inspect} run: takes a js chain " \
559
+ "(js.dispatch(\"code:complete\")) or a raw [[op, args], ...] list, got #{run.class}"
560
+ end
561
+ raise ArgumentError, "reactive_on_complete #{name.inspect} got no ops — a dead binding" if pairs.empty?
562
+
563
+ Phlex::Reactive::JS.assert_ops_allowed!(pairs) unless run.is_a?(Phlex::Reactive::JS)
564
+ pairs
565
+ end
566
+
483
567
  # Named after the shape it normalizes.
484
568
  def normalize_compute_inputs(inputs)
485
569
  return normalize_typed_compute_inputs(inputs) if inputs.is_a?(Hash)
@@ -98,6 +98,14 @@ module Phlex
98
98
  if self.class.respond_to?(:reactive_effect_attrs) && (effects = self.class.reactive_effect_attrs)
99
99
  data.merge!(effects)
100
100
  end
101
+ # Completion bindings (issue #226): the declared reactive_on_complete
102
+ # bindings ride the root as ONE JSON attr the client evaluates over
103
+ # the owned fields (rising edge → run the ops). nil when undeclared —
104
+ # no key, byte-stable wire. Per-class memo (one integer compare).
105
+ if self.class.respond_to?(:reactive_on_complete_attr) &&
106
+ (on_complete = self.class.reactive_on_complete_attr)
107
+ data[:reactive_on_complete] = on_complete
108
+ end
101
109
  { data: }
102
110
  end
103
111
 
@@ -381,6 +389,14 @@ module Phlex
381
389
  raise ArgumentError, "on_client(#{event.inspect}) got no ops — a dead trigger" if ops.empty?
382
390
 
383
391
  event = event.to_s
392
+ # Issue #226: requestSubmit dispatches the very `submit` event this
393
+ # trigger would be bound to — an infinite loop. Loud at render.
394
+ if event == "submit" && ops.ops.any? { |name, _| name == "submit" }
395
+ raise ArgumentError,
396
+ "on_client(:submit, js.submit) would re-fire itself — requestSubmit dispatches the " \
397
+ "submit event this trigger is bound to. Bind the submit op to another event " \
398
+ "(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
399
+ end
384
400
  window_bound = window || outside
385
401
  attrs = {
386
402
  data: {
@@ -601,20 +617,35 @@ module Phlex
601
617
  # hidden options) and each option's own on(:select, …) trigger —
602
618
  # selection still round-trips as a signed action; only FILTERING is
603
619
  # local. Blank selectors raise: a dead binding must fail at render.
604
- def reactive_filter(field = nil, input: :__removed, option: nil, group: nil, empty: nil)
605
- # Issue #186: the four-selector kwarg form is removed name the FIELD that
606
- # drives the filter instead. A leftover input: means the old call shape.
607
- unless input == :__removed
620
+ #
621
+ # Issue #224: `input:` is the ESCAPE HATCH a raw CSS selector for the
622
+ # one driving input the field form can't express: a deliberately
623
+ # NAME-LESS query input inside a real POST form (a named input would
624
+ # submit a stray param), targeted by id. A form builder (phlex-forms'
625
+ # tag_field) computes that id per instance. Verbatim, never re-scoped;
626
+ # exactly one of field/input: per call — the field form stays the
627
+ # blessed default:
628
+ # reactive_filter(input: "#user_tags_query")
629
+ def reactive_filter(field = nil, input: nil, option: nil, group: nil, empty: nil)
630
+ # nil-presence, NOT truthiness: `input: cond && "#sel"` with cond false
631
+ # must fail loudly in filter_selector! below (a boolean is never a
632
+ # selector), never slip into the field branch and emit a dead binding.
633
+ if field && !input.nil?
608
634
  raise ArgumentError,
609
- "reactive_filter(input:) was removed in issue #186 name the driving field: " \
610
- "reactive_filter(:q) (compiles to [name=\"q\"], scope-aware; option defaults to [role=option])."
635
+ "reactive_filter takes ONE driving-input forma field name (reactive_filter(:q), " \
636
+ "scope-aware) OR input: (a raw CSS selector for a name-less input), not both"
637
+ end
638
+ if field.nil? && input.nil?
639
+ raise ArgumentError,
640
+ "reactive_filter needs a field name — reactive_filter(:q) — or the input: " \
641
+ "escape hatch (a raw CSS selector, e.g. input: \"#tags_query\")"
611
642
  end
612
- raise ArgumentError, "reactive_filter needs a field name — reactive_filter(:q)" if field.nil?
613
643
 
614
644
  data = {
615
645
  # Compile the field to a scoped [name="…"] selector (same scope convention
616
- # reactive_field uses, so the filter input aligns with its own field).
617
- reactive_filter_input: %([name="#{scoped_field_name(field)}"]),
646
+ # reactive_field uses, so the filter input aligns with its own field) — or
647
+ # take the input: selector verbatim (issue #224).
648
+ reactive_filter_input: input.nil? ? %([name="#{scoped_field_name(field)}"]) : filter_selector!(:input, input),
618
649
  # option defaults to the [role=option] convention; a kwarg overrides it.
619
650
  reactive_filter_option: option ? filter_selector!(:option, option) : "[role=option]"
620
651
  }
@@ -680,9 +711,30 @@ module Phlex
680
711
  # and reactive_listnav (Arrow/Enter/Escape; Enter picks the highlighted
681
712
  # option via its own tagsPick trigger, and reactive_tags_add only adds
682
713
  # the TYPED text when nothing is highlighted — no double add).
683
- def reactive_tags(field = nil)
714
+ #
715
+ # Issue #224: `name:` is the ESCAPE HATCH for an instance-dynamic wire
716
+ # name — a form builder (phlex-forms' tag_field) computes "user[tags]"
717
+ # per instance, which the class-level reactive_scope compile can't
718
+ # express. Verbatim, NEVER re-scoped (reactive_field's explicit-name
719
+ # precedent); exactly one of field/name: per call:
720
+ # reactive_tags(name: "user[tags]")
721
+ def reactive_tags(field = nil, name: nil)
722
+ # nil-presence, NOT truthiness: `name: cond && "user[tags]"` with cond
723
+ # false must fail loudly in verbatim_name_selector!, never silently
724
+ # fall through to the field branch.
725
+ unless name.nil?
726
+ if field
727
+ raise ArgumentError,
728
+ "reactive_tags takes ONE field form — a field name (reactive_tags(:tags), scope-aware) " \
729
+ "OR name: (a verbatim wire name, never re-scoped), not both"
730
+ end
731
+ return { data: { reactive_tags_field: verbatim_name_selector!(:reactive_tags, name) } }
732
+ end
733
+
684
734
  if field.nil? || field.to_s.strip.empty?
685
- raise ArgumentError, "reactive_tags needs a field name — reactive_tags(:tags)"
735
+ raise ArgumentError,
736
+ "reactive_tags needs a field name — reactive_tags(:tags) — or the name: " \
737
+ "escape hatch (a verbatim wire name, e.g. name: \"user[tags]\")"
686
738
  end
687
739
 
688
740
  # Compile the field to a scoped [name="…"] selector, the reactive_filter
@@ -785,7 +837,15 @@ module Phlex
785
837
  # a `reactive_scope :order` component emits
786
838
  # order[line_items_attributes][3][quantity] (never a nested-bracket
787
839
  # corruption of the scope wrap).
788
- def nested_field_name(association, field, index: NESTED_NEW_ROW)
840
+ #
841
+ # Issue #224: `scope:` is the per-call ESCAPE HATCH — a form builder's
842
+ # object name is per-instance ("order", or itself bracketed for a
843
+ # nested fieldset: "user[profile]"), which the class-level
844
+ # reactive_scope can't express. Used verbatim as the wrap and WINS over
845
+ # reactive_scope:
846
+ # nested_field_name(:line_items, :quantity, scope: "order")
847
+ # # => order[line_items_attributes][NEW_ROW][quantity]
848
+ def nested_field_name(association, field, index: NESTED_NEW_ROW, scope: nil)
789
849
  nested_identifier!(:nested_field_name, :association, association)
790
850
  nested_identifier!(:nested_field_name, :field, field)
791
851
  unless index.to_s == NESTED_NEW_ROW || index.to_s.match?(/\A\d+\z/)
@@ -794,7 +854,9 @@ module Phlex
794
854
  "got #{index.inspect} — anything else corrupts the bracketed wire name"
795
855
  end
796
856
 
797
- "#{scoped_field_name(:"#{association}_attributes")}[#{index}][#{field}]"
857
+ base = :"#{association}_attributes"
858
+ prefix = scope.nil? ? scoped_field_name(base) : "#{nested_scope!(scope)}[#{base}]"
859
+ "#{prefix}[#{index}][#{field}]"
798
860
  end
799
861
 
800
862
  # The container cloned rows land in — one per association, inside the
@@ -810,10 +872,23 @@ module Phlex
810
872
  # naming the hidden field the client mirrors the rows into on every
811
873
  # add/remove/input. The default (:attributes) is unchanged — the plain
812
874
  # accepts_nested_attributes_for wire.
813
- def reactive_nested_list(association, as: :attributes)
814
- name = nested_identifier!(:reactive_nested_list, :association, association)
815
- data = { reactive_nested_list: name }
816
- return { data: } if as == :attributes
875
+ #
876
+ # Issue #224: `name:` is the verbatim ESCAPE HATCH for that hidden
877
+ # field's wire name — a form builder's "order[todos]" the class-level
878
+ # reactive_scope compile can't express. JSON-mode only (the
879
+ # :attributes mode has no field to name); never re-scoped:
880
+ # reactive_nested_list(:todos, as: :json, name: "order[todos]")
881
+ def reactive_nested_list(association, as: :attributes, name: nil)
882
+ assoc = nested_identifier!(:reactive_nested_list, :association, association)
883
+ data = { reactive_nested_list: assoc }
884
+ if as == :attributes
885
+ unless name.nil?
886
+ raise ArgumentError,
887
+ "reactive_nested_list(name:) only applies to as: :json — it names the hidden JSON " \
888
+ "sync field; the :attributes mode has no field to name"
889
+ end
890
+ return { data: }
891
+ end
817
892
 
818
893
  unless as == :json
819
894
  raise ArgumentError,
@@ -824,9 +899,17 @@ module Phlex
824
899
  # JSON mode: mark the container and name the hidden field the client
825
900
  # keeps in sync — a scope-aware [name="…"] selector, the same
826
901
  # convention reactive_tags/reactive_filter use so the field resolves
827
- # under reactive_scope too.
828
- data[:reactive_nested_json] = name
829
- data[:reactive_nested_json_field] = %([name="#{scoped_field_name(association)}"])
902
+ # under reactive_scope too. name: takes the wire name verbatim
903
+ # (issue #224).
904
+ data[:reactive_nested_json] = assoc
905
+ # nil-presence, NOT truthiness (the reactive_tags rationale): a falsy
906
+ # non-nil name: must fail loudly in verbatim_name_selector!.
907
+ data[:reactive_nested_json_field] =
908
+ if name.nil?
909
+ %([name="#{scoped_field_name(association)}"])
910
+ else
911
+ verbatim_name_selector!(:reactive_nested_list, name)
912
+ end
830
913
  { data: }
831
914
  end
832
915
 
@@ -1109,10 +1192,12 @@ module Phlex
1109
1192
  # A reactive_filter/reactive_listnav selector, validated non-blank and
1110
1193
  # stringified (issue #163). A blank selector is a dead binding — the
1111
1194
  # client would silently match nothing — so it fails loudly at render,
1112
- # like reactive_show's predicate validation.
1195
+ # like reactive_show's predicate validation. A boolean is rejected too
1196
+ # (issue #224): `input: cond && "#sel"` with cond false would otherwise
1197
+ # stringify to the plausible-looking dead selector "false".
1113
1198
  def filter_selector!(name, value)
1114
1199
  selector = value.to_s
1115
- if selector.strip.empty?
1200
+ if value == true || value == false || selector.strip.empty?
1116
1201
  raise ArgumentError,
1117
1202
  "reactive_filter/reactive_listnav #{name}: needs a CSS selector, got #{value.inspect}"
1118
1203
  end
@@ -1135,6 +1220,54 @@ module Phlex
1135
1220
  value
1136
1221
  end
1137
1222
 
1223
+ # A verbatim wire name for the name:-form escape hatches (issue #224) —
1224
+ # an instance-dynamic name (a form builder's "user[tags]") used exactly
1225
+ # as given, never re-scoped. The name is interpolated into a
1226
+ # double-quoted [name="…"] attribute selector the CLIENT passes to
1227
+ # querySelectorAll, so anything that breaks a CSS string breaks the
1228
+ # binding IN THE BROWSER: a `"` ends the string early, a `\` CSS-escapes
1229
+ # (a trailing one swallows the closing quote — the selector silently
1230
+ # matches the wrong name), and a raw control character (newline) makes
1231
+ # querySelectorAll THROW, aborting the controller's connect. All fail
1232
+ # loudly here at render instead. Booleans are rejected with the blank
1233
+ # check: `name: cond && "user[tags]"` with cond false must never
1234
+ # compile the plausible-looking [name="false"].
1235
+ def verbatim_name_selector!(helper, name)
1236
+ value = name.to_s
1237
+ if name == true || name == false || value.strip.empty?
1238
+ raise ArgumentError,
1239
+ "#{helper} name: needs a non-blank wire name (e.g. name: \"user[tags]\"), got #{name.inspect}"
1240
+ end
1241
+ if value.match?(/["\\\x00-\x1f]/)
1242
+ raise ArgumentError,
1243
+ "#{helper} name: can't contain a double quote, backslash, or control character — " \
1244
+ "the name is compiled into a [name=\"…\"] selector the client queries with, " \
1245
+ "got #{name.inspect}"
1246
+ end
1247
+
1248
+ %([name="#{value}"])
1249
+ end
1250
+
1251
+ # The per-call scope: override for nested_field_name (issue #224) — a
1252
+ # form builder's parent prefix, possibly itself bracketed
1253
+ # ("user[profile]"), validated non-blank. Used verbatim as the wrap.
1254
+ # String/Symbol only: `scope: cond && "order"` with cond false would
1255
+ # otherwise stringify to the silently-corrupting "false[…]" wire name.
1256
+ def nested_scope!(scope)
1257
+ unless scope.is_a?(String) || scope.is_a?(Symbol)
1258
+ raise ArgumentError,
1259
+ "nested_field_name scope: needs a String or Symbol parent prefix " \
1260
+ "(e.g. scope: \"order\"), got #{scope.inspect}"
1261
+ end
1262
+
1263
+ value = scope.to_s
1264
+ return value unless value.strip.empty?
1265
+
1266
+ raise ArgumentError,
1267
+ "nested_field_name scope: needs a non-blank parent prefix (e.g. scope: \"order\"), " \
1268
+ "got #{scope.inspect}"
1269
+ end
1270
+
1138
1271
  # An association/field name for the nested-rows wire (issue #208),
1139
1272
  # validated to a plain Ruby identifier at render — it becomes an
1140
1273
  # attribute value, a CSS selector fragment, AND a bracketed param name,
@@ -50,6 +50,8 @@ module Phlex
50
50
  state_keys: :@reactive_own_state_keys,
51
51
  collections: :@reactive_own_collections,
52
52
  computes: :@reactive_own_computes,
53
+ # Completion bindings — reactive_on_complete (issue #226).
54
+ on_completes: :@reactive_own_on_completes,
53
55
  record_key: :@reactive_own_record_key,
54
56
  # Form-field namespace — reactive_scope (issue #180).
55
57
  scope: :@reactive_own_scope,
@@ -104,6 +104,15 @@ module Phlex
104
104
  # default-deny; the client interpreter re-checks the shape).
105
105
  ComputeDefinition = Data.define(:name, :inputs, :outputs, :reducer, :input_types, :mirror)
106
106
 
107
+ # A declared completion binding (issue #226): `conditions` are the
108
+ # pre-compiled ShowConditions DNF groups (the ONE conditions language,
109
+ # including the #226 length: form); `ops` is the validated [[op, args],…]
110
+ # client-op chain. The generic controller evaluates the conditions over
111
+ # the owned fields on input/change and runs the ops on the RISING EDGE —
112
+ # the conditions' first flip to true — through the same frozen CLIENT_OPS
113
+ # whitelist as on_client. The connect/morph pass arms without firing.
114
+ OnCompleteDefinition = Data.define(:name, :conditions, :ops)
115
+
107
116
  # A declared add/remove-row collection (issue #35): the list contract tied
108
117
  # into one unit — the per-row item component, the container DOM id rows
109
118
  # live in, an optional companion count id, an optional empty-state
@@ -170,6 +170,21 @@ module Phlex
170
170
  append("focus_first", target_args(to, global:))
171
171
  end
172
172
 
173
+ # --- Submit (issue #226) ---
174
+ #
175
+ # submit(to = :root) — requestSubmit() the TARGET'S OWN form: the target
176
+ # itself when it IS a form, else its form owner (input.form, honoring a
177
+ # form= attribute), else the nearest ancestor form. requestSubmit runs
178
+ # constraint validation and fires a REAL cancelable `submit` event, so it
179
+ # composes with both a native/Turbo form and an on(:action, event:
180
+ # "submit") interception. ACTOR-ONLY like focus: allowed from on_client /
181
+ # reply.js / a reducer's $ops, refused in broadcast_to(js:) — a broadcast
182
+ # submit would force-submit every subscriber's form.
183
+
184
+ def submit(to = :root, global: false)
185
+ append("submit", target_args(to, global:))
186
+ end
187
+
173
188
  # --- Text content (issue #159) ---
174
189
  #
175
190
  # text(to, value) — set the target's textContent (stringified; nil clears).
@@ -17,6 +17,9 @@ module Phlex
17
17
  # (..10) -> lte 10
18
18
  # (...10) -> lt 10
19
19
  # (10..20) -> gte 10 AND lte 20 (two terms, one group)
20
+ # { length: 6 } -> len_eq 6 (issue #226 — the value's
21
+ # { length: 6.. } / (4..8) CODEPOINT count; Integer Ranges reuse
22
+ # the threshold vocabulary as len_*)
20
23
  # Under unless: each term is NEGATED (De Morgan):
21
24
  # scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
22
25
  # predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
@@ -29,6 +32,12 @@ module Phlex
29
32
  # bounded-range complement splits). There is NO expression surface: every
30
33
  # term is a declared literal predicate — the pre-#180 default-deny posture.
31
34
  module ShowConditions
35
+ # The length-predicate wire keys (issue #226) — the Ruby half; the client
36
+ # mirrors them in SHOW_LENGTH_KEYS. Length is counted in CODEPOINTS
37
+ # (String#length here, [...value].length there) so multibyte values agree
38
+ # — the shared fixture's emoji vector proves it.
39
+ LENGTH_KEYS = %w[len_eq len_gte len_gt len_lte len_lt].freeze
40
+
32
41
  module_function
33
42
 
34
43
  # Compile if:/if_any:/unless: into DNF groups. Loud validation at render
@@ -128,6 +137,7 @@ module Phlex
128
137
  case value
129
138
  when Range then range_terms(name, value)
130
139
  when Array then [membership_term(name, value)]
140
+ when Hash then length_terms(name, value)
131
141
  else [{ "field" => name, "equals" => equals_literal(value) }]
132
142
  end
133
143
  end
@@ -171,6 +181,93 @@ module Phlex
171
181
  end
172
182
  end
173
183
 
184
+ # --- the length predicate (issue #226) ----------------------------------
185
+
186
+ # A Hash value names a STRUCTURAL predicate — today only length:. Exact
187
+ # Integer -> one len_eq term; an Integer Range -> len_gte/len_lte/len_lt
188
+ # terms exactly like range_terms. Length is a total function over the
189
+ # value's codepoints (blank/absent -> 0), so { length: 0 } legitimately
190
+ # matches a blank field.
191
+ def length_terms(name, hash)
192
+ value = length_value!(name, hash)
193
+ case value
194
+ when Integer
195
+ validate_length_literal!(name, value)
196
+ [{ "field" => name, "len_eq" => value }]
197
+ when Range then length_range_terms(name, value)
198
+ else raise_length_kind(name, value)
199
+ end
200
+ end
201
+
202
+ def length_range_terms(name, range)
203
+ first = range.begin
204
+ last = range.end
205
+ raise_length_kind(name, range) if first.nil? && last.nil?
206
+ [first, last].compact.each { validate_length_literal!(name, it) }
207
+
208
+ terms = []
209
+ terms << { "field" => name, "len_gte" => first } unless first.nil?
210
+ if last
211
+ terms << { "field" => name, (range.exclude_end? ? "len_lt" : "len_lte") => last }
212
+ end
213
+ terms
214
+ end
215
+
216
+ # The complement of a length predicate, under unless:. Exact length
217
+ # negates to the outside disjunction (len < n OR len > n) — two
218
+ # alternatives, like a bounded numeric range; ranges complement each leg
219
+ # (not-gte -> lt, not-lte -> gt, not-lt -> gte). Length is total, so
220
+ # unlike the numeric complements there is no blank/NaN fail-closed
221
+ # asymmetry — the complement is exact.
222
+ def length_complement(name, hash)
223
+ value = length_value!(name, hash)
224
+ case value
225
+ when Integer
226
+ validate_length_literal!(name, value)
227
+ [[{ "field" => name, "len_lt" => value }], [{ "field" => name, "len_gt" => value }]]
228
+ when Range
229
+ first = value.begin
230
+ last = value.end
231
+ raise_length_kind(name, value) if first.nil? && last.nil?
232
+ [first, last].compact.each { validate_length_literal!(name, it) }
233
+
234
+ low = first.nil? ? nil : [{ "field" => name, "len_lt" => first }]
235
+ high =
236
+ if last.nil? then nil
237
+ elsif value.exclude_end? then [{ "field" => name, "len_gte" => last }]
238
+ else [{ "field" => name, "len_gt" => last }]
239
+ end
240
+ [low, high].compact
241
+ else raise_length_kind(name, value)
242
+ end
243
+ end
244
+
245
+ # The one Hash key must be length: — anything else is a typo'd predicate
246
+ # that must fail at render, never silently in the browser.
247
+ def length_value!(name, hash)
248
+ unless hash.size == 1 && hash.keys.first.to_s == "length"
249
+ raise ArgumentError,
250
+ "reactive_show: #{name.inspect} Hash value supports only length: " \
251
+ "(got #{hash.keys.inspect})"
252
+ end
253
+
254
+ hash.values.first
255
+ end
256
+
257
+ def validate_length_literal!(name, value)
258
+ return if value.is_a?(Integer) && value >= 0
259
+
260
+ raise ArgumentError,
261
+ "reactive_show: #{name.inspect} length: takes a non-negative Integer " \
262
+ "(a codepoint count), got #{value.inspect}"
263
+ end
264
+
265
+ def raise_length_kind(name, value)
266
+ raise ArgumentError,
267
+ "reactive_show: #{name.inspect} length: takes a non-negative Integer " \
268
+ "or an Integer Range, got #{value.inspect}"
269
+ end
270
+
174
271
  # --- the value language (negated, under unless:) -----------------------
175
272
 
176
273
  # A field => value under unless: -> a LIST of alternative term-lists.
@@ -182,6 +279,7 @@ module Phlex
182
279
  name = field.to_s
183
280
  case value
184
281
  when Range then range_complement(name, value)
282
+ when Hash then length_complement(name, value)
185
283
  when Array
186
284
  list = value.map(&:to_s)
187
285
  raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
@@ -223,10 +321,28 @@ module Phlex
223
321
  if term.key?("equals") then value == term["equals"]
224
322
  elsif term.key?("not") then value != term["not"]
225
323
  elsif term.key?("in") then term["in"].include?(value)
324
+ elsif (key = LENGTH_KEYS.find { term.key?(it) }) then length_term_matches?(key, term[key], value)
226
325
  else numeric_term_matches?(term, value)
227
326
  end
228
327
  end
229
328
 
329
+ # len_* — compare the value's CODEPOINT count (String#length) against the
330
+ # Integer literal. Length is total (blank/absent -> 0), so every value is
331
+ # decidable; a malformed non-Integer literal (a hand-built term) is
332
+ # fail-closed, mirroring the client's warn-skip.
333
+ def length_term_matches?(key, literal, value)
334
+ return false unless literal.is_a?(Integer)
335
+
336
+ length = value.to_s.length
337
+ case key
338
+ when "len_eq" then length == literal
339
+ when "len_gte" then length >= literal
340
+ when "len_gt" then length > literal
341
+ when "len_lte" then length <= literal
342
+ when "len_lt" then length < literal
343
+ end
344
+ end
345
+
230
346
  # gte/gt/lte/lt — coerce the field value to a number; a blank/non-numeric
231
347
  # value is fail-closed (never matches). Mirrors the client's
232
348
  # numericPredicateMatches (blank/whitespace -> NaN -> false).
@@ -33,9 +33,12 @@ module Phlex
33
33
  # up without ever sharing a mutable context across threads.
34
34
  ThreadViewContext = Struct.new(:view_context, :builder, :renderer, :generation)
35
35
 
36
- # Focus ops are actor-only (they steal focus): a js broadcast rejects a
37
- # broadcast that carries one. Names mirror Phlex::Reactive::JS's focus verbs.
38
- BROADCAST_REFUSED_OPS = %w[focus focus_first].freeze
36
+ # Actor-only ops: a js broadcast rejects a broadcast that carries one.
37
+ # Focus ops steal focus in every subscriber's tab (issue #96); submit
38
+ # (issue #226) would force-submit every subscriber's form. Both belong to
39
+ # the actor's own reply (reply.js) or gesture (on_client / a reducer's
40
+ # $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
41
+ BROADCAST_REFUSED_OPS = %w[focus focus_first submit].freeze
39
42
 
40
43
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
41
44
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -516,16 +519,18 @@ module Phlex
516
519
  # instrumentation + pgbus-threading path for the class-level and module-level
517
520
  # broadcast_to, so the transport logic has exactly one spelling.)
518
521
 
519
- # Validate + serialize broadcast ops: reject focus-class ops (they steal
520
- # focus in every tab) and an empty chain (a dead broadcast), then return
521
- # the JSON wire form. Works on a JS chain (inspect .ops) and a raw array.
522
+ # Validate + serialize broadcast ops: reject actor-only ops (focus steals
523
+ # focus in every tab; submit force-submits every subscriber's form) and an
524
+ # empty chain (a dead broadcast), then return the JSON wire form. Works on
525
+ # a JS chain (inspect .ops) and a raw array.
522
526
  def broadcast_js_ops_json(ops)
523
527
  pairs = ops.is_a?(Phlex::Reactive::JS) ? ops.ops : Array(ops)
524
528
  refused = pairs.map { |name, _| name.to_s } & Phlex::Reactive::Streamable::BROADCAST_REFUSED_OPS
525
529
  unless refused.empty?
526
530
  raise ArgumentError,
527
- "broadcast_js_to refuses focus op(s) #{refused.join(", ")} — broadcasting focus " \
528
- "steals it in every subscriber's tab. Focus is an actor-reply concern (reply.js)."
531
+ "broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
532
+ "steals it in every subscriber's tab; broadcasting submit force-submits every " \
533
+ "subscriber's form. These are actor concerns (reply.js / on_client / $ops)."
529
534
  end
530
535
 
531
536
  Phlex::Reactive::Response.js_ops_json(ops)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.12.1"
5
+ VERSION = "0.12.3"
6
6
  end
7
7
  end
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.12.1
4
+ version: 0.12.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson