phlex-reactive 0.16.4 → 0.16.6
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +72 -1
- data/README.md +80 -4
- data/app/javascript/phlex/reactive/core.min.js +4 -4
- data/app/javascript/phlex/reactive/core.min.js.map +3 -3
- data/app/javascript/phlex/reactive/early.js +16 -1
- data/app/javascript/phlex/reactive/early.min.js +2 -2
- data/app/javascript/phlex/reactive/early.min.js.map +3 -3
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/app/javascript/phlex/reactive/runtime.js +27 -11
- data/lib/phlex/reactive/component/helpers.rb +43 -4
- data/lib/phlex/reactive/component/shared_field.rb +40 -0
- data/lib/phlex/reactive/presence/transport.rb +16 -5
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +2 -1
|
@@ -3223,7 +3223,7 @@ export default class extends Controller {
|
|
|
3223
3223
|
// modifier params (issue #80). The client decides preventDefault behavior
|
|
3224
3224
|
// from event.params — set by the Ruby on() — never by sniffing the
|
|
3225
3225
|
// Stimulus descriptor.
|
|
3226
|
-
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
|
|
3226
|
+
const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic, prevent } =
|
|
3227
3227
|
event.params
|
|
3228
3228
|
if (!action) return
|
|
3229
3229
|
|
|
@@ -3276,7 +3276,11 @@ export default class extends Controller {
|
|
|
3276
3276
|
// A shortcut (issue #357) IS prevented: its key filter has already narrowed
|
|
3277
3277
|
// the window binding to one chord the page claims (mod+s must not also open
|
|
3278
3278
|
// the browser's save dialog).
|
|
3279
|
-
|
|
3279
|
+
//
|
|
3280
|
+
// `prevent: false` (issue #433) opts an element-bound trigger out too: the
|
|
3281
|
+
// event keeps its native default (a drop still reaches a file input). A
|
|
3282
|
+
// shortcut stays prevented whatever the markup says.
|
|
3283
|
+
if ((shortcut != null || (!windowBound && prevent !== false)) && !this.#keepsNativeToggle(optimistic, target))
|
|
3280
3284
|
preventTrigger(event, shortcut)
|
|
3281
3285
|
|
|
3282
3286
|
// A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
|
|
@@ -3395,12 +3399,12 @@ export default class extends Controller {
|
|
|
3395
3399
|
if (controller) return controller.dispatch(event)
|
|
3396
3400
|
// Held past this event's dispatch: cancel its native default NOW, by
|
|
3397
3401
|
// dispatch()'s own rule, or a submit/link would act before the request.
|
|
3398
|
-
const { shortcut, window: windowBound, optimistic } = event.params
|
|
3402
|
+
const { shortcut, window: windowBound, optimistic, prevent } = event.params
|
|
3399
3403
|
// A shortcut that does not apply (the other twin, a field, a key handled
|
|
3400
3404
|
// first) is neither prevented nor held, as dispatch() would leave it.
|
|
3401
3405
|
if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
|
|
3402
3406
|
const target = event.currentTarget ?? event.target
|
|
3403
|
-
if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
|
|
3407
|
+
if ((shortcut != null || (!windowBound && prevent !== false)) && !this.#keepsNativeToggle(optimistic, target))
|
|
3404
3408
|
preventTrigger(event, shortcut)
|
|
3405
3409
|
heldForwards.set(root, [...(heldForwards.get(root) ?? []), [event, target]])
|
|
3406
3410
|
const data = root.dataset
|
|
@@ -3463,9 +3467,10 @@ export default class extends Controller {
|
|
|
3463
3467
|
continue
|
|
3464
3468
|
if (onceBindingSpent(this, event, record)) continue
|
|
3465
3469
|
// A legacy attr reads its flags element-wide — but never a mix-ed on()'s
|
|
3466
|
-
// debounce/throttle
|
|
3470
|
+
// debounce/throttle (issue #393) or prevent (issue #433) params: those
|
|
3471
|
+
// belong to the dispatch.
|
|
3467
3472
|
this.#runBinding(
|
|
3468
|
-
record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined } : record,
|
|
3473
|
+
record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined, prevent: undefined } : record,
|
|
3469
3474
|
event,
|
|
3470
3475
|
trigger,
|
|
3471
3476
|
records.indexOf(record),
|
|
@@ -3476,7 +3481,7 @@ export default class extends Controller {
|
|
|
3476
3481
|
// One on_client binding (issue #95): its outside guard, preventDefault rule,
|
|
3477
3482
|
// confirm gate, then its ops — every flag read from the binding itself.
|
|
3478
3483
|
#runBinding(record, event, trigger, ordinal) {
|
|
3479
|
-
const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut } = record
|
|
3484
|
+
const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut, prevent } = record
|
|
3480
3485
|
// Outside guard FIRST — identical semantics to dispatch() (issue #80): an
|
|
3481
3486
|
// outside: trigger is a COMPLETE no-op for events inside this root, before
|
|
3482
3487
|
// preventDefault and before any op runs.
|
|
@@ -3489,7 +3494,8 @@ export default class extends Controller {
|
|
|
3489
3494
|
// (possibly async) confirm gate below — a native default can't wait for a
|
|
3490
3495
|
// pending dialog (same ordering as dispatch()).
|
|
3491
3496
|
// A shortcut (issue #357) is prevented: it is one chord the page claims.
|
|
3492
|
-
|
|
3497
|
+
// A record with `prevent: false` (issue #433) keeps the native default.
|
|
3498
|
+
if (shortcut != null || (!windowBound && prevent !== false)) preventTrigger(event, shortcut)
|
|
3493
3499
|
|
|
3494
3500
|
// Resolve the effective confirm message — static string, or the conditional
|
|
3495
3501
|
// Hash form (issue #179) evaluated over collected fields (a promise of it
|
|
@@ -4352,12 +4358,22 @@ export default class extends Controller {
|
|
|
4352
4358
|
// #ownershipFilter for SERVER params (issue #410): nested client-only roots
|
|
4353
4359
|
// are transparent, so a control under one is this root's when the first
|
|
4354
4360
|
// token-bearing root above it is this.element. A nested token-bearing root
|
|
4355
|
-
// keeps its own fields (issue #15)
|
|
4356
|
-
//
|
|
4361
|
+
// keeps its own fields (issue #15), except a control marked
|
|
4362
|
+
// data-reactive-shared-field (issue #434): this root collects it too when
|
|
4363
|
+
// the control's own server root sits directly below this one. One level: a
|
|
4364
|
+
// grand-host never sees it. Whatever reads #collectFields sees it too: the
|
|
4365
|
+
// request, and the conditional confirm (it judges the fields the action is
|
|
4366
|
+
// about to post). Bindings, computes, listnav, dirty tracking and persist
|
|
4367
|
+
// keep the strict nearest-root #ownershipFilter / #ownsField.
|
|
4357
4368
|
#serverFieldFilter() {
|
|
4358
4369
|
const nested = this.element.querySelectorAll(AWAKE_ROOT)
|
|
4359
4370
|
if (nested.length === 0 || [...nested].every(clientOnlyRoot)) return () => true
|
|
4360
|
-
return (el) =>
|
|
4371
|
+
return (el) => {
|
|
4372
|
+
const own = serverRoot(el, AWAKE_ROOT, this.element)
|
|
4373
|
+
if (own === this.element) return true
|
|
4374
|
+
return el.getAttribute("data-reactive-shared-field") != null &&
|
|
4375
|
+
serverRoot(own.parentElement, AWAKE_ROOT, this.element) === this.element
|
|
4376
|
+
}
|
|
4361
4377
|
}
|
|
4362
4378
|
|
|
4363
4379
|
// One walk over THIS root's named controls (not a nested reactive root's),
|
|
@@ -20,6 +20,7 @@ module Phlex
|
|
|
20
20
|
include ImageFallbackBinding
|
|
21
21
|
include CountdownBinding
|
|
22
22
|
include CssVarsBinding
|
|
23
|
+
include SharedField
|
|
23
24
|
include FieldReferences
|
|
24
25
|
include ShowOptions
|
|
25
26
|
|
|
@@ -321,6 +322,16 @@ module Phlex
|
|
|
321
322
|
# span(**on(:close, shortcut: { keys: "esc", in_editable: true }))
|
|
322
323
|
# span(**on(:leave, shortcut: { keys: "esc", handled: true }))
|
|
323
324
|
#
|
|
325
|
+
# `prevent: false` (issue #433, also RESERVED) lets an ELEMENT-BOUND
|
|
326
|
+
# trigger keep the event's native default: the client skips its
|
|
327
|
+
# preventDefault, so a drop still reaches a native file input inside
|
|
328
|
+
# the trigger. Default `true` (today's behaviour, no attribute). A
|
|
329
|
+
# window-bound or outside: trigger never prevents, so there it is a
|
|
330
|
+
# no-op; with shortcut: (a chord the page claims) it raises. The
|
|
331
|
+
# forced type="button" of a click trigger stays. With confirm:, this
|
|
332
|
+
# trigger does not cancel the default; the dialog gates only its action.
|
|
333
|
+
# div(**on(:track, event: "drop", prevent: false))
|
|
334
|
+
#
|
|
324
335
|
# `optimistic:` (issue #98) — a small, ALWAYS-REVERSIBLE vocabulary of
|
|
325
336
|
# COSMETIC hints the client applies the instant the trigger fires and
|
|
326
337
|
# REVERTS if the round trip fails, so a click/toggle gives instant feedback
|
|
@@ -367,7 +378,7 @@ module Phlex
|
|
|
367
378
|
# button(**on(:destroy, confirm: "Sure?", busy: { add_class: "opacity-50" })) { "Delete" }
|
|
368
379
|
def on(action_name, event: nil, debounce: nil, throttle: nil, confirm: nil,
|
|
369
380
|
window: false, once: false, outside: false, optimistic: nil, busy: nil,
|
|
370
|
-
shortcut: nil, **params)
|
|
381
|
+
shortcut: nil, prevent: true, **params)
|
|
371
382
|
reject_removed_on_kwargs!(action_name, params)
|
|
372
383
|
# A typo'd or forgotten action renders fine and only surfaces as an
|
|
373
384
|
# opaque 403 at CLICK time (the endpoint's default-deny). Under
|
|
@@ -401,6 +412,7 @@ module Phlex
|
|
|
401
412
|
|
|
402
413
|
# Guarded here, not inside: building the caller label allocates (on() is a hot path).
|
|
403
414
|
shortcut = shortcut_for!("on(#{action_name.inspect})", shortcut, event:, window:, outside:) unless shortcut.nil?
|
|
415
|
+
keeps_default!("on(#{action_name.inspect})", prevent, shortcut) unless prevent == true
|
|
404
416
|
event ||= "click"
|
|
405
417
|
window_bound = window || outside || !shortcut.nil?
|
|
406
418
|
action = trigger_descriptors(event, shortcut, window_bound, "dispatch", once)
|
|
@@ -424,6 +436,10 @@ module Phlex
|
|
|
424
436
|
# The client decides preventDefault behavior from event.params (never by
|
|
425
437
|
# sniffing the descriptor), so EVERY window binding flags the param.
|
|
426
438
|
attrs[:data][:reactive_window_param] = "true" if window_bound
|
|
439
|
+
# Issue #433: the STRING "false" (Phlex drops a false attribute;
|
|
440
|
+
# Stimulus typecasts "false" back to false). A window binding never
|
|
441
|
+
# prevents, so it carries nothing.
|
|
442
|
+
attrs[:data][:reactive_prevent_param] = "false" unless prevent || window_bound
|
|
427
443
|
# Issue #357: the client reads the shortcut to pick the twin for its
|
|
428
444
|
# platform, skip editable targets and prevent the chord.
|
|
429
445
|
attrs[:data][:reactive_shortcut_param] = shortcut_param_attr(shortcut) if shortcut
|
|
@@ -481,6 +497,13 @@ module Phlex
|
|
|
481
497
|
# input(**on_client(:input, js.submit("#search"), debounce: 300))
|
|
482
498
|
# div(**on_client(:scroll, js.add_class("#bar", "stuck"), window: true, throttle: 250))
|
|
483
499
|
#
|
|
500
|
+
# Issue #433: `prevent: false` lets an element-bound binding keep the
|
|
501
|
+
# event's native default (on()'s rule): a drop on a native file input
|
|
502
|
+
# inside the element still sets the input's files while the ops run.
|
|
503
|
+
# It rides on the record ("prevent":false, right after "on"); a no-op
|
|
504
|
+
# on a window-bound binding, an ArgumentError with shortcut:.
|
|
505
|
+
# div(**on_client(:drop, js.remove_class("#zone", "is-over"), prevent: false))
|
|
506
|
+
#
|
|
484
507
|
# Ops are EPHEMERAL UI: any server re-render of the component (an action
|
|
485
508
|
# reply, a broadcast, a morph) rebuilds from server state and resets
|
|
486
509
|
# whatever they toggled — by design (the LiveView JS-commands caveat). Use
|
|
@@ -490,7 +513,7 @@ module Phlex
|
|
|
490
513
|
# accepted — a dead trigger should fail at render, not no-op in the
|
|
491
514
|
# browser.
|
|
492
515
|
def on_client(event, ops, window: false, once: false, outside: false, confirm: nil, detail: nil,
|
|
493
|
-
shortcut: nil, debounce: nil, throttle: nil)
|
|
516
|
+
shortcut: nil, debounce: nil, throttle: nil, prevent: true)
|
|
494
517
|
unless ops.is_a?(Phlex::Reactive::JS)
|
|
495
518
|
raise ArgumentError,
|
|
496
519
|
"on_client expects a Phlex::Reactive::JS chain (e.g. js.toggle(\"#menu\")), " \
|
|
@@ -508,8 +531,10 @@ module Phlex
|
|
|
508
531
|
"(change/input), or gate it behind a reducer's $ops / reactive_on_complete."
|
|
509
532
|
end
|
|
510
533
|
shortcut = client_shortcut!(event, shortcut, window:, outside:)
|
|
534
|
+
keeps_default!("on_client(#{event.inspect})", prevent, shortcut) unless prevent == true
|
|
511
535
|
window_bound = window || outside || !shortcut.nil?
|
|
512
|
-
|
|
536
|
+
keep_default = !prevent && !window_bound
|
|
537
|
+
record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:)
|
|
513
538
|
client_binding_wait!(record, event, debounce, throttle) unless debounce.nil? && throttle.nil?
|
|
514
539
|
# The record's `on` stays the bare keydown: the client reads a key
|
|
515
540
|
# filter from `on`, and `mod` is not a key.
|
|
@@ -1571,8 +1596,11 @@ module Phlex
|
|
|
1571
1596
|
# overridable confirmResolver on(:action, confirm:) uses (#52/#55); the
|
|
1572
1597
|
# client prompts BEFORE applying the ops. Issue #179: a Hash confirm: is
|
|
1573
1598
|
# CONDITIONAL, compiled by the shared compile_conditional_confirm.
|
|
1574
|
-
def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
|
|
1599
|
+
def client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:, keep_default:)
|
|
1575
1600
|
record = { "on" => event }
|
|
1601
|
+
# Issue #433: RIGHT AFTER "on" — phlex/reactive/early reads the pair
|
|
1602
|
+
# `"on":"<event>","prevent":false` off the raw attribute before connect.
|
|
1603
|
+
record["prevent"] = false if keep_default
|
|
1576
1604
|
record["window"] = true if window_bound
|
|
1577
1605
|
record["outside"] = true if outside
|
|
1578
1606
|
# A mix-ed regular sibling keeps calling runOps after Stimulus drops
|
|
@@ -1889,6 +1917,17 @@ module Phlex
|
|
|
1889
1917
|
Phlex::Reactive::Shortcut.parse(shortcut)
|
|
1890
1918
|
end
|
|
1891
1919
|
|
|
1920
|
+
# Issue #433: validate prevent: once it is not the default true — only
|
|
1921
|
+
# false is left, and a shortcut (a chord the page claims) refuses it.
|
|
1922
|
+
def keeps_default!(caller, prevent, shortcut)
|
|
1923
|
+
raise ArgumentError, "#{caller} prevent: must be true or false, got #{prevent.inspect}" unless prevent == false
|
|
1924
|
+
return if shortcut.nil?
|
|
1925
|
+
|
|
1926
|
+
raise ArgumentError,
|
|
1927
|
+
"#{caller} got shortcut: with prevent: false — a shortcut is a chord the page claims, " \
|
|
1928
|
+
"so it is always prevented; drop prevent:"
|
|
1929
|
+
end
|
|
1930
|
+
|
|
1892
1931
|
# on_client's event is positional: with shortcut: it must be the bare
|
|
1893
1932
|
# keydown (the key filter comes from the shortcut).
|
|
1894
1933
|
def client_shortcut!(event, shortcut, window:, outside:)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# A field a nested component shares with its host (issue #434). A reactive
|
|
7
|
+
# component rendered inside another is its own root, and its named fields
|
|
8
|
+
# are its own (issue #15). A form widget with actions of its own (a picker
|
|
9
|
+
# that stores the chosen id in a hidden field) still has to post that value
|
|
10
|
+
# with the form around it:
|
|
11
|
+
#
|
|
12
|
+
# input(type: "hidden", **reactive_shared_field(name: "item[choice]", value: @choice))
|
|
13
|
+
# # => name="item[choice]" value="…" data-reactive-shared-field
|
|
14
|
+
#
|
|
15
|
+
# The marked control is collected by its own root's actions AND by the
|
|
16
|
+
# next token-bearing root above it (client-only roots in between are
|
|
17
|
+
# transparent, issue #410). One level only: a grand-host collects it only
|
|
18
|
+
# when the host marks a copy of its own. A native <form> posts it anyway,
|
|
19
|
+
# as it does any named control.
|
|
20
|
+
#
|
|
21
|
+
# The name is the HOST's param and goes on the wire verbatim, never
|
|
22
|
+
# re-scoped under this component's reactive_scope.
|
|
23
|
+
module SharedField
|
|
24
|
+
def reactive_shared_field(name:, value: nil, **attrs)
|
|
25
|
+
unless (name.is_a?(String) || name.is_a?(Symbol)) && !name.to_s.strip.empty?
|
|
26
|
+
raise ArgumentError,
|
|
27
|
+
"reactive_shared_field needs a non-blank String or Symbol name: " \
|
|
28
|
+
"(the host's param, e.g. name: \"item[choice]\"), " \
|
|
29
|
+
"got #{name.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
binding = { name: name.to_s }
|
|
33
|
+
binding[:value] = value unless value.nil?
|
|
34
|
+
binding[:data] = { reactive_shared_field: true }
|
|
35
|
+
mix(binding, attrs)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
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.
|
|
4
|
+
version: 0.16.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Mikael Henriksson
|
|
@@ -214,6 +214,7 @@ files:
|
|
|
214
214
|
- lib/phlex/reactive/component/presence_declaration.rb
|
|
215
215
|
- lib/phlex/reactive/component/registry.rb
|
|
216
216
|
- lib/phlex/reactive/component/selection.rb
|
|
217
|
+
- lib/phlex/reactive/component/shared_field.rb
|
|
217
218
|
- lib/phlex/reactive/component/show_options.rb
|
|
218
219
|
- lib/phlex/reactive/component/sortable.rb
|
|
219
220
|
- lib/phlex/reactive/defer.rb
|