phlex-reactive 0.13.3 → 0.13.4

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