phlex-reactive 0.9.4 → 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,45 +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
- def reactive_show(field, **options)
445
- predicates = options.slice(*SHOW_PREDICATE_KEYS)
446
- attrs = options.except(*SHOW_PREDICATE_KEYS)
447
- unless predicates.size == 1
448
- raise ArgumentError,
449
- "reactive_show(#{field.inspect}) needs exactly one predicate equals:, not:, or " \
450
- "in: got #{predicates.keys.inspect}"
451
- end
452
-
453
- key, value = normalize_show_predicate(predicates, "reactive_show(#{field.inspect})").first
454
- data = { reactive_show_field: field.to_s }
455
- data[:"reactive_show_#{key}"] = key == "in" ? value.to_json : value
456
-
457
- mix({ data: }, attrs)
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" }
454
+ # ]))
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.
474
+ def reactive_show(field = nil, **options)
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
485
+
486
+ result = mix({ data: }, attrs)
487
+ apply_first_paint_hidden(result, groups, values_override)
458
488
  end
459
489
 
460
490
  # Client-side option filtering for the searchable combobox (issue #163)
@@ -527,33 +557,35 @@ module Phlex
527
557
  # reactive_root — the client reads it off the controller element):
528
558
  #
529
559
  # div(**mix(reactive_root, reactive_show_targets(:mode,
530
- # "#advanced-tab" => { equals: "advanced" },
531
- # "#advanced-panel" => { equals: "advanced" },
532
- # "#basic-note" => { not: "advanced" })))
560
+ # "#advanced-tab" => "advanced", # equals
561
+ # "#advanced-panel" => "advanced",
562
+ # "#premium-note" => %w[gold platinum]))) # membership
533
563
  #
534
564
  # Same posture as mirror: — opt-in and declared, never implicit (a plain
535
565
  # reactive_show stays root-isolated); targets are SINGLE ID SELECTORS
536
566
  # only, enforced here at declare time AND warn-and-skipped by the client
537
- # interpreter (two-sided default-deny); the predicate is the same
538
- # literal-only reactive_show vocabulary (exactly one of equals:/not:/
539
- # in:, no expressions); and the toggle is `hidden` only — no innerHTML,
540
- # no attribute freedom. The FIELD read stays owned (#15): you can only
541
- # drive outside visibility from a field this root owns. A target id not
542
- # 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.
543
575
  #
544
576
  # ONE call per root. Phlex `mix` space-joins duplicate STRING data
545
577
  # values, so a second call's JSON would concatenate into an unparseable
546
578
  # attr and the client would drop BOTH maps (it warns when that
547
579
  # happens). Several fields therefore go in ONE call via the hash form:
548
580
  #
549
- # reactive_show_targets(mode: { "#advanced-tab" => { equals: "advanced" } },
550
- # kind: { "#premium-note" => { not: "basic" } })
581
+ # reactive_show_targets(mode: { "#advanced-tab" => "advanced" },
582
+ # kind: { "#premium-note" => %w[gold platinum] })
551
583
  def reactive_show_targets(field, targets = nil)
552
584
  field_maps = targets.nil? ? field : { field => targets }
553
585
  unless field_maps.is_a?(Hash) && field_maps.any?
554
586
  raise ArgumentError,
555
587
  "reactive_show_targets needs at least one target " \
556
- "(:field, \"#id\" => { equals:/not:/in: ... }), got #{field_maps.inspect}"
588
+ "(:field, \"#id\" => value), got #{field_maps.inspect}"
557
589
  end
558
590
 
559
591
  normalized = field_maps.to_h do |name, map|
@@ -658,12 +690,17 @@ module Phlex
658
690
  reactive_record_for_nested.update!(**nested_attributes(association, attrs), **extra)
659
691
  end
660
692
 
661
- # The declared reactive_show predicates (issue #161): a literal match on
662
- # the controlling field's current value. Exactly one per binding —
663
- # enforced loudly at render (a dead binding must not no-op in the
664
- # browser). `not`/`in` are Ruby keywords, so they arrive via **options
665
- # rather than named kwargs.
666
- SHOW_PREDICATE_KEYS = %i[equals not in].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
667
704
 
668
705
  # The declared optimistic-hint class ops (issue #98): the cosmetic class
669
706
  # vocabulary the client applies instantly and reverts on failure. Enforced
@@ -688,50 +725,110 @@ module Phlex
688
725
  selector
689
726
  end
690
727
 
691
- # Normalize + validate ONE field's target map (issue #164): each key a
692
- # single id selector (loud raise — the declare-time half of the
693
- # two-sided default-deny), each value one literal predicate. Shared by
694
- # 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.
695
733
  def normalize_show_target_map(field, targets)
696
734
  unless targets.is_a?(Hash) && targets.any?
697
735
  raise ArgumentError,
698
736
  "reactive_show_targets(#{field.inspect}) needs at least one target " \
699
- "(\"#id\" => { equals:/not:/in: ... }), got #{targets.inspect}"
737
+ "(\"#id\" => value), got #{targets.inspect}"
700
738
  end
701
739
 
702
- targets.to_h do |selector, predicate|
740
+ targets.to_h do |selector, value|
703
741
  selector = selector.to_s
704
742
  unless selector.match?(DSL::MIRROR_ID_SELECTOR)
705
743
  raise ArgumentError,
706
744
  "reactive_show_targets(#{field.inspect}) target #{selector.inspect} must be a single " \
707
745
  "ID selector (\"#id\") — cross-root visibility is id-allowlisted, like mirror: (#159)"
708
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
709
753
 
710
- context = "reactive_show_targets(#{field.inspect}) target #{selector.inspect}"
711
- [selector, normalize_show_predicate(predicate.is_a?(Hash) ? predicate : {}, context)]
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]
712
757
  end
713
758
  end
714
759
 
715
- # Validate ONE declared literal show predicate (issues #161/#164) and
716
- # return its wire form { "equals" => "v" } / { "not" => "v" } /
717
- # { "in" => ["a", …] } (a real array: reactive_show JSON-encodes it into
718
- # its own attr; the reactive_show_targets map embeds it directly).
719
- # Shared by both helpers so the vocabulary and the loud validation can
720
- # never drift. `context` names the call site in the error.
721
- def normalize_show_predicate(predicates, context)
722
- unless predicates.size == 1 && SHOW_PREDICATE_KEYS.include?(predicates.keys.first)
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)
765
+ unless field.nil?
766
+ raise ArgumentError,
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."
770
+ end
771
+
772
+ if (pred = options.keys & LEGACY_SHOW_PREDICATE_KEYS).any?
773
+ raise ArgumentError,
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)."
777
+ end
778
+ if (conn = options.keys & LEGACY_SHOW_CONNECTIVE_KEYS).any?
779
+ replacement = conn.first == :all ? "if: { … }" : "if_any: [{ … }, { … }]"
723
780
  raise ArgumentError,
724
- "#{context} needs exactly one predicate equals:, not:, or in: — " \
725
- "got #{predicates.keys.inspect}"
781
+ "reactive_show(#{conn.first}: [...]) was removeduse #{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."
726
784
  end
785
+ end
786
+
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)
727
795
 
728
- key, value = predicates.first
729
- return { key.to_s => value.to_s } unless key == :in
796
+ provided = show_values(values_override)
797
+ return attrs if provided.nil?
730
798
 
731
- list = Array(value).map(&:to_s)
732
- raise ArgumentError, "#{context} in: needs at least one value" if list.empty?
799
+ referenced = Phlex::Reactive::ShowConditions.fields(groups)
800
+ return attrs unless referenced.all? { provided.key?(it) }
733
801
 
734
- { "in" => list }
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
831
+ end
735
832
  end
736
833
 
737
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
@@ -57,13 +57,24 @@ module Phlex
57
57
  # end
58
58
  # end
59
59
  #
60
- # Assembled from three cohesive concerns (issue #115) — one include, zero
61
- # public API change:
62
- # * Component::DSL — the five declaration registries (via
63
- # Component::Registry) + from_identity
64
- # * Component::Identityreactive_token + the hot-path ivar precomputation
65
- # * Component::Helpers — reply/js, reactive_attrs/root, on/on_client,
66
- # the field/select/text bindings, and the nested-attributes helpers
60
+ # Assembled from cohesive concerns (issue #115, restructured in #180) — one
61
+ # include, zero public API change. The include stack, in order:
62
+ # * Phlex::Reactive::Streamable — the render/broadcast/#id surface (mixed in
63
+ # first, so its methods sit below the component's own).
64
+ # * Phlex::Reactive::ClientBindings — the CLIENT-ONLY surface (issue #180),
65
+ # itself Component::DSL (the declaration registries via Component::Registry
66
+ # + from_identity) + Component::Helpers (reply/js, reactive_attrs/root,
67
+ # on/on_client, the field/select/text bindings, reactive_show/filter/
68
+ # compute, and the nested-attributes helpers). ClientBindings is the ONE
69
+ # implementation of that surface, shared with the standalone client-only
70
+ # include; it carries NO token machinery.
71
+ # * Component::Identity — reactive_token + the hot-path ivar precomputation.
72
+ # Its presence is what makes the server-action macros (action/
73
+ # reactive_record/reactive_state) legal here — they raise on a
74
+ # ClientBindings-only class that lacks it.
75
+ # * Component::Lazy — reactive_lazy (deferred initial mount, issue #165).
76
+ # So a full, token-bearing reactive component is a SUPERSET of a client-only
77
+ # one, not a fork.
67
78
  module Component
68
79
  extend ActiveSupport::Concern
69
80
  include Phlex::Reactive::Streamable
@@ -111,9 +122,12 @@ module Phlex
111
122
  end
112
123
  end
113
124
 
114
- include DSL
125
+ # ClientBindings (issue #180) is the client-only surface (DSL + Helpers),
126
+ # tokenless and Streamable-free. Component includes it as the ONE
127
+ # implementation, then layers Identity + Lazy (and Streamable, above) on
128
+ # top — a token-bearing root is a superset of the client-only one.
129
+ include ClientBindings
115
130
  include Identity
116
- include Helpers
117
131
  include Lazy
118
132
  end
119
133
  end