phlex-reactive 0.14.3 → 0.15.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -0
  3. data/README.md +258 -5
  4. data/app/controllers/phlex/reactive/actions_controller.rb +28 -18
  5. data/app/javascript/phlex/reactive/core.js +7 -1
  6. data/app/javascript/phlex/reactive/core.min.js +6 -2
  7. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  8. data/app/javascript/phlex/reactive/early.js +28 -9
  9. data/app/javascript/phlex/reactive/early.min.js +2 -2
  10. data/app/javascript/phlex/reactive/early.min.js.map +3 -3
  11. data/app/javascript/phlex/reactive/features/bindings.js +188 -107
  12. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  13. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  14. data/app/javascript/phlex/reactive/features/form.js +75 -46
  15. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  16. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  17. data/app/javascript/phlex/reactive/features/persist.js +12 -3
  18. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  19. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  20. data/app/javascript/phlex/reactive/features/presence.js +205 -0
  21. data/app/javascript/phlex/reactive/features/presence.min.js +4 -0
  22. data/app/javascript/phlex/reactive/features/presence.min.js.map +10 -0
  23. data/app/javascript/phlex/reactive/features/sortable.js +463 -0
  24. data/app/javascript/phlex/reactive/features/sortable.min.js +4 -0
  25. data/app/javascript/phlex/reactive/features/sortable.min.js.map +10 -0
  26. data/app/javascript/phlex/reactive/inspect.js +5 -1
  27. data/app/javascript/phlex/reactive/inspect.min.js +2 -2
  28. data/app/javascript/phlex/reactive/inspect.min.js.map +3 -3
  29. data/app/javascript/phlex/reactive/reactive_controller.js +5 -1
  30. data/app/javascript/phlex/reactive/reactive_controller.min.js +6 -2
  31. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +10 -8
  32. data/app/javascript/phlex/reactive/runtime.js +286 -18
  33. data/lib/phlex/reactive/component/dsl.rb +5 -0
  34. data/lib/phlex/reactive/component/editable.rb +78 -0
  35. data/lib/phlex/reactive/component/helpers.rb +81 -8
  36. data/lib/phlex/reactive/component/presence_declaration.rb +118 -0
  37. data/lib/phlex/reactive/component/registry.rb +3 -1
  38. data/lib/phlex/reactive/component/sortable.rb +212 -0
  39. data/lib/phlex/reactive/component.rb +1 -0
  40. data/lib/phlex/reactive/defer.rb +5 -0
  41. data/lib/phlex/reactive/engine.rb +9 -4
  42. data/lib/phlex/reactive/js.rb +24 -0
  43. data/lib/phlex/reactive/presence/component_methods.rb +130 -0
  44. data/lib/phlex/reactive/presence/roster.rb +90 -0
  45. data/lib/phlex/reactive/presence/transport.rb +154 -0
  46. data/lib/phlex/reactive/presence.rb +162 -0
  47. data/lib/phlex/reactive/presence_render_job.rb +26 -0
  48. data/lib/phlex/reactive/shortcut.rb +105 -0
  49. data/lib/phlex/reactive/streamable.rb +7 -3
  50. data/lib/phlex/reactive/version.rb +1 -1
  51. data/lib/phlex/reactive.rb +10 -0
  52. metadata +16 -1
@@ -13,6 +13,8 @@ module Phlex
13
13
  module Helpers
14
14
  extend ActiveSupport::Concern
15
15
  include Selection
16
+ include Sortable
17
+ include Editable
16
18
 
17
19
  # The acting client's SSE connection id during the current action (nil
18
20
  # outside an action, or when the client isn't subscribed to a stream).
@@ -125,6 +127,10 @@ module Phlex
125
127
  (on_complete = self.class.reactive_on_complete_attr)
126
128
  data[:reactive_on_complete] = on_complete
127
129
  end
130
+ # Presence (issue #358): the signed src of the roster's presence
131
+ # stream, from which the client mints its subscription. Only a class
132
+ # that declares reactive_presence has the method.
133
+ data.merge!(reactive_presence_data) if respond_to?(:reactive_presence_data)
128
134
  { data: }
