phlex-reactive 0.16.5 → 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.
@@ -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
- if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
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 params (issue #393): those belong to the dispatch.
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
- if (shortcut != null || !windowBound) preventTrigger(event, shortcut)
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). Bindings, computes and listnav keep the
4356
- // strict nearest-root #ownershipFilter.
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) => serverRoot(el, AWAKE_ROOT, this.element) === this.element
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
- record = client_binding_record(event, ops, window_bound:, outside:, once:, confirm:, detail:)
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.5"
5
+ VERSION = "0.16.6"
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.5
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