@unabridged/midwest 0.22.1 → 0.23.1

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 (31) hide show
  1. package/README.md +1 -1
  2. package/app/assets/javascript/midwest/index.ts +8 -0
  3. package/app/assets/javascript/midwest/keyboard_navigation.ts +68 -0
  4. package/app/assets/javascript/midwest/markdown.ts +79 -0
  5. package/app/assets/javascript/midwest.js +795 -39
  6. package/app/assets/javascript/midwest.js.map +1 -1
  7. package/app/assets/stylesheets/midwest/utilities.css +4 -0
  8. package/app/assets/stylesheets/midwest.css +1 -1
  9. package/app/assets/stylesheets/midwest.tailwind.css +13 -1
  10. package/dist/css/midwest.css +1 -1
  11. package/dist/javascript/collection/app/assets/javascript/midwest/index.js +8 -0
  12. package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
  13. package/dist/javascript/collection/app/assets/javascript/midwest/keyboard_navigation.js +42 -0
  14. package/dist/javascript/collection/app/assets/javascript/midwest/keyboard_navigation.js.map +1 -0
  15. package/dist/javascript/collection/app/assets/javascript/midwest/markdown.js +52 -0
  16. package/dist/javascript/collection/app/assets/javascript/midwest/markdown.js.map +1 -0
  17. package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js +270 -2
  18. package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js.map +1 -1
  19. package/dist/javascript/collection/app/components/midwest/dropdown_component/dropdown_component_controller.js +8 -37
  20. package/dist/javascript/collection/app/components/midwest/dropdown_component/dropdown_component_controller.js.map +1 -1
  21. package/dist/javascript/collection/app/components/midwest/focus_trap_component/focus_trap_component_controller.js +83 -0
  22. package/dist/javascript/collection/app/components/midwest/focus_trap_component/focus_trap_component_controller.js.map +1 -0
  23. package/dist/javascript/collection/app/components/midwest/form/switch_component/theme_switch_controller.js +42 -0
  24. package/dist/javascript/collection/app/components/midwest/form/switch_component/theme_switch_controller.js.map +1 -0
  25. package/dist/javascript/collection/app/components/midwest/intersection_observer_component/intersection_observer_component_controller.js +43 -0
  26. package/dist/javascript/collection/app/components/midwest/intersection_observer_component/intersection_observer_component_controller.js.map +1 -0
  27. package/dist/javascript/collection/app/components/midwest/onboarding_component/onboarding_component_controller.js +273 -0
  28. package/dist/javascript/collection/app/components/midwest/onboarding_component/onboarding_component_controller.js.map +1 -0
  29. package/dist/javascript/midwest.js +795 -39
  30. package/dist/javascript/midwest.js.map +1 -1
  31. package/package.json +1 -1
@@ -2511,6 +2511,46 @@ const computePosition = (reference, floating, options) => {
2511
2511
  });
2512
2512
  };
2513
2513
 
