@unabridged/midwest 0.16.0 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3465,10 +3465,12 @@ class FormLiveSummary extends Controller {
3465
3465
  this.render();
3466
3466
  this.form?.addEventListener("input", this.render);
3467
3467
  this.form?.addEventListener("change", this.render);
3468
+ this.form?.addEventListener("midwest-autocomplete:change", this.render);
3468
3469
  }
3469
3470
  disconnect() {
3470
3471
  this.form?.removeEventListener("input", this.render);
3471
3472
  this.form?.removeEventListener("change", this.render);
3473
+ this.form?.removeEventListener("midwest-autocomplete:change", this.render);
3472
3474
  }
3473
3475
  get form() {
3474
3476
  return this.element.closest("form");
@@ -3551,6 +3553,16 @@ class FormLiveSummary extends Controller {
3551
3553
  value: element.value
3552
3554
  });
3553
3555
  });
3556
+ form.querySelectorAll(".midwest-autocomplete").forEach((ac) => {
3557
+ if (this.element.contains(ac))
3558
+ return;
3559
+ if (!ac.querySelector('[role="combobox"]'))
3560
+ return;
3561
+ const labelEl = ac.querySelector("label");
3562
+ const label = labelEl?.textContent?.trim() || this.prettifyName((ac.dataset.name ?? "").replace(/\[.*\]$/, ""));
3563
+ const selected = Array.from(ac.querySelectorAll(".midwest-autocomplete-tokens .value")).map((el) => el.textContent?.trim()).filter(Boolean).join(", ");
3564
+ fields.push({ label, value: selected || "\u2014" });
3565
+ });
3554
3566
  return fields;
3555
3567
  }
3556
3568
  // Finds the label associated with a specific element via `for` attribute,
@@ -3820,22 +3832,75 @@ class Rating extends Controller {
3820
3832
  }
3821
3833
 
3822
3834
  class Autocomplete extends Controller {
3823
- static targets = ["input", "dropdown", "list", "tokens", "token", "multipleTokenTemplate", "singleTokenTemplate", "status"];
3835
+ static targets = ["input", "dropdown", "list", "tokens", "multipleTokenTemplate", "singleTokenTemplate", "status", "turboList", "loader"];
3824
3836
  static values = {
3825
3837
  options: { type: Array, default: [] },
3826
3838
  url: { type: String, default: "" },
3827
- multiple: { type: Boolean, default: true }
3839
+ multiple: { type: Boolean, default: true },
3840
+ turboFrameUrl: { type: String, default: "" }
3828
3841
  };
3829
- selectedValues = /* @__PURE__ */ new Map();
3830
3842
  highlightedIndex = -1;
3831
3843
  fetchAbort = null;
3844
+ searchTimer;
3845
+ // ── Named handlers ─────────────────────────────────────────────────────────
3846
+ // Arrow-function class properties preserve `this` and produce a stable
3847
+ // reference so addEventListener/removeEventListener pairs always match.
3848
+ onFrameLoad = () => {
3849
+ this.syncSelectedState();
3850
+ this.loading = false;
3851
+ this.showDropdown();
3852
+ };
3853
+ onToggle = (e) => {
3854
+ const opened = e.newState === "open";
3855
+ this.inputTarget.setAttribute("aria-expanded", String(opened));
3856
+ if (!opened) {
3857
+ this.inputTarget.removeAttribute("aria-activedescendant");
3858
+ this.highlightedIndex = -1;
3859
+ }
3860
+ };
3861
+ onFocusOut = (e) => {
3862
+ if (!this.element.contains(e.relatedTarget)) {
3863
+ this.hideDropdown();
3864
+ }
3865
+ };
3866
+ onListMouseDown = (e) => {
3867
+ const option = e.target.closest(".midwest-autocomplete-option");
3868
+ if (option) {
3869
+ e.preventDefault();
3870
+ this.pickItem(option);
3871
+ }
3872
+ };
3873
+ // ── Lifecycle ───────────────────────────────────────────────────────────────
3832
3874
  connect() {
3833
- this.loadInitialTokens();
3875
+ this.element.addEventListener("focusout", this.onFocusOut);
3834
3876
  }
3835
3877
  disconnect() {
3836
3878
  this.fetchAbort?.abort();
3879
+ clearTimeout(this.searchTimer);
3880
+ this.element.removeEventListener("focusout", this.onFocusOut);
3881
+ }
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
+ dropdownTargetConnected(el) {
3886
+ el.addEventListener("toggle", this.onToggle);
3887
+ }
3888
+ dropdownTargetDisconnected(el) {
3889
+ el.removeEventListener("toggle", this.onToggle);
3890
+ }
3891
+ listTargetConnected(el) {
3892
+ el.addEventListener("mousedown", this.onListMouseDown);
3893
+ }
3894
+ listTargetDisconnected(el) {
3895
+ el.removeEventListener("mousedown", this.onListMouseDown);
3896
+ }
3897
+ turboListTargetConnected(el) {
3898
+ el.addEventListener("turbo:frame-load", this.onFrameLoad);
3899
+ }
3900
+ turboListTargetDisconnected(el) {
3901
+ el.removeEventListener("turbo:frame-load", this.onFrameLoad);
3837
3902
  }
3838
- // ── Public actions ─────────────────────────────────────────────────────────
3903
+ // ── Public actions ──────────────────────────────────────────────────────────
3839
3904
  focusInput() {
3840
3905
  this.inputTarget.focus();
3841
3906
  this.open();
@@ -3843,21 +3908,20 @@ class Autocomplete extends Controller {
3843
3908
  open() {
3844
3909
  if (this.dropdownTarget.matches(":popover-open"))
3845
3910
  return;
3846
- const query = this.inputTarget.value.trim();
3847
- if (this.urlValue) {
3848
- this.fetch(query);
3849
- } else {
3850
- this.renderOptions(this.filteredOptions(query));
3851
- }
3911
+ clearTimeout(this.searchTimer);
3912
+ this.resolve(this.inputTarget.value.trim());
3852
3913
  }
3853
- async search() {
3914
+ search() {
3854
3915
  const query = this.inputTarget.value.trim();
3855
- if (this.urlValue) {
3856
- await this.fetch(query);
3857
- } else {
3916
+ if (!this.hasTurboListTarget && !this.urlValue) {
3858
3917
  this.renderOptions(this.filteredOptions(query));
3918
+ return;
3859
3919
  }
3860
- this.showDropdown();
3920
+ clearTimeout(this.searchTimer);
3921
+ this.searchTimer = window.setTimeout(() => {
3922
+ this.searchTimer = void 0;
3923
+ this.resolve(query);
3924
+ }, 200);
3861
3925
  }
3862
3926
  navigate(event) {
3863
3927
  switch (event.key) {
@@ -3872,6 +3936,8 @@ class Autocomplete extends Controller {
3872
3936
  }
3873
3937
  case "ArrowUp": {
3874
3938
  event.preventDefault();
3939
+ if (!this.dropdownTarget.matches(":popover-open"))
3940
+ break;
3875
3941
  const items = this.visibleOptions();
3876
3942
  this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
3877
3943
  this.syncHighlight(items);
@@ -3879,6 +3945,8 @@ class Autocomplete extends Controller {
3879
3945
  }
3880
3946
  case "Enter": {
3881
3947
  event.preventDefault();
3948
+ if (!this.dropdownTarget.matches(":popover-open"))
3949
+ break;
3882
3950
  const items = this.visibleOptions();
3883
3951
  if (this.highlightedIndex >= 0 && items[this.highlightedIndex]) {
3884
3952
  this.pickItem(items[this.highlightedIndex]);
@@ -3890,19 +3958,14 @@ class Autocomplete extends Controller {
3890
3958
  break;
3891
3959
  case "Backspace":
3892
3960
  if (this.inputTarget.value === "" && this.multipleValue) {
3893
- const entries = Array.from(this.selectedValues.keys());
3894
- if (entries.length > 0)
3895
- this.deselect(entries[entries.length - 1]);
3961
+ const tokens = this.tokensTarget.querySelectorAll("[data-value]");
3962
+ const last = tokens[tokens.length - 1];
3963
+ if (last?.dataset.value)
3964
+ this.deselect(last.dataset.value);
3896
3965
  }
3897
3966
  break;
3898
3967
  }
3899
3968
  }
3900
- visibleOptions() {
3901
- return this.listTarget.querySelectorAll('.midwest-autocomplete-option:not([aria-selected="true"])');
3902
- }
3903
- handleBlur() {
3904
- setTimeout(() => this.hideDropdown(), 150);
3905
- }
3906
3969
  removeToken(event) {
3907
3970
  event.stopPropagation();
3908
3971
  const btn = event.currentTarget;
@@ -3910,16 +3973,31 @@ class Autocomplete extends Controller {
3910
3973
  if (value)
3911
3974
  this.deselect(value);
3912
3975
  }
3913
- // ── Private ────────────────────────────────────────────────────────────────
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
+ selectOption(event) {
3980
+ event.preventDefault();
3981
+ this.pickItem(event.currentTarget);
3982
+ }
3983
+ // ── Private ─────────────────────────────────────────────────────────────────
3914
3984
  get optionIdPrefix() {
3915
3985
  return `ac-opt-${(this.element.dataset.name ?? "field").replace(/\W/g, "-")}`;
3916
3986
  }
3917
- loadInitialTokens() {
3918
- this.tokenTargets.forEach((token) => {
3919
- const value = token.dataset.value ?? "";
3920
- const label = token.querySelector(".value")?.textContent?.trim() ?? value;
3921
- this.selectedValues.set(value, { value, label });
3922
- });
3987
+ isSelected(value) {
3988
+ return !!this.tokensTarget.querySelector(`[data-value="${CSS.escape(value)}"]`);
3989
+ }
3990
+ // Routes to the correct data source. Shared by open() and the debounced search path.
3991
+ resolve(query) {
3992
+ if (this.hasTurboListTarget) {
3993
+ this.navigateFrame(query);
3994
+ return;
3995
+ }
3996
+ if (this.urlValue) {
3997
+ this.fetch(query);
3998
+ return;
3999
+ }
4000
+ this.renderOptions(this.filteredOptions(query));
3923
4001
  }
3924
4002
  filteredOptions(query) {
3925
4003
  const q = query.toLowerCase();
@@ -3927,10 +4005,36 @@ class Autocomplete extends Controller {
3927
4005
  (o) => q === "" || o.label.toLowerCase().includes(q) || (o.support?.toLowerCase().includes(q) ?? false)
3928
4006
  );
3929
4007
  }
4008
+ navigateFrame(query) {
4009
+ const url = new URL(this.turboFrameUrlValue, window.location.href);
4010
+ if (query)
4011
+ url.searchParams.set("q", query);
4012
+ this.tokensTarget.querySelectorAll("input[type=hidden]").forEach((input) => {
4013
+ url.searchParams.append("selected[]", input.value);
4014
+ });
4015
+ this.loading = true;
4016
+ this.turboListTarget.setAttribute("src", url.toString());
4017
+ this.showDropdown();
4018
+ }
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
+ syncSelectedState() {
4023
+ const options = this.turboListTarget.querySelectorAll(".midwest-autocomplete-option");
4024
+ options.forEach((option) => {
4025
+ const val = option.dataset.value ?? "";
4026
+ option.setAttribute("aria-selected", this.isSelected(val) ? "true" : "false");
4027
+ });
4028
+ const visibleCount = this.visibleOptions().length;
4029
+ this.announce(visibleCount > 0 ? `${visibleCount} option${visibleCount === 1 ? "" : "s"} available` : "No more options");
4030
+ }
3930
4031
  async fetch(query) {
3931
4032
  this.fetchAbort?.abort();
3932
4033
  this.fetchAbort = new AbortController();
3933
- this.showLoading();
4034
+ this.listTarget.replaceChildren();
4035
+ this.loading = true;
4036
+ this.announce("Loading");
4037
+ this.showDropdown();
3934
4038
  try {
3935
4039
  const url = new URL(this.urlValue, window.location.href);
3936
4040
  if (query)
@@ -3948,11 +4052,6 @@ class Autocomplete extends Controller {
3948
4052
  }
3949
4053
  }
3950
4054
  select(option) {
3951
- if (!this.multipleValue) {
3952
- this.selectedValues.forEach((_, val) => this.removeTokenElement(val));
3953
- this.selectedValues.clear();
3954
- }
3955
- this.selectedValues.set(option.value, option);
3956
4055
  this.appendToken(option);
3957
4056
  this.hideDropdown();
3958
4057
  this.inputTarget.value = "";
@@ -3961,102 +4060,106 @@ class Autocomplete extends Controller {
3961
4060
  this.dispatch("change", { detail: { value: option.value, label: option.label } });
3962
4061
  }
3963
4062
  deselect(value) {
3964
- const option = this.selectedValues.get(value);
3965
- this.selectedValues.delete(value);
3966
- this.removeTokenElement(value);
3967
- if (option)
3968
- this.announce(`${option.label} removed`);
4063
+ const token = this.tokensTarget.querySelector(`[data-value="${CSS.escape(value)}"]`);
4064
+ const label = token?.querySelector(".value")?.textContent?.trim();
4065
+ token?.remove();
4066
+ if (label)
4067
+ this.announce(`${label} removed`);
3969
4068
  this.dispatch("change", { detail: { value: null } });
3970
4069
  }
3971
4070
  appendToken(option) {
3972
- const name = this.element.dataset.name ?? "";
4071
+ if (!this.multipleValue) {
4072
+ this.tokensTarget.querySelectorAll("[data-value]").forEach((el) => el.remove());
4073
+ }
4074
+ const template = this.multipleValue ? this.multipleTokenTemplateTarget : this.singleTokenTemplateTarget;
4075
+ const fragment = template.content.cloneNode(true);
4076
+ const badge = fragment.querySelector(".midwest-badge");
4077
+ if (!badge)
4078
+ return;
4079
+ badge.dataset.value = option.value;
4080
+ const valueEl = badge.querySelector(".value");
4081
+ if (valueEl)
4082
+ valueEl.textContent = option.label;
3973
4083
  if (this.multipleValue) {
3974
- const fragment = this.multipleTokenTemplateTarget.content.cloneNode(true);
3975
- const badge = fragment.querySelector(".midwest-badge");
3976
- badge.dataset.value = option.value;
3977
- badge.querySelector(".value").textContent = option.label;
3978
4084
  const removeBtn = badge.querySelector(".remove");
3979
- removeBtn.dataset.value = option.value;
3980
- removeBtn.setAttribute("aria-label", `Remove ${option.label}`);
3981
- const input = badge.querySelector("input[type=hidden]");
3982
- input.name = name;
3983
- input.value = option.value;
3984
- this.tokensTarget.appendChild(fragment);
3985
- } else {
3986
- this.tokensTarget.querySelectorAll("[data-value]").forEach((el) => el.remove());
3987
- const fragment = this.singleTokenTemplateTarget.content.cloneNode(true);
3988
- const badge = fragment.querySelector(".midwest-badge");
3989
- badge.dataset.value = option.value;
3990
- badge.querySelector(".value").textContent = option.label;
3991
- const input = badge.querySelector("input[type=hidden]");
3992
- input.name = name;
4085
+ if (removeBtn) {
4086
+ removeBtn.dataset.value = option.value;
4087
+ removeBtn.setAttribute("aria-label", `Remove ${option.label}`);
4088
+ }
4089
+ }
4090
+ const input = badge.querySelector("input[type=hidden]");
4091
+ if (input) {
4092
+ input.name = this.element.dataset.name ?? "";
3993
4093
  input.value = option.value;
3994
- this.tokensTarget.appendChild(fragment);
3995
4094
  }
3996
- }
3997
- removeTokenElement(value) {
3998
- const token = this.tokensTarget.querySelector(`[data-value="${CSS.escape(value)}"]`);
3999
- token?.remove();
4095
+ this.tokensTarget.appendChild(fragment);
4000
4096
  }
4001
4097
  renderOptions(options) {
4002
- this.listTarget.innerHTML = "";
4003
4098
  this.highlightedIndex = -1;
4004
4099
  if (options.length === 0) {
4005
4100
  this.renderEmpty();
4006
4101
  return;
4007
4102
  }
4103
+ this.listTarget.replaceChildren();
4008
4104
  options.forEach((option, index) => {
4009
4105
  const li = document.createElement("li");
4010
4106
  li.id = `${this.optionIdPrefix}-${index}`;
4011
4107
  li.className = "midwest-autocomplete-option";
4012
4108
  li.setAttribute("role", "option");
4013
- li.setAttribute("aria-selected", this.selectedValues.has(option.value) ? "true" : "false");
4109
+ li.setAttribute("aria-selected", this.isSelected(option.value) ? "true" : "false");
4014
4110
  li.dataset.value = option.value;
4015
- li.innerHTML = `
4016
- <span class="midwest-autocomplete-option-label">${this.escapeHtml(option.label)}</span>
4017
- ${option.support ? `<span class="midwest-autocomplete-option-support">${this.escapeHtml(option.support)}</span>` : ""}
4018
- `;
4019
- li.addEventListener("mousedown", (e) => {
4020
- e.preventDefault();
4021
- this.select(option);
4022
- });
4111
+ const label = document.createElement("span");
4112
+ label.className = "midwest-autocomplete-option-label";
4113
+ label.textContent = option.label;
4114
+ li.appendChild(label);
4115
+ if (option.support) {
4116
+ const support = document.createElement("span");
4117
+ support.className = "midwest-autocomplete-option-support";
4118
+ support.textContent = option.support;
4119
+ li.appendChild(support);
4120
+ }
4023
4121
  this.listTarget.appendChild(li);
4024
4122
  });
4025
- const availableCount = options.filter((o) => !this.selectedValues.has(o.value)).length;
4123
+ const availableCount = options.filter((o) => !this.isSelected(o.value)).length;
4026
4124
  if (availableCount === 0) {
4027
4125
  this.renderEmpty("No more options");
4028
4126
  return;
4029
4127
  }
4128
+ this.loading = false;
4030
4129
  this.announce(`${availableCount} option${availableCount === 1 ? "" : "s"} available`);
4031
4130
  this.showDropdown();
4032
4131
  }
4033
4132
  renderEmpty(message = "No results") {
4034
- this.listTarget.innerHTML = `<li class="midwest-autocomplete-empty" aria-hidden="true">${this.escapeHtml(message)}</li>`;
4133
+ const li = document.createElement("li");
4134
+ li.className = "midwest-autocomplete-empty";
4135
+ li.setAttribute("aria-hidden", "true");
4136
+ li.textContent = message;
4137
+ this.listTarget.replaceChildren(li);
4138
+ this.loading = false;
4035
4139
  this.announce(message);
4036
4140
  this.showDropdown();
4037
4141
  }
4038
- showLoading() {
4039
- this.listTarget.innerHTML = '<li class="midwest-autocomplete-loading" aria-hidden="true">Loading\u2026</li>';
4040
- this.announce("Loading");
4041
- this.showDropdown();
4142
+ set loading(on) {
4143
+ if (this.hasLoaderTarget)
4144
+ this.loaderTarget.hidden = !on;
4042
4145
  }
4043
4146
  showDropdown() {
4044
4147
  if (!this.dropdownTarget.matches(":popover-open"))
4045
4148
  this.dropdownTarget.showPopover();
4046
- this.inputTarget.setAttribute("aria-expanded", "true");
4047
4149
  }
4048
4150
  hideDropdown() {
4049
4151
  if (this.dropdownTarget.matches(":popover-open"))
4050
4152
  this.dropdownTarget.hidePopover();
4051
- this.inputTarget.setAttribute("aria-expanded", "false");
4052
- this.inputTarget.removeAttribute("aria-activedescendant");
4053
- this.highlightedIndex = -1;
4153
+ }
4154
+ visibleOptions() {
4155
+ const container = this.hasTurboListTarget ? this.turboListTarget : this.listTarget;
4156
+ return container.querySelectorAll('.midwest-autocomplete-option:not([aria-selected="true"])');
4054
4157
  }
4055
4158
  syncHighlight(items) {
4056
4159
  items.forEach((item, i) => item.classList.toggle("is-highlighted", i === this.highlightedIndex));
4057
4160
  const active = items[this.highlightedIndex];
4058
4161
  active?.scrollIntoView({ block: "nearest" });
4059
- if (active) {
4162
+ if (active?.id) {
4060
4163
  this.inputTarget.setAttribute("aria-activedescendant", active.id);
4061
4164
  } else {
4062
4165
  this.inputTarget.removeAttribute("aria-activedescendant");
@@ -4064,7 +4167,7 @@ class Autocomplete extends Controller {
4064
4167
  }
4065
4168
  pickItem(item) {
4066
4169
  const value = item.dataset.value ?? "";
4067
- const label = item.querySelector(".midwest-autocomplete-option-label")?.textContent?.trim() ?? value;
4170
+ const label = item.dataset.label?.trim() || item.querySelector(".midwest-autocomplete-option-label")?.textContent?.trim() || value;
4068
4171
  const support = item.querySelector(".midwest-autocomplete-option-support")?.textContent?.trim();
4069
4172
  this.select({ value, label, support });
4070
4173
  }
@@ -4072,9 +4175,6 @@ class Autocomplete extends Controller {
4072
4175
  if (this.hasStatusTarget)
4073
4176
  this.statusTarget.textContent = message;
4074
4177
  }
4075
- escapeHtml(str) {
4076
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4077
- }
4078
4178
  }
4079
4179
 
4080
4180
  function registerMidwestControllers(application) {