phlex-reactive 0.15.2 → 0.15.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +269 -2
- data/README.md +92 -13
- data/app/javascript/phlex/reactive/core.js +1 -0
- data/app/javascript/phlex/reactive/core.min.js +6 -6
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/features/bindings.js +153 -21
- 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/features/countdown.js +140 -0
- data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
- data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
- data/app/javascript/phlex/reactive/features/form.js +54 -22
- data/app/javascript/phlex/reactive/features/form.min.js +2 -2
- data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/persist.js +41 -8
- data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
- data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +9 -8
- data/app/javascript/phlex/reactive/runtime.js +313 -32
- data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
- data/lib/phlex/reactive/component/field_references.rb +130 -0
- data/lib/phlex/reactive/component/helpers.rb +81 -19
- data/lib/phlex/reactive/component/identity.rb +34 -1
- data/lib/phlex/reactive/component/image_fallback_binding.rb +67 -0
- data/lib/phlex/reactive/component/media_binding.rb +30 -4
- data/lib/phlex/reactive/component/media_pattern.rb +79 -0
- data/lib/phlex/reactive/component/selection.rb +24 -2
- data/lib/phlex/reactive/component/show_options.rb +91 -0
- data/lib/phlex/reactive/doctor.rb +20 -8
- data/lib/phlex/reactive/engine.rb +2 -2
- data/lib/phlex/reactive/js.rb +58 -5
- data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
- data/lib/phlex/reactive/show_conditions.rb +39 -6
- data/lib/phlex/reactive/streamable.rb +6 -3
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +10 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Render-time checks for a binding that names ANOTHER field: same:
|
|
7
|
+
# (issue #395), after: (#396) and clear: (#394). The binding's attrs are
|
|
8
|
+
# built before its section's children render, so the check waits for the
|
|
9
|
+
# end of the OUTERMOST Phlex render in progress and reads the fields that
|
|
10
|
+
# render produced — a field below the binding, or inside a nested
|
|
11
|
+
# component, counts. An unknown name (or a file input named in clear:)
|
|
12
|
+
# raises ArgumentError at render, never silently in the browser.
|
|
13
|
+
#
|
|
14
|
+
# Nothing is recorded when no binding names a field, and a helper called
|
|
15
|
+
# outside a render (no buffer to read) is not checked. A fragment render
|
|
16
|
+
# (only part of the template reaches the buffer) is not checked either,
|
|
17
|
+
# nor is a render that calls `flush`. The scan accepts any element with
|
|
18
|
+
# that name, including one inside a nested reactive root the client will
|
|
19
|
+
# not read (ownership is the client's rule), so it catches typos, not
|
|
20
|
+
# misplaced fields.
|
|
21
|
+
module FieldReferences
|
|
22
|
+
REFS_KEY = :__phlex_reactive_field_refs
|
|
23
|
+
|
|
24
|
+
# A start tag and its attributes (quoted values may hold ">").
|
|
25
|
+
TAG = %r{<[a-zA-Z][\w-]*((?:\s+[^\s"'>/=]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]+))?)*)\s*/?>}
|
|
26
|
+
# One attribute of that list, walked in order so a "name=" inside
|
|
27
|
+
# another attribute's quoted value is never read as the name.
|
|
28
|
+
ATTR = %r{\s+([^\s"'>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?}
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def around_template(&)
|
|
33
|
+
# Thread#[] is fiber-local: a nested component, or a capture inside
|
|
34
|
+
# this render, shares the outermost render's list. `true` marks a
|
|
35
|
+
# render in progress with nothing recorded yet, so a render without
|
|
36
|
+
# such a binding allocates nothing here.
|
|
37
|
+
return super if Thread.current[REFS_KEY] || !defined?(@_state) || @_state.nil?
|
|
38
|
+
|
|
39
|
+
buffer = @_state.buffer
|
|
40
|
+
start = buffer.bytesize
|
|
41
|
+
Thread.current[REFS_KEY] = true
|
|
42
|
+
begin
|
|
43
|
+
super
|
|
44
|
+
refs = Thread.current[REFS_KEY]
|
|
45
|
+
ensure
|
|
46
|
+
Thread.current[REFS_KEY] = nil
|
|
47
|
+
end
|
|
48
|
+
check_field_references!(refs, buffer.byteslice(start..)) if refs.is_a?(Array) && !@_state.fragments
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
public
|
|
52
|
+
|
|
53
|
+
# A flush moves the buffer out mid-render, so the end-of-render scan
|
|
54
|
+
# would miss fields above it: record the references as unchecked.
|
|
55
|
+
def flush
|
|
56
|
+
Thread.current[REFS_KEY] = :flushed if Thread.current[REFS_KEY]
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# Record a binding's field references for the end-of-render check.
|
|
63
|
+
# `names` are bare show names (scope-aware, like the client resolver);
|
|
64
|
+
# `no_file` refuses a file input under the name.
|
|
65
|
+
def reference_fields!(helper, key, names, no_file: false)
|
|
66
|
+
refs = Thread.current[REFS_KEY]
|
|
67
|
+
return unless refs
|
|
68
|
+
|
|
69
|
+
return if refs == :flushed
|
|
70
|
+
|
|
71
|
+
refs = Thread.current[REFS_KEY] = [] if refs == true
|
|
72
|
+
names.each { refs << [helper, key, it, field_wire_name(it), no_file] }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Every same: name in a binding's compiled groups (issue #395).
|
|
76
|
+
def reference_same_fields!(helper, groups)
|
|
77
|
+
names = groups.flatten.filter_map { it["same"] }
|
|
78
|
+
reference_fields!(helper, :same, names.uniq) if names.any?
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# The name the client's resolver queries: scope[name] under
|
|
82
|
+
# reactive_scope, a bracketed name (an author's raw wire name) verbatim.
|
|
83
|
+
def field_wire_name(name)
|
|
84
|
+
name.include?("[") ? name : scoped_field_name(name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def check_field_references!(refs, html)
|
|
88
|
+
names, files = rendered_fields(html)
|
|
89
|
+
refs.each do |helper, key, name, wire, no_file|
|
|
90
|
+
unless names.include?(wire)
|
|
91
|
+
raise ArgumentError,
|
|
92
|
+
"#{helper} #{key}: #{name.to_sym.inspect} names no field this render produced " \
|
|
93
|
+
"(no element with name=\"#{wire}\")"
|
|
94
|
+
end
|
|
95
|
+
next unless no_file && files.include?(wire)
|
|
96
|
+
|
|
97
|
+
raise ArgumentError,
|
|
98
|
+
"#{helper} #{key}: #{name.to_sym.inspect} is a file input — a file field cannot be cleared"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The name of every element the HTML renders, and those that are
|
|
103
|
+
# file inputs.
|
|
104
|
+
def rendered_fields(html)
|
|
105
|
+
names = Set.new
|
|
106
|
+
files = Set.new
|
|
107
|
+
html.scan(TAG) do
|
|
108
|
+
attrs = tag_attributes(Regexp.last_match(1).to_s)
|
|
109
|
+
next unless (name = attrs["name"])
|
|
110
|
+
|
|
111
|
+
names << name
|
|
112
|
+
files << name if attrs["type"]&.casecmp?("file")
|
|
113
|
+
end
|
|
114
|
+
[names, files]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A tag's attributes by lowercased name (HTML attribute names are
|
|
118
|
+
# case-insensitive); the first occurrence wins, as in the browser.
|
|
119
|
+
def tag_attributes(text)
|
|
120
|
+
attrs = {}
|
|
121
|
+
text.scan(ATTR) do |key, double, single, bare|
|
|
122
|
+
value = double || single || bare
|
|
123
|
+
attrs[key.downcase] ||= value.nil? ? "" : CGI.unescapeHTML(value)
|
|
124
|
+
end
|
|
125
|
+
attrs
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -16,6 +16,10 @@ module Phlex
|
|
|
16
16
|
include Sortable
|
|
17
17
|
include Editable
|
|
18
18
|
include MediaBinding
|
|
19
|
+
include ImageFallbackBinding
|
|
20
|
+
include CountdownBinding
|
|
21
|
+
include FieldReferences
|
|
22
|
+
include ShowOptions
|
|
19
23
|
|
|
20
24
|
# The acting client's SSE connection id during the current action (nil
|
|
21
25
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -386,11 +390,7 @@ module Phlex
|
|
|
386
390
|
"(declared: #{actions.keys.inspect})"
|
|
387
391
|
end
|
|
388
392
|
|
|
389
|
-
if debounce && throttle
|
|
390
|
-
raise ArgumentError,
|
|
391
|
-
"on(#{action_name.inspect}) got both debounce: and throttle: — they are mutually " \
|
|
392
|
-
"exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
|
|
393
|
-
end
|
|
393
|
+
raise ArgumentError, both_waits_message("on(#{action_name.inspect})") if debounce && throttle
|
|
394
394
|
|
|
395
395
|
# Guarded here, not inside: building the caller label allocates (on() is a hot path).
|
|
396
396
|
shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
|
|
@@ -467,6 +467,13 @@ module Phlex
|
|
|
467
467
|
# no-op (no confirm, no preventDefault). Not Turbo-specific: any
|
|
468
468
|
# CustomEvent whose detail carries a flag works.
|
|
469
469
|
#
|
|
470
|
+
# Issue #393: `debounce:` (trailing edge) and `throttle:` (leading edge)
|
|
471
|
+
# wait the way on()'s do — non-negative Integer milliseconds, never both,
|
|
472
|
+
# on the same client timers (a blur flushes a pending debounce). once:
|
|
473
|
+
# still means one run, after the wait:
|
|
474
|
+
# input(**on_client(:input, js.submit("#search"), debounce: 300))
|
|
475
|
+
# div(**on_client(:scroll, js.add_class("#bar", "stuck"), window: true, throttle: 250))
|
|
476
|
+
#
|
|
470
477
|
# Ops are EPHEMERAL UI: any server re-render of the component (an action
|
|
471
478
|
# reply, a broadcast, a morph) rebuilds from server state and resets
|
|
472
479
|
# whatever they toggled — by design (the LiveView JS-commands caveat). Use
|
|
@@ -476,7 +483,7 @@ module Phlex
|
|
|
476
483
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
477
484
|
# browser.
|
|
478
485
|
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
|
|
479
|
-
shortcut: nil)
|
|
486
|
+
shortcut: nil, debounce: nil, throttle: nil)
|
|
480
487
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
481
488
|
raise ArgumentError,
|
|
482
489
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -496,6 +503,7 @@ module Phlex
|
|
|
496
503
|
shortcut = client_shortcut!(event, shortcut, window:, outside:)
|
|
497
504
|
window_bound = window || outside || !shortcut.nil?
|
|
498
505
|
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
|
|
506
|
+
client_binding_wait!(record, event, debounce, throttle) unless debounce.nil? && throttle.nil?
|
|
499
507
|
# The record's `on` stays the bare keydown: the client reads a key
|
|
500
508
|
# filter from `on`, and `mod` is not a key.
|
|
501
509
|
record["shortcut"] = shortcut.param if shortcut
|
|
@@ -673,13 +681,19 @@ module Phlex
|
|
|
673
681
|
conditions = options.slice(*SHOW_CONDITION_KEYS)
|
|
674
682
|
disable = options.delete(:disable)
|
|
675
683
|
values_override = options.delete(:values)
|
|
676
|
-
|
|
684
|
+
after, clear, focus, scroll = options.values_at(:after, :clear, :focus, :scroll)
|
|
685
|
+
attrs = options.except(*SHOW_CONDITION_KEYS, *SHOW_OPTION_KEYS)
|
|
677
686
|
|
|
678
687
|
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
688
|
+
reference_same_fields!(:reactive_show, groups)
|
|
679
689
|
data = { reactive_show: { "any" => groups }.to_json }
|
|
680
690
|
data[:reactive_show_disable] = "true" if disable
|
|
691
|
+
data.merge!(show_option_data(groups, after:, clear:, focus:, scroll:))
|
|
681
692
|
|
|
682
693
|
result = mix({ data: }, attrs)
|
|
694
|
+
# after: — the server never saw a blur, so the section starts hidden.
|
|
695
|
+
return result.key?(:hidden) ? result : result.merge(hidden: true) unless after.nil?
|
|
696
|
+
|
|
683
697
|
apply_first_paint_hidden(result, groups, values_override)
|
|
684
698
|
end
|
|
685
699
|
|
|
@@ -1182,10 +1196,11 @@ module Phlex
|
|
|
1182
1196
|
{ data: { reactive_show_targets: normalized.to_json } }
|
|
1183
1197
|
end
|
|
1184
1198
|
|
|
1185
|
-
# Client-only
|
|
1186
|
-
# #239) — "don't make me start over". Spread on the ROOT (mix with
|
|
1199
|
+
# Client-only browser-storage draft over the fields this root OWNS
|
|
1200
|
+
# (issue #239) — "don't make me start over". Spread on the ROOT (mix with
|
|
1187
1201
|
# reactive_root, like reactive_show_targets); the generic controller
|
|
1188
|
-
# then writes every owned control's value to
|
|
1202
|
+
# then writes every owned control's value to the selected store
|
|
1203
|
+
# (localStorage, or sessionStorage with `storage: :session`) as the user
|
|
1189
1204
|
# types (debounced on `input`, immediate on `change`, flushed on
|
|
1190
1205
|
# disconnect), restores the draft on the NEXT connect, and forgets it
|
|
1191
1206
|
# when the owning form submits successfully (turbo:submit-end), when
|
|
@@ -1214,14 +1229,22 @@ module Phlex
|
|
|
1214
1229
|
# control the server rendered BLANK, so a 422 re-render's submitted
|
|
1215
1230
|
# values win over an older draft; `:always` lets the draft win.
|
|
1216
1231
|
#
|
|
1232
|
+
# `storage:` — `:local` (default) keeps the draft in localStorage,
|
|
1233
|
+
# shared by every tab and outliving them; `:session` (issue #397) keeps
|
|
1234
|
+
# it in sessionStorage: this tab only, restored on reload, gone when
|
|
1235
|
+
# the tab closes (a tab opened from it with an opener, or duplicated,
|
|
1236
|
+
# starts with the browser's one-time copy of the session store). A
|
|
1237
|
+
# string of the same word is accepted. Each store is
|
|
1238
|
+
# read on its own: a `:local` draft is never restored by `:session`.
|
|
1239
|
+
#
|
|
1217
1240
|
# Same posture as reactive_show: no token, no POST, no expression
|
|
1218
1241
|
# surface. Stored values are plain user input replayed via
|
|
1219
1242
|
# .value/.checked (never HTML) — a tampered draft can only fill what
|
|
1220
|
-
# the user could type. PII sits in
|
|
1221
|
-
# successful-submit clear and `
|
|
1222
|
-
# (see docs/security). ONE call per root — Phlex `mix` space-joins
|
|
1243
|
+
# the user could type. PII sits in the selected store for `ttl`; the
|
|
1244
|
+
# successful-submit clear, `ttl` and `storage: :session` are the
|
|
1245
|
+
# shared-computer mitigation (see docs/security). ONE call per root — Phlex `mix` space-joins
|
|
1223
1246
|
# duplicate string data values, so a second call would corrupt the JSON.
|
|
1224
|
-
def reactive_persist(key:, ttl: 7.days, fields: nil, restore: :blank, debounce: 300)
|
|
1247
|
+
def reactive_persist(key:, ttl: 7.days, fields: nil, restore: :blank, debounce: 300, storage: :local)
|
|
1225
1248
|
payload = {
|
|
1226
1249
|
"key" => validate_persist_key!(key),
|
|
1227
1250
|
"ttl" => validate_persist_ttl!(ttl),
|
|
@@ -1229,6 +1252,7 @@ module Phlex
|
|
|
1229
1252
|
}
|
|
1230
1253
|
payload["restore"] = "always" if validate_persist_restore!(restore) == :always
|
|
1231
1254
|
payload["fields"] = validate_persist_fields!(fields) if fields
|
|
1255
|
+
payload["storage"] = "session" if validate_persist_storage!(storage) == :session
|
|
1232
1256
|
|
|
1233
1257
|
{ data: { reactive_persist: payload.to_json } }
|
|
1234
1258
|
end
|
|
@@ -1265,6 +1289,16 @@ module Phlex
|
|
|
1265
1289
|
raise ArgumentError, "reactive_persist restore: must be :blank or :always, got #{restore.inspect}"
|
|
1266
1290
|
end
|
|
1267
1291
|
|
|
1292
|
+
PERSIST_STORAGES = %w[local session].freeze
|
|
1293
|
+
private_constant :PERSIST_STORAGES
|
|
1294
|
+
|
|
1295
|
+
def validate_persist_storage!(storage)
|
|
1296
|
+
name = storage.to_s if storage.is_a?(Symbol) || storage.is_a?(String)
|
|
1297
|
+
return name.to_sym if PERSIST_STORAGES.include?(name)
|
|
1298
|
+
|
|
1299
|
+
raise ArgumentError, "reactive_persist storage: must be :local or :session, got #{storage.inspect}"
|
|
1300
|
+
end
|
|
1301
|
+
|
|
1268
1302
|
def validate_persist_fields!(fields)
|
|
1269
1303
|
list = Array(fields)
|
|
1270
1304
|
raise ArgumentError, "reactive_persist fields: needs at least one field name" if list.empty?
|
|
@@ -1378,6 +1412,10 @@ module Phlex
|
|
|
1378
1412
|
# vocabulary; there are no predicate kwargs any more.
|
|
1379
1413
|
SHOW_CONDITION_KEYS = %i[if if_any unless].freeze
|
|
1380
1414
|
|
|
1415
|
+
# reactive_show's own keywords beyond disable:/values: (ShowOptions):
|
|
1416
|
+
# the after: blur gate (#396) and the visibility transitions (#394).
|
|
1417
|
+
SHOW_OPTION_KEYS = %i[after clear focus scroll].freeze
|
|
1418
|
+
|
|
1381
1419
|
# The removed 0.9.5 surface (issue #180 clean break): each of these
|
|
1382
1420
|
# kwargs — and a positional field — now raises a GUIDED error printing
|
|
1383
1421
|
# the if:/if_any:/unless: rewrite. Kept only to detect the legacy call
|
|
@@ -1513,6 +1551,27 @@ module Phlex
|
|
|
1513
1551
|
record
|
|
1514
1552
|
end
|
|
1515
1553
|
|
|
1554
|
+
# Issue #393: the one message on() and on_client() raise when given both
|
|
1555
|
+
# waits. Built only on that error path (on() is a hot path).
|
|
1556
|
+
def both_waits_message(label)
|
|
1557
|
+
"#{label} got both debounce: and throttle: — they are mutually exclusive " \
|
|
1558
|
+
"(debounce is trailing-edge, throttle is leading-edge); pick one"
|
|
1559
|
+
end
|
|
1560
|
+
|
|
1561
|
+
# Issue #393: on_client's debounce:/throttle: ride on the binding record,
|
|
1562
|
+
# validated loud at render (non-negative Integer ms, never both).
|
|
1563
|
+
def client_binding_wait!(record, event, debounce, throttle)
|
|
1564
|
+
label = "on_client(#{event.inspect})"
|
|
1565
|
+
raise ArgumentError, both_waits_message(label) if debounce && throttle
|
|
1566
|
+
|
|
1567
|
+
key, ms = debounce.nil? ? ["throttle", throttle] : ["debounce", debounce]
|
|
1568
|
+
unless ms.is_a?(Integer) && !ms.negative?
|
|
1569
|
+
raise ArgumentError, "#{label} #{key}: must be a non-negative Integer (ms), got #{ms.inspect}"
|
|
1570
|
+
end
|
|
1571
|
+
|
|
1572
|
+
record[key] = ms
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1516
1575
|
# Issue #346: validate + stringify on_client's detail: filter. Loud at
|
|
1517
1576
|
# render, like the other on_client validations — a dead filter fails
|
|
1518
1577
|
# here, never silently in the browser.
|
|
@@ -1766,13 +1825,16 @@ module Phlex
|
|
|
1766
1825
|
|
|
1767
1826
|
private_constant :REMOVED_ON_KWARGS, :PENDING_CLASS_OPS
|
|
1768
1827
|
|
|
1828
|
+
# Runs on every on() (a hot path): a key lookup per removed name, no arrays.
|
|
1769
1829
|
def reject_removed_on_kwargs!(action_name, params)
|
|
1770
|
-
|
|
1771
|
-
return if removed.empty?
|
|
1830
|
+
return if params.empty?
|
|
1772
1831
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1832
|
+
REMOVED_ON_KWARGS.each_key do
|
|
1833
|
+
next unless params.key?(it)
|
|
1834
|
+
|
|
1835
|
+
raise ArgumentError,
|
|
1836
|
+
"on(#{action_name.inspect}) #{it}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[it]}"
|
|
1837
|
+
end
|
|
1776
1838
|
end
|
|
1777
1839
|
|
|
1778
1840
|
# Issue #357: parse on()'s shortcut: (nil when absent). A shortcut
|
|
@@ -18,6 +18,16 @@ module Phlex
|
|
|
18
18
|
module Identity
|
|
19
19
|
extend ActiveSupport::Concern
|
|
20
20
|
|
|
21
|
+
# Whether the installed GlobalID gives a class its own app
|
|
22
|
+
# (global_id_app, then GlobalID.app — rails/globalid after 1.4).
|
|
23
|
+
# Read once: it is a fact about the loaded gem.
|
|
24
|
+
def self.per_class_gid_app?
|
|
25
|
+
return @per_class_gid_app if defined?(@per_class_gid_app)
|
|
26
|
+
|
|
27
|
+
@per_class_gid_app = ::GlobalID::Identification.const_defined?(:ClassMethods, false) &&
|
|
28
|
+
::GlobalID::Identification::ClassMethods.method_defined?(:global_id_app)
|
|
29
|
+
end
|
|
30
|
+
|
|
21
31
|
class_methods do
|
|
22
32
|
# The record's instance-variable symbol (e.g. :@todo), computed once.
|
|
23
33
|
# reactive_token reads it on every render; interpolating :"@#{key}" each
|
|
@@ -74,7 +84,7 @@ module Phlex
|
|
|
74
84
|
# before to_gid (nil.to_gid would raise).
|
|
75
85
|
if (record_ivar = klass.reactive_record_ivar) &&
|
|
76
86
|
(record = instance_variable_get(record_ivar)) && signable_gid?(record)
|
|
77
|
-
payload["gid"] = record
|
|
87
|
+
payload["gid"] = reactive_gid(record)
|
|
78
88
|
end
|
|
79
89
|
|
|
80
90
|
state_ivars = klass.reactive_state_ivars
|
|
@@ -95,6 +105,29 @@ module Phlex
|
|
|
95
105
|
def signable_gid?(record)
|
|
96
106
|
!(record.respond_to?(:persisted?) && !record.persisted?)
|
|
97
107
|
end
|
|
108
|
+
|
|
109
|
+
# record.to_gid.to_s, without the URI. For a record whose to_gid is
|
|
110
|
+
# GlobalID's own (not overridden on its class or on itself) and whose
|
|
111
|
+
# id is an Integer, GlobalID's string is exactly
|
|
112
|
+
# "gid://<GlobalID.app>/<class name>/<id>": an Integer needs no
|
|
113
|
+
# escaping and there are no params. Building it directly skips a
|
|
114
|
+
# validated URI::GID (~37 objects, about half a record token's cost),
|
|
115
|
+
# once per record-backed row per render. Anything else goes through
|
|
116
|
+
# to_gid unchanged, its errors included.
|
|
117
|
+
def reactive_gid(record)
|
|
118
|
+
if record.is_a?(::GlobalID::Identification) &&
|
|
119
|
+
record.method(:to_gid).owner.equal?(::GlobalID::Identification) &&
|
|
120
|
+
(id = record.id).is_a?(Integer) && (app = reactive_gid_app(record.class)) && (name = record.class.name)
|
|
121
|
+
"gid://#{app}/#{name}/#{id}"
|
|
122
|
+
else
|
|
123
|
+
record.to_gid.to_s
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# The app to_gid would use: the class's own where GlobalID has one.
|
|
128
|
+
def reactive_gid_app(klass)
|
|
129
|
+
(Identity.per_class_gid_app? && klass.global_id_app) || ::GlobalID.app
|
|
130
|
+
end
|
|
98
131
|
end
|
|
99
132
|
end
|
|
100
133
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# An <img> that falls back when its src fails and recovers when the src
|
|
7
|
+
# changes (issue #383).
|
|
8
|
+
#
|
|
9
|
+
# img(id: "logo", alt: "", **reactive_image_fallback(hide: true))
|
|
10
|
+
# img(id: "logo", alt: "Bank", **reactive_image_fallback(show_alt: "#bank-name"))
|
|
11
|
+
# span(id: "bank-name", hidden: true)
|
|
12
|
+
#
|
|
13
|
+
# On `error` the client sets data-reactive-image-state="error", adds
|
|
14
|
+
# `hidden` (hide: true, the default) and, with show_alt:, writes the
|
|
15
|
+
# img's alt into that node (a CSS selector inside the root) and shows it.
|
|
16
|
+
# On `load` the state is "loaded". A later src — copy_media,
|
|
17
|
+
# reactive_media, preview_file, a morph, or any src that loads — clears
|
|
18
|
+
# the error and shows the img again; an img with no src is never an
|
|
19
|
+
# error. hide: false leaves visibility alone: style the state with CSS.
|
|
20
|
+
# Composes with reactive_media (its **attrs mix the data attribute in):
|
|
21
|
+
#
|
|
22
|
+
# img(**reactive_media(from: :rib, pool: POOL, **reactive_image_fallback))
|
|
23
|
+
#
|
|
24
|
+
#
|
|
25
|
+
# wrap: (issue #392) names the box around the img (a CSS selector inside
|
|
26
|
+
# the root, like show_alt:). On error the client hides it and marks the
|
|
27
|
+
# hide as its own (data-reactive-wrap-hidden); recovery shows it again
|
|
28
|
+
# only when that mark is there, so a wrapper the author hid stays
|
|
29
|
+
# hidden. The wrapper may hold the img: its text is never written.
|
|
30
|
+
# hide: false with wrap: hides the wrapper only.
|
|
31
|
+
#
|
|
32
|
+
# span(id: "badge", class: "rounded-full p-1") do
|
|
33
|
+
# img(id: "logo", alt: "", **reactive_image_fallback(hide: false, wrap: "#badge"))
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# Named ImageFallbackBinding for the reason MediaBinding is (issue #233).
|
|
37
|
+
module ImageFallbackBinding
|
|
38
|
+
def reactive_image_fallback(hide: true, show_alt: nil, wrap: nil, **attrs)
|
|
39
|
+
wire = { "hide" => image_fallback_hide!(hide) }
|
|
40
|
+
wire["alt"] = image_fallback_selector!(:show_alt, show_alt, "#bank-name") unless show_alt.nil?
|
|
41
|
+
wire["wrap"] = image_fallback_selector!(:wrap, wrap, "#badge") unless wrap.nil?
|
|
42
|
+
mix({ data: { reactive_image_fallback: wire.to_json } }, attrs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def image_fallback_hide!(hide)
|
|
48
|
+
return hide if [true, false].include?(hide)
|
|
49
|
+
|
|
50
|
+
raise ArgumentError, "reactive_image_fallback hide: must be true or false, got #{hide.inspect}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A selector the client passes to querySelector, never a URL.
|
|
54
|
+
def image_fallback_selector!(key, selector, example)
|
|
55
|
+
if selector == JS::ROOT_SENTINEL
|
|
56
|
+
raise ArgumentError,
|
|
57
|
+
"reactive_image_fallback #{key}: #{selector} names the whole root — pass the selector of a node inside it"
|
|
58
|
+
end
|
|
59
|
+
return selector if selector.is_a?(String) && !selector.strip.empty?
|
|
60
|
+
|
|
61
|
+
raise ArgumentError,
|
|
62
|
+
"reactive_image_fallback #{key}: must be a CSS selector (e.g. \"#{example}\"), got #{selector.inspect}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -20,18 +20,37 @@ module Phlex
|
|
|
20
20
|
# first n characters as the key; omit it to use the whole value. A pool
|
|
21
21
|
# value is a CSS SELECTOR for an <img> inside the root, never a URL: the
|
|
22
22
|
# client copies that element's src, srcset and alt the way
|
|
23
|
-
# js.copy_media does. A key, selector or element that misses
|
|
24
|
-
# preview.
|
|
25
|
-
# image
|
|
23
|
+
# js.copy_media does. A key, selector or element that misses (or is not
|
|
24
|
+
# an <img>) clears the preview. A pool <img> with an alt and no src is a
|
|
25
|
+
# hit without an image: the preview drops its src and takes the alt
|
|
26
|
+
# (issue #381). `label:` names a node whose textContent becomes the
|
|
27
|
+
# chosen image's alt (`blank_label`, or "", on a miss). Client-only: no token,
|
|
26
28
|
# no round trip.
|
|
27
29
|
#
|
|
30
|
+
# Until the field has `take` characters, or while `match:` (a Regexp,
|
|
31
|
+
# anchored to the whole key) rejects the key, the binding is PENDING
|
|
32
|
+
# (issue #380): never `blank_label`. `pending: :clear` (the default)
|
|
33
|
+
# clears the preview and empties the label; `pending: :keep` leaves both
|
|
34
|
+
# alone. The preview's data-reactive-media-state reads pending, hit or
|
|
35
|
+
# miss, so a row can hide itself with CSS until there is an answer.
|
|
36
|
+
#
|
|
37
|
+
# `when:` (a Regexp, anchored to the WHOLE field value, issue #390) is
|
|
38
|
+
# tested before the pool lookup while the key stays the `take` prefix: a
|
|
39
|
+
# value it rejects is pending too ("007bad" never hits "007"). It follows
|
|
40
|
+
# match:'s MediaPattern rules; match: still tests the key only.
|
|
41
|
+
#
|
|
28
42
|
# Named MediaBinding, not Media: this module sits in every reactive
|
|
29
43
|
# component's ancestry, and a bare `Media` would shadow a host app's kit
|
|
30
44
|
# component of that name (issue #233).
|
|
31
45
|
module MediaBinding
|
|
32
|
-
def reactive_media(from:, pool: nil, take: nil,
|
|
46
|
+
def reactive_media(from:, pool: nil, take: nil, match: nil, when: nil, pending: :clear, label: nil,
|
|
47
|
+
blank_label: nil, **attrs)
|
|
48
|
+
whole = binding.local_variable_get(:when)
|
|
33
49
|
wire = { "field" => media_field!(from) }
|
|
34
50
|
wire["take"] = media_take!(take) unless take.nil?
|
|
51
|
+
wire["match"] = MediaPattern.to_js(match) unless match.nil?
|
|
52
|
+
wire["when"] = MediaPattern.to_js(whole, :when) unless whole.nil?
|
|
53
|
+
wire["pending"] = "keep" if media_pending!(pending) == "keep"
|
|
35
54
|
wire["pool"] = media_pool!(pool)
|
|
36
55
|
wire["label"] = media_selector!(:label, label) unless label.nil?
|
|
37
56
|
wire["blank"] = blank_label.to_s unless blank_label.nil?
|
|
@@ -54,6 +73,13 @@ module Phlex
|
|
|
54
73
|
"got #{take.inspect}"
|
|
55
74
|
end
|
|
56
75
|
|
|
76
|
+
def media_pending!(pending)
|
|
77
|
+
mode = pending.to_s if pending.is_a?(Symbol) || pending.is_a?(String)
|
|
78
|
+
return mode if %w[clear keep].include?(mode)
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "reactive_media pending: must be :clear or :keep, got #{pending.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
57
83
|
def media_pool!(pool)
|
|
58
84
|
if pool.nil?
|
|
59
85
|
raise ArgumentError, "reactive_media needs pool: — a Hash of key => selector of a server-rendered <img> " \
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# reactive_media's match: (issue #380) and when: (issue #390) — a Ruby
|
|
7
|
+
# Regexp as the JavaScript source the client anchors as ^(?:…)$ and
|
|
8
|
+
# compiles without flags. Errors name the keyword. The source is tokenized (an escape is one token, so an escaped backslash is
|
|
9
|
+
# never mistaken for one): a leading \A and a trailing \z/\Z are dropped,
|
|
10
|
+
# and anything JavaScript would read differently raises at render rather
|
|
11
|
+
# than matching silently wrong in the browser.
|
|
12
|
+
module MediaPattern
|
|
13
|
+
# Escapes Ruby reads as an anchor or a class and JS reads as a letter,
|
|
14
|
+
# plus \s/\S, whose JS class also holds Unicode spaces Ruby's does not.
|
|
15
|
+
RUBY_ONLY_ESCAPES = %w[\A \z \Z \G \h \H \R \X \K \p \P \s \S].freeze
|
|
16
|
+
# The group openers both engines read alike: (?: (?= (?! (?<= (?<! (?<name>
|
|
17
|
+
SHARED_GROUPS = [":", "=", "!", "<"].freeze
|
|
18
|
+
OPTIONS = Regexp::IGNORECASE | Regexp::EXTENDED | Regexp::MULTILINE
|
|
19
|
+
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def to_js(match, name = :match)
|
|
23
|
+
unless match.is_a?(Regexp)
|
|
24
|
+
raise ArgumentError,
|
|
25
|
+
"reactive_media #{name}: must be a Regexp (e.g. /\\A\\d{3}\\z/), got #{match.inspect}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
unless match.options.nobits?(OPTIONS)
|
|
29
|
+
refuse!(name, match,
|
|
30
|
+
"has options (i, m, x) the client would read differently — drop them")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
tokens = match.source.scan(/\\.|./m)
|
|
34
|
+
tokens.shift if tokens.first == "\\A"
|
|
35
|
+
final = tokens.pop if %w[\z \Z].include?(tokens.last)
|
|
36
|
+
check!(tokens, match, name)
|
|
37
|
+
# Ruby's \Z also matches before one final newline.
|
|
38
|
+
final == "\\Z" ? "#{tokens.join}\\n?" : tokens.join
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def check!(tokens, match, name)
|
|
42
|
+
in_class = false
|
|
43
|
+
tokens.each_with_index do |token, i|
|
|
44
|
+
if RUBY_ONLY_ESCAPES.include?(token)
|
|
45
|
+
refuse!(name, match,
|
|
46
|
+
"uses #{token}, which is Ruby-only (JavaScript reads it differently)")
|
|
47
|
+
end
|
|
48
|
+
refuse!(name, match, "has a character above U+FFFF (the client pattern has no u flag)") if token.ord > 0xFFFF
|
|
49
|
+
if token == "\\u" && tokens[i + 1] == "{"
|
|
50
|
+
refuse!(name, match,
|
|
51
|
+
"uses \\u{…}, which JavaScript without the u flag reads as a repeated u")
|
|
52
|
+
end
|
|
53
|
+
if in_class
|
|
54
|
+
in_class = false if token == "]"
|
|
55
|
+
class_syntax!(tokens, i, match, name)
|
|
56
|
+
elsif token == "["
|
|
57
|
+
in_class = true
|
|
58
|
+
elsif token == "(" && tokens[i + 1] == "?" && !SHARED_GROUPS.include?(tokens[i + 2])
|
|
59
|
+
refuse!(name, match, "uses (?#{tokens[i + 2]}…), a group JavaScript does not read the same way")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A [ inside a class (a nested class or a POSIX [:digit:]) and && (an
|
|
65
|
+
# intersection) are Ruby-only: JavaScript reads them as literals.
|
|
66
|
+
def class_syntax!(tokens, index, match, name)
|
|
67
|
+
token = tokens[index]
|
|
68
|
+
return unless token == "[" || (token == "&" && tokens[index + 1] == "&")
|
|
69
|
+
|
|
70
|
+
refuse!(name, match, "has a nested character class, a POSIX [:class:] or && — JavaScript reads them literally")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def refuse!(name, match, why)
|
|
74
|
+
raise ArgumentError, "reactive_media #{name}: #{match.inspect} #{why}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -34,6 +34,7 @@ module Phlex
|
|
|
34
34
|
attrs = options.except(*Helpers::SHOW_CONDITION_KEYS)
|
|
35
35
|
|
|
36
36
|
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
37
|
+
reference_same_fields!(:reactive_enable, groups)
|
|
37
38
|
result = mix({ data: { reactive_enable: { "any" => groups }.to_json } }, attrs)
|
|
38
39
|
return result if result.key?(:disabled)
|
|
39
40
|
|
|
@@ -46,8 +47,21 @@ module Phlex
|
|
|
46
47
|
# flips, so computes, shows and counts re-run); its own checked /
|
|
47
48
|
# indeterminate state follows the group. Give the header NO `name` —
|
|
48
49
|
# it would post, and count as a member.
|
|
49
|
-
|
|
50
|
-
|
|
50
|
+
#
|
|
51
|
+
# Nested groups (issue #398): under: "library.books" is a GROUP PATH.
|
|
52
|
+
# The header then owns only the boxes of the name whose
|
|
53
|
+
# data-reactive-group is that path or below it ("library.books.view"),
|
|
54
|
+
# so every leaf still posts under ONE name while a tree of headers
|
|
55
|
+
# ticks, unticks and goes indeterminate per branch. A header with no
|
|
56
|
+
# under: is the whole name, as before.
|
|
57
|
+
#
|
|
58
|
+
# input(type: "checkbox", **reactive_select_all("perms[]")) # everything
|
|
59
|
+
# input(type: "checkbox", **reactive_select_all("perms[]", under: "library")) # a branch
|
|
60
|
+
# input(type: "checkbox", name: "perms[]", value: "books", data: { reactive_group: "library" })
|
|
61
|
+
def reactive_select_all(group, under: nil, **attrs)
|
|
62
|
+
data = { reactive_select_all: selection_group!(:reactive_select_all, group) }
|
|
63
|
+
data[:reactive_select_all_under] = selection_path!(group, under) unless under.nil?
|
|
64
|
+
mix({ data: }, attrs)
|
|
51
65
|
end
|
|
52
66
|
|
|
53
67
|
# A text binding that shows how many owned boxes of the group are
|
|
@@ -176,6 +190,14 @@ module Phlex
|
|
|
176
190
|
{ "any" => Phlex::Reactive::ShowConditions.normalize(**conditions) }
|
|
177
191
|
end
|
|
178
192
|
|
|
193
|
+
def selection_path!(group, under)
|
|
194
|
+
path = under.to_s
|
|
195
|
+
return path unless path.strip.empty? || path.split(".", -1).any? { it.empty? || it != it.strip }
|
|
196
|
+
|
|
197
|
+
raise ArgumentError, "reactive_select_all(#{group.to_s.inspect}) under: needs a group path like " \
|
|
198
|
+
"\"library\" or \"library.books\" (no empty or space-padded segment), got #{under.inspect}"
|
|
199
|
+
end
|
|
200
|
+
|
|
179
201
|
def selection_group!(helper, group)
|
|
180
202
|
name = group.to_s
|
|
181
203
|
return name unless name.strip.empty?
|