phlex-reactive 0.16.5 → 0.16.7

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.
@@ -1490,16 +1490,36 @@ function previewFile(input, args, resolveTargets) {
1490
1490
  // `invalid` event fired into the app's own listeners.
1491
1491
  const files = [...(args.valid === true && input.validity?.valid === false ? [] : (input.files ?? []))]
1492
1492
  const accepts = previewAccepts(input, args.accept)
1493
- const listed = files.filter((f) => typeof f?.type === "string" && accepts(f))
1494
- // Only an image is painted: a listed PDF clears the preview, unrefused.
1495
- let file = listed.find((f) => f.type.startsWith("image/"))
1496
- // A refused file (issue #391) clears like an empty pick and says why. The
1497
- // input keeps its files and its validity: the page owns the reset. Size is
1498
- // read BEFORE an object URL exists, so an over-size file never gets one.
1499
- let reason = files.length > 0 && listed.length === 0 ? "type" : null
1500
- if (file && Number.isInteger(args.max_bytes) && file.size > args.max_bytes) reason = "size"
1501
- const refused = reason === "size" ? file : files[0]
1502
- if (reason) file = null
1493
+ // Size is read BEFORE an object URL exists, so an over-size file never gets one.
1494
+ const oversize = (f) => Number.isInteger(args.max_bytes) && f.size > args.max_bytes
1495
+ const rejected = []
1496
+ let file
1497
+ if (args.per_file === true && input.multiple) {
1498
+ // Several files (issue #439): every file is checked, accept then
1499
+ // max_bytes. The refused ones leave input.files here, synchronously, so an
1500
+ // action bound after the op on the same event posts only the kept ones.
1501
+ const kept = files.filter((f, index) => {
1502
+ const reason = typeof f?.type === "string" && accepts(f) ? oversize(f) && "size" : "type"
1503
+ if (reason) rejected.push({ reason, file: f, index })
1504
+ return !reason
1505
+ })
1506
+ // remaining: the files the input keeps, and so the ones an action posts.
1507
+ const remaining = rejected.length > 0 && !keepFiles(input, kept) ? files.length : kept.length
1508
+ for (const detail of rejected) detail.remaining = remaining
1509
+ file = kept.find((f) => f.type.startsWith("image/"))
1510
+ } else {
1511
+ const listed = files.filter((f) => typeof f?.type === "string" && accepts(f))
1512
+ // Only an image is painted: a listed PDF clears the preview, unrefused.
1513
+ file = listed.find((f) => f.type.startsWith("image/"))
1514
+ // A refused file (issue #391) clears like an empty pick and says why. The
1515
+ // input keeps its files and its validity: the page owns the reset.
1516
+ let reason = files.length > 0 && listed.length === 0 ? "type" : null
1517
+ if (file && oversize(file)) reason = "size"
1518
+ if (reason) {
1519
+ rejected.push({ reason, file: reason === "size" ? file : files[0] })
1520
+ file = null
1521
+ }
1522
+ }
1503
1523
  forgetPreview(img)
1504
1524
  if (file) {
1505
1525
  const url = URL.createObjectURL(file)
@@ -1516,9 +1536,23 @@ function previewFile(input, args, resolveTargets) {
1516
1536
  if (args.name != null) {
1517
1537
  for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
1518
1538
  }
1519
- if (reason) {
1520
- input.dispatchEvent(new CustomEvent("reactive:file-rejected", { bubbles: true, detail: { reason, file: refused } }))
1539
+ for (const detail of rejected) input.dispatchEvent(new CustomEvent("reactive:file-rejected", { bubbles: true, detail }))
1540
+ }
1541
+
1542
+ // Rebuild input.files from the kept files (issue #439); true once it did. A
1543
+ // DataTransfer is the one way to make a FileList; without one the refused
1544
+ // files stay in the input (the rejections still fire), and a verbose root is told.
1545
+ function keepFiles(input, kept) {
1546
+ if (typeof DataTransfer !== "function") {
1547
+ if (input.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]')) {
1548
+ warnPreview("per_file: has no DataTransfer to drop the refused files from the input")
1549
+ }
1550
+ return false
1521
1551
  }
1552
+ const transfer = new DataTransfer()
1553
+ for (const f of kept) transfer.items.add(f)
1554
+ input.files = transfer.files
1555
+ return true
1522
1556
  }
1523
1557
 
1524
1558
  // Which files the page takes: an image by default. accept: true reads the
@@ -3223,7 +3257,7 @@ export default class extends Controller {
3223
3257
  // modifier params (issue #80). The client decides preventDefault behavior
3224
3258
  // from event.params — set by the Ruby on() — never by sniffing the
3225
3259
  // Stimulus descriptor.
3226
- const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic } =
3260
+ const { action, params, debounce, throttle, confirm, confirmWhen, outside, window: windowBound, optimistic, prevent } =
3227
3261
  event.params
3228
3262
  if (!action) return
3229
3263
 
@@ -3276,7 +3310,11 @@ export default class extends Controller {
3276
3310
  // A shortcut (issue #357) IS prevented: its key filter has already narrowed
3277
3311
  // the window binding to one chord the page claims (mod+s must not also open
3278
3312
  // the browser's save dialog).
3279
- if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
3313
+ //
3314
+ // `prevent: false` (issue #433) opts an element-bound trigger out too: the
3315
+ // event keeps its native default (a drop still reaches a file input). A
3316
+ // shortcut stays prevented whatever the markup says.
3317
+ if ((shortcut != null || (!windowBound && prevent !== false)) && !this.#keepsNativeToggle(optimistic, target))
3280
3318
  preventTrigger(event, shortcut)
3281
3319
 
3282
3320
  // A reactive_lazy(on:) shell's trigger (issue #276) goes through the one
@@ -3395,12 +3433,12 @@ export default class extends Controller {
3395
3433
  if (controller) return controller.dispatch(event)
3396
3434
  // Held past this event's dispatch: cancel its native default NOW, by
3397
3435
  // dispatch()'s own rule, or a submit/link would act before the request.
3398
- const { shortcut, window: windowBound, optimistic } = event.params
3436
+ const { shortcut, window: windowBound, optimistic, prevent } = event.params
3399
3437
  // A shortcut that does not apply (the other twin, a field, a key handled
3400
3438
  // first) is neither prevented nor held, as dispatch() would leave it.
3401
3439
  if (shortcut != null && !shortcutApplies(shortcut, event, this.application?.schema?.keyMappings)) return
3402
3440
  const target = event.currentTarget ?? event.target
3403
- if ((shortcut != null || !windowBound) && !this.#keepsNativeToggle(optimistic, target))
3441
+ if ((shortcut != null || (!windowBound && prevent !== false)) && !this.#keepsNativeToggle(optimistic, target))
3404
3442
  preventTrigger(event, shortcut)
3405
3443
  heldForwards.set(root, [...(heldForwards.get(root) ?? []), [event, target]])
3406
3444
  const data = root.dataset
@@ -3463,9 +3501,10 @@ export default class extends Controller {
3463
3501
  continue
3464
3502
  if (onceBindingSpent(this, event, record)) continue
3465
3503
  // 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.
3504
+ // debounce/throttle (issue #393) or prevent (issue #433) params: those
3505
+ // belong to the dispatch.
3467
3506
  this.#runBinding(
3468
- record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined } : record,
3507
+ record.legacy ? { ...params, ops: record.ops, debounce: undefined, throttle: undefined, prevent: undefined } : record,
3469
3508
  event,
3470
3509
  trigger,
3471
3510
  records.indexOf(record),
@@ -3476,7 +3515,7 @@ export default class extends Controller {
3476
3515
  // One on_client binding (issue #95): its outside guard, preventDefault rule,
3477
3516
  // confirm gate, then its ops — every flag read from the binding itself.
3478
3517
  #runBinding(record, event, trigger, ordinal) {
3479
- const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut } = record
3518
+ const { ops, confirm, confirmWhen, outside, window: windowBound, shortcut, prevent } = record
3480
3519
  // Outside guard FIRST — identical semantics to dispatch() (issue #80): an
3481
3520
  // outside: trigger is a COMPLETE no-op for events inside this root, before
3482
3521
  // preventDefault and before any op runs.
@@ -3489,7 +3528,8 @@ export default class extends Controller {
3489
3528
  // (possibly async) confirm gate below — a native default can't wait for a
3490
3529
  // pending dialog (same ordering as dispatch()).
3491
3530
  // A shortcut (issue #357) is prevented: it is one chord the page claims.
3492
- if (shortcut != null || !windowBound) preventTrigger(event, shortcut)
3531
+ // A record with `prevent: false` (issue #433) keeps the native default.
3532
+ if (shortcut != null || (!windowBound && prevent !== false)) preventTrigger(event, shortcut)
3493
3533
 
3494
3534
  // Resolve the effective confirm message — static string, or the conditional
3495
3535
  // Hash form (issue #179) evaluated over collected fields (a promise of it
@@ -4352,12 +4392,22 @@ export default class extends Controller {
4352
4392
  // #ownershipFilter for SERVER params (issue #410): nested client-only roots
4353
4393
  // are transparent, so a control under one is this root's when the first
4354
4394
  // 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.
4395
+ // keeps its own fields (issue #15), except a control marked
4396
+ // data-reactive-shared-field (issue #434): this root collects it too when
4397
+ // the control's own server root sits directly below this one. One level: a
4398
+ // grand-host never sees it. Whatever reads #collectFields sees it too: the
4399
+ // request, and the conditional confirm (it judges the fields the action is
4400
+ // about to post). Bindings, computes, listnav, dirty tracking and persist
4401
+ // keep the strict nearest-root #ownershipFilter / #ownsField.
4357
4402
  #serverFieldFilter() {
4358
4403
  const nested = this.element.querySelectorAll(AWAKE_ROOT)
4359
4404
  if (nested.length === 0 || [...nested].every(clientOnlyRoot)) return () => true
4360
- return (el) => serverRoot(el, AWAKE_ROOT, this.element) === this.element
4405
+ return (el) => {
4406
+ const own = serverRoot(el, AWAKE_ROOT, this.element)
4407
+ if (own === this.element) return true
4408
+ return el.getAttribute("data-reactive-shared-field") != null &&
4409
+ serverRoot(own.parentElement, AWAKE_ROOT, this.element) === this.element
4410
+ }
4361
4411
  }
4362
4412
 
4363
4413
  // 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
@@ -329,8 +329,21 @@ module Phlex
329
329
  # input(type: "file", id: "photo-file", accept: "image/png,image/jpeg",
330
330
  # **on_client(:change, js.preview_file("#photo-file", into: "#photo",
331
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)
332
+ #
333
+ # Several files (issue #439): `per_file: true` on an input with
334
+ # `multiple` checks EVERY file (accept, then max_bytes), rebuilds
335
+ # input.files from the accepted ones (synchronously, so an
336
+ # `on(:action, event: "change")` mixed in AFTER the op posts only those),
337
+ # dispatches one `reactive:file-rejected` per refused file
338
+ # (detail.reason, file, index — its place in the pick — and remaining,
339
+ # how many files the input keeps), and paints the first accepted image.
340
+ # A single input, or per_file: omitted, keeps the behaviour above.
341
+ # input(type: "file", id: "pick", name: "file", multiple: true, accept: "image/png",
342
+ # **mix(on_client(:change, js.preview_file("#pick", into: "#shot", max_bytes: 1_000_000,
343
+ # accept: true, per_file: true)), on(:upload, event: "change")))
344
+
345
+ def preview_file(to, into:, name: nil, global: false, valid: false, wrap: nil, max_bytes: nil, accept: nil,
346
+ per_file: false)
334
347
  if [:root, ROOT_SENTINEL].include?(to)
335
348
  raise ArgumentError,
336
349
  "#{self.class}: preview_file reads a file input, not the component root — " \
@@ -344,6 +357,7 @@ module Phlex
344
357
  args["wrap"] = preview_selector(:wrap, wrap) unless wrap.nil?
345
358
  args["max_bytes"] = preview_max_bytes(max_bytes) unless max_bytes.nil?
346
359
  args["accept"] = preview_accept(accept) unless accept.nil? || accept == false
360
+ args["per_file"] = true if preview_per_file(per_file)
347
361
  append("preview_file", args.freeze)
348
362
  end
349
363
 
@@ -560,6 +574,15 @@ module Phlex
560
574
  "#{self.class}: preview_file max_bytes: must be a positive Integer, got #{bytes.inspect}"
561
575
  end
562
576
 
577
+ # per_file: is a strict boolean (nil, like the other keywords, means
578
+ # omitted): a typo'd truthy value must not change which files an upload posts.
579
+ def preview_per_file(per_file)
580
+ return per_file == true if [true, false, nil].include?(per_file)
581
+
582
+ raise ArgumentError,
583
+ "#{self.class}: preview_file per_file: must be true, false or nil, got #{per_file.inspect}"
584
+ end
585
+
563
586
  # true reads the input's accept attribute; a String is the list itself.
564
587
  def preview_accept(accept)
565
588
  return accept if accept == true || (accept.is_a?(String) && !accept.strip.empty?)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Phlex
4
4
  module Reactive
5
- VERSION = "0.16.5"
5
+ VERSION = "0.16.7"
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.7
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