PageSourceSearch

https://dash.evaboot.com/static/js/components/x-select.c5563f43fc13.js

js evaboot.com collected 2026-10-01 13:13:50 UTC 14,280 bytes, 372 lines download raw bytes

1/**
2 * <x-select> — custom dropdown over a native <select> (light DOM).
3 *
4 * Every dropdown in the app goes through this component: the browser's own
5 * select popup is never shown. Wrap an ordinary <select>; the component hides
6 * it and renders a styled trigger button plus a popover listbox.
7 *
8 * The <select> stays the source of truth, so everything bound to it keeps
9 * working: form submission, `name`/`required`, Alpine `x-model` and
10 * `:hidden`/`:disabled` on options, htmx `hx-trigger="change"`, and inline
11 * `onchange`. Picking an option sets `select.value` and dispatches `input` and
12 * `change` on the select, like a native pick.
13 *
14 * The trigger takes the select's `class` (so each call site keeps its size and
15 * ring) and the select gets `sr-only`. Disabled options render greyed out and
16 * `hidden` options are skipped.
17 *
18 * Footer: any child marked `data-select-footer` moves to the bottom of the
19 * listbox, below a divider. Use it for actions such as "Connect an
20 * integration" links.
21 *
22 * Usage:
23 *   <x-select class="block">
24 *     <select name="frequency" class="block w-full rounded-lg ...">...</select>
25 *     <a data-select-footer href="/dash/integrations/new/">Connect</a>
26 *   </x-select>
27 *
28 * Host display: inline-block by default; add `class="block"` for full width.
29 */
30class XSelect extends HTMLElement {
31  static TRIGGER =
32    "inline-flex w-full items-center justify-between gap-2 text-left cursor-pointer " +
33    "disabled:cursor-not-allowed disabled:opacity-50 focus:outline-none " +
34    "focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary";
35
36  static LIST =
37    "m-0 border-0 p-1 overflow-y-auto rounded-lg bg-white dark:bg-gray-800 " +
38    "text-sm text-gray-900 dark:text-gray-100 shadow-lg ring-1 ring-black/5 dark:ring-white/10";
39
40  static OPTION =
41    "flex items-center justify-between gap-3 rounded-md px-3 py-2 cursor-pointer select-none " +
42    "data-[active]:bg-gray-100 dark:data-[active]:bg-gray-700 " +
43    "aria-disabled:cursor-not-allowed aria-disabled:opacity-50";
44
45  static CHEVRON =
46    '<svg class="size-4 shrink-0 text-gray-400 dark:text-gray-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">' +
47    '<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/></svg>';
48
49  static CHECK =
50    '<svg class="size-4 shrink-0 text-primary" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">' +
51    '<path fill-rule="evenodd" d="M16.7 5.3a1 1 0 0 1 0 1.4l-8 8a1 1 0 0 1-1.4 0l-4-4a1 1 0 1 1 1.4-1.4L8 12.58l7.3-7.3a1 1 0 0 1 1.4 0Z" clip-rule="evenodd"/></svg>';
52
53  static uid = 0;
54
55  connectedCallback() {
56    if (this.hasAttribute("data-rendered")) return;
57    this.setAttribute("data-rendered", "");
58    // Children are not parsed yet when connectedCallback fires.
59    requestAnimationFrame(() => this._render());
60  }
61
62  disconnectedCallback() {
63    this._observer?.disconnect();
64    this._unwatchPosition();
65  }
66
67  _render() {
68    const select = this.querySelector(":scope > select");
69    if (!select) return;
70    this.select = select;
71    const id = `x-select-${++XSelect.uid}`;
72    if (!this.style.display && !/\bblock\b/.test(this.className)) {
73      this.style.display = "inline-block";
74    }
75
76    this.button = document.createElement("button");
77    this.button.type = "button";
78    this.button.className = `${select.getAttribute("class") || ""} ${XSelect.TRIGGER}`;
79    this.button.setAttribute("role", "combobox");
80    this.button.setAttribute("aria-haspopup", "listbox");
81    this.button.setAttribute("aria-expanded", "false");
82    this.button.setAttribute("aria-controls", `${id}-listbox`);
83    const label = select.id && document.querySelector(`label[for="${CSS.escape(select.id)}"]`);
84    if (label) {
85      label.id ||= `${id}-label`;
86      this.button.setAttribute("aria-labelledby", `${label.id} ${id}-value`);
87      // A label click would focus the hidden select; send it to the trigger.
88      label.addEventListener("click", (e) => {
89        e.preventDefault();
90        this.button.focus();
91      });
92    } else if (select.getAttribute("aria-label")) {
93      this.button.setAttribute("aria-label", select.getAttribute("aria-label"));
94    }
95    this.button.innerHTML = `<span id="${id}-value" class="truncate"></span>${XSelect.CHEVRON}`;
96    this.valueEl = this.button.firstElementChild;
97
98    this.list = document.createElement("div");
99    this.list.id = `${id}-list`;
100    this.list.setAttribute("popover", "auto");
101    this.list.className = XSelect.LIST;
102    this.options = document.createElement("div");
103    this.options.setAttribute("role", "listbox");
104    this.options.id = `${id}-listbox`;
105    this.list.append(this.options);
106    this._footers = Array.from(this.querySelectorAll(":scope > [data-select-footer]"));
107    this._footers.forEach((el, n) => {
108      el.setAttribute("role", "option");
109      el.id = `${id}-footer-${n}`;
110      el.tabIndex = -1;
111      el.addEventListener("click", () => this.list.hidePopover());
112    });
113    if (this._footers.length) {
114      const footers = this._footers;
115      const foot = document.createElement("div");
116      foot.className = "mt-1 border-t border-gray-100 dark:border-gray-700 pt-1";
117      foot.append(...footers);
118      this.list.append(foot);
119    }
120
121    // Replace, not add: the copied sizing classes (w-full, block) would beat sr-only.
122    select.className = "sr-only";
123    select.tabIndex = -1;
124    select.setAttribute("aria-hidden", "true");
125    this.append(this.button, this.list);
126
127    // popovertarget (not a click handler) so a click on the open trigger
128    // closes the list instead of light-dismissing and reopening it.
129    this.button.popoverTargetElement = this.list;
130    this.button.addEventListener("keydown", (e) => this._onKey(e));
131    // beforetoggle fires synchronously inside showPopover, so a key pressed
132    // right after opening moves from the right starting option.
133    this.list.addEventListener("beforetoggle", (e) => {
134      if (e.newState !== "open") return;
135      const selected = this._items.findIndex((el) => el.getAttribute("aria-selected") === "true");
136      this._setActive(selected >= 0 ? selected : this._enabledFrom(0, 1));
137    });
138    this.list.addEventListener("toggle", (e) => this._onToggle(e));
139    // Keep focus on the trigger so its key handler (Escape, arrows) stays in charge.
140    this.list.addEventListener("mousedown", (e) => e.preventDefault());
141    this.list.addEventListener("mousemove", (e) => {
142      const el = e.target.closest("[role=option]");
143      if (el) this._setActive(this._items.indexOf(el));
144    });
145    this.options.addEventListener("click", (e) => {
146      const el = e.target.closest("[role=option]");
147      if (el && !el.hasAttribute("aria-disabled")) this._pick(el.dataset.index);
148    });
149    select.addEventListener("change", () => this._sync());
150    select.addEventListener("invalid", () => this.button.focus());
151    select.form?.addEventListener("reset", () => setTimeout(() => this._sync()));
152
153    // Programmatic changes (Alpine x-model sets option.selected, scripts set
154    // select.value) fire no event, so wrap those setters.
155    this._wrapSetter(select, HTMLSelectElement.prototype, "value");
156    this._wrapSetter(select, HTMLSelectElement.prototype, "selectedIndex");
157    this._observer = new MutationObserver(() => this._schedule());
158    this._observer.observe(select, {
159      childList: true,
160      subtree: true,
161      attributes: true,
162      characterData: true,
163    });
164    this._build();
165  }
166
167  _wrapSetter(obj, proto, prop) {
168    const desc = Object.getOwnPropertyDescriptor(proto, prop);
169    const self = this;
170    Object.defineProperty(obj, prop, {
171      configurable: true,
172      get() {
173        return desc.get.call(this);
174      },
175      set(v) {
176        desc.set.call(this, v);
177        self._sync();
178      },
179    });
180  }
181
182  _schedule() {
183    if (this._pending) return;
184    this._pending = true;
185    queueMicrotask(() => {
186      this._pending = false;
187      this._build();
188    });
189  }
190
191  _build() {
192    const select = this.select;
193    this.button.disabled = select.disabled;
194    this._items = [];
195    this.options.replaceChildren();
196    Array.from(select.options).forEach((opt, index) => {
197      // A property, not a data- attribute: an attribute write would re-fire
198      // the MutationObserver and rebuild forever.
199      if (!opt._xSelectWrapped) {
200        opt._xSelectWrapped = true;
201        this._wrapSetter(opt, HTMLOptionElement.prototype, "selected");
202      }
203      if (opt.hidden) return;
204      const el = document.createElement("div");
205      el.setAttribute("role", "option");
206      el.id = `${this.list.id}-${index}`;
207      el.dataset.index = index;
208      el.className = XSelect.OPTION;
209      if (opt.disabled) el.setAttribute("aria-disabled", "true");
210      const text = document.createElement("span");
211      text.textContent = opt.textContent.trim();
212      el.append(text);
213      this.options.append(el);
214      this._items.push(el);
215    });
216    // Footer actions come last in keyboard order.
217    this._items.push(...this._footers);
218    this._sync();
219  }
220
221  _sync() {
222    const select = this.select;
223    if (!select || !this._items) return;
224    const current = select.options[select.selectedIndex];
225    this.valueEl.textContent = current ? current.textContent.trim() : "";
226    for (const el of this._items) {
227      if (el.hasAttribute("data-select-footer")) continue;
228      const selected = Number(el.dataset.index) === select.selectedIndex;
229      el.setAttribute("aria-selected", String(selected));
230      const check = el.querySelector("svg");
231      if (selected && !check) el.insertAdjacentHTML("beforeend", XSelect.CHECK);
232      if (!selected && check) check.remove();
233    }
234  }
235
236  _pick(index) {
237    const select = this.select;
238    this.list.hidePopover();
239    this.button.focus();
240    if (Number(index) === select.selectedIndex) return;
241    select.selectedIndex = Number(index);
242    select.dispatchEvent(new Event("input", { bubbles: true }));
243    select.dispatchEvent(new Event("change", { bubbles: true }));
244  }
245
246  _onToggle(e) {
247    const open = e.newState === "open";
248    this.button.setAttribute("aria-expanded", String(open));
249    if (open) {
250      this._position();
251      this._watchPosition();
252      if (this._active >= 0) this._items[this._active].scrollIntoView({ block: "nearest" });
253    } else {
254      this._unwatchPosition();
255      this._setActive(-1);
256    }
257  }
258
259  _position() {
260    const r = this.button.getBoundingClientRect();
261    const gap = 4;
262    const below = window.innerHeight - r.bottom - gap - 8;
263    const above = r.top - gap - 8;
264    const s = this.list.style;
265    s.position = "fixed";
266    s.inset = "auto";
267    s.left = `${Math.max(8, Math.min(r.left, window.innerWidth - Math.max(r.width, 160) - 8))}px`;
268    // Full-width fields keep the list flush with the trigger; narrow ones
269    // (inline filters, table cells) let it grow to fit its labels.
270    if (r.width >= 240) s.width = `${r.width}px`;
271    else s.minWidth = `${r.width}px`;
272    s.maxWidth = `${window.innerWidth - 16}px`;
273    const natural = Math.min(this.list.scrollHeight, 288);
274    if (natural > below && above > below) {
275      s.top = "auto";
276      s.bottom = `${window.innerHeight - r.top + gap}px`;
277      s.maxHeight = `${Math.min(288, above)}px`;
278    } else {
279      s.bottom = "auto";
280      s.top = `${r.bottom + gap}px`;
281      s.maxHeight = `${Math.min(288, below)}px`;
282    }
283  }
284
285  _watchPosition() {
286    this._reposition = () => this._position();
287    window.addEventListener("scroll", this._reposition, true);
288    window.addEventListener("resize", this._reposition);
289  }
290
291  _unwatchPosition() {
292    if (!this._reposition) return;
293    window.removeEventListener("scroll", this._reposition, true);
294    window.removeEventListener("resize", this._reposition);
295    this._reposition = null;
296  }
297
298  _enabledFrom(start, step) {
299    for (let i = start; i >= 0 && i < this._items.length; i += step) {
300      if (!this._items[i].hasAttribute("aria-disabled")) return i;
301    }
302    return -1;
303  }
304
305  _setActive(i) {
306    this._items.forEach((el, n) => el.toggleAttribute("data-active", n === i));
307    this._active = i;
308    if (i >= 0) {
309      this.button.setAttribute("aria-activedescendant", this._items[i].id);
310      this._items[i].scrollIntoView({ block: "nearest" });
311    } else {
312      this.button.removeAttribute("aria-activedescendant");
313    }
314  }
315
316  _onKey(e) {
317    const open = this.list.matches(":popover-open");
318    const move = (start, step) => {
319      const i = this._enabledFrom(start, step);
320      if (i >= 0) this._setActive(i);
321    };
322    switch (e.key) {
323      case "ArrowDown":
324      case "ArrowUp":
325        e.preventDefault();
326        if (!open) return this.list.showPopover();
327        if (e.key === "ArrowDown") move(this._active + 1, 1);
328        else move(this._active - 1, -1);
329        return;
330      case "Home":
331      case "End":
332        if (!open) return;
333        e.preventDefault();
334        if (e.key === "Home") move(0, 1);
335        else move(this._items.length - 1, -1);
336        return;
337      case "Enter":
338      case " ":
339        e.preventDefault();
340        if (!open) return this.list.showPopover();
341        if (this._active < 0) return;
342        if (this._items[this._active].hasAttribute("data-select-footer")) this._items[this._active].click();
343        else this._pick(this._items[this._active].dataset.index);
344        return;
345      case "Escape":
346        // Close only the list; a modal's Escape handler must not see it.
347        if (!open) return;
348        e.preventDefault();
349        e.stopPropagation();
350        this.list.hidePopover();
351        return;
352      case "Tab":
353        if (open) this.list.hidePopover();
354        return;
355      default:
356        if (open && e.key.length === 1) this._typeahead(e.key);
357    }
358  }
359
360  _typeahead(key) {
361    clearTimeout(this._typeTimer);
362    this._typed = (this._typed || "") + key.toLowerCase();
363    this._typeTimer = setTimeout(() => (this._typed = ""), 500);
364    const i = this._items.findIndex(
365      (el) =>
366        !el.hasAttribute("aria-disabled") && el.textContent.trim().toLowerCase().startsWith(this._typed),
367    );
368    if (i >= 0) this._setActive(i);
369  }
370}
371
372customElements.define("x-select", XSelect);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.