2514
+ const NAVIGATION_KEYS = ["ArrowDown", "ArrowUp", "Home", "End", "Escape", "Tab"];
2515
+ function handleRovingFocusKeydown(event, items, options = {}) {
2516
+ if (items.length === 0)
2517
+ return false;
2518
+ if (!NAVIGATION_KEYS.includes(event.key))
2519
+ return false;
2520
+ const { onEscape, onTab, loop = true } = options;
2521
+ const current = items.findIndex((item) => item === document.activeElement);
2522
+ switch (event.key) {
2523
+ case "ArrowDown": {
2524
+ event.preventDefault();
2525
+ const next = current < items.length - 1 ? current + 1 : loop ? 0 : items.length - 1;
2526
+ items[next].focus();
2527
+ break;
2528
+ }
2529
+ case "ArrowUp": {
2530
+ event.preventDefault();
2531
+ const prev = current > 0 ? current - 1 : loop ? items.length - 1 : 0;
2532
+ items[prev].focus();
2533
+ break;
2534
+ }
2535
+ case "Home":
2536
+ event.preventDefault();
2537
+ items[0].focus();
2538
+ break;
2539
+ case "End":
2540
+ event.preventDefault();
2541
+ items[items.length - 1].focus();
2542
+ break;
2543
+ case "Escape":
2544
+ event.preventDefault();
2545
+ onEscape?.();
2546
+ break;
2547
+ case "Tab":
2548
+ onTab?.();
2549
+ break;
2550
+ }
2551
+ return true;
2552
+ }
2553
+
2514
2554
  class Dropdown extends Controller {
2515
2555
  static values = {
2516
2556
  computed: { type: Boolean, default: false },
@@ -2564,45 +2604,15 @@ class Dropdown extends Controller {
2564
2604
  }
2565
2605
  };
2566
2606
  handleKeydown = (event) => {
2567
- const items = this.menuItems;
2568
- if (items.length === 0)
2569
- return;
2570
- if (!["ArrowDown", "ArrowUp", "Home", "End", "Escape", "Tab"].includes(event.key))
2571
- return;
2572
- event.stopPropagation();
2573
- const currentIndex = items.findIndex((item) => item === document.activeElement);
2574
- switch (event.key) {
2575
- case "ArrowDown": {
2576
- event.preventDefault();
2577
- const next = currentIndex < items.length - 1 ? currentIndex + 1 : 0;
2578
- items[next].focus();
2579
- break;
2580
- }
2581
- case "ArrowUp": {
2582
- event.preventDefault();
2583
- const prev = currentIndex > 0 ? currentIndex - 1 : items.length - 1;
2584
- items[prev].focus();
2585
- break;
2586
- }
2587
- case "Home":
2588
- event.preventDefault();
2589
- items[0].focus();
2590
- break;
2591
- case "End":
2592
- event.preventDefault();
2593
- items[items.length - 1].focus();
2594
- break;
2595
- case "Escape":
2596
- event.preventDefault();
2607
+ const handled = handleRovingFocusKeydown(event, this.menuItems, {
2608
+ onEscape: () => {
2597
2609
  this.element.hidePopover();
2598
2610
  requestAnimationFrame(() => this.buttonEl?.focus());
2599
- break;
2600
- case "Tab":
2601
- this.element.hidePopover();
2602
- break;
2603
- default:
2604
- return;
2605
- }
2611
+ },
2612
+ onTab: () => this.element.hidePopover()
2613
+ });
2614
+ if (handled)
2615
+ event.stopPropagation();
2606
2616
  };
2607
2617
  get menuItems() {
2608
2618
  return Array.from(
@@ -5364,20 +5374,97 @@ function cloneTemplate(template) {
5364
5374
  return el;
5365
5375
  }
5366
5376
 
5377
+ const PLACEHOLDER = "\uE000";
5378
+ function renderMarkdown(src) {
5379
+ const blocks = [];
5380
+ const withoutFences = src.replace(/```(\w*)\n?([\s\S]*?)```/g, (_match, _lang, code) => {
5381
+ const body = escapeHtml(code.replace(/\n$/, ""));
5382
+ blocks.push(`<pre class="command-palette-code"><code>${body}</code></pre>`);
5383
+ return `${PLACEHOLDER}${blocks.length - 1}${PLACEHOLDER}`;
5384
+ });
5385
+ const lines = escapeHtml(withoutFences).split("\n");
5386
+ const out = [];
5387
+ let listOpen = false;
5388
+ const closeList = () => {
5389
+ if (listOpen) {
5390
+ out.push("</ul>");
5391
+ listOpen = false;
5392
+ }
5393
+ };
5394
+ for (const line of lines) {
5395
+ const placeholder = line.match(new RegExp(`^${PLACEHOLDER}(\\d+)${PLACEHOLDER}$`));
5396
+ if (placeholder) {
5397
+ closeList();
5398
+ out.push(line);
5399
+ continue;
5400
+ }
5401
+ const item = line.match(/^\s*[-*]\s+(.*)$/);
5402
+ if (item) {
5403
+ if (!listOpen) {
5404
+ out.push("<ul>");
5405
+ listOpen = true;
5406
+ }
5407
+ out.push(`<li>${inline(item[1])}</li>`);
5408
+ continue;
5409
+ }
5410
+ closeList();
5411
+ if (line.trim() !== "")
5412
+ out.push(`<p>${inline(line)}</p>`);
5413
+ }
5414
+ closeList();
5415
+ return out.join("\n").replace(new RegExp(`${PLACEHOLDER}(\\d+)${PLACEHOLDER}`, "g"), (_match, index) => blocks[Number(index)]);
5416
+ }
5417
+ function inline(text) {
5418
+ return text.replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(
5419
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
5420
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>'
5421
+ );
5422
+ }
5423
+ function escapeHtml(text) {
5424
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
5425
+ }
5426
+
5367
5427
  class CommandPalette extends Controller {
5368
- static targets = ["input", "list", "empty", "body", "turboList", "itemTemplate", "groupTemplate"];
5428
+ static targets = [
5429
+ "input",
5430
+ "list",
5431
+ "empty",
5432
+ "body",
5433
+ "turboList",
5434
+ "itemTemplate",
5435
+ "groupTemplate",
5436
+ "modes",
5437
+ "chat",
5438
+ "transcript",
5439
+ "starters",
5440
+ "messageTemplate",
5441
+ "actionTemplate"
5442
+ ];
5369
5443
  static values = {
5370
5444
  hotkey: { type: String, default: "k" },
5371
5445
  items: { type: Array, default: [] },
5372
5446
  src: { type: String, default: "" },
5373
- turboFrameUrl: { type: String, default: "" }
5447
+ turboFrameUrl: { type: String, default: "" },
5448
+ chatEnabled: { type: Boolean, default: false },
5449
+ chatMode: { type: String, default: "proxy" },
5450
+ chatTrigger: { type: String, default: "toggle" },
5451
+ chatAgentic: { type: Boolean, default: true },
5452
+ chatPlaceholder: { type: String, default: "Ask AI\u2026" },
5453
+ chatEndpoint: { type: String, default: "" },
5454
+ chatUrl: { type: String, default: "" },
5455
+ chatModel: { type: String, default: "" }
5374
5456
  };
5375
5457
  highlightedIndex = -1;
5376
5458
  fetchAbort = null;
5377
5459
  searchTimer;
5378
5460
  allItems = [];
5461
+ mode = "command";
5462
+ commandPlaceholder = "";
5463
+ messages = [];
5464
+ streamAbort = null;
5379
5465
  connect() {
5380
5466
  this.allItems = this.itemsValue;
5467
+ this.commandPlaceholder = this.inputTarget.placeholder;
5381
5468
  document.addEventListener("keydown", this.handleGlobalKeydown);
5382
5469
  this.element.addEventListener("click", this.handleBackdropClick);
5383
5470
  if (!this.turboFrameUrlValue) {
@@ -5389,6 +5476,8 @@ class CommandPalette extends Controller {
5389
5476
  this.element.removeEventListener("click", this.handleBackdropClick);
5390
5477
  if (this.fetchAbort)
5391
5478
  this.fetchAbort.abort();
5479
+ if (this.streamAbort)
5480
+ this.streamAbort.abort();
5392
5481
  if (this.searchTimer)
5393
5482
  clearTimeout(this.searchTimer);
5394
5483
  }
@@ -5422,6 +5511,8 @@ class CommandPalette extends Controller {
5422
5511
  this.element.showModal();
5423
5512
  this.inputTarget.value = "";
5424
5513
  this.highlightedIndex = -1;
5514
+ if (this.chatEnabledValue)
5515
+ this.resetChat();
5425
5516
  if (this.turboFrameUrlValue) {
5426
5517
  this.navigateFrame("");
5427
5518
  } else {
@@ -5435,6 +5526,8 @@ class CommandPalette extends Controller {
5435
5526
  this.element.close();
5436
5527
  }
5437
5528
  filter() {
5529
+ if (this.mode === "chat")
5530
+ return;
5438
5531
  const query = this.inputTarget.value.trim().toLowerCase();
5439
5532
  if (this.turboFrameUrlValue) {
5440
5533
  this.debouncedNavigateFrame(query);
@@ -5455,6 +5548,21 @@ class CommandPalette extends Controller {
5455
5548
  this.renderItems(filtered);
5456
5549
  }
5457
5550
  navigate(event) {
5551
+ if (this.mode === "chat") {
5552
+ if (event.key === "Enter" && !event.shiftKey) {
5553
+ event.preventDefault();
5554
+ this.send();
5555
+ } else if (event.key === "Escape") {
5556
+ event.preventDefault();
5557
+ this.close();
5558
+ }
5559
+ return;
5560
+ }
5561
+ if (event.key === "Tab" && !event.shiftKey && this.chatEnabledValue && this.chatTriggerValue === "toggle" && this.inputTarget.value.trim() === "") {
5562
+ event.preventDefault();
5563
+ this.setMode("chat");
5564
+ return;
5565
+ }
5458
5566
  const items = this.visibleItems;
5459
5567
  switch (event.key) {
5460
5568
  case "ArrowDown":
@@ -5489,6 +5597,225 @@ class CommandPalette extends Controller {
5489
5597
  this.selectItem(items[index]);
5490
5598
  }
5491
5599
  }
5600
+ // ── Chat mode ───────────────────────────────────────────────────────
5601
+ switchMode(event) {
5602
+ const mode = event.currentTarget.dataset.mode === "chat" ? "chat" : "command";
5603
+ this.setMode(mode);
5604
+ if (mode === "command")
5605
+ this.filter();
5606
+ this.inputTarget.focus();
5607
+ }
5608
+ useStarter(event) {
5609
+ this.inputTarget.value = event.currentTarget.textContent?.trim() || "";
5610
+ this.send();
5611
+ }
5612
+ resetChat() {
5613
+ if (this.streamAbort)
5614
+ this.streamAbort.abort();
5615
+ this.messages = [];
5616
+ if (this.hasTranscriptTarget)
5617
+ this.transcriptTarget.innerHTML = "";
5618
+ if (this.hasStartersTarget)
5619
+ this.startersTarget.hidden = false;
5620
+ this.setBusy(false);
5621
+ this.setMode("command");
5622
+ }
5623
+ setMode(mode) {
5624
+ if (!this.chatEnabledValue)
5625
+ return;
5626
+ this.mode = mode;
5627
+ const chat = mode === "chat";
5628
+ this.element.classList.toggle("chat-active", chat);
5629
+ if (this.hasModesTarget) {
5630
+ this.modesTarget.querySelectorAll(".command-palette-mode").forEach((el) => {
5631
+ el.setAttribute("aria-pressed", String(el.dataset.mode === mode));
5632
+ });
5633
+ }
5634
+ if (this.hasChatTarget)
5635
+ this.chatTarget.hidden = !chat;
5636
+ if (chat) {
5637
+ this.emptyTarget.hidden = true;
5638
+ if (this.hasListTarget)
5639
+ this.listTarget.hidden = true;
5640
+ if (this.hasTurboListTarget)
5641
+ this.turboListTarget.hidden = true;
5642
+ this.inputTarget.placeholder = this.chatPlaceholderValue;
5643
+ } else {
5644
+ this.inputTarget.placeholder = this.commandPlaceholder;
5645
+ if (this.hasTurboListTarget)
5646
+ this.turboListTarget.hidden = false;
5647
+ }
5648
+ }
5649
+ stop() {
5650
+ if (this.streamAbort)
5651
+ this.streamAbort.abort();
5652
+ }
5653
+ send() {
5654
+ if (this.streamAbort)
5655
+ return;
5656
+ const text = this.inputTarget.value.trim();
5657
+ if (!text)
5658
+ return;
5659
+ this.appendMessage("user", text);
5660
+ this.messages.push({ role: "user", content: text });
5661
+ this.inputTarget.value = "";
5662
+ if (this.hasStartersTarget)
5663
+ this.startersTarget.hidden = true;
5664
+ void this.streamReply();
5665
+ }
5666
+ async streamReply() {
5667
+ const message = this.appendMessage("assistant", "");
5668
+ const bubble = message.querySelector(".command-palette-message-bubble");
5669
+ this.setBusy(true);
5670
+ this.streamAbort = new AbortController();
5671
+ let reply = "";
5672
+ try {
5673
+ const response = await fetch(this.chatRequestUrl(), {
5674
+ method: "POST",
5675
+ headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
5676
+ body: JSON.stringify(this.chatRequestBody()),
5677
+ signal: this.streamAbort.signal
5678
+ });
5679
+ if (!response.ok || !response.body)
5680
+ throw new Error(`Chat request failed (${response.status})`);
5681
+ const reader = response.body.getReader();
5682
+ const decoder = new TextDecoder();
5683
+ let buffer = "";
5684
+ for (; ; ) {
5685
+ const { value, done } = await reader.read();
5686
+ if (done)
5687
+ break;
5688
+ buffer += decoder.decode(value, { stream: true });
5689
+ const events = buffer.split("\n\n");
5690
+ buffer = events.pop() || "";
5691
+ for (const event of events) {
5692
+ const data = this.parseSseData(event);
5693
+ if (!data)
5694
+ continue;
5695
+ const delta = data.choices?.[0]?.delta?.content;
5696
+ if (delta) {
5697
+ reply += delta;
5698
+ bubble.innerHTML = renderMarkdown(reply);
5699
+ this.scrollTranscript();
5700
+ } else if (data.midwest) {
5701
+ this.handleAgentEvent(data.midwest);
5702
+ }
5703
+ }
5704
+ }
5705
+ this.messages.push({ role: "assistant", content: reply });
5706
+ } catch (error) {
5707
+ if (error.name === "AbortError") {
5708
+ if (reply)
5709
+ this.messages.push({ role: "assistant", content: reply });
5710
+ } else {
5711
+ message.classList.add("is-error");
5712
+ bubble.textContent = "Something went wrong. Please try again.";
5713
+ }
5714
+ } finally {
5715
+ this.streamAbort = null;
5716
+ this.setBusy(false);
5717
+ this.scrollTranscript();
5718
+ }
5719
+ }
5720
+ parseSseData(event) {
5721
+ const data = event.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("");
5722
+ if (!data || data === "[DONE]")
5723
+ return null;
5724
+ try {
5725
+ return JSON.parse(data);
5726
+ } catch {
5727
+ return null;
5728
+ }
5729
+ }
5730
+ // Agentic events: the server streams a tool "action row" and/or a client
5731
+ // directive (navigate / run a palette command). Directives reuse the same
5732
+ // execution path as manual selection.
5733
+ handleAgentEvent(event) {
5734
+ if (event.type === "tool") {
5735
+ this.appendActionRow(event);
5736
+ } else if (event.type === "directive" && event.directive) {
5737
+ this.performDirective(event.directive);
5738
+ }
5739
+ }
5740
+ // Action rows live in the current assistant message's card footer.
5741
+ appendActionRow(event) {
5742
+ if (!this.hasActionTemplateTarget)
5743
+ return;
5744
+ const footer = this.currentActionFooter();
5745
+ if (!footer)
5746
+ return;
5747
+ const action = cloneTemplate(this.actionTemplateTarget);
5748
+ const label = action.querySelector(".command-palette-action-label");
5749
+ if (label)
5750
+ label.textContent = event.label || "Action";
5751
+ footer.hidden = false;
5752
+ footer.appendChild(action);
5753
+ this.scrollTranscript();
5754
+ }
5755
+ currentActionFooter() {
5756
+ const messages = this.transcriptTarget.querySelectorAll(
5757
+ ".command-palette-message.role-assistant"
5758
+ );
5759
+ const last = messages[messages.length - 1];
5760
+ return last ? last.querySelector(".command-palette-action-footer") : null;
5761
+ }
5762
+ performDirective(directive) {
5763
+ if (directive.url) {
5764
+ this.visit(directive.url, directive.method);
5765
+ } else if (directive.action) {
5766
+ this.element.dispatchEvent(
5767
+ new CustomEvent("midwest-command-palette:action", {
5768
+ bubbles: true,
5769
+ detail: { action: directive.action }
5770
+ })
5771
+ );
5772
+ }
5773
+ }
5774
+ chatRequestUrl() {
5775
+ return this.chatModeValue === "proxy" ? this.chatEndpointValue : this.chatUrlValue;
5776
+ }
5777
+ chatRequestBody() {
5778
+ const body = { messages: this.messages, stream: true };
5779
+ if (this.chatModeValue !== "proxy" && this.chatModelValue)
5780
+ body.model = this.chatModelValue;
5781
+ if (this.chatModeValue === "proxy" && this.chatAgenticValue) {
5782
+ body.commands = this.allItems.map((item) => ({
5783
+ label: item.label,
5784
+ description: item.description,
5785
+ url: item.url,
5786
+ action: item.action,
5787
+ method: item.method,
5788
+ icon: item.icon
5789
+ }));
5790
+ }
5791
+ return body;
5792
+ }
5793
+ setBusy(busy) {
5794
+ this.element.classList.toggle("chat-busy", busy);
5795
+ if (this.hasStopTarget)
5796
+ this.stopTarget.hidden = !busy;
5797
+ if (this.hasTranscriptTarget)
5798
+ this.transcriptTarget.setAttribute("aria-busy", String(busy));
5799
+ }
5800
+ scrollTranscript() {
5801
+ if (this.hasTranscriptTarget)
5802
+ this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight;
5803
+ }
5804
+ appendMessage(role, text) {
5805
+ const li = cloneTemplate(this.messageTemplateTarget);
5806
+ li.classList.add(`role-${role}`);
5807
+ if (role === "user")
5808
+ li.classList.add("is-end");
5809
+ const roleEl = li.querySelector(".command-palette-message-role");
5810
+ if (roleEl)
5811
+ roleEl.textContent = role === "user" ? "You" : "Assistant";
5812
+ const bubble = li.querySelector(".command-palette-message-bubble");
5813
+ if (text)
5814
+ bubble.textContent = text;
5815
+ this.transcriptTarget.appendChild(li);
5816
+ this.scrollTranscript();
5817
+ return li;
5818
+ }
5492
5819
  // ── Remote search ───────────────────────────────────────────────────
5493
5820
  fetchRemote(query) {
5494
5821
  if (this.fetchAbort)
@@ -6555,6 +6882,431 @@ class TimePicker extends Controller {
6555
6882
  }
6556
6883
  }
6557
6884
 
6885
+ class ViewportObserver extends Controller {
6886
+ static values = {
6887
+ threshold: { type: Number, default: 0 },
6888
+ rootMargin: { type: String, default: "0px" },
6889
+ once: { type: Boolean, default: false },
6890
+ visibleClass: { type: String, default: "" }
6891
+ };
6892
+ observer = null;
6893
+ connect() {
6894
+ this.observer = new IntersectionObserver(
6895
+ (entries) => {
6896
+ for (const entry of entries)
6897
+ this.handle(entry);
6898
+ },
6899
+ { threshold: this.thresholdValue, rootMargin: this.rootMarginValue }
6900
+ );
6901
+ this.observer.observe(this.element);
6902
+ }
6903
+ disconnect() {
6904
+ this.observer?.disconnect();
6905
+ this.observer = null;
6906
+ }
6907
+ handle(entry) {
6908
+ if (entry.isIntersecting) {
6909
+ this.element.setAttribute("data-intersecting", "true");
6910
+ if (this.visibleClassValue)
6911
+ this.element.classList.add(this.visibleClassValue);
6912
+ this.dispatch("enter", { detail: { entry }, bubbles: true });
6913
+ if (this.onceValue)
6914
+ this.disconnect();
6915
+ } else {
6916
+ this.element.setAttribute("data-intersecting", "false");
6917
+ if (this.visibleClassValue)
6918
+ this.element.classList.remove(this.visibleClassValue);
6919
+ this.dispatch("leave", { detail: { entry }, bubbles: true });
6920
+ }
6921
+ }
6922
+ }
6923
+
6924
+ const FOCUSABLE$1 = [
6925
+ "a[href]",
6926
+ "button:not([disabled])",
6927
+ "textarea:not([disabled])",
6928
+ "input:not([disabled])",
6929
+ "select:not([disabled])",
6930
+ '[tabindex]:not([tabindex="-1"])'
6931
+ ].join(", ");
6932
+ class FocusTrap extends Controller {
6933
+ static values = {
6934
+ active: { type: Boolean, default: true },
6935
+ returnFocus: { type: Boolean, default: true },
6936
+ initialFocus: { type: String, default: "" }
6937
+ };
6938
+ engaged = false;
6939
+ previousFocus = null;
6940
+ // Fires on connect for the initial value and on every subsequent change,
6941
+ // so it is the single entry point for engaging/releasing the trap.
6942
+ activeValueChanged(active) {
6943
+ if (active)
6944
+ this.engage();
6945
+ else
6946
+ this.release();
6947
+ }
6948
+ disconnect() {
6949
+ this.release();
6950
+ }
6951
+ engage() {
6952
+ if (this.engaged)
6953
+ return;
6954
+ this.engaged = true;
6955
+ this.previousFocus = document.activeElement;
6956
+ this.element.addEventListener("keydown", this.handleKeydown);
6957
+ requestAnimationFrame(() => {
6958
+ if (this.engaged)
6959
+ this.focusInitial();
6960
+ });
6961
+ }
6962
+ release() {
6963
+ if (!this.engaged)
6964
+ return;
6965
+ this.engaged = false;
6966
+ this.element.removeEventListener("keydown", this.handleKeydown);
6967
+ if (this.returnFocusValue)
6968
+ this.previousFocus?.focus();
6969
+ this.previousFocus = null;
6970
+ }
6971
+ focusInitial() {
6972
+ const target = this.initialFocusValue ? this.element.querySelector(this.initialFocusValue) : this.focusable()[0];
6973
+ target?.focus();
6974
+ }
6975
+ focusable() {
6976
+ return Array.from(
6977
+ this.element.querySelectorAll(FOCUSABLE$1)
6978
+ ).filter((el) => !el.closest("[inert]"));
6979
+ }
6980
+ handleKeydown = (event) => {
6981
+ if (event.key !== "Tab")
6982
+ return;
6983
+ const focusable = this.focusable();
6984
+ if (focusable.length === 0) {
6985
+ event.preventDefault();
6986
+ return;
6987
+ }
6988
+ const first = focusable[0];
6989
+ const last = focusable[focusable.length - 1];
6990
+ const active = document.activeElement;
6991
+ if (event.shiftKey) {
6992
+ if (active === first || !this.element.contains(active)) {
6993
+ event.preventDefault();
6994
+ last.focus();
6995
+ }
6996
+ } else if (active === last || !this.element.contains(active)) {
6997
+ event.preventDefault();
6998
+ first.focus();
6999
+ }
7000
+ };
7001
+ }
7002
+
7003
+ const FOCUSABLE = [
7004
+ "a[href]",
7005
+ "button:not([disabled])",
7006
+ "textarea:not([disabled])",
7007
+ "input:not([disabled])",
7008
+ "select:not([disabled])",
7009
+ '[tabindex]:not([tabindex="-1"])'
7010
+ ].join(", ");
7011
+ const GAP = 12;
7012
+ class Onboarding extends Controller {
7013
+ static targets = ["step", "overlay", "card", "title", "body", "back", "next", "done", "arrow"];
7014
+ static values = {
7015
+ autoOpen: { type: Boolean, default: false },
7016
+ confirm: { type: String, default: "" }
7017
+ };
7018
+ steps = [];
7019
+ index = 0;
7020
+ active = false;
7021
+ previousFocus = null;
7022
+ highlighted = null;
7023
+ connect() {
7024
+ this.steps = this.stepTargets.map((el) => ({
7025
+ el,
7026
+ name: el.dataset.name ?? "",
7027
+ selector: el.dataset.selector ?? "",
7028
+ position: el.dataset.position ?? "auto",
7029
+ title: el.dataset.title ?? "",
7030
+ nextText: el.dataset.nextText ?? "Next",
7031
+ backText: el.dataset.backText ?? "Back",
7032
+ completeText: el.dataset.completeText ?? "Done",
7033
+ canClickTarget: el.dataset.canClickTarget === "true"
7034
+ }));
7035
+ window.addEventListener("midwest-onboarding:open", this.handleOpen);
7036
+ if (this.autoOpenValue)
7037
+ this.start();
7038
+ }
7039
+ disconnect() {
7040
+ window.removeEventListener("midwest-onboarding:open", this.handleOpen);
7041
+ this.teardown();
7042
+ }
7043
+ start() {
7044
+ if (this.active || this.steps.length === 0)
7045
+ return;
7046
+ this.active = true;
7047
+ this.previousFocus = document.activeElement;
7048
+ this.overlayTarget.hidden = false;
7049
+ this.cardTarget.hidden = false;
7050
+ document.addEventListener("keydown", this.handleKeydown, true);
7051
+ window.addEventListener("resize", this.reposition);
7052
+ window.addEventListener("scroll", this.reposition, true);
7053
+ this.dispatch("start", { bubbles: true });
7054
+ this.render(0);
7055
+ }
7056
+ next() {
7057
+ if (this.index < this.steps.length - 1)
7058
+ this.render(this.index + 1);
7059
+ else
7060
+ this.complete();
7061
+ }
7062
+ back() {
7063
+ if (this.index > 0)
7064
+ this.render(this.index - 1);
7065
+ }
7066
+ // Jump to a step by its `name`.
7067
+ show(name) {
7068
+ const target = this.steps.findIndex((step) => step.name === name);
7069
+ if (target >= 0) {
7070
+ if (!this.active)
7071
+ this.start();
7072
+ this.render(target);
7073
+ }
7074
+ }
7075
+ complete() {
7076
+ if (!this.active)
7077
+ return;
7078
+ this.teardown();
7079
+ this.dispatch("complete", { bubbles: true });
7080
+ }
7081
+ cancel() {
7082
+ if (!this.active)
7083
+ return;
7084
+ if (this.confirmValue && !window.confirm(this.confirmValue))
7085
+ return;
7086
+ this.teardown();
7087
+ this.dispatch("cancel", { bubbles: true });
7088
+ }
7089
+ render(index) {
7090
+ this.index = index;
7091
+ const step = this.steps[index];
7092
+ this.clearHighlight();
7093
+ this.titleTarget.textContent = step.title;
7094
+ this.renderBody(step);
7095
+ this.renderButtons(step, index);
7096
+ const target = step.selector ? document.querySelector(step.selector) : null;
7097
+ if (target) {
7098
+ this.highlight(target, step.canClickTarget);
7099
+ this.position(target, step.position);
7100
+ } else {
7101
+ this.center();
7102
+ }
7103
+ this.dispatch("show", { detail: { index, name: step.name }, bubbles: true });
7104
+ this.focusCard();
7105
+ }
7106
+ renderBody(step) {
7107
+ this.bodyTarget.replaceChildren();
7108
+ const template = step.el.querySelector("template");
7109
+ if (template)
7110
+ this.bodyTarget.appendChild(template.content.cloneNode(true));
7111
+ }
7112
+ renderButtons(step, index) {
7113
+ const isFirst = index === 0;
7114
+ const isLast = index === this.steps.length - 1;
7115
+ this.backTarget.textContent = step.backText;
7116
+ this.backTarget.style.visibility = isFirst ? "hidden" : "";
7117
+ this.nextTarget.textContent = step.nextText;
7118
+ this.nextTarget.style.display = isLast ? "none" : "";
7119
+ this.doneTarget.textContent = step.completeText;
7120
+ this.doneTarget.style.display = isLast ? "" : "none";
7121
+ }
7122
+ highlight(target, canClickTarget) {
7123
+ target.classList.add("midwest-onboarding-highlight");
7124
+ target.classList.toggle("is-locked", !canClickTarget);
7125
+ this.highlighted = target;
7126
+ target.scrollIntoView({ behavior: "smooth", block: "center" });
7127
+ }
7128
+ clearHighlight() {
7129
+ this.highlighted?.classList.remove("midwest-onboarding-highlight", "is-locked");
7130
+ this.highlighted = null;
7131
+ }
7132
+ // Places the card against the target, choosing a side when `position` is auto.
7133
+ position(target, position) {
7134
+ const rect = target.getBoundingClientRect();
7135
+ const card = this.cardTarget.getBoundingClientRect();
7136
+ const vw = window.innerWidth;
7137
+ const vh = window.innerHeight;
7138
+ let placement = position;
7139
+ if (placement === "auto") {
7140
+ if (rect.bottom + card.height + GAP <= vh)
7141
+ placement = "bottom";
7142
+ else if (rect.top - card.height - GAP >= 0)
7143
+ placement = "top";
7144
+ else if (rect.right + card.width + GAP <= vw)
7145
+ placement = "right";
7146
+ else
7147
+ placement = "left";
7148
+ }
7149
+ let top;
7150
+ let left;
7151
+ switch (placement) {
7152
+ case "top":
7153
+ top = rect.top - card.height - GAP;
7154
+ left = rect.left + rect.width / 2 - card.width / 2;
7155
+ break;
7156
+ case "left":
7157
+ top = rect.top + rect.height / 2 - card.height / 2;
7158
+ left = rect.left - card.width - GAP;
7159
+ break;
7160
+ case "right":
7161
+ top = rect.top + rect.height / 2 - card.height / 2;
7162
+ left = rect.right + GAP;
7163
+ break;
7164
+ default:
7165
+ top = rect.bottom + GAP;
7166
+ left = rect.left + rect.width / 2 - card.width / 2;
7167
+ break;
7168
+ }
7169
+ left = Math.max(8, Math.min(left, vw - card.width - 8));
7170
+ top = Math.max(8, Math.min(top, vh - card.height - 8));
7171
+ this.cardTarget.style.top = `${top}px`;
7172
+ this.cardTarget.style.left = `${left}px`;
7173
+ this.cardTarget.dataset.placement = placement;
7174
+ this.positionArrow(placement, rect, top, left);
7175
+ }
7176
+ // Nudges the arrow so it points at the target's center, not the card's.
7177
+ positionArrow(placement, rect, top, left) {
7178
+ const card = this.cardTarget.getBoundingClientRect();
7179
+ const arrow = this.arrowTarget.style;
7180
+ if (placement === "top" || placement === "bottom") {
7181
+ const targetCenter = rect.left + rect.width / 2;
7182
+ const offset = Math.max(12, Math.min(targetCenter - left, card.width - 12));
7183
+ arrow.left = `${offset}px`;
7184
+ arrow.top = "";
7185
+ } else {
7186
+ const targetCenter = rect.top + rect.height / 2;
7187
+ const offset = Math.max(12, Math.min(targetCenter - top, card.height - 12));
7188
+ arrow.top = `${offset}px`;
7189
+ arrow.left = "";
7190
+ }
7191
+ }
7192
+ center() {
7193
+ const card = this.cardTarget.getBoundingClientRect();
7194
+ this.cardTarget.style.top = `${Math.max(8, window.innerHeight / 2 - card.height / 2)}px`;
7195
+ this.cardTarget.style.left = `${Math.max(8, window.innerWidth / 2 - card.width / 2)}px`;
7196
+ this.cardTarget.dataset.placement = "center";
7197
+ }
7198
+ focusCard() {
7199
+ requestAnimationFrame(() => {
7200
+ if (this.active)
7201
+ this.cardTarget.focus();
7202
+ });
7203
+ }
7204
+ teardown() {
7205
+ this.active = false;
7206
+ this.clearHighlight();
7207
+ this.overlayTarget.hidden = true;
7208
+ this.cardTarget.hidden = true;
7209
+ document.removeEventListener("keydown", this.handleKeydown, true);
7210
+ window.removeEventListener("resize", this.reposition);
7211
+ window.removeEventListener("scroll", this.reposition, true);
7212
+ this.previousFocus?.focus();
7213
+ this.previousFocus = null;
7214
+ }
7215
+ handleOpen = (event) => {
7216
+ const detail = event.detail ?? {};
7217
+ const id = String(detail.id ?? "").replace("#", "");
7218
+ if (id === this.element.id)
7219
+ this.start();
7220
+ };
7221
+ reposition = () => {
7222
+ if (!this.active)
7223
+ return;
7224
+ const step = this.steps[this.index];
7225
+ const target = step.selector ? document.querySelector(step.selector) : null;
7226
+ if (target)
7227
+ this.position(target, step.position);
7228
+ else
7229
+ this.center();
7230
+ };
7231
+ handleKeydown = (event) => {
7232
+ if (!this.active)
7233
+ return;
7234
+ switch (event.key) {
7235
+ case "Escape":
7236
+ event.preventDefault();
7237
+ this.cancel();
7238
+ break;
7239
+ case "ArrowRight":
7240
+ event.preventDefault();
7241
+ this.next();
7242
+ break;
7243
+ case "ArrowLeft":
7244
+ event.preventDefault();
7245
+ this.back();
7246
+ break;
7247
+ case "Tab":
7248
+ this.trapTab(event);
7249
+ break;
7250
+ }
7251
+ };
7252
+ // Keep Tab focus within the card while the tour is active.
7253
+ trapTab(event) {
7254
+ const focusable = Array.from(this.cardTarget.querySelectorAll(FOCUSABLE)).filter((el) => !el.hidden && el.offsetParent !== null && getComputedStyle(el).visibility !== "hidden");
7255
+ if (focusable.length === 0)
7256
+ return;
7257
+ const first = focusable[0];
7258
+ const last = focusable[focusable.length - 1];
7259
+ const active = document.activeElement;
7260
+ if (event.shiftKey) {
7261
+ if (active === first || active === this.cardTarget || !this.cardTarget.contains(active)) {
7262
+ event.preventDefault();
7263
+ last.focus();
7264
+ }
7265
+ } else if (active === last || !this.cardTarget.contains(active)) {
7266
+ event.preventDefault();
7267
+ first.focus();
7268
+ }
7269
+ }
7270
+ }
7271
+
7272
+ class ThemeSwitch extends Controller {
7273
+ static values = {
7274
+ storageKey: { type: String, default: "midwest-theme" }
7275
+ };
7276
+ connect() {
7277
+ const isDark = this.resolveTheme() === "dark";
7278
+ this.apply(isDark);
7279
+ this.element.checked = isDark;
7280
+ }
7281
+ toggle() {
7282
+ const isDark = this.element.checked;
7283
+ this.apply(isDark);
7284
+ this.persist(isDark ? "dark" : "light");
7285
+ }
7286
+ resolveTheme() {
7287
+ const stored = this.read();
7288
+ if (stored === "dark" || stored === "light")
7289
+ return stored;
7290
+ return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
7291
+ }
7292
+ apply(isDark) {
7293
+ document.documentElement.classList.toggle("dark", isDark);
7294
+ }
7295
+ read() {
7296
+ try {
7297
+ return window.localStorage.getItem(this.storageKeyValue);
7298
+ } catch {
7299
+ return null;
7300
+ }
7301
+ }
7302
+ persist(theme) {
7303
+ try {
7304
+ window.localStorage.setItem(this.storageKeyValue, theme);
7305
+ } catch {
7306
+ }
7307
+ }
7308
+ }
7309
+
6558
7310
  function registerMidwestControllers(application) {
6559
7311
  application.register("midwest-card", Card);
6560
7312
  application.register("midwest-banner", Banner);
@@ -6590,6 +7342,10 @@ function registerMidwestControllers(application) {
6590
7342
  application.register("midwest-code-block", CodeBlock);
6591
7343
  application.register("midwest-date-picker", DatePicker);
6592
7344
  application.register("midwest-time-picker", TimePicker);
7345
+ application.register("midwest-intersection-observer", ViewportObserver);
7346
+ application.register("midwest-focus-trap", FocusTrap);
7347
+ application.register("midwest-onboarding", Onboarding);
7348
+ application.register("midwest-theme-switch", ThemeSwitch);
6593
7349
  }
6594
7350
 
6595
7351
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };