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
@@ -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.3"
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.3
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,8 +196,10 @@ 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
200
205
  - lib/phlex/reactive/component/image_fallback_binding.rb
@@ -204,6 +209,7 @@ files:
204
209
  - lib/phlex/reactive/component/presence_declaration.rb
205
210
  - lib/phlex/reactive/component/registry.rb
206
211
  - lib/phlex/reactive/component/selection.rb
212
+ - lib/phlex/reactive/component/show_options.rb
207
213
  - lib/phlex/reactive/component/sortable.rb
208
214
  - lib/phlex/reactive/defer.rb
209
215
  - lib/phlex/reactive/deferred_render_job.rb
@@ -241,6 +247,7 @@ files:
241
247
  - lib/phlex/reactive/settles.rb
242
248
  - lib/phlex/reactive/shortcut.rb
243
249
  - lib/phlex/reactive/show_conditions.rb
250
+ - lib/phlex/reactive/show_conditions/compare_terms.rb
244
251
  - lib/phlex/reactive/stream.rb
245
252
  - lib/phlex/reactive/streamable.rb
246
253
  - lib/phlex/reactive/test_helpers.rb