@unabridged/midwest 0.15.1 → 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.
@@ -549,17 +549,23 @@ class Banner extends Controller {
549
549
  }
550
550
 
551
551
  class Input extends Controller {
552
- static targets = ["input"];
552
+ static targets = ["input", "counter"];
553
553
  updateClearButton = null;
554
+ updateCounter = null;
554
555
  connect() {
555
556
  this.applyColor();
556
557
  this.applySearchClear();
558
+ this.applyCounter();
557
559
  }
558
560
  disconnect() {
559
561
  if (this.updateClearButton) {
560
562
  this.inputTarget.removeEventListener("input", this.updateClearButton);
561
563
  this.updateClearButton = null;
562
564
  }
565
+ if (this.updateCounter) {
566
+ this.inputTarget.removeEventListener("input", this.updateCounter);
567
+ this.updateCounter = null;
568
+ }
563
569
  }
564
570
  applyColor() {
565
571
  if (this.inputTarget.type !== "color") {
@@ -569,6 +575,15 @@ class Input extends Controller {
569
575
  this.inputTarget.setAttribute("value", this.inputTarget.value);
570
576
  });
571
577
  }
578
+ applyCounter() {
579
+ if (!this.hasCounterTarget)
580
+ return;
581
+ this.counterTarget.textContent = String(this.inputTarget.value.length);
582
+ this.updateCounter = () => {
583
+ this.counterTarget.textContent = String(this.inputTarget.value.length);
584
+ };
585
+ this.inputTarget.addEventListener("input", this.updateCounter);
586
+ }
572
587
  applySearchClear() {
573
588
  if (this.inputTarget.type !== "search")
574
589
  return;
@@ -3450,10 +3465,12 @@ class FormLiveSummary extends Controller {
3450
3465
  this.render();
3451
3466
  this.form?.addEventListener("input", this.render);
3452
3467
  this.form?.addEventListener("change", this.render);
3468
+ this.form?.addEventListener("midwest-autocomplete:change", this.render);
3453
3469
  }
3454
3470
  disconnect() {
3455
3471
  this.form?.removeEventListener("input", this.render);
3456
3472
  this.form?.removeEventListener("change", this.render);
3473
+ this.form?.removeEventListener("midwest-autocomplete:change", this.render);
3457
3474
  }
3458
3475
  get form() {
3459
3476
  return this.element.closest("form");
@@ -3536,6 +3553,16 @@ class FormLiveSummary extends Controller {
3536
3553
  value: element.value
3537
3554
  });
3538
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
+ });
3539
3566
  return fields;
3540
3567
  }
3541
3568
  // Finds the label associated with a specific element via `for` attribute,
@@ -3804,6 +3831,352 @@ class Rating extends Controller {
3804
3831
  }
3805
3832
  }
3806
3833
 
3834
+ class Autocomplete extends Controller {
3835
+ static targets = ["input", "dropdown", "list", "tokens", "multipleTokenTemplate", "singleTokenTemplate", "status", "turboList", "loader"];
3836
+ static values = {
3837
+ options: { type: Array, default: [] },
3838
+ url: { type: String, default: "" },
3839
+ multiple: { type: Boolean, default: true },
3840
+ turboFrameUrl: { type: String, default: "" }
3841
+ };
3842
+ highlightedIndex = -1;
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 ───────────────────────────────────────────────────────────────
3874
+ connect() {
3875
+ this.element.addEventListener("focusout", this.onFocusOut);
3876
+ }
3877
+ disconnect() {
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);
3902
+ }
3903
+ // ── Public actions ──────────────────────────────────────────────────────────
3904
+ focusInput() {
3905
+ this.inputTarget.focus();
3906
+ this.open();
3907
+ }
3908
+ open() {
3909
+ if (this.dropdownTarget.matches(":popover-open"))
3910
+ return;
3911
+ clearTimeout(this.searchTimer);
3912
+ this.resolve(this.inputTarget.value.trim());
3913
+ }
3914
+ search() {
3915
+ const query = this.inputTarget.value.trim();
3916
+ if (!this.hasTurboListTarget && !this.urlValue) {
3917
+ this.renderOptions(this.filteredOptions(query));
3918
+ return;
3919
+ }
3920
+ clearTimeout(this.searchTimer);
3921
+ this.searchTimer = window.setTimeout(() => {
3922
+ this.searchTimer = void 0;
3923
+ this.resolve(query);
3924
+ }, 200);
3925
+ }
3926
+ navigate(event) {
3927
+ switch (event.key) {
3928
+ case "ArrowDown": {
3929
+ event.preventDefault();
3930
+ if (!this.dropdownTarget.matches(":popover-open"))
3931
+ this.open();
3932
+ const items = this.visibleOptions();
3933
+ this.highlightedIndex = Math.min(this.highlightedIndex + 1, items.length - 1);
3934
+ this.syncHighlight(items);
3935
+ break;
3936
+ }
3937
+ case "ArrowUp": {
3938
+ event.preventDefault();
3939
+ if (!this.dropdownTarget.matches(":popover-open"))
3940
+ break;
3941
+ const items = this.visibleOptions();
3942
+ this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
3943
+ this.syncHighlight(items);
3944
+ break;
3945
+ }
3946
+ case "Enter": {
3947
+ 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]);
3953
+ }
3954
+ break;
3955
+ }
3956
+ case "Escape":
3957
+ this.hideDropdown();
3958
+ break;
3959
+ case "Backspace":
3960
+ if (this.inputTarget.value === "" && this.multipleValue) {
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);
3965
+ }
3966
+ break;
3967
+ }
3968
+ }
3969
+ removeToken(event) {
3970
+ event.stopPropagation();
3971
+ const btn = event.currentTarget;
3972
+ const value = btn.dataset.value;
3973
+ if (value)
3974
+ this.deselect(value);
3975
+ }
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 ─────────────────────────────────────────────────────────────────
3984
+ get optionIdPrefix() {
3985
+ return `ac-opt-${(this.element.dataset.name ?? "field").replace(/\W/g, "-")}`;
3986
+ }
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));
4001
+ }
4002
+ filteredOptions(query) {
4003
+ const q = query.toLowerCase();
4004
+ return this.optionsValue.filter(
4005
+ (o) => q === "" || o.label.toLowerCase().includes(q) || (o.support?.toLowerCase().includes(q) ?? false)
4006
+ );
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
+ }
4031
+ async fetch(query) {
4032
+ this.fetchAbort?.abort();
4033
+ this.fetchAbort = new AbortController();
4034
+ this.listTarget.replaceChildren();
4035
+ this.loading = true;
4036
+ this.announce("Loading");
4037
+ this.showDropdown();
4038
+ try {
4039
+ const url = new URL(this.urlValue, window.location.href);
4040
+ if (query)
4041
+ url.searchParams.set("q", query);
4042
+ const res = await globalThis.fetch(url.toString(), {
4043
+ signal: this.fetchAbort.signal,
4044
+ headers: { Accept: "application/json" }
4045
+ });
4046
+ const data = await res.json();
4047
+ this.renderOptions(data);
4048
+ } catch (err) {
4049
+ if (err.name !== "AbortError") {
4050
+ this.renderEmpty();
4051
+ }
4052
+ }
4053
+ }
4054
+ select(option) {
4055
+ this.appendToken(option);
4056
+ this.hideDropdown();
4057
+ this.inputTarget.value = "";
4058
+ this.inputTarget.focus();
4059
+ this.announce(`${option.label} selected`);
4060
+ this.dispatch("change", { detail: { value: option.value, label: option.label } });
4061
+ }
4062
+ deselect(value) {
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`);
4068
+ this.dispatch("change", { detail: { value: null } });
4069
+ }
4070
+ appendToken(option) {
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;
4083
+ if (this.multipleValue) {
4084
+ const removeBtn = badge.querySelector(".remove");
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 ?? "";
4093
+ input.value = option.value;
4094
+ }
4095
+ this.tokensTarget.appendChild(fragment);
4096
+ }
4097
+ renderOptions(options) {
4098
+ this.highlightedIndex = -1;
4099
+ if (options.length === 0) {
4100
+ this.renderEmpty();
4101
+ return;
4102
+ }
4103
+ this.listTarget.replaceChildren();
4104
+ options.forEach((option, index) => {
4105
+ const li = document.createElement("li");
4106
+ li.id = `${this.optionIdPrefix}-${index}`;
4107
+ li.className = "midwest-autocomplete-option";
4108
+ li.setAttribute("role", "option");
4109
+ li.setAttribute("aria-selected", this.isSelected(option.value) ? "true" : "false");
4110
+ li.dataset.value = option.value;
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
+ }
4121
+ this.listTarget.appendChild(li);
4122
+ });
4123
+ const availableCount = options.filter((o) => !this.isSelected(o.value)).length;
4124
+ if (availableCount === 0) {
4125
+ this.renderEmpty("No more options");
4126
+ return;
4127
+ }
4128
+ this.loading = false;
4129
+ this.announce(`${availableCount} option${availableCount === 1 ? "" : "s"} available`);
4130
+ this.showDropdown();
4131
+ }
4132
+ renderEmpty(message = "No results") {
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;
4139
+ this.announce(message);
4140
+ this.showDropdown();
4141
+ }
4142
+ set loading(on) {
4143
+ if (this.hasLoaderTarget)
4144
+ this.loaderTarget.hidden = !on;
4145
+ }
4146
+ showDropdown() {
4147
+ if (!this.dropdownTarget.matches(":popover-open"))
4148
+ this.dropdownTarget.showPopover();
4149
+ }
4150
+ hideDropdown() {
4151
+ if (this.dropdownTarget.matches(":popover-open"))
4152
+ this.dropdownTarget.hidePopover();
4153
+ }
4154
+ visibleOptions() {
4155
+ const container = this.hasTurboListTarget ? this.turboListTarget : this.listTarget;
4156
+ return container.querySelectorAll('.midwest-autocomplete-option:not([aria-selected="true"])');
4157
+ }
4158
+ syncHighlight(items) {
4159
+ items.forEach((item, i) => item.classList.toggle("is-highlighted", i === this.highlightedIndex));
4160
+ const active = items[this.highlightedIndex];
4161
+ active?.scrollIntoView({ block: "nearest" });
4162
+ if (active?.id) {
4163
+ this.inputTarget.setAttribute("aria-activedescendant", active.id);
4164
+ } else {
4165
+ this.inputTarget.removeAttribute("aria-activedescendant");
4166
+ }
4167
+ }
4168
+ pickItem(item) {
4169
+ const value = item.dataset.value ?? "";
4170
+ const label = item.dataset.label?.trim() || item.querySelector(".midwest-autocomplete-option-label")?.textContent?.trim() || value;
4171
+ const support = item.querySelector(".midwest-autocomplete-option-support")?.textContent?.trim();
4172
+ this.select({ value, label, support });
4173
+ }
4174
+ announce(message) {
4175
+ if (this.hasStatusTarget)
4176
+ this.statusTarget.textContent = message;
4177
+ }
4178
+ }
4179
+
3807
4180
  function registerMidwestControllers(application) {
3808
4181
  application.register("midwest-card", Card);
3809
4182
  application.register("midwest-banner", Banner);
@@ -3824,6 +4197,7 @@ function registerMidwestControllers(application) {
3824
4197
  application.register("midwest-chart", Chart);
3825
4198
  application.register("midwest-range", Range);
3826
4199
  application.register("midwest-rating", Rating);
4200
+ application.register("midwest-autocomplete", Autocomplete);
3827
4201
  }
3828
4202
 
3829
4203
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };