phlex-reactive 0.15.0 → 0.15.2

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.
@@ -15,6 +15,7 @@ module Phlex
15
15
  include Selection
16
16
  include Sortable
17
17
  include Editable
18
+ include MediaBinding
18
19
 
19
20
  # The acting client's SSE connection id during the current action (nil
20
21
  # outside an action, or when the client isn't subscribed to a stream).
@@ -31,6 +32,20 @@ module Phlex
31
32
  Phlex::Reactive.current_connection_id
32
33
  end
33
34
 
35
+ # The names of this action's posted fields that a morph held over a
36
+ # changed server value (issue #370): someone else changed the field
37
+ # while this user typed, and this tab kept the user's text. [] when
38
+ # nothing was held, and outside an action. Filtered to the action's
39
+ # declared params; a hint about what the user saw, never authorization.
40
+ #
41
+ # def save(title: nil)
42
+ # return conflict!(title) if reactive_held_fields.include?("title") && title != @note.title
43
+ # @note.update!(title:)
44
+ # end
45
+ def reactive_held_fields
46
+ Phlex::Reactive::HeldFields.current
47
+ end
48
+
34
49
  # Manually satisfy the verify_authorized guard (issue #168) for a bespoke
35
50
  # authorization check the interceptor can't see — a hand-rolled policy, a
36
51
  # feature flag, an ownership comparison that doesn't go through one of
@@ -1790,12 +1805,27 @@ module Phlex
1790
1805
  # The data-action descriptors of an on()/on_client trigger: one, or a
1791
1806
  # shortcut's meta and ctrl twins (space-joined, as mix joins them).
1792
1807
  # The common, shortcut-less path is ONE interpolation (on() is a hot path).
1808
+ # A chord whose key Stimulus lacks (issue #366: delete, backspace) binds a
1809
+ # bare window keydown: the client's shortcut check matches it alone.
1793
1810
  def trigger_descriptors(event, shortcut, window_bound, method, once)
1794
1811
  return "#{event}#{"@window" if window_bound}->reactive##{method}#{":once" if once}" unless shortcut
1812
+ return bare_shortcut_descriptor(shortcut, method, once) unless shortcut.stimulus_filterable?
1795
1813
 
1796
1814
  shortcut.filters.map { "keydown.#{it}@window->reactive##{method}#{":once" if once}" }.join(" ")
1797
1815
  end
1798
1816
 
1817
+ # Stimulus removes a `:once` listener after its FIRST event, and a bare
1818
+ # keydown hears every key, so the first keypress of any kind would spend it.
1819
+ def bare_shortcut_descriptor(shortcut, method, once)
1820
+ if once
1821
+ raise ArgumentError,
1822
+ "shortcut: #{shortcut.keys.inspect} cannot take once: — its key is matched by the " \
1823
+ "library, not Stimulus, so the first key pressed of any kind would spend the binding"
1824
+ end
1825
+
1826
+ "keydown@window->reactive##{method}"
1827
+ end
1828
+
1799
1829
  # A Hash param is JSON (Stimulus's param reader parses it back into an
1800
1830
  # object); the String form stays a plain string.
1801
1831
  def shortcut_param_attr(shortcut)
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module Component
6
+ # A preview image that follows a field (issue #373), with no URL in the
7
+ # page's ops or attributes: the server renders every candidate <img>
8
+ # (hidden), and the binding copies the one whose key matches.
9
+ #
10
+ # input(name: "code")
11
+ # img(id: "logo", alt: "", **reactive_media(
12
+ # from: :code, take: 3,
13
+ # pool: { "007" => "#logo-007", "011" => "#logo-011" },
14
+ # label: "#caption", blank_label: "Unknown"
15
+ # ))
16
+ # img(id: "logo-007", src: "/logos/007.svg", alt: "North", hidden: true)
17
+ #
18
+ # The field is read the way reactive_show reads one (owned fields, a
19
+ # radio group's checked value, reactive_scope applied). `take:` keeps the
20
+ # first n characters as the key; omit it to use the whole value. A pool
21
+ # value is a CSS SELECTOR for an <img> inside the root, never a URL: the
22
+ # client copies that element's src, srcset and alt the way
23
+ # js.copy_media does. A key, selector or element that misses clears the
24
+ # preview. `label:` names a node whose textContent becomes the chosen
25
+ # image's alt (`blank_label`, or "", on a miss). Client-only: no token,
26
+ # no round trip.
27
+ #
28
+ # Named MediaBinding, not Media: this module sits in every reactive
29
+ # component's ancestry, and a bare `Media` would shadow a host app's kit
30
+ # component of that name (issue #233).
31
+ module MediaBinding
32
+ def reactive_media(from:, pool: nil, take: nil, label: nil, blank_label: nil, **attrs)
33
+ wire = { "field" => media_field!(from) }
34
+ wire["take"] = media_take!(take) unless take.nil?
35
+ wire["pool"] = media_pool!(pool)
36
+ wire["label"] = media_selector!(:label, label) unless label.nil?
37
+ wire["blank"] = blank_label.to_s unless blank_label.nil?
38
+ mix({ data: { reactive_media: wire.to_json } }, attrs)
39
+ end
40
+
41
+ private
42
+
43
+ def media_field!(from)
44
+ name = from.to_s if from.is_a?(Symbol) || from.is_a?(String)
45
+ return name unless name.nil? || name.strip.empty?
46
+
47
+ raise ArgumentError, "reactive_media from: needs the field name to watch (e.g. from: :code), got #{from.inspect}"
48
+ end
49
+
50
+ def media_take!(take)
51
+ return take if take.is_a?(Integer) && take.positive?
52
+
53
+ raise ArgumentError, "reactive_media take: must be a positive Integer (or omitted for the whole value), " \
54
+ "got #{take.inspect}"
55
+ end
56
+
57
+ def media_pool!(pool)
58
+ if pool.nil?
59
+ raise ArgumentError, "reactive_media needs pool: — a Hash of key => selector of a server-rendered <img> " \
60
+ "(e.g. pool: { \"007\" => \"#logo-007\" })"
61
+ end
62
+ unless pool.is_a?(Hash) && !pool.empty?
63
+ raise ArgumentError,
64
+ "reactive_media pool: must be a non-empty Hash of key => <img> selector, got #{pool.inspect}"
65
+ end
66
+
67
+ pool.to_h { |key, selector| [key.to_s, media_selector!(:pool, selector)] }
68
+ end
69
+
70
+ # A pool value or label: is a selector, never a URL — the client only
71
+ # ever passes it to querySelector.
72
+ def media_selector!(kind, selector)
73
+ if selector == JS::ROOT_SENTINEL
74
+ raise ArgumentError,
75
+ "reactive_media #{kind}: #{selector} names the whole root — pass the selector of an element inside it"
76
+ end
77
+ return selector if selector.is_a?(String) && !selector.strip.empty?
78
+
79
+ raise ArgumentError,
80
+ "reactive_media #{kind}: values are CSS selectors (e.g. \"#logo-007\"), got #{selector.inspect}"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ # The fields a morph held over a changed server value (issue #370).
6
+ #
7
+ # Since #338 a morph keeps the value of the field being typed in and writes
8
+ # the render's value as the field's default. When that render differs from
9
+ # the old default, someone else changed the field while the user typed.
10
+ # The client marks such a field and the root's next action request names it
11
+ # in the X-Reactive-Held header (a JSON array of field names). Inside the
12
+ # action, `reactive_held_fields` returns those names, so the app can refuse
13
+ # or merge instead of silently overwriting the other change:
14
+ #
15
+ # def save(title: nil)
16
+ # authorize!(@note, to: :update?)
17
+ # return conflict! if reactive_held_fields.include?("title") && title != @note.title
18
+ # ...
19
+ #
20
+ # The header is UNTRUSTED input, like every request field: it carries names
21
+ # only, never values, and is read as data — capped in bytes, count and
22
+ # length, non-strings dropped, and filtered to the names the running action
23
+ # declares as params (matched on the base name, so `title[en]` counts for a
24
+ # declared `title`). A name the action can't receive can't be overwritten
25
+ # by it. Malformed input reads as nothing held; it never fails a request.
26
+ # It is a hint about what the user saw, not authorization.
27
+ module HeldFields
28
+ HEADER = "X-Reactive-Held"
29
+
30
+ # A raw header longer than this is ignored unparsed.
31
+ MAX_BYTES = 4_096
32
+ # At most this many names are read …
33
+ MAX_NAMES = 64
34
+ # … each at most this long.
35
+ MAX_NAME_LENGTH = 128
36
+
37
+ EMPTY = [].freeze
38
+
39
+ class << self
40
+ # The held field names during the current action ([] outside one).
41
+ def current
42
+ Thread.current[:phlex_reactive_held_fields] || EMPTY
43
+ end
44
+
45
+ # Expose `names` for the block, restoring the previous list after.
46
+ def with(names)
47
+ previous = Thread.current[:phlex_reactive_held_fields]
48
+ Thread.current[:phlex_reactive_held_fields] = names
49
+ yield
50
+ ensure
51
+ Thread.current[:phlex_reactive_held_fields] = previous
52
+ end
53
+
54
+ # The header's names that `action_def` declares, frozen; [] for
55
+ # anything absent, oversized or malformed.
56
+ def parse(raw, action_def)
57
+ return EMPTY if raw.nil? || raw.empty? || raw.bytesize > MAX_BYTES
58
+
59
+ names = JSON.parse(raw)
60
+ names.is_a?(Array) ? filter(names, action_def) : EMPTY
61
+ rescue JSON::ParserError
62
+ EMPTY
63
+ end
64
+
65
+ # Keep the strings among `names` that `action_def` declares as params.
66
+ def filter(names, action_def)
67
+ declared = declared_names(action_def)
68
+ return EMPTY if declared.empty?
69
+
70
+ names.first(MAX_NAMES).select do
71
+ it.is_a?(String) && it.length <= MAX_NAME_LENGTH && declared.include?(base_name(it))
72
+ end.uniq.freeze
73
+ end
74
+
75
+ private
76
+
77
+ def declared_names(action_def)
78
+ params = action_def.params
79
+ params.is_a?(Hash) ? params.keys.map(&:to_s) : EMPTY
80
+ end
81
+
82
+ # "title" for "title", "title[]" and "title[en]".
83
+ def base_name(name) = name.split("[", 2).first
84
+ end
85
+ end
86
+ end
87
+ end
@@ -11,10 +11,15 @@ module Phlex
11
11
  # # -> data-reactive-ops-param='[["toggle",{"to":"#menu"}]]'
