phlex-reactive 0.16.3 → 0.16.5

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.
@@ -1751,9 +1751,28 @@ function keyFilterMatches(filter, event, keyMappings) {
1751
1751
  // its bare keydown hears every key, and this check alone picks the chord.
1752
1752
  // (Stimulus typecasts a digit shortcut, "1", to a number: hence String().)
1753
1753
  const shortcutKeys = (spec) => (spec && typeof spec === "object" ? spec.keys : String(spec))
1754
+ // Issue #429: the keydowns a shortcut prevented itself. A key another handler
1755
+ // already claimed (the drag feature's Escape cancelling a drag, an
1756
+ // element-bound keydown binding) is not the shortcut's — unless it says so,
1757
+ // `handled: true` — but a shortcut's OWN preventDefault does not count: two
1758
+ // shortcuts on one chord both run. Nor does early.js's, made for a shortcut
1759
+ // whose root had not connected: it adds the event to this same set (shared
1760
+ // through the early state), and its replay reads as not prevented. (So while
1761
+ // such a root is on the page, early.js's capture-phase preventDefault lands
1762
+ // before a drag's, and that Escape still runs the shortcuts on its chord — as
1763
+ // before #429: a preventDefault cannot tell who came second.)
1764
+ const shortcutPrevented = () => (earlyState().prevented ??= new WeakSet())
1765
+ // A trigger's preventDefault, a shortcut's remembered as its own (only when
1766
+ // nothing prevented the key first: handled: true on a handled key does not
1767
+ // make it the next shortcut's).
1768
+ function preventTrigger(event, shortcut) {
1769
+ if (shortcut != null && !event.defaultPrevented) shortcutPrevented().add(event)
1770
+ event.preventDefault()
1771
+ }
1754
1772
  // A shortcut record keeps a bare `on: "keydown"`, so bindingMatches lets every
1755
1773
  // keydown through and this check, per record, picks the chord that fired.
1756
1774
  function shortcutApplies(spec, event, keyMappings) {
1775
+ if (event.defaultPrevented && spec?.handled !== true && !shortcutPrevented().has(event)) return false
1757
1776
  const target = event.target
1758
1777
  if (spec?.inEditable !== true && (target?.isContentEditable || /^(input|textarea|select)$/.test(target?.localName)))
1759
1778
  return false
@@ -2474,7 +2493,9 @@ function earlyReplayEvent(event, el, win) {
2474
2493
  submitter: event.submitter,
2475
2494
  bubbles: event.bubbles,
2476
2495
  timeStamp: event.timeStamp,
2477
- defaultPrevented: event.defaultPrevented,
2496
+ // Not when early.js prevented it for a shortcut (issue #429): a shortcut
2497
+ // skips only a key some OTHER handler prevented first.
2498
+ defaultPrevented: event.defaultPrevented && !shortcutPrevented().has(event),
2478
2499
  preventDefault() {},
2479
2500
  stopPropagation() {},
2480
2501
  stopImmediatePropagation() {},
@@ -3255,7 +3276,8 @@ export default class extends Controller {
3255
3276
  // A shortcut (issue #357) IS prevented: its key filter has already narrowed
3256
3277
  // the window binding to one chord the page claims (mod+s must not also open
3257
3278
  // the browser's save dialog).
3258
- if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
3279
+ if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
3280
+ preventTrigger(event, shortcut)
3259
3281
 
3260
3282
  // A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
3261
3283
  // materialize entry point, which dedupes it against the observer, the
@@ -3374,8 +3396,12 @@ export default class extends Controller {
3374
3396
  // Held past this event's dispatch: cancel its native default NOW, by
3375
3397
  // dispatch()'s own rule, or a submit/link would act before the request.
3376
3398
  const { shortcut, window: windowBound, optimistic } = event.params
3399
+ // A shortcut that does not apply (the other twin, a field, a key handled
3400
+ // first) is neither prevented nor held, as dispatch() would leave it.
3401
+ if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
3377
3402
  const target = event.currentTarget ?? event.target
3378
- if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target)) event.preventDefault()
3403
+ if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
3404
+ preventTrigger(event, shortcut)
3379
3405
  heldForwards.set(root, [...(heldForwards.get(root) ?? []), [event, target]])
3380
3406
  const data = root.dataset
3381
3407
  if (data.reactiveDormant) {
@@ -3463,7 +3489,7 @@ export default class extends Controller {
3463
3489
  // (possibly async) confirm gate below — a native default can't wait for a
3464
3490
  // pending dialog (same ordering as dispatch()).
3465
3491
  // A shortcut (issue #357) is prevented: it is one chord the page claims.
3466
- if (shortcut != null || !windowBound) event.preventDefault()
3492
+ if (shortcut != null || !windowBound) preventTrigger(event, shortcut)
3467
3493
 
3468
3494
  // Resolve the effective confirm message — static string, or the conditional
3469
3495
  // Hash form (issue #179) evaluated over collected fields (a promise of it
@@ -9,7 +9,8 @@ module Phlex
9
9
  # Draggable regions (issue #416): a box that moves and resizes inside a
10
10
  # frame, or a point that moves inside it, with the result in fields as
11
11
  # fractions of the frame — 0..1 on each axis, so a resize, a responsive
12
- # layout or a morph changes nothing about them.
12
+ # layout or a morph changes nothing about them. With scale: 100 the
13
+ # fields hold percentages instead (issue #428).
13
14
  #
14
15
  # div(**mix(reactive_frame, class: "relative")) do
15
16
  # img(src: @photo.url, alt: "", class: "block w-full")
@@ -67,14 +68,17 @@ module Phlex
67
68
  # aspect: a Numeric ratio (16/9r), or the name of a field holding one
68
69
  # ("16:9", "1.5", or "" for free), read at drag start
69
70
  # min: the smallest width/height, a fraction of the frame
71
+ # scale: what the fields hold: the fraction times this (100: percent,
72
+ # 2 decimals); value:, the style and the action's params stay fractions
70
73
  # action: sent once on release, with x/y/width/height params
71
74
  # busy: on(busy:)'s vocabulary, applied while that action is pending
72
75
  # messages: the live-region words, merged over DRAG_MESSAGES
73
- def reactive_box(fields:, value:, aspect: nil, min: 0.05, action: nil, busy: nil, messages: nil)
76
+ def reactive_box(fields:, value:, aspect: nil, min: 0.05, scale: 1, action: nil, busy: nil, messages: nil)
74
77
  fields = drag_fields!(:reactive_box, fields, BOX_KEYS)
75
78
  value = drag_value!(:reactive_box, value, BOX_KEYS)
76
79
  drag_inside_frame!(value)
77
80
  data = { reactive_box: fields.to_json, reactive_box_min: drag_min!(min) }
81
+ drag_scale!(:reactive_box, scale, data)
78
82
  drag_aspect!(aspect, data) unless aspect.nil?
79
83
  drag_action!(:reactive_box, action, busy, data)
80
84
  data[:reactive_box_messages] = drag_messages!(:reactive_box, messages)
@@ -99,12 +103,14 @@ module Phlex
99
103
 
100
104
  # A point (its centre) that moves inside the frame — or, with within:,
101
105
  # inside the element it sits in that matches the selector (a box): its
102
- # fractions are then of that element, and it moves with it.
103
- def reactive_point(fields:, value:, within: nil, action: nil, busy: nil, messages: nil)
106
+ # fractions are then of that element, and it moves with it. scale: is
107
+ # reactive_box's.
108
+ def reactive_point(fields:, value:, within: nil, scale: 1, action: nil, busy: nil, messages: nil)
104
109
  fields = drag_fields!(:reactive_point, fields, POINT_KEYS)
105
110
  value = drag_value!(:reactive_point, value, POINT_KEYS)
106
111
  data = { reactive_point: fields.to_json }
107
112
  data[:reactive_point_within] = drag_within!(within) unless within.nil?
113
+ drag_scale!(:reactive_point, scale, data)
108
114
  drag_action!(:reactive_point, action, busy, data)
109
115
  data[:reactive_point_messages] = drag_messages!(:reactive_point, messages)
110
116
  style = "position: absolute; left: #{drag_percent(value[:x])}; top: #{drag_percent(value[:y])}; " \
@@ -180,6 +186,22 @@ module Phlex
180
186
  "reactive_box min: must be a fraction of the frame above 0 and at most 1 (e.g. 0.05), got #{min.inspect}"
181
187
  end
182
188
 
189
+ # Issue #428: the fields' scale, written only when it is not 1, so a
190
+ # region without scale: renders as it always did. The browser reads it
191
+ # as a Float, so one that is Infinity (10**400) or 0 (1/10**400) as a
192
+ # Float is refused, like a Float that is.
193
+ def drag_scale!(helper, scale, data)
194
+ float = scale.to_f if scale.is_a?(Numeric) && scale.real?
195
+ unless float&.finite? && float.positive?
196
+ raise ArgumentError,
197
+ "#{helper} scale: must be a positive finite Numeric (100: percent), got #{scale.inspect}"
198
+ end
199
+ return if scale == 1
200
+
201
+ # (A Rational would render as "1000/3", which the browser cannot read.)
202
+ data[:"#{helper}_scale"] = scale.is_a?(Integer) || scale.is_a?(Float) ? scale : float
203
+ end
204
+
183
205
  def drag_aspect!(aspect, data)
184
206
  if aspect.is_a?(Numeric) && aspect.real? && aspect.positive? && aspect.finite?
185
207
  data[:reactive_box_ratio] = aspect.to_f
@@ -313,10 +313,13 @@ module Phlex
313
313
  # runs the one for its platform). Unlike a plain window binding, the
314
314
  # client prevents the chord's native default, and it skips the chord
315
315
  # while focus is in an input, textarea, select or contenteditable
316
- # (native text undo keeps working) unless the Hash form opts in. It
317
- # decides event:, window: and outside:, so passing one raises.
316
+ # (native text undo keeps working) unless the Hash form opts in. A
317
+ # keydown another handler already prevented (the drag's Escape
318
+ # cancelling a drag) is skipped too, unless `handled: true` (issue
319
+ # #429). It decides event:, window: and outside:, so passing one raises.
318
320
  # span(**on(:undo, shortcut: "mod+z"))
319
321
  # span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
322
+ # span(**on(:leave, shortcut: { keys: "esc", handled: true }))
320
323
  #
321
324
  # `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
322
325
  # COSMETIC hints the client applies the instant the trigger fires and
@@ -29,6 +29,10 @@ module Phlex
29
29
  # is absent or does not expose the setting.
30
30
  DEFAULT_HEARTBEAT = 15
31
31
 
32
+ # The Store key of the lock register_ttl holds across its read, merge
33
+ # and write.
34
+ TTL_LOCK_KEY = :phlex_reactive_presence_ttl_lock
35
+
32
36
  # The change hook install! sets: the app's own hook (if any) first, then
33
37
  # presence's. Its class is how a second install! knows it is in place.
34
38
  ChainedHook = Data.define(:previous, :on_change) do
@@ -126,10 +130,12 @@ module Phlex
126
130
 
127
131
  # At boot: auto-join presence streams, and hear their changes. An
128
132
  # app's own change hook keeps running (chained, first).
133
+ # Settings change through their setters only: pgbus >= 0.19 freezes
134
+ # its Arrays and Hashes at boot (Configuration#finalize!).
129
135
  def install!(on_change)
130
136
  config = ::Pgbus.configuration
131
137
  patterns = config.streams_presence_patterns
132
- patterns << PATTERN unless patterns.include?(PATTERN)
138
+ config.streams_presence_patterns = [*patterns, PATTERN] unless patterns.include?(PATTERN)
133
139
  previous = config.streams_presence_on_change
134
140
  return if previous.is_a?(ChainedHook)
135
141
 
@@ -141,11 +147,16 @@ module Phlex
141
147
  # pgbus takes an Integer (every stream) or a Hash of stream name /
142
148
  # pattern => seconds with a :default; an Integer becomes the Hash's
143
149
  # default so the app's own setting still covers every other stream.
150
+ # A new Hash every time: a component can load after pgbus froze the
151
+ # setting at boot (eager_load off). Under a lock: two classes that
152
+ # load at once on two threads must each see the other's write.
144
153
  def register_ttl(name, seconds)
145
- config = ::Pgbus.configuration
146
- ttl = config.streams_presence_ttl
147
- config.streams_presence_ttl = ttl = { default: ttl } unless ttl.is_a?(Hash)
148
- ttl.store(/:presence:#{Regexp.escape(name.to_s)}\z/, seconds)
154
+ Phlex::Reactive::Store.fetch(TTL_LOCK_KEY) { Mutex.new }.synchronize do
155
+ config = ::Pgbus.configuration
156
+ ttl = config.streams_presence_ttl
157
+ ttl = { default: ttl } unless ttl.is_a?(Hash)
158
+ config.streams_presence_ttl = ttl.merge(/:presence:#{Regexp.escape(name.to_s)}\z/ => seconds)
159
+ end
149
160
  end
150
161
  end
151
162
  end
@@ -7,13 +7,16 @@ module Phlex
7
7
  #
8
8
  # Shortcut.parse("mod+z") # String form
9
9
  # Shortcut.parse({ keys: "esc", in_editable: true }) # Hash form
10
+ # Shortcut.parse({ keys: "esc", handled: true }) # runs on a handled key
10
11
  #
11
12
  # `mod` is meta on Apple platforms and ctrl elsewhere. Stimulus has no
12
13
  # "either" modifier (its key filter compares all four modifier flags
13
14
  # exactly), so a `mod` chord renders TWO descriptors, a meta twin and a ctrl
14
15
  # twin, and the client runs only the twin for its platform. Stimulus keeps
15
16
  # the key filtering; the client adds the platform check, the editable-target
16
- # guard and preventDefault. The exception is a key Stimulus's default map
17
+ # guard, the handled-key skip (issue #429: a keydown another handler already
18
+ # prevented is not the shortcut's, unless `handled: true`) and
19
+ # preventDefault. The exception is a key Stimulus's default map
17
20
  # lacks (delete, backspace — issue #366): it binds a bare window keydown,
18
21
  # no twins, and the client matches the whole chord.
19
22
  #
@@ -28,7 +31,7 @@ module Phlex
28
31
  # `keydown@window` and the client alone matches it.
29
32
  LIBRARY_KEYS = %w[delete backspace].freeze
30
33
  NAMED_KEYS = (STIMULUS_KEYS + LIBRARY_KEYS).freeze
31
- HASH_KEYS = %i[keys in_editable].freeze
34
+ HASH_KEYS = %i[keys in_editable handled].freeze
32
35
 
33
36
  private_constant :MODIFIERS, :STIMULUS_KEYS, :LIBRARY_KEYS, :NAMED_KEYS, :HASH_KEYS
34
37
 
@@ -36,34 +39,38 @@ module Phlex
36
39
 
37
40
  def self.parse(spec)
38
41
  case spec
39
- when String then new(spec, in_editable: false)
42
+ when String then new(spec, in_editable: false, handled: false)
40
43
  when Hash then from_hash(spec)
41
44
  else
42
45
  raise ArgumentError,
43
- "shortcut: takes a String (\"mod+z\") or a Hash ({ keys: \"esc\", in_editable: true }), " \
44
- "got #{spec.inspect}"
46
+ "shortcut: takes a String (\"mod+z\") or a Hash ({ keys: \"esc\", in_editable: true, " \
47
+ "handled: true }), got #{spec.inspect}"
45
48
  end
46
49
  end
47
50
 
48
51
  def self.from_hash(spec)
49
52
  keys = spec[:keys]
50
53
  in_editable = spec.fetch(:in_editable, false)
54
+ handled = spec.fetch(:handled, false)
51
55
  unless (spec.keys - HASH_KEYS).empty? && keys.is_a?(String)
52
56
  raise ArgumentError,
53
- "shortcut: as a Hash takes keys: (a String, e.g. \"esc\") and an optional " \
54
- "in_editable: (true/false), got #{spec.inspect}"
57
+ "shortcut: as a Hash takes keys: (a String, e.g. \"esc\") and optional " \
58
+ "in_editable: and handled: (true/false), got #{spec.inspect}"
55
59
  end
56
- unless [true, false].include?(in_editable)
57
- raise ArgumentError, "shortcut: in_editable: is true or false, got #{in_editable.inspect}"
60
+ { in_editable:, handled: }.each do |name, value|
61
+ next if [true, false].include?(value)
62
+
63
+ raise ArgumentError, "shortcut: #{name}: is true or false, got #{value.inspect}"
58
64
  end
59
65
 
60
- new(keys, in_editable:)
66
+ new(keys, in_editable:, handled:)
61
67
  end
62
68
  private_class_method :from_hash
63
69
 
64
- def initialize(keys, in_editable:)
70
+ def initialize(keys, in_editable:, handled:)
65
71
  tokens = keys.downcase.split("+").map(&:strip)
66
72
  @in_editable = in_editable
73
+ @handled = handled
67
74
  @keys = canonical(keys, tokens).freeze
68
75
  freeze
69
76
  end
@@ -83,8 +90,13 @@ module Phlex
83
90
 
84
91
  # The wire form the client reads (data-reactive-shortcut-param, or the
85
92
  # on_client record's "shortcut"): the canonical String, or the Hash form
86
- # when the shortcut also fires inside editable targets.
87
- def param = @in_editable ? { "keys" => @keys, "inEditable" => true } : @keys
93
+ # when the shortcut also fires inside editable targets and/or on a key
94
+ # another handler already prevented (issue #429).
95
+ def param
96
+ return @keys unless @in_editable || @handled
97
+
98
+ { "keys" => @keys, "inEditable" => (true if @in_editable), "handled" => (true if @handled) }.compact
99
+ end
88
100
 
89
101
  private
90
102
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.3"
5
+ VERSION = "0.16.5"
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.16.3
4
+ version: 0.16.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson