phlex-reactive 0.16.6 → 0.16.8
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 +70 -1
- data/README.md +62 -6
- 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/features/bindings.js +52 -17
- data/app/javascript/phlex/reactive/features/bindings.min.js +2 -2
- data/app/javascript/phlex/reactive/features/bindings.min.js.map +3 -3
- data/app/javascript/phlex/reactive/features/compute.js +32 -4
- data/app/javascript/phlex/reactive/features/compute.min.js +2 -2
- data/app/javascript/phlex/reactive/features/compute.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 +5 -5
- data/app/javascript/phlex/reactive/runtime.js +107 -23
- data/lib/phlex/reactive/js.rb +25 -2
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
|
@@ -136,6 +136,20 @@ export function registerReactiveToken() {
|
|
|
136
136
|
// is emitted AFTER all render streams in the reply (the endpoint appends it
|
|
137
137
|
// last), so focus("[name=next]") sees the freshly morphed DOM — Turbo applies
|
|
138
138
|
// streams in document order.
|
|
139
|
+
//
|
|
140
|
+
// Focus ops wait one frame (issue #446). Turbo renders a stream message inside
|
|
141
|
+
// its focus preservation: it reads document.activeElement, appends the
|
|
142
|
+
// streams, waits one animation frame, reads it again and, when the element
|
|
143
|
+
// focused before has an id and focus moved, focuses that element again. This
|
|
144
|
+
// action runs behind a frame each stream queued before that compare, so a
|
|
145
|
+
// focus, focus_first or blur applied here would be undone, and the old
|
|
146
|
+
// field's focus binding would run again. The ops before the first focus op
|
|
147
|
+
// still run now; that op and the rest of its chain (order kept) run in a frame
|
|
148
|
+
// queued from here, the one after the compare, which then saw nothing move and
|
|
149
|
+
// restored nothing. Plain requestAnimationFrame, never a timeout: in a hidden
|
|
150
|
+
// tab Turbo's compare waits for the first frame too.
|
|
151
|
+
const FOCUS_OPS = ["focus", "focus_first", "blur"]
|
|
152
|
+
|
|
139
153
|
export function registerReactiveJs() {
|
|
140
154
|
const actions = window.Turbo?.StreamActions
|
|
141
155
|
if (!actions || actions["reactive:js"]) return
|
|
@@ -155,16 +169,21 @@ export function registerReactiveJs() {
|
|
|
155
169
|
}
|
|
156
170
|
return
|
|
157
171
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
172
|
+
const run = (ops) =>
|
|
173
|
+
applyOps(
|
|
174
|
+
ops,
|
|
175
|
+
(args) => streamOpTargets(args, root),
|
|
176
|
+
verbose
|
|
177
|
+
? (name, args) =>
|
|
178
|
+
__SPLIT__
|
|
179
|
+
? withFeature("devtools", (devtools) => devtools.diagnoseStream(name, args, root))
|
|
180
|
+
: devtoolsFeature.diagnoseStream(name, args, root)
|
|
181
|
+
: undefined,
|
|
182
|
+
)
|
|
183
|
+
const at = list.findIndex((entry) => FOCUS_OPS.includes(entry?.[0]))
|
|
184
|
+
if (at < 0 || typeof requestAnimationFrame !== "function") return run(list)
|
|
185
|
+
run(list.slice(0, at))
|
|
186
|
+
requestAnimationFrame(() => run(list.slice(at)))
|
|
168
187
|
}
|
|
169
188
|
}
|
|
170
189
|
|
|
@@ -1490,16 +1509,36 @@ function previewFile(input, args, resolveTargets) {
|
|
|
1490
1509
|
// `invalid` event fired into the app's own listeners.
|
|
1491
1510
|
const files = [...(args.valid === true && input.validity?.valid === false ? [] : (input.files ?? []))]
|
|
1492
1511
|
const accepts = previewAccepts(input, args.accept)
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1512
|
+
// Size is read BEFORE an object URL exists, so an over-size file never gets one.
|
|
1513
|
+
const oversize = (f) => Number.isInteger(args.max_bytes) && f.size > args.max_bytes
|
|
1514
|
+
const rejected = []
|
|
1515
|
+
let file
|
|
1516
|
+
if (args.per_file === true && input.multiple) {
|
|
1517
|
+
// Several files (issue #439): every file is checked, accept then
|
|
1518
|
+
// max_bytes. The refused ones leave input.files here, synchronously, so an
|
|
1519
|
+
// action bound after the op on the same event posts only the kept ones.
|
|
1520
|
+
const kept = files.filter((f, index) => {
|
|
1521
|
+
const reason = typeof f?.type === "string" && accepts(f) ? oversize(f) && "size" : "type"
|
|
1522
|
+
if (reason) rejected.push({ reason, file: f, index })
|
|
1523
|
+
return !reason
|
|
1524
|
+
})
|
|
1525
|
+
// remaining: the files the input keeps, and so the ones an action posts.
|
|
1526
|
+
const remaining = rejected.length > 0 && !keepFiles(input, kept) ? files.length : kept.length
|
|
1527
|
+
for (const detail of rejected) detail.remaining = remaining
|
|
1528
|
+
file = kept.find((f) => f.type.startsWith("image/"))
|
|
1529
|
+
} else {
|
|
1530
|
+
const listed = files.filter((f) => typeof f?.type === "string" && accepts(f))
|
|
1531
|
+
// Only an image is painted: a listed PDF clears the preview, unrefused.
|
|
1532
|
+
file = listed.find((f) => f.type.startsWith("image/"))
|
|
1533
|
+
// A refused file (issue #391) clears like an empty pick and says why. The
|
|
1534
|
+
// input keeps its files and its validity: the page owns the reset.
|
|
1535
|
+
let reason = files.length > 0 && listed.length === 0 ? "type" : null
|
|
1536
|
+
if (file && oversize(file)) reason = "size"
|
|
1537
|
+
if (reason) {
|
|
1538
|
+
rejected.push({ reason, file: reason === "size" ? file : files[0] })
|
|
1539
|
+
file = null
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1503
1542
|
forgetPreview(img)
|
|
1504
1543
|
if (file) {
|
|
1505
1544
|
const url = URL.createObjectURL(file)
|
|
@@ -1516,9 +1555,23 @@ function previewFile(input, args, resolveTargets) {
|
|
|
1516
1555
|
if (args.name != null) {
|
|
1517
1556
|
for (const node of resolveTargets({ ...args, to: args.name })) node.textContent = file ? file.name : ""
|
|
1518
1557
|
}
|
|
1519
|
-
|
|
1520
|
-
|
|
1558
|
+
for (const detail of rejected) input.dispatchEvent(new CustomEvent("reactive:file-rejected", { bubbles: true, detail }))
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
// Rebuild input.files from the kept files (issue #439); true once it did. A
|
|
1562
|
+
// DataTransfer is the one way to make a FileList; without one the refused
|
|
1563
|
+
// files stay in the input (the rejections still fire), and a verbose root is told.
|
|
1564
|
+
function keepFiles(input, kept) {
|
|
1565
|
+
if (typeof DataTransfer !== "function") {
|
|
1566
|
+
if (input.closest?.('[data-reactive-verbose="true"], [data-reactive-debug="true"]')) {
|
|
1567
|
+
warnPreview("per_file: has no DataTransfer to drop the refused files from the input")
|
|
1568
|
+
}
|
|
1569
|
+
return false
|
|
1521
1570
|
}
|
|
1571
|
+
const transfer = new DataTransfer()
|
|
1572
|
+
for (const f of kept) transfer.items.add(f)
|
|
1573
|
+
input.files = transfer.files
|
|
1574
|
+
return true
|
|
1522
1575
|
}
|
|
1523
1576
|
|
|
1524
1577
|
// Which files the page takes: an image by default. accept: true reads the
|
|
@@ -2012,6 +2065,10 @@ function streamOpTargets(args, root) {
|
|
|
2012
2065
|
// core.owns(el) is this control this root's own, not
|
|
2013
2066
|
// a nested reactive root's (issue #15)
|
|
2014
2067
|
// core.ownership() the hoisted per-pass form of owns
|
|
2068
|
+
// core.bindingOwns(el) may this root's bindings READ this
|
|
2069
|
+
// control: owns, or a shared field
|
|
2070
|
+
// just below (issue #440)
|
|
2071
|
+
// core.bindingOwnership() the hoisted per-pass form of it
|
|
2015
2072
|
// core.opTargets(args) the elements an op's `to:` names
|
|
2016
2073
|
// core.applyOps(list, defaultTo) run an op list root-scoped
|
|
2017
2074
|
// core.diagnose(label, args) the zero-target warning
|
|
@@ -2957,6 +3014,8 @@ export default class extends Controller {
|
|
|
2957
3014
|
},
|
|
2958
3015
|
owns: (el) => this.#ownsField(el),
|
|
2959
3016
|
ownership: () => this.#ownershipFilter(),
|
|
3017
|
+
bindingOwns: (el) => this.#bindingOwns(el),
|
|
3018
|
+
bindingOwnership: () => this.#bindingOwnership(),
|
|
2960
3019
|
opTargets: (args) => this.#opTargets(args),
|
|
2961
3020
|
diagnose: (label, args) => this.#diagnoseZeroTargets(label, args),
|
|
2962
3021
|
// Apply an op list root-scoped, with the zero-target diagnostics; a
|
|
@@ -4363,7 +4422,8 @@ export default class extends Controller {
|
|
|
4363
4422
|
// the control's own server root sits directly below this one. One level: a
|
|
4364
4423
|
// grand-host never sees it. Whatever reads #collectFields sees it too: the
|
|
4365
4424
|
// request, and the conditional confirm (it judges the fields the action is
|
|
4366
|
-
// about to post).
|
|
4425
|
+
// about to post). The client bindings read such a control too
|
|
4426
|
+
// (#bindingOwnership); listnav, ops, busy_on, dirty tracking and persist
|
|
4367
4427
|
// keep the strict nearest-root #ownershipFilter / #ownsField.
|
|
4368
4428
|
#serverFieldFilter() {
|
|
4369
4429
|
const nested = this.element.querySelectorAll(AWAKE_ROOT)
|
|
@@ -4376,6 +4436,30 @@ export default class extends Controller {
|
|
|
4376
4436
|
}
|
|
4377
4437
|
}
|
|
4378
4438
|
|
|
4439
|
+
// What this root's client BINDINGS read (issue #440): its own controls, as
|
|
4440
|
+
// #ownsField, plus a control marked data-reactive-shared-field whose own
|
|
4441
|
+
// server root is this one (under client-only roots, issue #410) or sits
|
|
4442
|
+
// directly below it: one level, the #serverFieldFilter rule. Field reads
|
|
4443
|
+
// only: show (every term, after:, clear:, disable: true), enable, show and
|
|
4444
|
+
// group targets, checked_* counts, on_complete, media, css vars, compute
|
|
4445
|
+
// inputs and outputs. Which binding ELEMENTS a root evaluates, and every op, listnav,
|
|
4446
|
+
// busy_on, dirty and persist read, stay on the strict #ownsField. An
|
|
4447
|
+
// unmarked control in a nested root costs one attribute read more.
|
|
4448
|
+
#bindingOwns(el) {
|
|
4449
|
+
if (this.#ownsField(el)) return true
|
|
4450
|
+
if (el.getAttribute?.("data-reactive-shared-field") == null) return false
|
|
4451
|
+
const own = serverRoot(el, AWAKE_ROOT, this.element)
|
|
4452
|
+
return own === this.element || serverRoot(own?.parentElement, AWAKE_ROOT, this.element) === this.element
|
|
4453
|
+
}
|
|
4454
|
+
|
|
4455
|
+
// The hoisted per-pass form of #bindingOwns, with #ownershipFilter's fast
|
|
4456
|
+
// path: no nested root, every control is this root's. Never cached on the
|
|
4457
|
+
// instance (a morph replaces nodes).
|
|
4458
|
+
#bindingOwnership() {
|
|
4459
|
+
if (this.element.querySelectorAll(AWAKE_ROOT).length === 0) return () => true
|
|
4460
|
+
return (el) => this.#bindingOwns(el)
|
|
4461
|
+
}
|
|
4462
|
+
|
|
4379
4463
|
// One walk over THIS root's named controls (not a nested reactive root's),
|
|
4380
4464
|
// returning both the scalar `fields` and any chosen `files`. The ownership
|
|
4381
4465
|
// predicate is hoisted ONCE (issue #117) via #ownershipFilter — in the common
|
data/lib/phlex/reactive/js.rb
CHANGED
|
@@ -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
|
-
|
|
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?)
|