@unabridged/midwest 0.15.1 → 0.16.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.
@@ -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;
@@ -3804,6 +3819,264 @@ class Rating extends Controller {
3804
3819
  }
3805
3820
  }
3806
3821
 
3822
+ class Autocomplete extends Controller {
3823
+ static targets = ["input", "dropdown", "list", "tokens", "token", "multipleTokenTemplate", "singleTokenTemplate", "status"];
3824
+ static values = {
3825
+ options: { type: Array, default: [] },
3826
+ url: { type: String, default: "" },
3827
+ multiple: { type: Boolean, default: true }
3828
+ };
3829
+ selectedValues = /* @__PURE__ */ new Map();
3830
+ highlightedIndex = -1;
3831
+ fetchAbort = null;
3832
+ connect() {
3833
+ this.loadInitialTokens();
3834
+ }
3835
+ disconnect() {
3836
+ this.fetchAbort?.abort();
3837
+ }
3838
+ // ── Public actions ─────────────────────────────────────────────────────────
3839
+ focusInput() {
3840
+ this.inputTarget.focus();
3841
+ this.open();
3842
+ }
3843
+ open() {
3844
+ if (this.dropdownTarget.matches(":popover-open"))
3845
+ 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
+ }
3852
+ }
3853
+ async search() {
3854
+ const query = this.inputTarget.value.trim();
3855
+ if (this.urlValue) {
3856
+ await this.fetch(query);
3857
+ } else {
3858
+ this.renderOptions(this.filteredOptions(query));
3859
+ }
3860
+ this.showDropdown();
3861
+ }
3862
+ navigate(event) {
3863
+ switch (event.key) {
3864
+ case "ArrowDown": {
3865
+ event.preventDefault();
3866
+ if (!this.dropdownTarget.matches(":popover-open"))
3867
+ this.open();
3868
+ const items = this.visibleOptions();
3869
+ this.highlightedIndex = Math.min(this.highlightedIndex + 1, items.length - 1);
3870
+ this.syncHighlight(items);
3871
+ break;
3872
+ }
3873
+ case "ArrowUp": {
3874
+ event.preventDefault();
3875
+ const items = this.visibleOptions();
3876
+ this.highlightedIndex = Math.max(this.highlightedIndex - 1, 0);
3877
+ this.syncHighlight(items);
3878
+ break;
3879
+ }
3880
+ case "Enter": {
3881
+ event.preventDefault();
3882
+ const items = this.visibleOptions();
3883
+ if (this.highlightedIndex >= 0 && items[this.highlightedIndex]) {
3884
+ this.pickItem(items[this.highlightedIndex]);
3885
+ }
3886
+ break;
3887
+ }
3888
+ case "Escape":
3889
+ this.hideDropdown();
3890
+ break;
3891
+ case "Backspace":
3892
+ 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]);
3896
+ }
3897
+ break;
3898
+ }
3899
+ }
3900
+ visibleOptions() {
3901
+ return this.listTarget.querySelectorAll('.midwest-autocomplete-option:not([aria-selected="true"])');
3902
+ }
3903
+ handleBlur() {
3904
+ setTimeout(() => this.hideDropdown(), 150);
3905
+ }
3906
+ removeToken(event) {
3907
+ event.stopPropagation();
3908
+ const btn = event.currentTarget;
3909
+ const value = btn.dataset.value;
3910
+ if (value)
3911
+ this.deselect(value);
3912
+ }
3913
+ // ── Private ────────────────────────────────────────────────────────────────
3914
+ get optionIdPrefix() {
3915
+ return `ac-opt-${(this.element.dataset.name ?? "field").replace(/\W/g, "-")}`;
3916
+ }
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
+ });
3923
+ }
3924
+ filteredOptions(query) {
3925
+ const q = query.toLowerCase();
3926
+ return this.optionsValue.filter(
3927
+ (o) => q === "" || o.label.toLowerCase().includes(q) || (o.support?.toLowerCase().includes(q) ?? false)
3928
+ );
3929
+ }
3930
+ async fetch(query) {
3931
+ this.fetchAbort?.abort();
3932
+ this.fetchAbort = new AbortController();
3933
+ this.showLoading();
3934
+ try {
3935
+ const url = new URL(this.urlValue, window.location.href);
3936
+ if (query)
3937
+ url.searchParams.set("q", query);
3938
+ const res = await globalThis.fetch(url.toString(), {
3939
+ signal: this.fetchAbort.signal,
3940
+ headers: { Accept: "application/json" }
3941
+ });
3942
+ const data = await res.json();
3943
+ this.renderOptions(data);
3944
+ } catch (err) {
3945
+ if (err.name !== "AbortError") {
3946
+ this.renderEmpty();
3947
+ }
3948
+ }
3949
+ }
3950
+ 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
+ this.appendToken(option);
3957
+ this.hideDropdown();
3958
+ this.inputTarget.value = "";
3959
+ this.inputTarget.focus();
3960
+ this.announce(`${option.label} selected`);
3961
+ this.dispatch("change", { detail: { value: option.value, label: option.label } });
3962
+ }
3963
+ 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`);
3969
+ this.dispatch("change", { detail: { value: null } });
3970
+ }
3971
+ appendToken(option) {
3972
+ const name = this.element.dataset.name ?? "";
3973
+ 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
+ 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;
3993
+ input.value = option.value;
3994
+ this.tokensTarget.appendChild(fragment);
3995
+ }
3996
+ }
3997
+ removeTokenElement(value) {
3998
+ const token = this.tokensTarget.querySelector(`[data-value="${CSS.escape(value)}"]`);
3999
+ token?.remove();
4000
+ }
4001
+ renderOptions(options) {
4002
+ this.listTarget.innerHTML = "";
4003
+ this.highlightedIndex = -1;
4004
+ if (options.length === 0) {
4005
+ this.renderEmpty();
4006
+ return;
4007
+ }
4008
+ options.forEach((option, index) => {
4009
+ const li = document.createElement("li");
4010
+ li.id = `${this.optionIdPrefix}-${index}`;
4011
+ li.className = "midwest-autocomplete-option";
4012
+ li.setAttribute("role", "option");
4013
+ li.setAttribute("aria-selected", this.selectedValues.has(option.value) ? "true" : "false");
4014
+ 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
+ });
4023
+ this.listTarget.appendChild(li);
4024
+ });
4025
+ const availableCount = options.filter((o) => !this.selectedValues.has(o.value)).length;
4026
+ if (availableCount === 0) {
4027
+ this.renderEmpty("No more options");
4028
+ return;
4029
+ }
4030
+ this.announce(`${availableCount} option${availableCount === 1 ? "" : "s"} available`);
4031
+ this.showDropdown();
4032
+ }
4033
+ renderEmpty(message = "No results") {
4034
+ this.listTarget.innerHTML = `<li class="midwest-autocomplete-empty" aria-hidden="true">${this.escapeHtml(message)}</li>`;
4035
+ this.announce(message);
4036
+ this.showDropdown();
4037
+ }
4038
+ showLoading() {
4039
+ this.listTarget.innerHTML = '<li class="midwest-autocomplete-loading" aria-hidden="true">Loading\u2026</li>';
4040
+ this.announce("Loading");
4041
+ this.showDropdown();
4042
+ }
4043
+ showDropdown() {
4044
+ if (!this.dropdownTarget.matches(":popover-open"))
4045
+ this.dropdownTarget.showPopover();
4046
+ this.inputTarget.setAttribute("aria-expanded", "true");
4047
+ }
4048
+ hideDropdown() {
4049
+ if (this.dropdownTarget.matches(":popover-open"))
4050
+ this.dropdownTarget.hidePopover();
4051
+ this.inputTarget.setAttribute("aria-expanded", "false");
4052
+ this.inputTarget.removeAttribute("aria-activedescendant");
4053
+ this.highlightedIndex = -1;
4054
+ }
4055
+ syncHighlight(items) {
4056
+ items.forEach((item, i) => item.classList.toggle("is-highlighted", i === this.highlightedIndex));
4057
+ const active = items[this.highlightedIndex];
4058
+ active?.scrollIntoView({ block: "nearest" });
4059
+ if (active) {
4060
+ this.inputTarget.setAttribute("aria-activedescendant", active.id);
4061
+ } else {
4062
+ this.inputTarget.removeAttribute("aria-activedescendant");
4063
+ }
4064
+ }
4065
+ pickItem(item) {
4066
+ const value = item.dataset.value ?? "";
4067
+ const label = item.querySelector(".midwest-autocomplete-option-label")?.textContent?.trim() ?? value;
4068
+ const support = item.querySelector(".midwest-autocomplete-option-support")?.textContent?.trim();
4069
+ this.select({ value, label, support });
4070
+ }
4071
+ announce(message) {
4072
+ if (this.hasStatusTarget)
4073
+ this.statusTarget.textContent = message;
4074
+ }
4075
+ escapeHtml(str) {
4076
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
4077
+ }
4078
+ }
4079
+
3807
4080
  function registerMidwestControllers(application) {
3808
4081
  application.register("midwest-card", Card);
3809
4082
  application.register("midwest-banner", Banner);
@@ -3824,6 +4097,7 @@ function registerMidwestControllers(application) {
3824
4097
  application.register("midwest-chart", Chart);
3825
4098
  application.register("midwest-range", Range);
3826
4099
  application.register("midwest-rating", Rating);
4100
+ application.register("midwest-autocomplete", Autocomplete);
3827
4101
  }
3828
4102
 
3829
4103
  export { Banner, Card, Chart, CountdownTimer, registerMidwestControllers };