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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +168 -0
  3. data/README.md +81 -12
  4. data/app/javascript/phlex/reactive/core.js +1 -0
  5. data/app/javascript/phlex/reactive/core.min.js +6 -6
  6. data/app/javascript/phlex/reactive/core.min.js.map +4 -4
  7. data/app/javascript/phlex/reactive/features/bindings.js +129 -16
  8. data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
  9. data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
  10. data/app/javascript/phlex/reactive/features/countdown.js +140 -0
  11. data/app/javascript/phlex/reactive/features/countdown.min.js +4 -0
  12. data/app/javascript/phlex/reactive/features/countdown.min.js.map +10 -0
  13. data/app/javascript/phlex/reactive/features/persist.js +10 -6
  14. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  17. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  18. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +8 -7
  19. data/app/javascript/phlex/reactive/runtime.js +172 -30
  20. data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
  21. data/lib/phlex/reactive/component/field_references.rb +130 -0
  22. data/lib/phlex/reactive/component/helpers.rb +72 -14
  23. data/lib/phlex/reactive/component/image_fallback_binding.rb +18 -5
  24. data/lib/phlex/reactive/component/media_binding.rb +9 -1
  25. data/lib/phlex/reactive/component/media_pattern.rb +17 -17
  26. data/lib/phlex/reactive/component/selection.rb +24 -2
  27. data/lib/phlex/reactive/component/show_options.rb +91 -0
  28. data/lib/phlex/reactive/engine.rb +2 -2
  29. data/lib/phlex/reactive/js.rb +44 -2
  30. data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
  31. data/lib/phlex/reactive/show_conditions.rb +39 -6
  32. data/lib/phlex/reactive/streamable.rb +6 -3
  33. data/lib/phlex/reactive/version.rb +1 -1
  34. 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
- attrs = options.except(*SHOW_CONDITION_KEYS)
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 localStorage draft over the fields this root OWNS (issue
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 localStorage as the user
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 localStorage for `ttl`; the
1222
- # successful-submit clear and `ttl` are the shared-computer mitigation
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"] = image_fallback_alt!(show_alt) unless show_alt.nil?
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 image_fallback_alt!(selector)
54
+ def image_fallback_selector!(key, selector, example)
42
55
  if selector == JS::ROOT_SENTINEL
43
56
  raise ArgumentError,
44
- "reactive_image_fallback show_alt: #{selector} names the whole root — pass the selector of a node inside it"
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 show_alt: must be a CSS selector (e.g. \"#bank-name\"), got #{selector.inspect}"
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, blank_label: nil, **attrs)
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) — a Ruby Regexp as the JavaScript
7
- # source the client anchors as ^(?:…)$ and compiles without flags. The
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 match: must be a Regexp (e.g. /\\A\\d{3}\\z/), got #{match.inspect}"
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 match: #{match.inspect} #{why}"
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
- def reactive_select_all(group, **attrs)
50
- mix({ data: { reactive_select_all: selection_group!(:reactive_select_all, group) } }, attrs)
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
@@ -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
- def preview_file(to, into:, name: nil, global: false, valid: false)
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)