12
12
  #
13
13
  # These are declarative DOM OPERATIONS, not state: nothing is shipped back
14
- # to the server, nothing is trusted from the client, and any server
14
+ # to the server, nothing is trusted from the client, and a server
15
15
  # re-render of the component resets whatever they toggled (the LiveView
16
- # JS-commands caveat — by design; use a signed action for state that must
17
- # survive re-renders).
16
+ # JS-commands caveat; use a signed action for state the server must know).
17
+ # The exception is `keep: true` (issue #369) on the class, visibility and
18
+ # attribute ops: the client records what the op did to each target, and a
19
+ # MORPH of that element re-applies it, so a selection or an expanded row
20
+ # survives another viewer's broadcast. An element a stream REPLACES is a
21
+ # new element and starts clean. Kept state belongs to one tab, so
22
+ # broadcast_to(js:) refuses the flag.
18
23
  #
19
24
  # Targets: a CSS selector string is resolved WITHIN the component's root by
20
25
  # default (nested reactive roots excluded, issue #15 semantics); `:root`
@@ -38,10 +43,13 @@ module Phlex
38
43
  # * the URL set — href/src/srcdoc/action/formaction/xlink:href can carry
39
44
  # a `javascript:` payload; setting them from client ops
40
45
  # is a navigation/injection surface, not a UI toggle.
46
+ # srcset (issue #373) is a URL list that picks what an
47
+ # <img> loads: copy_media copies it from a server-rendered
48
+ # image instead.
41
49
  # * style — inline CSS injection; use classes (add_class/...).
42
50
  # The INTENDED surface is class ops plus boolean/state attributes:
43
51
  # hidden, disabled, open, selected, aria-*, data-*.
44
- URL_BEARING_ATTRS = %w[href src srcdoc action formaction xlink:href].freeze
52
+ URL_BEARING_ATTRS = %w[href src srcset srcdoc action formaction xlink:href].freeze
45
53
  EVENT_HANDLER_ATTR = /\Aon/i
46
54
 
47
55
  # The attr ops whose args carry a "name" the allowlist must gate. Used to
@@ -118,31 +126,39 @@ module Phlex
118
126
  # selector, document-wide under global: true). Pair it with ONE disclosure
119
127
  # target — with several, the last one wins.
120
128
  # js.toggle("#menu", expanded: "#menu-trigger")
129
+ #
130
+ # `keep:` (issue #369) — on show/hide/toggle, the class ops and the attr
131
+ # ops: a morph of the target re-applies the op's result (the class on or
132
+ # off, `hidden`, the attribute's value or its absence) instead of the
133
+ # server render's. The latest kept op on an element and a class or
134
+ # attribute wins, so "clear all, then select one" works:
135
+ # js.remove_class(".item", "selected", keep: true).add_class("#item-2", "selected", keep: true)
136
+ # With `expanded:`, the trigger's aria-expanded is kept too.
121
137
 
122
- def show(to, global: false, transition: nil, expanded: nil)
123
- append("show", target_args(to, global:, transition:, expanded:))
138
+ def show(to, global: false, transition: nil, expanded: nil, keep: false)
139
+ append("show", target_args(to, global:, transition:, expanded:, keep:))
124
140
  end
125
141
 
126
- def hide(to, global: false, transition: nil, expanded: nil)
127
- append("hide", target_args(to, global:, transition:, expanded:))
142
+ def hide(to, global: false, transition: nil, expanded: nil, keep: false)
143
+ append("hide", target_args(to, global:, transition:, expanded:, keep:))
128
144
  end
129
145
 
130
- def toggle(to, global: false, transition: nil, expanded: nil)
131
- append("toggle", target_args(to, global:, transition:, expanded:))
146
+ def toggle(to, global: false, transition: nil, expanded: nil, keep: false)
147
+ append("toggle", target_args(to, global:, transition:, expanded:, keep:))
132
148
  end
133
149
 
134
150
  # --- Classes ---
135
151
 
136
- def add_class(to, *classes, global: false)
137
- append("add_class", class_args(to, classes, global:))
152
+ def add_class(to, *classes, global: false, keep: false)
153
+ append("add_class", class_args(to, classes, global:, keep:))
138
154
  end
139
155
 
140
- def remove_class(to, *classes, global: false)
141
- append("remove_class", class_args(to, classes, global:))
156
+ def remove_class(to, *classes, global: false, keep: false)
157
+ append("remove_class", class_args(to, classes, global:, keep:))
142
158
  end
143
159
 
144
- def toggle_class(to, *classes, global: false)
145
- append("toggle_class", class_args(to, classes, global:))
160
+ def toggle_class(to, *classes, global: false, keep: false)
161
+ append("toggle_class", class_args(to, classes, global:, keep:))
146
162
  end
147
163
 
148
164
  # --- Attributes (issue #96) — allowlisted names only ---
@@ -157,19 +173,19 @@ module Phlex
157
173
  # attribute becomes `on`. For aria-expanded/aria-pressed/data-state:
158
174
  # js.toggle_attr("#trigger", "aria-expanded", "true", "false")
159
175
 
160
- def set_attr(to, name, value, global: false)
161
- append("set_attr", attr_args(to, name, global:, value:))
176
+ def set_attr(to, name, value, global: false, keep: false)
177
+ append("set_attr", attr_args(to, name, global:, keep:, value:))
162
178
  end
163
179
 
164
- def remove_attr(to, name, global: false)
165
- append("remove_attr", attr_args(to, name, global:))
180
+ def remove_attr(to, name, global: false, keep: false)
181
+ append("remove_attr", attr_args(to, name, global:, keep:))
166
182
  end
167
183
 
168
- def toggle_attr(to, name, *values, global: false)
169
- args = attr_args(to, name, global:)
184
+ def toggle_attr(to, name, *values, global: false, keep: false)
185
+ args = attr_args(to, name, global:, keep:)
170
186
  return append("toggle_attr", args) if values.empty?
171
187
 
172
- append("toggle_attr", args.merge("values" => toggle_values(name, values)).freeze)
188
+ append("toggle_attr", with_keep(args.except("keep").merge("values" => toggle_values(name, values)), keep))
173
189
  end
174
190
 
175
191
  # --- Focus (issue #96) ---
@@ -186,6 +202,26 @@ module Phlex
186
202
  append("focus_first", target_args(to, global:))
187
203
  end
188
204
 
205
+ # --- Modal dialogs (issue #365) ---
206
+ #
207
+ # show_modal(to) — HTMLDialogElement#showModal(): the top layer, a
208
+ # focus trap, ::backdrop, Escape to close and focus
209
+ # restored to the opener, all the browser's own. A
210
+ # dialog already open NON-modally is closed and
211
+ # reopened as a modal; one already modal is left as is.
212
+ # close_dialog(to) — HTMLDialogElement#close() (a closed one is left as is).
213
+ # A target that is not a <dialog> is skipped. Both ACTOR-ONLY like focus:
214
+ # opening a modal steals focus, so a broadcast refuses them — a
215
+ # broadcaster renders the dialog open with data: { reactive_modal: true }.
216
+
217
+ def show_modal(to, global: false)
218
+ append("show_modal", target_args(to, global:))
219
+ end
220
+
221
+ def close_dialog(to, global: false)
222
+ append("close_dialog", target_args(to, global:))
223
+ end
224
+
189
225
  # --- Submit (issue #226) ---
190
226
  #
191
227
  # submit(to = :root) — requestSubmit() the TARGET'S OWN form: the target
@@ -236,7 +272,7 @@ module Phlex
236
272
  # element (the two passes would fight over the same attribute).
237
273
 
238
274
  def paste_into(to, global: false)
239
- if to == :root
275
+ if [:root, ROOT_SENTINEL].include?(to)
240
276
  raise ArgumentError,
241
277
  "#{self.class}: paste_into targets a field, not the component root — " \
242
278
  "pass the input's CSS selector (e.g. js.paste_into(\"[name=code]\"))"
@@ -245,6 +281,36 @@ module Phlex
245
281
  append("paste_into", target_args(to, global:))
246
282
  end
247
283
 
284
+ # --- Preview a chosen file (issue #375) ---
285
+ #
286
+ # preview_file(to, into:, name: nil) — on the file input's change, paint
287
+ # the first chosen IMAGE file onto the `into:` img through an object URL
288
+ # the client creates itself (URL.createObjectURL) and revokes on the next
289
+ # choice, on a clear, and when the img leaves the page. The op carries NO
290
+ # URL — there is no parameter for one, and the client never reads a `url`
291
+ # key — so the `src` refusal of set_attr (issue #96) stays whole. `to` is
292
+ # an input[type=file] (an explicit :root is refused); `into:` must
293
+ # resolve to an <img> (anything else warns and is skipped in the
294
+ # browser); `name:` optionally names a node that gets file.name as
295
+ # textContent. An empty selection or a non-image file clears the preview.
296
+ # ACTOR-ONLY like paste_into: refused in broadcast_to(js:) — a broadcast
297
+ # that read every subscriber's file input would be hostile.
298
+ # input(type: "file", id: "photo-file", accept: "image/*",
299
+ # **on_client(:change, js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))
300
+
301
+ def preview_file(to, into:, name: nil, global: false)
302
+ if [:root, ROOT_SENTINEL].include?(to)
303
+ raise ArgumentError,
304
+ "#{self.class}: preview_file reads a file input, not the component root — " \
305
+ "pass the input's CSS selector (e.g. js.preview_file(\"#photo-file\", into: \"#photo\"))"
306
+ end
307
+
308
+ args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
309
+ args["name"] = preview_selector(:name, name) unless name.nil?
310
+ args["global"] = true if global
311
+ append("preview_file", args.freeze)
312
+ end
313
+
248
314
  # --- Text content (issue #159) ---
249
315
  #
250
316
  # text(to, value) — set the target's textContent (stringified; nil clears).
@@ -259,6 +325,56 @@ module Phlex
259
325
  append("text", args.freeze)
260
326
  end
261
327
 
328
+ # --- Copy a server-rendered image (issue #373) ---
329
+ #
330
+ # copy_media(to, from:) — copy src, srcset and alt from the `from` <img>
331
+ # onto the `to` <img>. Both are ELEMENTS (:root or a CSS selector,
332
+ # resolved with the op's own scoping, global: included): the URL is read
333
+ # off the live source element in the browser and is never an argument,
334
+ # so the set_attr refusal of src (issue #96) stays. A missing source, a
335
+ # non-<img> source or one without src clears the target's src, srcset
336
+ # and alt; a target that is not an <img> is skipped. The same URL twice
337
+ # is not written again (no reload). Broadcast-safe: it only reads
338
+ # elements already in the subscriber's document.
339
+ # button(**on_client(:click, js.copy_media("#logo", from: "#logo-007"))) { "North" }
340
+
341
+ def copy_media(to, from:, global: false)
342
+ source = normalize_target(from)
343
+ raise ArgumentError, "#{self.class}: copy_media from: needs a CSS selector, got an empty string" if source.empty?
344
+
345
+ args = { "to" => normalize_target(to), "from" => source }
346
+ args["global"] = true if global
347
+ append("copy_media", args.freeze)
348
+ end
349
+
350
+ # --- A field's live value (issue #374) ---
351
+ #
352
+ # set_value(to, value) — assign the target field's IDL `.value` (never
353
+ # the attribute: that is set_attr, and a field the user typed in does not
354
+ # follow it), then dispatch a bubbling `input` when the value changed, so
355
+ # reducers and reactive_show see it as typed — the set-value contract
356
+ # paste_into uses (issue #183). Never `change`, never focus (chain
357
+ # js.focus for that), never parsed as HTML. The value is stringified; nil
358
+ # and "" clear the field. The target is an input, textarea or select:
359
+ # :root is refused loudly like paste_into, and anything else (a
360
+ # contenteditable — reactive_editable writes those — a non-field, a
361
+ # non-empty value for a file input) warns and skips in the browser.
362
+ # ACTOR-ONLY like paste_into: a broadcast that overwrote every
363
+ # subscriber's field would be hostile (BROADCAST_REFUSED_OPS).
364
+ # button(**on_client(:click, js.set_value("#notes", "Thanks"))) { "Thanks" }
365
+
366
+ def set_value(to, value, global: false)
367
+ if [:root, ROOT_SENTINEL].include?(to)
368
+ raise ArgumentError,
369
+ "#{self.class}: set_value targets a field, not the component root — " \
370
+ "pass the field's CSS selector (e.g. js.set_value(\"#notes\", \"Thanks\"))"
371
+ end
372
+
373
+ args = { "to" => normalize_target(to), "value" => value.to_s }
374
+ args["global"] = true if global
375
+ append("set_value", args.freeze)
376
+ end
377
+
262
378
  # --- Tick or untick a checkbox group (issue #342) ---
263
379
  #
264
380
  # check_group(group, checked = true) — set every OWNED box of the group
@@ -375,14 +491,31 @@ module Phlex
375
491
  self.class.new([*@ops, [name, args].freeze].freeze)
376
492
  end
377
493
 
378
- def target_args(to, global:, transition: nil, expanded: nil)
494
+ def target_args(to, global:, transition: nil, expanded: nil, keep: false)
379
495
  args = { "to" => normalize_target(to) }
380
496
  args["global"] = true if global
381
497
  args["transition"] = normalize_transition(transition) if transition
382
498
  args["expanded"] = normalize_target(expanded) unless expanded.nil?
499
+ with_keep(args, keep)
500
+ end
501
+
502
+ # keep: (issue #369) rides LAST and only when true, so an op without it
503
+ # serializes byte-identically to before.
504
+ def with_keep(args, keep)
505
+ args["keep"] = true if keep
383
506
  args.freeze
384
507
  end
385
508
 
509
+ # preview_file's into:/name: — a non-empty CSS selector String, never
510
+ # :root or its "@root" sentinel (the root is a container, not the img —
511
+ # and a name: on it would replace the whole component with a file name).
512
+ def preview_selector(key, selector)
513
+ return selector if selector.is_a?(String) && !selector.strip.empty? && selector != ROOT_SENTINEL
514
+
515
+ raise ArgumentError,
516
+ "#{self.class}: preview_file #{key}: must be a CSS selector string, got #{selector.inspect}"
517
+ end
518
+
386
519
  # The [on, off] pair of a two-value toggle_attr, stringified. Loud on a
387
520
  # half-specified pair or one that could never change anything.
388
521
  def toggle_values(name, values)
@@ -402,13 +535,13 @@ module Phlex
402
535
 
403
536
  # An attr op's args: the target, the allowlisted name, an optional
404
537
  # (stringified) value. The name is validated LOUDLY at build time.
405
- def attr_args(to, name, global:, value: :__none)
538
+ def attr_args(to, name, global:, keep: false, value: :__none)
406
539
  name = name.to_s
407
540
  assert_allowed_attr(name)
408
541
  args = { "to" => normalize_target(to), "name" => name }
409
542
  args["value"] = value.to_s unless value == :__none
410
543
  args["global"] = true if global
411
- args.freeze
544
+ with_keep(args, keep)
412
545
  end
413
546
 
414
547
  # Build-time attr-name allowlist for the instance builder — delegates to the
@@ -436,14 +569,14 @@ module Phlex
436
569
  [transition[:during], transition[:from], transition[:to]].map(&:to_s).freeze
437
570
  end
438
571
 
439
- def class_args(to, classes, global:)
572
+ def class_args(to, classes, global:, keep: false)
440
573
  if classes.empty?
441
574
  raise ArgumentError, "#{self.class}: a class op needs at least one class (got none for #{to.inspect})"
442
575
  end
443
576
 
444
577
  args = { "to" => normalize_target(to), "classes" => classes.map(&:to_s).freeze }
445
578
  args["global"] = true if global
446
- args.freeze
579
+ with_keep(args, keep)
447
580
  end
448
581
 
449
582
  # Instance-side alias for the class method (used by the op builder). See
@@ -13,17 +13,24 @@ module Phlex
13
13
  # exactly), so a `mod` chord renders TWO descriptors, a meta twin and a ctrl
14
14
  # twin, and the client runs only the twin for its platform. Stimulus keeps
15
15
  # the key filtering; the client adds the platform check, the editable-target
16
- # guard and preventDefault.
16
+ # guard and preventDefault. The exception is a key Stimulus's default map
17
+ # lacks (delete, backspace — issue #366): it binds a bare window keydown,
18
+ # no twins, and the client matches the whole chord.
17
19
  #
18
20
  # Validation is loud, at render: Stimulus reports an unknown key token by
19
21
  # throwing at keypress time, which is too late to notice.
20
22
  class Shortcut
21
23
  MODIFIERS = %w[mod meta ctrl alt shift].freeze
22
24
  # Stimulus's default keyMappings: the named keys plus a-z and 0-9.
23
- NAMED_KEYS = %w[enter tab esc space up down left right home end page_up page_down].freeze
25
+ STIMULUS_KEYS = %w[enter tab esc space up down left right home end page_up page_down].freeze
26
+ # Keys Stimulus's default map lacks (issue #366): a key filter naming one
27
+ # makes Stimulus throw at keypress, so such a chord renders a bare
28
+ # `keydown@window` and the client alone matches it.
29
+ LIBRARY_KEYS = %w[delete backspace].freeze
30
+ NAMED_KEYS = (STIMULUS_KEYS + LIBRARY_KEYS).freeze
24
31
  HASH_KEYS = %i[keys in_editable].freeze
25
32
 
26
- private_constant :MODIFIERS, :NAMED_KEYS, :HASH_KEYS
33
+ private_constant :MODIFIERS, :STIMULUS_KEYS, :LIBRARY_KEYS, :NAMED_KEYS, :HASH_KEYS
27
34
 
28
35
  attr_reader :keys
29
36
 
@@ -61,6 +68,11 @@ module Phlex
61
68
  freeze
62
69
  end
63
70
 
71
+ # False for a chord whose key Stimulus's default map lacks (delete,
72
+ # backspace): it binds without a key filter and without twins — the
73
+ # client's shortcut check matches the key and picks the platform's `mod`.
74
+ def stimulus_filterable? = !LIBRARY_KEYS.include?(@keys.split("+").last)
75
+
64
76
  # The Stimulus key filters to bind: the meta and ctrl twins for a `mod`
65
77
  # chord, else the chord itself.
66
78
  def filters
@@ -95,8 +107,9 @@ module Phlex
95
107
 
96
108
  raise ArgumentError,
