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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +114 -0
- data/README.md +91 -11
- data/app/controllers/phlex/reactive/actions_controller.rb +4 -1
- data/app/javascript/phlex/reactive/compute.js +14 -1
- data/app/javascript/phlex/reactive/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/compute.min.js.map +3 -3
- data/app/javascript/phlex/reactive/core.min.js +6 -5
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/early.js +12 -4
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/bindings.js +47 -0
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +4 -4
- data/app/javascript/phlex/reactive/runtime.js +479 -20
- data/lib/phlex/reactive/component/helpers.rb +30 -0
- data/lib/phlex/reactive/component/media_binding.rb +85 -0
- data/lib/phlex/reactive/held_fields.rb +87 -0
- data/lib/phlex/reactive/js.rb +162 -29
- data/lib/phlex/reactive/shortcut.rb +18 -5
- data/lib/phlex/reactive/streamable.rb +31 -2
- data/lib/phlex/reactive/test_helpers.rb +11 -2
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +3 -1
|
@@ -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
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -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
|
|
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
|
|
17
|
-
#
|
|
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))
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
99
|
-
"known on the server, bind
|
|
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
|
|
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
|