129
135
  end
130
136
 
@@ -278,6 +284,17 @@ module Phlex
278
284
  # (trailing-edge) — passing both raises ArgumentError.
279
285
  # div(**mix(reactive_root, on(:track, event: "scroll", window: true, throttle: 250)))
280
286
  #
287
+ # `shortcut:` (issue #357, also RESERVED) binds a page-level keyboard
288
+ # chord: a window keydown whose `mod` is meta on Apple platforms and
289
+ # ctrl elsewhere (rendered as a meta twin and a ctrl twin; the client
290
+ # runs the one for its platform). Unlike a plain window binding, the
291
+ # client prevents the chord's native default, and it skips the chord
292
+ # while focus is in an input, textarea, select or contenteditable
293
+ # (native text undo keeps working) unless the Hash form opts in. It
294
+ # decides event:, window: and outside:, so passing one raises.
295
+ # span(**on(:undo, shortcut: "mod+z"))
296
+ # span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
297
+ #
281
298
  # `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
282
299
  # COSMETIC hints the client applies the instant the trigger fires and
283
300
  # REVERTS if the round trip fails, so a click/toggle gives instant feedback
@@ -322,9 +339,9 @@ module Phlex
322
339
  # busy_on for scoped indicators.
323
340
  # button(**on(:save, busy: "Saving…")) { "Save" }
324
341
  # button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
325
- def on(action_name, event: "click", debounce: nil, throttle: nil, confirm: nil,
342
+ def on(action_name, event: nil, debounce: nil, throttle: nil, confirm: nil,
326
343
  window: false, once: false, outside: false, optimistic: nil, busy: nil,
327
- **params)
344
+ shortcut: nil, **params)
328
345
  reject_removed_on_kwargs!(action_name, params)
329
346
  # A typo'd or forgotten action renders fine and only surfaces as an
330
347
  # opaque 403 at CLICK time (the endpoint's default-deny). Under
@@ -360,8 +377,11 @@ module Phlex
360
377
  "exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
361
378
  end
362
379
 
363
- window_bound = window || outside
364
- action = "#{event}#{"@window" if window_bound}->reactive#dispatch#{":once" if once}"
380
+ # Guarded here, not inside: building the caller label allocates (on() is a hot path).
381
+ shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
382
+ event ||= "click"
383
+ window_bound = window || outside || !shortcut.nil?
384
+ action = trigger_descriptors(event, shortcut, window_bound, "dispatch", once)
365
385
  attrs = {
366
386
  data: {
367
387
  action:,
@@ -382,6 +402,9 @@ module Phlex
382
402
  # The client decides preventDefault behavior from event.params (never by
383
403
  # sniffing the descriptor), so EVERY window binding flags the param.
384
404
  attrs[:data][:reactive_window_param] = "true" if window_bound
405
+ # Issue #357: the client reads the shortcut to pick the twin for its
406
+ # platform, skip editable targets and prevent the chord.
407
+ attrs[:data][:reactive_shortcut_param] = shortcut_param_attr(shortcut) if shortcut
385
408
  # Force type="button" for click triggers so a bare button inside a <form>
386
409
  # can't submit it — EXCEPT when checked: :keep is declared: that hint's
387
410
  # whole point is to let a click-bound checkbox/radio flip natively, and a
@@ -404,7 +427,9 @@ module Phlex
404
427
  # modifiers (#80): outside-click-to-close a dropdown is
405
428
  # div(**mix(reactive_root, on_client(:click, js.hide("#menu"), outside: true)))
406
429
  # Window-bound triggers are never preventDefault-ed by the client and skip
407
- # the forced type="button".
430
+ # the forced type="button". `shortcut:` (issue #357) works as on()'s, on
431
+ # a bare :keydown only:
432
+ # div(**mix(reactive_root, on_client(:keydown, js.toggle("#palette"), shortcut: "mod+k")))
408
433
  #
409
434
  # Issue #271: each call emits ONE self-describing binding record —
410
435
  # data-reactive-ops-param='{"on":"click","window":true,"outside":true,"ops":[…]}'
@@ -435,7 +460,8 @@ module Phlex
435
460
  # Validation is loud: only a non-empty Phlex::Reactive::JS chain is
436
461
  # accepted — a dead trigger should fail at render, not no-op in the
437
462
  # browser.
438
- def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil)
463
+ def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
464
+ shortcut: nil)
439
465
  unless ops.is_a?(Phlex::Reactive::JS)
440
466
  raise ArgumentError,
441
467
  "on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
@@ -452,11 +478,15 @@ module Phlex
452
478
  "submit event this trigger is bound to. Bind the submit op to another event " \
453
479
  "(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
454
480
  end
455
- window_bound = window || outside
481
+ shortcut = client_shortcut!(event, shortcut, window:, outside:)
482
+ window_bound = window || outside || !shortcut.nil?
456
483
  record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
484
+ # The record's `on` stays the bare keydown: the client reads a key
485
+ # filter from `on`, and `mod` is not a key.
486
+ record["shortcut"] = shortcut.param if shortcut
457
487
  attrs = {
458
488
  data: {
459
- action: "#{event}#{"@window" if window_bound}->reactive#runOps#{":once" if once}",
489
+ action: trigger_descriptors(event, shortcut, window_bound, "runOps", once),
460
490
  # Space-free (issue #271): compact JSON has no structural
461
491
  # whitespace, so every space is inside a string, where \u0020
462
492
  # parses to the same string — and mix's space-join stays splittable.
@@ -1730,6 +1760,49 @@ module Phlex
1730
1760
  "on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
1731
1761
  end
1732
1762
 
1763
+ # Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
1764
+ # decides the event, the window binding and the containment rule, so
1765
+ # an explicit event:/window:/outside: next to it is a mistake.
1766
+ def shortcut_for!(caller, shortcut, event:, window:, outside:)
1767
+ { "event:" => event, "window:" => window, "outside:" => outside }.each do |name, value|
1768
+ next unless value
1769
+
1770
+ raise ArgumentError,
1771
+ "#{caller} got shortcut: with #{name} — a shortcut is always a window keydown " \
1772
+ "binding; drop #{name}"
1773
+ end
1774
+ Phlex::Reactive::Shortcut.parse(shortcut)
1775
+ end
1776
+
1777
+ # on_client's event is positional: with shortcut: it must be the bare
1778
+ # keydown (the key filter comes from the shortcut).
1779
+ def client_shortcut!(event, shortcut, window:, outside:)
1780
+ return if shortcut.nil?
1781
+
1782
+ unless event == "keydown"
1783
+ raise ArgumentError,
1784
+ "on_client(#{event.inspect}) got shortcut: — bind it to :keydown " \
1785
+ "(on_client(:keydown, ops, shortcut: \"mod+k\")); the key comes from the shortcut"
1786
+ end
1787
+ shortcut_for!("on_client(:keydown)", shortcut, event: nil, window:, outside:)
1788
+ end
1789
+
1790
+ # The data-action descriptors of an on()/on_client trigger: one, or a
1791
+ # shortcut's meta and ctrl twins (space-joined, as mix joins them).
1792
+ # The common, shortcut-less path is ONE interpolation (on() is a hot path).
1793
+ def trigger_descriptors(event, shortcut, window_bound, method, once)
1794
+ return "#{event}#{"@window" if window_bound}->reactive##{method}#{":once" if once}" unless shortcut
1795
+
1796
+ shortcut.filters.map { "keydown.#{it}@window->reactive##{method}#{":once" if once}" }.join(" ")
1797
+ end
1798
+
1799
+ # A Hash param is JSON (Stimulus's param reader parses it back into an
1800
+ # object); the String form stays a plain string.
1801
+ def shortcut_param_attr(shortcut)
1802
+ param = shortcut.param
1803
+ param.is_a?(Hash) ? param.to_json : param
1804
+ end
1805
+
1733
1806
  # Normalize + validate a pending-state hint (issue #181), returning its
1734
1807
  # JSON wire form. ONE vocabulary + ONE normalizer for both optimistic: (kind
1735
1808
  # :optimistic, reverts on FAILURE) and busy: (kind :busy, reverts on
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # The reactive_presence macro (issue #358). Not named `Presence`: this
7
+ # module is in every reactive component's constant lookup, where a bare
8
+ # noun would shadow a host app's kit component of that name (the #233
9
+ # rule). The behaviour it adds lives in Phlex::Reactive::Presence.
10
+ module PresenceDeclaration
11
+ extend ActiveSupport::Concern
12
+
13
+ # A presence name is the last part of the stream name, so it is one
14
+ # identifier: no colon (the stream-name separator), no spaces.
15
+ PRESENCE_NAME = /\A[a-z][a-z0-9_]*\z/
16
+
17
+ class_methods do
18
+ # Declare this component the roster of a presence stream:
19
+ # [record, :presence, name], or [*key, :presence, name] for a
20
+ # record-less component.
21
+ #
22
+ # reactive_record :board
23
+ # reactive_presence :viewers, state: { selected: :integer }, ttl: 45
24
+ #
25
+ # `state:` is each tab's ephemeral state, typed with the same table as
26
+ # action params; undeclared keys are dropped. `ttl:` is how many
27
+ # seconds a silent tab survives. Adds the presence_set action (what
28
+ # js.presence sends) and `presence` (the roster) to the component.
29
+ def reactive_presence(name, state: {}, ttl: Phlex::Reactive::Presence::DEFAULT_TTL, key: nil)
30
+ require_server_actions!(:reactive_presence)
31
+ declaration = build_presence_declaration(name, state, ttl, key)
32
+ refuse_dormant_presence! if reactive_dormant?
33
+ # The name's fit in pgbus's stream-name budget is configuration (the
34
+ # queue prefix), so it is refused here; the render re-checks it.
35
+ transport = Phlex::Reactive::Presence::Transport
36
+ if transport.wired? && !transport.fits?(declaration.name)
37
+ raise Phlex::Reactive::ConfigurationError, transport.over_budget_message(self, declaration.name)
38
+ end
39
+
40
+ # The generated action. Exempt from verify_authorized: the signed
41
+ # token already proves the actor rendered this root, and the write
42
+ # is typed, default-deny, ephemeral and lands only on the actor's own
43
+ # row (keyed by its secret SSE connection id). A component that needs
44
+ # a check defines authorize_presence_state!(**state); the action runs
45
+ # it first.
46
+ action :presence_set, params: declaration.state
47
+ skip_verify_authorized :presence_set
48
+ Registry.write_scalar(self, :presence, declaration)
49
+ include Phlex::Reactive::Presence::ComponentMethods
50
+
51
+ # Class load runs before a database exists: the signatures alone gate it.
52
+ transport.register_ttl(declaration.name, ttl) if transport.wired?
53
+ declaration
54
+ end
55
+
56
+ # The Phlex::Reactive::Presence::Declaration, or nil.
57
+ def reactive_presence_declaration
58
+ Registry.resolve_scalar(self, :presence, :reactive_presence_declaration)
59
+ end
60
+
61
+ # The presence stream name a roster of `record` subscribes to — what
62
+ # streams_authorize sees. For a strict per-record check at connect
63
+ # time: compare it with the key of each record the user may view.
64
+ # A record-less roster (key:) ignores `record`.
65
+ def reactive_presence_stream_name_for(record = nil)
66
+ declaration = reactive_presence_declaration
67
+ subject = declaration.key || [record]
68
+ Phlex::Reactive::Presence::Transport.stream_name([*subject, :presence, declaration.name])
69
+ end
70
+
71
+ private
72
+
73
+ def build_presence_declaration(name, state, ttl, key)
74
+ name = name.to_s
75
+ unless PRESENCE_NAME.match?(name)
76
+ raise ArgumentError,
77
+ "#{self.name}: reactive_presence name must be a lowercase identifier (it ends the stream " \
78
+ "name), got #{name.inspect}"
79
+ end
80
+ unless ttl.is_a?(Integer) && ttl.positive?
81
+ raise ArgumentError, "#{self.name}: reactive_presence ttl: must be a positive Integer (seconds), " \
82
+ "got #{ttl.inspect}"
83
+ end
84
+ refuse_ttl_within_heartbeat!(ttl)
85
+
86
+ state = state.transform_keys(&:to_sym)
87
+ if state.key?(Phlex::Reactive::Presence::NOTE_KEY.to_sym)
88
+ raise ArgumentError,
89
+ "#{self.name}: reactive_presence state key #{Phlex::Reactive::Presence::NOTE_KEY} is reserved"
90
+ end
91
+
92
+ Phlex::Reactive::Presence::Declaration.new(
93
+ name: name.to_sym, state: state.freeze, ttl:, key: key && Array(key).dup.freeze
94
+ )
95
+ end
96
+
97
+ # A ttl at or below the heartbeat would expire a live tab between two
98
+ # beats: its row would vanish and come back on the next one.
99
+ def refuse_ttl_within_heartbeat!(ttl)
100
+ heartbeat = Phlex::Reactive::Presence::Transport.heartbeat_interval
101
+ return if ttl > heartbeat
102
+
103
+ raise ArgumentError,
104
+ "#{name}: reactive_presence ttl: #{ttl} is at or below the transport's heartbeat interval " \
105
+ "(#{heartbeat} s), so a live tab would expire between two beats. Use more than #{heartbeat} " \
106
+ "(two missed beats is a good floor)."
107
+ end
108
+
109
+ def refuse_dormant_presence!
110
+ raise ArgumentError,
111
+ "#{name}: reactive_presence cannot be dormant — a dormant root never connects, so its tab " \
112
+ "would never join the stream. Drop reactive_dormant on this component."
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -66,7 +66,9 @@ module Phlex
66
66
  # verify_authorized opt-out (issue #168): a scalar bare flag (skip the
67
67
  # WHOLE component) plus a list of named actions.
68
68
  skip_all: :@reactive_own_skip_all,
69
- skip_actions: :@reactive_own_skip_actions
69
+ skip_actions: :@reactive_own_skip_actions,
70
+ # Presence roster — reactive_presence (issue #358).
71
+ presence: :@reactive_own_presence
70
72
  }.freeze
71
73
 
72
74
  # The identity-side hot-path memos swept by bump! (see the contract
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # Sortable lists (issue #350): drag rows to reorder a list, or drag a row
7
+ # in from another list, and ONE declared action receives the row and its
8
+ # new position — through the normal action pipeline (the signed token,
9
+ # the request queue, busy:, the reply).
10
+ #
11
+ # ul(**mix(reactive_root, reactive_sortable(:move, handle: "[data-drag-handle]"))) do
12
+ # @list.tasks.each do |task|
13
+ # li(id: dom_id(task), **reactive_sortable_item(task.id)) do
14
+ # span(data: { drag_handle: true }) { "⠿" }
15
+ # plain task.title
16
+ # end
17
+ # end
18
+ # end
19
+ #
20
+ # On drop the client sends the action with these params (declare the
21
+ # ones you want; default-deny drops the rest):
22
+ #
23
+ # item the dragged row's key (reactive_sortable_item(key))
24
+ # position its 0-based index in the target list after the drop
25
+ # after the key of the row it now follows, nil when it is first
26
+ # from "self" for a reorder, else the name of the list it came from
27
+ #
28
+ # The row moves in the DOM at once and moves back when the action fails
29
+ # (or its reply does not render the list). Rows can be dragged with a
30
+ # mouse, a pen or a finger (a 200 ms press), and with the keyboard: a
31
+ # focused row picks up on Space/Enter, moves with the arrow keys, drops
32
+ # on Space/Enter and goes back on Escape, each step announced through a
33
+ # live region in the words of `messages:`.
34
+ #
35
+ # The drag itself is the client's sortable feature module
36
+ # (features/sortable.js); a page without a sortable loads none of it on
37
+ # the split client.
38
+ module Sortable
39
+ # The announcement words, {position} and {count} filled in by the
40
+ # client. Override any of them (a translation) with messages:.
41
+ SORTABLE_MESSAGES = {
42
+ picked: "Picked up item {position} of {count}",
43
+ moved: "Moved to position {position} of {count}",
44
+ dropped: "Dropped",
45
+ cancelled: "Cancelled"
46
+ }.freeze
47
+
48
+ # The params the client fills in on drop: a static params: may not set them.
49
+ SORTABLE_DROP_PARAMS = %i[item position after from].freeze
50
+
51
+ # A sortable list. `action` is called on drop.
52
+ #
53
+ # group: links lists (drag between them) and sources to targets
54
+ # handle: a CSS selector, resolved inside each row; only it starts a
55
+ # pointer drag. Without it the whole row drags.
56
+ # name: what a row dragged OUT of this list into another reports
57
+ # as `from` (and what that list's from: map keys on)
58
+ # from: a drag-in calls another action: from: { "templates" => :insert }
59
+ # params: static params sent with every drop (e.g. the lane a list is)
60
+ # disabled: refuse every drag from or into this list
61
+ # busy: on(busy:)'s vocabulary, applied to the list while pending
62
+ # messages: the live-region words, merged over SORTABLE_MESSAGES
63
+ def reactive_sortable(action, group: nil, handle: nil, name: nil, from: nil, params: nil,
64
+ disabled: false, busy: nil, messages: nil)
65
+ sortable_requires_component!
66
+ action = sortable_action!(action)
67
+ data = { reactive_sortable: action }
68
+ data[:reactive_sortable_group] = sortable_name!(:group, group) unless group.nil?
69
+ data[:reactive_sortable_handle] = sortable_handle!(handle) unless handle.nil?
70
+ data[:reactive_sortable_name] = sortable_list_name!(name) unless name.nil?
71
+ data[:reactive_sortable_from] = sortable_from!(from) unless from.nil?
72
+ data[:reactive_sortable_params] = sortable_params!(params) unless params.nil?
73
+ data[:reactive_sortable_disabled] = "true" if disabled
74
+ data[:reactive_sortable_busy] = pending_hint_json(busy, action, :busy) if busy
75
+ data[:reactive_sortable_messages] = sortable_messages!(messages)
76
+ { data: }
77
+ end
78
+
79
+ # A list whose rows are COPIED into any sortable of the same group,
80
+ # never moved. The target's action (or its from: mapping for `name`)
81
+ # decides what lands, and its reply renders the new row. Needs no
82
+ # token: it works on a ClientBindings root.
83
+ def reactive_sortable_source(group:, name: "source")
84
+ { data: { reactive_sortable_source: sortable_list_name!(name),
85
+ reactive_sortable_group: sortable_name!(:group, group) } }
86
+ end
87
+
88
+ # A row and its key — a String or an Integer, sent as-is (JSON-typed).
89
+ # The row is focusable, for the keyboard model (unless disabled).
90
+ def reactive_sortable_item(key, disabled: false)
91
+ unless (key.is_a?(String) && !key.strip.empty?) || key.is_a?(Integer)
92
+ raise ArgumentError,
93
+ "reactive_sortable_item key must be a String or an Integer (the row's id), got #{key.inspect}"
94
+ end
95
+
96
+ data = { reactive_sortable_item: key.to_json }
97
+ # A disabled row cannot be picked up, so it stays out of the tab order.
98
+ return { data: data.merge(reactive_sortable_disabled: "true") } if disabled
99
+
100
+ { tabindex: 0, data: }
101
+ end
102
+
103
+ private
104
+
105
+ def sortable_requires_component!
106
+ return if self.class.include?(Phlex::Reactive::Component::Identity)
107
+
108
+ raise ArgumentError,
109
+ "reactive_sortable needs the full Phlex::Reactive::Component — a drop calls a server action, " \
110
+ "which Phlex::Reactive::ClientBindings has no token for. A copy-only list on a client-only " \
111
+ "root is reactive_sortable_source(group:)."
112
+ end
113
+
114
+ # The on() courtesy (issue #105): an undeclared action fails at render
115
+ # under verbose_errors, not as a 403 at drop time.
116
+ def sortable_action!(action)
117
+ if action.nil? || action.to_s.strip.empty?
118
+ raise ArgumentError, "reactive_sortable needs an action to call on drop, e.g. reactive_sortable(:move)"
119
+ end
120
+
121
+ if Phlex::Reactive.verbose_errors &&
122
+ (actions = self.class.reactive_actions).any? && !actions.key?(action.to_sym)
123
+ raise Phlex::Reactive::Error,
124
+ "#{self.class} has no declared action #{action.to_sym.inspect} (declared: #{actions.keys.inspect})"
125
+ end
126
+
127
+ action.to_s
128
+ end
129
+
130
+ def sortable_name!(option, value)
131
+ unless (value.is_a?(String) || value.is_a?(Symbol)) && !value.to_s.strip.empty?
132
+ raise ArgumentError, "reactive_sortable #{option}: needs a name (a String or Symbol), got #{value.inspect}"
133
+ end
134
+
135
+ value.to_s
136
+ end
137
+
138
+ # "self" is what a reorder within one list reports as `from`.
139
+ def sortable_list_name!(value)
140
+ name = sortable_name!(:name, value)
141
+ if name == "self"
142
+ raise ArgumentError,
143
+ 'reactive_sortable name: "self" is reserved — it is the `from` of a reorder within one list'
144
+ end
145
+
146
+ name
147
+ end
148
+
149
+ def sortable_handle!(handle)
150
+ selector = handle.to_s
151
+ if handle == true || handle == false || selector.strip.empty?
152
+ raise ArgumentError,
153
+ "reactive_sortable handle: needs a CSS selector resolved inside each row " \
154
+ "(e.g. \"[data-drag-handle]\"), got #{handle.inspect}"
155
+ end
156
+
157
+ selector
158
+ end
159
+
160
+ def sortable_from!(from)
161
+ valid = from.is_a?(Hash) && from.any? && from.keys.all? { !it.to_s.strip.empty? }
162
+ unless valid
163
+ raise ArgumentError,
164
+ "reactive_sortable from: maps a source list's name to the action a drag-in from it calls, " \
165
+ "e.g. from: { \"templates\" => :insert }, got #{from.inspect}"
166
+ end
167
+
168
+ from.to_h { |list, action| [sortable_list_name!(list), sortable_action!(action)] }.to_json
169
+ end
170
+
171
+ def sortable_params!(params)
172
+ raise ArgumentError, "reactive_sortable params: must be a Hash, got #{params.inspect}" unless params.is_a?(Hash)
173
+
174
+ taken = params.keys.map(&:to_sym) & SORTABLE_DROP_PARAMS
175
+ unless taken.empty?
176
+ raise ArgumentError,
177
+ "reactive_sortable params: cannot set #{taken.first.inspect} — the client fills in " \
178
+ "#{SORTABLE_DROP_PARAMS.map(&:inspect).join(", ")} on drop"
179
+ end
180
+
181
+ params.to_json
182
+ end
183
+
184
+ def sortable_messages!(messages)
185
+ return SORTABLE_MESSAGES_JSON if messages.nil?
186
+ unless messages.is_a?(Hash)
187
+ raise ArgumentError,
188
+ "reactive_sortable messages: must be a Hash, got #{messages.inspect}"
189
+ end
190
+
191
+ unknown = messages.keys.map { it.to_s.to_sym } - SORTABLE_MESSAGES.keys
192
+ unless unknown.empty?
193
+ raise ArgumentError,
194
+ "reactive_sortable got an unknown messages: key #{unknown.first.inspect} — " \
195
+ "supported: #{SORTABLE_MESSAGES.keys.map(&:inspect).join(", ")}"
196
+ end
197
+
198
+ messages.each do |name, words|
199
+ next if words.is_a?(String)
200
+
201
+ raise ArgumentError, "reactive_sortable messages: #{name} must be a String, got #{words.inspect}"
202
+ end
203
+
204
+ SORTABLE_MESSAGES.merge(messages.transform_keys { it.to_s.to_sym }).to_json
205
+ end
206
+
207
+ SORTABLE_MESSAGES_JSON = SORTABLE_MESSAGES.to_json
208
+ private_constant :SORTABLE_MESSAGES_JSON, :SORTABLE_DROP_PARAMS
209
+ end
210
+ end
211
+ end
212
+ end
@@ -146,6 +146,7 @@ module Phlex
146
146
  include ClientBindings
147
147
  include Identity
148
148
  include Lazy
149
+ include PresenceDeclaration
149
150
  end
150
151
  end
151
152
  end
@@ -112,6 +112,11 @@ module Phlex
112
112
  "got #{placeholder.class}"
113
113
  end
114
114
 
115
+ # The signed SSE URL a <pgbus-stream-source> subscribes to `key` with —
116
+ # minted off-view, as pgbus_stream_from would. Shared with presence
117
+ # (issue #358), whose roster subscribes to its stream the same way.
118
+ def stream_src(key) = signed_stream_src(key)
119
+
115
120
  private
116
121
 
117
122
  # The delivery directive: `<turbo-stream action="reactive:defer">`
@@ -20,10 +20,10 @@ module Phlex
20
20
  # build script (scripts/build_client.js), the core's own table and the
21
21
  # default entry name the same features —
22
22
  # spec/phlex/engine_client_pin_spec.rb fails when they disagree.
23
- CLIENT_FEATURES = %w[persist defer form bindings compute effects hints devtools].freeze
24
- # (Gzipped, bun zlib level 9: the default file 26.1 KB; the core 12.4 KB;
25
- # persist 3.2, defer 2.8, form 1.0, bindings 5.4, compute 2.1, effects
26
- # 1.7, hints 1.0, devtools 1.6 KB. spec/javascript/bundle_budget.test.js
23
+ CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence effects hints devtools].freeze
24
+ # (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 30.9 KB; the core 14.3 KB;
25
+ # persist 3.1, defer 2.9, form 1.1, bindings 6.5, compute 2.0, sortable
26
+ # 3.1, presence 1.3, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
27
27
  # is the source of truth and holds a ratchet on each.)
28
28
 
29
29
  # Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
@@ -202,6 +202,11 @@ module Phlex
202
202
  Phlex::Reactive.freeze_param_types!
203
203
  # Freeze the named-schema registry the same way (issue #184).
204
204
  Phlex::Reactive.freeze_param_schemas!
205
+
206
+ # Presence (issue #358): register the presence stream pattern and the
207
+ # change hook with the transport. A no-op without a capable pgbus — an
208
+ # app on Action Cable boots exactly as before.
209
+ Phlex::Reactive::Presence.install!
205
210
  end
206
211
  end
207
212
  end
@@ -304,6 +304,30 @@ module Phlex
304
304
  append("dispatch", args.freeze)
305
305
  end
306
306
 
307
+ # --- Presence (issue #358) ---
308
+ #
309
+ # presence(**state) — write this tab's presence state through the root's
310
+ # generated presence_set action (reactive_presence): only the declared
311
+ # keys reach the server, typed like action params. Always targets the
312
+ # ROOT, which must declare reactive_presence (the client warns and does
313
+ # nothing otherwise). ACTOR-ONLY like focus/submit: a broadcast that set
314
+ # every subscriber's selection would be hostile (BROADCAST_REFUSED_OPS).
315
+ # li(**on_client(:click, js.presence(selected: item.id))) { item.title }
316
+ def presence(**state)
317
+ raise ArgumentError, "#{self.class}: presence needs at least one state key (e.g. selected: 7)" if state.empty?
318
+
319
+ state.each do |name, value|
320
+ next if value.nil? || [String, Integer, TrueClass, FalseClass].any? { value.is_a?(it) }
321
+ next if value.is_a?(Float) && value.finite? # NaN and Infinity have no JSON form
322
+
323
+ raise ArgumentError,
324
+ "#{self.class}: presence state values must be scalar (String/Integer/finite Float/true/false/nil) — " \
325
+ "#{name.inspect} is #{value.class}"
326
+ end
327
+
328
+ append("presence", { "to" => ROOT_SENTINEL, "state" => state.transform_keys(&:to_s).freeze }.freeze)
329
+ end
330
+
307
331
  # The wire format: a JSON array of [op, args] pairs, applied in order.
308
332
  def to_json(*)
309
333
  @ops.to_json