phlex-reactive 0.11.2 → 0.11.4
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 +30 -0
- data/README.md +58 -0
- data/app/javascript/phlex/reactive/reactive_controller.js +263 -1
- data/app/javascript/phlex/reactive/reactive_controller.min.js +2 -2
- data/app/javascript/phlex/reactive/reactive_controller.min.js.map +3 -3
- data/lib/phlex/reactive/component/helpers.rb +99 -0
- data/lib/phlex/reactive/test_helpers/system.rb +99 -11
- data/lib/phlex/reactive/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dec3134e528500e32143217e96b93da16da30322cdf883017db367f645ab1c7d
|
|
4
|
+
data.tar.gz: ff6d5f992ea6cb7a5558a447f4256b9885170e4e27e1171c62ecf9c92ac221f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8952c2437efb9f8d3d8dafd86c2a09658a178ed657e2e4c632a45bc3be17f965f38d518348dd4b8c17609e7937a1793474b40e68db208cec83ef6f50ce28e4c3
|
|
7
|
+
data.tar.gz: aaddf4f15bd7aa7da26f74e4aca681e4c07c0c3baee62bb9107baf8ea5ab5e244a80b84baeec14fdad79935bede5bb0cf00d7796d4c82eac0f01ace5f916371a
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
11
|
+
- **`reactive_tags` — the tag-chip input primitive (#203).** The composed
|
|
12
|
+
combobox/tags widget (preload suggestions, type to narrow, Enter/click to add,
|
|
13
|
+
remove chips) with zero bespoke JavaScript and zero round trips. The value is
|
|
14
|
+
FORM state in a hidden **comma-joined** field the root names
|
|
15
|
+
(`reactive_tags(:tags)`, scope-aware like `reactive_filter`); the chip list is
|
|
16
|
+
a client projection of that field, rebuilt per change by cloning a
|
|
17
|
+
server-owned `<template>` chip (`textContent` writes only — never
|
|
18
|
+
`innerHTML`). Three client-only triggers: `reactive_tags_add` (Enter adds the
|
|
19
|
+
typed text — composed after `reactive_listnav`, a highlighted option wins and
|
|
20
|
+
Enter never submits the enclosing form), `reactive_tags_option(tag)` (click a
|
|
21
|
+
suggestion to add its declared tag), `reactive_tags_remove(tag)` (a chip's
|
|
22
|
+
remove button; no arg inside the template — the client fills it per chip).
|
|
23
|
+
Tags dedupe case-insensitively; a comma-separated paste splits; a declared tag
|
|
24
|
+
containing a comma raises at render. Selected suggestions hide + mark
|
|
25
|
+
`data-reactive-tags-selected` (`reactive_filter` keeps them hidden through
|
|
26
|
+
re-filters) and resurface on removal; a morph re-projects the chips from the
|
|
27
|
+
field's server value; every write dispatches a real `input` event on the
|
|
28
|
+
hidden field so `reactive_dirty`/`reactive_show`/`reactive_compute` see it.
|
|
29
|
+
Works in a token-less `ClientBindings` component — nothing here POSTs.
|
|
30
|
+
|
|
11
31
|
- **A global reactive-activity signal — the analogue of Turbo's progress bar (#201).**
|
|
12
32
|
The client now maintains a document-level count of in-flight reactive operations
|
|
13
33
|
across ALL roots — incremented when a dispatch round trip or a deferred render
|
|
@@ -47,6 +67,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
47
67
|
|
|
48
68
|
### Fixed
|
|
49
69
|
|
|
70
|
+
- **`have_reactive_value` now reads the field's `.value` property, so it can verify a
|
|
71
|
+
reducer-set disabled/computed field (#204).** The matcher (added in #201) delegated to
|
|
72
|
+
Capybara's `have_field(with:)`, which matches the value **attribute** — but a
|
|
73
|
+
`reactive_compute` reducer paints a computed output by setting its JS `.value`
|
|
74
|
+
**property** (`el.value = …`), and for a **disabled / read-only** output the attribute
|
|
75
|
+
never reflects that. So the matcher read `""` and failed on exactly the fields it was
|
|
76
|
+
built for. It now re-resolves the field by id **or name** each poll and asserts the live
|
|
77
|
+
`.value` property (via `evaluate_script`), covering enabled AND disabled/computed fields
|
|
78
|
+
while keeping the morph-immunity. `have_reactive_text` (textContent) was unaffected.
|
|
79
|
+
|
|
50
80
|
- **A freshly-rendered `reactive_compute` root now self-seeds its derived fields on connect (#199).**
|
|
51
81
|
Before, a compute root computed nothing until the first user `input` — a fresh render
|
|
52
82
|
(or a server validation-error re-render that replaced the body) left every derived
|
data/README.md
CHANGED
|
@@ -390,6 +390,8 @@ Use in controllers: `render turbo_stream: Counter.replace(counter)`.
|
|
|
390
390
|
| `reactive_show_targets(:field, "#id" => value)` | **Cross-root visibility**: the component that owns the field declares which **outside**, id-allowlisted elements it governs (a nav tab, a panel in another pane) — the visibility parallel of `mirror:`. Spread on the **root** via `mix(reactive_root, …)`, **once per root** — several fields go in one call via the hash form. The value uses the same `where`-style vocabulary (`"advanced"`, `%w[a b]`, `10..`). Id selectors only (raise at render + client warn-skip); toggles `hidden` only. See [Value-conditional visibility](#value-conditional-visibility-reactive_show). |
|
|
391
391
|
| `reactive_filter(:field, option: nil, group: nil, empty: nil)` | **Client-side option filtering** for a preloaded combobox: spread onto the root and name the **field** that drives it — `reactive_filter(:q)` compiles `:q` to `[name="q"]` (scope-aware) and typing shows/hides the options by their `data-reactive-filter-text` haystack, **zero round trips**. `option:` defaults to `[role=option]`; optional `group:` collapses an all-hidden group header; `empty:` reveals a no-matches node. See [Client-side option filtering](#client-side-option-filtering-reactive_filter). |
|
|
392
392
|
| `reactive_listnav("[role=option]")` | The **standalone** combobox keyboard wiring (Arrow/Enter/Escape) for an input that fires **no action** — the preload-and-filter case. Same behavior as `on(…, listnav:)`, minus the POST. |
|
|
393
|
+
| `reactive_tags(:tags)` | **Tag-chip input** (the combobox/tags widget): spread onto the root and name the hidden field that stores the **comma-joined** value — the client maintains that field + the chip list entirely client-side (form state, zero round trips), rebuilding chips from your server-owned `<template>`. Composes with `reactive_filter` (type to narrow) and `reactive_listnav` (Enter picks the highlighted option). See [Tag-chip input](#tag-chip-input-reactive_tags). |
|
|
394
|
+
| `reactive_tags_add` / `reactive_tags_option(tag)` / `reactive_tags_remove(tag)` | The tags triggers, all **client-only**: `reactive_tags_add` on the query input adds the typed text on Enter (mix it **after** `reactive_listnav`; Enter never submits the enclosing form); `reactive_tags_option` makes a preloaded suggestion add its declared tag on click; `reactive_tags_remove` is a chip's remove button (no arg inside the template — the client fills the tag per chip). |
|
|
393
395
|
| `reactive_compute :name, inputs: { title: :string, qty: :number }, outputs:` | **Typed** inputs: a `:string` reaches the JS reducer raw, a `:number` is coerced through `Number`. The array form (`inputs: %i[a b]`) stays all-numeric; the **permit-style** form (`inputs: [:qty, title: :string]`) mixes both — bare symbols default `:number`, a trailing hash types the exceptions. `outputs:` is the field allowlist; a reducer-result key also paints any owned `reactive_text` node by presence and any `mirror:` id, so an `outputs:` entry that exists only to reach a text node is redundant (harmless — a widening). |
|
|
394
396
|
| `reactive_compute :name, ..., mirror: { sum: "#summary-sum" }` | **Cross-root text mirrors**: paint a compute value into declared, id-allowlisted nodes **outside** the reactive root (a recap in another tab pane) via `textContent` — no bespoke listener. See [Cross-root mirrors](#cross-root-mirrors-mirror--painting-a-recap-outside-the-root). |
|
|
395
397
|
| `reactive_dirty` / `reactive_dirty warn_unsaved: true` / `reactive_dirty only: %i[...]` | **Dirty tracking**, declared once at the class level, against the DOM's own `defaultValue`/`defaultChecked`/`defaultSelected` — no client state. Marks changed fields + the root `data-reactive-dirty`; `warn_unsaved:` arms a `beforeunload`/`turbo:before-visit` guard; `only:` scopes tracking to named fields. Style with `[data-reactive-dirty]`. See [Dirty-field tracking](#dirty-field-tracking-reactive_dirty). |
|
|
@@ -1194,6 +1196,62 @@ stays its own signed `on(:select)` trigger. Only *filtering* is client-side —
|
|
|
1194
1196
|
selection still round-trips as a real signed action. Blank selectors raise at
|
|
1195
1197
|
render: a dead binding must fail loudly, not no-op in the browser.
|
|
1196
1198
|
|
|
1199
|
+
### Tag-chip input (`reactive_tags`)
|
|
1200
|
+
|
|
1201
|
+
The composed combobox/tags widget: preload suggestions, type to narrow, Enter or
|
|
1202
|
+
click to add, remove chips — the classic bespoke `tags` Stimulus controller,
|
|
1203
|
+
gone. The value is **form state** (like text in an input), never component
|
|
1204
|
+
state: it lives in a hidden **comma-joined** field inside your form, the client
|
|
1205
|
+
maintains it with **zero round trips**, and the surrounding form submit carries
|
|
1206
|
+
it to the server (`tags.split(",")` on your side). Because nothing here needs a
|
|
1207
|
+
signed action, the widget works in a token-less `ClientBindings` component too.
|
|
1208
|
+
|
|
1209
|
+
```ruby
|
|
1210
|
+
div(**mix(reactive_root, reactive_tags(:tags), reactive_filter(:tag_query))) do
|
|
1211
|
+
input(type: :hidden, **reactive_field(:tags), value: @post.tags.join(","))
|
|
1212
|
+
|
|
1213
|
+
div(data: { reactive_tags_list: true }) do # chips render here
|
|
1214
|
+
@post.tags.each { chip(it) } # server-rendered first paint
|
|
1215
|
+
end
|
|
1216
|
+
template(data: { reactive_tags_template: true }) { chip } # the chip markup, server-owned
|
|
1217
|
+
|
|
1218
|
+
# listnav FIRST, tags_add second: Enter picks the highlighted option;
|
|
1219
|
+
# free text adds only when nothing is highlighted. Never submits the form.
|
|
1220
|
+
input(name: "tag_query", **mix(reactive_listnav, reactive_tags_add))
|
|
1221
|
+
|
|
1222
|
+
SUGGESTIONS.each do |tag, haystack|
|
|
1223
|
+
button(**mix(reactive_tags_option(tag), # click → add (client-only)
|
|
1224
|
+
data: { reactive_filter_text: haystack })) { tag }
|
|
1225
|
+
end
|
|
1226
|
+
end
|
|
1227
|
+
|
|
1228
|
+
# One chip method serves both forms: with a tag it's the server-rendered chip,
|
|
1229
|
+
# without it's the template prototype (the client fills text + remove param).
|
|
1230
|
+
def chip(tag = nil)
|
|
1231
|
+
span(class: "chip", data: { reactive_tag: tag }) do
|
|
1232
|
+
span(data: { reactive_tag_text: true }) { tag }
|
|
1233
|
+
button(**(tag ? reactive_tags_remove(tag) : reactive_tags_remove)) { "×" }
|
|
1234
|
+
end
|
|
1235
|
+
end
|
|
1236
|
+
```
|
|
1237
|
+
|
|
1238
|
+
The chip list is a **client projection of the hidden field** — the field is the
|
|
1239
|
+
single source of truth. Every change re-clones your `<template>` per tag,
|
|
1240
|
+
writing the tag through `textContent` only (never `innerHTML` — the XSS-safe
|
|
1241
|
+
`reactive_text` posture) and stamping the remove button's tag param. Tags dedupe
|
|
1242
|
+
case-insensitively (first casing wins); a comma-separated paste splits into
|
|
1243
|
+
individual tags; a declared tag containing a comma **raises at render** (it
|
|
1244
|
+
would corrupt the joined value). An already-selected suggestion is hidden and
|
|
1245
|
+
marked `data-reactive-tags-selected` — `reactive_filter` keeps it hidden through
|
|
1246
|
+
re-filters — and resurfaces when its chip is removed. A server re-render/morph
|
|
1247
|
+
re-projects the chips from the field's fresh server value, and each write
|
|
1248
|
+
dispatches a real `input` event on the hidden field, so `reactive_dirty`,
|
|
1249
|
+
`reactive_show`, and `reactive_compute` all see it.
|
|
1250
|
+
|
|
1251
|
+
The styled, form-builder-integrated version of this widget (label/errors/
|
|
1252
|
+
theming) belongs in your form layer — these helpers are deliberately the
|
|
1253
|
+
unstyled primitives, like `reactive_filter` before them.
|
|
1254
|
+
|
|
1197
1255
|
**Combining `on(...)` / `reactive_attrs` with your own attributes.** Both return
|
|
1198
1256
|
a hash that includes a `data:` key. Spreading them *and* passing another `data:`
|
|
1199
1257
|
(or `class:`, `id:`) would clobber it — use Phlex's `mix` to deep-merge. For the
|
|
@@ -1131,6 +1131,12 @@ export default class extends Controller {
|
|
|
1131
1131
|
// Option filtering (issue #163): the ONE delegated sync handler shared by the
|
|
1132
1132
|
// root's input/turbo:morph-element listeners, held for teardown.
|
|
1133
1133
|
#boundSyncFilter
|
|
1134
|
+
// Tag-chip input (issue #203): the bound re-projection attached to
|
|
1135
|
+
// turbo:morph-element (a morph rewrites the hidden field to server truth, so
|
|
1136
|
+
// the chip projection must follow), held for teardown — plus the once-only
|
|
1137
|
+
// missing-template warning latch.
|
|
1138
|
+
#boundSyncTags
|
|
1139
|
+
#tagsWarnedTemplate = false
|
|
1134
1140
|
// Connect-time compute seed (issue #199): the bound re-seed attached to
|
|
1135
1141
|
// turbo:morph-element so an in-place morph re-runs the compute, held for teardown.
|
|
1136
1142
|
#boundSeedCompute
|
|
@@ -1240,6 +1246,21 @@ export default class extends Controller {
|
|
|
1240
1246
|
this.#syncFilter()
|
|
1241
1247
|
}
|
|
1242
1248
|
|
|
1249
|
+
// Tag-chip input (issue #203) — ONLY when the root names the hidden value
|
|
1250
|
+
// field (reactive_tags), so a component without one pays one attribute
|
|
1251
|
+
// read. The chip list is a CLIENT PROJECTION of the hidden field's
|
|
1252
|
+
// comma-joined value: connect seeds it (a plain replace re-connects with
|
|
1253
|
+
// the server-rendered value), and turbo:morph-element re-projects after an
|
|
1254
|
+
// in-place morph (the morph wrote server truth into the hidden field while
|
|
1255
|
+
// the chips DOM kept the pre-morph projection). Registered AFTER the
|
|
1256
|
+
// filter's listeners so a morph re-filters first and the tags pass then
|
|
1257
|
+
// re-marks selected options on the fresh visibility state.
|
|
1258
|
+
if (this.#tagsEnabled()) {
|
|
1259
|
+
this.#boundSyncTags = () => this.#syncTags()
|
|
1260
|
+
this.element.addEventListener?.("turbo:morph-element", this.#boundSyncTags)
|
|
1261
|
+
this.#syncTags()
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1243
1264
|
// Connect-time compute seed (issue #199) — ONLY when the root carries a
|
|
1244
1265
|
// reactive_compute binding that opts in (data-reactive-compute-seed). A
|
|
1245
1266
|
// freshly-rendered compute root (a first paint, or a server validation-error
|
|
@@ -1291,6 +1312,7 @@ export default class extends Controller {
|
|
|
1291
1312
|
this.#teardownDirtyTracking()
|
|
1292
1313
|
this.#teardownShowSync()
|
|
1293
1314
|
this.#teardownFilterSync()
|
|
1315
|
+
this.#teardownTagsSync()
|
|
1294
1316
|
this.#teardownComputeSeed()
|
|
1295
1317
|
if (this.#boundProbeLazyDefer) {
|
|
1296
1318
|
this.element.removeEventListener?.("turbo:morph-element", this.#boundProbeLazyDefer)
|
|
@@ -1697,6 +1719,80 @@ export default class extends Controller {
|
|
|
1697
1719
|
return Array.from(this.element.querySelectorAll(selector)).filter((el) => !el.hidden && owns(el))
|
|
1698
1720
|
}
|
|
1699
1721
|
|
|
1722
|
+
// Tag-chip input (issue #203) — the composed combobox/tags primitive. The
|
|
1723
|
+
// root's data-reactive-tags-field names the hidden input that stores the
|
|
1724
|
+
// COMMA-JOINED value; these three actions are its only writers. All of it is
|
|
1725
|
+
// FORM state (like text in an input) — no token, no POST: the surrounding
|
|
1726
|
+
// form submit carries the joined value. The chip list is re-projected from
|
|
1727
|
+
// the field on every write (#syncTags), so the field stays the single source
|
|
1728
|
+
// of truth.
|
|
1729
|
+
//
|
|
1730
|
+
// Enter on the query input: add the TYPED text — unless this Enter belongs
|
|
1731
|
+
// to listnav (reactive_tags_add composes after reactive_listnav on the same
|
|
1732
|
+
// keydown.enter). Two guards make the composition order-independent:
|
|
1733
|
+
// defaultPrevented means listnavPick ALREADY picked the highlighted option
|
|
1734
|
+
// (adding the typed text too would double-add); a still-visible highlighted
|
|
1735
|
+
// option means listnavPick is ABOUT to pick it (when tagsAdd is bound
|
|
1736
|
+
// first). Past the guards, Enter is OURS — preventDefault unconditionally so
|
|
1737
|
+
// it can never submit the enclosing form (blank input included). A
|
|
1738
|
+
// comma-separated paste splits into individual tags (the value is
|
|
1739
|
+
// comma-joined, so a comma can never be part of one tag). The input clears
|
|
1740
|
+
// only when something was actually added — a duplicate keeps the typed text
|
|
1741
|
+
// for correction.
|
|
1742
|
+
tagsAdd(event) {
|
|
1743
|
+
if (!this.#tagsEnabled()) return
|
|
1744
|
+
if (event?.defaultPrevented) return
|
|
1745
|
+
if (this.#listnavOptions(event).some((el) => el.hasAttribute?.("data-reactive-highlighted"))) return
|
|
1746
|
+
event?.preventDefault?.()
|
|
1747
|
+
|
|
1748
|
+
const input = event?.currentTarget ?? event?.target
|
|
1749
|
+
if (!input) return
|
|
1750
|
+
const added = this.#tagsAddValues(String(input.value ?? "").split(","))
|
|
1751
|
+
if (!added) return
|
|
1752
|
+
input.value = ""
|
|
1753
|
+
if (this.#filterEnabled()) this.#syncFilter()
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// Click (or listnav Enter, which CLICKS the highlighted option) on a
|
|
1757
|
+
// preloaded option: add its DECLARED tag (data-reactive-tag-param — set by
|
|
1758
|
+
// reactive_tags_option, never free text). After a successful add, reset the
|
|
1759
|
+
// query so the next tag starts from the full list: clear the filter input,
|
|
1760
|
+
// re-narrow, and hand focus back for continued typing.
|
|
1761
|
+
tagsPick(event) {
|
|
1762
|
+
if (!this.#tagsEnabled()) return
|
|
1763
|
+
event?.preventDefault?.()
|
|
1764
|
+
|
|
1765
|
+
const trigger = event?.currentTarget ?? event?.target
|
|
1766
|
+
const tag = trigger?.getAttribute?.("data-reactive-tag-param")
|
|
1767
|
+
if (!tag) return
|
|
1768
|
+
if (!this.#tagsAddValues([tag])) return
|
|
1769
|
+
|
|
1770
|
+
const input = this.#tagsQueryInput()
|
|
1771
|
+
if (!input) return
|
|
1772
|
+
input.value = ""
|
|
1773
|
+
this.#syncFilter()
|
|
1774
|
+
input.focus?.()
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// Click on a chip's remove button: drop its tag (case-insensitive match, the
|
|
1778
|
+
// dedupe convention) from the hidden value. The re-projection removes the
|
|
1779
|
+
// chip and resurfaces the option. Removing an absent tag is a no-op.
|
|
1780
|
+
tagsRemove(event) {
|
|
1781
|
+
if (!this.#tagsEnabled()) return
|
|
1782
|
+
event?.preventDefault?.()
|
|
1783
|
+
|
|
1784
|
+
const trigger = event?.currentTarget ?? event?.target
|
|
1785
|
+
const tag = trigger?.getAttribute?.("data-reactive-tag-param")
|
|
1786
|
+
if (!tag) return
|
|
1787
|
+
const field = this.#tagsField()
|
|
1788
|
+
if (!field) return
|
|
1789
|
+
|
|
1790
|
+
const tags = this.#tagsRead(field)
|
|
1791
|
+
const next = tags.filter((t) => t.toLowerCase() !== tag.toLowerCase())
|
|
1792
|
+
if (next.length === tags.length) return
|
|
1793
|
+
this.#tagsWrite(field, next)
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1700
1796
|
// Parse a JSON string list from a root data attr; [] on absence/parse error so
|
|
1701
1797
|
// a malformed binding degrades to "no fields" rather than throwing on input.
|
|
1702
1798
|
#parseComputeList(attr) {
|
|
@@ -2891,7 +2987,11 @@ export default class extends Controller {
|
|
|
2891
2987
|
for (const el of this.element.querySelectorAll(optionSelector)) {
|
|
2892
2988
|
if (!owns(el)) continue // a nested root's option is its own controller's job
|
|
2893
2989
|
const haystack = (el.getAttribute("data-reactive-filter-text") ?? el.textContent ?? "").toLowerCase()
|
|
2894
|
-
|
|
2990
|
+
// An option whose tag is already selected (reactive_tags, issue #203)
|
|
2991
|
+
// stays hidden through every re-filter — clearing the query must not
|
|
2992
|
+
// resurface an already-added tag.
|
|
2993
|
+
const hidden =
|
|
2994
|
+
el.hasAttribute?.("data-reactive-tags-selected") || (query !== "" && !haystack.includes(query))
|
|
2895
2995
|
el.hidden = hidden
|
|
2896
2996
|
if (hidden) el.removeAttribute("data-reactive-highlighted")
|
|
2897
2997
|
else visible++
|
|
@@ -2926,6 +3026,168 @@ export default class extends Controller {
|
|
|
2926
3026
|
this.#boundSyncFilter = undefined
|
|
2927
3027
|
}
|
|
2928
3028
|
|
|
3029
|
+
// Whether this root declares a tag-chip binding (issue #203) — the connect()
|
|
3030
|
+
// gate and every tags action's first check (an action bound without the root
|
|
3031
|
+
// binding is default-denied, the filter posture).
|
|
3032
|
+
#tagsEnabled() {
|
|
3033
|
+
return !!this.element.getAttribute?.("data-reactive-tags-field")
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
// The hidden input storing the comma-joined value, resolved fresh per use
|
|
3037
|
+
// (a morph replaces nodes — never cache it) and OWNED by this root (issue
|
|
3038
|
+
// #15). null when the selector resolves nothing — every caller then no-ops:
|
|
3039
|
+
// a binding that can't resolve must never break the page.
|
|
3040
|
+
#tagsField() {
|
|
3041
|
+
if (typeof this.element?.querySelectorAll !== "function") return null
|
|
3042
|
+
const selector = this.element.getAttribute("data-reactive-tags-field")
|
|
3043
|
+
if (!selector) return null
|
|
3044
|
+
const owns = this.#ownershipFilter()
|
|
3045
|
+
return [...this.element.querySelectorAll(selector)].find(owns) ?? null
|
|
3046
|
+
}
|
|
3047
|
+
|
|
3048
|
+
// Parse the field's comma-joined value into the canonical tag list: split,
|
|
3049
|
+
// trim, drop blanks, dedupe case-insensitively KEEPING the first casing (the
|
|
3050
|
+
// server may have stored a ragged value — the projection normalizes without
|
|
3051
|
+
// rewriting the field, so we never fight server truth).
|
|
3052
|
+
#tagsRead(field) {
|
|
3053
|
+
const seen = new Set()
|
|
3054
|
+
const tags = []
|
|
3055
|
+
for (const part of String(field.value ?? "").split(",")) {
|
|
3056
|
+
const tag = part.trim()
|
|
3057
|
+
if (tag === "" || seen.has(tag.toLowerCase())) continue
|
|
3058
|
+
seen.add(tag.toLowerCase())
|
|
3059
|
+
tags.push(tag)
|
|
3060
|
+
}
|
|
3061
|
+
return tags
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
// Append any NEW tags (trimmed, non-blank, not already present under the
|
|
3065
|
+
// case-insensitive dedupe) and write the field once. Returns whether
|
|
3066
|
+
// anything was actually added — callers only clear the query input then.
|
|
3067
|
+
#tagsAddValues(values) {
|
|
3068
|
+
const field = this.#tagsField()
|
|
3069
|
+
if (!field) return false
|
|
3070
|
+
|
|
3071
|
+
const tags = this.#tagsRead(field)
|
|
3072
|
+
const seen = new Set(tags.map((tag) => tag.toLowerCase()))
|
|
3073
|
+
let added = false
|
|
3074
|
+
for (const value of values) {
|
|
3075
|
+
const tag = String(value ?? "").trim()
|
|
3076
|
+
if (tag === "" || seen.has(tag.toLowerCase())) continue
|
|
3077
|
+
seen.add(tag.toLowerCase())
|
|
3078
|
+
tags.push(tag)
|
|
3079
|
+
added = true
|
|
3080
|
+
}
|
|
3081
|
+
if (added) this.#tagsWrite(field, tags)
|
|
3082
|
+
return added
|
|
3083
|
+
}
|
|
3084
|
+
|
|
3085
|
+
// The ONE writer: join, store, dispatch a real bubbling `input` on the field
|
|
3086
|
+
// (the set-value + dispatch contract, issue #183 — dirty tracking,
|
|
3087
|
+
// reactive_show, and compute all see the change), then re-project.
|
|
3088
|
+
#tagsWrite(field, tags) {
|
|
3089
|
+
field.value = tags.join(",")
|
|
3090
|
+
if (typeof field.dispatchEvent === "function") {
|
|
3091
|
+
field.dispatchEvent(new Event("input", { bubbles: true }))
|
|
3092
|
+
}
|
|
3093
|
+
this.#syncTags()
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
// The query input the tags widget resets after a pick — the SAME input that
|
|
3097
|
+
// drives reactive_filter (a tags widget without filtering has none; the
|
|
3098
|
+
// caller then skips the reset).
|
|
3099
|
+
#tagsQueryInput() {
|
|
3100
|
+
if (typeof this.element?.querySelectorAll !== "function") return null
|
|
3101
|
+
const selector = this.element.getAttribute("data-reactive-filter-input")
|
|
3102
|
+
if (!selector) return null
|
|
3103
|
+
const owns = this.#ownershipFilter()
|
|
3104
|
+
return [...this.element.querySelectorAll(selector)].find(owns) ?? null
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// Re-project the hidden field into the DOM (issue #203): rebuild the chip
|
|
3108
|
+
// list from the <template> and mark/hide the options whose tag is already
|
|
3109
|
+
// selected. The field is the single source of truth — this never writes it.
|
|
3110
|
+
#syncTags() {
|
|
3111
|
+
const field = this.#tagsField()
|
|
3112
|
+
if (!field) return
|
|
3113
|
+
const tags = this.#tagsRead(field)
|
|
3114
|
+
const owns = this.#ownershipFilter()
|
|
3115
|
+
this.#tagsRenderChips(tags, owns)
|
|
3116
|
+
this.#tagsMarkOptions(tags, owns)
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// Rebuild the chip list: clear the container and clone one chip per tag from
|
|
3120
|
+
// the server-owned template. The tag lands in the clone's
|
|
3121
|
+
// [data-reactive-tag-text] node via textContent (XSS-safe by construction —
|
|
3122
|
+
// never innerHTML, the reactive_text posture), and every tagsRemove trigger
|
|
3123
|
+
// in the clone gets the tag as its param. A missing list is a chip-less
|
|
3124
|
+
// widget (fine — the value still maintains); a missing/empty template warns
|
|
3125
|
+
// ONCE (a half-built binding should be loud, but never per-keystroke).
|
|
3126
|
+
#tagsRenderChips(tags, owns) {
|
|
3127
|
+
const list = [...this.element.querySelectorAll("[data-reactive-tags-list]")].find(owns)
|
|
3128
|
+
if (!list) return
|
|
3129
|
+
|
|
3130
|
+
const template = [...this.element.querySelectorAll("[data-reactive-tags-template]")].find(owns)
|
|
3131
|
+
const chipProto = template?.content?.firstElementChild
|
|
3132
|
+
if (!chipProto) {
|
|
3133
|
+
if (!this.#tagsWarnedTemplate) {
|
|
3134
|
+
console.warn(
|
|
3135
|
+
"[phlex-reactive] reactive_tags: no chip <template data-reactive-tags-template> found in this root — " +
|
|
3136
|
+
"chips will not render (the hidden field still updates). Add a template with a " +
|
|
3137
|
+
"[data-reactive-tag-text] node and a reactive_tags_remove button."
|
|
3138
|
+
)
|
|
3139
|
+
this.#tagsWarnedTemplate = true
|
|
3140
|
+
}
|
|
3141
|
+
return
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
while (list.firstChild) list.removeChild(list.firstChild)
|
|
3145
|
+
for (const tag of tags) {
|
|
3146
|
+
const chip = chipProto.cloneNode(true)
|
|
3147
|
+
chip.setAttribute?.("data-reactive-tag", tag)
|
|
3148
|
+
const sink = chip.matches?.("[data-reactive-tag-text]")
|
|
3149
|
+
? chip
|
|
3150
|
+
: (chip.querySelectorAll?.("[data-reactive-tag-text]") ?? [])[0]
|
|
3151
|
+
if (sink) sink.textContent = tag
|
|
3152
|
+
const removers = [...(chip.querySelectorAll?.('[data-action*="reactive#tagsRemove"]') ?? [])]
|
|
3153
|
+
if (chip.matches?.('[data-action*="reactive#tagsRemove"]')) removers.push(chip)
|
|
3154
|
+
for (const remover of removers) remover.setAttribute?.("data-reactive-tag-param", tag)
|
|
3155
|
+
list.appendChild(chip)
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
// Hide + mark every owned option whose DECLARED tag is already selected
|
|
3160
|
+
// (data-reactive-tags-selected — #syncFilter keeps it hidden through
|
|
3161
|
+
// re-filters), and resurface an option WE hid when its tag is removed. Only
|
|
3162
|
+
// marker-carrying options are un-hidden — an option hidden by the filter or
|
|
3163
|
+
// the server stays as-is. With a filter bound, one final #syncFilter re-folds
|
|
3164
|
+
// groups/empty against the new selected set.
|
|
3165
|
+
#tagsMarkOptions(tags, owns) {
|
|
3166
|
+
const selected = new Set(tags.map((tag) => tag.toLowerCase()))
|
|
3167
|
+
for (const el of this.element.querySelectorAll("[role=option]")) {
|
|
3168
|
+
if (!owns(el)) continue
|
|
3169
|
+
const tag = el.getAttribute?.("data-reactive-tag-param")
|
|
3170
|
+
if (!tag) continue
|
|
3171
|
+
if (selected.has(tag.toLowerCase())) {
|
|
3172
|
+
el.setAttribute("data-reactive-tags-selected", "true")
|
|
3173
|
+
el.hidden = true
|
|
3174
|
+
el.removeAttribute?.("data-reactive-highlighted")
|
|
3175
|
+
} else if (el.hasAttribute?.("data-reactive-tags-selected")) {
|
|
3176
|
+
el.removeAttribute("data-reactive-tags-selected")
|
|
3177
|
+
if (!this.#filterEnabled()) el.hidden = false
|
|
3178
|
+
}
|
|
3179
|
+
}
|
|
3180
|
+
if (this.#filterEnabled()) this.#syncFilter()
|
|
3181
|
+
}
|
|
3182
|
+
|
|
3183
|
+
// Remove the tags morph listener on disconnect, so a stray morph event after
|
|
3184
|
+
// the element leaves the DOM never re-projects against a detached root.
|
|
3185
|
+
#teardownTagsSync() {
|
|
3186
|
+
if (!this.#boundSyncTags) return
|
|
3187
|
+
this.element.removeEventListener?.("turbo:morph-element", this.#boundSyncTags)
|
|
3188
|
+
this.#boundSyncTags = undefined
|
|
3189
|
+
}
|
|
3190
|
+
|
|
2929
3191
|
// Remove the compute seed morph listener on disconnect (issue #199), so a
|
|
2930
3192
|
// stray turbo:morph-element after the element leaves the DOM never re-seeds
|
|
2931
3193
|
// against a detached root.
|