activeadmin-command_palette 0.1.6

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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +51 -0
  3. data/CODE_OF_CONDUCT.md +26 -0
  4. data/CONTRIBUTING.md +23 -0
  5. data/GOVERNANCE.md +25 -0
  6. data/LICENSE.md +21 -0
  7. data/README.md +152 -0
  8. data/SECURITY.md +25 -0
  9. data/activeadmin-command_palette.gemspec +68 -0
  10. data/app/assets/controllers/activeadmin_command_palette/command_palette_controller.js +817 -0
  11. data/app/controllers/active_admin/command_palette/options_controller.rb +61 -0
  12. data/app/views/active_admin/command_palette/_palette.html.erb +45 -0
  13. data/config/locales/activeadmin_command_palette.en.yml +21 -0
  14. data/config/locales/activeadmin_command_palette.fi.yml +21 -0
  15. data/config/locales/activeadmin_command_palette.sv.yml +21 -0
  16. data/config/polyrun_coverage.yml +8 -0
  17. data/config/routes.rb +7 -0
  18. data/lib/activeadmin/command_palette/catalog.rb +184 -0
  19. data/lib/activeadmin/command_palette/compiler.rb +159 -0
  20. data/lib/activeadmin/command_palette/configuration.rb +148 -0
  21. data/lib/activeadmin/command_palette/dsl.rb +13 -0
  22. data/lib/activeadmin/command_palette/engine.rb +67 -0
  23. data/lib/activeadmin/command_palette/finder.rb +103 -0
  24. data/lib/activeadmin/command_palette/helper.rb +136 -0
  25. data/lib/activeadmin/command_palette/integration.rb +17 -0
  26. data/lib/activeadmin/command_palette/resource_config.rb +52 -0
  27. data/lib/activeadmin/command_palette/resource_dsl.rb +47 -0
  28. data/lib/activeadmin/command_palette/resource_methods.rb +11 -0
  29. data/lib/activeadmin/command_palette/url_helpers.rb +22 -0
  30. data/lib/activeadmin/command_palette/version.rb +7 -0
  31. data/lib/activeadmin/command_palette.rb +38 -0
  32. data/sig/activeadmin/command_palette.rbs +5 -0
  33. metadata +392 -0
