forge-select 0.1.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.
@@ -0,0 +1,751 @@
1
+ "use strict";
2
+ var ForgeSelectBundle = (() => {
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/index.ts
22
+ var index_exports = {};
23
+ __export(index_exports, {
24
+ ForgeSelect: () => ForgeSelect,
25
+ default: () => ForgeSelect
26
+ });
27
+
28
+ // src/emitter.ts
29
+ var Emitter = class {
30
+ constructor() {
31
+ this.handlers = /* @__PURE__ */ new Map();
32
+ }
33
+ on(event, handler) {
34
+ let set = this.handlers.get(event);
35
+ if (!set) {
36
+ set = /* @__PURE__ */ new Set();
37
+ this.handlers.set(event, set);
38
+ }
39
+ set.add(handler);
40
+ }
41
+ off(event, handler) {
42
+ this.handlers.get(event)?.delete(handler);
43
+ }
44
+ emit(event, ...args) {
45
+ const set = this.handlers.get(event);
46
+ if (!set) return;
47
+ for (const handler of [...set]) handler(...args);
48
+ }
49
+ clear() {
50
+ this.handlers.clear();
51
+ }
52
+ };
53
+
54
+ // src/i18n.ts
55
+ var locales = {
56
+ en: {
57
+ noResults: "No results found",
58
+ loading: "Loading\u2026",
59
+ createOption: 'Create "{query}"',
60
+ clearSelection: "Clear selection",
61
+ removeItem: "Remove {label}",
62
+ search: "Search"
63
+ },
64
+ vi: {
65
+ noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
66
+ loading: "\u0110ang t\u1EA3i\u2026",
67
+ createOption: 'T\u1EA1o "{query}"',
68
+ clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
69
+ removeItem: "X\xF3a {label}",
70
+ search: "T\xECm ki\u1EBFm"
71
+ }
72
+ };
73
+ function getStrings(language) {
74
+ if (typeof language === "string") {
75
+ return locales[language] ?? locales.en;
76
+ }
77
+ return { ...locales.en, ...language };
78
+ }
79
+ function format(template, vars) {
80
+ return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
81
+ }
82
+
83
+ // src/ForgeSelect.ts
84
+ var DEFAULT_ITEM_HEIGHT = 36;
85
+ var VIRTUAL_BUFFER = 5;
86
+ var VIRTUAL_THRESHOLD = 100;
87
+ var ROW_CACHE_LIMIT = 2e3;
88
+ var uidCounter = 0;
89
+ function isGroup(item) {
90
+ return item.options !== void 0;
91
+ }
92
+ var ForgeSelect = class {
93
+ constructor(target, options = {}) {
94
+ this.selected = [];
95
+ this.selectedOptions = /* @__PURE__ */ new Map();
96
+ this.emitter = new Emitter();
97
+ this.uid = `forge-select-${++uidCounter}`;
98
+ this.searchInput = null;
99
+ this.isOpen = false;
100
+ this.isDisabled = false;
101
+ this.destroyed = false;
102
+ this.query = "";
103
+ this.rows = [];
104
+ this.navItems = [];
105
+ this.highlightedIndex = -1;
106
+ this.rowContentCache = /* @__PURE__ */ new Map();
107
+ this.loading = false;
108
+ this.ajaxTimer = null;
109
+ this.ajaxRequestId = 0;
110
+ this.remoteLoaded = false;
111
+ this.onDocumentMouseDown = (event) => {
112
+ if (!this.root.contains(event.target)) this.close();
113
+ };
114
+ const el = typeof target === "string" ? document.querySelector(target) : target;
115
+ if (!el) {
116
+ throw new Error(`ForgeSelect: target element not found: ${String(target)}`);
117
+ }
118
+ this.el = el;
119
+ const nativeSelect = el instanceof HTMLSelectElement ? el : null;
120
+ this.opts = {
121
+ placeholder: options.placeholder ?? "",
122
+ searchable: options.searchable ?? true,
123
+ multiple: options.multiple ?? nativeSelect?.multiple ?? false,
124
+ clearable: options.clearable ?? false,
125
+ allowCreate: options.allowCreate ?? false,
126
+ theme: options.theme ?? "default",
127
+ disabled: options.disabled ?? false,
128
+ data: options.data,
129
+ ajax: options.ajax,
130
+ templateResult: options.templateResult,
131
+ templateSelection: options.templateSelection,
132
+ virtualScroll: options.virtualScroll,
133
+ itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
134
+ language: options.language ?? "en",
135
+ plugins: options.plugins ?? []
136
+ };
137
+ this.strings = getStrings(this.opts.language);
138
+ this.plugins = this.opts.plugins;
139
+ this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
140
+ if (nativeSelect && !this.opts.data) {
141
+ for (const option of Array.from(nativeSelect.querySelectorAll("option"))) {
142
+ if (option.hasAttribute("selected")) this.selectValue(option.value, false);
143
+ }
144
+ }
145
+ this.buildDom();
146
+ this.renderValue();
147
+ if (this.opts.disabled) this.disable();
148
+ for (const plugin of this.plugins) plugin.onInit?.(this);
149
+ }
150
+ // ---------------------------------------------------------------- public API
151
+ open() {
152
+ if (this.isOpen || this.isDisabled || this.destroyed) return;
153
+ this.isOpen = true;
154
+ this.dropdown.hidden = false;
155
+ this.root.classList.add("forge-select--open");
156
+ this.control.setAttribute("aria-expanded", "true");
157
+ document.addEventListener("mousedown", this.onDocumentMouseDown);
158
+ if (this.opts.ajax && !this.remoteLoaded) {
159
+ this.scheduleRemoteLoad(this.query, 0);
160
+ }
161
+ this.renderList();
162
+ if (this.searchInput) this.searchInput.focus();
163
+ this.emitter.emit("open");
164
+ for (const plugin of this.plugins) plugin.onOpen?.(this);
165
+ }
166
+ close() {
167
+ if (!this.isOpen) return;
168
+ this.isOpen = false;
169
+ this.dropdown.hidden = true;
170
+ this.root.classList.remove("forge-select--open");
171
+ this.control.setAttribute("aria-expanded", "false");
172
+ document.removeEventListener("mousedown", this.onDocumentMouseDown);
173
+ this.highlightedIndex = -1;
174
+ if (this.searchInput) {
175
+ this.searchInput.value = "";
176
+ this.query = "";
177
+ }
178
+ this.emitter.emit("close");
179
+ for (const plugin of this.plugins) plugin.onClose?.(this);
180
+ }
181
+ destroy() {
182
+ if (this.destroyed) return;
183
+ this.close();
184
+ for (const plugin of this.plugins) plugin.onDestroy?.(this);
185
+ this.destroyed = true;
186
+ if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
187
+ this.rowContentCache.clear();
188
+ this.root.remove();
189
+ this.el.style.display = "";
190
+ this.emitter.clear();
191
+ }
192
+ getValue() {
193
+ if (this.opts.multiple) return [...this.selected];
194
+ return this.selected[0] ?? null;
195
+ }
196
+ setValue(value) {
197
+ const values = value == null ? [] : Array.isArray(value) ? value : [value];
198
+ const next = this.opts.multiple ? values : values.slice(0, 1);
199
+ if (arraysEqual(next, this.selected)) return;
200
+ this.selected = [];
201
+ for (const v of next) this.selectValue(v, false);
202
+ this.afterSelectionChange();
203
+ }
204
+ enable() {
205
+ this.isDisabled = false;
206
+ this.root.classList.remove("forge-select--disabled");
207
+ this.control.tabIndex = 0;
208
+ this.control.setAttribute("aria-disabled", "false");
209
+ }
210
+ disable() {
211
+ this.close();
212
+ this.isDisabled = true;
213
+ this.root.classList.add("forge-select--disabled");
214
+ this.control.tabIndex = -1;
215
+ this.control.setAttribute("aria-disabled", "true");
216
+ }
217
+ on(event, handler) {
218
+ this.emitter.on(event, handler);
219
+ }
220
+ off(event, handler) {
221
+ this.emitter.off(event, handler);
222
+ }
223
+ // ---------------------------------------------------------------- DOM setup
224
+ buildDom() {
225
+ this.root = document.createElement("div");
226
+ this.root.className = "forge-select";
227
+ this.root.dataset.theme = this.opts.theme;
228
+ this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
229
+ this.control = document.createElement("div");
230
+ this.control.className = "forge-select__control";
231
+ this.control.setAttribute("role", "combobox");
232
+ this.control.setAttribute("aria-haspopup", "listbox");
233
+ this.control.setAttribute("aria-expanded", "false");
234
+ this.control.setAttribute("aria-controls", `${this.uid}-list`);
235
+ this.control.tabIndex = 0;
236
+ this.valueEl = document.createElement("div");
237
+ this.valueEl.className = "forge-select__value";
238
+ this.clearBtn = document.createElement("button");
239
+ this.clearBtn.type = "button";
240
+ this.clearBtn.className = "forge-select__clear";
241
+ this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
242
+ this.clearBtn.textContent = "\xD7";
243
+ this.clearBtn.hidden = true;
244
+ const arrow = document.createElement("span");
245
+ arrow.className = "forge-select__arrow";
246
+ arrow.setAttribute("aria-hidden", "true");
247
+ this.control.append(this.valueEl, this.clearBtn, arrow);
248
+ this.dropdown = document.createElement("div");
249
+ this.dropdown.className = "forge-select__dropdown";
250
+ this.dropdown.hidden = true;
251
+ if (this.opts.searchable) {
252
+ this.searchInput = document.createElement("input");
253
+ this.searchInput.type = "search";
254
+ this.searchInput.className = "forge-select__search";
255
+ this.searchInput.setAttribute("aria-label", this.strings.search);
256
+ this.searchInput.setAttribute("aria-autocomplete", "list");
257
+ this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
258
+ this.dropdown.append(this.searchInput);
259
+ }
260
+ this.list = document.createElement("ul");
261
+ this.list.className = "forge-select__list";
262
+ this.list.id = `${this.uid}-list`;
263
+ this.list.setAttribute("role", "listbox");
264
+ if (this.opts.multiple) this.list.setAttribute("aria-multiselectable", "true");
265
+ this.dropdown.append(this.list);
266
+ this.root.append(this.control, this.dropdown);
267
+ this.el.style.display = "none";
268
+ this.el.insertAdjacentElement("afterend", this.root);
269
+ this.bindEvents();
270
+ }
271
+ bindEvents() {
272
+ this.control.addEventListener("click", (event) => {
273
+ if (event.target === this.clearBtn) return;
274
+ if (this.isDisabled) return;
275
+ this.isOpen ? this.close() : this.open();
276
+ });
277
+ this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
278
+ this.clearBtn.addEventListener("click", (event) => {
279
+ event.stopPropagation();
280
+ this.clearSelection();
281
+ });
282
+ if (this.searchInput) {
283
+ this.searchInput.addEventListener("input", () => {
284
+ this.query = this.searchInput.value;
285
+ this.highlightedIndex = -1;
286
+ this.list.scrollTop = 0;
287
+ this.emitter.emit("search", this.query);
288
+ if (this.opts.ajax) {
289
+ this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
290
+ } else {
291
+ this.renderList();
292
+ }
293
+ });
294
+ this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
295
+ }
296
+ this.list.addEventListener("click", (event) => {
297
+ const li = event.target.closest("li[data-nav-index]");
298
+ if (!li) return;
299
+ const navIndex = Number(li.dataset.navIndex);
300
+ this.activateNavItem(navIndex);
301
+ });
302
+ this.list.addEventListener("scroll", () => {
303
+ if (this.usesVirtualScroll()) this.renderRows();
304
+ });
305
+ }
306
+ handleKeydown(event) {
307
+ if (this.isDisabled) return;
308
+ switch (event.key) {
309
+ case "Enter":
310
+ event.preventDefault();
311
+ if (!this.isOpen) this.open();
312
+ else if (this.highlightedIndex >= 0) this.activateNavItem(this.highlightedIndex);
313
+ break;
314
+ case " ":
315
+ if (event.target === this.control) {
316
+ event.preventDefault();
317
+ if (!this.isOpen) this.open();
318
+ }
319
+ break;
320
+ case "ArrowDown":
321
+ event.preventDefault();
322
+ if (!this.isOpen) this.open();
323
+ else this.moveHighlight(1);
324
+ break;
325
+ case "ArrowUp":
326
+ event.preventDefault();
327
+ if (this.isOpen) this.moveHighlight(-1);
328
+ break;
329
+ case "Escape":
330
+ if (this.isOpen) {
331
+ event.preventDefault();
332
+ this.close();
333
+ this.control.focus();
334
+ }
335
+ break;
336
+ case "Tab":
337
+ this.close();
338
+ break;
339
+ }
340
+ }
341
+ // ---------------------------------------------------------------- selection
342
+ selectValue(value, notify) {
343
+ if (this.selected.includes(value)) return;
344
+ const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
345
+ this.selectedOptions.set(value, option);
346
+ if (this.opts.multiple) this.selected.push(value);
347
+ else this.selected = [value];
348
+ if (notify) this.afterSelectionChange();
349
+ }
350
+ deselectValue(value, notify) {
351
+ const index = this.selected.indexOf(value);
352
+ if (index === -1) return;
353
+ this.selected.splice(index, 1);
354
+ if (notify) this.afterSelectionChange();
355
+ }
356
+ clearSelection() {
357
+ if (this.selected.length === 0) return;
358
+ this.selected = [];
359
+ this.emitter.emit("clear");
360
+ this.afterSelectionChange();
361
+ }
362
+ afterSelectionChange() {
363
+ this.renderValue();
364
+ this.syncNativeSelect();
365
+ if (this.isOpen) this.renderList();
366
+ this.emitter.emit("change", this.getValue());
367
+ }
368
+ syncNativeSelect() {
369
+ if (!(this.el instanceof HTMLSelectElement)) return;
370
+ const existing = /* @__PURE__ */ new Set();
371
+ for (const option of Array.from(this.el.options)) {
372
+ existing.add(option.value);
373
+ option.selected = this.selected.includes(option.value);
374
+ }
375
+ for (const value of this.selected) {
376
+ if (existing.has(value)) continue;
377
+ const option = document.createElement("option");
378
+ option.value = value;
379
+ option.textContent = this.selectedOptions.get(value)?.label ?? value;
380
+ option.selected = true;
381
+ this.el.append(option);
382
+ }
383
+ this.el.dispatchEvent(new Event("change", { bubbles: true }));
384
+ }
385
+ findOption(value) {
386
+ for (const item of this.data) {
387
+ if (isGroup(item)) {
388
+ const found = item.options.find((o) => o.value === value);
389
+ if (found) return found;
390
+ } else if (item.value === value) {
391
+ return item;
392
+ }
393
+ }
394
+ return void 0;
395
+ }
396
+ createFromQuery() {
397
+ const label = this.query.trim();
398
+ if (!label) return;
399
+ const option = { value: label, label };
400
+ this.data.push(option);
401
+ if (this.searchInput) {
402
+ this.searchInput.value = "";
403
+ this.query = "";
404
+ }
405
+ this.selectValue(option.value, true);
406
+ if (!this.opts.multiple) this.close();
407
+ }
408
+ activateNavItem(navIndex) {
409
+ const item = this.navItems[navIndex];
410
+ if (!item) return;
411
+ if (item.kind === "create") {
412
+ this.createFromQuery();
413
+ return;
414
+ }
415
+ const { value } = item.option;
416
+ if (this.opts.multiple) {
417
+ this.selected.includes(value) ? this.deselectValue(value, true) : this.selectValue(value, true);
418
+ } else {
419
+ this.selectValue(value, true);
420
+ this.close();
421
+ this.control.focus();
422
+ }
423
+ }
424
+ // ---------------------------------------------------------------- rendering
425
+ renderValue() {
426
+ this.valueEl.textContent = "";
427
+ const hasValue = this.selected.length > 0;
428
+ this.clearBtn.hidden = !(this.opts.clearable && hasValue);
429
+ if (!hasValue) {
430
+ const placeholder = document.createElement("span");
431
+ placeholder.className = "forge-select__placeholder";
432
+ placeholder.textContent = this.opts.placeholder;
433
+ this.valueEl.append(placeholder);
434
+ return;
435
+ }
436
+ if (this.opts.multiple) {
437
+ for (const value of this.selected) {
438
+ const option = this.selectedOptions.get(value) ?? { value, label: value };
439
+ const tag = document.createElement("span");
440
+ tag.className = "forge-select__tag";
441
+ const label = document.createElement("span");
442
+ label.className = "forge-select__tag-label";
443
+ this.renderTemplate(label, option, this.opts.templateSelection, "inline");
444
+ const remove = document.createElement("button");
445
+ remove.type = "button";
446
+ remove.className = "forge-select__tag-remove";
447
+ remove.setAttribute("aria-label", format(this.strings.removeItem, { label: option.label }));
448
+ remove.textContent = "\xD7";
449
+ remove.addEventListener("click", (event) => {
450
+ event.stopPropagation();
451
+ if (!this.isDisabled) this.deselectValue(value, true);
452
+ });
453
+ tag.append(label, remove);
454
+ this.valueEl.append(tag);
455
+ }
456
+ } else {
457
+ const option = this.selectedOptions.get(this.selected[0]) ?? {
458
+ value: this.selected[0],
459
+ label: this.selected[0]
460
+ };
461
+ const span = document.createElement("span");
462
+ span.className = "forge-select__single-value";
463
+ this.renderTemplate(span, option, this.opts.templateSelection, "inline");
464
+ this.valueEl.append(span);
465
+ }
466
+ }
467
+ renderTemplate(container, option, template, variant = "row") {
468
+ if (template) {
469
+ const result = template(option);
470
+ if (typeof result === "string") container.innerHTML = result;
471
+ else container.append(result);
472
+ return;
473
+ }
474
+ if (!option.avatar && !option.description) {
475
+ container.textContent = option.label;
476
+ return;
477
+ }
478
+ if (option.avatar) {
479
+ const avatar = document.createElement("img");
480
+ avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
481
+ avatar.src = option.avatar;
482
+ avatar.alt = "";
483
+ avatar.setAttribute("loading", "lazy");
484
+ avatar.setAttribute("decoding", "async");
485
+ container.append(avatar);
486
+ }
487
+ if (variant === "row" && option.description) {
488
+ const body = document.createElement("span");
489
+ body.className = "forge-select__option-body";
490
+ const label = document.createElement("span");
491
+ label.className = "forge-select__option-label";
492
+ label.textContent = option.label;
493
+ const desc = document.createElement("span");
494
+ desc.className = "forge-select__option-desc";
495
+ desc.textContent = option.description;
496
+ body.append(label, desc);
497
+ container.append(body);
498
+ } else {
499
+ const label = document.createElement("span");
500
+ label.className = "forge-select__option-label";
501
+ label.textContent = option.label;
502
+ container.append(label);
503
+ }
504
+ }
505
+ buildRows() {
506
+ this.rows = [];
507
+ this.navItems = [];
508
+ const query = this.query.trim().toLowerCase();
509
+ const matches = (option) => query === "" || option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false);
510
+ const pushOption = (option) => {
511
+ let navIndex = -1;
512
+ if (!option.disabled) {
513
+ navIndex = this.navItems.length;
514
+ this.navItems.push({ kind: "option", option });
515
+ }
516
+ this.rows.push({ kind: "option", option, navIndex });
517
+ };
518
+ if (this.loading) {
519
+ this.rows.push({ kind: "loading" });
520
+ return;
521
+ }
522
+ for (const item of this.data) {
523
+ if (isGroup(item)) {
524
+ const visible = item.options.filter(matches);
525
+ if (visible.length === 0) continue;
526
+ this.rows.push({ kind: "group", label: item.label });
527
+ visible.forEach(pushOption);
528
+ } else if (matches(item)) {
529
+ pushOption(item);
530
+ }
531
+ }
532
+ if (this.opts.allowCreate && query !== "" && !this.hasExactMatch(query)) {
533
+ const navIndex = this.navItems.length;
534
+ this.navItems.push({ kind: "create" });
535
+ this.rows.push({ kind: "create", navIndex });
536
+ }
537
+ if (this.rows.length === 0) this.rows.push({ kind: "empty" });
538
+ }
539
+ hasExactMatch(lowerQuery) {
540
+ for (const item of this.data) {
541
+ const options = isGroup(item) ? item.options : [item];
542
+ if (options.some((o) => o.label.toLowerCase() === lowerQuery)) return true;
543
+ }
544
+ return false;
545
+ }
546
+ usesVirtualScroll() {
547
+ return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
548
+ }
549
+ renderList() {
550
+ this.buildRows();
551
+ this.renderRows();
552
+ }
553
+ renderRows() {
554
+ const scrollTop = this.list.scrollTop;
555
+ const clientHeight = this.list.clientHeight;
556
+ const virtual = this.usesVirtualScroll();
557
+ this.list.textContent = "";
558
+ const rowHeight = this.opts.itemHeight;
559
+ let start = 0;
560
+ let end = this.rows.length;
561
+ if (virtual) {
562
+ const viewport = clientHeight || rowHeight * 8;
563
+ start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
564
+ end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
565
+ const topSpacer = document.createElement("li");
566
+ topSpacer.className = "forge-select__spacer";
567
+ topSpacer.setAttribute("aria-hidden", "true");
568
+ topSpacer.style.height = `${start * rowHeight}px`;
569
+ this.list.append(topSpacer);
570
+ }
571
+ for (let i = start; i < end; i++) {
572
+ this.list.append(this.renderRow(this.rows[i]));
573
+ }
574
+ if (virtual) {
575
+ const bottomSpacer = document.createElement("li");
576
+ bottomSpacer.className = "forge-select__spacer";
577
+ bottomSpacer.setAttribute("aria-hidden", "true");
578
+ bottomSpacer.style.height = `${(this.rows.length - end) * rowHeight}px`;
579
+ this.list.append(bottomSpacer);
580
+ if (this.list.scrollTop !== scrollTop) {
581
+ this.list.scrollTop = scrollTop;
582
+ }
583
+ }
584
+ this.updateActiveDescendant();
585
+ }
586
+ renderRow(row) {
587
+ const li = document.createElement("li");
588
+ switch (row.kind) {
589
+ case "group":
590
+ li.className = "forge-select__group-label";
591
+ li.setAttribute("role", "presentation");
592
+ li.textContent = row.label;
593
+ break;
594
+ case "empty":
595
+ li.className = "forge-select__empty";
596
+ li.textContent = this.strings.noResults;
597
+ break;
598
+ case "loading":
599
+ li.className = "forge-select__loading";
600
+ li.textContent = this.strings.loading;
601
+ break;
602
+ case "create":
603
+ li.className = "forge-select__option forge-select__option--create";
604
+ li.setAttribute("role", "option");
605
+ li.id = `${this.uid}-nav-${row.navIndex}`;
606
+ li.dataset.navIndex = String(row.navIndex);
607
+ li.textContent = format(this.strings.createOption, { query: this.query.trim() });
608
+ if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
609
+ break;
610
+ case "option": {
611
+ li.className = "forge-select__option";
612
+ li.setAttribute("role", "option");
613
+ const isSelected = this.selected.includes(row.option.value);
614
+ li.setAttribute("aria-selected", String(isSelected));
615
+ if (isSelected) li.classList.add("forge-select__option--selected");
616
+ if (row.option.disabled) {
617
+ li.classList.add("forge-select__option--disabled");
618
+ li.setAttribute("aria-disabled", "true");
619
+ } else {
620
+ li.id = `${this.uid}-nav-${row.navIndex}`;
621
+ li.dataset.navIndex = String(row.navIndex);
622
+ if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
623
+ }
624
+ li.append(this.optionContent(row.option));
625
+ break;
626
+ }
627
+ }
628
+ return li;
629
+ }
630
+ /**
631
+ * Rendered row content is cached per option value and cloned on each render,
632
+ * so templates run once per option instead of once per scroll frame.
633
+ * State classes (selected/highlighted/disabled) live on the <li>, keeping the
634
+ * cached content state-free.
635
+ */
636
+ optionContent(option) {
637
+ let cached = this.rowContentCache.get(option.value);
638
+ if (!cached) {
639
+ const holder = document.createElement("span");
640
+ holder.className = "forge-select__option-content";
641
+ this.renderTemplate(holder, option, this.opts.templateResult);
642
+ if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
643
+ const oldest = this.rowContentCache.keys().next().value;
644
+ this.rowContentCache.delete(oldest);
645
+ }
646
+ this.rowContentCache.set(option.value, holder);
647
+ cached = holder;
648
+ }
649
+ return cached.cloneNode(true);
650
+ }
651
+ moveHighlight(delta) {
652
+ if (this.navItems.length === 0) return;
653
+ const next = this.highlightedIndex === -1 && delta > 0 ? 0 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
654
+ this.highlightedIndex = next;
655
+ if (this.usesVirtualScroll()) {
656
+ const rowIndex = this.rows.findIndex(
657
+ (row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
658
+ );
659
+ if (rowIndex >= 0) {
660
+ const rowHeight = this.opts.itemHeight;
661
+ const top = rowIndex * rowHeight;
662
+ const viewport = this.list.clientHeight || rowHeight * 8;
663
+ let target = this.list.scrollTop;
664
+ if (top < target) target = top;
665
+ else if (top + rowHeight > target + viewport) target = top + rowHeight - viewport;
666
+ if (target !== this.list.scrollTop) this.list.scrollTop = target;
667
+ }
668
+ this.renderRows();
669
+ } else {
670
+ this.renderRows();
671
+ const highlighted = this.list.querySelector(".forge-select__option--highlighted");
672
+ highlighted?.scrollIntoView?.({ block: "nearest" });
673
+ }
674
+ }
675
+ updateActiveDescendant() {
676
+ const target = this.searchInput ?? this.control;
677
+ if (this.highlightedIndex >= 0) {
678
+ target.setAttribute("aria-activedescendant", `${this.uid}-nav-${this.highlightedIndex}`);
679
+ } else {
680
+ target.removeAttribute("aria-activedescendant");
681
+ }
682
+ }
683
+ // ---------------------------------------------------------------- remote data
684
+ scheduleRemoteLoad(query, delay) {
685
+ if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
686
+ this.loading = true;
687
+ this.renderList();
688
+ this.ajaxTimer = setTimeout(() => {
689
+ void this.loadRemote(query);
690
+ }, delay);
691
+ }
692
+ async loadRemote(query) {
693
+ const ajax = this.opts.ajax;
694
+ const requestId = ++this.ajaxRequestId;
695
+ try {
696
+ const url = buildUrl(ajax, query);
697
+ const response = await fetch(url);
698
+ const json = await response.json();
699
+ if (requestId !== this.ajaxRequestId || this.destroyed) return;
700
+ this.data = ajax.transform ? ajax.transform(json) : json;
701
+ this.rowContentCache.clear();
702
+ this.remoteLoaded = true;
703
+ } catch {
704
+ if (requestId !== this.ajaxRequestId || this.destroyed) return;
705
+ this.data = [];
706
+ this.rowContentCache.clear();
707
+ } finally {
708
+ if (requestId === this.ajaxRequestId && !this.destroyed) {
709
+ this.loading = false;
710
+ if (this.isOpen) this.renderList();
711
+ }
712
+ }
713
+ }
714
+ };
715
+ function parseNativeOptions(select) {
716
+ const data = [];
717
+ for (const child of Array.from(select.children)) {
718
+ if (child instanceof HTMLOptGroupElement) {
719
+ data.push({
720
+ label: child.label,
721
+ options: Array.from(child.querySelectorAll("option")).map(parseOption)
722
+ });
723
+ } else if (child instanceof HTMLOptionElement) {
724
+ data.push(parseOption(child));
725
+ }
726
+ }
727
+ return data;
728
+ }
729
+ function parseOption(option) {
730
+ return {
731
+ value: option.value,
732
+ label: option.textContent?.trim() ?? option.value,
733
+ disabled: option.disabled || void 0
734
+ };
735
+ }
736
+ function buildUrl(ajax, query) {
737
+ if (typeof ajax.url === "function") return ajax.url(query);
738
+ if (!ajax.params) return ajax.url;
739
+ const params = new URLSearchParams();
740
+ for (const [key, value] of Object.entries(ajax.params(query))) {
741
+ params.set(key, String(value));
742
+ }
743
+ const separator = ajax.url.includes("?") ? "&" : "?";
744
+ return `${ajax.url}${separator}${params.toString()}`;
745
+ }
746
+ function arraysEqual(a, b) {
747
+ return a.length === b.length && a.every((value, index) => value === b[index]);
748
+ }
749
+ return __toCommonJS(index_exports);
750
+ })();
751
+ //# sourceMappingURL=index.global.js.map