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,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
@@ -248,7 +248,15 @@ module Phlex
248
248
  # authorization / mark_authorized! call. It is :unknown, NEVER a hard fail:
249
249
  # a helper may authorize indirectly, so this is a hint, not a verdict.
250
250
  def authorization_check(components)
251
- suspects = components.flat_map { unauthorized_action_labels(it) }
251
+ infos = nil
252
+ suspects = components.flat_map do
253
+ next [] if component_defines_authorization_method?(it)
254
+
255
+ # Read once, and only when needed: Inspector.components Prism-scans
256
+ # every component's actions.
257
+ infos ||= inspector_infos
258
+ unauthorized_action_labels(it, infos)
259
+ end
252
260
  return Check.new(:ok, "every mutating action appears to authorize", name: :authorization) if suspects.empty?
253
261
 
254
262
  Check.new(:unknown, "actions with no detected authorization call: #{suspects.join(", ")}",
@@ -394,13 +402,11 @@ module Phlex
394
402
  end
395
403
 
396
404
  # "Klass#action" for every declared action on `klass` that (advisory)
397
- # appears unauthorized: the component defines no authorization method in its
398
- # ancestry, the action isn't skipped, and the Prism scan (via the shared
399
- # Inspector) finds no authorization / mark_authorized! call in its body.
400
- def unauthorized_action_labels(klass)
401
- return [] if component_defines_authorization_method?(klass)
402
-
403
- info = Phlex::Reactive::Inspector.components.find { it.klass == klass }
405
+ # appears unauthorized (the caller has already ruled out an authorization
406
+ # method in its ancestry): the action isn't skipped, and the Prism scan (via
407
+ # the shared Inspector) finds no authorization / mark_authorized! call in its body.
408
+ def unauthorized_action_labels(klass, infos)
409
+ info = infos[klass]
404
410
  return [] unless info
405
411
 
406
412
  info.actions
@@ -417,6 +423,12 @@ module Phlex
417
423
  # Phlex::Reactive.authorization_methods and NOT the Inspector's set, which
418
424
  # folds in mark_authorized! — every component inherits that instance helper,
419
425
  # so including it would silence the check for everything.
426
+ def inspector_infos
427
+ Phlex::Reactive::Inspector.components.to_h { [it.klass, it] }
428
+ rescue StandardError
429
+ {}
430
+ end
431
+
420
432
  def component_defines_authorization_method?(klass)
421
433
  Phlex::Reactive.authorization_methods.any? do
422
434
  klass.method_defined?(it) || klass.private_method_defined?(it)
@@ -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
@@ -297,8 +304,33 @@ module Phlex
297
304
  # that read every subscriber's file input would be hostile.
298
305
  # input(type: "file", id: "photo-file", accept: "image/*",
299
306
  # **on_client(:change, js.preview_file("#photo-file", into: "#photo", name: "#photo-name")))
300
-
301
- def preview_file(to, into:, name: nil, global: false)
307
+ #
308
+ # Upload validation (issue #382): `valid: true` treats an input whose
309
+ # validity is false (setCustomValidity, required) as empty, so a rejected
310
+ # file never gets a preview. An app that clears the input without a
311
+ # change event dispatches `reactive:file-changed` on it; bind the same op
312
+ # to that event too and the preview clears:
313
+ # op = js.preview_file("#photo-file", into: "#photo", valid: true)
314
+ # input(type: "file", id: "photo-file",
315
+ # **mix(on_client(:change, op), on_client("reactive:file-changed", op)))
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)
302
334
  if [:root, ROOT_SENTINEL].include?(to)
303
335
  raise ArgumentError,
304
336
  "#{self.class}: preview_file reads a file input, not the component root — " \
@@ -308,6 +340,10 @@ module Phlex
308
340
  args = { "to" => normalize_target(to), "into" => preview_selector(:into, into) }
309
341
  args["name"] = preview_selector(:name, name) unless name.nil?
310
342
  args["global"] = true if global
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
311
347
  append("preview_file", args.freeze)
312
348
  end
313
349
 
@@ -331,9 +367,10 @@ module Phlex
331
367
  # onto the `to` <img>. Both are ELEMENTS (:root or a CSS selector,
332
368
  # resolved with the op's own scoping, global: included): the URL is read
333
369
  # off the live source element in the browser and is never an argument,
334
- # so the set_attr refusal of src (issue #96) stays. A missing source, a
335
- # non-<img> source or one without src clears the target's src, srcset
336
- # and alt; a target that is not an <img> is skipped. The same URL twice
370
+ # so the set_attr refusal of src (issue #96) stays. A missing or
371
+ # non-<img> source clears the target's src, srcset and alt; an <img>
372
+ # source without src removes the target's src and srcset and copies its
373
+ # alt (issue #381); a target that is not an <img> is skipped. The same URL twice
337
374
  # is not written again (no reload). Broadcast-safe: it only reads
338
375
  # elements already in the subscriber's document.
339
376
  # button(**on_client(:click, js.copy_media("#logo", from: "#logo-007"))) { "North" }
@@ -516,6 +553,22 @@ module Phlex
516
553
  "#{self.class}: preview_file #{key}: must be a CSS selector string, got #{selector.inspect}"
517
554
  end
518
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
+
519
572
  # The [on, off] pair of a two-value toggle_attr, stringified. Loud on a
520
573
  # half-specified pair or one that could never change anything.
521
574
  def toggle_values(name, values)
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phlex
4
+ module Reactive
5
+ module ShowConditions
6
+ # The Hash-valued terms that are not a count: `same:` (issue #395) — a
7
+ # field equal to ANOTHER owned field — and `digits:` (issue #396) — a
8
+ # closed format: ASCII digits only, an exact count or an Integer Range,
9
+ # and an optional digit prefix (`starts_with:`). Compiled and evaluated
10
+ # here; the client mirrors both in features/bindings.js, and the shared
11
+ # fixture (spec/fixtures/show_predicate_vectors.json) holds them to
12
+ # agreement.
13
+ #
14
+ # A negated term (under unless:) keeps its shape and carries
15
+ # "negate" => true, the way unless: turns equals into not.
16
+ module CompareTerms
17
+ # The digit-count wire keys: the count suffixes length: uses.
18
+ DIGITS_KEYS = %w[digits_eq digits_gte digits_lt digits_lte].freeze
19
+ DIGIT_STRING = /\A[0-9]*\z/
20
+ PREFIX = /\A[0-9]+\z/
21
+
22
+ # { digits: 10, starts_with: "9" } -> ONE term (a Range's bounds sit in
23
+ # the same term, so unless: can negate the whole format at once).
24
+ def digits_term(name, hash, negate:)
25
+ options = option_keys(hash)
26
+ if (extra = options.keys - %i[digits starts_with]).any?
27
+ raise ArgumentError, "reactive_show: #{name.inspect} digits: takes only starts_with: (got #{extra.inspect})"
28
+ end
29
+ unless options.key?(:digits)
30
+ raise ArgumentError, "reactive_show: #{name.inspect} starts_with: needs digits: (e.g. digits: 10)"
31
+ end
32
+
33
+ term = { "field" => name }.merge(digits_bounds(name, options[:digits]))
34
+ term["prefix"] = digits_prefix!(name, options[:starts_with]) if options.key?(:starts_with)
35
+ term["negate"] = true if negate
36
+ term
37
+ end
38
+
39
+ # digits: — every character is 0-9, the count fits every bound, and the
40
+ # value starts with the prefix; negate flips it. A malformed literal
41
+ # (a hand-built term) is false either way, like the client's skip.
42
+ def digits_matches?(term, value)
43
+ keys = DIGITS_KEYS.select { term.key?(it) }
44
+ return false unless keys.all? { term[it].is_a?(Integer) } && (term["prefix"].nil? || term["prefix"].is_a?(String))
45
+
46
+ text = value.to_s
47
+ ok = text.match?(DIGIT_STRING) &&
48
+ keys.all? { count_matches?(it, term[it], text.length) } &&
49
+ (term["prefix"].nil? || text.start_with?(term["prefix"]))
50
+ ok != (term["negate"] == true)
51
+ end
52
+
53
+ # Symbol/String keys as Symbols; any other key stays as it is, so the
54
+ # unknown-key check names it in an ArgumentError.
55
+ def option_keys(hash)
56
+ hash.transform_keys { it.is_a?(String) || it.is_a?(Symbol) ? it.to_sym : it }
57
+ end
58
+
59
+ # { same: :other, case: :fold } -> one term comparing this field's
60
+ # value with another owned field's, both read as strings. The other
61
+ # name is a bare field like any show field (the client applies
62
+ # reactive_scope).
63
+ def same_term(name, hash, negate:)
64
+ options = option_keys(hash)
65
+ if (extra = options.keys - %i[same case]).any?
66
+ raise ArgumentError, "reactive_show: #{name.inspect} same: takes only case: (got #{extra.inspect})"
67
+ end
68
+
69
+ term = { "field" => name, "same" => same_field!(name, options[:same]) }
70
+ term["fold"] = true if same_case!(name, options[:case])
71
+ term["negate"] = true if negate
72
+ term
73
+ end
74
+
75
+ # same: — this field's value against the other field's (an absent
76
+ # field is ""), folded when the term says so; negate flips it.
77
+ def same_matches?(term, value, values)
78
+ left = value.to_s
79
+ right = values.fetch(term["same"], "").to_s
80
+ if term["fold"] == true
81
+ left = ascii_fold(left)
82
+ right = ascii_fold(right)
83
+ end
84
+ (left == right) != (term["negate"] == true)
85
+ end
86
+
87
+ # The ASCII-only fold both evaluators apply: A-Z to a-z and nothing
88
+ # else (never downcase, which folds letters the client would not).
89
+ def ascii_fold(value)
90
+ value.tr("A-Z", "a-z")
91
+ end
92
+
93
+ private
94
+
95
+ def digits_bounds(name, digits)
96
+ case digits
97
+ when Integer
98
+ return { "digits_eq" => digits } if digits >= 0
99
+ when Range
100
+ first = digits.begin
101
+ last = digits.end
102
+ if [first, last].compact.any? && [first, last].compact.all? { it.is_a?(Integer) && it >= 0 }
103
+ bounds = {}
104
+ bounds["digits_gte"] = first unless first.nil?
105
+ bounds[digits.exclude_end? ? "digits_lt" : "digits_lte"] = last unless last.nil?
106
+ return bounds
107
+ end
108
+ end
109
+
110
+ raise ArgumentError,
111
+ "reactive_show: #{name.inspect} digits: takes a non-negative Integer or an Integer Range " \
112
+ "(a digit count), got #{digits.inspect}"
113
+ end
114
+
115
+ def digits_prefix!(name, prefix)
116
+ return prefix if prefix.is_a?(String) && prefix.match?(PREFIX)
117
+
118
+ raise ArgumentError,
119
+ "reactive_show: #{name.inspect} starts_with: takes a String of digits (0-9), got #{prefix.inspect}"
120
+ end
121
+
122
+ def same_field!(name, other)
123
+ field = other.to_s if other.is_a?(Symbol) || other.is_a?(String)
124
+ return field unless field.nil? || field.strip.empty?
125
+
126
+ raise ArgumentError,
127
+ "reactive_show: #{name.inspect} same: needs the other field's name (e.g. same: :email), " \
128
+ "got #{other.inspect}"
129
+ end
130
+
131
+ # case: is absent (an exact compare) or :fold. Anything else raises:
132
+ # there is no locale-aware fold to ask for.
133
+ def same_case!(name, value)
134
+ return false if value.nil?
135
+ return true if (value.is_a?(Symbol) || value.is_a?(String)) && value.to_s == "fold"
136
+
137
+ raise ArgumentError, "reactive_show: #{name.inspect} case: takes only :fold, got #{value.inspect}"
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -23,6 +23,9 @@ module Phlex
23
23
  # { checked: 1.. } / { checked: 0 } -> checked_* (issue #319 — how many
24
24
  # owned boxes of a checkbox GROUP are
25
25
  # ticked; same Integer/Range shapes)
26
+ # { same: :email } -> same "email" (issue #395 — equal to
27
+ # { same: :email, case: :fold } ANOTHER owned field; fold lowercases
28
+ # ASCII A-Z only, on both sides)
26
29
  # Under unless: each term is NEGATED (De Morgan):
27
30
  # scalar -> not; Array -> ∉ (AND of nots); Range -> the complement
28
31
  # predicate (¬gte→lt, ¬lte→gt, ¬lt→gte), and a BOUNDED range complements
@@ -49,6 +52,8 @@ module Phlex
49
52
  # A Hash value's one key -> its wire-key prefix.
50
53
  COUNT_PREFIXES = { "length" => "len", "checked" => "checked" }.freeze
51
54
 
55
+ extend CompareTerms
56
+
52
57
  module_function
53
58
 
54
59
  # Compile if:/if_any:/unless: into DNF groups. Loud validation at render
@@ -83,7 +88,7 @@ module Phlex
83
88
  # Every field named across all groups/terms — drives reactive_values
84
89
  # coverage (server first paint only fires when every field is provided).
85
90
  def fields(groups)
86
- groups.flat_map { |group| group.map { |term| term.fetch("field") } }.uniq
91
+ groups.flat_map { |group| group.flat_map { |term| [term.fetch("field"), term["same"]].compact } }.uniq
87
92
  end
88
93
  # rubocop:enable Style/ItBlockParameter
89
94
 
@@ -147,7 +152,7 @@ module Phlex
147
152
  case value
148
153
  when Range then range_terms(name, value)
149
154
  when Array then [membership_term(name, value)]
150
- when Hash then count_terms(name, value)
155
+ when Hash then hash_terms(name, value)
151
156
  else [{ "field" => name, "equals" => equals_literal(value) }]
152
157
  end
153
158
  end
@@ -191,6 +196,32 @@ module Phlex
191
196
  end
192
197
  end
193
198
 
199
+ # --- Hash values: same: (#395, in CompareTerms) or a count -------------
200
+
201
+ def hash_terms(name, hash)
202
+ case hash_kind(hash)
203
+ when :same then [same_term(name, hash, negate: false)]
204
+ when :digits then [digits_term(name, hash, negate: false)]
205
+ else count_terms(name, hash)
206
+ end
207
+ end
208
+
209
+ # The complement under unless: — a list of alternative term-lists.
210
+ def hash_complement(name, hash)
211
+ case hash_kind(hash)
212
+ when :same then [[same_term(name, hash, negate: true)]]
213
+ when :digits then [[digits_term(name, hash, negate: true)]]
214
+ else count_complement(name, hash)
215
+ end
216
+ end
217
+
218
+ def hash_kind(hash)
219
+ keys = hash.keys.map(&:to_s)
220
+ if keys.include?("same") then :same
221
+ elsif keys.include?("digits") || keys.include?("starts_with") then :digits
222
+ end
223
+ end
224
+
194
225
  # --- the count predicates: length: (#226) and checked: (#319) ---------
195
226
 
196
227
  # A Hash value names a STRUCTURAL predicate over a COUNT — length: (the
@@ -262,7 +293,7 @@ module Phlex
262
293
  kind = hash.keys.first.to_s if hash.size == 1
263
294
  unless COUNT_PREFIXES.key?(kind)
264
295
  raise ArgumentError,
265
- "reactive_show: #{name.inspect} Hash value supports only length: or checked: " \
296
+ "reactive_show: #{name.inspect} Hash value supports only same:, digits:, or one of length: or checked: " \
266
297
  "(got #{hash.keys.inspect})"
267
298
  end
268
299
 
@@ -294,7 +325,7 @@ module Phlex
294
325
  name = field.to_s
295
326
  case value
296
327
  when Range then range_complement(name, value)
297
- when Hash then count_complement(name, value)
328
+ when Hash then hash_complement(name, value)
298
329
  when Array
299
330
  list = value.map(&:to_s)
300
331
  raise ArgumentError, "reactive_show: #{name.inspect} Array needs at least one value" if list.empty?
@@ -333,7 +364,9 @@ module Phlex
333
364
  field = term.fetch("field")
334
365
  value = values.fetch(field, "")
335
366
 
336
- if term.key?("equals") then value == term["equals"]
367
+ if term.key?("same") then same_matches?(term, value, values)
368
+ elsif CompareTerms::DIGITS_KEYS.any? { term.key?(it) } then digits_matches?(term, value)
369
+ elsif term.key?("equals") then value == term["equals"]
337
370
  elsif term.key?("not") then value != term["not"]
338
371
  elsif term.key?("in") then term["in"].include?(value)
339
372
  elsif (key = LENGTH_KEYS.find { term.key?(it) }) then count_matches?(key, term[key], value.to_s.length)
@@ -350,7 +383,7 @@ module Phlex
350
383
  def count_matches?(key, literal, count)
351
384
  return false unless literal.is_a?(Integer)
352
385
 
353
- case key.delete_prefix("len_").delete_prefix("checked_")
386
+ case key.split("_", 2).last
354
387
  when "eq" then count == literal
355
388
  when "gte" then count >= literal
356
389
  when "gt" then count > literal
@@ -34,7 +34,9 @@ module Phlex
34
34
  ThreadViewContext = Struct.new(:view_context, :builder, :renderer, :generation)
35
35
 
36
36
  # Actor-only ops: a js broadcast rejects a broadcast that carries one.
37
- # Focus ops steal focus in every subscriber's tab (issue #96); submit
37
+ # Focus ops steal focus in every subscriber's tab (issue #96); blur (issue
38
+ # #399) would drop focus in every subscriber whose focused element sits
39
+ # inside the target; submit
38
40
  # (issue #226) would force-submit every subscriber's form; paste_into
39
41
  # (issue #228) would read every subscriber's clipboard; check_group
40
42
  # (issue #342) would tick or clear every subscriber's selection; presence
@@ -46,7 +48,7 @@ module Phlex
46
48
  # belong to the actor's own reply (reply.js) or gesture (on_client / a
47
49
  # reducer's $ops), never a broadcast. Names mirror Phlex::Reactive::JS's verbs.
48
50
  BROADCAST_REFUSED_OPS = %w[
49
- focus focus_first submit paste_into persist_state persist_clear check_group presence
51
+ focus focus_first blur submit paste_into persist_state persist_clear check_group presence
50
52
  show_modal close_dialog
51
53
  preview_file
52
54
  set_value
@@ -205,7 +207,8 @@ module Phlex
205
207
  unless refused.empty?
206
208
  raise ArgumentError,
207
209
  "broadcast_to(js:) refuses actor-only op(s) #{refused.join(", ")} — broadcasting focus " \
208
- "steals it in every subscriber's tab; broadcasting submit force-submits every " \
210
+ "steals it in every subscriber's tab; broadcasting blur drops focus in every subscriber " \
211
+ "whose focused element is inside the target; broadcasting submit force-submits every " \
209
212
  "subscriber's form; broadcasting paste_into reads every subscriber's clipboard; " \
210
213
  "broadcasting check_group ticks or clears every subscriber's selection; " \
211
214
  "broadcasting presence writes every subscriber's presence state; " \
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.15.2"
5
+ VERSION = "0.15.4"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phlex-reactive
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.2
4
+ version: 0.15.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -140,6 +140,9 @@ files:
140
140
  - app/javascript/phlex/reactive/features/compute.js
141
141
  - app/javascript/phlex/reactive/features/compute.min.js
142
142
  - app/javascript/phlex/reactive/features/compute.min.js.map
143
+ - app/javascript/phlex/reactive/features/countdown.js
144
+ - app/javascript/phlex/reactive/features/countdown.min.js
145
+ - app/javascript/phlex/reactive/features/countdown.min.js.map
143
146
  - app/javascript/phlex/reactive/features/defer.js
144
147
  - app/javascript/phlex/reactive/features/defer.min.js
145
148
  - app/javascript/phlex/reactive/features/defer.min.js.map
@@ -193,15 +196,20 @@ files:
193
196
  - lib/phlex/reactive/client_bindings.rb
194
197
  - lib/phlex/reactive/collections.rb
195
198
  - lib/phlex/reactive/component.rb
199
+ - lib/phlex/reactive/component/countdown_binding.rb
196
200
  - lib/phlex/reactive/component/dsl.rb
197
201
  - lib/phlex/reactive/component/editable.rb
202
+ - lib/phlex/reactive/component/field_references.rb
198
203
  - lib/phlex/reactive/component/helpers.rb
199
204
  - lib/phlex/reactive/component/identity.rb
205
+ - lib/phlex/reactive/component/image_fallback_binding.rb
200
206
  - lib/phlex/reactive/component/lazy.rb
201
207
  - lib/phlex/reactive/component/media_binding.rb
208
+ - lib/phlex/reactive/component/media_pattern.rb
202
209
  - lib/phlex/reactive/component/presence_declaration.rb
203
210
  - lib/phlex/reactive/component/registry.rb
204
211
  - lib/phlex/reactive/component/selection.rb
212
+ - lib/phlex/reactive/component/show_options.rb
205
213
  - lib/phlex/reactive/component/sortable.rb
206
214
  - lib/phlex/reactive/defer.rb
207
215
  - lib/phlex/reactive/deferred_render_job.rb
@@ -239,6 +247,7 @@ files:
239
247
  - lib/phlex/reactive/settles.rb
240
248
  - lib/phlex/reactive/shortcut.rb
241
249
  - lib/phlex/reactive/show_conditions.rb
250
+ - lib/phlex/reactive/show_conditions/compare_terms.rb
242
251
  - lib/phlex/reactive/stream.rb
243
252
  - lib/phlex/reactive/streamable.rb
244
253
  - lib/phlex/reactive/test_helpers.rb