@unabridged/midwest 0.16.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/app/assets/javascript/midwest/index.ts +6 -0
  2. package/app/assets/javascript/midwest.js +970 -39
  3. package/app/assets/javascript/midwest.js.map +1 -1
  4. package/app/assets/stylesheets/midwest.css +1 -1
  5. package/app/assets/stylesheets/midwest.tailwind.css +6 -1
  6. package/dist/css/midwest.css +1 -1
  7. package/dist/javascript/collection/app/assets/javascript/midwest/index.js +6 -0
  8. package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
  9. package/dist/javascript/collection/app/components/midwest/carousel_component/carousel_component_controller.js +17 -5
  10. package/dist/javascript/collection/app/components/midwest/carousel_component/carousel_component_controller.js.map +1 -1
  11. package/dist/javascript/collection/app/components/midwest/color_picker_component/color_picker_component_controller.js +135 -0
  12. package/dist/javascript/collection/app/components/midwest/color_picker_component/color_picker_component_controller.js.map +1 -0
  13. package/dist/javascript/collection/app/components/midwest/countdown_timer_component/countdown_timer_component_controller.js +27 -6
  14. package/dist/javascript/collection/app/components/midwest/countdown_timer_component/countdown_timer_component_controller.js.map +1 -1
  15. package/dist/javascript/collection/app/components/midwest/form/autocomplete_component/autocomplete_component_controller.js +109 -21
  16. package/dist/javascript/collection/app/components/midwest/form/autocomplete_component/autocomplete_component_controller.js.map +1 -1
  17. package/dist/javascript/collection/app/components/midwest/form/color_picker_component/color_picker_component_controller.js +129 -0
  18. package/dist/javascript/collection/app/components/midwest/form/color_picker_component/color_picker_component_controller.js.map +1 -0
  19. package/dist/javascript/collection/app/components/midwest/form/input_component/input_component_controller.js +50 -1
  20. package/dist/javascript/collection/app/components/midwest/form/input_component/input_component_controller.js.map +1 -1
  21. package/dist/javascript/collection/app/components/midwest/form/live_summary_component/live_summary_component_controller.js +38 -5
  22. package/dist/javascript/collection/app/components/midwest/form/live_summary_component/live_summary_component_controller.js.map +1 -1
  23. package/dist/javascript/collection/app/components/midwest/image_component/color_thief.js +490 -0
  24. package/dist/javascript/collection/app/components/midwest/image_component/color_thief.js.map +1 -0
  25. package/dist/javascript/collection/app/components/midwest/image_component/image_component_controller.js +47 -0
  26. package/dist/javascript/collection/app/components/midwest/image_component/image_component_controller.js.map +1 -0
  27. package/dist/javascript/collection/app/components/midwest/notification_component/notification_component_controller.js +60 -0
  28. package/dist/javascript/collection/app/components/midwest/notification_component/notification_component_controller.js.map +1 -0
  29. package/dist/javascript/collection/app/components/midwest/tabs_component/tabs_component_controller.js +15 -1
  30. package/dist/javascript/collection/app/components/midwest/tabs_component/tabs_component_controller.js.map +1 -1
  31. package/dist/javascript/midwest.d.ts +5 -0
  32. package/dist/javascript/midwest.js +970 -39
  33. package/dist/javascript/midwest.js.map +1 -1
  34. package/package.json +1 -1
@@ -549,13 +549,16 @@ class Banner extends Controller {
549
549
  }
550
550
 
551
551
  class Input extends Controller {
552
- static targets = ["input", "counter"];
552
+ static targets = ["input", "counter", "capsLockWarning"];
553
553
  updateClearButton = null;
554
554
  updateCounter = null;
555
+ updateCapsLock = null;
556
+ hideCapsLock = null;
555
557
  connect() {
556
558
  this.applyColor();
557
559
  this.applySearchClear();
558
560
  this.applyCounter();
561
+ this.applyPassword();
559
562
  }
560
563
  disconnect() {
561
564
  if (this.updateClearButton) {
@@ -566,6 +569,38 @@ class Input extends Controller {
566
569
  this.inputTarget.removeEventListener("input", this.updateCounter);
567
570
  this.updateCounter = null;
568
571
  }
572
+ if (this.updateCapsLock) {
573
+ this.inputTarget.removeEventListener("keydown", this.updateCapsLock);
574
+ this.inputTarget.removeEventListener("keyup", this.updateCapsLock);
575
+ this.inputTarget.removeEventListener("mousedown", this.updateCapsLock);
576
+ this.updateCapsLock = null;
577
+ }
578
+ if (this.hideCapsLock) {
579
+ this.inputTarget.removeEventListener("blur", this.hideCapsLock);
580
+ this.hideCapsLock = null;
581
+ }
582
+ }
583
+ increment() {
584
+ const input = this.inputTarget;
585
+ const step = parseFloat(input.step) || 1;
586
+ const max = input.max !== "" ? parseFloat(input.max) : Infinity;
587
+ const newValue = (parseFloat(input.value) || 0) + step;
588
+ if (newValue <= max) {
589
+ input.value = String(newValue);
590
+ input.dispatchEvent(new Event("input", { bubbles: true }));
591
+ input.dispatchEvent(new Event("change", { bubbles: true }));
592
+ }
593
+ }
594
+ decrement() {
595
+ const input = this.inputTarget;
596
+ const step = parseFloat(input.step) || 1;
597
+ const min = input.min !== "" ? parseFloat(input.min) : -Infinity;
598
+ const newValue = (parseFloat(input.value) || 0) - step;
599
+ if (newValue >= min) {
600
+ input.value = String(newValue);
601
+ input.dispatchEvent(new Event("input", { bubbles: true }));
602
+ input.dispatchEvent(new Event("change", { bubbles: true }));
603
+ }
569
604
  }
570
605
  applyColor() {
571
606
  if (this.inputTarget.type !== "color") {
@@ -584,6 +619,20 @@ class Input extends Controller {
584
619
  };
585
620
  this.inputTarget.addEventListener("input", this.updateCounter);
586
621
  }
622
+ applyPassword() {
623
+ if (!this.hasCapsLockWarningTarget)
624
+ return;
625
+ this.updateCapsLock = (e) => {
626
+ this.capsLockWarningTarget.hidden = !e.getModifierState("CapsLock");
627
+ };
628
+ this.hideCapsLock = () => {
629
+ this.capsLockWarningTarget.hidden = true;
630
+ };
631
+ this.inputTarget.addEventListener("keydown", this.updateCapsLock);
632
+ this.inputTarget.addEventListener("keyup", this.updateCapsLock);
633
+ this.inputTarget.addEventListener("mousedown", this.updateCapsLock);
634
+ this.inputTarget.addEventListener("blur", this.hideCapsLock);
635
+ }
587
636
  applySearchClear() {
588
637
  if (this.inputTarget.type !== "search")
589
638
  return;
@@ -3111,16 +3160,23 @@ class Dialog extends Controller {
3111
3160
  }
3112
3161
 
3113
3162
  class Tabs extends Controller {
3114
- static targets = ["tab", "panel"];
3163
+ static targets = ["tab", "panel", "list"];
3115
3164
  connect() {
3116
3165
  const activeIndex = this.tabTargets.findIndex(
3117
3166
  (tab) => tab.getAttribute("aria-selected") === "true"
3118
3167
  );
3119
3168
  this.activateTab(activeIndex >= 0 ? activeIndex : 0);
3120
3169
  this.element.addEventListener("keydown", this.handleKeydown);
3170
+ if (this.hasListTarget) {
3171
+ this.updateOverflow();
3172
+ this.listTarget.addEventListener("scroll", this.updateOverflow);
3173
+ }
3121
3174
  }
3122
3175
  disconnect() {
3123
3176
  this.element.removeEventListener("keydown", this.handleKeydown);
3177
+ if (this.hasListTarget) {
3178
+ this.listTarget.removeEventListener("scroll", this.updateOverflow);
3179
+ }
3124
3180
  }
3125
3181
  select(event) {
3126
3182
  const tab = event.currentTarget;
@@ -3128,6 +3184,13 @@ class Tabs extends Controller {
3128
3184
  if (index >= 0)
3129
3185
  this.activateTab(index);
3130
3186
  }
3187
+ updateOverflow = () => {
3188
+ if (!this.hasListTarget)
3189
+ return;
3190
+ const list = this.listTarget;
3191
+ list.classList.toggle("can-scroll-left", list.scrollLeft > 0);
3192
+ list.classList.toggle("can-scroll-right", list.scrollLeft < list.scrollWidth - list.clientWidth - 1);
3193
+ };
3131
3194
  handleKeydown = (event) => {
3132
3195
  const tabs = this.tabTargets;
3133
3196
  if (tabs.length === 0)
@@ -3230,7 +3293,8 @@ class Carousel extends Controller {
3230
3293
  static targets = ["track", "prev", "next", "dot"];
3231
3294
  static values = {
3232
3295
  wizard: { type: Boolean, default: false },
3233
- autoplay: { type: Number, default: 0 }
3296
+ autoplay: { type: Number, default: 0 },
3297
+ loop: { type: Boolean, default: false }
3234
3298
  };
3235
3299
  observer = null;
3236
3300
  _currentIndex = 0;
@@ -3266,10 +3330,21 @@ class Carousel extends Controller {
3266
3330
  this.stopAutoplay();
3267
3331
  }
3268
3332
  next() {
3269
- this.trackTarget.scrollBy({ left: this.trackTarget.clientWidth, behavior: "smooth" });
3333
+ const atEnd = this._currentIndex >= this.slides.length - 1;
3334
+ if (this.loopValue && atEnd) {
3335
+ this.slides[0]?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
3336
+ } else {
3337
+ this.trackTarget.scrollBy({ left: this.trackTarget.clientWidth, behavior: "smooth" });
3338
+ }
3270
3339
  }
3271
3340
  previous() {
3272
- this.trackTarget.scrollBy({ left: -this.trackTarget.clientWidth, behavior: "smooth" });
3341
+ const atStart = this._currentIndex <= 0;
3342
+ if (this.loopValue && atStart) {
3343
+ const last = this.slides[this.slides.length - 1];
3344
+ last?.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" });
3345
+ } else {
3346
+ this.trackTarget.scrollBy({ left: -this.trackTarget.clientWidth, behavior: "smooth" });
3347
+ }
3273
3348
  }
3274
3349
  goto(event) {
3275
3350
  const button = event.currentTarget;
@@ -3436,11 +3511,11 @@ class Carousel extends Controller {
3436
3511
  const atStart = track.scrollLeft <= 0;
3437
3512
  const atEnd = track.scrollLeft >= track.scrollWidth - track.clientWidth - 1;
3438
3513
  if (this.hasPrevTarget)
3439
- this.prevTarget.classList.toggle("hide", atStart);
3514
+ this.prevTarget.classList.toggle("hide", atStart && !this.loopValue);
3440
3515
  if (this.hasNextTarget) {
3441
3516
  const atLastSlide = this._currentIndex >= this.slides.length - 1;
3442
3517
  const frontierReached = this.wizardValue && this._currentIndex >= this._maxUnlocked;
3443
- this.nextTarget.classList.toggle("hide", atEnd || atLastSlide || frontierReached);
3518
+ this.nextTarget.classList.toggle("hide", (atEnd || atLastSlide || frontierReached) && !this.loopValue);
3444
3519
  }
3445
3520
  if (this.wizardValue && this.hasDotTarget) {
3446
3521
  this.dotTargets.forEach((dot, i) => {
@@ -3466,11 +3541,13 @@ class FormLiveSummary extends Controller {
3466
3541
  this.form?.addEventListener("input", this.render);
3467
3542
  this.form?.addEventListener("change", this.render);
3468
3543
  this.form?.addEventListener("midwest-autocomplete:change", this.render);
3544
+ this.form?.addEventListener("midwest-color-picker:update", this.render);
3469
3545
  }
3470
3546
  disconnect() {
3471
3547
  this.form?.removeEventListener("input", this.render);
3472
3548
  this.form?.removeEventListener("change", this.render);
3473
3549
  this.form?.removeEventListener("midwest-autocomplete:change", this.render);
3550
+ this.form?.removeEventListener("midwest-color-picker:update", this.render);
3474
3551
  }
3475
3552
  get form() {
3476
3553
  return this.element.closest("form");
@@ -3488,15 +3565,18 @@ class FormLiveSummary extends Controller {
3488
3565
  }
3489
3566
  const dl = document.createElement("dl");
3490
3567
  dl.className = "midwest-form-live-summary-list";
3491
- fields.forEach(({ label, value }) => {
3568
+ fields.forEach(({ label, value, swatchColor }) => {
3492
3569
  const dt = Object.assign(document.createElement("dt"), {
3493
3570
  className: "midwest-form-live-summary-term",
3494
3571
  textContent: label
3495
3572
  });
3496
- const dd = Object.assign(document.createElement("dd"), {
3497
- className: "midwest-form-live-summary-definition",
3498
- textContent: value || "\u2014"
3499
- });
3573
+ const dd = document.createElement("dd");
3574
+ dd.className = "midwest-form-live-summary-definition";
3575
+ if (swatchColor !== void 0) {
3576
+ dd.append(this.buildSwatch(swatchColor), value || "\u2014");
3577
+ } else {
3578
+ dd.textContent = value || "\u2014";
3579
+ }
3500
3580
  dl.append(dt, dd);
3501
3581
  });
3502
3582
  this.listTarget.replaceChildren(dl);
@@ -3553,6 +3633,18 @@ class FormLiveSummary extends Controller {
3553
3633
  value: element.value
3554
3634
  });
3555
3635
  });
3636
+ form.querySelectorAll('[data-midwest-color-picker-target="input"]').forEach((input) => {
3637
+ if (this.element.contains(input))
3638
+ return;
3639
+ if (!input.name)
3640
+ return;
3641
+ const picker = input.closest(".midwest-color-picker");
3642
+ const labelEl = picker?.querySelector(".midwest-form-group__top label") ?? this.form?.querySelector(`label[for="${CSS.escape(input.id)}"]`);
3643
+ const label = labelEl?.textContent?.trim() || this.prettifyName(input.name.replace(/\[.*\]$/, ""));
3644
+ const value = input.value;
3645
+ const display = value.startsWith("#") ? value : value ? value.charAt(0).toUpperCase() + value.slice(1) : "\u2014";
3646
+ fields.push({ label, value: display, swatchColor: value || void 0 });
3647
+ });
3556
3648
  form.querySelectorAll(".midwest-autocomplete").forEach((ac) => {
3557
3649
  if (this.element.contains(ac))
3558
3650
  return;
@@ -3565,6 +3657,22 @@ class FormLiveSummary extends Controller {
3565
3657
  });
3566
3658
  return fields;
3567
3659
  }
3660
+ // Builds a small inline colour swatch to display alongside a colour value.
3661
+ // Hex values are applied as an inline background-color; named theme colours
3662
+ // use a scoped theme-{color} class so the CSS variable cascade does the work.
3663
+ // An empty/transparent value renders a diagonal-stripe placeholder.
3664
+ buildSwatch(color) {
3665
+ const swatch = document.createElement("span");
3666
+ swatch.className = "midwest-form-live-summary-swatch";
3667
+ if (color.startsWith("#")) {
3668
+ swatch.style.backgroundColor = color;
3669
+ } else if (color && color !== "transparent") {
3670
+ swatch.classList.add(`theme-${color}`, "bg-theme-6");
3671
+ } else {
3672
+ swatch.classList.add("is-transparent");
3673
+ }
3674
+ return swatch;
3675
+ }
3568
3676
  // Finds the label associated with a specific element via `for` attribute,
3569
3677
  // wrapping label, or falls back to a prettified version of the element name.
3570
3678
  labelFor(el) {
@@ -3630,16 +3738,37 @@ class CountdownTimer extends Controller {
3630
3738
  seconds: { type: Number, default: 5 }
3631
3739
  };
3632
3740
  timer = null;
3741
+ remaining = 0;
3742
+ startTime = 0;
3633
3743
  connect() {
3634
- this.timer = setTimeout(() => {
3635
- this.dispatch("complete", { bubbles: true });
3636
- }, this.secondsValue * 1e3);
3744
+ this.remaining = this.secondsValue * 1e3;
3745
+ this.resume();
3637
3746
  }
3638
3747
  disconnect() {
3639
- if (this.timer !== null) {
3640
- clearTimeout(this.timer);
3748
+ this.pause();
3749
+ }
3750
+ pause() {
3751
+ if (this.timer === null)
3752
+ return;
3753
+ clearTimeout(this.timer);
3754
+ this.timer = null;
3755
+ this.remaining = Math.max(0, this.remaining - (Date.now() - this.startTime));
3756
+ this.setPlayState("paused");
3757
+ }
3758
+ resume() {
3759
+ if (this.timer !== null)
3760
+ return;
3761
+ this.startTime = Date.now();
3762
+ this.timer = setTimeout(() => {
3641
3763
  this.timer = null;
3642
- }
3764
+ this.dispatch("complete", { bubbles: true });
3765
+ }, this.remaining);
3766
+ this.setPlayState("running");
3767
+ }
3768
+ setPlayState(state) {
3769
+ const progress = this.element.querySelector(".midwest-countdown-timer__progress");
3770
+ if (progress)
3771
+ progress.style.animationPlayState = state;
3643
3772
  }
3644
3773
  }
3645
3774
 
@@ -3832,19 +3961,30 @@ class Rating extends Controller {
3832
3961
  }
3833
3962
 
3834
3963
  class Autocomplete extends Controller {
3835
- static targets = ["input", "dropdown", "list", "tokens", "multipleTokenTemplate", "singleTokenTemplate", "status", "turboList", "loader"];
3964
+ static targets = [
3965
+ "input",
3966
+ "dropdown",
3967
+ "list",
3968
+ "tokens",
3969
+ "multipleTokenTemplate",
3970
+ "singleTokenTemplate",
3971
+ "status",
3972
+ "turboList",
3973
+ "loader",
3974
+ "freeFormHintList",
3975
+ "freeFormHintValue"
3976
+ ];
3836
3977
  static values = {
3837
3978
  options: { type: Array, default: [] },
3838
3979
  url: { type: String, default: "" },
3839
3980
  multiple: { type: Boolean, default: true },
3840
- turboFrameUrl: { type: String, default: "" }
3981
+ turboFrameUrl: { type: String, default: "" },
3982
+ freeForm: { type: Boolean, default: false }
3841
3983
  };
3842
3984
  highlightedIndex = -1;
3843
3985
  fetchAbort = null;
3844
3986
  searchTimer;
3845
3987
  // ── Named handlers ─────────────────────────────────────────────────────────
3846
- // Arrow-function class properties preserve `this` and produce a stable
3847
- // reference so addEventListener/removeEventListener pairs always match.
3848
3988
  onFrameLoad = () => {
3849
3989
  this.syncSelectedState();
3850
3990
  this.loading = false;
@@ -3879,9 +4019,6 @@ class Autocomplete extends Controller {
3879
4019
  clearTimeout(this.searchTimer);
3880
4020
  this.element.removeEventListener("focusout", this.onFocusOut);
3881
4021
  }
3882
- // Target callbacks — Stimulus calls these automatically when targets connect
3883
- // or disconnect, including after the initial connect(). This replaces manual
3884
- // hasTurboListTarget guards and addEventListener calls inside connect().
3885
4022
  dropdownTargetConnected(el) {
3886
4023
  el.addEventListener("toggle", this.onToggle);
3887
4024
  }
@@ -3906,13 +4043,27 @@ class Autocomplete extends Controller {
3906
4043
  this.open();
3907
4044
  }
3908
4045
  open() {
4046
+ if (!this.hasDropdownTarget)
4047
+ return;
3909
4048
  if (this.dropdownTarget.matches(":popover-open"))
3910
4049
  return;
4050
+ const query = this.inputTarget.value.trim();
4051
+ if (this.freeFormValue && !this.hasListTarget && !this.hasTurboListTarget) {
4052
+ if (query) {
4053
+ this.updateFreeFormHint(query);
4054
+ this.showDropdown();
4055
+ }
4056
+ return;
4057
+ }
3911
4058
  clearTimeout(this.searchTimer);
3912
- this.resolve(this.inputTarget.value.trim());
4059
+ this.resolve(query);
3913
4060
  }
3914
4061
  search() {
3915
4062
  const query = this.inputTarget.value.trim();
4063
+ if (this.freeFormValue)
4064
+ this.updateFreeFormHint(query);
4065
+ if (!this.hasListTarget && !this.hasTurboListTarget)
4066
+ return;
3916
4067
  if (!this.hasTurboListTarget && !this.urlValue) {
3917
4068
  this.renderOptions(this.filteredOptions(query));
3918
4069
  return;
@@ -3927,6 +4078,8 @@ class Autocomplete extends Controller {
3927
4078
  switch (event.key) {
3928
4079
  case "ArrowDown": {
3929
4080
  event.preventDefault();
4081
+ if (!this.hasDropdownTarget)
4082
+ break;
3930
4083
  if (!this.dropdownTarget.matches(":popover-open"))
3931
4084
  this.open();
3932
4085
  const items = this.visibleOptions();
@@ -3936,7 +4089,7 @@ class Autocomplete extends Controller {
3936
4089
  }
3937
4090
  case "ArrowUp": {
3938
4091
  event.preventDefault();
3939
- if (!this.dropdownTarget.matches(":popover-open"))
4092
+ if (!this.hasDropdownTarget || !this.dropdownTarget.matches(":popover-open"))
3940
4093
  break;
3941
4094
  const items = this.visibleOptions();
3942
4095
  this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
@@ -3945,11 +4098,22 @@ class Autocomplete extends Controller {
3945
4098
  }
3946
4099
  case "Enter": {
3947
4100
  event.preventDefault();
3948
- if (!this.dropdownTarget.matches(":popover-open"))
3949
- break;
3950
- const items = this.visibleOptions();
3951
- if (this.highlightedIndex >= 0 && items[this.highlightedIndex]) {
3952
- this.pickItem(items[this.highlightedIndex]);
4101
+ if (this.hasDropdownTarget && this.dropdownTarget.matches(":popover-open") && (this.hasListTarget || this.hasTurboListTarget)) {
4102
+ const items = this.visibleOptions();
4103
+ if (this.highlightedIndex >= 0 && items[this.highlightedIndex]) {
4104
+ this.pickItem(items[this.highlightedIndex]);
4105
+ break;
4106
+ }
4107
+ }
4108
+ if (this.freeFormValue) {
4109
+ this.commitFreeFormTag();
4110
+ }
4111
+ break;
4112
+ }
4113
+ case ",": {
4114
+ if (this.freeFormValue && this.inputTarget.value.trim()) {
4115
+ event.preventDefault();
4116
+ this.commitFreeFormTag();
3953
4117
  }
3954
4118
  break;
3955
4119
  }
@@ -3966,6 +4130,22 @@ class Autocomplete extends Controller {
3966
4130
  break;
3967
4131
  }
3968
4132
  }
4133
+ // Splits pasted text on commas and newlines into individual tags (free-form mode only).
4134
+ pasteTag(event) {
4135
+ if (!this.freeFormValue)
4136
+ return;
4137
+ const text = event.clipboardData?.getData("text") ?? "";
4138
+ const tags = text.split(/[\n,]+/).map((t) => t.trim()).filter(Boolean);
4139
+ if (tags.length === 0)
4140
+ return;
4141
+ event.preventDefault();
4142
+ tags.forEach((tag) => this.addFreeFormTag(tag));
4143
+ }
4144
+ // Mousedown on the "Hit Enter to add 'X'" hint row.
4145
+ addFreeFormTagFromHint(event) {
4146
+ event.preventDefault();
4147
+ this.commitFreeFormTag();
4148
+ }
3969
4149
  removeToken(event) {
3970
4150
  event.stopPropagation();
3971
4151
  const btn = event.currentTarget;
@@ -3973,9 +4153,6 @@ class Autocomplete extends Controller {
3973
4153
  if (value)
3974
4154
  this.deselect(value);
3975
4155
  }
3976
- // Public action used by turbo-frame option items via data-action.
3977
- // Each server-rendered <li> should have:
3978
- // data-action="mousedown->midwest-autocomplete#selectOption"
3979
4156
  selectOption(event) {
3980
4157
  event.preventDefault();
3981
4158
  this.pickItem(event.currentTarget);
@@ -3987,7 +4164,6 @@ class Autocomplete extends Controller {
3987
4164
  isSelected(value) {
3988
4165
  return !!this.tokensTarget.querySelector(`[data-value="${CSS.escape(value)}"]`);
3989
4166
  }
3990
- // Routes to the correct data source. Shared by open() and the debounced search path.
3991
4167
  resolve(query) {
3992
4168
  if (this.hasTurboListTarget) {
3993
4169
  this.navigateFrame(query);
@@ -4016,9 +4192,6 @@ class Autocomplete extends Controller {
4016
4192
  this.turboListTarget.setAttribute("src", url.toString());
4017
4193
  this.showDropdown();
4018
4194
  }
4019
- // After a turbo frame loads, mark already-selected items and update the live region.
4020
- // The empty state element is always present in the frame and CSS shows/hides it
4021
- // based on whether any non-selected options exist.
4022
4195
  syncSelectedState() {
4023
4196
  const options = this.turboListTarget.querySelectorAll(".midwest-autocomplete-option");
4024
4197
  options.forEach((option) => {
@@ -4054,6 +4227,7 @@ class Autocomplete extends Controller {
4054
4227
  select(option) {
4055
4228
  this.appendToken(option);
4056
4229
  this.hideDropdown();
4230
+ this.hideFreeFormHint();
4057
4231
  this.inputTarget.value = "";
4058
4232
  this.inputTarget.focus();
4059
4233
  this.announce(`${option.label} selected`);
@@ -4125,11 +4299,19 @@ class Autocomplete extends Controller {
4125
4299
  this.renderEmpty("No more options");
4126
4300
  return;
4127
4301
  }
4302
+ this.hideFreeFormHint();
4128
4303
  this.loading = false;
4129
4304
  this.announce(`${availableCount} option${availableCount === 1 ? "" : "s"} available`);
4130
4305
  this.showDropdown();
4131
4306
  }
4132
4307
  renderEmpty(message = "No results") {
4308
+ if (this.freeFormValue && this.inputTarget.value.trim()) {
4309
+ if (this.hasListTarget)
4310
+ this.listTarget.replaceChildren();
4311
+ this.loading = false;
4312
+ this.updateFreeFormHint(this.inputTarget.value.trim());
4313
+ return;
4314
+ }
4133
4315
  const li = document.createElement("li");
4134
4316
  li.className = "midwest-autocomplete-empty";
4135
4317
  li.setAttribute("aria-hidden", "true");
@@ -4139,15 +4321,39 @@ class Autocomplete extends Controller {
4139
4321
  this.announce(message);
4140
4322
  this.showDropdown();
4141
4323
  }
4324
+ // Shows or hides the "Hit Enter to add 'X'" hint based on the current query.
4325
+ updateFreeFormHint(query) {
4326
+ if (!this.hasFreeFormHintListTarget)
4327
+ return;
4328
+ if (query && !this.isSelected(query)) {
4329
+ if (this.hasFreeFormHintValueTarget) {
4330
+ this.freeFormHintValueTarget.textContent = `"${query}"`;
4331
+ }
4332
+ this.freeFormHintListTarget.hidden = false;
4333
+ this.showDropdown();
4334
+ } else {
4335
+ this.hideFreeFormHint();
4336
+ if (!this.hasListTarget && !this.hasTurboListTarget)
4337
+ this.hideDropdown();
4338
+ }
4339
+ }
4340
+ hideFreeFormHint() {
4341
+ if (this.hasFreeFormHintListTarget)
4342
+ this.freeFormHintListTarget.hidden = true;
4343
+ }
4142
4344
  set loading(on) {
4143
4345
  if (this.hasLoaderTarget)
4144
4346
  this.loaderTarget.hidden = !on;
4145
4347
  }
4146
4348
  showDropdown() {
4349
+ if (!this.hasDropdownTarget)
4350
+ return;
4147
4351
  if (!this.dropdownTarget.matches(":popover-open"))
4148
4352
  this.dropdownTarget.showPopover();
4149
4353
  }
4150
4354
  hideDropdown() {
4355
+ if (!this.hasDropdownTarget)
4356
+ return;
4151
4357
  if (this.dropdownTarget.matches(":popover-open"))
4152
4358
  this.dropdownTarget.hidePopover();
4153
4359
  }
@@ -4171,12 +4377,734 @@ class Autocomplete extends Controller {
4171
4377
  const support = item.querySelector(".midwest-autocomplete-option-support")?.textContent?.trim();
4172
4378
  this.select({ value, label, support });
4173
4379
  }
4380
+ commitFreeFormTag() {
4381
+ const value = this.inputTarget.value.trim();
4382
+ this.addFreeFormTag(value);
4383
+ }
4384
+ addFreeFormTag(value) {
4385
+ if (!value)
4386
+ return;
4387
+ if (this.isSelected(value))
4388
+ return;
4389
+ this.select({ value, label: value });
4390
+ }
4174
4391
  announce(message) {
4175
4392
  if (this.hasStatusTarget)
4176
4393
  this.statusTarget.textContent = message;
4177
4394
  }
4178
4395
  }
4179
4396
 
4397
+ /*
4398
+ * Color Thief v2.0
4399
+ * by Lokesh Dhakar - http://www.lokeshdhakar.com
4400
+ *
4401
+ * Thanks
4402
+ * ------
4403
+ * Nick Rabinowitz - For creating quantize.js.
4404
+ * John Schulz - For clean up and optimization. @JFSIII
4405
+ * Nathan Spady - For adding drag and drop support to the demo page.
4406
+ *
4407
+ * License
4408
+ * -------
4409
+ * Copyright 2011, 2015 Lokesh Dhakar
4410
+ * Released under the MIT license
4411
+ * https://raw.githubusercontent.com/lokesh/color-thief/master/LICENSE
4412
+ *
4413
+ * @license
4414
+ */
4415
+ const CanvasImage = function(image) {
4416
+ this.canvas = document.createElement("canvas");
4417
+ this.context = this.canvas.getContext("2d");
4418
+ document.body.appendChild(this.canvas);
4419
+ this.width = this.canvas.width = image.width;
4420
+ this.height = this.canvas.height = image.height;
4421
+ this.context.drawImage(image, 0, 0, this.width, this.height);
4422
+ };
4423
+ CanvasImage.prototype.clear = function() {
4424
+ this.context.clearRect(0, 0, this.width, this.height);
4425
+ };
4426
+ CanvasImage.prototype.update = function(imageData) {
4427
+ this.context.putImageData(imageData, 0, 0);
4428
+ };
4429
+ CanvasImage.prototype.getPixelCount = function() {
4430
+ return this.width * this.height;
4431
+ };
4432
+ CanvasImage.prototype.getImageData = function() {
4433
+ return this.context.getImageData(0, 0, this.width, this.height);
4434
+ };
4435
+ CanvasImage.prototype.removeCanvas = function() {
4436
+ this.canvas.parentNode.removeChild(this.canvas);
4437
+ };
4438
+ const ColorThief = function() {
4439
+ };
4440
+ ColorThief.prototype.getColor = function(sourceImage2, quality) {
4441
+ const palette = this.getPalette(sourceImage2, 5, quality);
4442
+ const dominantColor = palette[0];
4443
+ return dominantColor;
4444
+ };
4445
+ ColorThief.prototype.getPalette = function(sourceImage2, colorCount, quality) {
4446
+ if (typeof colorCount === "undefined" || colorCount < 2 || colorCount > 256) {
4447
+ colorCount = 10;
4448
+ }
4449
+ if (typeof quality === "undefined" || quality < 1) {
4450
+ quality = 10;
4451
+ }
4452
+ const image = new CanvasImage(sourceImage2);
4453
+ const imageData = image.getImageData();
4454
+ const pixels = imageData.data;
4455
+ const pixelCount = image.getPixelCount();
4456
+ const pixelArray = [];
4457
+ for (var i = 0, offset, r, g, b, a; i < pixelCount; i = i + quality) {
4458
+ offset = i * 4;
4459
+ r = pixels[offset + 0];
4460
+ g = pixels[offset + 1];
4461
+ b = pixels[offset + 2];
4462
+ a = pixels[offset + 3];
4463
+ if (a >= 125) {
4464
+ if (!(r > 250 && g > 250 && b > 250)) {
4465
+ pixelArray.push([r, g, b]);
4466
+ }
4467
+ }
4468
+ }
4469
+ const cmap = MMCQ.quantize(pixelArray, colorCount);
4470
+ const palette = cmap ? cmap.palette() : null;
4471
+ image.removeCanvas();
4472
+ return palette;
4473
+ };
4474
+ ColorThief.prototype.getColorFromUrl = function(imageUrl, callback, quality) {
4475
+ sourceImage = document.createElement("img");
4476
+ const thief = this;
4477
+ sourceImage.addEventListener("load", function() {
4478
+ const palette = thief.getPalette(sourceImage, 5, quality);
4479
+ const dominantColor = palette[0];
4480
+ callback(dominantColor, imageUrl);
4481
+ });
4482
+ sourceImage.src = imageUrl;
4483
+ };
4484
+ ColorThief.prototype.getImageData = function(imageUrl, callback) {
4485
+ xhr = new XMLHttpRequest();
4486
+ xhr.open("GET", imageUrl, true);
4487
+ xhr.responseType = "arraybuffer";
4488
+ xhr.onload = function(e) {
4489
+ if (this.status == 200) {
4490
+ uInt8Array = new Uint8Array(this.response);
4491
+ i = uInt8Array.length;
4492
+ binaryString = new Array(i);
4493
+ for (var i = 0; i < uInt8Array.length; i++) {
4494
+ binaryString[i] = String.fromCharCode(uInt8Array[i]);
4495
+ }
4496
+ data = binaryString.join("");
4497
+ base64 = window.btoa(data);
4498
+ callback("data:image/png;base64," + base64);
4499
+ }
4500
+ };
4501
+ xhr.send();
4502
+ };
4503
+ ColorThief.prototype.getColorAsync = function(imageUrl, callback, quality) {
4504
+ const thief = this;
4505
+ this.getImageData(imageUrl, function(imageData) {
4506
+ sourceImage = document.createElement("img");
4507
+ sourceImage.addEventListener("load", function() {
4508
+ const palette = thief.getPalette(sourceImage, 5, quality);
4509
+ const dominantColor = palette[0];
4510
+ callback(dominantColor, this);
4511
+ });
4512
+ sourceImage.src = imageData;
4513
+ });
4514
+ };
4515
+ /*!
4516
+ * quantize.js Copyright 2008 Nick Rabinowitz.
4517
+ * Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
4518
+ * @license
4519
+ */
4520
+ /*!
4521
+ * Block below copied from Protovis: http://mbostock.github.com/protovis/
4522
+ * Copyright 2010 Stanford Visualization Group
4523
+ * Licensed under the BSD License: http://www.opensource.org/licenses/bsd-license.php
4524
+ * @license
4525
+ */
4526
+ if (!pv) {
4527
+ var pv = {
4528
+ map: function(array, f) {
4529
+ const o = {};
4530
+ return f ? array.map(function(d, i) {
4531
+ o.index = i;
4532
+ return f.call(o, d);
4533
+ }) : array.slice();
4534
+ },
4535
+ naturalOrder: function(a, b) {
4536
+ return a < b ? -1 : a > b ? 1 : 0;
4537
+ },
4538
+ sum: function(array, f) {
4539
+ const o = {};
4540
+ return array.reduce(f ? function(p, d, i) {
4541
+ o.index = i;
4542
+ return p + f.call(o, d);
4543
+ } : function(p, d) {
4544
+ return p + d;
4545
+ }, 0);
4546
+ },
4547
+ max: function(array, f) {
4548
+ return Math.max.apply(null, f ? pv.map(array, f) : array);
4549
+ }
4550
+ };
4551
+ }
4552
+ var MMCQ = function() {
4553
+ const sigbits = 5, rshift = 8 - sigbits, maxIterations = 1e3, fractByPopulations = 0.75;
4554
+ function getColorIndex(r, g, b) {
4555
+ return (r << 2 * sigbits) + (g << sigbits) + b;
4556
+ }
4557
+ function PQueue(comparator) {
4558
+ let contents = [], sorted = false;
4559
+ function sort() {
4560
+ contents.sort(comparator);
4561
+ sorted = true;
4562
+ }
4563
+ return {
4564
+ push: function(o) {
4565
+ contents.push(o);
4566
+ sorted = false;
4567
+ },
4568
+ peek: function(index) {
4569
+ if (!sorted)
4570
+ sort();
4571
+ if (index === void 0)
4572
+ index = contents.length - 1;
4573
+ return contents[index];
4574
+ },
4575
+ pop: function() {
4576
+ if (!sorted)
4577
+ sort();
4578
+ return contents.pop();
4579
+ },
4580
+ size: function() {
4581
+ return contents.length;
4582
+ },
4583
+ map: function(f) {
4584
+ return contents.map(f);
4585
+ },
4586
+ debug: function() {
4587
+ if (!sorted)
4588
+ sort();
4589
+ return contents;
4590
+ }
4591
+ };
4592
+ }
4593
+ function VBox(r1, r2, g1, g2, b1, b2, histo) {
4594
+ const vbox = this;
4595
+ vbox.r1 = r1;
4596
+ vbox.r2 = r2;
4597
+ vbox.g1 = g1;
4598
+ vbox.g2 = g2;
4599
+ vbox.b1 = b1;
4600
+ vbox.b2 = b2;
4601
+ vbox.histo = histo;
4602
+ }
4603
+ VBox.prototype = {
4604
+ volume: function(force) {
4605
+ const vbox = this;
4606
+ if (!vbox._volume || force) {
4607
+ vbox._volume = (vbox.r2 - vbox.r1 + 1) * (vbox.g2 - vbox.g1 + 1) * (vbox.b2 - vbox.b1 + 1);
4608
+ }
4609
+ return vbox._volume;
4610
+ },
4611
+ count: function(force) {
4612
+ const vbox = this, histo = vbox.histo;
4613
+ if (!vbox._count_set || force) {
4614
+ let npix = 0, index, i, j, k;
4615
+ for (i = vbox.r1; i <= vbox.r2; i++) {
4616
+ for (j = vbox.g1; j <= vbox.g2; j++) {
4617
+ for (k = vbox.b1; k <= vbox.b2; k++) {
4618
+ index = getColorIndex(i, j, k);
4619
+ npix += histo[index] || 0;
4620
+ }
4621
+ }
4622
+ }
4623
+ vbox._count = npix;
4624
+ vbox._count_set = true;
4625
+ }
4626
+ return vbox._count;
4627
+ },
4628
+ copy: function() {
4629
+ const vbox = this;
4630
+ return new VBox(vbox.r1, vbox.r2, vbox.g1, vbox.g2, vbox.b1, vbox.b2, vbox.histo);
4631
+ },
4632
+ avg: function(force) {
4633
+ const vbox = this, histo = vbox.histo;
4634
+ if (!vbox._avg || force) {
4635
+ let ntot = 0, mult = 1 << 8 - sigbits, rsum = 0, gsum = 0, bsum = 0, hval, i, j, k, histoindex;
4636
+ for (i = vbox.r1; i <= vbox.r2; i++) {
4637
+ for (j = vbox.g1; j <= vbox.g2; j++) {
4638
+ for (k = vbox.b1; k <= vbox.b2; k++) {
4639
+ histoindex = getColorIndex(i, j, k);
4640
+ hval = histo[histoindex] || 0;
4641
+ ntot += hval;
4642
+ rsum += hval * (i + 0.5) * mult;
4643
+ gsum += hval * (j + 0.5) * mult;
4644
+ bsum += hval * (k + 0.5) * mult;
4645
+ }
4646
+ }
4647
+ }
4648
+ if (ntot) {
4649
+ vbox._avg = [~~(rsum / ntot), ~~(gsum / ntot), ~~(bsum / ntot)];
4650
+ } else {
4651
+ vbox._avg = [
4652
+ ~~(mult * (vbox.r1 + vbox.r2 + 1) / 2),
4653
+ ~~(mult * (vbox.g1 + vbox.g2 + 1) / 2),
4654
+ ~~(mult * (vbox.b1 + vbox.b2 + 1) / 2)
4655
+ ];
4656
+ }
4657
+ }
4658
+ return vbox._avg;
4659
+ },
4660
+ contains: function(pixel) {
4661
+ const vbox = this, rval = pixel[0] >> rshift;
4662
+ gval = pixel[1] >> rshift;
4663
+ bval = pixel[2] >> rshift;
4664
+ return rval >= vbox.r1 && rval <= vbox.r2 && gval >= vbox.g1 && gval <= vbox.g2 && bval >= vbox.b1 && bval <= vbox.b2;
4665
+ }
4666
+ };
4667
+ function CMap() {
4668
+ this.vboxes = new PQueue(function(a, b) {
4669
+ return pv.naturalOrder(
4670
+ a.vbox.count() * a.vbox.volume(),
4671
+ b.vbox.count() * b.vbox.volume()
4672
+ );
4673
+ });
4674
+ }
4675
+ CMap.prototype = {
4676
+ push: function(vbox) {
4677
+ this.vboxes.push({
4678
+ vbox,
4679
+ color: vbox.avg()
4680
+ });
4681
+ },
4682
+ palette: function() {
4683
+ return this.vboxes.map(function(vb) {
4684
+ return vb.color;
4685
+ });
4686
+ },
4687
+ size: function() {
4688
+ return this.vboxes.size();
4689
+ },
4690
+ map: function(color) {
4691
+ const vboxes = this.vboxes;
4692
+ for (let i = 0; i < vboxes.size(); i++) {
4693
+ if (vboxes.peek(i).vbox.contains(color)) {
4694
+ return vboxes.peek(i).color;
4695
+ }
4696
+ }
4697
+ return this.nearest(color);
4698
+ },
4699
+ nearest: function(color) {
4700
+ let vboxes = this.vboxes, d1, d2, pColor;
4701
+ for (let i = 0; i < vboxes.size(); i++) {
4702
+ d2 = Math.sqrt(
4703
+ Math.pow(color[0] - vboxes.peek(i).color[0], 2) + Math.pow(color[1] - vboxes.peek(i).color[1], 2) + Math.pow(color[2] - vboxes.peek(i).color[2], 2)
4704
+ );
4705
+ if (d2 < d1 || d1 === void 0) {
4706
+ d1 = d2;
4707
+ pColor = vboxes.peek(i).color;
4708
+ }
4709
+ }
4710
+ return pColor;
4711
+ },
4712
+ forcebw: function() {
4713
+ const vboxes = this.vboxes;
4714
+ vboxes.sort(function(a, b) {
4715
+ return pv.naturalOrder(pv.sum(a.color), pv.sum(b.color));
4716
+ });
4717
+ const lowest = vboxes[0].color;
4718
+ if (lowest[0] < 5 && lowest[1] < 5 && lowest[2] < 5)
4719
+ vboxes[0].color = [0, 0, 0];
4720
+ const idx = vboxes.length - 1, highest = vboxes[idx].color;
4721
+ if (highest[0] > 251 && highest[1] > 251 && highest[2] > 251)
4722
+ vboxes[idx].color = [255, 255, 255];
4723
+ }
4724
+ };
4725
+ function getHisto(pixels) {
4726
+ let histosize = 1 << 3 * sigbits, histo = new Array(histosize), index, rval, gval2, bval2;
4727
+ pixels.forEach(function(pixel) {
4728
+ rval = pixel[0] >> rshift;
4729
+ gval2 = pixel[1] >> rshift;
4730
+ bval2 = pixel[2] >> rshift;
4731
+ index = getColorIndex(rval, gval2, bval2);
4732
+ histo[index] = (histo[index] || 0) + 1;
4733
+ });
4734
+ return histo;
4735
+ }
4736
+ function vboxFromPixels(pixels, histo) {
4737
+ let rmin = 1e6, rmax = 0, gmin = 1e6, gmax = 0, bmin = 1e6, bmax = 0, rval, gval2, bval2;
4738
+ pixels.forEach(function(pixel) {
4739
+ rval = pixel[0] >> rshift;
4740
+ gval2 = pixel[1] >> rshift;
4741
+ bval2 = pixel[2] >> rshift;
4742
+ if (rval < rmin)
4743
+ rmin = rval;
4744
+ else if (rval > rmax)
4745
+ rmax = rval;
4746
+ if (gval2 < gmin)
4747
+ gmin = gval2;
4748
+ else if (gval2 > gmax)
4749
+ gmax = gval2;
4750
+ if (bval2 < bmin)
4751
+ bmin = bval2;
4752
+ else if (bval2 > bmax)
4753
+ bmax = bval2;
4754
+ });
4755
+ return new VBox(rmin, rmax, gmin, gmax, bmin, bmax, histo);
4756
+ }
4757
+ function medianCutApply(histo, vbox) {
4758
+ if (!vbox.count())
4759
+ return;
4760
+ const rw = vbox.r2 - vbox.r1 + 1, gw = vbox.g2 - vbox.g1 + 1, bw = vbox.b2 - vbox.b1 + 1, maxw = pv.max([rw, gw, bw]);
4761
+ if (vbox.count() == 1) {
4762
+ return [vbox.copy()];
4763
+ }
4764
+ let total = 0, partialsum = [], lookaheadsum = [], i, j, k, sum, index;
4765
+ if (maxw == rw) {
4766
+ for (i = vbox.r1; i <= vbox.r2; i++) {
4767
+ sum = 0;
4768
+ for (j = vbox.g1; j <= vbox.g2; j++) {
4769
+ for (k = vbox.b1; k <= vbox.b2; k++) {
4770
+ index = getColorIndex(i, j, k);
4771
+ sum += histo[index] || 0;
4772
+ }
4773
+ }
4774
+ total += sum;
4775
+ partialsum[i] = total;
4776
+ }
4777
+ } else if (maxw == gw) {
4778
+ for (i = vbox.g1; i <= vbox.g2; i++) {
4779
+ sum = 0;
4780
+ for (j = vbox.r1; j <= vbox.r2; j++) {
4781
+ for (k = vbox.b1; k <= vbox.b2; k++) {
4782
+ index = getColorIndex(j, i, k);
4783
+ sum += histo[index] || 0;
4784
+ }
4785
+ }
4786
+ total += sum;
4787
+ partialsum[i] = total;
4788
+ }
4789
+ } else {
4790
+ for (i = vbox.b1; i <= vbox.b2; i++) {
4791
+ sum = 0;
4792
+ for (j = vbox.r1; j <= vbox.r2; j++) {
4793
+ for (k = vbox.g1; k <= vbox.g2; k++) {
4794
+ index = getColorIndex(j, k, i);
4795
+ sum += histo[index] || 0;
4796
+ }
4797
+ }
4798
+ total += sum;
4799
+ partialsum[i] = total;
4800
+ }
4801
+ }
4802
+ partialsum.forEach(function(d, i2) {
4803
+ lookaheadsum[i2] = total - d;
4804
+ });
4805
+ function doCut(color) {
4806
+ let dim1 = color + "1", dim2 = color + "2", left, right, vbox1, vbox2, d2, count2 = 0;
4807
+ for (i = vbox[dim1]; i <= vbox[dim2]; i++) {
4808
+ if (partialsum[i] > total / 2) {
4809
+ vbox1 = vbox.copy();
4810
+ vbox2 = vbox.copy();
4811
+ left = i - vbox[dim1];
4812
+ right = vbox[dim2] - i;
4813
+ if (left <= right)
4814
+ d2 = Math.min(vbox[dim2] - 1, ~~(i + right / 2));
4815
+ else
4816
+ d2 = Math.max(vbox[dim1], ~~(i - 1 - left / 2));
4817
+ while (!partialsum[d2])
4818
+ d2++;
4819
+ count2 = lookaheadsum[d2];
4820
+ while (!count2 && partialsum[d2 - 1])
4821
+ count2 = lookaheadsum[--d2];
4822
+ vbox1[dim2] = d2;
4823
+ vbox2[dim1] = vbox1[dim2] + 1;
4824
+ return [vbox1, vbox2];
4825
+ }
4826
+ }
4827
+ }
4828
+ return maxw == rw ? doCut("r") : maxw == gw ? doCut("g") : doCut("b");
4829
+ }
4830
+ function quantize(pixels, maxcolors) {
4831
+ if (!pixels.length || maxcolors < 2 || maxcolors > 256) {
4832
+ return false;
4833
+ }
4834
+ const histo = getHisto(pixels);
4835
+ histo.forEach(function() {
4836
+ });
4837
+ const vbox = vboxFromPixels(pixels, histo), pq = new PQueue(function(a, b) {
4838
+ return pv.naturalOrder(a.count(), b.count());
4839
+ });
4840
+ pq.push(vbox);
4841
+ function iter(lh, target) {
4842
+ let ncolors = 1, niters = 0, vbox2;
4843
+ while (niters < maxIterations) {
4844
+ vbox2 = lh.pop();
4845
+ if (!vbox2.count()) {
4846
+ lh.push(vbox2);
4847
+ niters++;
4848
+ continue;
4849
+ }
4850
+ const vboxes = medianCutApply(histo, vbox2), vbox1 = vboxes[0], vbox22 = vboxes[1];
4851
+ if (!vbox1) {
4852
+ return;
4853
+ }
4854
+ lh.push(vbox1);
4855
+ if (vbox22) {
4856
+ lh.push(vbox22);
4857
+ ncolors++;
4858
+ }
4859
+ if (ncolors >= target)
4860
+ return;
4861
+ if (niters++ > maxIterations) {
4862
+ return;
4863
+ }
4864
+ }
4865
+ }
4866
+ iter(pq, fractByPopulations * maxcolors);
4867
+ const pq2 = new PQueue(function(a, b) {
4868
+ return pv.naturalOrder(a.count() * a.volume(), b.count() * b.volume());
4869
+ });
4870
+ while (pq.size()) {
4871
+ pq2.push(pq.pop());
4872
+ }
4873
+ iter(pq2, maxcolors - pq2.size());
4874
+ const cmap = new CMap();
4875
+ while (pq2.size()) {
4876
+ cmap.push(pq2.pop());
4877
+ }
4878
+ return cmap;
4879
+ }
4880
+ return {
4881
+ quantize
4882
+ };
4883
+ }();
4884
+
4885
+ class Image extends Controller {
4886
+ static targets = ["dialog"];
4887
+ static values = { poster: String };
4888
+ connect() {
4889
+ this.extractColor();
4890
+ }
4891
+ extractColor() {
4892
+ if (!this.hasDialogTarget)
4893
+ return;
4894
+ const mainImg = this.element.querySelector("img.midwest-image-main");
4895
+ const colorSource = this.posterValue || mainImg?.src;
4896
+ if (!colorSource)
4897
+ return;
4898
+ const sample = new window.Image(80, 80);
4899
+ sample.crossOrigin = "Anonymous";
4900
+ sample.addEventListener("load", () => {
4901
+ try {
4902
+ const [r, g, b] = new ColorThief().getColor(sample);
4903
+ this.dialogTarget.style.setProperty("--image-dominant-color", `${r} ${g} ${b}`);
4904
+ } catch {
4905
+ }
4906
+ });
4907
+ sample.src = colorSource;
4908
+ }
4909
+ zoom() {
4910
+ if (this.hasDialogTarget) {
4911
+ this.dialogTarget.showModal();
4912
+ }
4913
+ }
4914
+ close() {
4915
+ if (this.hasDialogTarget) {
4916
+ this.dialogTarget.close();
4917
+ }
4918
+ }
4919
+ // Close when clicking the backdrop (outside the dialog content).
4920
+ backdropClose(event) {
4921
+ if (event.target === this.dialogTarget) {
4922
+ this.dialogTarget.close();
4923
+ }
4924
+ }
4925
+ }
4926
+
4927
+ class ColorPicker extends Controller {
4928
+ static targets = ["input", "swatch", "customSwatch", "colorInput"];
4929
+ onCustomColorChange = null;
4930
+ get scaleTarget() {
4931
+ return this.element.dataset.scaleTarget ?? "theme";
4932
+ }
4933
+ connect() {
4934
+ if (this.hasColorInputTarget) {
4935
+ this.onCustomColorChange = (e) => this.handleCustomColor(e);
4936
+ this.colorInputTarget.addEventListener("input", this.onCustomColorChange);
4937
+ }
4938
+ if (this.hasInputTarget && this.inputTarget.value.startsWith("#")) {
4939
+ this.restoreCustom(this.inputTarget.value);
4940
+ }
4941
+ }
4942
+ disconnect() {
4943
+ if (this.hasColorInputTarget && this.onCustomColorChange) {
4944
+ this.colorInputTarget.removeEventListener("input", this.onCustomColorChange);
4945
+ this.onCustomColorChange = null;
4946
+ }
4947
+ }
4948
+ select(e) {
4949
+ const button = e.currentTarget;
4950
+ const color = button.dataset.color ?? "";
4951
+ this.applySelection(color);
4952
+ this.dispatch("update", { detail: { color, scaleTarget: this.scaleTarget } });
4953
+ }
4954
+ openCustom(e) {
4955
+ e.preventDefault();
4956
+ this.colorInputTarget.click();
4957
+ }
4958
+ handleCustomColor(e) {
4959
+ const hex = e.target.value;
4960
+ if (this.hasCustomSwatchTarget) {
4961
+ this.customSwatchTarget.dataset.color = hex;
4962
+ this.customSwatchTarget.style.background = hex;
4963
+ }
4964
+ const scale = this.generateScale(hex);
4965
+ this.applyScale(scale);
4966
+ this.applySelection(hex);
4967
+ this.dispatch("update", { detail: { color: hex, scale, scaleTarget: this.scaleTarget } });
4968
+ }
4969
+ restoreCustom(hex) {
4970
+ if (this.hasColorInputTarget) {
4971
+ this.colorInputTarget.value = hex;
4972
+ }
4973
+ if (this.hasCustomSwatchTarget) {
4974
+ this.customSwatchTarget.style.background = hex;
4975
+ }
4976
+ const scale = this.generateScale(hex);
4977
+ this.applyScale(scale);
4978
+ }
4979
+ applySelection(color) {
4980
+ if (this.hasInputTarget) {
4981
+ this.inputTarget.value = color;
4982
+ }
4983
+ this.swatchTargets.forEach((swatch) => {
4984
+ const isSelected = swatch.dataset.color === color;
4985
+ swatch.classList.toggle("is-selected", isSelected);
4986
+ swatch.setAttribute("aria-pressed", String(isSelected));
4987
+ });
4988
+ }
4989
+ // Generates 13 RGB channel strings (space-separated, matching --{color}-{n}-channels format)
4990
+ // from a hex color by fixing the hue/saturation and distributing lightness across the scale.
4991
+ generateScale(hex) {
4992
+ const [h, s] = this.hexToHsl(hex);
4993
+ const lightnessSteps = [97, 90, 82, 74, 65, 56, 48, 40, 33, 26, 18, 10, 3];
4994
+ return Object.fromEntries(
4995
+ lightnessSteps.map((l, i) => {
4996
+ const [r, g, b] = this.hslToRgb(h, s, l);
4997
+ return [String(i), `${r} ${g} ${b}`];
4998
+ })
4999
+ );
5000
+ }
5001
+ applyScale(scale) {
5002
+ const el = this.element;
5003
+ Object.entries(scale).forEach(([step, channels]) => {
5004
+ el.style.setProperty(`--${this.scaleTarget}-${step}-channels`, channels);
5005
+ });
5006
+ }
5007
+ hexToHsl(hex) {
5008
+ const r = parseInt(hex.slice(1, 3), 16) / 255;
5009
+ const g = parseInt(hex.slice(3, 5), 16) / 255;
5010
+ const b = parseInt(hex.slice(5, 7), 16) / 255;
5011
+ const max = Math.max(r, g, b);
5012
+ const min = Math.min(r, g, b);
5013
+ const l = (max + min) / 2;
5014
+ if (max === min)
5015
+ return [0, 0, l * 100];
5016
+ const d = max - min;
5017
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
5018
+ const h = max === r ? ((g - b) / d + (g < b ? 6 : 0)) / 6 : max === g ? ((b - r) / d + 2) / 6 : ((r - g) / d + 4) / 6;
5019
+ return [h * 360, s * 100, l * 100];
5020
+ }
5021
+ hslToRgb(h, s, l) {
5022
+ h /= 360;
5023
+ s /= 100;
5024
+ l /= 100;
5025
+ if (s === 0) {
5026
+ const v = Math.round(l * 255);
5027
+ return [v, v, v];
5028
+ }
5029
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
5030
+ const p = 2 * l - q;
5031
+ return [
5032
+ Math.round(this.hue2rgb(p, q, h + 1 / 3) * 255),
5033
+ Math.round(this.hue2rgb(p, q, h) * 255),
5034
+ Math.round(this.hue2rgb(p, q, h - 1 / 3) * 255)
5035
+ ];
5036
+ }
5037
+ hue2rgb(p, q, t) {
5038
+ if (t < 0)
5039
+ t += 1;
5040
+ if (t > 1)
5041
+ t -= 1;
5042
+ if (t < 1 / 6)
5043
+ return p + (q - p) * 6 * t;
5044
+ if (t < 1 / 2)
5045
+ return q;
5046
+ if (t < 2 / 3)
5047
+ return p + (q - p) * (2 / 3 - t) * 6;
5048
+ return p;
5049
+ }
5050
+ }
5051
+
5052
+ class Notification extends Controller {
5053
+ container = null;
5054
+ observer = null;
5055
+ connect() {
5056
+ this.container = this.element.closest("[popover]");
5057
+ this.container?.showPopover?.();
5058
+ requestAnimationFrame(() => {
5059
+ this.element.classList.add("visible");
5060
+ });
5061
+ const frame = this.element.parentElement;
5062
+ if (frame) {
5063
+ this.observer = new MutationObserver(() => this.syncCountdown());
5064
+ this.observer.observe(frame, { childList: true });
5065
+ setTimeout(() => this.syncCountdown());
5066
+ }
5067
+ }
5068
+ disconnect() {
5069
+ this.observer?.disconnect();
5070
+ this.observer = null;
5071
+ if (this.container) {
5072
+ const frame = this.container.querySelector("turbo-frame");
5073
+ if (frame?.children.length === 0 && this.container.matches(":popover-open")) {
5074
+ this.container.hidePopover?.();
5075
+ }
5076
+ this.container = null;
5077
+ }
5078
+ }
5079
+ close() {
5080
+ if (!this.element.classList.contains("animated")) {
5081
+ this.element.remove();
5082
+ return;
5083
+ }
5084
+ this.element.classList.add("closing");
5085
+ setTimeout(() => {
5086
+ this.element.remove();
5087
+ }, 350);
5088
+ }
5089
+ syncCountdown() {
5090
+ const timerEl = this.element.querySelector('[data-controller~="midwest-countdown-timer"]');
5091
+ if (!timerEl)
5092
+ return;
5093
+ const timer = this.application.getControllerForElementAndIdentifier(
5094
+ timerEl,
5095
+ "midwest-countdown-timer"
5096
+ );
5097
+ if (!timer)
5098
+ return;
5099
+ const isTop = this.element === this.element.parentElement?.firstElementChild;
5100
+ if (isTop) {
5101
+ timer.resume();
5102
+ } else {
5103
+ timer.pause();
5104
+ }
5105
+ }
5106
+ }
5107
+
4180
5108
  function registerMidwestControllers(application) {
4181
5109
  application.register("midwest-card", Card);
4182
5110
  application.register("midwest-banner", Banner);
@@ -4198,6 +5126,9 @@ function registerMidwestControllers(application) {
4198
5126
  application.register("midwest-range", Range);
4199
5127
  application.register("midwest-rating", Rating);
4200
5128
  application.register("midwest-autocomplete", Autocomplete);
5129
+ application.register("midwest-image", Image);
5130
+ application.register("midwest-color-picker", ColorPicker);
5131
+ application.register("midwest-notification", Notification);
4201
5132
  }
4202
5133
 
4203
5134
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };