@unabridged/midwest 0.23.4 → 0.24.3

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. package/app/assets/javascript/midwest/index.ts +6 -0
  2. package/app/assets/javascript/midwest/viewport_observer.ts +49 -0
  3. package/app/assets/javascript/midwest.js +660 -72
  4. package/app/assets/javascript/midwest.js.map +1 -1
  5. package/app/assets/stylesheets/midwest/dark-mode.css +50 -0
  6. package/app/assets/stylesheets/midwest.css +1 -1
  7. package/app/assets/stylesheets/midwest.tailwind.css +8 -1
  8. package/dist/css/midwest.css +1 -1
  9. package/dist/javascript/collection/app/assets/javascript/midwest/index.js +6 -0
  10. package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
  11. package/dist/javascript/collection/app/assets/javascript/midwest/viewport_observer.js +22 -0
  12. package/dist/javascript/collection/app/assets/javascript/midwest/viewport_observer.js.map +1 -0
  13. package/dist/javascript/collection/app/components/midwest/carousel_component/carousel_component_controller.js +4 -7
  14. package/dist/javascript/collection/app/components/midwest/carousel_component/carousel_component_controller.js.map +1 -1
  15. package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js +66 -8
  16. package/dist/javascript/collection/app/components/midwest/command_palette_component/command_palette_component_controller.js.map +1 -1
  17. package/dist/javascript/collection/app/components/midwest/dropdown_component/dropdown_component_controller.js +0 -6
  18. package/dist/javascript/collection/app/components/midwest/dropdown_component/dropdown_component_controller.js.map +1 -1
  19. package/dist/javascript/collection/app/components/midwest/intersection_observer_component/intersection_observer_component_controller.js +20 -25
  20. package/dist/javascript/collection/app/components/midwest/intersection_observer_component/intersection_observer_component_controller.js.map +1 -1
  21. package/dist/javascript/collection/app/components/midwest/motion_component/motion_component_controller.js +32 -0
  22. package/dist/javascript/collection/app/components/midwest/motion_component/motion_component_controller.js.map +1 -0
  23. package/dist/javascript/collection/app/components/midwest/notification_component/notification_component_controller.js +4 -12
  24. package/dist/javascript/collection/app/components/midwest/notification_component/notification_component_controller.js.map +1 -1
  25. package/dist/javascript/collection/app/components/midwest/panorama_component/panorama_component_controller.js +296 -0
  26. package/dist/javascript/collection/app/components/midwest/panorama_component/panorama_component_controller.js.map +1 -0
  27. package/dist/javascript/collection/app/components/midwest/playlist_component/playlist_component_controller.js +220 -0
  28. package/dist/javascript/collection/app/components/midwest/playlist_component/playlist_component_controller.js.map +1 -0
  29. package/dist/javascript/collection/app/components/midwest/table_component/load_more_controller.js +10 -14
  30. package/dist/javascript/collection/app/components/midwest/table_component/load_more_controller.js.map +1 -1
  31. package/dist/javascript/midwest.js +660 -72
  32. package/dist/javascript/midwest.js.map +1 -1
  33. package/package.json +1 -1
@@ -2557,7 +2557,6 @@ class Dropdown extends Controller {
2557
2557
  supported: { type: Boolean, default: true },
2558
2558
  align: { type: String, default: "right" }
2559
2559
  };
2560
- observer = new IntersectionObserver(this.computedCB.bind(this), { threshold: 1 });
2561
2560
  cleanup;
2562
2561
  connect() {
2563
2562
  this.supportedValue = CSS.supports("anchor-name: --myanchor");
@@ -2623,11 +2622,6 @@ class Dropdown extends Controller {
2623
2622
  (item) => item.closest('[data-controller~="midwest-dropdown"]') === this.element && getComputedStyle(item).display !== "none"
2624
2623
  );
2625
2624
  }
2626
- computedCB(entries) {
2627
- entries.forEach(async () => {
2628
- this.computation();
2629
- });
2630
- }
2631
2625
  closeIfOpen = () => {
2632
2626
  this.element.hidePopover();
2633
2627
  };
@@ -3012,7 +3006,7 @@ class Carousel extends Controller {
3012
3006
  }
3013
3007
  const atEnd = this._currentIndex >= this.slides.length - 1;
3014
3008
  if (this.loopValue && atEnd) {
3015
- this.slides[0]?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
3009
+ this.trackTarget.scrollTo({ left: 0, behavior: "smooth" });
3016
3010
  } else {
3017
3011
  this.trackTarget.scrollBy({ left: this.trackTarget.clientWidth, behavior: "smooth" });
3018
3012
  }
@@ -3026,8 +3020,7 @@ class Carousel extends Controller {
3026
3020
  }
3027
3021
  const atStart = this._currentIndex <= 0;
3028
3022
  if (this.loopValue && atStart) {
3029
- const last = this.slides[this.slides.length - 1];
3030
- last?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
3023
+ this.trackTarget.scrollTo({ left: (this.slides.length - 1) * this.trackTarget.clientWidth, behavior: "smooth" });
3031
3024
  } else {
3032
3025
  this.trackTarget.scrollBy({ left: -this.trackTarget.clientWidth, behavior: "smooth" });
3033
3026
  }
@@ -3039,8 +3032,7 @@ class Carousel extends Controller {
3039
3032
  this.activateSlide(index);
3040
3033
  return;
3041
3034
  }
3042
- const slide = this.slides[index];
3043
- slide?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
3035
+ this.trackTarget.scrollTo({ left: index * this.trackTarget.clientWidth, behavior: "smooth" });
3044
3036
  }
3045
3037
  // Unlocks the next slide and advances to it. Intended for wizard mode: wire
3046
3038
  // a "Continue" button inside a slide to this action so users progress
@@ -3144,8 +3136,7 @@ class Carousel extends Controller {
3144
3136
  }
3145
3137
  const atEnd = this._currentIndex >= this.slides.length - 1;
3146
3138
  if (atEnd) {
3147
- const firstSlide = this.slides[0];
3148
- firstSlide?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
3139
+ this.trackTarget.scrollTo({ left: 0, behavior: "smooth" });
3149
3140
  } else {
3150
3141
  this.next();
3151
3142
  }
@@ -4871,10 +4862,7 @@ class Notification extends Controller {
4871
4862
  connect() {
4872
4863
  this.container = this.element.closest("[popover]");
4873
4864
  this.container?.showPopover?.();
4874
- requestAnimationFrame(() => {
4875
- this.element.classList.add("visible");
4876
- });
4877
- const frame = this.element.parentElement;
4865
+ const frame = this.element.closest("turbo-frame");
4878
4866
  if (frame) {
4879
4867
  this.observer = new MutationObserver(() => this.syncCountdown());
4880
4868
  this.observer.observe(frame, { childList: true });
@@ -4893,14 +4881,8 @@ class Notification extends Controller {
4893
4881
  }
4894
4882
  }
4895
4883
  close() {
4896
- if (!this.element.classList.contains("animated")) {
4897
- this.element.remove();
4898
- return;
4899
- }
4900
4884
  this.element.classList.add("closing");
4901
- setTimeout(() => {
4902
- this.element.remove();
4903
- }, 350);
4885
+ setTimeout(() => (this.element.parentElement ?? this.element).remove(), 350);
4904
4886
  }
4905
4887
  syncCountdown() {
4906
4888
  const timerEl = this.element.querySelector('[data-controller~="midwest-countdown-timer"]');
@@ -4912,7 +4894,8 @@ class Notification extends Controller {
4912
4894
  );
4913
4895
  if (!timer)
4914
4896
  return;
4915
- const isTop = this.element === this.element.parentElement?.firstElementChild;
4897
+ const wrapper = this.element.parentElement ?? this.element;
4898
+ const isTop = wrapper === wrapper.parentElement?.firstElementChild;
4916
4899
  if (isTop) {
4917
4900
  timer.resume();
4918
4901
  } else {
@@ -4986,36 +4969,51 @@ class Table extends Controller {
4986
4969
  }
4987
4970
  }
4988
4971
 
4972
+ function observeViewport(element, options = {}) {
4973
+ const { threshold = 0, rootMargin = "0px", once = false, onEnter, onLeave } = options;
4974
+ const observer = new IntersectionObserver(
4975
+ (entries) => {
4976
+ for (const entry of entries) {
4977
+ if (entry.isIntersecting) {
4978
+ onEnter?.(entry);
4979
+ if (once)
4980
+ observer.disconnect();
4981
+ } else {
4982
+ onLeave?.(entry);
4983
+ }
4984
+ }
4985
+ },
4986
+ { threshold, rootMargin }
4987
+ );
4988
+ observer.observe(element);
4989
+ return () => observer.disconnect();
4990
+ }
4991
+
4989
4992
  class LoadMore extends Controller {
4990
4993
  static values = {
4991
4994
  url: String,
4992
4995
  tbodyId: String
4993
4996
  };
4994
- observer = null;
4997
+ teardown = null;
4995
4998
  loading = false;
4996
4999
  connect() {
4997
5000
  if (!this.urlValue)
4998
5001
  return;
4999
- this.observer = new IntersectionObserver(
5000
- (entries) => {
5001
- if (entries[0].isIntersecting)
5002
- this.load();
5003
- },
5004
- // Start fetching 300px before the sentinel enters the viewport so rows
5005
- // appear before the user actually reaches the bottom.
5006
- { rootMargin: "0px 0px 300px 0px" }
5007
- );
5008
- this.observer.observe(this.element);
5002
+ this.teardown = observeViewport(this.element, {
5003
+ rootMargin: "0px 0px 300px 0px",
5004
+ once: true,
5005
+ onEnter: () => this.load()
5006
+ });
5009
5007
  }
5010
5008
  disconnect() {
5011
- this.observer?.disconnect();
5012
- this.observer = null;
5009
+ this.teardown?.();
5010
+ this.teardown = null;
5013
5011
  }
5014
5012
  async load() {
5015
5013
  if (this.loading)
5016
5014
  return;
5017
5015
  this.loading = true;
5018
- this.observer?.disconnect();
5016
+ this.teardown?.();
5019
5017
  try {
5020
5018
  const response = await fetch(this.urlValue, {
5021
5019
  headers: {
@@ -5462,11 +5460,16 @@ class CommandPalette extends Controller {
5462
5460
  commandPlaceholder = "";
5463
5461
  messages = [];
5464
5462
  streamAbort = null;
5463
+ // While true, new/streamed content pins the transcript to the bottom. Turns
5464
+ // false when the user scrolls up to read history so we don't yank them down.
5465
+ stickToBottom = true;
5465
5466
  connect() {
5466
5467
  this.allItems = this.itemsValue;
5467
5468
  this.commandPlaceholder = this.inputTarget.placeholder;
5468
5469
  document.addEventListener("keydown", this.handleGlobalKeydown);
5469
5470
  this.element.addEventListener("click", this.handleBackdropClick);
5471
+ if (this.hasBodyTarget)
5472
+ this.bodyTarget.addEventListener("scroll", this.handleTranscriptScroll, { passive: true });
5470
5473
  if (!this.turboFrameUrlValue) {
5471
5474
  this.renderItems(this.allItems);
5472
5475
  }
@@ -5474,6 +5477,8 @@ class CommandPalette extends Controller {
5474
5477
  disconnect() {
5475
5478
  document.removeEventListener("keydown", this.handleGlobalKeydown);
5476
5479
  this.element.removeEventListener("click", this.handleBackdropClick);
5480
+ if (this.hasBodyTarget)
5481
+ this.bodyTarget.removeEventListener("scroll", this.handleTranscriptScroll);
5477
5482
  if (this.fetchAbort)
5478
5483
  this.fetchAbort.abort();
5479
5484
  if (this.streamAbort)
@@ -5617,6 +5622,7 @@ class CommandPalette extends Controller {
5617
5622
  this.transcriptTarget.innerHTML = "";
5618
5623
  if (this.hasStartersTarget)
5619
5624
  this.startersTarget.hidden = false;
5625
+ this.stickToBottom = true;
5620
5626
  this.setBusy(false);
5621
5627
  this.setMode("command");
5622
5628
  }
@@ -5656,6 +5662,7 @@ class CommandPalette extends Controller {
5656
5662
  const text = this.inputTarget.value.trim();
5657
5663
  if (!text)
5658
5664
  return;
5665
+ this.stickToBottom = true;
5659
5666
  this.appendMessage("user", text);
5660
5667
  this.messages.push({ role: "user", content: text });
5661
5668
  this.inputTarget.value = "";
@@ -5727,16 +5734,31 @@ class CommandPalette extends Controller {
5727
5734
  return null;
5728
5735
  }
5729
5736
  }
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.
5737
+ // Agentic events: the server streams a tool "action row", a live preview
5738
+ // ("embed"), and/or a client directive (navigate / run a palette command).
5739
+ // Directives reuse the same execution path as manual selection.
5733
5740
  handleAgentEvent(event) {
5734
5741
  if (event.type === "tool") {
5735
5742
  this.appendActionRow(event);
5743
+ } else if (event.type === "embed" && event.srcdoc) {
5744
+ this.appendEmbed(event);
5736
5745
  } else if (event.type === "directive" && event.directive) {
5737
5746
  this.performDirective(event.directive);
5747
+ } else if (event.type === "error") {
5748
+ this.renderStreamError(event.message);
5738
5749
  }
5739
5750
  }
5751
+ // A server-reported failure (e.g. provider billing/rate-limit error). Render
5752
+ // it into the current assistant bubble instead of failing silently.
5753
+ renderStreamError(message) {
5754
+ const last = this.currentAssistantMessage();
5755
+ if (!last)
5756
+ return;
5757
+ last.classList.add("is-error");
5758
+ const bubble = last.querySelector(".command-palette-message-bubble");
5759
+ if (bubble)
5760
+ bubble.textContent = message || "Something went wrong. Please try again.";
5761
+ }
5740
5762
  // Action rows live in the current assistant message's card footer.
5741
5763
  appendActionRow(event) {
5742
5764
  if (!this.hasActionTemplateTarget)
@@ -5745,19 +5767,42 @@ class CommandPalette extends Controller {
5745
5767
  if (!footer)
5746
5768
  return;
5747
5769
  const action = cloneTemplate(this.actionTemplateTarget);
5748
- const label = action.querySelector(".command-palette-action-label");
5770
+ const label = action.querySelector(".value");
5749
5771
  if (label)
5750
5772
  label.textContent = event.label || "Action";
5751
5773
  footer.hidden = false;
5752
5774
  footer.appendChild(action);
5753
5775
  this.scrollTranscript();
5754
5776
  }
5777
+ // A live preview streamed by an agentic tool (e.g. generate_iframe). The
5778
+ // server sends only the inner document; the client owns the <iframe> element
5779
+ // and hard-codes the sandbox (no allow-scripts) so injected markup is styled
5780
+ // but never executed — the markdown text path can't render HTML at all.
5781
+ appendEmbed(event) {
5782
+ const message = this.currentAssistantMessage();
5783
+ if (!message || !event.srcdoc)
5784
+ return;
5785
+ const body = message.querySelector(".command-palette-message-body") || message;
5786
+ const frame = document.createElement("iframe");
5787
+ frame.className = "command-palette-embed";
5788
+ frame.setAttribute("sandbox", "allow-same-origin");
5789
+ frame.setAttribute("loading", "lazy");
5790
+ frame.title = event.title || "Preview";
5791
+ frame.srcdoc = event.srcdoc;
5792
+ if (event.height)
5793
+ frame.style.height = `${event.height}px`;
5794
+ body.appendChild(frame);
5795
+ this.scrollTranscript();
5796
+ }
5755
5797
  currentActionFooter() {
5798
+ const last = this.currentAssistantMessage();
5799
+ return last ? last.querySelector(".command-palette-action-footer") : null;
5800
+ }
5801
+ currentAssistantMessage() {
5756
5802
  const messages = this.transcriptTarget.querySelectorAll(
5757
5803
  ".command-palette-message.role-assistant"
5758
5804
  );
5759
- const last = messages[messages.length - 1];
5760
- return last ? last.querySelector(".command-palette-action-footer") : null;
5805
+ return messages[messages.length - 1] || null;
5761
5806
  }
5762
5807
  performDirective(directive) {
5763
5808
  if (directive.url) {
@@ -5797,10 +5842,21 @@ class CommandPalette extends Controller {
5797
5842
  if (this.hasTranscriptTarget)
5798
5843
  this.transcriptTarget.setAttribute("aria-busy", String(busy));
5799
5844
  }
5845
+ // The body is the scrollable region; the transcript itself does not scroll.
5800
5846
  scrollTranscript() {
5801
- if (this.hasTranscriptTarget)
5802
- this.transcriptTarget.scrollTop = this.transcriptTarget.scrollHeight;
5847
+ if (this.hasBodyTarget && this.stickToBottom) {
5848
+ this.bodyTarget.scrollTop = this.bodyTarget.scrollHeight;
5849
+ }
5803
5850
  }
5851
+ // Track whether the user is anchored to the bottom. Once they scroll up to
5852
+ // read earlier messages we stop auto-scrolling; returning to the bottom
5853
+ // re-arms it so streamed text keeps the latest content on screen.
5854
+ handleTranscriptScroll = () => {
5855
+ if (!this.hasBodyTarget)
5856
+ return;
5857
+ const { scrollHeight, scrollTop, clientHeight } = this.bodyTarget;
5858
+ this.stickToBottom = scrollHeight - scrollTop - clientHeight < 24;
5859
+ };
5804
5860
  appendMessage(role, text) {
5805
5861
  const li = cloneTemplate(this.messageTemplateTarget);
5806
5862
  li.classList.add(`role-${role}`);
@@ -6889,35 +6945,29 @@ class ViewportObserver extends Controller {
6889
6945
  once: { type: Boolean, default: false },
6890
6946
  visibleClass: { type: String, default: "" }
6891
6947
  };
6892
- observer = null;
6948
+ teardown = null;
6893
6949
  connect() {
6894
- this.observer = new IntersectionObserver(
6895
- (entries) => {
6896
- for (const entry of entries)
6897
- this.handle(entry);
6950
+ this.teardown = observeViewport(this.element, {
6951
+ threshold: this.thresholdValue,
6952
+ rootMargin: this.rootMarginValue,
6953
+ once: this.onceValue,
6954
+ onEnter: (entry) => {
6955
+ this.element.setAttribute("data-intersecting", "true");
6956
+ if (this.visibleClassValue)
6957
+ this.element.classList.add(this.visibleClassValue);
6958
+ this.dispatch("enter", { detail: { entry }, bubbles: true });
6898
6959
  },
6899
- { threshold: this.thresholdValue, rootMargin: this.rootMarginValue }
6900
- );
6901
- this.observer.observe(this.element);
6960
+ onLeave: (entry) => {
6961
+ this.element.setAttribute("data-intersecting", "false");
6962
+ if (this.visibleClassValue)
6963
+ this.element.classList.remove(this.visibleClassValue);
6964
+ this.dispatch("leave", { detail: { entry }, bubbles: true });
6965
+ }
6966
+ });
6902
6967
  }
6903
6968
  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
- }
6969
+ this.teardown?.();
6970
+ this.teardown = null;
6921
6971
  }
6922
6972
  }
6923
6973
 
@@ -7307,6 +7357,541 @@ class ThemeSwitch extends Controller {
7307
7357
  }
7308
7358
  }
7309
7359
 
7360
+ const FRICTION = 0.92;
7361
+ const MIN_VELOCITY = 0.05;
7362
+ const KEY_STEP_FRACTION = 0.12;
7363
+ class Panorama extends Controller {
7364
+ static targets = ["viewport", "stage", "media", "status", "hint", "reset"];
7365
+ static values = {
7366
+ wrap: { type: Boolean, default: false },
7367
+ autoRotate: { type: Number, default: 0 },
7368
+ start: { type: Number, default: 0 }
7369
+ };
7370
+ offset = 0;
7371
+ // current translateX, always <= 0
7372
+ mediaWidth = 0;
7373
+ // rendered width of a single media copy
7374
+ viewportWidth = 0;
7375
+ dragging = false;
7376
+ pointerId = null;
7377
+ lastX = 0;
7378
+ lastMoveTime = 0;
7379
+ velocity = 0;
7380
+ inertiaRaf = null;
7381
+ autoRaf = null;
7382
+ lastAutoTime = 0;
7383
+ clone = null;
7384
+ resizeObserver = null;
7385
+ interacted = false;
7386
+ connect() {
7387
+ this.viewportTarget.addEventListener("pointerdown", this.onPointerDown);
7388
+ this.element.addEventListener("keydown", this.onKeydown);
7389
+ this.element.addEventListener("pointerenter", this.pauseAuto);
7390
+ this.element.addEventListener("pointerleave", this.resumeAuto);
7391
+ this.element.addEventListener("focusin", this.pauseAuto);
7392
+ this.element.addEventListener("focusout", this.resumeAuto);
7393
+ this.resizeObserver = new ResizeObserver(() => this.measure());
7394
+ this.resizeObserver.observe(this.viewportTarget);
7395
+ this.whenMediaReady(() => {
7396
+ this.setupWrap();
7397
+ this.measure();
7398
+ this.offset = this.startOffset();
7399
+ this.apply();
7400
+ this.startAuto();
7401
+ });
7402
+ }
7403
+ disconnect() {
7404
+ this.viewportTarget.removeEventListener("pointerdown", this.onPointerDown);
7405
+ window.removeEventListener("pointermove", this.onPointerMove);
7406
+ window.removeEventListener("pointerup", this.onPointerUp);
7407
+ window.removeEventListener("pointercancel", this.onPointerUp);
7408
+ this.element.removeEventListener("keydown", this.onKeydown);
7409
+ this.element.removeEventListener("pointerenter", this.pauseAuto);
7410
+ this.element.removeEventListener("pointerleave", this.resumeAuto);
7411
+ this.element.removeEventListener("focusin", this.pauseAuto);
7412
+ this.element.removeEventListener("focusout", this.resumeAuto);
7413
+ this.resizeObserver?.disconnect();
7414
+ this.stopInertia();
7415
+ this.stopAuto();
7416
+ }
7417
+ // Recenters the view to its starting position (wired to the reset button).
7418
+ reset() {
7419
+ this.stopInertia();
7420
+ this.markInteracted();
7421
+ this.offset = this.startOffset();
7422
+ this.apply();
7423
+ this.announce("View reset");
7424
+ }
7425
+ // --- media readiness & measurement -----------------------------------------
7426
+ whenMediaReady(cb) {
7427
+ const media = this.mediaTarget;
7428
+ if (media instanceof HTMLImageElement) {
7429
+ if (media.complete && media.naturalWidth > 0) {
7430
+ cb();
7431
+ return;
7432
+ }
7433
+ media.addEventListener("load", cb, { once: true });
7434
+ media.addEventListener("error", cb, { once: true });
7435
+ } else {
7436
+ if (media.readyState >= 1 && media.videoWidth > 0) {
7437
+ cb();
7438
+ return;
7439
+ }
7440
+ media.addEventListener("loadedmetadata", cb, { once: true });
7441
+ }
7442
+ }
7443
+ measure() {
7444
+ this.viewportWidth = this.viewportTarget.clientWidth;
7445
+ const first = this.stageTarget.querySelector(".midwest-panorama-media");
7446
+ this.mediaWidth = first?.getBoundingClientRect().width ?? 0;
7447
+ this.offset = this.normalize(this.offset);
7448
+ this.apply();
7449
+ }
7450
+ // Duplicates the image once so the stage can loop seamlessly. Only images
7451
+ // wrap — a live <video> can't be cheaply cloned/kept in sync.
7452
+ setupWrap() {
7453
+ if (!this.wrapValue || this.clone)
7454
+ return;
7455
+ if (!(this.mediaTarget instanceof HTMLImageElement))
7456
+ return;
7457
+ const clone = this.mediaTarget.cloneNode(true);
7458
+ clone.setAttribute("aria-hidden", "true");
7459
+ clone.setAttribute("alt", "");
7460
+ clone.removeAttribute("data-midwest-panorama-target");
7461
+ this.stageTarget.appendChild(clone);
7462
+ this.clone = clone;
7463
+ }
7464
+ // --- geometry --------------------------------------------------------------
7465
+ canWrap() {
7466
+ return this.wrapValue && this.clone !== null && this.mediaWidth > 0;
7467
+ }
7468
+ // Constrains an offset: modulo for seamless wrap, hard clamp otherwise.
7469
+ normalize(x) {
7470
+ const w = this.mediaWidth;
7471
+ if (this.canWrap()) {
7472
+ if (w <= 0)
7473
+ return 0;
7474
+ let n = x % w;
7475
+ if (n > 0)
7476
+ n -= w;
7477
+ return n;
7478
+ }
7479
+ const min = Math.min(0, this.viewportWidth - w);
7480
+ return Math.max(min, Math.min(0, x));
7481
+ }
7482
+ startOffset() {
7483
+ if (this.canWrap())
7484
+ return this.normalize(-this.startValue * this.mediaWidth);
7485
+ const min = Math.min(0, this.viewportWidth - this.mediaWidth);
7486
+ return min * this.startValue;
7487
+ }
7488
+ apply() {
7489
+ this.stageTarget.style.transform = `translate3d(${this.offset}px, 0, 0)`;
7490
+ }
7491
+ // --- pointer dragging ------------------------------------------------------
7492
+ onPointerDown = (event) => {
7493
+ if (event.button !== 0)
7494
+ return;
7495
+ this.dragging = true;
7496
+ this.pointerId = event.pointerId;
7497
+ this.lastX = event.clientX;
7498
+ this.lastMoveTime = performance.now();
7499
+ this.velocity = 0;
7500
+ this.stopInertia();
7501
+ this.pauseAuto();
7502
+ this.markInteracted();
7503
+ this.element.classList.add("is-dragging");
7504
+ this.viewportTarget.setPointerCapture(event.pointerId);
7505
+ window.addEventListener("pointermove", this.onPointerMove);
7506
+ window.addEventListener("pointerup", this.onPointerUp);
7507
+ window.addEventListener("pointercancel", this.onPointerUp);
7508
+ };
7509
+ onPointerMove = (event) => {
7510
+ if (!this.dragging)
7511
+ return;
7512
+ const dx = event.clientX - this.lastX;
7513
+ const now = performance.now();
7514
+ const dt = now - this.lastMoveTime;
7515
+ if (dt > 0)
7516
+ this.velocity = dx / dt * 16;
7517
+ this.lastX = event.clientX;
7518
+ this.lastMoveTime = now;
7519
+ this.offset = this.normalize(this.offset + dx);
7520
+ this.apply();
7521
+ };
7522
+ onPointerUp = () => {
7523
+ if (!this.dragging)
7524
+ return;
7525
+ this.dragging = false;
7526
+ this.element.classList.remove("is-dragging");
7527
+ if (this.pointerId !== null) {
7528
+ try {
7529
+ this.viewportTarget.releasePointerCapture(this.pointerId);
7530
+ } catch {
7531
+ }
7532
+ }
7533
+ this.pointerId = null;
7534
+ window.removeEventListener("pointermove", this.onPointerMove);
7535
+ window.removeEventListener("pointerup", this.onPointerUp);
7536
+ window.removeEventListener("pointercancel", this.onPointerUp);
7537
+ this.startInertia();
7538
+ };
7539
+ startInertia() {
7540
+ if (Math.abs(this.velocity) < MIN_VELOCITY) {
7541
+ this.resumeAuto();
7542
+ return;
7543
+ }
7544
+ const step = () => {
7545
+ this.offset = this.normalize(this.offset + this.velocity);
7546
+ this.apply();
7547
+ this.velocity *= FRICTION;
7548
+ if (!this.canWrap()) {
7549
+ const min = Math.min(0, this.viewportWidth - this.mediaWidth);
7550
+ if (this.offset <= min || this.offset >= 0)
7551
+ this.velocity = 0;
7552
+ }
7553
+ if (Math.abs(this.velocity) < MIN_VELOCITY) {
7554
+ this.inertiaRaf = null;
7555
+ this.resumeAuto();
7556
+ return;
7557
+ }
7558
+ this.inertiaRaf = requestAnimationFrame(step);
7559
+ };
7560
+ this.inertiaRaf = requestAnimationFrame(step);
7561
+ }
7562
+ stopInertia() {
7563
+ if (this.inertiaRaf !== null) {
7564
+ cancelAnimationFrame(this.inertiaRaf);
7565
+ this.inertiaRaf = null;
7566
+ }
7567
+ }
7568
+ // --- keyboard --------------------------------------------------------------
7569
+ onKeydown = (event) => {
7570
+ const step = this.viewportWidth * KEY_STEP_FRACTION;
7571
+ let handled = true;
7572
+ switch (event.key) {
7573
+ case "ArrowLeft":
7574
+ this.offset = this.normalize(this.offset + step);
7575
+ break;
7576
+ case "ArrowRight":
7577
+ this.offset = this.normalize(this.offset - step);
7578
+ break;
7579
+ case "Home":
7580
+ this.offset = this.startOffset();
7581
+ break;
7582
+ default:
7583
+ handled = false;
7584
+ }
7585
+ if (!handled)
7586
+ return;
7587
+ event.preventDefault();
7588
+ this.stopInertia();
7589
+ this.pauseAuto();
7590
+ this.markInteracted();
7591
+ this.apply();
7592
+ this.announce();
7593
+ };
7594
+ // --- auto-rotate -----------------------------------------------------------
7595
+ startAuto() {
7596
+ if (this.autoRotateValue <= 0 || this.autoRaf !== null || this.dragging)
7597
+ return;
7598
+ if (this.prefersReducedMotion())
7599
+ return;
7600
+ this.lastAutoTime = performance.now();
7601
+ const step = (now) => {
7602
+ const dt = (now - this.lastAutoTime) / 1e3;
7603
+ this.lastAutoTime = now;
7604
+ this.offset = this.normalize(this.offset - this.autoRotateValue * dt);
7605
+ this.apply();
7606
+ this.autoRaf = requestAnimationFrame(step);
7607
+ };
7608
+ this.autoRaf = requestAnimationFrame(step);
7609
+ }
7610
+ stopAuto() {
7611
+ if (this.autoRaf !== null) {
7612
+ cancelAnimationFrame(this.autoRaf);
7613
+ this.autoRaf = null;
7614
+ }
7615
+ }
7616
+ pauseAuto = () => {
7617
+ this.stopAuto();
7618
+ };
7619
+ resumeAuto = () => {
7620
+ if (this.dragging)
7621
+ return;
7622
+ this.startAuto();
7623
+ };
7624
+ // --- helpers ---------------------------------------------------------------
7625
+ markInteracted() {
7626
+ if (this.interacted)
7627
+ return;
7628
+ this.interacted = true;
7629
+ this.element.classList.add("has-interacted");
7630
+ }
7631
+ announce(message) {
7632
+ if (!this.hasStatusTarget)
7633
+ return;
7634
+ this.statusTarget.textContent = message ?? `Viewing ${this.percent()}%`;
7635
+ }
7636
+ percent() {
7637
+ const w = this.mediaWidth;
7638
+ if (w <= 0)
7639
+ return 0;
7640
+ if (this.canWrap()) {
7641
+ const n = (-this.offset % w + w) % w;
7642
+ return Math.round(n / w * 100);
7643
+ }
7644
+ const min = Math.min(0, this.viewportWidth - w);
7645
+ return min === 0 ? 0 : Math.round(this.offset / min * 100);
7646
+ }
7647
+ prefersReducedMotion() {
7648
+ return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
7649
+ }
7650
+ }
7651
+
7652
+ function formatTime(seconds) {
7653
+ const mins = Math.floor(seconds / 60);
7654
+ const secs = Math.floor(seconds % 60);
7655
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
7656
+ }
7657
+ class Playlist extends Controller {
7658
+ static targets = [
7659
+ "audio",
7660
+ "audioSource",
7661
+ "playIcon",
7662
+ "pauseIcon",
7663
+ "trackTitle",
7664
+ "trackArtist",
7665
+ "artworkContainer",
7666
+ "placeholderIcon",
7667
+ "progressBar",
7668
+ "currentTime",
7669
+ "totalTime",
7670
+ "songList",
7671
+ "details"
7672
+ ];
7673
+ static values = {
7674
+ playlistName: { type: String, default: "Playlist" },
7675
+ autoplay: { type: Boolean, default: false },
7676
+ view: { type: String, default: "playlist" },
7677
+ playlistVisible: { type: Boolean, default: true },
7678
+ artwork: { type: Boolean, default: false }
7679
+ };
7680
+ currentIndex = 0;
7681
+ songElements = [];
7682
+ connect() {
7683
+ this.songElements = Array.from(this.songListTarget.querySelectorAll(".midwest-song button"));
7684
+ this.updatePlaylistVisibility();
7685
+ this.audioTarget.addEventListener("timeupdate", this.handleTimeUpdate);
7686
+ this.audioTarget.addEventListener("loadedmetadata", this.handleMetadataLoaded);
7687
+ this.audioTarget.addEventListener("ended", this.handleEnded);
7688
+ this.audioTarget.addEventListener("play", this.handlePlayEvent);
7689
+ this.audioTarget.addEventListener("pause", this.handlePauseEvent);
7690
+ this.element.addEventListener("keydown", this.handleKeydown);
7691
+ if (this.autoplayValue && this.songElements.length > 0) {
7692
+ this.loadSong(0);
7693
+ }
7694
+ }
7695
+ disconnect() {
7696
+ this.audioTarget.removeEventListener("timeupdate", this.handleTimeUpdate);
7697
+ this.audioTarget.removeEventListener("loadedmetadata", this.handleMetadataLoaded);
7698
+ this.audioTarget.removeEventListener("ended", this.handleEnded);
7699
+ this.audioTarget.removeEventListener("play", this.handlePlayEvent);
7700
+ this.audioTarget.removeEventListener("pause", this.handlePauseEvent);
7701
+ this.element.removeEventListener("keydown", this.handleKeydown);
7702
+ }
7703
+ // Play/pause toggle
7704
+ togglePlay() {
7705
+ if (this.audioTarget.paused) {
7706
+ if (this.audioSourceTarget.src) {
7707
+ this.audioTarget.play();
7708
+ } else if (this.songElements.length > 0) {
7709
+ this.loadSong(0);
7710
+ }
7711
+ } else {
7712
+ this.audioTarget.pause();
7713
+ }
7714
+ }
7715
+ // Play the song whose button was clicked (wired from the song template)
7716
+ playSong(event) {
7717
+ const button = event.currentTarget;
7718
+ const index = this.songElements.indexOf(button);
7719
+ if (index === -1)
7720
+ return;
7721
+ if (index === this.currentIndex && this.audioSourceTarget.src) {
7722
+ this.togglePlay();
7723
+ return;
7724
+ }
7725
+ this.loadSong(index);
7726
+ }
7727
+ // Load and play a specific song by index
7728
+ loadSong(index) {
7729
+ const songButton = this.songElements[index];
7730
+ if (!songButton)
7731
+ return;
7732
+ const songElement = songButton.closest(".midwest-song");
7733
+ if (!songElement)
7734
+ return;
7735
+ const src = songElement.dataset.src;
7736
+ const title = songElement.dataset.title || "Unknown";
7737
+ const artist = songElement.dataset.artist || "Unknown";
7738
+ const picture = songElement.dataset.picture;
7739
+ if (!src)
7740
+ return;
7741
+ this.audioSourceTarget.src = src;
7742
+ this.audioTarget.load();
7743
+ this.trackTitleTarget.textContent = title;
7744
+ this.trackArtistTarget.textContent = artist;
7745
+ if (picture) {
7746
+ const img = document.createElement("img");
7747
+ img.src = picture;
7748
+ img.alt = `Album art for ${title}`;
7749
+ img.className = "midwest-playlist__artwork-image";
7750
+ const existingImg = this.artworkContainerTarget.querySelector(".midwest-playlist__artwork-image");
7751
+ if (existingImg) {
7752
+ existingImg.replaceWith(img);
7753
+ } else {
7754
+ this.artworkContainerTarget.appendChild(img);
7755
+ }
7756
+ this.placeholderIconTarget.classList.add("hidden");
7757
+ } else {
7758
+ this.placeholderIconTarget.classList.remove("hidden");
7759
+ }
7760
+ this.songElements.forEach((btn, i) => {
7761
+ const song = btn.closest(".midwest-song");
7762
+ if (i === index) {
7763
+ song.classList.add("is-playing");
7764
+ } else {
7765
+ song.classList.remove("is-playing");
7766
+ }
7767
+ });
7768
+ this.currentIndex = index;
7769
+ this.audioTarget.play().catch(() => {
7770
+ });
7771
+ }
7772
+ // Play next song
7773
+ next() {
7774
+ const nextIndex = (this.currentIndex + 1) % this.songElements.length;
7775
+ this.loadSong(nextIndex);
7776
+ }
7777
+ // Play previous song
7778
+ previous() {
7779
+ const prevIndex = this.currentIndex === 0 ? this.songElements.length - 1 : this.currentIndex - 1;
7780
+ this.loadSong(prevIndex);
7781
+ }
7782
+ // Seek to position in track
7783
+ seek(event) {
7784
+ const rect = this.progressBarTarget.getBoundingClientRect();
7785
+ const x = event.clientX - rect.left;
7786
+ const percentage = Math.max(0, Math.min(1, x / rect.width));
7787
+ if (this.audioTarget.duration) {
7788
+ this.audioTarget.currentTime = percentage * this.audioTarget.duration;
7789
+ }
7790
+ }
7791
+ // Toggle playlist visibility
7792
+ togglePlaylist() {
7793
+ this.playlistVisibleValue = !this.playlistVisibleValue;
7794
+ this.updatePlaylistVisibility();
7795
+ this.savePlaylistVisibility();
7796
+ }
7797
+ // Toggle between playlist and art view
7798
+ toggleView() {
7799
+ this.viewValue = this.viewValue === "playlist" ? "art" : "playlist";
7800
+ this.updateView();
7801
+ }
7802
+ // Private: Handle time update for progress bar
7803
+ handleTimeUpdate = () => {
7804
+ const { currentTime, duration } = this.audioTarget;
7805
+ if (duration) {
7806
+ const percentage = currentTime / duration * 100;
7807
+ this.progressBarTarget.value = percentage;
7808
+ this.currentTimeTarget.textContent = formatTime(currentTime);
7809
+ this.totalTimeTarget.textContent = formatTime(duration);
7810
+ }
7811
+ };
7812
+ // Private: Handle metadata loaded
7813
+ handleMetadataLoaded = () => {
7814
+ const { duration } = this.audioTarget;
7815
+ if (duration) {
7816
+ this.totalTimeTarget.textContent = formatTime(duration);
7817
+ }
7818
+ };
7819
+ // Private: advance to the next track when the current one finishes
7820
+ handleEnded = () => {
7821
+ this.next();
7822
+ };
7823
+ // Private: reflect the <audio> playing state in the UI
7824
+ handlePlayEvent = () => {
7825
+ this.playIconTarget.hidden = true;
7826
+ this.pauseIconTarget.hidden = false;
7827
+ this.element.classList.add("is-playing");
7828
+ };
7829
+ handlePauseEvent = () => {
7830
+ this.playIconTarget.hidden = false;
7831
+ this.pauseIconTarget.hidden = true;
7832
+ this.element.classList.remove("is-playing");
7833
+ };
7834
+ // Private: Handle keyboard controls
7835
+ handleKeydown = (event) => {
7836
+ if (event.key === " " || event.key === "k") {
7837
+ event.preventDefault();
7838
+ this.togglePlay();
7839
+ } else if (event.key === "ArrowLeft") {
7840
+ event.preventDefault();
7841
+ this.previous();
7842
+ } else if (event.key === "ArrowRight") {
7843
+ event.preventDefault();
7844
+ this.next();
7845
+ }
7846
+ };
7847
+ // Private: Update playlist visibility
7848
+ updatePlaylistVisibility() {
7849
+ if (this.playlistVisibleValue) {
7850
+ this.songListTarget.classList.remove("is-hidden");
7851
+ } else {
7852
+ this.songListTarget.classList.add("is-hidden");
7853
+ }
7854
+ }
7855
+ // Private: Update view mode
7856
+ updateView() {
7857
+ this.element.setAttribute("view", this.viewValue);
7858
+ }
7859
+ // Private: Save playlist visibility to localStorage
7860
+ savePlaylistVisibility() {
7861
+ try {
7862
+ localStorage.setItem(`playlist-${this.playlistNameValue}-visibility`, this.playlistVisibleValue.toString());
7863
+ } catch {
7864
+ }
7865
+ }
7866
+ }
7867
+
7868
+ class Motion extends Controller {
7869
+ static values = {
7870
+ threshold: { type: Number, default: 0.15 },
7871
+ once: { type: Boolean, default: true }
7872
+ };
7873
+ teardown = null;
7874
+ connect() {
7875
+ this.teardown = observeViewport(this.element, {
7876
+ threshold: this.thresholdValue,
7877
+ once: this.onceValue,
7878
+ onEnter: () => this.reveal(),
7879
+ onLeave: this.onceValue ? void 0 : () => this.reset()
7880
+ });
7881
+ }
7882
+ disconnect() {
7883
+ this.teardown?.();
7884
+ this.teardown = null;
7885
+ }
7886
+ reveal() {
7887
+ this.element.setAttribute("data-motion-visible", "");
7888
+ this.dispatch("reveal", { bubbles: true });
7889
+ }
7890
+ reset() {
7891
+ this.element.removeAttribute("data-motion-visible");
7892
+ }
7893
+ }
7894
+
7310
7895
  function registerMidwestControllers(application) {
7311
7896
  application.register("midwest-card", Card);
7312
7897
  application.register("midwest-banner", Banner);
@@ -7346,6 +7931,9 @@ function registerMidwestControllers(application) {
7346
7931
  application.register("midwest-focus-trap", FocusTrap);
7347
7932
  application.register("midwest-onboarding", Onboarding);
7348
7933
  application.register("midwest-theme-switch", ThemeSwitch);
7934
+ application.register("midwest-panorama", Panorama);
7935
+ application.register("midwest-playlist", Playlist);
7936
+ application.register("midwest-motion", Motion);
7349
7937
  }
7350
7938
 
7351
7939
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };