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