97
109
  "shortcut: #{source.inspect} has an unknown key #{key.first.inspect} — use a letter, a digit, " \
98
- "or one of #{NAMED_KEYS.join(", ")} (Stimulus's default key map; custom keyMappings are not " \
99
- "known on the server, bind those with event: and window: instead)"
110
+ "or one of #{NAMED_KEYS.join(", ")} (Stimulus's default key map, plus delete and backspace, " \
111
+ "which the library matches itself; custom keyMappings are not known on the server, bind " \
112
+ "those with event: and window: instead)"
100
113
  end
101
114
 
102
115
  def known_key?(key) = NAMED_KEYS.include?(key) || key.match?(/\A[a-z0-9]\z/)
@@ -38,12 +38,23 @@ module Phlex
38
38
  # (issue #226) would force-submit every subscriber's form; paste_into
39
39
  # (issue #228) would read every subscriber's clipboard; check_group
40
40
  # (issue #342) would tick or clear every subscriber's selection; presence
41
- # (issue #358) would write every subscriber's presence state. They
41
+ # (issue #358) would write every subscriber's presence state; show_modal
42
+ # and close_dialog (issue #365) would open or close a modal, and move
43
+ # focus, on every screen at once; set_value (issue #374) would overwrite
44
+ # every subscriber's field; preview_file (issue #375) would read every
45
+ # subscriber's file input. They
42
46
  # belong to the actor's own reply (reply.js) or gesture (on_client / a
43
47
  # reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
44
48
  BROADCAST_REFUSED_OPS = %w[
45
49
  focus focus_first submit paste_into persist_state persist_clear check_group presence
50
+ show_modal close_dialog
51
+ preview_file
52
+ set_value
46
53
  ].freeze
54
+ # Refused as a FLAG, on any op (issue #369): keep: records client state in
55
+ # the tab that ran the op, so a broadcast carrying it would plant that
56
+ # state in every subscriber's tab. The op itself broadcasts fine without it.
57
+ BROADCAST_REFUSED_ARG = "keep"
47
58
 
48
59
  # The broadcast_to verb kwargs (issue #185) → their Turbo stream action.
49
60
  # SELF-TARGETING verbs derive the target from the component's #id (require a
@@ -197,13 +208,31 @@ module Phlex
197
208
  "steals it in every subscriber's tab; broadcasting submit force-submits every " \
198
209
  "subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
199
210
  "broadcasting check_group ticks or clears every subscriber's selection; " \
200
- "broadcasting presence writes every subscriber's presence state. " \
211
+ "broadcasting presence writes every subscriber's presence state; " \
212
+ "broadcasting set_value overwrites every subscriber's field (render the value instead); " \
213
+ "broadcasting show_modal or close_dialog opens or closes a modal (and moves focus) on " \
214
+ "every screen — render the dialog open or closed, with data: { reactive_modal: true }, instead. " \
215
+ "Broadcasting preview_file reads every subscriber's chosen file onto their page. " \
201
216
  "These are actor concerns (reply.js / on_client / $ops)."
202
217
  end
218
+ refuse_kept_broadcast_ops!(pairs)
203
219
 
204
220
  Phlex::Reactive::Response.js_ops_json(ops)
205
221
  end
206
222
 
223
+ # keep: (issue #369) is per-tab client state: refuse the flag on any op.
224
+ def refuse_kept_broadcast_ops!(pairs)
225
+ kept = pairs.filter_map do |name, args|
226
+ name.to_s if args.is_a?(::Hash) && (args[BROADCAST_REFUSED_ARG] || args[BROADCAST_REFUSED_ARG.to_sym])
227
+ end
228
+ return if kept.empty?
229
+
230
+ raise ArgumentError,
231
+ "broadcast_to(js:) refuses keep: (on #{kept.uniq.join(", ")}) — kept state belongs to the tab " \
232
+ "that ran the op; broadcasting it would plant it in every subscriber's tab. Drop keep: from the " \
233
+ "broadcast, or render the state on the server."
234
+ end
235
+
207
236
  # Render a built component to HTML for a broadcast, ALWAYS instrumented
208
237
  # (issue #185): a Streamable renders through its own render_component (which
209
238
  # fires render.phlex_reactive); a plain component renders through the module