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.