phlex-reactive 0.15.3 → 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 +168 -0
- data/README.md +81 -12
- 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 +129 -16
- 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/persist.js +10 -6
- 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 +8 -7
- data/app/javascript/phlex/reactive/runtime.js +172 -30
- 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 +72 -14
- data/lib/phlex/reactive/component/image_fallback_binding.rb +18 -5
- data/lib/phlex/reactive/component/media_binding.rb +9 -1
- data/lib/phlex/reactive/component/media_pattern.rb +17 -17
- data/lib/phlex/reactive/component/selection.rb +24 -2
- data/lib/phlex/reactive/component/show_options.rb +91 -0
- data/lib/phlex/reactive/engine.rb +2 -2
- data/lib/phlex/reactive/js.rb +44 -2
- 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 +8 -1
|
@@ -17,6 +17,9 @@ module Phlex
|
|
|
17
17
|
include Editable
|
|
18
18
|
include MediaBinding
|
|
19
19
|
include ImageFallbackBinding
|
|
20
|
+
include CountdownBinding
|
|
21
|
+
include FieldReferences
|
|
22
|
+
include ShowOptions
|
|
20
23
|
|
|
21
24
|
# The acting client's SSE connection id during the current action (nil
|
|
22
25
|
# outside an action, or when the client isn't subscribed to a stream).
|
|
@@ -387,11 +390,7 @@ module Phlex
|
|
|
387
390
|
"(declared: #{actions.keys.inspect})"
|
|
388
391
|
end
|
|
389
392
|
|
|
390
|
-
if debounce && throttle
|
|
391
|
-
raise ArgumentError,
|
|
392
|
-
"on(#{action_name.inspect}) got both debounce: and throttle: — they are mutually " \
|
|
393
|
-
"exclusive (debounce is trailing-edge, throttle is leading-edge); pick one"
|
|
394
|
-
end
|
|
393
|
+
raise ArgumentError, both_waits_message("on(#{action_name.inspect})") if debounce && throttle
|
|
395
394
|
|
|
396
395
|
# Guarded here, not inside: building the caller label allocates (on() is a hot path).
|
|
397
396
|
shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
|
|
@@ -468,6 +467,13 @@ module Phlex
|
|
|
468
467
|
# no-op (no confirm, no preventDefault). Not Turbo-specific: any
|
|
469
468
|
# CustomEvent whose detail carries a flag works.
|
|
470
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
|
+
#
|
|
471
477
|
# Ops are EPHEMERAL UI: any server re-render of the component (an action
|
|
472
478
|
# reply, a broadcast, a morph) rebuilds from server state and resets
|
|
473
479
|
# whatever they toggled — by design (the LiveView JS-commands caveat). Use
|
|
@@ -477,7 +483,7 @@ module Phlex
|
|
|
477
483
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
478
484
|
# browser.
|
|
479
485
|
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
|
|
480
|
-
shortcut: nil)
|
|
486
|
+
shortcut: nil, debounce: nil, throttle: nil)
|
|
481
487
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
482
488
|
raise ArgumentError,
|
|
483
489
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -497,6 +503,7 @@ module Phlex
|
|
|
497
503
|
shortcut = client_shortcut!(event, shortcut, window:, outside:)
|
|
498
504
|
window_bound = window || outside || !shortcut.nil?
|
|
499
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?
|
|
500
507
|
# The record's `on` stays the bare keydown: the client reads a key
|
|
501
508
|
# filter from `on`, and `mod` is not a key.
|
|
502
509
|
record["shortcut"] = shortcut.param if shortcut
|
|
@@ -674,13 +681,19 @@ module Phlex
|
|
|
674
681
|
conditions = options.slice(*SHOW_CONDITION_KEYS)
|
|
675
682
|
disable = options.delete(:disable)
|
|
676
683
|
values_override = options.delete(:values)
|
|
677
|
-
|
|
684
|
+
after, clear, focus, scroll = options.values_at(:after, :clear, :focus, :scroll)
|
|
685
|
+
attrs = options.except(*SHOW_CONDITION_KEYS, *SHOW_OPTION_KEYS)
|
|
678
686
|
|
|
679
687
|
groups = Phlex::Reactive::ShowConditions.normalize(**conditions)
|
|
688
|
+
reference_same_fields!(:reactive_show, groups)
|
|
680
689
|
data = { reactive_show: { "any" => groups }.to_json }
|
|
681
690
|
data[:reactive_show_disable] = "true" if disable
|
|
691
|
+
data.merge!(show_option_data(groups, after:, clear:, focus:, scroll:))
|
|
682
692
|
|
|
683
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
|
+
|
|
684
697
|
apply_first_paint_hidden(result, groups, values_override)
|
|
685
698
|
end
|
|
686
699
|
|
|
@@ -1183,10 +1196,11 @@ module Phlex
|
|
|
1183
1196
|
{ data: { reactive_show_targets: normalized.to_json } }
|
|
1184
1197
|
end
|
|
1185
1198
|
|
|
1186
|
-
# Client-only
|
|
1187
|
-
# #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
|
|
1188
1201
|
# reactive_root, like reactive_show_targets); the generic controller
|
|
1189
|
-
# 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
|
|
1190
1204
|
# types (debounced on `input`, immediate on `change`, flushed on
|
|
1191
1205
|
# disconnect), restores the draft on the NEXT connect, and forgets it
|
|
1192
1206
|
# when the owning form submits successfully (turbo:submit-end), when
|
|
@@ -1215,14 +1229,22 @@ module Phlex
|
|
|
1215
1229
|
# control the server rendered BLANK, so a 422 re-render's submitted
|
|
1216
1230
|
# values win over an older draft; `:always` lets the draft win.
|
|
1217
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
|
+
#
|
|
1218
1240
|
# Same posture as reactive_show: no token, no POST, no expression
|
|
1219
1241
|
# surface. Stored values are plain user input replayed via
|
|
1220
1242
|
# .value/.checked (never HTML) — a tampered draft can only fill what
|
|
1221
|
-
# the user could type. PII sits in
|
|
1222
|
-
# successful-submit clear and `
|
|
1223
|
-
# (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
|
|
1224
1246
|
# duplicate string data values, so a second call would corrupt the JSON.
|
|
1225
|
-
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)
|
|
1226
1248
|
payload = {
|
|
1227
1249
|
"key" => validate_persist_key!(key),
|
|
1228
1250
|
"ttl" => validate_persist_ttl!(ttl),
|
|
@@ -1230,6 +1252,7 @@ module Phlex
|
|
|
1230
1252
|
}
|
|
1231
1253
|
payload["restore"] = "always" if validate_persist_restore!(restore) == :always
|
|
1232
1254
|
payload["fields"] = validate_persist_fields!(fields) if fields
|
|
1255
|
+
payload["storage"] = "session" if validate_persist_storage!(storage) == :session
|
|
1233
1256
|
|
|
1234
1257
|
{ data: { reactive_persist: payload.to_json } }
|
|
1235
1258
|
end
|
|
@@ -1266,6 +1289,16 @@ module Phlex
|
|
|
1266
1289
|
raise ArgumentError, "reactive_persist restore: must be :blank or :always, got #{restore.inspect}"
|
|
1267
1290
|
end
|
|
1268
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
|
+
|
|
1269
1302
|
def validate_persist_fields!(fields)
|
|
1270
1303
|
list = Array(fields)
|
|
1271
1304
|
raise ArgumentError, "reactive_persist fields: needs at least one field name" if list.empty?
|
|
@@ -1379,6 +1412,10 @@ module Phlex
|
|
|
1379
1412
|
# vocabulary; there are no predicate kwargs any more.
|
|
1380
1413
|
SHOW_CONDITION_KEYS = %i[if if_any unless].freeze
|
|
1381
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
|
+
|
|
1382
1419
|
# The removed 0.9.5 surface (issue #180 clean break): each of these
|
|
1383
1420
|
# kwargs — and a positional field — now raises a GUIDED error printing
|
|
1384
1421
|
# the if:/if_any:/unless: rewrite. Kept only to detect the legacy call
|
|
@@ -1514,6 +1551,27 @@ module Phlex
|
|
|
1514
1551
|
record
|
|
1515
1552
|
end
|
|
1516
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
|
+
|
|
1517
1575
|
# Issue #346: validate + stringify on_client's detail: filter. Loud at
|
|
1518
1576
|
# render, like the other on_client validations — a dead filter fails
|
|
1519
1577
|
# here, never silently in the browser.
|
|
@@ -21,11 +21,24 @@ module Phlex
|
|
|
21
21
|
#
|
|
22
22
|
# img(**reactive_media(from: :rib, pool: POOL, **reactive_image_fallback))
|
|
23
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
|
+
#
|
|
24
36
|
# Named ImageFallbackBinding for the reason MediaBinding is (issue #233).
|
|
25
37
|
module ImageFallbackBinding
|
|
26
|
-
def reactive_image_fallback(hide: true, show_alt: nil, **attrs)
|
|
38
|
+
def reactive_image_fallback(hide: true, show_alt: nil, wrap: nil, **attrs)
|
|
27
39
|
wire = { "hide" => image_fallback_hide!(hide) }
|
|
28
|
-
wire["alt"] =
|
|
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?
|
|
29
42
|
mix({ data: { reactive_image_fallback: wire.to_json } }, attrs)
|
|
30
43
|
end
|
|
31
44
|
|
|
@@ -38,15 +51,15 @@ module Phlex
|
|
|
38
51
|
end
|
|
39
52
|
|
|
40
53
|
# A selector the client passes to querySelector, never a URL.
|
|
41
|
-
def
|
|
54
|
+
def image_fallback_selector!(key, selector, example)
|
|
42
55
|
if selector == JS::ROOT_SENTINEL
|
|
43
56
|
raise ArgumentError,
|
|
44
|
-
"reactive_image_fallback
|
|
57
|
+
"reactive_image_fallback #{key}: #{selector} names the whole root — pass the selector of a node inside it"
|
|
45
58
|
end
|
|
46
59
|
return selector if selector.is_a?(String) && !selector.strip.empty?
|
|
47
60
|
|
|
48
61
|
raise ArgumentError,
|
|
49
|
-
"reactive_image_fallback
|
|
62
|
+
"reactive_image_fallback #{key}: must be a CSS selector (e.g. \"#{example}\"), got #{selector.inspect}"
|
|
50
63
|
end
|
|
51
64
|
end
|
|
52
65
|
end
|
|
@@ -34,14 +34,22 @@ module Phlex
|
|
|
34
34
|
# alone. The preview's data-reactive-media-state reads pending, hit or
|
|
35
35
|
# miss, so a row can hide itself with CSS until there is an answer.
|
|
36
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
|
+
#
|
|
37
42
|
# Named MediaBinding, not Media: this module sits in every reactive
|
|
38
43
|
# component's ancestry, and a bare `Media` would shadow a host app's kit
|
|
39
44
|
# component of that name (issue #233).
|
|
40
45
|
module MediaBinding
|
|
41
|
-
def reactive_media(from:, pool: nil, take: nil, match: nil, pending: :clear, label: 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)
|
|
42
49
|
wire = { "field" => media_field!(from) }
|
|
43
50
|
wire["take"] = media_take!(take) unless take.nil?
|
|
44
51
|
wire["match"] = MediaPattern.to_js(match) unless match.nil?
|
|
52
|
+
wire["when"] = MediaPattern.to_js(whole, :when) unless whole.nil?
|
|
45
53
|
wire["pending"] = "keep" if media_pending!(pending) == "keep"
|
|
46
54
|
wire["pool"] = media_pool!(pool)
|
|
47
55
|
wire["label"] = media_selector!(:label, label) unless label.nil?
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module Phlex
|
|
4
4
|
module Reactive
|
|
5
5
|
module Component
|
|
6
|
-
# reactive_media's match: (issue #380)
|
|
7
|
-
# source the client anchors as ^(?:…)$ and
|
|
8
|
-
# source is tokenized (an escape is one token, so an escaped backslash is
|
|
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
9
|
# never mistaken for one): a leading \A and a trailing \z/\Z are dropped,
|
|
10
10
|
# and anything JavaScript would read differently raises at render rather
|
|
11
11
|
# than matching silently wrong in the browser.
|
|
@@ -19,59 +19,59 @@ module Phlex
|
|
|
19
19
|
|
|
20
20
|
module_function
|
|
21
21
|
|
|
22
|
-
def to_js(match)
|
|
22
|
+
def to_js(match, name = :match)
|
|
23
23
|
unless match.is_a?(Regexp)
|
|
24
24
|
raise ArgumentError,
|
|
25
|
-
"reactive_media
|
|
25
|
+
"reactive_media #{name}: must be a Regexp (e.g. /\\A\\d{3}\\z/), got #{match.inspect}"
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
unless match.options.nobits?(OPTIONS)
|
|
29
|
-
refuse!(match,
|
|
29
|
+
refuse!(name, match,
|
|
30
30
|
"has options (i, m, x) the client would read differently — drop them")
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
tokens = match.source.scan(/\\.|./m)
|
|
34
34
|
tokens.shift if tokens.first == "\\A"
|
|
35
35
|
final = tokens.pop if %w[\z \Z].include?(tokens.last)
|
|
36
|
-
check!(tokens, match)
|
|
36
|
+
check!(tokens, match, name)
|
|
37
37
|
# Ruby's \Z also matches before one final newline.
|
|
38
38
|
final == "\\Z" ? "#{tokens.join}\\n?" : tokens.join
|
|
39
39
|
end
|
|
40
40
|
|
|
41
|
-
def check!(tokens, match)
|
|
41
|
+
def check!(tokens, match, name)
|
|
42
42
|
in_class = false
|
|
43
43
|
tokens.each_with_index do |token, i|
|
|
44
44
|
if RUBY_ONLY_ESCAPES.include?(token)
|
|
45
|
-
refuse!(match,
|
|
45
|
+
refuse!(name, match,
|
|
46
46
|
"uses #{token}, which is Ruby-only (JavaScript reads it differently)")
|
|
47
47
|
end
|
|
48
|
-
refuse!(match, "has a character above U+FFFF (the client pattern has no u flag)") if token.ord > 0xFFFF
|
|
48
|
+
refuse!(name, match, "has a character above U+FFFF (the client pattern has no u flag)") if token.ord > 0xFFFF
|
|
49
49
|
if token == "\\u" && tokens[i + 1] == "{"
|
|
50
|
-
refuse!(match,
|
|
50
|
+
refuse!(name, match,
|
|
51
51
|
"uses \\u{…}, which JavaScript without the u flag reads as a repeated u")
|
|
52
52
|
end
|
|
53
53
|
if in_class
|
|
54
54
|
in_class = false if token == "]"
|
|
55
|
-
class_syntax!(tokens, i, match)
|
|
55
|
+
class_syntax!(tokens, i, match, name)
|
|
56
56
|
elsif token == "["
|
|
57
57
|
in_class = true
|
|
58
58
|
elsif token == "(" && tokens[i + 1] == "?" && !SHARED_GROUPS.include?(tokens[i + 2])
|
|
59
|
-
refuse!(match, "uses (?#{tokens[i + 2]}…), a group JavaScript does not read the same way")
|
|
59
|
+
refuse!(name, match, "uses (?#{tokens[i + 2]}…), a group JavaScript does not read the same way")
|
|
60
60
|
end
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
# A [ inside a class (a nested class or a POSIX [:digit:]) and && (an
|
|
65
65
|
# intersection) are Ruby-only: JavaScript reads them as literals.
|
|
66
|
-
def class_syntax!(tokens, index, match)
|
|
66
|
+
def class_syntax!(tokens, index, match, name)
|
|
67
67
|
token = tokens[index]
|
|
68
68
|
return unless token == "[" || (token == "&" && tokens[index + 1] == "&")
|
|
69
69
|
|
|
70
|
-
refuse!(match, "has a nested character class, a POSIX [:class:] or && — JavaScript reads them literally")
|
|
70
|
+
refuse!(name, match, "has a nested character class, a POSIX [:class:] or && — JavaScript reads them literally")
|
|
71
71
|
end
|
|
72
72
|
|
|
73
|
-
def refuse!(match, why)
|
|
74
|
-
raise ArgumentError, "reactive_media
|
|
73
|
+
def refuse!(name, match, why)
|
|
74
|
+
raise ArgumentError, "reactive_media #{name}: #{match.inspect} #{why}"
|
|
75
75
|
end
|
|
76
76
|
end
|
|
77
77
|
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?
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# The reactive_show keywords beyond the condition itself.
|
|
7
|
+
#
|
|
8
|
+
# after: (issue #396) — keep the section hidden until the named field has
|
|
9
|
+
# fired `blur` once since connect. `after: :blur` names the condition's
|
|
10
|
+
# one field; a condition over several fields names it: `after: :code`.
|
|
11
|
+
# The client keeps the touched flag on the root's controller, so a morph
|
|
12
|
+
# keeps it and a full navigation (a new controller) drops it. The server
|
|
13
|
+
# cannot have seen a blur, so first paint is hidden.
|
|
14
|
+
#
|
|
15
|
+
# clear:, focus:, scroll: (issue #394) — work the client does when the
|
|
16
|
+
# section CHANGES visibility, never on first paint. Going hidden, each
|
|
17
|
+
# clear: field (owned, scope-aware) is emptied with js.set_value's write
|
|
18
|
+
# (a checkbox or radio is unchecked; a file input is rejected at render) and a bubbling
|
|
19
|
+
# `input` is dispatched per field that changed. Going visible, the first
|
|
20
|
+
# element inside the root matching focus: is focused, and scroll: true
|
|
21
|
+
# scrolls the section into view. clear: names are checked at render: an
|
|
22
|
+
# unknown one, or a file input, raises.
|
|
23
|
+
module ShowOptions
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# The wire data for a reactive_show's options, merged into its data:.
|
|
27
|
+
def show_option_data(groups, after:, clear:, focus:, scroll:)
|
|
28
|
+
data = {}
|
|
29
|
+
data[:reactive_show_after] = show_after_field!(groups, after) unless after.nil?
|
|
30
|
+
data[:reactive_show_clear] = show_clear_fields!(clear).to_json unless clear.nil?
|
|
31
|
+
data[:reactive_show_focus] = show_focus!(focus) unless focus.nil?
|
|
32
|
+
data[:reactive_show_scroll] = "true" if show_scroll!(scroll)
|
|
33
|
+
data
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_clear_fields!(clear)
|
|
37
|
+
names = Array(clear).map do
|
|
38
|
+
name = it.to_s if it.is_a?(Symbol) || it.is_a?(String)
|
|
39
|
+
next name unless name.nil? || name.strip.empty?
|
|
40
|
+
|
|
41
|
+
raise ArgumentError,
|
|
42
|
+
"reactive_show clear: takes a field name or an Array of them, got #{clear.inspect}"
|
|
43
|
+
end
|
|
44
|
+
raise ArgumentError, "reactive_show clear: needs at least one field name, got #{clear.inspect}" if names.empty?
|
|
45
|
+
|
|
46
|
+
reference_fields!(:reactive_show, :clear, names.uniq, no_file: true)
|
|
47
|
+
names.uniq
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def show_focus!(focus)
|
|
51
|
+
return focus if focus.is_a?(String) && !focus.strip.empty?
|
|
52
|
+
|
|
53
|
+
raise ArgumentError,
|
|
54
|
+
"reactive_show focus: takes a non-empty CSS selector inside the root (e.g. \"#notes\"), got #{focus.inspect}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def show_scroll!(scroll)
|
|
58
|
+
return scroll if [nil, true, false].include?(scroll)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "reactive_show scroll: takes true or false, got #{scroll.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def show_after_field!(groups, after)
|
|
64
|
+
fields = Phlex::Reactive::ShowConditions.fields(groups)
|
|
65
|
+
unless after.is_a?(Symbol) || after.is_a?(String)
|
|
66
|
+
raise ArgumentError,
|
|
67
|
+
"reactive_show after: takes :blur or the name of the condition's field to wait for, got #{after.inspect}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
name = after == :blur ? show_after_single!(fields) : after.to_s
|
|
71
|
+
unless fields.include?(name)
|
|
72
|
+
raise ArgumentError,
|
|
73
|
+
"reactive_show after: #{after.inspect} is not a field of this condition " \
|
|
74
|
+
"(it names #{fields.map(&:to_sym).inspect})"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
reference_fields!(:reactive_show, :after, [name])
|
|
78
|
+
name
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def show_after_single!(fields)
|
|
82
|
+
return fields.first if fields.size == 1
|
|
83
|
+
|
|
84
|
+
raise ArgumentError,
|
|
85
|
+
"reactive_show after: :blur needs a condition over one field — this one names " \
|
|
86
|
+
"#{fields.map(&:to_sym).inspect}; pass the field to wait for (after: #{fields.first.to_sym.inspect})"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -20,10 +20,10 @@ module Phlex
|
|
|
20
20
|
# build script (scripts/build_client.js), the core's own table and the
|
|
21
21
|
# default entry name the same features —
|
|
22
22
|
# spec/phlex/engine_client_pin_spec.rb fails when they disagree.
|
|
23
|
-
CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence effects hints devtools].freeze
|
|
23
|
+
CLIENT_FEATURES = %w[persist defer form bindings compute sortable presence countdown effects hints devtools].freeze
|
|
24
24
|
# (Gzipped, bun zlib level 9, KB = 1,024 B: the default file 30.9 KB; the core 14.3 KB;
|
|
25
25
|
# persist 3.1, defer 2.9, form 1.1, bindings 6.5, compute 2.0, sortable
|
|
26
|
-
# 3.1, presence 1.3, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
|
|
26
|
+
# 3.1, presence 1.3, countdown 0.9, effects 1.7, hints 0.9, devtools 1.5 KB. spec/javascript/bundle_budget.test.js
|
|
27
27
|
# is the source of truth and holds a ratchet on each.)
|
|
28
28
|
|
|
29
29
|
# Mount POST /reactive/actions -> Phlex::Reactive::ActionsController#create
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -193,6 +193,9 @@ module Phlex
|
|
|
193
193
|
# focus(to) — focus the FIRST match of the selector.
|
|
194
194
|
# focus_first(to) — focus the first FOCUSABLE DESCENDANT of the match
|
|
195
195
|
# (e.g. focus the first menuitem inside an opened menu).
|
|
196
|
+
# blur(to) — blur the focused element when it IS the match or sits
|
|
197
|
+
# inside it (issue #399): Escape ends a menu's
|
|
198
|
+
# :focus-within. Focus elsewhere is left alone.
|
|
196
199
|
|
|
197
200
|
def focus(to, global: false)
|
|
198
201
|
append("focus", target_args(to, global:))
|
|
@@ -202,6 +205,10 @@ module Phlex
|
|
|
202
205
|
append("focus_first", target_args(to, global:))
|
|
203
206
|
end
|
|
204
207
|
|
|
208
|
+
def blur(to, global: false)
|
|
209
|
+
append("blur", target_args(to, global:))
|
|
210
|
+
end
|
|
211
|
+
|
|
205
212
|
# --- Modal dialogs (issue #365) ---
|
|
206
213
|
#
|
|
207
214
|
# show_modal(to) — HTMLDialogElement#showModal(): the top layer, a
|
|
@@ -306,8 +313,24 @@ module Phlex
|
|
|
306
313
|
# op = js.preview_file("#photo-file", into: "#photo", valid: true)
|
|
307
314
|
# input(type: "file", id: "photo-file",
|
|
308
315
|
# **mix(on_client(:change, op), on_client("reactive:file-changed", op)))
|
|
309
|
-
|
|
310
|
-
|
|
316
|
+
#
|
|
317
|
+
# The box and the refusals (issue #391): `wrap:` is a CSS selector the
|
|
318
|
+
# client shows when it paints an image and hides when the preview clears
|
|
319
|
+
# (it shows again only a wrapper it hid itself, never one the author
|
|
320
|
+
# hid, and never writes its text). `max_bytes:` (a positive Integer)
|
|
321
|
+
# refuses a larger file before any object URL exists. `accept: true`
|
|
322
|
+
# reads the input's own accept attribute (extensions, MIME types,
|
|
323
|
+
# type/*); a String is that list for this op; omitted keeps the image/*
|
|
324
|
+
# rule. Only an image is painted: a listed non-image (a PDF) clears the
|
|
325
|
+
# preview and hides `wrap:`, unrefused. A refused file clears the img,
|
|
326
|
+
# hides `wrap:` and dispatches
|
|
327
|
+
# `reactive:file-rejected` on the input (detail.reason "type" | "size").
|
|
328
|
+
# The op never clears input.files nor sets a custom validity message.
|
|
329
|
+
# input(type: "file", id: "photo-file", accept: "image/png,image/jpeg",
|
|
330
|
+
# **on_client(:change, js.preview_file("#photo-file", into: "#photo",
|
|
331
|
+
# wrap: "#photo-frame", max_bytes: 2_000_000, accept: true)))
|
|
332
|
+
|
|
333
|
+
def preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil)
|
|
311
334
|
if [:root, ROOT_SENTINEL].include?(to)
|
|
312
335
|
raise ArgumentError,
|
|
313
336
|
"#{self.class}: preview_file reads a file input, not the component root — " \
|
|
@@ -318,6 +341,9 @@ module Phlex
|
|
|
318
341
|
args["name"] = preview_selector(:name, name) unless name.nil?
|
|
319
342
|
args["global"] = true if global
|
|
320
343
|
args["valid"] = true if valid
|
|
344
|
+
args["wrap"] = preview_selector(:wrap, wrap) unless wrap.nil?
|
|
345
|
+
args["max_bytes"] = preview_max_bytes(max_bytes) unless max_bytes.nil?
|
|
346
|
+
args["accept"] = preview_accept(accept) unless accept.nil? || accept == false
|
|
321
347
|
append("preview_file", args.freeze)
|
|
322
348
|
end
|
|
323
349
|
|
|
@@ -527,6 +553,22 @@ module Phlex
|
|
|
527
553
|
"#{self.class}: preview_file #{key}: must be a CSS selector string, got #{selector.inspect}"
|
|
528
554
|
end
|
|
529
555
|
|
|
556
|
+
def preview_max_bytes(bytes)
|
|
557
|
+
return bytes if bytes.is_a?(Integer) && bytes.positive?
|
|
558
|
+
|
|
559
|
+
raise ArgumentError,
|
|
560
|
+
"#{self.class}: preview_file max_bytes: must be a positive Integer, got #{bytes.inspect}"
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# true reads the input's accept attribute; a String is the list itself.
|
|
564
|
+
def preview_accept(accept)
|
|
565
|
+
return accept if accept == true || (accept.is_a?(String) && !accept.strip.empty?)
|
|
566
|
+
|
|
567
|
+
raise ArgumentError,
|
|
568
|
+
"#{self.class}: preview_file accept: must be true or an accept list " \
|
|
569
|
+
"(e.g. \"image/png,.jpg\"), got #{accept.inspect}"
|
|
570
|
+
end
|
|
571
|
+
|
|
530
572
|
# The [on, off] pair of a two-value toggle_attr, stringified. Loud on a
|
|
531
573
|
# half-specified pair or one that could never change anything.
|
|
532
574
|
def toggle_values(name, values)
|