@@ -0,0 +1,817 @@
1
+ import { Controller } from "@hotwired/stimulus";
2
+
3
+ const RECENT_KEY = "activeadmin-command-palette-recent";
4
+ const RECENT_LIMIT = 8;
5
+ const LIST_LIMIT = 40;
6
+ const RECORD_LIMIT = 20;
7
+ const DEBOUNCE_MS = 200;
8
+ const REQUEST_TIMEOUT_MS = 5_000;
9
+ const STATIC_KINDS = new Set(["navigate", "action"]);
10
+
11
+ export default class extends Controller {
12
+ static targets = ["dialog", "query", "list", "panel", "shortcut", "announcer"];
13
+ static values = {
14
+ navigate: Array,
15
+ actions: Array,
16
+ context: Object,
17
+ optionsUrl: String,
18
+ csrf: String,
19
+ prefixes: Object,
20
+ kinds: Array,
21
+ minQueryLength: { type: Number, default: 2 },
22
+ requestTimeout: { type: Number, default: REQUEST_TIMEOUT_MS },
23
+ empty: { type: String, default: "No matches" },
24
+ loading: { type: String, default: "Searching…" },
25
+ error: { type: String, default: "Search failed. Try again." },
26
+ unauthorized: {
27
+ type: String,
28
+ default: "Session expired. Sign in again to keep searching.",
29
+ },
30
+ forbidden: {
31
+ type: String,
32
+ default: "You do not have permission to search these records.",
33
+ },
34
+ required: { type: String, default: "required" },
35
+ optional: { type: String, default: "optional" },
36
+ helpItems: Array,
37
+ signInPath: { type: String, default: "/user/sign_in" },
38
+ };
39
+
40
+ connect() {
41
+ this.open = false;
42
+ this.highlightedIndex = -1;
43
+ this.visibleItems = [];
44
+ this.status = null;
45
+ this.announceStatus();
46
+ this.queryGeneration = 0;
47
+ this.abortController = null;
48
+ this.timeout = null;
49
+ this.previousActive = null;
50
+ this.forcedKind = null;
51
+ this.boundKeydown = this.globalKeydown.bind(this);
52
+ document.addEventListener("keydown", this.boundKeydown);
53
+ this.updateShortcutLabel();
54
+ }
55
+
56
+ disconnect() {
57
+ document.removeEventListener("keydown", this.boundKeydown);
58
+ clearTimeout(this.timeout);
59
+ this.abortInFlight();
60
+ }
61
+
62
+ updateShortcutLabel() {
63
+ if (!this.hasShortcutTarget) return;
64
+ const mac = /Mac|iPhone|iPad|iPod/.test(navigator.platform || "");
65
+ this.shortcutTarget.textContent = mac ? "⌘K" : "Ctrl+K";
66
+ }
67
+
68
+ globalKeydown(event) {
69
+ const metaK = (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k";
70
+ if (metaK) {
71
+ event.preventDefault();
72
+ this.toggle();
73
+ return;
74
+ }
75
+
76
+ if (event.key === "Escape" && this.open) {
77
+ event.preventDefault();
78
+ this.close();
79
+ }
80
+ }
81
+
82
+ toggle() {
83
+ if (this.open) {
84
+ this.close();
85
+ } else {
86
+ this.show();
87
+ }
88
+ }
89
+
90
+ show() {
91
+ this.open = true;
92
+ this.forcedKind = null;
93
+ this.previousActive = document.activeElement;
94
+ this.dialogTarget.classList.remove("hidden");
95
+ this.queryTarget.value = "";
96
+ this.queryTarget.setAttribute("aria-expanded", "true");
97
+ this.refresh();
98
+ this.queryTarget.focus();
99
+ }
100
+
101
+ close() {
102
+ this.open = false;
103
+ this.forcedKind = null;
104
+ clearTimeout(this.timeout);
105
+ this.abortInFlight();
106
+ this.dialogTarget.classList.add("hidden");
107
+ this.queryTarget.value = "";
108
+ this.queryTarget.setAttribute("aria-expanded", "false");
109
+ this.queryTarget.removeAttribute("aria-activedescendant");
110
+ this.listTarget.replaceChildren();
111
+ this.visibleItems = [];
112
+ this.highlightedIndex = -1;
113
+ this.status = null;
114
+ if (this.previousActive && typeof this.previousActive.focus === "function") {
115
+ this.previousActive.focus();
116
+ }
117
+ this.previousActive = null;
118
+ }
119
+
120
+ onInput() {
121
+ clearTimeout(this.timeout);
122
+ const mode = this.parseMode(this.queryTarget.value);
123
+ if (mode.name === "find" || mode.name === "kind" || mode.name === "actions") {
124
+ if (mode.name === "find" || mode.name === "kind") {
125
+ this.markPendingSearch();
126
+ }
127
+ this.timeout = setTimeout(() => this.refresh(), DEBOUNCE_MS);
128
+ } else {
129
+ this.refresh();
130
+ }
131
+ }
132
+
133
+ markPendingSearch() {
134
+ this.abortInFlight();
135
+ this.visibleItems = [];
136
+ this.highlightedIndex = -1;
137
+ this.status = "loading";
138
+ this.render();
139
+ }
140
+
141
+ abortInFlight() {
142
+ if (this.abortController) {
143
+ this.abortController.abort();
144
+ this.abortController = null;
145
+ }
146
+ }
147
+
148
+ onKeydown(event) {
149
+ if (event.key === "ArrowDown") {
150
+ event.preventDefault();
151
+ this.moveHighlight(1);
152
+ } else if (event.key === "ArrowUp") {
153
+ event.preventDefault();
154
+ this.moveHighlight(-1);
155
+ } else if (event.key === "Enter") {
156
+ event.preventDefault();
157
+ this.activateHighlighted();
158
+ }
159
+ }
160
+
161
+ onDialogKeydown(event) {
162
+ if (event.key !== "Tab" || !this.open) return;
163
+
164
+ const candidates = [
165
+ this.queryTarget,
166
+ ...this.dialogTarget.querySelectorAll(
167
+ 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])'
168
+ ),
169
+ ];
170
+ const focusable = [...new Set(candidates)];
171
+ if (focusable.length === 0) return;
172
+
173
+ const currentIndex = focusable.indexOf(document.activeElement);
174
+ const direction = event.shiftKey ? -1 : 1;
175
+ const nextIndex = (currentIndex + direction + focusable.length) % focusable.length;
176
+ event.preventDefault();
177
+ focusable[nextIndex].focus();
178
+ }
179
+
180
+ parseMode(raw) {
181
+ const value = raw.toString();
182
+ if (value.startsWith(">") || value.startsWith("?")) {
183
+ this.forcedKind = null;
184
+ }
185
+
186
+ if (this.forcedKind) {
187
+ return {
188
+ name: "kind",
189
+ kind: this.forcedKind,
190
+ query: value.trim(),
191
+ };
192
+ }
193
+
194
+ if (value.startsWith(">")) {
195
+ return { name: "actions", query: value.slice(1).trim() };
196
+ }
197
+ if (value.startsWith("?")) {
198
+ return { name: "help", query: value.slice(1).trim() };
199
+ }
200
+
201
+ const prefixes = this.hasPrefixesValue ? this.prefixesValue : {};
202
+ const orderedPrefixes = Object.entries(prefixes).sort(
203
+ (left, right) => right[0].length - left[0].length
204
+ );
205
+ for (const [prefix, kind] of orderedPrefixes) {
206
+ if (prefix && value.startsWith(prefix)) {
207
+ return {
208
+ name: "kind",
209
+ kind,
210
+ query: value.slice(prefix.length).trim(),
211
+ };
212
+ }
213
+ }
214
+
215
+ return { name: "find", query: value.trim() };
216
+ }
217
+
218
+ contextObject() {
219
+ return this.hasContextValue ? this.contextValue || {} : {};
220
+ }
221
+
222
+ contextFilteredActions() {
223
+ const context = this.contextObject();
224
+ return (this.actionsValue || []).filter((action) => this.actionMatchesContext(action, context));
225
+ }
226
+
227
+ actionMatchesContext(action, context) {
228
+ const scope = (action.scope || "global").toString();
229
+ const whenList = Array.isArray(action.when)
230
+ ? action.when.map(String)
231
+ : [String(action.when || "any")];
232
+ const rawAction = (context.action || "other").toString();
233
+ const actionName = rawAction === "create" ? "new" : rawAction === "update" ? "edit" : rawAction;
234
+
235
+ if (scope === "member" && !context.record_param) return false;
236
+ if (scope === "collection" && actionName !== "index") return false;
237
+ if (
238
+ !whenList.includes("any") &&
239
+ !whenList.includes(actionName) &&
240
+ !whenList.includes(rawAction)
241
+ ) {
242
+ return false;
243
+ }
244
+ if ((scope === "member" || scope === "collection") && action.resource) {
245
+ if (String(context.resource || "") !== String(action.resource)) return false;
246
+ }
247
+ return true;
248
+ }
249
+
250
+ findActionForQuery(query) {
251
+ const trimmed = (query || "").trim();
252
+ if (!trimmed) return null;
253
+ const lower = trimmed.toLowerCase();
254
+ let best = null;
255
+ for (const action of this.contextFilteredActions()) {
256
+ const label = (action.label || "").trim();
257
+ const labelLower = label.toLowerCase();
258
+ if (lower === labelLower || lower.startsWith(`${labelLower} `)) {
259
+ if (!best || label.length > (best.action.label || "").length) {
260
+ best = {
261
+ action,
262
+ remainder: trimmed.slice(label.length).trim(),
263
+ };
264
+ }
265
+ }
266
+ }
267
+ return best;
268
+ }
269
+
270
+ actionsForQuery(query) {
271
+ const actions = this.contextFilteredActions();
272
+ const match = this.findActionForQuery(query);
273
+ if (match) return [match.action];
274
+ return this.filterLocal(actions, query);
275
+ }
276
+
277
+ async refresh() {
278
+ const generation = ++this.queryGeneration;
279
+ this.abortInFlight();
280
+ this.abortController = new AbortController();
281
+ const signal = this.abortController.signal;
282
+
283
+ const mode = this.parseMode(this.queryTarget.value);
284
+ let items = [];
285
+ let status = null;
286
+
287
+ try {
288
+ if (mode.name === "help") {
289
+ items = this.helpItemsValue || [];
290
+ } else if (mode.name === "actions") {
291
+ items = this.actionsForQuery(mode.query);
292
+ const match = this.findActionForQuery(mode.query);
293
+ if (match?.action?.verb === "view" && match.remainder.length >= this.minimumQueryLength()) {
294
+ const result = await this.fetchRecords(match.action.finder_kind, match.remainder, signal);
295
+ if (result.aborted) return;
296
+ if (result.error) {
297
+ status = result.error;
298
+ } else {
299
+ items = this.mergeUnique(items, result.items);
300
+ }
301
+ }
302
+ if (!mode.query) {
303
+ items = this.mergeUnique(this.recentItems("action"), items);
304
+ }
305
+ } else if (mode.name === "kind") {
306
+ const result = await this.fetchRecords(mode.kind, mode.query, signal);
307
+ if (result.aborted) return;
308
+ if (result.error) {
309
+ status = result.error;
310
+ items = [];
311
+ } else {
312
+ items = result.items;
313
+ }
314
+ } else {
315
+ const navigate = this.filterLocal(this.navigateValue, mode.query);
316
+ const recent = mode.query ? [] : this.recentItems();
317
+ let records = [];
318
+ if (mode.query.length >= this.minimumQueryLength()) {
319
+ const result = await this.fetchAllRecords(mode.query, signal);
320
+ if (result.aborted) return;
321
+ if (result.error) {
322
+ status = result.error;
323
+ } else {
324
+ records = result.items;
325
+ }
326
+ }
327
+ items = this.mergeUnique(recent, navigate, records);
328
+ }
329
+ } catch (_error) {
330
+ if (signal.aborted || generation !== this.queryGeneration) return;
331
+ status = "error";
332
+ items = this.filterLocal(
333
+ this.mergeUnique(this.navigateValue, this.contextFilteredActions()),
334
+ this.parseMode(this.queryTarget.value).query
335
+ );
336
+ }
337
+
338
+ if (generation !== this.queryGeneration) return;
339
+
340
+ this.status = status;
341
+ this.visibleItems = items.slice(0, LIST_LIMIT);
342
+ this.highlightedIndex = this.visibleItems.length > 0 ? 0 : -1;
343
+ this.render();
344
+ }
345
+
346
+ async fetchAllRecords(query, signal) {
347
+ const kinds = this.hasKindsValue ? this.kindsValue : [];
348
+ if (!kinds.length) return { items: [] };
349
+
350
+ const result = await this.fetchOptions({ kinds, q: query }, signal);
351
+ if (result.items) result.items = result.items.slice(0, RECORD_LIMIT);
352
+ return result;
353
+ }
354
+
355
+ async fetchRecords(kind, query, signal) {
356
+ if (!query || query.length < this.minimumQueryLength()) return { items: [] };
357
+ if (!this.optionsUrlValue) return { items: [] };
358
+
359
+ return this.fetchOptions({ kind, q: query }, signal);
360
+ }
361
+
362
+ async fetchOptions(payload, signal) {
363
+ const requestController = new AbortController();
364
+ let timedOut = false;
365
+ const abortRequest = () => requestController.abort();
366
+ if (signal?.aborted) return { aborted: true };
367
+ signal?.addEventListener("abort", abortRequest, { once: true });
368
+ const requestTimeout = setTimeout(() => {
369
+ timedOut = true;
370
+ requestController.abort();
371
+ }, this.requestTimeoutMilliseconds());
372
+
373
+ try {
374
+ const response = await fetch(this.optionsUrlValue, {
375
+ body: JSON.stringify(payload),
376
+ headers: {
377
+ Accept: "application/json",
378
+ "Content-Type": "application/json",
379
+ "X-CSRF-Token": this.csrfValue,
380
+ },
381
+ credentials: "same-origin",
382
+ method: "POST",
383
+ signal: requestController.signal,
384
+ });
385
+ if (response.status === 401) return { error: "unauthorized" };
386
+ if (response.status === 403) return { error: "forbidden" };
387
+ if (!response.ok) return { error: "error" };
388
+ const items = await response.json();
389
+ if (!Array.isArray(items)) return { error: "error" };
390
+ return {
391
+ items: items.map((item) => ({
392
+ ...item,
393
+ kind: item.kind || payload.kind,
394
+ method: item.method || "get",
395
+ })),
396
+ };
397
+ } catch (error) {
398
+ if (signal?.aborted) return { aborted: true };
399
+ if (timedOut) return { error: "error" };
400
+ if (error?.name === "AbortError") return { aborted: true };
401
+ return { error: "error" };
402
+ } finally {
403
+ clearTimeout(requestTimeout);
404
+ signal?.removeEventListener("abort", abortRequest);
405
+ }
406
+ }
407
+
408
+ minimumQueryLength() {
409
+ const value = this.hasMinQueryLengthValue ? Number(this.minQueryLengthValue) : 2;
410
+ return Number.isFinite(value) && value >= 0 ? value : 2;
411
+ }
412
+
413
+ requestTimeoutMilliseconds() {
414
+ const value = this.hasRequestTimeoutValue
415
+ ? Number(this.requestTimeoutValue)
416
+ : REQUEST_TIMEOUT_MS;
417
+ return Number.isFinite(value) && value > 0 ? value : REQUEST_TIMEOUT_MS;
418
+ }
419
+
420
+ filterLocal(items, query) {
421
+ if (!query) return items || [];
422
+ const needle = query.toLowerCase();
423
+ return (items || []).filter((item) => this.itemMatchesQuery(item, needle));
424
+ }
425
+
426
+ itemMatchesQuery(item, needle) {
427
+ const fields = [item.label, item.detail, item.kind, ...(item.keywords || [])];
428
+ return fields.some((field) => this.matchesText(field, needle));
429
+ }
430
+
431
+ matchesText(haystack, needle) {
432
+ if (!needle) return true;
433
+ const text = String(haystack || "").toLowerCase();
434
+ if (!text) return false;
435
+ if (text.includes(needle)) return true;
436
+
437
+ let index = 0;
438
+ for (const character of text) {
439
+ if (character === needle[index]) index += 1;
440
+ if (index === needle.length) return true;
441
+ }
442
+ return false;
443
+ }
444
+
445
+ mergeUnique(...groups) {
446
+ const seen = new Set();
447
+ const result = [];
448
+ groups.flat().forEach((item) => {
449
+ if (!item) return;
450
+ const key = `${item.kind}:${item.id || item.url}`;
451
+ if (seen.has(key)) return;
452
+ seen.add(key);
453
+ result.push(item);
454
+ });
455
+ return result;
456
+ }
457
+
458
+ recentItems(kind = null) {
459
+ try {
460
+ const raw = JSON.parse(sessionStorage.getItem(RECENT_KEY) || "[]");
461
+ const currentItems = this.mergeUnique(
462
+ this.navigateValue || [],
463
+ this.contextFilteredActions()
464
+ ).filter((item) => {
465
+ if (!item?.url || !STATIC_KINDS.has(item.kind)) return false;
466
+ if ((item.method || "get").toLowerCase() !== "get") return false;
467
+ return (item.scope || "global") !== "member";
468
+ });
469
+ const currentByKey = new Map(
470
+ currentItems.map((item) => [`${item.kind}:${item.id || item.url}`, item])
471
+ );
472
+
473
+ return raw.flatMap((item) => {
474
+ if (!STATIC_KINDS.has(item.kind)) return [];
475
+ if (kind && item.kind !== kind) return [];
476
+ const current = currentByKey.get(`${item.kind}:${item.id || item.url}`);
477
+ return current ? [current] : [];
478
+ });
479
+ } catch (_error) {
480
+ return [];
481
+ }
482
+ }
483
+
484
+ remember(item) {
485
+ if (!item?.url || !STATIC_KINDS.has(item.kind)) return;
486
+ if ((item.method || "get").toLowerCase() !== "get") return;
487
+ if ((item.scope || "global") === "member") return;
488
+ const next = this.mergeUnique([{ ...item, id: item.id || item.url }], this.recentItems()).slice(
489
+ 0,
490
+ RECENT_LIMIT
491
+ );
492
+ sessionStorage.setItem(RECENT_KEY, JSON.stringify(next));
493
+ }
494
+
495
+ render() {
496
+ this.listTarget.replaceChildren();
497
+ this.announceStatus();
498
+
499
+ if (this.status === "loading" && this.visibleItems.length === 0) {
500
+ this.appendStatusRow(this.loadingValue);
501
+ this.queryTarget.removeAttribute("aria-activedescendant");
502
+ return;
503
+ }
504
+
505
+ let previousKind = null;
506
+ this.visibleItems.forEach((item, index) => {
507
+ const kind = item.kind || "go";
508
+ if (kind !== previousKind) {
509
+ this.appendSectionHeader(kind);
510
+ previousKind = kind;
511
+ }
512
+
513
+ const row = document.createElement("li");
514
+ const optionId = `activeadmin-command-palette-option-${index}`;
515
+ row.id = optionId;
516
+ row.className = this.rowClass(index);
517
+ row.dataset.index = String(index);
518
+ row.dataset.action = "mousedown->activeadmin-command-palette--command-palette#activateRow";
519
+ row.setAttribute("role", "option");
520
+ row.setAttribute("aria-selected", index === this.highlightedIndex ? "true" : "false");
521
+
522
+ const badge = document.createElement("span");
523
+ badge.className =
524
+ "me-2 shrink-0 text-[10px] uppercase tracking-wide text-gray-500 dark:text-gray-400";
525
+ badge.textContent = kind;
526
+
527
+ const text = document.createElement("span");
528
+ text.className = "min-w-0 flex flex-col";
529
+
530
+ const label = document.createElement("span");
531
+ label.textContent = item.label;
532
+ text.appendChild(label);
533
+
534
+ if (item.detail) {
535
+ const detail = document.createElement("span");
536
+ detail.className = "text-xs text-gray-500 dark:text-gray-400";
537
+ detail.textContent = item.detail;
538
+ text.appendChild(detail);
539
+ }
540
+
541
+ row.append(badge, text);
542
+ this.listTarget.appendChild(row);
543
+ });
544
+
545
+ if (this.status === "unauthorized") {
546
+ this.appendStatusRow(this.unauthorizedValue, this.signInPathValue);
547
+ } else if (this.status === "forbidden") {
548
+ this.appendStatusRow(this.forbiddenValue);
549
+ } else if (this.status === "error") {
550
+ this.appendStatusRow(this.errorValue);
551
+ } else if (this.status === "loading") {
552
+ this.appendStatusRow(this.loadingValue);
553
+ } else if (this.visibleItems.length === 0) {
554
+ this.appendStatusRow(this.emptyValue);
555
+ }
556
+
557
+ if (this.highlightedIndex >= 0 && this.visibleItems.length > 0) {
558
+ this.queryTarget.setAttribute(
559
+ "aria-activedescendant",
560
+ `activeadmin-command-palette-option-${this.highlightedIndex}`
561
+ );
562
+ } else {
563
+ this.queryTarget.removeAttribute("aria-activedescendant");
564
+ }
565
+ }
566
+
567
+ appendSectionHeader(kind) {
568
+ const header = document.createElement("li");
569
+ header.className =
570
+ "px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500";
571
+ header.setAttribute("role", "presentation");
572
+ header.textContent = kind;
573
+ this.listTarget.appendChild(header);
574
+ }
575
+
576
+ appendStatusRow(text, href = null) {
577
+ const empty = document.createElement("li");
578
+ empty.className = "px-3 py-3 text-sm text-gray-500 dark:text-gray-400";
579
+ empty.setAttribute("role", "presentation");
580
+ if (href) {
581
+ const link = document.createElement("a");
582
+ link.href = href;
583
+ link.className = "underline";
584
+ link.textContent = text;
585
+ empty.appendChild(link);
586
+ } else {
587
+ empty.textContent = text;
588
+ }
589
+ this.listTarget.appendChild(empty);
590
+ }
591
+
592
+ announceStatus() {
593
+ if (!this.hasAnnouncerTarget) return;
594
+
595
+ const messages = {
596
+ loading: this.loadingValue,
597
+ unauthorized: this.unauthorizedValue,
598
+ forbidden: this.forbiddenValue,
599
+ error: this.errorValue,
600
+ };
601
+ const message =
602
+ messages[this.status] ||
603
+ (!this.status && this.visibleItems?.length === 0 ? this.emptyValue : "");
604
+ this.announcerTarget.textContent = message;
605
+ this.queryTarget?.setAttribute("aria-busy", this.status === "loading" ? "true" : "false");
606
+ }
607
+
608
+ rowClass(index) {
609
+ const base =
610
+ "flex cursor-pointer items-center px-3 py-2 text-sm text-gray-900 dark:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-800";
611
+ return index === this.highlightedIndex ? `${base} bg-gray-100 dark:bg-gray-800` : base;
612
+ }
613
+
614
+ moveHighlight(delta) {
615
+ if (this.visibleItems.length === 0) return;
616
+ this.highlightedIndex =
617
+ (this.highlightedIndex + delta + this.visibleItems.length) % this.visibleItems.length;
618
+ this.render();
619
+ }
620
+
621
+ activateRow(event) {
622
+ event.preventDefault();
623
+ const index = Number(event.currentTarget.dataset.index);
624
+ this.highlightedIndex = index;
625
+ this.activateHighlighted();
626
+ }
627
+
628
+ activateHighlighted() {
629
+ let index = this.highlightedIndex;
630
+ if (index < 0 && this.visibleItems.length === 1) {
631
+ index = 0;
632
+ }
633
+ const item = this.visibleItems[index];
634
+ if (!item || item.kind === "help") return;
635
+
636
+ if (item.kind === "action") {
637
+ const mode = this.parseMode(this.queryTarget.value);
638
+ const remainder =
639
+ mode.name === "actions" ? this.findActionForQuery(mode.query)?.remainder || "" : "";
640
+ this.activateAction(item, remainder);
641
+ return;
642
+ }
643
+
644
+ if (!item.url) return;
645
+ this.navigateItem(item);
646
+ }
647
+
648
+ activateAction(action, remainder) {
649
+ const verb = (action.verb || "").toString();
650
+
651
+ if (verb === "view") {
652
+ if (!action.finder_kind) return;
653
+ this.enterKindFind(action.finder_kind, remainder);
654
+ return;
655
+ }
656
+
657
+ if (verb === "create") {
658
+ const parsed = this.parseActionParams(remainder);
659
+ const missing = (action.params || []).filter(
660
+ (param) => param.required && (parsed[param.name] == null || parsed[param.name] === "")
661
+ );
662
+ if (missing.length || (!action.url && remainder)) {
663
+ this.showActionHelp(action);
664
+ return;
665
+ }
666
+ const url = this.applyQueryParams(action.url, action, parsed);
667
+ if (!url) {
668
+ this.showActionHelp(action);
669
+ return;
670
+ }
671
+ const safeUrl = this.safeNavigationUrl(url);
672
+ if (!safeUrl) {
673
+ this.showNavigationError();
674
+ return;
675
+ }
676
+ this.remember(action);
677
+ this.close();
678
+ window.location.assign(safeUrl);
679
+ return;
680
+ }
681
+
682
+ if (!action.url) {
683
+ if (action.help || (action.params || []).length) {
684
+ this.showActionHelp(action);
685
+ }
686
+ return;
687
+ }
688
+
689
+ this.navigateItem(action);
690
+ }
691
+
692
+ enterKindFind(kind, query = "") {
693
+ this.forcedKind = kind;
694
+ this.queryTarget.value = query || "";
695
+ this.refresh();
696
+ this.queryTarget.focus();
697
+ }
698
+
699
+ showActionHelp(action) {
700
+ const rows = [];
701
+ if (action.help) {
702
+ rows.push({
703
+ id: `${action.id}-help`,
704
+ kind: "help",
705
+ label: action.help,
706
+ url: null,
707
+ });
708
+ }
709
+ (action.params || []).forEach((param) => {
710
+ const required = param.required ? this.requiredValue : this.optionalValue;
711
+ rows.push({
712
+ id: `${action.id}-param-${param.name}`,
713
+ kind: "help",
714
+ label: `${param.name}= (${required})${param.help ? ` — ${param.help}` : ""}`,
715
+ url: null,
716
+ });
717
+ });
718
+ this.status = null;
719
+ this.visibleItems = rows.slice(0, LIST_LIMIT);
720
+ this.highlightedIndex = this.visibleItems.length > 0 ? 0 : -1;
721
+ this.render();
722
+ }
723
+
724
+ parseActionParams(remainder) {
725
+ const result = {};
726
+ String(remainder || "")
727
+ .split(/\s+/)
728
+ .filter(Boolean)
729
+ .forEach((token) => {
730
+ const eq = token.indexOf("=");
731
+ if (eq <= 0) return;
732
+ result[token.slice(0, eq)] = token.slice(eq + 1);
733
+ });
734
+ return result;
735
+ }
736
+
737
+ applyQueryParams(url, action, parsed) {
738
+ if (!url) return null;
739
+ const search = new URLSearchParams();
740
+ (action.params || []).forEach((param) => {
741
+ const value = parsed[param.name];
742
+ if (value == null || value === "") return;
743
+ search.append(param.query_key || param.name, value);
744
+ });
745
+ const qs = search.toString();
746
+ if (!qs) return url;
747
+ const fragmentIndex = url.indexOf("#");
748
+ const base = fragmentIndex >= 0 ? url.slice(0, fragmentIndex) : url;
749
+ const fragment = fragmentIndex >= 0 ? url.slice(fragmentIndex) : "";
750
+ const separator = base.includes("?") ? "&" : "?";
751
+ return `${base}${separator}${qs}${fragment}`;
752
+ }
753
+
754
+ navigateItem(item) {
755
+ const method = (item.method || "get").toLowerCase();
756
+ const mutating = method === "post" || method === "put" || method === "delete";
757
+ const safeUrl = this.safeNavigationUrl(item.url, { sameOrigin: mutating });
758
+ if (!safeUrl) {
759
+ this.showNavigationError();
760
+ return;
761
+ }
762
+ if (mutating) {
763
+ if (!item.confirm) return;
764
+ if (!window.confirm(item.confirm)) return;
765
+ this.remember(item);
766
+ this.close();
767
+ this.submitMethod(safeUrl, method);
768
+ return;
769
+ }
770
+
771
+ this.remember(item);
772
+ this.close();
773
+ window.location.assign(safeUrl);
774
+ }
775
+
776
+ safeNavigationUrl(url, { sameOrigin = false } = {}) {
777
+ if (!url) return null;
778
+
779
+ try {
780
+ const resolved = new URL(url, window.location.href);
781
+ if (resolved.protocol !== "http:" && resolved.protocol !== "https:") return null;
782
+ if (sameOrigin && resolved.origin !== window.location.origin) return null;
783
+ return url;
784
+ } catch (_error) {
785
+ return null;
786
+ }
787
+ }
788
+
789
+ showNavigationError() {
790
+ this.status = "error";
791
+ this.render();
792
+ }
793
+
794
+ submitMethod(url, method) {
795
+ const form = document.createElement("form");
796
+ form.method = "post";
797
+ form.action = url;
798
+ form.style.display = "none";
799
+
800
+ const csrf = document.createElement("input");
801
+ csrf.type = "hidden";
802
+ csrf.name = "authenticity_token";
803
+ csrf.value = this.csrfValue;
804
+ form.appendChild(csrf);
805
+
806
+ if (method !== "post") {
807
+ const methodInput = document.createElement("input");
808
+ methodInput.type = "hidden";
809
+ methodInput.name = "_method";
810
+ methodInput.value = method;
811
+ form.appendChild(methodInput);
812
+ }
813
+
814
+ document.body.appendChild(form);
815
+ form.submit();
816
+ }
817
+ }