phlex-reactive 0.16.0 → 0.16.2
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 +282 -145
- data/README.md +85 -1
- data/app/controllers/phlex/reactive/actions_controller.rb +10 -0
- data/app/javascript/phlex/reactive/core.js +1 -0
- data/app/javascript/phlex/reactive/core.min.js +4 -4
- data/app/javascript/phlex/reactive/core.min.js.map +4 -4
- data/app/javascript/phlex/reactive/features/bindings.js +81 -4
- 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/drag.js +522 -0
- data/app/javascript/phlex/reactive/features/drag.min.js +4 -0
- data/app/javascript/phlex/reactive/features/drag.min.js.map +10 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +2 -0
- data/app/javascript/phlex/reactive/reactive_controller.min.js +4 -4
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +7 -6
- data/app/javascript/phlex/reactive/runtime.js +57 -23
- data/lib/phlex/reactive/component/css_vars_binding.rb +118 -0
- data/lib/phlex/reactive/component/drag.rb +270 -0
- data/lib/phlex/reactive/component/dsl.rb +3 -0
- data/lib/phlex/reactive/component/helpers.rb +5 -1
- data/lib/phlex/reactive/component/registry.rb +15 -5
- data/lib/phlex/reactive/engine.rb +14 -5
- data/lib/phlex/reactive/held_fields.rb +21 -1
- data/lib/phlex/reactive/param_schema.rb +5 -1
- data/lib/phlex/reactive/response.rb +55 -4
- data/lib/phlex/reactive/shareable.rb +18 -3
- data/lib/phlex/reactive/test_helpers.rb +6 -0
- data/lib/phlex/reactive/version.rb +1 -1
- data/sig/phlex/reactive/response.rbs +3 -0
- metadata +6 -1
|
@@ -36,6 +36,7 @@ import * as devtoolsFeature from "phlex/reactive/features/devtools"
|
|
|
36
36
|
import * as hintsFeature from "phlex/reactive/features/hints"
|
|
37
37
|
import * as deferFeature from "phlex/reactive/features/defer"
|
|
38
38
|
import * as sortableFeature from "phlex/reactive/features/sortable"
|
|
39
|
+
import * as dragFeature from "phlex/reactive/features/drag"
|
|
39
40
|
import * as presenceFeature from "phlex/reactive/features/presence"
|
|
40
41
|
import * as countdownFeature from "phlex/reactive/features/countdown"
|
|
41
42
|
|
|
@@ -735,8 +736,9 @@ export function registerReactiveMorphFocus() {
|
|
|
735
736
|
// through later morphs while focused, like `value`), a bubbling reactive:held
|
|
736
737
|
// { name, theirs } is dispatched, and the root's next action sends the names as
|
|
737
738
|
// X-Reactive-Held. The root's own reply never marks (it answers exactly those
|
|
738
|
-
// values), and once applied the root holds nothing
|
|
739
|
-
//
|
|
739
|
+
// values), and once applied the root holds nothing — unless it refused them
|
|
740
|
+
// (X-Reactive-Refused, issue #413): then the marks it names stay. A morph of
|
|
741
|
+
// the field while unfocused writes the server's value and drops the mark with it.
|
|
740
742
|
const HELD = "data-reactive-held"
|
|
741
743
|
const ROOT = '[data-controller~="reactive"]'
|
|
742
744
|
let replyRoot = null
|
|
@@ -781,18 +783,35 @@ function heldHeader(root) {
|
|
|
781
783
|
return JSON.stringify(names).replace(/[\u007f-]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`)
|
|
782
784
|
}
|
|
783
785
|
|
|
784
|
-
|
|
785
|
-
|
|
786
|
+
// `keep`: true keeps every mark, a list keeps the marks of those names.
|
|
787
|
+
function releaseHeld(root, keep) {
|
|
788
|
+
if (keep === true) return
|
|
789
|
+
for (const el of root.querySelectorAll(`[${HELD}]`)) {
|
|
790
|
+
if (el.closest(ROOT) === root && !keep?.includes(el.getAttribute("name"))) el.removeAttribute(HELD)
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// What a reply refused (issue #413): "*" keeps every mark, a JSON list the
|
|
795
|
+
// marks of those names. Anything else reads as a plain reply.
|
|
796
|
+
function refusedHeader(response) {
|
|
797
|
+
const raw = response.headers.get("X-Reactive-Refused")
|
|
798
|
+
if (raw === "*") return true
|
|
799
|
+
try {
|
|
800
|
+
const names = JSON.parse(raw)
|
|
801
|
+
return Array.isArray(names) ? names : null
|
|
802
|
+
} catch {
|
|
803
|
+
return null
|
|
804
|
+
}
|
|
786
805
|
}
|
|
787
806
|
|
|
788
807
|
// Hand a root's own reply to Turbo: while its streams render nothing in the
|
|
789
|
-
// root is marked, and once they have the root holds nothing
|
|
790
|
-
// each stream synchronously inside renderStreamMessage
|
|
791
|
-
// its render, innermost); a reply none of whose streams
|
|
792
|
-
// An error reply (
|
|
793
|
-
// server refused those values rather than answering them.
|
|
794
|
-
function renderOwnReply(root, html,
|
|
795
|
-
const own = { root, streams: 0,
|
|
808
|
+
// root is marked, and once they have the root holds nothing but what `keep`
|
|
809
|
+
// names. Turbo announces each stream synchronously inside renderStreamMessage
|
|
810
|
+
// (streamOwnReply wraps its render, innermost); a reply none of whose streams
|
|
811
|
+
// rendered releases now. An error reply (keep true) never marks either, but
|
|
812
|
+
// releases nothing: the server refused those values rather than answering them.
|
|
813
|
+
function renderOwnReply(root, html, keep = null) {
|
|
814
|
+
const own = { root, streams: 0, keep }
|
|
796
815
|
const previous = replyRoot
|
|
797
816
|
replyRoot = own
|
|
798
817
|
try {
|
|
@@ -800,7 +819,7 @@ function renderOwnReply(root, html, release = true) {
|
|
|
800
819
|
} finally {
|
|
801
820
|
replyRoot = previous
|
|
802
821
|
}
|
|
803
|
-
if (
|
|
822
|
+
if (own.streams === 0) releaseHeld(root, keep)
|
|
804
823
|
}
|
|
805
824
|
|
|
806
825
|
function streamOwnReply(event) {
|
|
@@ -817,7 +836,7 @@ function streamOwnReply(event) {
|
|
|
817
836
|
const left = replying.get(own.root) - 1
|
|
818
837
|
if (left) replying.set(own.root, left)
|
|
819
838
|
else replying.delete(own.root)
|
|
820
|
-
|
|
839
|
+
releaseHeld(own.root, own.keep)
|
|
821
840
|
}
|
|
822
841
|
}
|
|
823
842
|
}
|
|
@@ -2060,18 +2079,18 @@ function formNeeded(root) {
|
|
|
2060
2079
|
// Show bindings and cross-root show targets, completion bindings, option
|
|
2061
2080
|
// filtering, the bulk-selection group bindings (enable, select-all, count —
|
|
2062
2081
|
// issue #319) and their cross-root targets (#343), the tag-chip input, draft
|
|
2063
|
-
// nested rows, editable text's keys and paste (#351)
|
|
2064
|
-
// (#373)
|
|
2065
|
-
//
|
|
2066
|
-
// the split, unchanged.)
|
|
2082
|
+
// nested rows, editable text's keys and paste (#351), media previews
|
|
2083
|
+
// (#373) and fields fed into CSS custom properties (#417): a form's
|
|
2084
|
+
// client-only bindings. The root declares one, or owns an element that does.
|
|
2085
|
+
// (The probes the connect-time gates ran before the split, unchanged.)
|
|
2067
2086
|
function bindingsNeeded(root) {
|
|
2068
2087
|
return (
|
|
2069
|
-
["show-targets", "group-targets", "on-complete", "filter-input", "tags-field"].some(
|
|
2088
|
+
["show-targets", "group-targets", "on-complete", "filter-input", "tags-field", "css-vars"].some(
|
|
2070
2089
|
(marker) => root.getAttribute?.(`data-reactive-${marker}`) != null,
|
|
2071
2090
|
) ||
|
|
2072
2091
|
(root.querySelectorAll?.("[data-reactive-show-field], [data-reactive-show]") ?? []).length > 0 ||
|
|
2073
2092
|
root.querySelector?.(
|
|
2074
|
-
"[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable], [data-reactive-media]",
|
|
2093
|
+
"[data-reactive-nested-json], [data-reactive-nested-list], [data-reactive-confirm-when-param], [data-reactive-enable], [data-reactive-select-all], [data-reactive-count], [data-reactive-editable], [data-reactive-media], [data-reactive-css-vars]",
|
|
2075
2094
|
) != null
|
|
2076
2095
|
)
|
|
2077
2096
|
}
|
|
@@ -2089,6 +2108,14 @@ function sortableNeeded(root) {
|
|
|
2089
2108
|
return Boolean(root.matches?.(lists) || root.querySelector?.(lists))
|
|
2090
2109
|
}
|
|
2091
2110
|
|
|
2111
|
+
// Draggable regions (issue #416): the root is, or holds, a frame, a box or a
|
|
2112
|
+
// point. (A nested root's frame loads the module for its parent too; the
|
|
2113
|
+
// module then checks ownership before it drags anything.)
|
|
2114
|
+
function dragNeeded(root) {
|
|
2115
|
+
const marks = "[data-reactive-frame],[data-reactive-box],[data-reactive-point]"
|
|
2116
|
+
return Boolean(root.matches?.(marks) || root.querySelector?.(marks))
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2092
2119
|
// Presence (issue #358): the root is a reactive_presence roster.
|
|
2093
2120
|
function presenceNeeded(root) {
|
|
2094
2121
|
return root.getAttribute?.("data-reactive-presence") != null
|
|
@@ -2111,6 +2138,8 @@ const PRODUCTION_FEATURES = [
|
|
|
2111
2138
|
["compute", [computeNeeded, null, null, true]],
|
|
2112
2139
|
// Nothing it does at connect is read by a request: it never gates.
|
|
2113
2140
|
["sortable", [sortableNeeded, null]],
|
|
2141
|
+
// A drag writes fields only when the user drags: it never gates.
|
|
2142
|
+
["drag", [dragNeeded, null]],
|
|
2114
2143
|
// Its announce is a request of its own; nothing it does at connect is read
|
|
2115
2144
|
// by another request: it never gates.
|
|
2116
2145
|
["presence", [presenceNeeded, null]],
|
|
@@ -2279,7 +2308,7 @@ function featureTimeoutMs() {
|
|
|
2279
2308
|
export function reactiveFeatureNames() {
|
|
2280
2309
|
return __SPLIT__
|
|
2281
2310
|
? [...FEATURES.keys()]
|
|
2282
|
-
: ["persist", "defer", "form", "bindings", "compute", "sortable", "presence", "countdown", "effects", "hints", "devtools"]
|
|
2311
|
+
: ["persist", "defer", "form", "bindings", "compute", "sortable", "drag", "presence", "countdown", "effects", "hints", "devtools"]
|
|
2283
2312
|
}
|
|
2284
2313
|
|
|
2285
2314
|
export function __setReactiveFeatureForTest(name, needs, load, waiting, gates) {
|
|
@@ -2302,6 +2331,7 @@ export function __loadReactiveFeatureForTest(name) {
|
|
|
2302
2331
|
bindings: bindingsFeature,
|
|
2303
2332
|
compute: computeFeature,
|
|
2304
2333
|
sortable: sortableFeature,
|
|
2334
|
+
drag: dragFeature,
|
|
2305
2335
|
presence: presenceFeature,
|
|
2306
2336
|
countdown: countdownFeature,
|
|
2307
2337
|
effects: effectsFeature,
|
|
@@ -2359,6 +2389,7 @@ const COMPUTE = 16
|
|
|
2359
2389
|
const SORTABLE = 32
|
|
2360
2390
|
const PRESENCE = 64
|
|
2361
2391
|
const COUNTDOWN = 128
|
|
2392
|
+
const DRAG = 256
|
|
2362
2393
|
|
|
2363
2394
|
// The default entry's features are part of this file: install them now, as
|
|
2364
2395
|
// the opt-in entry does when one arrives.
|
|
@@ -2687,6 +2718,7 @@ export default class extends Controller {
|
|
|
2687
2718
|
this.#connectFeatureNow(BINDINGS, "bindings", bindingsNeeded, (core) => bindingsFeature.connect(this, core, morphed))
|
|
2688
2719
|
this.#connectFeatureNow(COMPUTE, "compute", computeNeeded, (core) => computeFeature.connect(this, core, morphed))
|
|
2689
2720
|
this.#connectFeatureNow(SORTABLE, "sortable", sortableNeeded, (core) => sortableFeature.connect(this, core))
|
|
2721
|
+
this.#connectFeatureNow(DRAG, "drag", dragNeeded, (core) => dragFeature.connect(this, core))
|
|
2690
2722
|
this.#connectFeatureNow(PRESENCE, "presence", presenceNeeded, (core) => presenceFeature.connect(this, core))
|
|
2691
2723
|
this.#connectFeatureNow(COUNTDOWN, "countdown", countdownNeeded, (core) => countdownFeature.connect(this, core))
|
|
2692
2724
|
}
|
|
@@ -2726,6 +2758,7 @@ export default class extends Controller {
|
|
|
2726
2758
|
if (on & BINDINGS) this.#disconnectFeatureNow("bindings", () => bindingsFeature.disconnect(this, core))
|
|
2727
2759
|
if (on & COMPUTE) this.#disconnectFeatureNow("compute", () => computeFeature.disconnect(this, core))
|
|
2728
2760
|
if (on & SORTABLE) this.#disconnectFeatureNow("sortable", () => sortableFeature.disconnect(this, core))
|
|
2761
|
+
if (on & DRAG) this.#disconnectFeatureNow("drag", () => dragFeature.disconnect(this, core))
|
|
2729
2762
|
if (on & PRESENCE) this.#disconnectFeatureNow("presence", () => presenceFeature.disconnect(this, core))
|
|
2730
2763
|
if (on & COUNTDOWN) this.#disconnectFeatureNow("countdown", () => countdownFeature.disconnect(this, core))
|
|
2731
2764
|
}
|
|
@@ -4096,7 +4129,7 @@ export default class extends Controller {
|
|
|
4096
4129
|
if (__SPLIT__) this.#devtools((devtools) => devtools.recordBody(debug, errorBody, fresh))
|
|
4097
4130
|
else devtoolsFeature.recordBody(debug, errorBody, fresh)
|
|
4098
4131
|
}
|
|
4099
|
-
renderOwnReply(this.element, errorBody,
|
|
4132
|
+
renderOwnReply(this.element, errorBody, true)
|
|
4100
4133
|
}
|
|
4101
4134
|
this.#markError("http")
|
|
4102
4135
|
this.#emitError(action, params, allParams, { kind: "http", status: response.status, body: errorBody })
|
|
@@ -4128,8 +4161,9 @@ export default class extends Controller {
|
|
|
4128
4161
|
// outerHTML swap (focus on the replaced subtree is lost); a method="morph"
|
|
4129
4162
|
// replace (Response.morph) or an update morphs in place, preserving the
|
|
4130
4163
|
// focused input + caret on unchanged nodes — see issue #28. As the root's
|
|
4131
|
-
// own reply, it clears what the root held (issue #370)
|
|
4132
|
-
|
|
4164
|
+
// own reply, it clears what the root held (issue #370), but what it
|
|
4165
|
+
// refused (issue #413).
|
|
4166
|
+
renderOwnReply(this.element, html, refusedHeader(response))
|
|
4133
4167
|
// Debug-only (issue #181): the morph may apply a microtask later, so check
|
|
4134
4168
|
// the resurrected-hide case AFTER it lands. Off the debug path, resurrect is
|
|
4135
4169
|
// null — zero cost.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phlex
|
|
4
|
+
module Reactive
|
|
5
|
+
module Component
|
|
6
|
+
# Fields that feed CSS custom properties on an element (issue #417): a
|
|
7
|
+
# preview that follows a control, with no request.
|
|
8
|
+
#
|
|
9
|
+
# div(**mix(reactive_root(id: "light"), reactive_css_vars(to: "#sample", vars: {
|
|
10
|
+
# "--light" => "light", # field name -> custom property
|
|
11
|
+
# "--turn" => { field: "turn", unit: "deg" } # with a unit from UNITS
|
|
12
|
+
# }))) do
|
|
13
|
+
# input(type: "range", name: "light", min: "0.5", max: "1.5", step: "0.01", value: "1")
|
|
14
|
+
# img(id: "sample", alt: "", style: "--light: 1; filter: brightness(var(--light, 1))")
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# The client reads each field the way reactive_show does (owned fields, a
|
|
18
|
+
# radio group's checked value, reactive_scope applied; a checkbox gives 1
|
|
19
|
+
# or 0), parses it as a finite number and writes
|
|
20
|
+
# style.setProperty(name, number + unit) on every `to:` element. Anything
|
|
21
|
+
# else (empty, text, NaN) removes the property, so the author's var()
|
|
22
|
+
# fallback applies. No string from a field ever reaches CSS: the wire
|
|
23
|
+
# carries names and units, never a value. It runs on connect, on the
|
|
24
|
+
# fields' input/change, and after a morph. First paint is the author's:
|
|
25
|
+
# render the same properties inline.
|
|
26
|
+
#
|
|
27
|
+
# `to:` resolves like a client op's: inside the root (nested roots
|
|
28
|
+
# excluded), `:root` for the root itself, document-wide with
|
|
29
|
+
# `global: true`; an Array names several. The attribute goes on the root
|
|
30
|
+
# or on any element inside it; `to:` is resolved from the root either way.
|
|
31
|
+
# Client-only: no token, no round trip, no broadcast.
|
|
32
|
+
module CssVarsBinding
|
|
33
|
+
# Prefixed: this module sits in every reactive component's ancestry, so
|
|
34
|
+
# a bare NAME or UNITS would shadow a host app's top-level constant.
|
|
35
|
+
CSS_VAR_NAME = /\A--[a-z0-9-]+\z/i
|
|
36
|
+
CSS_VAR_UNITS = ["", "px", "%", "deg", "turn", "ms", "s", "em", "rem", "fr"].freeze
|
|
37
|
+
private_constant :CSS_VAR_NAME, :CSS_VAR_UNITS
|
|
38
|
+
|
|
39
|
+
def reactive_css_vars(to:, vars:, global: false, **attrs)
|
|
40
|
+
wire = { "to" => css_vars_targets!(to), "vars" => css_vars_map!(vars) }
|
|
41
|
+
wire["global"] = true if global
|
|
42
|
+
mix({ data: { reactive_css_vars: wire.to_json } }, attrs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def css_vars_targets!(to)
|
|
48
|
+
targets = Array(to)
|
|
49
|
+
if targets.empty? || targets.any? { !it.equal?(:root) && (!it.is_a?(String) || it.strip.empty?) }
|
|
50
|
+
raise ArgumentError, "reactive_css_vars to: takes :root, a CSS selector String (e.g. \"#sample\") " \
|
|
51
|
+
"or an Array of them, got #{to.inspect}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
targets.map { JS.normalize_target(it) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def css_vars_map!(vars)
|
|
58
|
+
unless vars.is_a?(Hash) && !vars.empty?
|
|
59
|
+
raise ArgumentError, "reactive_css_vars vars: takes a non-empty Hash of custom property => field " \
|
|
60
|
+
"(e.g. vars: { \"--light\" => \"light\" }), got #{vars.inspect}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
vars.each_with_object({}) do |(name, spec), map|
|
|
64
|
+
name = css_var_name!(name)
|
|
65
|
+
raise ArgumentError, "reactive_css_vars vars: names #{name} twice" if map.key?(name)
|
|
66
|
+
|
|
67
|
+
map[name] = css_var_spec!(name, spec)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def css_var_name!(name)
|
|
72
|
+
string = name.to_s if name.is_a?(String) || name.is_a?(Symbol)
|
|
73
|
+
return string if string&.match?(CSS_VAR_NAME)
|
|
74
|
+
|
|
75
|
+
raise ArgumentError, "reactive_css_vars vars: #{name.inspect} is not a custom property name: it starts " \
|
|
76
|
+
"with -- and holds only letters, digits and dashes (e.g. \"--light\")"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def css_var_spec!(name, spec)
|
|
80
|
+
return { "field" => css_var_field!(name, spec) } unless spec.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
unknown = spec.keys - %i[field unit]
|
|
83
|
+
unless unknown.empty?
|
|
84
|
+
raise ArgumentError, "reactive_css_vars vars: #{name} takes only field: and unit:, got #{unknown.inspect}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
wire = { "field" => css_var_field!(name, spec.fetch(:field, nil)) }
|
|
88
|
+
unit = css_var_unit!(name, spec.fetch(:unit, ""))
|
|
89
|
+
wire["unit"] = unit unless unit.empty?
|
|
90
|
+
wire
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The client reads the field through a [name="…"] selector, which a
|
|
94
|
+
# double quote, backslash or control character would break (the
|
|
95
|
+
# verbatim_name_selector! rule).
|
|
96
|
+
def css_var_field!(name, field)
|
|
97
|
+
string = field.to_s if field.is_a?(String) || field.is_a?(Symbol)
|
|
98
|
+
if string&.match?(/["\\\x00-\x1f]/)
|
|
99
|
+
raise ArgumentError, "reactive_css_vars vars: #{name} field can't contain a double quote, backslash, " \
|
|
100
|
+
"or control character (it is read through a [name=\"…\"] selector), got #{field.inspect}"
|
|
101
|
+
end
|
|
102
|
+
return string unless string.nil? || string.strip.empty?
|
|
103
|
+
|
|
104
|
+
raise ArgumentError, "reactive_css_vars vars: #{name} needs the field name to read " \
|
|
105
|
+
"(e.g. \"#{name}\" => \"light\" or { field: \"light\", unit: \"px\" }), got #{field.inspect}"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def css_var_unit!(name, unit)
|
|
109
|
+
string = unit.to_s if unit.is_a?(String) || unit.is_a?(Symbol)
|
|
110
|
+
return string if CSS_VAR_UNITS.include?(string)
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, "reactive_css_vars vars: #{name} unit: must be one of " \
|
|
113
|
+
"#{CSS_VAR_UNITS.map(&:inspect).join(", ")}, got #{unit.inspect}"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# DRAG_MESSAGES_JSON is built with Hash#to_json while this file loads.
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Phlex
|
|
7
|
+
module Reactive
|
|
8
|
+
module Component
|
|
9
|
+
# Draggable regions (issue #416): a box that moves and resizes inside a
|
|
10
|
+
# frame, or a point that moves inside it, with the result in fields as
|
|
11
|
+
# fractions of the frame — 0..1 on each axis, so a resize, a responsive
|
|
12
|
+
# layout or a morph changes nothing about them.
|
|
13
|
+
#
|
|
14
|
+
# div(**mix(reactive_frame, class: "relative")) do
|
|
15
|
+
# img(src: @photo.url, alt: "", class: "block w-full")
|
|
16
|
+
# div(**reactive_box(fields: { x: "crop[x]", y: "crop[y]", width: "crop[width]", height: "crop[height]" },
|
|
17
|
+
# value: @crop.to_h, aspect: "crop[aspect]")) do
|
|
18
|
+
# %i[nw n ne e se s sw w].each { span(**reactive_box_handle(it)) }
|
|
19
|
+
# span(**reactive_point(fields: { x: "focus[x]", y: "focus[y]" }, value: @focus.to_h, within: "[data-reactive-box]"))
|
|
20
|
+
# end
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# The box, its handles and the point render their position as inline
|
|
24
|
+
# percentages, so the first paint is right with no JavaScript and a
|
|
25
|
+
# re-render with new values moves them. The frame's padding box is the
|
|
26
|
+
# coordinate space; the frame must be positioned (relative or absolute),
|
|
27
|
+
# which the helper leaves to the author. Style the look with classes, and
|
|
28
|
+
# add any inline style AFTER the helper's: mix(reactive_box(...), style: "…").
|
|
29
|
+
#
|
|
30
|
+
# The client (features/drag.js) drags them with a mouse, a pen or a
|
|
31
|
+
# finger, and the keyboard: arrows move 1% (Shift: 10%), Alt+arrows
|
|
32
|
+
# resize from the bottom-right corner or from the focused handle, Escape
|
|
33
|
+
# puts the drag back, each step announced in the words of `messages:`.
|
|
34
|
+
# The fields get `input` as the box moves and one `change` on release.
|
|
35
|
+
# `action:` is sent once on release through the root's pipeline; without
|
|
36
|
+
# it the binding is client-only and works on a ClientBindings root.
|
|
37
|
+
module Drag
|
|
38
|
+
# The announcement words, {x}, {y}, {width} and {height} filled in by
|
|
39
|
+
# the client as whole percents. Override any of them with messages:.
|
|
40
|
+
DRAG_MESSAGES = {
|
|
41
|
+
moved: "Moved to {x}%, {y}%",
|
|
42
|
+
resized: "Resized to {width}% by {height}%",
|
|
43
|
+
cancelled: "Cancelled"
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
# Where each handle sits on the box (left, top) and the cursor it shows.
|
|
47
|
+
DRAG_HANDLES = {
|
|
48
|
+
nw: ["0%", "0%", "nwse"], n: ["50%", "0%", "ns"], ne: ["100%", "0%", "nesw"], e: ["100%", "50%", "ew"],
|
|
49
|
+
se: ["100%", "100%", "nwse"], s: ["50%", "100%", "ns"], sw: ["0%", "100%", "nesw"], w: ["0%", "50%", "ew"]
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
BOX_KEYS = %i[x y width height].freeze
|
|
53
|
+
POINT_KEYS = %i[x y].freeze
|
|
54
|
+
# A ratio written as a String: "16:9", "1.5".
|
|
55
|
+
RATIO_STRING = /\A\s*\d*\.?\d+\s*(:\s*\d*\.?\d+\s*)?\z/
|
|
56
|
+
# A box may end at the frame's edge as the fields round it: x and width
|
|
57
|
+
# rounded apart to 4 decimals can sum to 1.0001 (0.45545 + 0.54455).
|
|
58
|
+
EDGE_SLACK = 1e-4 + 1e-9
|
|
59
|
+
|
|
60
|
+
# The coordinate space: its padding box is 0..1 on each axis.
|
|
61
|
+
def reactive_frame = { data: { reactive_frame: true } }
|
|
62
|
+
|
|
63
|
+
# A box that moves and resizes inside the frame.
|
|
64
|
+
#
|
|
65
|
+
# fields: { x:, y:, width:, height: } — the names of the fields that hold it
|
|
66
|
+
# value: { x:, y:, width:, height: } — where it is, fractions of the frame
|
|
67
|
+
# aspect: a Numeric ratio (16/9r), or the name of a field holding one
|
|
68
|
+
# ("16:9", "1.5", or "" for free), read at drag start
|
|
69
|
+
# min: the smallest width/height, a fraction of the frame
|
|
70
|
+
# action: sent once on release, with x/y/width/height params
|
|
71
|
+
# busy: on(busy:)'s vocabulary, applied while that action is pending
|
|
72
|
+
# 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)
|
|
74
|
+
fields = drag_fields!(:reactive_box, fields, BOX_KEYS)
|
|
75
|
+
value = drag_value!(:reactive_box, value, BOX_KEYS)
|
|
76
|
+
drag_inside_frame!(value)
|
|
77
|
+
data = { reactive_box: fields.to_json, reactive_box_min: drag_min!(min) }
|
|
78
|
+
drag_aspect!(aspect, data) unless aspect.nil?
|
|
79
|
+
drag_action!(:reactive_box, action, busy, data)
|
|
80
|
+
data[:reactive_box_messages] = drag_messages!(:reactive_box, messages)
|
|
81
|
+
style = "position: absolute; box-sizing: border-box; left: #{drag_percent(value[:x])}; " \
|
|
82
|
+
"top: #{drag_percent(value[:y])}; width: #{drag_percent(value[:width])}; " \
|
|
83
|
+
"height: #{drag_percent(value[:height])}; touch-action: none; cursor: move;"
|
|
84
|
+
{ tabindex: 0, style:, data: }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# An edge or corner of the box (inside it) to resize from: nw n ne e se s sw w.
|
|
88
|
+
def reactive_box_handle(edge)
|
|
89
|
+
left, top, cursor = DRAG_HANDLES.fetch(edge.to_s.to_sym) do
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"reactive_box_handle got an unknown edge #{edge.inspect} — one of " \
|
|
92
|
+
"#{DRAG_HANDLES.keys.map(&:inspect).join(", ")}"
|
|
93
|
+
end
|
|
94
|
+
{ tabindex: -1,
|
|
95
|
+
style: "position: absolute; left: #{left}; top: #{top}; translate: -50% -50%; touch-action: none; " \
|
|
96
|
+
"cursor: #{cursor}-resize;",
|
|
97
|
+
data: { reactive_box_handle: edge.to_s } }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A point (its centre) that moves inside the frame — or, with within:,
|
|
101
|
+
# 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)
|
|
104
|
+
fields = drag_fields!(:reactive_point, fields, POINT_KEYS)
|
|
105
|
+
value = drag_value!(:reactive_point, value, POINT_KEYS)
|
|
106
|
+
data = { reactive_point: fields.to_json }
|
|
107
|
+
data[:reactive_point_within] = drag_within!(within) unless within.nil?
|
|
108
|
+
drag_action!(:reactive_point, action, busy, data)
|
|
109
|
+
data[:reactive_point_messages] = drag_messages!(:reactive_point, messages)
|
|
110
|
+
style = "position: absolute; left: #{drag_percent(value[:x])}; top: #{drag_percent(value[:y])}; " \
|
|
111
|
+
"translate: -50% -50%; touch-action: none; cursor: move;"
|
|
112
|
+
{ tabindex: 0, style:, data: }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
# A fraction as a percentage with at most 2 decimals, rounded as the
|
|
118
|
+
# client rounds (Math.round(v * 1e4) / 100): 0.1 -> "10%".
|
|
119
|
+
def drag_percent(fraction) = "#{((fraction * 10_000).round / 100.0).to_s.delete_suffix(".0")}%"
|
|
120
|
+
|
|
121
|
+
def drag_fields!(helper, fields, keys)
|
|
122
|
+
example = "fields: { #{keys.map { "#{it}: \"region[#{it}]\"" }.join(", ")} }"
|
|
123
|
+
raise ArgumentError, "#{helper} fields: must be a Hash, e.g. #{example}" unless fields.is_a?(Hash)
|
|
124
|
+
|
|
125
|
+
fields = fields.transform_keys { it.to_s.to_sym }
|
|
126
|
+
drag_keys!(helper, :fields, fields, keys, example)
|
|
127
|
+
fields.each do |key, name|
|
|
128
|
+
next if (name.is_a?(String) || name.is_a?(Symbol)) && !name.to_s.strip.empty?
|
|
129
|
+
|
|
130
|
+
raise ArgumentError, "#{helper} fields: #{key} needs a field name, got #{name.inspect}"
|
|
131
|
+
end
|
|
132
|
+
fields.slice(*keys).transform_values(&:to_s)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def drag_keys!(helper, option, hash, keys, example)
|
|
136
|
+
missing = keys - hash.keys
|
|
137
|
+
unless missing.empty?
|
|
138
|
+
raise ArgumentError,
|
|
139
|
+
"#{helper} #{option}: is missing #{missing.map(&:inspect).join(", ")} — e.g. #{example}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
unknown = hash.keys - keys
|
|
143
|
+
return if unknown.empty?
|
|
144
|
+
|
|
145
|
+
raise ArgumentError,
|
|
146
|
+
"#{helper} got an unknown #{option}: key #{unknown.first.inspect} — it takes #{keys.map(&:inspect).join(", ")}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def drag_value!(helper, value, keys)
|
|
150
|
+
raise ArgumentError, "#{helper} value: must be a Hash of fractions, got #{value.inspect}" unless value.is_a?(Hash)
|
|
151
|
+
|
|
152
|
+
value = value.transform_keys { it.to_s.to_sym }
|
|
153
|
+
drag_keys!(helper, :value, value, keys, "value: { #{keys.map { "#{it}: 0.5" }.join(", ")} }")
|
|
154
|
+
value.each do |key, number|
|
|
155
|
+
next if number.is_a?(Numeric) && number.real? && number.between?(0, 1)
|
|
156
|
+
|
|
157
|
+
raise ArgumentError, "#{helper} value: #{key} must be a number from 0 to 1 (a fraction), got #{number.inspect}"
|
|
158
|
+
end
|
|
159
|
+
value
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def drag_inside_frame!(value)
|
|
163
|
+
%i[width height].each do
|
|
164
|
+
raise ArgumentError, "reactive_box value: #{it} must be greater than 0" unless value[it].positive?
|
|
165
|
+
end
|
|
166
|
+
{ x: :width, y: :height }.each do |at, size|
|
|
167
|
+
sum = value[at] + value[size]
|
|
168
|
+
next if sum <= 1 + EDGE_SLACK
|
|
169
|
+
|
|
170
|
+
raise ArgumentError,
|
|
171
|
+
"reactive_box value: #{at} + #{size} is #{sum.round(4)} — the box must stay inside the frame (at most 1)"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def drag_min!(min)
|
|
176
|
+
# (A Float: a Rational would render as "1/10", which the browser cannot read.)
|
|
177
|
+
return min.to_f if min.is_a?(Numeric) && min.real? && min.positive? && min <= 1
|
|
178
|
+
|
|
179
|
+
raise ArgumentError,
|
|
180
|
+
"reactive_box min: must be a fraction of the frame above 0 and at most 1 (e.g. 0.05), got #{min.inspect}"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def drag_aspect!(aspect, data)
|
|
184
|
+
if aspect.is_a?(Numeric) && aspect.real? && aspect.positive? && aspect.finite?
|
|
185
|
+
data[:reactive_box_ratio] = aspect.to_f
|
|
186
|
+
elsif aspect.is_a?(String) && aspect.match?(RATIO_STRING)
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"reactive_box aspect: #{aspect.inspect} is a ratio, not a field name — pass 16/9r or 1.5 " \
|
|
189
|
+
"(a Numeric), or the name of a field whose value is \"16:9\""
|
|
190
|
+
elsif (aspect.is_a?(String) || aspect.is_a?(Symbol)) && !aspect.to_s.strip.empty?
|
|
191
|
+
data[:reactive_box_aspect] = aspect.to_s
|
|
192
|
+
else
|
|
193
|
+
raise ArgumentError,
|
|
194
|
+
"reactive_box aspect: must be a positive Numeric (16/9r) or the name of a field holding the ratio, " \
|
|
195
|
+
"got #{aspect.inspect}"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def drag_within!(within)
|
|
200
|
+
return within if within.is_a?(String) && !within.strip.empty?
|
|
201
|
+
|
|
202
|
+
raise ArgumentError,
|
|
203
|
+
"reactive_point within: needs a CSS selector matching the element the point sits inside, " \
|
|
204
|
+
"e.g. \"[data-reactive-box]\", got #{within.inspect}"
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def drag_action!(helper, action, busy, data)
|
|
208
|
+
if action.nil?
|
|
209
|
+
raise ArgumentError, "#{helper} busy: needs an action: — it is applied while that action is pending" if busy
|
|
210
|
+
|
|
211
|
+
return
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
drag_requires_component!(helper)
|
|
215
|
+
name = drag_action_name!(helper, action)
|
|
216
|
+
data[:"#{helper}_action"] = name
|
|
217
|
+
data[:"#{helper}_busy"] = pending_hint_json(busy, name, :busy) if busy
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def drag_requires_component!(helper)
|
|
221
|
+
return if self.class.include?(Phlex::Reactive::Component::Identity)
|
|
222
|
+
|
|
223
|
+
raise ArgumentError,
|
|
224
|
+
"#{helper} action: needs the full Phlex::Reactive::Component — a release calls a server action, " \
|
|
225
|
+
"which Phlex::Reactive::ClientBindings has no token for. Without action: the fields alone hold it."
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The on() courtesy (issue #105): an undeclared action fails at render
|
|
229
|
+
# under verbose_errors, not as a 403 on release.
|
|
230
|
+
def drag_action_name!(helper, action)
|
|
231
|
+
if action.to_s.strip.empty?
|
|
232
|
+
raise ArgumentError, "#{helper} action: needs the name of a declared action, got #{action.inspect}"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if Phlex::Reactive.verbose_errors &&
|
|
236
|
+
(actions = self.class.reactive_actions).any? && !actions.key?(action.to_sym)
|
|
237
|
+
raise Phlex::Reactive::Error,
|
|
238
|
+
"#{self.class} has no declared action #{action.to_sym.inspect} (declared: #{actions.keys.inspect})"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
action.to_s
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def drag_messages!(helper, messages)
|
|
245
|
+
return DRAG_MESSAGES_JSON if messages.nil?
|
|
246
|
+
raise ArgumentError, "#{helper} messages: must be a Hash, got #{messages.inspect}" unless messages.is_a?(Hash)
|
|
247
|
+
|
|
248
|
+
unknown = messages.keys.map { it.to_s.to_sym } - DRAG_MESSAGES.keys
|
|
249
|
+
unless unknown.empty?
|
|
250
|
+
raise ArgumentError,
|
|
251
|
+
"#{helper} got an unknown messages: key #{unknown.first.inspect} — " \
|
|
252
|
+
"supported: #{DRAG_MESSAGES.keys.map(&:inspect).join(", ")}"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
messages.each do |name, words|
|
|
256
|
+
unless words.is_a?(String)
|
|
257
|
+
raise ArgumentError,
|
|
258
|
+
"#{helper} messages: #{name} must be a String, got #{words.inspect}"
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
DRAG_MESSAGES.merge(messages.transform_keys { it.to_s.to_sym }).to_json
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
DRAG_MESSAGES_JSON = DRAG_MESSAGES.to_json
|
|
266
|
+
private_constant :DRAG_MESSAGES_JSON, :DRAG_HANDLES, :BOX_KEYS, :POINT_KEYS, :RATIO_STRING, :EDGE_SLACK
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
@@ -311,6 +311,9 @@ module Phlex
|
|
|
311
311
|
|
|
312
312
|
# Issue #184: params: :symbol resolves a registered named schema.
|
|
313
313
|
params = Phlex::Reactive.param_schema(params) if params.is_a?(Symbol)
|
|
314
|
+
# A deep-frozen copy, shared with the compiled schema below: the
|
|
315
|
+
# definition stays readable from a non-main Ractor once warmed.
|
|
316
|
+
params = Phlex::Reactive::Shareable.share(params)
|
|
314
317
|
# If a scope is already declared, reject a schema nested under the scope
|
|
315
318
|
# key here (the scope-first declaration order). The action-first order is
|
|
316
319
|
# caught in reactive_scope.
|
|
@@ -14,10 +14,12 @@ module Phlex
|
|
|
14
14
|
extend ActiveSupport::Concern
|
|
15
15
|
include Selection
|
|
16
16
|
include Sortable
|
|
17
|
+
include Drag
|
|
17
18
|
include Editable
|
|
18
19
|
include MediaBinding
|
|
19
20
|
include ImageFallbackBinding
|
|
20
21
|
include CountdownBinding
|
|
22
|
+
include CssVarsBinding
|
|
21
23
|
include FieldReferences
|
|
22
24
|
include ShowOptions
|
|
23
25
|
|
|
@@ -42,8 +44,10 @@ module Phlex
|
|
|
42
44
|
# nothing was held, and outside an action. Filtered to the action's
|
|
43
45
|
# declared params; a hint about what the user saw, never authorization.
|
|
44
46
|
#
|
|
47
|
+
# action :save, params: { title: :string } # only declared fields are reported
|
|
48
|
+
#
|
|
45
49
|
# def save(title: nil)
|
|
46
|
-
# return
|
|
50
|
+
# return reply.morph.refused("title") if reactive_held_fields.include?("title") && title != @note.title
|
|
47
51
|
# @note.update!(title:)
|
|
48
52
|
# end
|
|
49
53
|
def reactive_held_fields
|