@radioactive-labs/plutonium 0.50.0 → 0.51.0

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.
@@ -1,7 +1,19 @@
1
1
  import { Controller } from "@hotwired/stimulus";
2
2
 
3
3
  // Connects to data-controller="slim-select"
4
+ //
5
+ // Optional values (used by the typeahead-capable ResourceSelect):
6
+ // typeahead-url — backend endpoint that returns
7
+ // {results: [{value, label}, ...], has_more: bool}.
8
+ // When present, SlimSelect's built-in client-side
9
+ // filter is replaced by a debounced fetch through
10
+ // this URL.
4
11
  export default class extends Controller {
12
+ static values = {
13
+ typeaheadUrl: String,
14
+ typeaheadDebounceMs: { type: Number, default: 200 }
15
+ }
16
+
5
17
  connect() {
6
18
  if (this.slimSelect) return;
7
19
 
@@ -47,9 +59,18 @@ export default class extends Controller {
47
59
  settings.openPosition = "auto";
48
60
  }
49
61
 
62
+ const events = {};
63
+
64
+ if (this.hasTypeaheadUrlValue && this.typeaheadUrlValue) {
65
+ // Replace SlimSelect's client-side filter with a server fetch.
66
+ // Returns the SlimSelect data array shape: {value, text}.
67
+ events.search = (search, currentData) => this.#typeaheadFetch(search, currentData);
68
+ }
69
+
50
70
  this.slimSelect = new SlimSelect({
51
71
  select: this.element,
52
72
  settings: settings,
73
+ events: events,
53
74
  });
54
75
 
55
76
  // Add event listeners for better positioning
@@ -177,6 +198,46 @@ export default class extends Controller {
177
198
  this.#cleanupSlimSelect();
178
199
  }
179
200
 
201
+ // Server-driven search. SlimSelect calls events.search on each
202
+ // keystroke; we debounce so that rapid typing produces a single
203
+ // request, and abort any in-flight fetch when a newer one starts.
204
+ // Returns a Promise resolving to either a DataArray (rendered as
205
+ // options) or a string (rendered as the no-results label).
206
+ #typeaheadFetch(search, _currentData) {
207
+ if (this._typeaheadDebounce) clearTimeout(this._typeaheadDebounce);
208
+ if (this._typeaheadAbort) this._typeaheadAbort.abort();
209
+
210
+ return new Promise((resolve) => {
211
+ this._typeaheadDebounce = setTimeout(() => {
212
+ this._typeaheadAbort = new AbortController();
213
+ this.#performTypeaheadFetch(search, this._typeaheadAbort.signal).then(resolve);
214
+ }, this.typeaheadDebounceMsValue);
215
+ });
216
+ }
217
+
218
+ async #performTypeaheadFetch(search, signal) {
219
+ const url = new URL(this.typeaheadUrlValue, window.location.origin);
220
+ url.searchParams.set("q", search || "");
221
+
222
+ try {
223
+ const res = await fetch(url.toString(), {
224
+ headers: { Accept: "application/json" },
225
+ signal: signal,
226
+ });
227
+ if (!res.ok) return "Search failed";
228
+ const json = await res.json();
229
+ const results = Array.isArray(json.results) ? json.results : [];
230
+ return results.map((row) => ({
231
+ value: String(row.value ?? ""),
232
+ text: String(row.label ?? ""),
233
+ }));
234
+ } catch (e) {
235
+ if (e.name === "AbortError") return [];
236
+ console.warn("[slim-select] typeahead error", e);
237
+ return "Search failed";
238
+ }
239
+ }
240
+
180
241
  #handleMorph() {
181
242
  if (!this.element.isConnected) return;
182
243
 
@@ -6,3 +6,36 @@ Turbo.StreamActions.redirect = function () {
6
6
  const url = this.getAttribute("url")
7
7
  Turbo.visit(url)
8
8
  }
9
+
10
+ // Closes the <dialog> rendered inside the targeted turbo-frame and
11
+ // empties the frame so the dialog can be re-opened later. Used by the
12
+ // stacked-modal flow: after a successful create inside the secondary
13
+ // modal, the server tells the browser to dismiss it.
14
+ Turbo.StreamActions.close_frame = function () {
15
+ const frameId = this.getAttribute("target")
16
+ if (!frameId) return
17
+
18
+ const frame = document.getElementById(frameId)
19
+ if (!frame) return
20
+
21
+ const dialog = frame.querySelector("dialog")
22
+ if (dialog && typeof dialog.close === "function") dialog.close()
23
+
24
+ // Clearing the frame's content keeps a future visit to the same URL
25
+ // re-fetching (turbo would otherwise treat the frame as cached).
26
+ frame.innerHTML = ""
27
+ frame.removeAttribute("src")
28
+ }
29
+
30
+ // Reloads the targeted turbo-frame from its current src. Used after a
31
+ // secondary-modal action mutates data the primary modal depends on
32
+ // (e.g. a newly created association option) so the primary re-renders.
33
+ Turbo.StreamActions.reload_frame = function () {
34
+ const frameId = this.getAttribute("target")
35
+ if (!frameId) return
36
+
37
+ const frame = document.getElementById(frameId)
38
+ if (!frame || typeof frame.reload !== "function") return
39
+
40
+ frame.reload()
41
+ }