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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +269 -2
  3. data/README.md +92 -13
  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 +153 -21
  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/form.js +54 -22
  14. data/app/javascript/phlex/reactive/features/form.min.js +2 -2
  15. data/app/javascript/phlex/reactive/features/form.min.js.map +3 -3
  16. data/app/javascript/phlex/reactive/features/persist.js +41 -8
  17. data/app/javascript/phlex/reactive/features/persist.min.js +2 -2
  18. data/app/javascript/phlex/reactive/features/persist.min.js.map +3 -3
  19. data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
  20. data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
  21. data/app/javascript/phlex/reactive/reactive_controller.min.js.map +9 -8
  22. data/app/javascript/phlex/reactive/runtime.js +313 -32
  23. data/lib/phlex/reactive/component/countdown_binding.rb +80 -0
  24. data/lib/phlex/reactive/component/field_references.rb +130 -0
  25. data/lib/phlex/reactive/component/helpers.rb +81 -19
  26. data/lib/phlex/reactive/component/identity.rb +34 -1
  27. data/lib/phlex/reactive/component/image_fallback_binding.rb +67 -0
  28. data/lib/phlex/reactive/component/media_binding.rb +30 -4
  29. data/lib/phlex/reactive/component/media_pattern.rb +79 -0
  30. data/lib/phlex/reactive/component/selection.rb +24 -2
  31. data/lib/phlex/reactive/component/show_options.rb +91 -0
  32. data/lib/phlex/reactive/doctor.rb +20 -8
  33. data/lib/phlex/reactive/engine.rb +2 -2
  34. data/lib/phlex/reactive/js.rb +58 -5
  35. data/lib/phlex/reactive/show_conditions/compare_terms.rb +142 -0
  36. data/lib/phlex/reactive/show_conditions.rb +39 -6
  37. data/lib/phlex/reactive/streamable.rb +6 -3
  38. data/lib/phlex/reactive/version.rb +1 -1
  39. 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
- 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)
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 localStorage draft over the fields this root OWNS (issue
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 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
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 localStorage for `ttl`; the
1221
- # successful-submit clear and `ttl` are the shared-computer mitigation
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
- removed = params.keys & REMOVED_ON_KWARGS.keys
1771
- return if removed.empty?
1830
+ return if params.empty?
1772
1831
 
1773
- key = removed.first
1774
- raise ArgumentError,
1775
- "on(#{action_name.inspect}) #{key}: was removed in issue #181 — use #{REMOVED_ON_KWARGS[key]}"
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.to_gid.to_s
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 clears the
24
- # preview. `label:` names a node whose textContent becomes the chosen
25
- # image's alt (`blank_label`, or "", on a miss). Client-only: no token,
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, 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)
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
- 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?