@radioactive-labs/plutonium 0.49.1 → 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.
@@ -0,0 +1,24 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="autosubmit"
4
+ // Submits the closest <form> after the user stops typing for `delay` ms
5
+ // (default 300). Use on inputs where the user expects "as you type"
6
+ // behavior (e.g. the toolbar search input).
7
+ export default class extends Controller {
8
+ static values = { delay: { type: Number, default: 300 } }
9
+
10
+ connect() {
11
+ this._timer = null
12
+ }
13
+
14
+ disconnect() {
15
+ if (this._timer) clearTimeout(this._timer)
16
+ }
17
+
18
+ submit() {
19
+ if (this._timer) clearTimeout(this._timer)
20
+ this._timer = setTimeout(() => {
21
+ this.element.closest("form")?.requestSubmit()
22
+ }, this.delayValue)
23
+ }
24
+ }
@@ -3,22 +3,7 @@ import { Controller } from "@hotwired/stimulus"
3
3
  // Connects to data-controller="bulk-actions"
4
4
  // Manages bulk action selection in resource tables
5
5
  export default class extends Controller {
6
- static targets = ["checkbox", "checkboxAll", "toolbar", "selectedCount", "actionButton", "selectionCell"]
7
- static values = {
8
- hasActions: { type: Boolean, default: false }
9
- }
10
-
11
- connect() {
12
- // Show selection column only if bulk actions exist
13
- if (this.hasActionsValue) {
14
- this.enableSelection()
15
- }
16
- }
17
-
18
- enableSelection() {
19
- // Show all selection cells (header + body cells)
20
- this.selectionCellTargets.forEach(el => el.classList.remove("hidden"))
21
- }
6
+ static targets = ["checkbox", "checkboxAll", "toolbar", "selectedCount", "actionButton", "filterPills"]
22
7
 
23
8
  toggle() {
24
9
  this.updateUI()
@@ -45,6 +30,11 @@ export default class extends Controller {
45
30
  this.toolbarTarget.classList.toggle("hidden", checked.length === 0)
46
31
  }
47
32
 
33
+ // FilterPills strip is mutually exclusive with the toolbar
34
+ if (this.hasFilterPillsTarget) {
35
+ this.filterPillsTarget.classList.toggle("hidden", checked.length > 0)
36
+ }
37
+
48
38
  // Update selected count display
49
39
  if (this.hasSelectedCountTarget) {
50
40
  this.selectedCountTarget.textContent = checked.length
@@ -99,6 +89,15 @@ export default class extends Controller {
99
89
  return allowedActions ? allowedActions.split(",").filter(a => a) : []
100
90
  }
101
91
 
92
+ clearSelection() {
93
+ this.checkboxTargets.forEach(cb => cb.checked = false)
94
+ if (this.hasCheckboxAllTarget) {
95
+ this.checkboxAllTarget.checked = false
96
+ this.checkboxAllTarget.indeterminate = false
97
+ }
98
+ this.updateUI()
99
+ }
100
+
102
101
  get checked() {
103
102
  return this.checkboxTargets.filter(cb => cb.checked)
104
103
  }
@@ -0,0 +1,14 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="capture-url"
4
+ // Sets the controller's own element's `value` to window.location.href
5
+ // on connect — capturing URL fragments (#tab-id) that the server never
6
+ // sees over HTTP. Apply directly to any input/button whose value should
7
+ // reflect the full client-side URL.
8
+ export default class extends Controller {
9
+ connect() {
10
+ if ("value" in this.element) {
11
+ this.element.value = window.location.href
12
+ }
13
+ }
14
+ }
@@ -1,35 +1,93 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="filter-panel"
4
+ //
5
+ // Hosts the toolbar's filter slideover. The trigger lives in the toolbar
6
+ // strip; the panel + backdrop are rendered as siblings inside the same
7
+ // controller scope. `open` is mirrored on both targets via the `data-open`
8
+ // attribute, which CSS uses to drive the slide / fade transitions.
4
9
  export default class extends Controller {
10
+ static targets = ["panel", "backdrop"]
11
+
12
+ connect() {
13
+ this._onKeydown = this._onKeydown.bind(this)
14
+ }
15
+
16
+ disconnect() {
17
+ if (this.isOpen) {
18
+ document.removeEventListener("keydown", this._onKeydown)
19
+ this._unlockBodyScroll()
20
+ }
21
+ }
22
+
23
+ toggle() {
24
+ this.isOpen ? this.close() : this.open()
25
+ }
26
+
27
+ open() {
28
+ if (this.hasPanelTarget) {
29
+ this.panelTarget.setAttribute("data-open", "")
30
+ this.panelTarget.setAttribute("aria-hidden", "false")
31
+ }
32
+ if (this.hasBackdropTarget) this.backdropTarget.setAttribute("data-open", "")
33
+ this._lockBodyScroll()
34
+ document.addEventListener("keydown", this._onKeydown)
35
+ }
36
+
37
+ close() {
38
+ if (this.hasPanelTarget) {
39
+ this.panelTarget.removeAttribute("data-open")
40
+ this.panelTarget.setAttribute("aria-hidden", "true")
41
+ }
42
+ if (this.hasBackdropTarget) this.backdropTarget.removeAttribute("data-open")
43
+ this._unlockBodyScroll()
44
+ document.removeEventListener("keydown", this._onKeydown)
45
+ }
46
+
47
+ // Mirrors remote-modal's approach: stash the body's current overflow
48
+ // and restore it on close. Avoids stomping a value another component
49
+ // (e.g. an open dialog) may have set.
50
+ _lockBodyScroll() {
51
+ if (this._previousBodyOverflow != null) return
52
+ this._previousBodyOverflow = document.body.style.overflow
53
+ document.body.style.overflow = "hidden"
54
+ }
55
+
56
+ _unlockBodyScroll() {
57
+ if (this._previousBodyOverflow == null) return
58
+ document.body.style.overflow = this._previousBodyOverflow
59
+ this._previousBodyOverflow = null
60
+ }
61
+
62
+ // Reset every input under this controller's scope, then submit so the
63
+ // table reflects the cleared filters immediately.
5
64
  clear() {
6
- this.element.querySelectorAll('input, select, textarea').forEach(input => {
7
- if (input.type === 'checkbox' || input.type === 'radio') {
65
+ this.element.querySelectorAll("input, select, textarea").forEach(input => {
66
+ if (input.type === "checkbox" || input.type === "radio") {
8
67
  input.checked = false
9
- } else if (input.tagName === 'SELECT') {
68
+ } else if (input.tagName === "SELECT") {
10
69
  input.selectedIndex = 0
11
- } else if (input.type === 'hidden') {
12
- // Clear hidden inputs that are filter values (e.g., flatpickr)
13
- if (input.dataset.controller === 'flatpickr') {
14
- input.value = ''
15
- }
70
+ } else if (input.type === "hidden") {
71
+ if (input.dataset.controller === "flatpickr") input.value = ""
16
72
  } else {
17
- input.value = ''
73
+ input.value = ""
18
74
  }
19
75
  })
20
76
 
21
- // Clear flatpickr instances via Stimulus controller
22
77
  this.element.querySelectorAll('[data-controller="flatpickr"]').forEach(input => {
23
- const controller = this.application.getControllerForElementAndIdentifier(input, 'flatpickr')
24
- if (controller?.picker) {
25
- controller.picker.clear()
26
- }
78
+ const controller = this.application.getControllerForElementAndIdentifier(input, "flatpickr")
79
+ if (controller?.picker) controller.picker.clear()
27
80
  })
28
81
 
29
- // Submit the parent form
30
- const form = this.element.closest('form')
31
- if (form) {
32
- form.requestSubmit()
33
- }
82
+ const form = this.element.querySelector("form")
83
+ if (form) form.requestSubmit()
84
+ }
85
+
86
+ get isOpen() {
87
+ return this.hasPanelTarget && this.panelTarget.hasAttribute("data-open")
88
+ }
89
+
90
+ _onKeydown(event) {
91
+ if (event.key === "Escape") this.close()
34
92
  }
35
93
  }
@@ -49,6 +49,16 @@ export default class extends Controller {
49
49
  }
50
50
 
51
51
  frameLoading(event) {
52
+ // turbo:click / turbo:submit-start bubble from links and forms inside
53
+ // the frame, even when those links target a different frame
54
+ // (e.g. data-turbo-frame="remote_modal"). Without this filter, the
55
+ // pulse animation is triggered for navigations that never resolve
56
+ // against this frame, leaving it stuck in a loading state.
57
+ if (event) {
58
+ const trigger = event.target.closest("a, form")
59
+ const requested = trigger?.dataset?.turboFrame
60
+ if (requested && requested !== this.frameTarget.id) return
61
+ }
52
62
  this.#loadingStarted()
53
63
  }
54
64
 
@@ -79,20 +89,38 @@ export default class extends Controller {
79
89
  homeButtonClicked(event) {
80
90
  this.frameLoading(null)
81
91
 
92
+ // Clear history immediately so Back/Home vanish during the load.
93
+ this.srcHistory = [this.originalFrameSrc]
94
+ this.#updateNavigationButtonsDisplay()
95
+
96
+ // Mark the next frame load as "home" so notifySrcChanged doesn't
97
+ // push the loaded URL onto a fresh stack (the loaded URL may differ
98
+ // from originalFrameSrc due to redirects / trailing slashes).
99
+ this._homeRequested = true
100
+
101
+ // Force a reload even if frame.src already matches the original
102
+ // (a same-value assignment wouldn't fire turbo:frame-load).
82
103
  this.frameTarget.src = this.originalFrameSrc
104
+ this.frameTarget.reload()
83
105
  }
84
106
 
85
107
  get currentSrc() { return this.srcHistory[this.srcHistory.length - 1] }
86
108
 
87
109
  #notifySrcChanged(src) {
88
- if (src == this.currentSrc) {
89
- // this must be a refresh
90
- // do nothing
91
- }
92
- else if (src == this.originalFrameSrc)
110
+ if (this._homeRequested) {
111
+ // Home click: capture the actually-loaded URL as the new singleton
112
+ // history root (handles redirect/trailing-slash differences from
113
+ // originalFrameSrc).
114
+ this._homeRequested = false
93
115
  this.srcHistory = [src]
94
- else
116
+ this.originalFrameSrc = src
117
+ } else if (src == this.currentSrc) {
118
+ // refresh — do nothing
119
+ } else if (src == this.originalFrameSrc) {
120
+ this.srcHistory = [src]
121
+ } else {
95
122
  this.srcHistory.push(src)
123
+ }
96
124
 
97
125
  this.#updateNavigationButtonsDisplay()
98
126
  if (this.hasMaximizeLinkTarget) this.maximizeLinkTarget.href = src
@@ -0,0 +1,22 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="icon-rail"
4
+ // Manages the collapsed ↔ pinned-expanded state of the IconRail sidebar.
5
+ // Pinned state is persisted in localStorage so it survives page reloads.
6
+ export default class extends Controller {
7
+ static values = {
8
+ storageKey: { type: String, default: "pu_rail_pinned" }
9
+ }
10
+
11
+ connect() {
12
+ const pinned = localStorage.getItem(this.storageKeyValue) === "true"
13
+ if (pinned) {
14
+ document.body.classList.add("pu-rail-pinned")
15
+ }
16
+ }
17
+
18
+ togglePin() {
19
+ const pinned = document.body.classList.toggle("pu-rail-pinned")
20
+ localStorage.setItem(this.storageKeyValue, pinned)
21
+ }
22
+ }
@@ -0,0 +1,128 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="icon-rail-flyout"
4
+ // Manages a flyout panel anchored to a trigger element.
5
+ // - Hover or focus the wrapper → open
6
+ // - Mouse leave (with delay) or blur → close
7
+ // - Esc → close immediately
8
+ // - Click trigger → toggle (touch-friendly)
9
+ //
10
+ // On open, the panel is portaled to <body> so it escapes any ancestor
11
+ // transform / overflow:hidden (the rail aside has both). On close,
12
+ // the panel returns to its original parent.
13
+ //
14
+ // IMPORTANT: once portaled, the panel is OUTSIDE the controller's
15
+ // element scope, so this.panelTarget stops resolving. We capture the
16
+ // node into _panel before moving it.
17
+ export default class extends Controller {
18
+ static targets = ["trigger", "panel"]
19
+ static values = {
20
+ closeDelay: { type: Number, default: 150 }
21
+ }
22
+
23
+ connect() {
24
+ this._closeTimer = null
25
+ this._open = false
26
+ this._panel = null
27
+ this._panelHome = null
28
+ this._onPanelEnter = () => {
29
+ if (this._closeTimer) {
30
+ clearTimeout(this._closeTimer)
31
+ this._closeTimer = null
32
+ }
33
+ }
34
+ this._onPanelLeave = () => this.scheduleClose()
35
+ }
36
+
37
+ disconnect() {
38
+ this._returnPanel()
39
+ }
40
+
41
+ open() {
42
+ if (this._closeTimer) {
43
+ clearTimeout(this._closeTimer)
44
+ this._closeTimer = null
45
+ }
46
+ if (this._open) return
47
+ if (!this._panel && !this.hasPanelTarget) return
48
+ this._open = true
49
+ this.element.dataset.flyoutOpen = "true"
50
+ this._portalPanel()
51
+ this._position()
52
+ }
53
+
54
+ scheduleClose() {
55
+ if (this._closeTimer) clearTimeout(this._closeTimer)
56
+ this._closeTimer = setTimeout(() => this.close(), this.closeDelayValue)
57
+ }
58
+
59
+ close() {
60
+ if (!this._open) return
61
+ this._open = false
62
+ delete this.element.dataset.flyoutOpen
63
+ this._returnPanel()
64
+ }
65
+
66
+ toggle(event) {
67
+ event.preventDefault()
68
+ this._open ? this.close() : this.open()
69
+ }
70
+
71
+ closeOnEsc(event) {
72
+ if (event.key === "Escape") this.close()
73
+ }
74
+
75
+ _portalPanel() {
76
+ if (this._panel) return
77
+ // Capture the panel BEFORE moving it — once it leaves the
78
+ // controller element, this.panelTarget no longer resolves.
79
+ const panel = this.panelTarget
80
+ if (!panel) return
81
+ this._panel = panel
82
+ this._panelHome = panel.parentElement
83
+ panel.addEventListener("mouseenter", this._onPanelEnter)
84
+ panel.addEventListener("mouseleave", this._onPanelLeave)
85
+ document.body.appendChild(panel)
86
+ panel.style.display = "block"
87
+ }
88
+
89
+ _returnPanel() {
90
+ if (!this._panel) return
91
+ const panel = this._panel
92
+ panel.removeEventListener("mouseenter", this._onPanelEnter)
93
+ panel.removeEventListener("mouseleave", this._onPanelLeave)
94
+ panel.style.position = ""
95
+ panel.style.left = ""
96
+ panel.style.top = ""
97
+ panel.style.display = ""
98
+ // If the original parent has been morphed away, the panel would
99
+ // orphan in <body>. Drop it instead of re-attaching to a detached
100
+ // home node.
101
+ if (this._panelHome && document.contains(this._panelHome)) {
102
+ this._panelHome.appendChild(panel)
103
+ } else {
104
+ panel.remove()
105
+ }
106
+ this._panel = null
107
+ this._panelHome = null
108
+ }
109
+
110
+ _position() {
111
+ if (!this._panel || !this.hasTriggerTarget) return
112
+ const panel = this._panel
113
+ const triggerRect = this.triggerTarget.getBoundingClientRect()
114
+ panel.style.position = "fixed"
115
+ panel.style.left = `${triggerRect.right + 4}px`
116
+ panel.style.top = `${triggerRect.top}px`
117
+
118
+ // Shift up if the panel would overflow the viewport bottom.
119
+ requestAnimationFrame(() => {
120
+ const panelRect = panel.getBoundingClientRect()
121
+ const viewportH = window.innerHeight
122
+ if (panelRect.bottom > viewportH - 8) {
123
+ const overflow = panelRect.bottom - (viewportH - 8)
124
+ panel.style.top = `${parseFloat(panel.style.top) - overflow}px`
125
+ }
126
+ })
127
+ }
128
+ }
@@ -24,6 +24,14 @@ import BulkActionsController from "./bulk_actions_controller.js"
24
24
  import FilterPanelController from "./filter_panel_controller.js"
25
25
  import TextareaAutogrowController from "./textarea_autogrow_controller.js"
26
26
  import ClipboardController from "./clipboard_controller.js"
27
+ import IconRailController from "./icon_rail_controller.js"
28
+ import IconRailFlyoutController from "./icon_rail_flyout_controller.js"
29
+ import TableHeaderController from "./table_header_controller.js"
30
+ import TableColumnMenuController from "./table_column_menu_controller.js"
31
+ import CaptureUrlController from "./capture_url_controller.js"
32
+ import RowClickController from "./row_click_controller.js"
33
+ import ViewSwitcherController from "./view_switcher_controller.js"
34
+ import AutosubmitController from "./autosubmit_controller.js"
27
35
 
28
36
  export default function (application) {
29
37
  // Register controllers here
@@ -52,4 +60,12 @@ export default function (application) {
52
60
  application.register("filter-panel", FilterPanelController)
53
61
  application.register("textarea-autogrow", TextareaAutogrowController)
54
62
  application.register("clipboard", ClipboardController)
63
+ application.register("icon-rail", IconRailController)
64
+ application.register("icon-rail-flyout", IconRailFlyoutController)
65
+ application.register("table-header", TableHeaderController)
66
+ application.register("table-column-menu", TableColumnMenuController)
67
+ application.register("capture-url", CaptureUrlController)
68
+ application.register("row-click", RowClickController)
69
+ application.register("view-switcher", ViewSwitcherController)
70
+ application.register("autosubmit", AutosubmitController)
55
71
  }
@@ -1,6 +1,15 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
3
  // Connects to data-controller="resource-tab-list"
4
+ //
5
+ // URL hash sync:
6
+ // - On connect, if location.hash matches a tab identifier, that tab is
7
+ // selected (overrides defaultTabValue). Falls back to defaultTabValue
8
+ // or the first tab.
9
+ // - On user click, the URL hash is updated via history.replaceState
10
+ // (no scroll, no back-button entry).
11
+ // - On hashchange (back/forward, manual hash edit), the matching tab
12
+ // is re-selected.
4
13
  export default class extends Controller {
5
14
  static targets = ["btn", "tab"]
6
15
  static values = {
@@ -13,14 +22,37 @@ export default class extends Controller {
13
22
  this.activeClasses = this.hasActiveClassesValue ? this.activeClassesValue.split(" ") : []
14
23
  this.inActiveClasses = this.hasInActiveClassesValue ? this.inActiveClassesValue.split(" ") : []
15
24
 
16
- this.#selectInternal(this.defaultTabValue || this.btnTargets[0].id)
25
+ const fromHash = this.#buttonIdFromHash()
26
+ const initialId = fromHash || this.defaultTabValue || this.btnTargets[0]?.id
27
+ this.#selectInternal(initialId, { skipFocus: true, skipHashUpdate: true })
28
+
29
+ this._syncFromHash = this._syncFromHash.bind(this)
30
+ // hashchange covers manual hash edits and back/forward.
31
+ window.addEventListener("hashchange", this._syncFromHash)
32
+ // turbo:load covers Turbo navigations (including morph) where the URL
33
+ // changes via pushState — which doesn't fire hashchange. Without this,
34
+ // a second submit landing via morph leaves the previously-active tab
35
+ // selected even though the URL hash points elsewhere.
36
+ document.addEventListener("turbo:load", this._syncFromHash)
37
+ }
38
+
39
+ disconnect() {
40
+ if (this._syncFromHash) {
41
+ window.removeEventListener("hashchange", this._syncFromHash)
42
+ document.removeEventListener("turbo:load", this._syncFromHash)
43
+ }
44
+ }
45
+
46
+ _syncFromHash() {
47
+ const id = this.#buttonIdFromHash()
48
+ if (id) this.#selectInternal(id, { skipFocus: true, skipHashUpdate: true })
17
49
  }
18
50
 
19
51
  select(event) {
20
52
  this.#selectInternal(event.currentTarget.id)
21
53
  }
22
54
 
23
- #selectInternal(id) {
55
+ #selectInternal(id, options = {}) {
24
56
  const selectedBtn = this.btnTargets.find(element => element.id === id)
25
57
  if (!selectedBtn) {
26
58
  console.error(`Tab Button with id "${id}" not found`)
@@ -56,9 +88,30 @@ export default class extends Controller {
56
88
  selectedTab.hidden = false
57
89
  selectedTab.setAttribute('aria-hidden', 'false')
58
90
 
91
+ // Sync URL hash so the tab is shareable / restorable on reload.
92
+ if (!options.skipHashUpdate) this.#updateHash(id)
93
+
59
94
  // Focus management
60
- if (selectedBtn !== document.activeElement) {
95
+ if (!options.skipFocus && selectedBtn !== document.activeElement) {
61
96
  selectedBtn.focus()
62
97
  }
63
98
  }
99
+
100
+ // Button ids follow `${identifier}-tab`. The URL hash carries just
101
+ // the identifier (e.g., #details, #orders).
102
+ #buttonIdFromHash() {
103
+ const hash = window.location.hash.replace(/^#/, "")
104
+ if (!hash) return null
105
+ const candidateId = `${hash}-tab`
106
+ const exists = this.btnTargets.some(btn => btn.id === candidateId)
107
+ return exists ? candidateId : null
108
+ }
109
+
110
+ #updateHash(buttonId) {
111
+ const identifier = buttonId.replace(/-tab$/, "")
112
+ const newHash = `#${identifier}`
113
+ if (window.location.hash !== newHash) {
114
+ history.replaceState(null, "", newHash)
115
+ }
116
+ }
64
117
  }
@@ -0,0 +1,21 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // Connects to data-controller="row-click"
4
+ //
5
+ // Makes a table row or grid card behave like the resource's Show
6
+ // affordance: clicking anywhere except a real interactive element
7
+ // triggers the row's existing Show button. Single source of truth for
8
+ // the URL — turbo-frame targets, modal opening, and any other
9
+ // configuration on the Show action are inherited automatically.
10
+ //
11
+ // Mark the show element with `data-row-click-target="show"`.
12
+ // Opt out of triggering for a specific element with
13
+ // `data-row-click-ignore`.
14
+ export default class extends Controller {
15
+ click(event) {
16
+ if (event.target.closest("a, button, input, label, select, textarea, [data-row-click-ignore]")) {
17
+ return
18
+ }
19
+ this.element.querySelector('[data-row-click-target="show"]')?.click()
20
+ }
21
+ }
@@ -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