phlex-reactive 0.9.5 → 0.10.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.
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The blessed CLIENT-ONLY include (issue #180). A view that only shows/hides
6
+ # (reactive_show), filters (reactive_filter), computes client-side
7
+ # (reactive_compute), or runs zero-round-trip ops (on_client/js) — with NO
8
+ # server actions and NO signed token — includes this instead of the full
9
+ # Phlex::Reactive::Component.
10
+ #
11
+ # Why it exists: including the full Component pulls in Streamable, whose
12
+ # class/instance methods (replace/update/append/prepend/remove,
13
+ # to_stream_*) collide by NAME with an app's own Turbo-stream concern — so
14
+ # adopters cherry-picked an internal module and hand-rolled a token-less root
15
+ # (and defined a dummy #id "never read at render" just to keep
16
+ # phlex_reactive:doctor quiet). ClientBindings is that missing seam, blessed:
17
+ #
18
+ # class ExerciseFilter < ApplicationComponent
19
+ # include Phlex::Reactive::ClientBindings
20
+ #
21
+ # reactive_scope :form
22
+ # def reactive_values = { role: @role }
23
+ #
24
+ # def view_template
25
+ # div(**reactive_root) do # token-less: no #id required
26
+ # select(name: "form[role]") { role_options }
27
+ # div(**reactive_show(if: { role: "individual" })) { demographics }
28
+ # end
29
+ # end
30
+ # end
31
+ #
32
+ # It includes the DECLARATION DSL and the view Helpers, but NOT Streamable
33
+ # (nothing to clobber) or Identity (no token). So a ClientBindings class never
34
+ # enters Streamable.registered_classes and is invisible to
35
+ # phlex_reactive:doctor's #id check — no dummy #id, no nag.
36
+ #
37
+ # The client-only DSL macros (reactive_scope, reactive_compute) work; the
38
+ # SERVER-ACTION macros (action, reactive_record, reactive_state) RAISE at
39
+ # class-definition time — without Identity they could only silently no-op
40
+ # (sign nothing, dispatch nowhere), so the guard fails loudly (the guard lives
41
+ # in Component::DSL#require_server_actions!, keyed on Identity presence).
42
+ #
43
+ # Helpers is token-tolerant (issue #180): reactive_attrs emits a token only
44
+ # when reactive_token is available, and reactive_root uses #id only when
45
+ # defined — so the client-only view helpers work with no Identity/Streamable.
46
+ #
47
+ # The full Component includes ClientBindings too (ONE implementation of the
48
+ # client-only surface), then layers Streamable + Identity on top — so a
49
+ # token-bearing reactive_root is a SUPERSET, not a fork.
50
+ module ClientBindings
51
+ extend ActiveSupport::Concern
52
+
53
+ include Component::DSL
54
+ include Component::Helpers
55
+ end
56
+ end
57
+ end
@@ -29,10 +29,31 @@ module Phlex
29
29
  MIRROR_ID_SELECTOR = /\A#[A-Za-z_][\w-]*\z/
30
30
 
31
31
  class_methods do
32
+ # Guard the three SERVER-ACTION macros (issue #180). ClientBindings
33
+ # includes this DSL for its client-only macros (reactive_scope,
34
+ # reactive_compute) but does NOT include Identity/Streamable — so a
35
+ # signed token is never minted and no action endpoint dispatches to
36
+ # this class. Declaring action/reactive_record/reactive_state there
37
+ # would silently no-op (an action that can never fire, a record that
38
+ # signs nothing). Fail LOUDLY at class-definition time instead — the
39
+ # default-deny posture. Keyed on Identity presence: a full
40
+ # Phlex::Reactive::Component includes it (passes); a ClientBindings-only
41
+ # class does not (raises).
42
+ def require_server_actions!(macro)
43
+ return if include?(Phlex::Reactive::Component::Identity)
44
+
45
+ raise ArgumentError,
46
+ "#{self}: `#{macro}` needs the full Phlex::Reactive::Component — it signs a token / " \
47
+ "dispatches a server action, which Phlex::Reactive::ClientBindings deliberately omits " \
48
+ "(client-only: no token, no endpoint). Include Phlex::Reactive::Component for actions, " \
49
+ "or drop `#{macro}` and use the client-only helpers (reactive_show/filter/compute, on_client)."
50
+ end
51
+
32
52
  # Declare the ActiveRecord (GlobalID-able) record this component is
33
53
  # rebuilt from. The signed token carries its GlobalID; the server
34
54
  # re-finds it on each action. State lives in the DB.
35
55
  def reactive_record(name)
56
+ require_server_actions!(:reactive_record)
36
57
  Registry.write_scalar(self, :record_key, name.to_sym)
37
58
  end
38
59
 
@@ -44,9 +65,24 @@ module Phlex
44
65
  Registry.resolve_scalar(self, :record_key, :reactive_record_key)
45
66
  end
46
67
 
68
+ # Declare (with a name) OR read (bare) a form-field NAMESPACE for this
69
+ # component's bindings (issue #180). With `reactive_scope :form`,
70
+ # reactive_show / reactive_values use bare symbols (`:director`) and the
71
+ # client resolves the owned field as `[name="form[director]"]`; the root
72
+ # emits `data-reactive-scope="form"`. Bare `reactive_scope` reads the
73
+ # nearest declared scope up the ancestry (nil when none) — the
74
+ # dual-purpose form matching reactive_compute's getter/setter. A
75
+ # per-binding raw string field name still passes through unscoped.
76
+ def reactive_scope(name = nil)
77
+ return Registry.resolve_scalar(self, :scope, :reactive_scope) if name.nil?
78
+
79
+ Registry.write_scalar(self, :scope, name.to_sym)
80
+ end
81
+
47
82
  # Opt into signed STATE for record-less components only.
48
83
  # reactive_state :count, :open
49
84
  def reactive_state(*names)
85
+ require_server_actions!(:reactive_state)
50
86
  Registry.append(self, :state_keys, names.map(&:to_sym))
51
87
  end
52
88
 
@@ -112,6 +148,7 @@ module Phlex
112
148
  # Array params accept BOTH a JSON array and a Rails-style index hash
113
149
  # ({ "0" => ..., "1" => ... }), so a fields_for collection works either way.
114
150
  def action(name, params: {})
151
+ require_server_actions!(:action)
115
152
  Registry.write_entry(
116
153
  self, :actions, name.to_sym,
117
154
  Action.new(name: name.to_sym, params: params, schema: Phlex::Reactive::ParamSchema.compile(params))
@@ -73,16 +73,24 @@ module Phlex
73
73
  # signed identity token. Spread onto the root:
74
74
  # div(id:, **reactive_attrs) { ... }
75
75
  def reactive_attrs
76
- data = {
77
- controller: "reactive",
78
- reactive_token_value: reactive_token
79
- }
76
+ data = { controller: "reactive" }
77
+ # A CLIENT-ONLY component (Phlex::Reactive::ClientBindings, issue #180)
78
+ # has no Identity, so no token — the root is tokenless (show/filter/
79
+ # compute need no signed round trip). reactive_token is private, so the
80
+ # include-private `respond_to?` mirrors to_stream_token's guard.
81
+ data[:reactive_token_value] = reactive_token if respond_to?(:reactive_token, true)
80
82
  # Client debug mode (issue #108): stamp the flag so the generic controller
81
83
  # console.groups every dispatch. STRING "true", not boolean — Phlex renders
82
84
  # a boolean-true attr VALUELESS, which getAttribute reads as "" (falsy in
83
85
  # JS), so the client's attr check would never fire (the on()/warn_unsaved
84
86
  # precedent). Off by default → no key, no string, zero client surface.
85
87
  data[:reactive_debug] = "true" if Phlex::Reactive.debug
88
+ # Field-name scope (issue #180): the client prefixes bare binding field
89
+ # names with `scope[...]`. Omitted entirely when undeclared — byte-stable
90
+ # wire for unscoped components.
91
+ if self.class.respond_to?(:reactive_scope) && (scope = self.class.reactive_scope)
92
+ data[:reactive_scope] = scope.to_s
93
+ end
86
94
  { data: }
87
95
  end
88
96
 
@@ -112,11 +120,16 @@ module Phlex
112
120
  # guard (STRING "true" — a boolean-true attr renders valueless, which the
113
121
  # client's param reader sees as "" → falsy).
114
122
  def reactive_root(**overrides)
115
- root_id = overrides.delete(:id) || id
123
+ # A CLIENT-ONLY component (ClientBindings, issue #180) needs no #id
124
+ # there's no token to self-match by id. Use an explicit override, else
125
+ # #id when the component defines one, else nothing (no id attr).
126
+ root_id = overrides.delete(:id)
127
+ root_id = id if root_id.nil? && respond_to?(:id)
116
128
  track_dirty = overrides.delete(:track_dirty)
117
129
  warn_unsaved = overrides.delete(:warn_unsaved)
118
130
 
119
- attrs = mix({ **reactive_attrs }, overrides, { id: root_id })
131
+ attrs = mix({ **reactive_attrs }, overrides)
132
+ attrs = mix(attrs, { id: root_id }) unless root_id.nil?
120
133
  attrs = mix(attrs, { data: { action: "input->reactive#trackDirty" } }) if track_dirty
121
134
  attrs = mix(attrs, { data: { reactive_warn_unsaved: "true" } }) if warn_unsaved
122
135
  attrs
@@ -416,62 +429,62 @@ module Phlex
416
429
  span(**mix({ data: { reactive_text: name.to_s } }, attrs)) { initial }
417
430
  end
418
431
 
419
- # Value-conditional visibility (issue #161) — the x-show / data-show /
420
- # wire:show equivalent, entirely client-side. Spread onto the element to
421
- # show/hide; it declares which OWNED field controls it plus ONE literal
422
- # predicate, and the generic controller toggles the `hidden` attribute
423
- # from the field's CURRENT value on every input/change — no round trip,
424
- # no token, no bespoke Stimulus controller:
425
- #
426
- # div(**reactive_show(:mode, not: "off")) { "details" }
427
- # div(**reactive_show(:kind, equals: "premium")) { "premium panel" }
428
- # div(**reactive_show(:size, in: %w[l xl])) { "surcharge note" }
429
- # div(**reactive_show(:gift, equals: true)) { "gift message" }
430
- #
431
- # Exactly one predicate — equals:, not:, or in: (a list) — and every
432
- # value is STRINGIFIED for a literal match against the field's value
433
- # (a checkbox compares its checked state as "true"/"false", so
434
- # `equals: true` is the checkbox form; a radio group reads the checked
435
- # radio's value). This is a DECLARED LITERAL MATCH, never an expression
436
- # there is no eval surface (default-deny, like the op vocabulary).
437
- #
438
- # Scope: presentational only, strictly less powerful than the js ops —
439
- # it reads an owned field (#15 ownership) and toggles `hidden` on an
440
- # owned element. The client seeds visibility at connect and reconciles
441
- # after a morph; render the initial `hidden:` yourself (from the same
442
- # server state that renders the field) to avoid a first-paint flash.
443
- # Extra attrs deep-merge over the binding (mix), like reactive_field.
444
- # Compound forms (issue #176 part A) — visibility that depends on MORE
445
- # THAN ONE field, or a threshold, staying inside the eval-free literal
446
- # contract. `all:`/`any:` fold a list of per-field terms (each the same
447
- # equals:/not:/in:/gte:/gt:/lte:/lt: vocabulary) with one fixed
448
- # connective; the compound has no single controlling field, so it
449
- # serializes as ONE JSON attr (data-reactive-show) like
450
- # reactive_show_targets rather than the flat data-reactive-show-* attrs:
451
- #
452
- # # visible while type == "individual" AND country != "domestic"
453
- # div(**reactive_show(all: [
454
- # { field: :type, equals: "individual" },
455
- # { field: :country, not: "domestic" }
432
+ # Value-conditional visibility (issue #180) — the x-show / data-show /
433
+ # wire:show equivalent, entirely client-side, in ONE Ruby-native
434
+ # conditions language. Spread onto the element to show/hide; declare an
435
+ # if:/if_any:/unless: condition with where-style values, and the generic
436
+ # controller toggles `hidden` from the fields' CURRENT values on every
437
+ # input/change — no round trip, no token, no bespoke Stimulus controller.
438
+ #
439
+ # THE VALUE LANGUAGE (Phlex::Reactive::ShowConditions):
440
+ # Hash = AND (multiple keys ANDed) if: { a: "x", b: "y" }
441
+ # Array = membership if: { size: %w[l xl] }
442
+ # Range = threshold (10.. / ..10 / ...10 / 10..20) if: { qty: 10.. }
443
+ # true/false = checkbox checked-state if: { gift: true }
444
+ # nil = blank if: { note: nil }
445
+ # unless: = negation (composes with if:/if_any:)
446
+ #
447
+ # div(**reactive_show(unless: { mode: "off" })) { "details" }
448
+ # div(**reactive_show(if: { size: %w[l xl] })) { "surcharge" }
449
+ # div(**reactive_show(if: { qty: 10.. })) { "bulk note" }
450
+ # # OR-of-AND — director OR (shareholder AND role == "individual"):
451
+ # div(**reactive_show(if_any: [
452
+ # { director: true },
453
+ # { shareholder: true, role: "individual" }
456
454
  # ]))
457
- # # visible while director OR shareholder is checked
458
- # div(**reactive_show(any: [
459
- # { field: :director, equals: true },
460
- # { field: :shareholder, equals: true }
461
- # ]))
462
- # # a numeric threshold, single field or as a compound term
463
- # div(**reactive_show(:quantity, gte: 10)) # visible while qty >= 10
464
- # div(**reactive_show(all: [{ field: :amount, gte: 5000 }, ...]))
465
- #
466
- # `all:`/`any:` are additive and mutually exclusive with the single-field
467
- # form and with each other — enforced loudly at render, like the
468
- # one-predicate rule. A single-field `all:` with one term degrades to the
469
- # flat form's behaviour client-side.
455
+ #
456
+ # There is no expression surface — every term is a declared literal, so
457
+ # the same default-deny posture as before. Everything normalizes to ONE
458
+ # DNF wire attr (data-reactive-show='{"any":[[term,…],…]}').
459
+ #
460
+ # FIRST PAINT is computed for you: declare reactive_values (an instance
461
+ # method returning { field => value }) and every binding whose fields are
462
+ # all provided renders the correct initial `hidden:` server-side — no
463
+ # per-section mirror method, no flash. An explicit `hidden:` always wins;
464
+ # a per-call `values:` override merges over reactive_values.
465
+ #
466
+ # `disable: true` disables the section's OWNED controls while it is hidden
467
+ # so a switched-away value never submits. `reactive_scope :form` lets
468
+ # bindings use bare field symbols ([name="form[field]"] on the client).
469
+ #
470
+ # Scope: presentational only, strictly less powerful than the js ops — it
471
+ # reads owned fields (#15 ownership) and toggles `hidden` (+ optionally
472
+ # `disabled`) on owned elements. Extra attrs deep-merge over the binding
473
+ # (mix), like reactive_field.
470
474
  def reactive_show(field = nil, **options)
471
- connectives = options.slice(*SHOW_CONNECTIVE_KEYS)
472
- return reactive_show_compound(field, connectives, options) if connectives.any?
475
+ reject_legacy_show_surface!(field, options)
476
+
477
+ conditions = options.slice(*SHOW_CONDITION_KEYS)
478
+ disable = options.delete(:disable)
479
+ values_override = options.delete(:values)
480
+ attrs = options.except(*SHOW_CONDITION_KEYS)
481
+
482
+ groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
483
+ data = { reactive_show: { "any" => groups }.to_json }
484
+ data[:reactive_show_disable] = "true" if disable
473
485
 
474
- reactive_show_single(field, options)
486
+ result = mix({ data: }, attrs)
487
+ apply_first_paint_hidden(result, groups, values_override)
475
488
  end
476
489
 
477
490
  # Client-side option filtering for the searchable combobox (issue #163)
@@ -544,33 +557,35 @@ module Phlex
544
557
  # reactive_root — the client reads it off the controller element):
545
558
  #
546
559
  # div(**mix(reactive_root, reactive_show_targets(:mode,
547
- # "#advanced-tab" => { equals: "advanced" },
548
- # "#advanced-panel" => { equals: "advanced" },
549
- # "#basic-note" => { not: "advanced" })))
560
+ # "#advanced-tab" => "advanced", # equals
561
+ # "#advanced-panel" => "advanced",
562
+ # "#premium-note" => %w[gold platinum]))) # membership
550
563
  #
551
564
  # Same posture as mirror: — opt-in and declared, never implicit (a plain
552
565
  # reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
553
566
  # only, enforced here at declare time AND warn-and-skipped by the client
554
- # interpreter (two-sided default-deny); the predicate is the same
555
- # literal-only reactive_show vocabulary (exactly one of equals:/not:/
556
- # in:, no expressions); and the toggle is `hidden` only — no innerHTML,
557
- # no attribute freedom. The FIELD read stays owned (#15): you can only
558
- # drive outside visibility from a field this root owns. A target id not
559
- # on the page is silently skipped (an unrendered tab pane is normal).
567
+ # interpreter (two-sided default-deny); the value uses the same where-
568
+ # style conditions vocabulary (scalar/Array/Range, no expressions); and
569
+ # the toggle is `hidden` only — no innerHTML, no attribute freedom. The
570
+ # FIELD read stays owned (#15): you can only drive outside visibility from
571
+ # a field this root owns. A target id not on the page is silently skipped
572
+ # (an unrendered tab pane is normal). A target value is positive-only (no
573
+ # per-target unless:) — express "not X" as a membership Array over the
574
+ # remaining options.
560
575
  #
561
576
  # ONE call per root. Phlex `mix` space-joins duplicate STRING data
562
577
  # values, so a second call's JSON would concatenate into an unparseable
563
578
  # attr and the client would drop BOTH maps (it warns when that
564
579
  # happens). Several fields therefore go in ONE call via the hash form:
565
580
  #
566
- # reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
567
- # kind: { "#premium-note" => { not: "basic" } })
581
+ # reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
582
+ # kind: { "#premium-note" => %w[gold platinum] })
568
583
  def reactive_show_targets(field, targets = nil)
569
584
  field_maps = targets.nil? ? field : { field => targets }
570
585
  unless field_maps.is_a?(Hash) && field_maps.any?
571
586
  raise ArgumentError,
572
587
  "reactive_show_targets needs at least one target " \
573
- "(:field, \"#id\" => { equals:/not:/in: ... }), got #{field_maps.inspect}"
588
+ "(:field, \"#id\" => value), got #{field_maps.inspect}"
574
589
  end
575
590
 
576
591
  normalized = field_maps.to_h do |name, map|
@@ -675,33 +690,17 @@ module Phlex
675
690
  reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
676
691
  end
677
692
 
678
- # The declared reactive_show predicates: a literal match on the
679
- # controlling field's current value. Exactly one per binding — enforced
680
- # loudly at render (a dead binding must not no-op in the browser).
681
- # `not`/`in` are Ruby keywords, so they arrive via **options rather than
682
- # named kwargs. Issue #161 shipped the string-literal trio; issue #176
683
- # adds the numeric-threshold quartet below.
684
- SHOW_LITERAL_KEYS = %i[equals not in].freeze
685
-
686
- # The numeric-threshold predicates (issue #176 part B): gte/gt/lte/lt
687
- # compare the field value coerced to a Number against a literal number.
688
- # Still a declared literal — the RHS is a number baked into the
689
- # attribute, never an expression — the only new capability is ordered
690
- # comparison. The RHS must be an actual Numeric in Ruby (stricter, so a
691
- # typo like gte: "10" fails at render, not silently in the browser).
692
- SHOW_NUMERIC_KEYS = %i[gte gt lte lt].freeze
693
-
694
- # Every predicate key a reactive_show / reactive_show_targets binding may
695
- # declare — the literal trio plus the numeric quartet. Exactly one of
696
- # these decides a single binding; a compound term declares one too.
697
- SHOW_PREDICATE_KEYS = (SHOW_LITERAL_KEYS + SHOW_NUMERIC_KEYS).freeze
698
-
699
- # The compound connectives (issue #176 part A): fold a list of per-field
700
- # literal/numeric terms with ONE fixed keyword. Not an expression surface
701
- # — the connective is one of two fixed keywords, each term is the same
702
- # declared predicate vocabulary. Mutually exclusive with a single field
703
- # and with each other (enforced loudly at render).
704
- SHOW_CONNECTIVE_KEYS = %i[all any].freeze
693
+ # The conditions-language kwargs (issue #180): if:/if_any:/unless:
694
+ # compiled by Phlex::Reactive::ShowConditions into the DNF wire. The ONE
695
+ # vocabulary; there are no predicate kwargs any more.
696
+ SHOW_CONDITION_KEYS = %i[if if_any unless].freeze
697
+
698
+ # The removed 0.9.5 surface (issue #180 clean break): each of these
699
+ # kwargs and a positional field — now raises a GUIDED error printing
700
+ # the if:/if_any:/unless: rewrite. Kept only to detect the legacy call
701
+ # shape; nothing here reaches the wire.
702
+ LEGACY_SHOW_PREDICATE_KEYS = %i[equals not in gte gt lte lt].freeze
703
+ LEGACY_SHOW_CONNECTIVE_KEYS = %i[all any].freeze
705
704
 
706
705
  # The declared optimistic-hint class ops (issue #98): the cosmetic class
707
706
  # vocabulary the client applies instantly and reverts on failure. Enforced
@@ -726,154 +725,110 @@ module Phlex
726
725
  selector
727
726
  end
728
727
 
729
- # Normalize + validate ONE field's target map (issue #164): each key a
730
- # single id selector (loud raise — the declare-time half of the
731
- # two-sided default-deny), each value one literal predicate. Shared by
732
- # both reactive_show_targets call forms.
728
+ # Normalize + validate ONE field's target map (issue #180): each key a
729
+ # single id selector (loud raise — the declare-time half of the two-sided
730
+ # default-deny), each value a where-style condition value (scalar/Array/
731
+ # Range) that compiles to ONE DNF group (terms ANDed). Shared by both
732
+ # reactive_show_targets call forms.
733
733
  def normalize_show_target_map(field, targets)
734
734
  unless targets.is_a?(Hash) && targets.any?
735
735
  raise ArgumentError,
736
736
  "reactive_show_targets(#{field.inspect}) needs at least one target " \
737
- "(\"#id\" => { equals:/not:/in: ... }), got #{targets.inspect}"
737
+ "(\"#id\" => value), got #{targets.inspect}"
738
738
  end
739
739
 
740
- targets.to_h do |selector, predicate|
740
+ targets.to_h do |selector, value|
741
741
  selector = selector.to_s
742
742
  unless selector.match?(DSL::MIRROR_ID_SELECTOR)
743
743
  raise ArgumentError,
744
744
  "reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
745
745
  "ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
746
746
  end
747
+ if value.is_a?(Hash)
748
+ raise ArgumentError,
749
+ "reactive_show_targets(#{field.inspect}) target #{selector.inspect}: the { equals: ... } " \
750
+ "predicate form was removed — pass a bare value (#{selector.inspect} => \"advanced\", " \
751
+ "=> %w[a b] for a set, => 10.. for a threshold)"
752
+ end
747
753
 
748
- context = "reactive_show_targets(#{field.inspect}) target #{selector.inspect}"
749
- [selector, normalize_show_predicate(predicate.is_a?(Hash) ? predicate : {}, context)]
750
- end
751
- end
752
-
753
- # Validate ONE declared show predicate (issues #161/#164/#176) and
754
- # return its wire form — { "equals" => "v" } / { "not" => "v" } /
755
- # { "in" => ["a", …] } / { "gte" => 10 } (a numeric RHS stays a real
756
- # Number so the wire carries a JSON number, not a string). reactive_show
757
- # JSON-encodes an in: array into its own attr; the reactive_show_targets
758
- # map and a compound term embed the predicate directly. Shared by every
759
- # helper so the vocabulary and the loud validation can never drift.
760
- # `context` names the call site in the error.
761
- def normalize_show_predicate(predicates, context)
762
- unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
763
- raise ArgumentError,
764
- "#{context} needs exactly one predicate — equals:, not:, in:, or " \
765
- "gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
766
- end
767
-
768
- key, value = predicates.first
769
- return normalize_show_numeric(key, value, context) if SHOW_NUMERIC_KEYS.include?(key)
770
- return { key.to_s => value.to_s } unless key == :in
771
-
772
- list = Array(value).map(&:to_s)
773
- raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
774
-
775
- { "in" => list }
776
- end
777
-
778
- # Validate a numeric threshold predicate (issue #176 part B): the RHS
779
- # must be an actual Numeric (a String "10" is a typo caught here, not a
780
- # silent NaN in the browser). It rides the wire as a JSON number so the
781
- # client compares Number(value) against it directly.
782
- def normalize_show_numeric(key, value, context)
783
- unless value.is_a?(Numeric)
784
- raise ArgumentError,
785
- "#{context} #{key}: needs a number (an ordered comparison against a literal), " \
786
- "got #{value.inspect}"
787
- end
788
-
789
- { key.to_s => value }
790
- end
791
-
792
- # The single-field reactive_show form (issue #161, extended for numeric
793
- # thresholds in #176): one owned field + one predicate → flat
794
- # data-reactive-show-* attrs. An in: list JSON-encodes into its own attr;
795
- # a numeric threshold or a literal stringifies into the flat attr and the
796
- # client Number()-coerces the numeric case back on read.
797
- def reactive_show_single(field, options)
798
- if field.nil?
799
- raise ArgumentError,
800
- "reactive_show needs a field (reactive_show(:mode, equals: …)) or a compound " \
801
- "connective (reactive_show(all: [...]) / any:) — got neither"
802
- end
803
-
804
- predicates = options.slice(*SHOW_PREDICATE_KEYS)
805
- attrs = options.except(*SHOW_PREDICATE_KEYS)
806
- unless predicates.size == 1
807
- raise ArgumentError,
808
- "reactive_show(#{field.inspect}) needs exactly one predicate — equals:, not:, in:, " \
809
- "or gte:/gt:/lte:/lt: — got #{predicates.keys.inspect}"
754
+ # A target compiles to ONE group: the field-vs-value condition.
755
+ groups = Phlex::Reactive::ShowConditions.normalize(if: { field => value })
756
+ [selector, groups.first]
810
757
  end
811
-
812
- key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
813
- data = { reactive_show_field: field.to_s }
814
- # in: → JSON array; a numeric threshold or a literal → the value as-is.
815
- # Phlex stringifies it into the flat attr; the client re-reads via
816
- # getAttribute (always a string) and Number()-coerces the numeric case.
817
- data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value.to_s
818
-
819
- mix({ data: }, attrs)
820
758
  end
821
759
 
822
- # The compound reactive_show form (issue #176 part A): `all:`/`any:` fold
823
- # a list of per-field terms with one fixed connective, serialized as one
824
- # JSON attr (data-reactive-show). A field alongside a connective, or two
825
- # connectives, is a contradiction raise. Each term is validated through
826
- # the SAME predicate normalizer, so a term's vocabulary never drifts from
827
- # the single-field form.
828
- def reactive_show_compound(field, connectives, options)
760
+ # Reject the removed 0.9.5 reactive_show surface (issue #180 clean break)
761
+ # with a GUIDED error printing the if:/if_any:/unless: rewrite. A
762
+ # positional field, a predicate kwarg (equals:/not:/in:/gte:/…), or a
763
+ # connective (all:/any:) all land here before any conditions parsing.
764
+ def reject_legacy_show_surface!(field, options)
829
765
  unless field.nil?
830
766
  raise ArgumentError,
831
- "reactive_show got a field AND all:/any: — the compound and single-field forms are " \
832
- "mutually exclusive; use one flat binding OR a compound list, not both"
833
- end
834
- unless connectives.size == 1
835
- raise ArgumentError,
836
- "reactive_show needs exactly one of all:/any: (one fixed connective), " \
837
- "got #{connectives.keys.inspect}"
767
+ "reactive_show no longer takes a positional field — the conditions language is " \
768
+ "keyword-only: reactive_show(if: { #{field}: <value> }) (a Range is a threshold, " \
769
+ "an Array is a set, unless: negates). See the 0.10 upgrade notes."
838
770
  end
839
771
 
840
- connective, terms = connectives.first
841
- # A top-level predicate alongside a connective (reactive_show(all: [...],
842
- # equals: "x")) is a misuse — predicates belong INSIDE terms. Catch it
843
- # loudly at render rather than leaking `equals="x"` as a bogus HTML attr
844
- # (the mix at the tail treats every leftover kwarg as a literal
845
- # attribute). Same default-deny posture as the single-field path.
846
- if (stray = options.slice(*SHOW_PREDICATE_KEYS)).any?
772
+ if (pred = options.keys & LEGACY_SHOW_PREDICATE_KEYS).any?
847
773
  raise ArgumentError,
848
- "reactive_show #{connective}: got a top-level predicate #{stray.keys.inspect} " \
849
- "predicates belong INSIDE each term ({ field: …, #{stray.keys.first}: }), not beside the connective"
774
+ "reactive_show(#{pred.first}: ...) was removed use the conditions language: " \
775
+ "reactive_show(if: { field: value }). equals:/not: if:/unless:, in: an Array value, " \
776
+ "gte:/gt:/lte:/lt: → a Range value (10.., ..10, ...10)."
850
777
  end
851
-
852
- attrs = options.except(*SHOW_CONNECTIVE_KEYS)
853
- unless terms.is_a?(Array) && terms.any?
778
+ if (conn = options.keys & LEGACY_SHOW_CONNECTIVE_KEYS).any?
779
+ replacement = conn.first == :all ? "if: { … }" : "if_any: [{ … }, { … }]"
854
780
  raise ArgumentError,
855
- "reactive_show #{connective}: needs at least one term " \
856
- "({ field: …, equals:/not:/in:/gte:/… }), got #{terms.inspect}"
781
+ "reactive_show(#{conn.first}: [...]) was removed use #{replacement}. " \
782
+ "all: → if: (one AND group); any: if_any: (OR of AND groups). Terms are now " \
783
+ "field => value pairs, not { field:, equals: } hashes."
857
784
  end
858
-
859
- normalized = terms.map { normalize_show_term(connective, it) }
860
- mix({ data: { reactive_show: { connective.to_s => normalized }.to_json } }, attrs)
861
785
  end
862
786
 
863
- # Normalize + validate ONE compound term (issue #176 part A): a Hash with
864
- # a :field and exactly one predicate. The predicate goes through the
865
- # shared normalizer; the field is stringified into the term. Returns
866
- # { "field" => name, <predicate> } so the wire is uniform with the
867
- # reactive_show_targets embedded-predicate shape.
868
- def normalize_show_term(connective, term)
869
- context = "reactive_show #{connective}: term"
870
- unless term.is_a?(Hash) && term.key?(:field)
871
- raise ArgumentError, "#{context} — each term needs a field (got #{term.inspect})"
787
+ # Compute the first-paint `hidden:` from reactive_values (issue #180) so
788
+ # the author never restates the predicate as a Ruby mirror method. Fires
789
+ # only when EVERY field the binding references is provided (by
790
+ # reactive_values, merged under a per-call `values:` override); otherwise
791
+ # the attrs are returned untouched. An explicit `hidden:` in the caller's
792
+ # attrs always wins (it survives the mix, so this is a no-op then).
793
+ def apply_first_paint_hidden(attrs, groups, values_override)
794
+ return attrs if attrs.key?(:hidden)
795
+
796
+ provided = show_values(values_override)
797
+ return attrs if provided.nil?
798
+
799
+ referenced = Phlex::Reactive::ShowConditions.fields(groups)
800
+ return attrs unless referenced.all? { provided.key?(it) }
801
+
802
+ visible = Phlex::Reactive::ShowConditions.match?(groups, provided)
803
+ attrs.merge(hidden: !visible)
804
+ end
805
+
806
+ # The { field => current-string-value } map the first-paint evaluator
807
+ # reads: reactive_values (if the component declares it) merged under a
808
+ # per-call values: override, both stringified the way the client reads a
809
+ # field (checkbox → "true"/"false", nil → ""). nil when neither source
810
+ # exists — first paint then no-ops (no flash guarantee is the author's,
811
+ # exactly as before).
812
+ def show_values(values_override)
813
+ base = respond_to?(:reactive_values) ? reactive_values : nil
814
+ return nil if base.nil? && values_override.nil?
815
+
816
+ merged = {}
817
+ merged.merge!(base) if base
818
+ merged.merge!(values_override) if values_override
819
+ merged.to_h { |name, value| [name.to_s, show_value_string(value)] }
820
+ end
821
+
822
+ # Stringify a reactive_values entry the way the client's #showFieldValue
823
+ # reports the live field: a boolean is the checkbox checked-state string,
824
+ # nil is blank, everything else is to_s.
825
+ def show_value_string(value)
826
+ case value
827
+ when true then "true"
828
+ when false then "false"
829
+ when nil then ""
830
+ else value.to_s
872
831
  end
873
-
874
- field = term[:field]
875
- predicate = normalize_show_predicate(term.except(:field), "#{context} (field #{field.inspect})")
876
- { "field" => field.to_s }.merge(predicate)
877
832
  end
878
833
 
879
834
  # True when the hint declares checked: :keep — the click-bound
@@ -51,6 +51,8 @@ module Phlex
51
51
  collections: :@reactive_own_collections,
52
52
  computes: :@reactive_own_computes,
53
53
  record_key: :@reactive_own_record_key,
54
+ # Form-field namespace — reactive_scope (issue #180).
55
+ scope: :@reactive_own_scope,
54
56
  # Deferred reply segments — reactive_lazy (issue #165).
55
57
  lazy: :@reactive_own_lazy,
56
58
  # verify_authorized opt-out (issue #168): a scalar bare flag (skip the