@unabridged/midwest 0.17.0 → 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 (26) hide show
  1. package/app/assets/javascript/midwest/index.ts +4 -0
  2. package/app/assets/javascript/midwest.js +407 -33
  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 +4 -1
  6. package/dist/css/midwest.css +1 -1
  7. package/dist/javascript/collection/app/assets/javascript/midwest/index.js +4 -0
  8. package/dist/javascript/collection/app/assets/javascript/midwest/index.js.map +1 -1
  9. package/dist/javascript/collection/app/components/midwest/color_picker_component/color_picker_component_controller.js +135 -0
  10. package/dist/javascript/collection/app/components/midwest/color_picker_component/color_picker_component_controller.js.map +1 -0
  11. package/dist/javascript/collection/app/components/midwest/countdown_timer_component/countdown_timer_component_controller.js +27 -6
  12. package/dist/javascript/collection/app/components/midwest/countdown_timer_component/countdown_timer_component_controller.js.map +1 -1
  13. package/dist/javascript/collection/app/components/midwest/form/autocomplete_component/autocomplete_component_controller.js +109 -21
  14. package/dist/javascript/collection/app/components/midwest/form/autocomplete_component/autocomplete_component_controller.js.map +1 -1
  15. package/dist/javascript/collection/app/components/midwest/form/color_picker_component/color_picker_component_controller.js +129 -0
  16. package/dist/javascript/collection/app/components/midwest/form/color_picker_component/color_picker_component_controller.js.map +1 -0
  17. package/dist/javascript/collection/app/components/midwest/form/input_component/input_component_controller.js +50 -1
  18. package/dist/javascript/collection/app/components/midwest/form/input_component/input_component_controller.js.map +1 -1
  19. package/dist/javascript/collection/app/components/midwest/form/live_summary_component/live_summary_component_controller.js +38 -5
  20. package/dist/javascript/collection/app/components/midwest/form/live_summary_component/live_summary_component_controller.js.map +1 -1
  21. package/dist/javascript/collection/app/components/midwest/notification_component/notification_component_controller.js +60 -0
  22. package/dist/javascript/collection/app/components/midwest/notification_component/notification_component_controller.js.map +1 -0
  23. package/dist/javascript/midwest.d.ts +5 -0
  24. package/dist/javascript/midwest.js +407 -33
  25. package/dist/javascript/midwest.js.map +1 -1
  26. 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;
@@ -3492,11 +3541,13 @@ class FormLiveSummary extends Controller {
3492
3541
  this.form?.addEventListener("input", this.render);
3493
3542
  this.form?.addEventListener("change", this.render);
3494
3543
  this.form?.addEventListener("midwest-autocomplete:change", this.render);
3544
+ this.form?.addEventListener("midwest-color-picker:update", this.render);
3495
3545
  }
3496
3546
  disconnect() {
3497
3547
  this.form?.removeEventListener("input", this.render);
3498
3548
  this.form?.removeEventListener("change", this.render);
3499
3549
  this.form?.removeEventListener("midwest-autocomplete:change", this.render);
3550
+ this.form?.removeEventListener("midwest-color-picker:update", this.render);
3500
3551
  }
3501
3552
  get form() {
3502
3553
  return this.element.closest("form");
@@ -3514,15 +3565,18 @@ class FormLiveSummary extends Controller {
3514
3565
  }
3515
3566
  const dl = document.createElement("dl");
3516
3567
  dl.className = "midwest-form-live-summary-list";
3517
- fields.forEach(({ label, value }) => {
3568
+ fields.forEach(({ label, value, swatchColor }) => {
3518
3569
  const dt = Object.assign(document.createElement("dt"), {
3519
3570
  className: "midwest-form-live-summary-term",
3520
3571
  textContent: label
3521
3572
  });
3522
- const dd = Object.assign(document.createElement("dd"), {
3523
- className: "midwest-form-live-summary-definition",
3524
- textContent: value || "\u2014"
3525
- });
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
+ }
3526
3580
  dl.append(dt, dd);
3527
3581
  });
3528
3582
  this.listTarget.replaceChildren(dl);
@@ -3579,6 +3633,18 @@ class FormLiveSummary extends Controller {
3579
3633
  value: element.value
3580
3634
  });
3581
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
+ });
3582
3648
  form.querySelectorAll(".midwest-autocomplete").forEach((ac) => {
3583
3649
  if (this.element.contains(ac))
3584
3650
  return;
@@ -3591,6 +3657,22 @@ class FormLiveSummary extends Controller {
3591
3657
  });
3592
3658
  return fields;
3593
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
+ }
3594
3676
  // Finds the label associated with a specific element via `for` attribute,
3595
3677
  // wrapping label, or falls back to a prettified version of the element name.
3596
3678
  labelFor(el) {
@@ -3656,16 +3738,37 @@ class CountdownTimer extends Controller {
3656
3738
  seconds: { type: Number, default: 5 }
3657
3739
  };
3658
3740
  timer = null;
3741
+ remaining = 0;
3742
+ startTime = 0;
3659
3743
  connect() {
3660
- this.timer = setTimeout(() => {
3661
- this.dispatch("complete", { bubbles: true });
3662
- }, this.secondsValue * 1e3);
3744
+ this.remaining = this.secondsValue * 1e3;
3745
+ this.resume();
3663
3746
  }
3664
3747
  disconnect() {
3665
- if (this.timer !== null) {
3666
- 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(() => {
3667
3763
  this.timer = null;
3668
- }
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;
3669
3772
  }
3670
3773
  }
3671
3774
 
@@ -3858,19 +3961,30 @@ class Rating extends Controller {
3858
3961
  }
3859
3962
 
3860
3963
  class Autocomplete extends Controller {
3861
- 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
+ ];
3862
3977
  static values = {
3863
3978
  options: { type: Array, default: [] },
3864
3979
  url: { type: String, default: "" },
3865
3980
  multiple: { type: Boolean, default: true },
3866
- turboFrameUrl: { type: String, default: "" }
3981
+ turboFrameUrl: { type: String, default: "" },
3982
+ freeForm: { type: Boolean, default: false }
3867
3983
  };
3868
3984
  highlightedIndex = -1;
3869
3985
  fetchAbort = null;
3870
3986
  searchTimer;
3871
3987
  // ── Named handlers ─────────────────────────────────────────────────────────
3872
- // Arrow-function class properties preserve `this` and produce a stable
3873
- // reference so addEventListener/removeEventListener pairs always match.
3874
3988
  onFrameLoad = () => {
3875
3989
  this.syncSelectedState();
3876
3990
  this.loading = false;
@@ -3905,9 +4019,6 @@ class Autocomplete extends Controller {
3905
4019
  clearTimeout(this.searchTimer);
3906
4020
  this.element.removeEventListener("focusout", this.onFocusOut);
3907
4021
  }
3908
- // Target callbacks — Stimulus calls these automatically when targets connect
3909
- // or disconnect, including after the initial connect(). This replaces manual
3910
- // hasTurboListTarget guards and addEventListener calls inside connect().
3911
4022
  dropdownTargetConnected(el) {
3912
4023
  el.addEventListener("toggle", this.onToggle);
3913
4024
  }
@@ -3932,13 +4043,27 @@ class Autocomplete extends Controller {
3932
4043
  this.open();
3933
4044
  }
3934
4045
  open() {
4046
+ if (!this.hasDropdownTarget)
4047
+ return;
3935
4048
  if (this.dropdownTarget.matches(":popover-open"))
3936
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
+ }
3937
4058
  clearTimeout(this.searchTimer);
3938
- this.resolve(this.inputTarget.value.trim());
4059
+ this.resolve(query);
3939
4060
  }
3940
4061
  search() {
3941
4062
  const query = this.inputTarget.value.trim();
4063
+ if (this.freeFormValue)
4064
+ this.updateFreeFormHint(query);
4065
+ if (!this.hasListTarget && !this.hasTurboListTarget)
4066
+ return;
3942
4067
  if (!this.hasTurboListTarget && !this.urlValue) {
3943
4068
  this.renderOptions(this.filteredOptions(query));
3944
4069
  return;
@@ -3953,6 +4078,8 @@ class Autocomplete extends Controller {
3953
4078
  switch (event.key) {
3954
4079
  case "ArrowDown": {
3955
4080
  event.preventDefault();
4081
+ if (!this.hasDropdownTarget)
4082
+ break;
3956
4083
  if (!this.dropdownTarget.matches(":popover-open"))
3957
4084
  this.open();
3958
4085
  const items = this.visibleOptions();
@@ -3962,7 +4089,7 @@ class Autocomplete extends Controller {
3962
4089
  }
3963
4090
  case "ArrowUp": {
3964
4091
  event.preventDefault();
3965
- if (!this.dropdownTarget.matches(":popover-open"))
4092
+ if (!this.hasDropdownTarget || !this.dropdownTarget.matches(":popover-open"))
3966
4093
  break;
3967
4094
  const items = this.visibleOptions();
3968
4095
  this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
@@ -3971,11 +4098,22 @@ class Autocomplete extends Controller {
3971
4098
  }
3972
4099
  case "Enter": {
3973
4100
  event.preventDefault();
3974
- if (!this.dropdownTarget.matches(":popover-open"))
3975
- break;
3976
- const items = this.visibleOptions();
3977
- if (this.highlightedIndex >= 0 && items[this.highlightedIndex]) {
3978
- 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();
3979
4117
  }
3980
4118
  break;
3981
4119
  }
@@ -3992,6 +4130,22 @@ class Autocomplete extends Controller {
3992
4130
  break;
3993
4131
  }
3994
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
+ }
3995
4149
  removeToken(event) {
3996
4150
  event.stopPropagation();
3997
4151
  const btn = event.currentTarget;
@@ -3999,9 +4153,6 @@ class Autocomplete extends Controller {
3999
4153
  if (value)
4000
4154
  this.deselect(value);
4001
4155
  }
4002
- // Public action used by turbo-frame option items via data-action.
4003
- // Each server-rendered <li> should have:
4004
- // data-action="mousedown->midwest-autocomplete#selectOption"
4005
4156
  selectOption(event) {
4006
4157
  event.preventDefault();
4007
4158
  this.pickItem(event.currentTarget);
@@ -4013,7 +4164,6 @@ class Autocomplete extends Controller {
4013
4164
  isSelected(value) {
4014
4165
  return !!this.tokensTarget.querySelector(`[data-value="${CSS.escape(value)}"]`);
4015
4166
  }
4016
- // Routes to the correct data source. Shared by open() and the debounced search path.
4017
4167
  resolve(query) {
4018
4168
  if (this.hasTurboListTarget) {
4019
4169
  this.navigateFrame(query);
@@ -4042,9 +4192,6 @@ class Autocomplete extends Controller {
4042
4192
  this.turboListTarget.setAttribute("src", url.toString());
4043
4193
  this.showDropdown();
4044
4194
  }
4045
- // After a turbo frame loads, mark already-selected items and update the live region.
4046
- // The empty state element is always present in the frame and CSS shows/hides it
4047
- // based on whether any non-selected options exist.
4048
4195
  syncSelectedState() {
4049
4196
  const options = this.turboListTarget.querySelectorAll(".midwest-autocomplete-option");
4050
4197
  options.forEach((option) => {
@@ -4080,6 +4227,7 @@ class Autocomplete extends Controller {
4080
4227
  select(option) {
4081
4228
  this.appendToken(option);
4082
4229
  this.hideDropdown();
4230
+ this.hideFreeFormHint();
4083
4231
  this.inputTarget.value = "";
4084
4232
  this.inputTarget.focus();
4085
4233
  this.announce(`${option.label} selected`);
@@ -4151,11 +4299,19 @@ class Autocomplete extends Controller {
4151
4299
  this.renderEmpty("No more options");
4152
4300
  return;
4153
4301
  }
4302
+ this.hideFreeFormHint();
4154
4303
  this.loading = false;
4155
4304
  this.announce(`${availableCount} option${availableCount === 1 ? "" : "s"} available`);
4156
4305
  this.showDropdown();
4157
4306
  }
4158
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
+ }
4159
4315
  const li = document.createElement("li");
4160
4316
  li.className = "midwest-autocomplete-empty";
4161
4317
  li.setAttribute("aria-hidden", "true");
@@ -4165,15 +4321,39 @@ class Autocomplete extends Controller {
4165
4321
  this.announce(message);
4166
4322
  this.showDropdown();
4167
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
+ }
4168
4344
  set loading(on) {
4169
4345
  if (this.hasLoaderTarget)
4170
4346
  this.loaderTarget.hidden = !on;
4171
4347
  }
4172
4348
  showDropdown() {
4349
+ if (!this.hasDropdownTarget)
4350
+ return;
4173
4351
  if (!this.dropdownTarget.matches(":popover-open"))
4174
4352
  this.dropdownTarget.showPopover();
4175
4353
  }
4176
4354
  hideDropdown() {
4355
+ if (!this.hasDropdownTarget)
4356
+ return;
4177
4357
  if (this.dropdownTarget.matches(":popover-open"))
4178
4358
  this.dropdownTarget.hidePopover();
4179
4359
  }
@@ -4197,6 +4377,17 @@ class Autocomplete extends Controller {
4197
4377
  const support = item.querySelector(".midwest-autocomplete-option-support")?.textContent?.trim();
4198
4378
  this.select({ value, label, support });
4199
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
+ }
4200
4391
  announce(message) {
4201
4392
  if (this.hasStatusTarget)
4202
4393
  this.statusTarget.textContent = message;
@@ -4733,6 +4924,187 @@ class Image extends Controller {
4733
4924
  }
4734
4925
  }
4735
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
+
4736
5108
  function registerMidwestControllers(application) {
4737
5109
  application.register("midwest-card", Card);
4738
5110
  application.register("midwest-banner", Banner);
@@ -4755,6 +5127,8 @@ function registerMidwestControllers(application) {
4755
5127
  application.register("midwest-rating", Rating);
4756
5128
  application.register("midwest-autocomplete", Autocomplete);
4757
5129
  application.register("midwest-image", Image);
5130
+ application.register("midwest-color-picker", ColorPicker);
5131
+ application.register("midwest-notification", Notification);
4758
5132
  }
4759
5133
 
4760
5134
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };