forge-select 0.3.0 → 0.5.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.cjs CHANGED
@@ -51,6 +51,17 @@ var Emitter = class {
51
51
  }
52
52
  };
53
53
 
54
+ // src/dropdown-position.ts
55
+ function computeDropdownPlacement(controlRect, dropdownHeight, viewportHeight, gap = 4) {
56
+ const spaceBelow = viewportHeight - controlRect.bottom;
57
+ const spaceAbove = controlRect.top;
58
+ const dropUp = dropdownHeight > spaceBelow && spaceAbove > spaceBelow;
59
+ return {
60
+ dropUp,
61
+ top: dropUp ? controlRect.top - dropdownHeight - gap : controlRect.bottom + gap
62
+ };
63
+ }
64
+
54
65
  // src/i18n.ts
55
66
  var locales = {
56
67
  en: {
@@ -62,7 +73,9 @@ var locales = {
62
73
  clearSelection: "Clear selection",
63
74
  removeItem: "Remove {label}",
64
75
  search: "Search",
65
- reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
76
+ reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder.",
77
+ minSearchLength: "Type {count} or more characters to search",
78
+ maximumSelected: "Maximum of {count} selections reached"
66
79
  },
67
80
  vi: {
68
81
  noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
@@ -73,7 +86,9 @@ var locales = {
73
86
  clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
74
87
  removeItem: "X\xF3a {label}",
75
88
  search: "T\xECm ki\u1EBFm",
76
- reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i."
89
+ reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i.",
90
+ minSearchLength: "Nh\u1EADp th\xEAm {count} k\xFD t\u1EF1 \u0111\u1EC3 t\xECm ki\u1EBFm",
91
+ maximumSelected: "\u0110\xE3 \u0111\u1EA1t t\u1ED1i \u0111a {count} l\u1EF1a ch\u1ECDn"
77
92
  }
78
93
  };
79
94
  function getStrings(language) {
@@ -149,6 +164,7 @@ function renderOptionContent(container, option, template, variant = "row") {
149
164
 
150
165
  // src/remote.ts
151
166
  function buildUrl(ajax, query, page) {
167
+ if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
152
168
  if (typeof ajax.url === "function") return ajax.url(query, page);
153
169
  if (!ajax.params) return ajax.url;
154
170
  const params = new URLSearchParams();
@@ -167,22 +183,106 @@ function normalizeRemoteResult(ajax, response) {
167
183
  return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
168
184
  }
169
185
 
186
+ // src/remote-cache.ts
187
+ var RemoteCache = class {
188
+ constructor() {
189
+ this.entries = /* @__PURE__ */ new Map();
190
+ }
191
+ get(key, now = Date.now()) {
192
+ const entry = this.entries.get(key);
193
+ if (!entry) return void 0;
194
+ if (entry.expiresAt <= now) {
195
+ this.entries.delete(key);
196
+ return void 0;
197
+ }
198
+ return entry.value;
199
+ }
200
+ set(key, value, ttl, now = Date.now()) {
201
+ if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
202
+ }
203
+ clear() {
204
+ this.entries.clear();
205
+ }
206
+ };
207
+
208
+ // src/search.ts
209
+ function normalizeSearchText(value, accentInsensitive = true) {
210
+ const lower = value.toLocaleLowerCase();
211
+ return accentInsensitive ? lower.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d") : lower;
212
+ }
213
+ function getSearchField(option, field) {
214
+ if (field === "label") return option.label;
215
+ if (field === "description") return option.description ?? "";
216
+ const path = field.slice(5).split(".");
217
+ let value = option.meta;
218
+ for (const key of path) {
219
+ if (!value || typeof value !== "object") return "";
220
+ value = value[key];
221
+ }
222
+ return value == null ? "" : String(value);
223
+ }
224
+ var SearchIndex = class {
225
+ constructor() {
226
+ this.cache = /* @__PURE__ */ new WeakMap();
227
+ }
228
+ clear() {
229
+ this.cache = /* @__PURE__ */ new WeakMap();
230
+ }
231
+ score(option, query, config) {
232
+ const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
233
+ if (!normalizedQuery) return 1;
234
+ if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
235
+ const key = `${config.accentInsensitive ? "1" : "0"}:${config.fields.join("\0")}`;
236
+ let variants = this.cache.get(option);
237
+ if (!variants) {
238
+ variants = /* @__PURE__ */ new Map();
239
+ this.cache.set(option, variants);
240
+ }
241
+ let haystacks = variants.get(key);
242
+ if (!haystacks) {
243
+ haystacks = config.fields.map(
244
+ (field) => normalizeSearchText(getSearchField(option, field), config.accentInsensitive)
245
+ );
246
+ variants.set(key, haystacks);
247
+ }
248
+ const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
249
+ if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
250
+ const label = haystacks[config.fields.indexOf("label")];
251
+ if (label === normalizedQuery) return 4;
252
+ if (label?.startsWith(normalizedQuery)) return 3;
253
+ if (label?.includes(normalizedQuery)) return 2;
254
+ return 1;
255
+ }
256
+ };
257
+ function findNormalizedRanges(label, query, accentInsensitive = true) {
258
+ const tokens = normalizeSearchText(query.trim(), accentInsensitive).split(/\s+/).filter(Boolean);
259
+ if (!tokens.length) return [];
260
+ const normalized = normalizeSearchText(label, accentInsensitive);
261
+ const ranges = [];
262
+ for (const token of tokens) {
263
+ const index = normalized.indexOf(token);
264
+ if (index >= 0) ranges.push([index, index + token.length]);
265
+ }
266
+ return ranges.sort((a, b) => a[0] - b[0]);
267
+ }
268
+
170
269
  // src/selection.ts
171
270
  function isGroup(item) {
172
271
  return item.options !== void 0;
173
272
  }
174
- function collectDescendantValues(option) {
273
+ var defaultIsDisabled = (option) => !!option.disabled;
274
+ function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
175
275
  if (!option.children) return [];
176
276
  const values = [];
177
277
  for (const child of option.children) {
178
- if (!child.disabled) values.push(child.value);
179
- values.push(...collectDescendantValues(child));
278
+ if (!isDisabled(child)) values.push(child.value);
279
+ values.push(...collectDescendantValues(child, isDisabled));
180
280
  }
181
281
  return values;
182
282
  }
183
- function computeCheckState(option, selected) {
283
+ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
184
284
  if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
185
- const states = option.children.filter((child) => !child.disabled).map((child) => computeCheckState(child, selected));
285
+ const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
186
286
  if (states.length === 0) return "none";
187
287
  if (states.every((state) => state === "all")) return "all";
188
288
  if (states.every((state) => state === "none")) return "none";
@@ -203,11 +303,11 @@ function findOption(items, value) {
203
303
  }
204
304
  return void 0;
205
305
  }
206
- function syncTreeAncestors(items, selected) {
306
+ function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
207
307
  const sync = (option) => {
208
308
  if (!option.children?.length) return;
209
309
  for (const child of option.children) sync(child);
210
- const state = computeCheckState(option, selected);
310
+ const state = computeCheckState(option, selected, isDisabled);
211
311
  const index = selected.indexOf(option.value);
212
312
  if (state === "all" && index === -1) selected.push(option.value);
213
313
  else if (state !== "all" && index !== -1) selected.splice(index, 1);
@@ -241,6 +341,7 @@ var ForgeSelect = class {
241
341
  this.emitter = new Emitter();
242
342
  this.uid = `forge-select-${++uidCounter}`;
243
343
  this.searchInput = null;
344
+ this.portalHost = null;
244
345
  this.isOpen = false;
245
346
  this.isDisabled = false;
246
347
  this.destroyed = false;
@@ -249,6 +350,8 @@ var ForgeSelect = class {
249
350
  this.navItems = [];
250
351
  this.highlightedIndex = -1;
251
352
  this.rowContentCache = /* @__PURE__ */ new Map();
353
+ this.rowHeightCache = /* @__PURE__ */ new Map();
354
+ this.searchIndex = new SearchIndex();
252
355
  this.expandedValues = /* @__PURE__ */ new Set();
253
356
  this.loading = false;
254
357
  this.loadingMore = false;
@@ -258,14 +361,33 @@ var ForgeSelect = class {
258
361
  this.ajaxRequestId = 0;
259
362
  this.ajaxController = null;
260
363
  this.remoteLoaded = false;
364
+ this.remoteCache = new RemoteCache();
261
365
  this.loadError = null;
262
366
  this.originalDisplay = "";
263
367
  this.originalDisabled = false;
264
368
  this.nativeSelect = null;
265
369
  this.nativeForm = null;
266
370
  this.syncingNative = false;
371
+ /** Combines the static `disabled` field with the dynamic `isOptionDisabled` callback. */
372
+ this.isOptionDisabled = (option) => option.disabled === true || (this.opts.isOptionDisabled?.(option) ?? false);
373
+ this.pointerDownOnControl = false;
267
374
  this.onDocumentMouseDown = (event) => {
268
- if (!this.root.contains(event.target)) this.close();
375
+ const target = event.target;
376
+ if (!this.root.contains(target) && !this.portalHost?.contains(target)) this.close();
377
+ };
378
+ this.onWindowResize = () => {
379
+ this.positionDropdown();
380
+ };
381
+ this.onAncestorScroll = () => {
382
+ if (this.portalHost) this.positionDropdown();
383
+ };
384
+ this.onNativeInvalid = (event) => {
385
+ event.preventDefault();
386
+ this.control.classList.add("forge-select__control--invalid");
387
+ this.control.setAttribute("aria-invalid", "true");
388
+ if (!this.isOpen) this.open();
389
+ this.control.focus();
390
+ this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
269
391
  };
270
392
  this.onNativeChange = () => {
271
393
  if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
@@ -294,19 +416,35 @@ var ForgeSelect = class {
294
416
  clearable: options.clearable ?? false,
295
417
  allowCreate: options.allowCreate ?? false,
296
418
  sortable: options.sortable ?? false,
419
+ closeOnSelect: options.closeOnSelect ?? false,
420
+ maxSelections: options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections)),
297
421
  theme: options.theme ?? "default",
298
422
  disabled: options.disabled ?? nativeSelect?.disabled ?? false,
423
+ required: options.required ?? nativeSelect?.required ?? false,
299
424
  data: options.data,
300
425
  ajax: options.ajax,
301
426
  templateResult: options.templateResult,
302
427
  templateSelection: options.templateSelection,
428
+ filterOption: options.filterOption,
429
+ searchFields: options.searchFields ?? ["label", "description"],
430
+ tokenSearch: options.tokenSearch ?? true,
431
+ accentInsensitive: options.accentInsensitive ?? true,
432
+ searchScorer: options.searchScorer,
433
+ highlightSearch: options.highlightSearch ?? false,
434
+ minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
435
+ minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
436
+ isOptionDisabled: options.isOptionDisabled,
303
437
  virtualScroll: options.virtualScroll,
304
- itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
438
+ itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
439
+ variableItemHeight: options.itemHeight === "auto",
305
440
  language: options.language ?? "en",
306
- plugins: options.plugins ?? []
441
+ plugins: options.plugins ?? [],
442
+ openOnFocus: options.openOnFocus ?? false,
443
+ dropdownParent: options.dropdownParent
307
444
  };
308
445
  this.strings = getStrings(this.opts.language);
309
446
  this.plugins = this.opts.plugins;
447
+ if (nativeSelect) nativeSelect.required = this.opts.required;
310
448
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
311
449
  if (nativeSelect && !this.opts.data) {
312
450
  const nativeOptions = Array.from(nativeSelect.options);
@@ -319,8 +457,10 @@ var ForgeSelect = class {
319
457
  this.renderValue();
320
458
  if (this.opts.disabled) this.disable();
321
459
  nativeSelect?.addEventListener("change", this.onNativeChange);
460
+ nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
322
461
  this.nativeForm?.addEventListener("reset", this.onFormReset);
323
462
  for (const plugin of this.plugins) plugin.onInit?.(this);
463
+ for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
324
464
  }
325
465
  applyNativeValues(values) {
326
466
  this.selected = [];
@@ -337,11 +477,14 @@ var ForgeSelect = class {
337
477
  this.root.classList.add("forge-select--open");
338
478
  this.control.setAttribute("aria-expanded", "true");
339
479
  document.addEventListener("mousedown", this.onDocumentMouseDown);
340
- if (this.opts.ajax && !this.remoteLoaded) {
480
+ if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
341
481
  this.scheduleRemoteLoad(this.query, 0);
342
482
  }
343
483
  this.renderList();
344
- if (this.searchInput) this.searchInput.focus();
484
+ this.positionDropdown();
485
+ window.addEventListener("resize", this.onWindowResize);
486
+ document.addEventListener("scroll", this.onAncestorScroll, true);
487
+ if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
345
488
  this.emitter.emit("open");
346
489
  for (const plugin of this.plugins) plugin.onOpen?.(this);
347
490
  }
@@ -350,8 +493,11 @@ var ForgeSelect = class {
350
493
  this.isOpen = false;
351
494
  this.dropdown.hidden = true;
352
495
  this.root.classList.remove("forge-select--open");
496
+ this.root.classList.remove("forge-select--drop-up");
353
497
  this.control.setAttribute("aria-expanded", "false");
354
498
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
499
+ window.removeEventListener("resize", this.onWindowResize);
500
+ document.removeEventListener("scroll", this.onAncestorScroll, true);
355
501
  this.highlightedIndex = -1;
356
502
  if (this.searchInput) {
357
503
  this.searchInput.value = "";
@@ -360,6 +506,23 @@ var ForgeSelect = class {
360
506
  this.emitter.emit("close");
361
507
  for (const plugin of this.plugins) plugin.onClose?.(this);
362
508
  }
509
+ /**
510
+ * Flips the dropdown above the control when there isn't enough room below
511
+ * but there is above. Recomputed on open() and on window resize — the
512
+ * dropdown is positioned absolutely inside the relatively-positioned root,
513
+ * so it already tracks the control correctly on page scroll without
514
+ * needing a scroll listener.
515
+ */
516
+ positionDropdown() {
517
+ const controlRect = this.control.getBoundingClientRect();
518
+ const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
519
+ this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
520
+ if (this.portalHost) {
521
+ this.portalHost.style.top = `${placement.top}px`;
522
+ this.portalHost.style.left = `${controlRect.left}px`;
523
+ this.portalHost.style.width = `${controlRect.width}px`;
524
+ }
525
+ }
363
526
  destroy() {
364
527
  if (this.destroyed) return;
365
528
  this.close();
@@ -368,8 +531,12 @@ var ForgeSelect = class {
368
531
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
369
532
  this.ajaxController?.abort();
370
533
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
534
+ this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
371
535
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
372
536
  this.rowContentCache.clear();
537
+ this.rowHeightCache.clear();
538
+ this.searchIndex.clear();
539
+ this.portalHost?.remove();
373
540
  this.root.remove();
374
541
  this.el.style.display = this.originalDisplay;
375
542
  if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
@@ -379,6 +546,106 @@ var ForgeSelect = class {
379
546
  if (this.opts.multiple) return [...this.selected];
380
547
  return this.selected[0] ?? null;
381
548
  }
549
+ getSearchQuery() {
550
+ return this.query;
551
+ }
552
+ setSearchQuery(query, options = {}) {
553
+ this.applySearchQuery(query, options.emitSearch ?? true);
554
+ }
555
+ isDropdownOpen() {
556
+ return this.isOpen;
557
+ }
558
+ updateOptions(options) {
559
+ if (options.data) this.setData(options.data);
560
+ if ("ajax" in options && options.ajax !== this.opts.ajax) {
561
+ this.opts.ajax = options.ajax;
562
+ this.remoteLoaded = false;
563
+ this.clearRemoteCache();
564
+ }
565
+ if (options.placeholder !== void 0) this.opts.placeholder = options.placeholder;
566
+ if (options.clearable !== void 0) this.opts.clearable = options.clearable;
567
+ if (options.allowCreate !== void 0) this.opts.allowCreate = options.allowCreate;
568
+ if (options.sortable !== void 0) this.opts.sortable = options.sortable;
569
+ if (options.closeOnSelect !== void 0) this.opts.closeOnSelect = options.closeOnSelect;
570
+ if ("maxSelections" in options)
571
+ this.opts.maxSelections = options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections));
572
+ if (options.theme !== void 0) {
573
+ this.opts.theme = options.theme;
574
+ this.root.dataset.theme = options.theme;
575
+ if (this.portalHost) this.portalHost.dataset.theme = options.theme;
576
+ }
577
+ if (options.required !== void 0) {
578
+ this.opts.required = options.required;
579
+ if (options.required) this.control.setAttribute("aria-required", "true");
580
+ else this.control.removeAttribute("aria-required");
581
+ if (this.nativeSelect) this.nativeSelect.required = options.required;
582
+ }
583
+ if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
584
+ if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
585
+ if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
586
+ if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
587
+ if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
588
+ if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
589
+ if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
590
+ if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
591
+ if (options.minSearchLength !== void 0)
592
+ this.opts.minSearchLength = Math.max(0, Math.floor(options.minSearchLength));
593
+ if (options.minResultsForSearch !== void 0)
594
+ this.opts.minResultsForSearch = Math.max(0, Math.floor(options.minResultsForSearch));
595
+ if (options.isOptionDisabled !== void 0) this.opts.isOptionDisabled = options.isOptionDisabled;
596
+ if (options.virtualScroll !== void 0) this.opts.virtualScroll = options.virtualScroll;
597
+ if (options.itemHeight !== void 0) {
598
+ this.opts.variableItemHeight = options.itemHeight === "auto";
599
+ if (typeof options.itemHeight === "number") this.opts.itemHeight = Math.max(1, options.itemHeight);
600
+ this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
601
+ this.portalHost?.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
602
+ }
603
+ if (options.language !== void 0) {
604
+ this.opts.language = options.language;
605
+ this.strings = getStrings(options.language);
606
+ this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
607
+ this.searchInput?.setAttribute("aria-label", this.strings.search);
608
+ }
609
+ if (options.openOnFocus !== void 0) this.opts.openOnFocus = options.openOnFocus;
610
+ if (options.disabled !== void 0) {
611
+ if (options.disabled) this.disable();
612
+ else this.enable();
613
+ }
614
+ this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
615
+ this.updateSearchVisibility();
616
+ this.rowContentCache.clear();
617
+ this.searchIndex.clear();
618
+ this.renderValue();
619
+ if (this.isOpen) this.renderList();
620
+ }
621
+ validate() {
622
+ const valid = (!this.opts.required || this.selected.length > 0) && (this.control.dataset.validationMessage ?? "") === "";
623
+ this.control.classList.toggle("forge-select__control--invalid", !valid);
624
+ this.control.setAttribute("aria-invalid", String(!valid));
625
+ return valid;
626
+ }
627
+ setCustomValidity(message) {
628
+ this.nativeSelect?.setCustomValidity(message);
629
+ this.control.dataset.validationMessage = message;
630
+ }
631
+ reportValidity() {
632
+ const valid = this.validate() && (this.nativeSelect?.checkValidity() ?? true);
633
+ if (!valid) {
634
+ const message = this.nativeSelect?.validationMessage ?? this.control.dataset.validationMessage ?? "";
635
+ if (this.nativeSelect) return this.nativeSelect.reportValidity();
636
+ this.emitter.emit("invalid", message);
637
+ }
638
+ return valid;
639
+ }
640
+ reload() {
641
+ if (!this.opts.ajax) return;
642
+ this.clearRemoteCache();
643
+ this.remoteLoaded = false;
644
+ this.scheduleRemoteLoad(this.query, 0);
645
+ }
646
+ clearRemoteCache() {
647
+ this.remoteCache.clear();
648
+ }
382
649
  setValue(value, options = {}) {
383
650
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
384
651
  const next = this.opts.multiple ? values : values.slice(0, 1);
@@ -387,6 +654,53 @@ var ForgeSelect = class {
387
654
  for (const v of next) this.selectValue(v, false);
388
655
  this.afterSelectionChange(options.emitChange ?? true);
389
656
  }
657
+ /**
658
+ * Replaces the option list after construction. An open dropdown re-renders
659
+ * immediately; a selection whose value isn't in the new data stays
660
+ * selected (rendered via the already-selected option's own label/avatar,
661
+ * the same fallback used for values selected from a stale ajax page).
662
+ */
663
+ setData(data) {
664
+ if (this.ajaxTimer) {
665
+ clearTimeout(this.ajaxTimer);
666
+ this.ajaxTimer = null;
667
+ }
668
+ this.ajaxController?.abort();
669
+ this.ajaxController = null;
670
+ this.ajaxRequestId += 1;
671
+ this.setLoading(false);
672
+ this.loadingMore = false;
673
+ this.loadError = null;
674
+ this.remoteLoaded = true;
675
+ this.page = 0;
676
+ this.hasMore = false;
677
+ this.data = data;
678
+ this.opts.data = data;
679
+ this.updateSearchVisibility();
680
+ this.rowContentCache.clear();
681
+ this.searchIndex.clear();
682
+ this.highlightedIndex = -1;
683
+ if (this.isOpen) this.renderList();
684
+ }
685
+ /**
686
+ * Multi-select only: selects every currently non-disabled option, including
687
+ * nested tree descendants and options inside groups. If `maxSelections` is
688
+ * set, stops once the cap is reached rather than exceeding it. A no-op for
689
+ * single-select.
690
+ */
691
+ selectAll() {
692
+ if (!this.opts.multiple) return;
693
+ this.selected = [];
694
+ for (const value of this.allSelectableValues()) {
695
+ const option = this.findOption(value);
696
+ if (option && this.canSelectOption(option)) this.selectValue(value, false);
697
+ }
698
+ this.afterSelectionChange();
699
+ }
700
+ /** Clears every selection. Equivalent to `setValue(null)`. */
701
+ clearAll() {
702
+ this.clearSelection();
703
+ }
390
704
  enable() {
391
705
  this.isDisabled = false;
392
706
  this.root.classList.remove("forge-select--disabled");
@@ -432,7 +746,22 @@ var ForgeSelect = class {
432
746
  }
433
747
  }
434
748
  }
749
+ shouldShowSearch() {
750
+ return this.opts.searchable && (this.opts.ajax != null || collectValues(this.data).size >= this.opts.minResultsForSearch);
751
+ }
752
+ updateSearchVisibility() {
753
+ if (!this.searchInput) return;
754
+ this.searchInput.hidden = !this.shouldShowSearch();
755
+ if (this.searchInput.hidden) {
756
+ this.searchInput.value = "";
757
+ this.query = "";
758
+ }
759
+ }
435
760
  buildDom() {
761
+ const portalParent = typeof this.opts.dropdownParent === "string" ? document.querySelector(this.opts.dropdownParent) : this.opts.dropdownParent;
762
+ if (this.opts.dropdownParent && !portalParent) {
763
+ throw new Error(`ForgeSelect: dropdown parent not found: ${String(this.opts.dropdownParent)}`);
764
+ }
436
765
  this.root = document.createElement("div");
437
766
  this.root.className = "forge-select";
438
767
  this.root.dataset.theme = this.opts.theme;
@@ -444,6 +773,7 @@ var ForgeSelect = class {
444
773
  this.control.setAttribute("aria-haspopup", "listbox");
445
774
  this.control.setAttribute("aria-expanded", "false");
446
775
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
776
+ if (this.opts.required) this.control.setAttribute("aria-required", "true");
447
777
  this.control.tabIndex = 0;
448
778
  this.applyAccessibleName();
449
779
  this.valueEl = document.createElement("div");
@@ -468,6 +798,7 @@ var ForgeSelect = class {
468
798
  this.searchInput.setAttribute("aria-label", this.strings.search);
469
799
  this.searchInput.setAttribute("aria-autocomplete", "list");
470
800
  this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
801
+ this.searchInput.hidden = !this.shouldShowSearch();
471
802
  this.dropdown.append(this.searchInput);
472
803
  }
473
804
  this.list = document.createElement("ul");
@@ -480,9 +811,18 @@ var ForgeSelect = class {
480
811
  this.liveRegion.className = "forge-select__sr-only";
481
812
  this.liveRegion.setAttribute("role", "status");
482
813
  this.liveRegion.setAttribute("aria-live", "polite");
483
- this.root.append(this.control, this.dropdown, this.liveRegion);
814
+ this.root.append(this.control, this.liveRegion);
815
+ if (!portalParent) this.root.append(this.dropdown);
484
816
  this.el.style.display = "none";
485
817
  this.el.insertAdjacentElement("afterend", this.root);
818
+ if (portalParent) {
819
+ this.portalHost = document.createElement("div");
820
+ this.portalHost.className = "forge-select forge-select--portal-host";
821
+ this.portalHost.dataset.theme = this.opts.theme;
822
+ this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
823
+ this.portalHost.append(this.dropdown);
824
+ portalParent.append(this.portalHost);
825
+ }
486
826
  this.bindEvents();
487
827
  }
488
828
  bindEvents() {
@@ -497,23 +837,46 @@ var ForgeSelect = class {
497
837
  else this.open();
498
838
  });
499
839
  this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
840
+ this.control.addEventListener("mousedown", () => {
841
+ this.pointerDownOnControl = true;
842
+ });
843
+ this.control.addEventListener("focus", () => {
844
+ if (this.opts.openOnFocus && !this.pointerDownOnControl && !this.isOpen && !this.isDisabled) {
845
+ this.open();
846
+ }
847
+ this.pointerDownOnControl = false;
848
+ });
500
849
  this.clearBtn.addEventListener("click", (event) => {
501
850
  event.stopPropagation();
502
851
  this.clearSelection();
503
852
  });
504
853
  if (this.searchInput) {
505
854
  this.searchInput.addEventListener("input", () => {
506
- this.query = this.searchInput.value;
507
- this.highlightedIndex = -1;
508
- this.list.scrollTop = 0;
509
- this.emitter.emit("search", this.query);
510
- if (this.opts.ajax) {
511
- this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
512
- } else {
513
- this.renderList();
514
- }
855
+ this.applySearchQuery(this.searchInput.value, true);
515
856
  });
516
857
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
858
+ this.searchInput.addEventListener("paste", (event) => {
859
+ if (!this.opts.multiple || !this.opts.allowCreate) return;
860
+ const text = event.clipboardData?.getData("text") ?? "";
861
+ const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
862
+ if (labels.length < 2) return;
863
+ event.preventDefault();
864
+ const created = [];
865
+ for (const label of labels) {
866
+ const result = this.createTag(label);
867
+ if (result) created.push(result);
868
+ }
869
+ if (created.length === 0) return;
870
+ this.searchInput.value = "";
871
+ this.query = "";
872
+ this.afterSelectionChange();
873
+ for (const result of created) {
874
+ if (result.created) this.emitter.emit("create", result.option);
875
+ this.emitter.emit("select", result.option);
876
+ }
877
+ if (this.opts.closeOnSelect) this.close();
878
+ else this.renderList();
879
+ });
517
880
  }
518
881
  this.list.addEventListener("click", (event) => {
519
882
  const target = event.target;
@@ -526,7 +889,12 @@ var ForgeSelect = class {
526
889
  return;
527
890
  }
528
891
  const li = target.closest("li[data-nav-index]");
529
- if (!li) return;
892
+ if (!li) {
893
+ const optionRow = target.closest("li[data-option-value]");
894
+ const option = optionRow ? this.findOption(optionRow.dataset.optionValue) : void 0;
895
+ if (option && this.hasReachedMaximum() && !this.selected.includes(option.value)) this.announceMaximum(option);
896
+ return;
897
+ }
530
898
  const navIndex = Number(li.dataset.navIndex);
531
899
  this.activateNavItem(navIndex);
532
900
  });
@@ -535,6 +903,29 @@ var ForgeSelect = class {
535
903
  this.maybeLoadNextPage();
536
904
  });
537
905
  }
906
+ applySearchQuery(query, emitSearch) {
907
+ this.query = query;
908
+ if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
909
+ this.highlightedIndex = -1;
910
+ this.list.scrollTop = 0;
911
+ this.rowContentCache.clear();
912
+ if (emitSearch) this.emitter.emit("search", query);
913
+ const trimmed = query.trim();
914
+ const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
915
+ if (this.opts.ajax && !belowMinLength) {
916
+ this.scheduleRemoteLoad(query, this.opts.ajax.debounce ?? 250);
917
+ return;
918
+ }
919
+ if (belowMinLength) {
920
+ if (this.ajaxTimer) {
921
+ clearTimeout(this.ajaxTimer);
922
+ this.ajaxTimer = null;
923
+ }
924
+ this.ajaxController?.abort();
925
+ this.setLoading(false);
926
+ }
927
+ this.renderList();
928
+ }
538
929
  handleKeydown(event) {
539
930
  if (this.isDisabled) return;
540
931
  switch (event.key) {
@@ -577,36 +968,61 @@ var ForgeSelect = class {
577
968
  }
578
969
  }
579
970
  // ---------------------------------------------------------------- selection
971
+ canSelectOption(option) {
972
+ if (this.opts.maxSelections == null) return true;
973
+ const projected = [...this.selected];
974
+ if (!projected.includes(option.value)) projected.push(option.value);
975
+ for (const value of collectDescendantValues(option, this.isOptionDisabled)) {
976
+ if (!projected.includes(value)) projected.push(value);
977
+ }
978
+ syncTreeAncestors(this.data, projected, this.isOptionDisabled);
979
+ return projected.length <= this.opts.maxSelections;
980
+ }
981
+ hasReachedMaximum() {
982
+ return this.opts.maxSelections != null && this.selected.length >= this.opts.maxSelections;
983
+ }
984
+ announceMaximum(option) {
985
+ const limit = this.opts.maxSelections;
986
+ if (limit == null) return;
987
+ this.liveRegion.textContent = format(this.strings.maximumSelected, { count: String(limit) });
988
+ this.emitter.emit("maximum", { limit, option });
989
+ }
580
990
  selectValue(value, notify) {
581
991
  if (this.selected.includes(value)) return;
582
992
  const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
583
993
  this.selectedOptions.set(value, option);
584
994
  if (this.opts.multiple) {
585
995
  this.selected.push(value);
586
- for (const v of collectDescendantValues(option)) {
996
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
587
997
  if (!this.selected.includes(v)) this.selected.push(v);
588
998
  }
589
999
  this.syncTreeAncestors();
590
1000
  } else {
591
1001
  this.selected = [value];
592
1002
  }
593
- if (notify) this.afterSelectionChange();
1003
+ if (notify) {
1004
+ this.afterSelectionChange();
1005
+ this.emitter.emit("select", option);
1006
+ }
594
1007
  }
595
1008
  deselectValue(value, notify) {
596
1009
  const index = this.selected.indexOf(value);
597
1010
  if (index === -1) return;
1011
+ const option = this.findOption(value) ?? this.selectedOptions.get(value);
598
1012
  this.selected.splice(index, 1);
599
1013
  if (this.opts.multiple) {
600
- const option = this.findOption(value) ?? this.selectedOptions.get(value);
601
1014
  if (option) {
602
- for (const v of collectDescendantValues(option)) {
1015
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
603
1016
  const i = this.selected.indexOf(v);
604
1017
  if (i !== -1) this.selected.splice(i, 1);
605
1018
  }
606
1019
  }
607
1020
  this.syncTreeAncestors();
608
1021
  }
609
- if (notify) this.afterSelectionChange();
1022
+ if (notify) {
1023
+ this.afterSelectionChange();
1024
+ this.emitter.emit("unselect", option ?? { value, label: value });
1025
+ }
610
1026
  }
611
1027
  /**
612
1028
  * Keeps every tree parent's own membership in `selected` consistent with
@@ -615,7 +1031,7 @@ var ForgeSelect = class {
615
1031
  * No-op for data with no `children` anywhere.
616
1032
  */
617
1033
  syncTreeAncestors() {
618
- syncTreeAncestors(this.data, this.selected);
1034
+ syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
619
1035
  }
620
1036
  clearSelection() {
621
1037
  if (this.selected.length === 0) return;
@@ -623,9 +1039,22 @@ var ForgeSelect = class {
623
1039
  this.emitter.emit("clear");
624
1040
  this.afterSelectionChange();
625
1041
  }
1042
+ allSelectableValues() {
1043
+ const values = [];
1044
+ const visit = (option) => {
1045
+ if (!this.isOptionDisabled(option)) values.push(option.value);
1046
+ option.children?.forEach(visit);
1047
+ };
1048
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
1049
+ return values;
1050
+ }
626
1051
  afterSelectionChange(emitChange = true) {
627
1052
  this.renderValue();
628
1053
  this.syncNativeSelect(emitChange);
1054
+ if (!this.opts.required || this.selected.length > 0) {
1055
+ this.control.classList.remove("forge-select__control--invalid");
1056
+ this.control.removeAttribute("aria-invalid");
1057
+ }
629
1058
  if (this.isOpen) this.renderList();
630
1059
  if (emitChange) this.emitter.emit("change", this.getValue());
631
1060
  }
@@ -661,17 +1090,58 @@ var ForgeSelect = class {
661
1090
  findOption(value) {
662
1091
  return findOption(this.data, value);
663
1092
  }
1093
+ findOptionByLabel(label) {
1094
+ const lower = label.toLowerCase();
1095
+ const search = (options) => {
1096
+ for (const option of options) {
1097
+ if (option.label.toLowerCase() === lower) return option;
1098
+ const found = option.children ? search(option.children) : void 0;
1099
+ if (found) return found;
1100
+ }
1101
+ return void 0;
1102
+ };
1103
+ for (const item of this.data) {
1104
+ const found = search(isGroup(item) ? item.options : [item]);
1105
+ if (found) return found;
1106
+ }
1107
+ return void 0;
1108
+ }
1109
+ /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
1110
+ createTag(label) {
1111
+ const trimmed = label.trim();
1112
+ if (!trimmed) return void 0;
1113
+ const existing = this.findOptionByLabel(trimmed);
1114
+ if (existing) {
1115
+ if (this.selected.includes(existing.value)) return void 0;
1116
+ if (this.opts.multiple && !this.canSelectOption(existing)) {
1117
+ this.announceMaximum(existing);
1118
+ return void 0;
1119
+ }
1120
+ this.selectValue(existing.value, false);
1121
+ return { option: existing, created: false };
1122
+ }
1123
+ const option = { value: trimmed, label: trimmed };
1124
+ if (this.opts.multiple && !this.canSelectOption(option)) {
1125
+ this.announceMaximum(option);
1126
+ return void 0;
1127
+ }
1128
+ this.data.push(option);
1129
+ this.selectValue(option.value, false);
1130
+ return { option, created: true };
1131
+ }
664
1132
  createFromQuery() {
665
1133
  const label = this.query.trim();
666
1134
  if (!label) return;
667
- const option = { value: label, label };
668
- this.data.push(option);
1135
+ const result = this.createTag(label);
1136
+ if (!result) return;
669
1137
  if (this.searchInput) {
670
1138
  this.searchInput.value = "";
671
1139
  this.query = "";
672
1140
  }
673
- this.selectValue(option.value, true);
674
- if (!this.opts.multiple) this.close();
1141
+ this.afterSelectionChange();
1142
+ if (result.created) this.emitter.emit("create", result.option);
1143
+ this.emitter.emit("select", result.option);
1144
+ if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
675
1145
  }
676
1146
  activateNavItem(navIndex) {
677
1147
  const item = this.navItems[navIndex];
@@ -682,8 +1152,17 @@ var ForgeSelect = class {
682
1152
  }
683
1153
  const { value } = item.option;
684
1154
  if (this.opts.multiple) {
685
- if (this.selected.includes(value)) this.deselectValue(value, true);
686
- else this.selectValue(value, true);
1155
+ let changed = false;
1156
+ if (this.selected.includes(value)) {
1157
+ this.deselectValue(value, true);
1158
+ changed = true;
1159
+ } else if (this.canSelectOption(item.option)) {
1160
+ this.selectValue(value, true);
1161
+ changed = true;
1162
+ } else {
1163
+ this.announceMaximum(item.option);
1164
+ }
1165
+ if (changed && this.opts.closeOnSelect) this.close();
687
1166
  } else {
688
1167
  this.selectValue(value, true);
689
1168
  this.close();
@@ -799,6 +1278,7 @@ var ForgeSelect = class {
799
1278
  this.selected = order;
800
1279
  this.suppressNextTagClick = true;
801
1280
  this.afterSelectionChange();
1281
+ this.emitter.emit("reorder", [...this.selected]);
802
1282
  };
803
1283
  tag.addEventListener("pointerdown", (event) => {
804
1284
  if (this.isDisabled || event.button !== 0) return;
@@ -822,6 +1302,7 @@ var ForgeSelect = class {
822
1302
  [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
823
1303
  this.selected = next;
824
1304
  this.afterSelectionChange();
1305
+ this.emitter.emit("reorder", [...this.selected]);
825
1306
  this.focusTagByValue(value);
826
1307
  }
827
1308
  focusTagByValue(value) {
@@ -835,12 +1316,19 @@ var ForgeSelect = class {
835
1316
  buildRows() {
836
1317
  this.rows = [];
837
1318
  this.navItems = [];
838
- const query = this.query.trim().toLowerCase();
839
- const matches = (option) => query === "" || option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false);
1319
+ const trimmedQuery = this.query.trim();
1320
+ const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1321
+ const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
1322
+ fields: this.opts.searchFields,
1323
+ tokenSearch: this.opts.tokenSearch,
1324
+ accentInsensitive: this.opts.accentInsensitive,
1325
+ scorer: this.opts.searchScorer
1326
+ }) > 0);
840
1327
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
841
1328
  const pushOption = (option, depth, parentValue) => {
842
1329
  let navIndex = -1;
843
- if (!option.disabled) {
1330
+ const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
1331
+ if (!interactionDisabled) {
844
1332
  navIndex = this.navItems.length;
845
1333
  this.navItems.push({ kind: "option", option, parentValue });
846
1334
  }
@@ -855,6 +1343,10 @@ var ForgeSelect = class {
855
1343
  }
856
1344
  }
857
1345
  };
1346
+ if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
1347
+ this.rows.push({ kind: "min-length" });
1348
+ return;
1349
+ }
858
1350
  if (this.loading) {
859
1351
  this.rows.push({ kind: "loading" });
860
1352
  return;
@@ -882,16 +1374,30 @@ var ForgeSelect = class {
882
1374
  else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
883
1375
  }
884
1376
  hasExactMatch(lowerQuery) {
885
- const matchesExactly = (option) => option.label.toLowerCase() === lowerQuery || (option.children ?? []).some(matchesExactly);
886
- for (const item of this.data) {
887
- const options = isGroup(item) ? item.options : [item];
888
- if (options.some(matchesExactly)) return true;
889
- }
890
- return false;
1377
+ return !!this.findOptionByLabel(lowerQuery);
891
1378
  }
892
1379
  usesVirtualScroll() {
893
1380
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
894
1381
  }
1382
+ rowKey(row, index) {
1383
+ if (row.kind === "option") return `option:${row.option.value}`;
1384
+ if (row.kind === "group") return `group:${row.label}:${index}`;
1385
+ return `${row.kind}:${index}`;
1386
+ }
1387
+ measuredRowHeight(index) {
1388
+ return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
1389
+ }
1390
+ rowOffset(index) {
1391
+ if (!this.opts.variableItemHeight) return index * this.opts.itemHeight;
1392
+ let offset = 0;
1393
+ for (let i = 0; i < index; i += 1) offset += this.measuredRowHeight(i);
1394
+ return offset;
1395
+ }
1396
+ rowOffsets() {
1397
+ const offsets = [0];
1398
+ for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1399
+ return offsets;
1400
+ }
895
1401
  renderList() {
896
1402
  this.buildRows();
897
1403
  this.renderRows();
@@ -899,7 +1405,7 @@ var ForgeSelect = class {
899
1405
  }
900
1406
  announceStatus() {
901
1407
  const first = this.rows[0];
902
- const message = first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : "";
1408
+ const message = this.hasReachedMaximum() ? format(this.strings.maximumSelected, { count: String(this.opts.maxSelections) }) : first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : first?.kind === "min-length" ? format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) }) : "";
903
1409
  if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
904
1410
  }
905
1411
  renderRows() {
@@ -908,26 +1414,40 @@ var ForgeSelect = class {
908
1414
  const virtual = this.usesVirtualScroll();
909
1415
  this.list.textContent = "";
910
1416
  const rowHeight = this.opts.itemHeight;
1417
+ const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
911
1418
  let start = 0;
912
1419
  let end = this.rows.length;
913
1420
  if (virtual) {
914
1421
  const viewport = clientHeight || rowHeight * 8;
915
- start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
916
- end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1422
+ if (this.opts.variableItemHeight) {
1423
+ while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1424
+ start = Math.max(0, start - VIRTUAL_BUFFER);
1425
+ end = start;
1426
+ const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
1427
+ while (end < this.rows.length && offsets[end] < target) end += 1;
1428
+ } else {
1429
+ start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1430
+ end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1431
+ }
917
1432
  const topSpacer = document.createElement("li");
918
1433
  topSpacer.className = "forge-select__spacer";
919
1434
  topSpacer.setAttribute("aria-hidden", "true");
920
- topSpacer.style.height = `${start * rowHeight}px`;
1435
+ topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
921
1436
  this.list.append(topSpacer);
922
1437
  }
923
1438
  for (let i = start; i < end; i++) {
924
- this.list.append(this.renderRow(this.rows[i]));
1439
+ const element = this.renderRow(this.rows[i]);
1440
+ this.list.append(element);
1441
+ if (this.opts.variableItemHeight) {
1442
+ const measured = element.getBoundingClientRect().height || element.offsetHeight;
1443
+ if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
1444
+ }
925
1445
  }
926
1446
  if (virtual) {
927
1447
  const bottomSpacer = document.createElement("li");
928
1448
  bottomSpacer.className = "forge-select__spacer";
929
1449
  bottomSpacer.setAttribute("aria-hidden", "true");
930
- bottomSpacer.style.height = `${(this.rows.length - end) * rowHeight}px`;
1450
+ bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
931
1451
  this.list.append(bottomSpacer);
932
1452
  if (this.list.scrollTop !== scrollTop) {
933
1453
  this.list.scrollTop = scrollTop;
@@ -950,6 +1470,13 @@ var ForgeSelect = class {
950
1470
  li.setAttribute("aria-selected", "false");
951
1471
  li.textContent = this.strings.noResults;
952
1472
  break;
1473
+ case "min-length":
1474
+ li.className = "forge-select__min-length";
1475
+ li.setAttribute("role", "option");
1476
+ li.setAttribute("aria-disabled", "true");
1477
+ li.setAttribute("aria-selected", "false");
1478
+ li.textContent = format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) });
1479
+ break;
953
1480
  case "error":
954
1481
  li.className = "forge-select__error";
955
1482
  li.setAttribute("role", "option");
@@ -979,17 +1506,20 @@ var ForgeSelect = class {
979
1506
  break;
980
1507
  case "option": {
981
1508
  li.className = "forge-select__option";
1509
+ li.dataset.optionValue = row.option.value;
1510
+ if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
982
1511
  li.setAttribute("role", "option");
983
1512
  const isSelected = this.selected.includes(row.option.value);
984
1513
  li.setAttribute("aria-selected", String(isSelected));
985
1514
  if (isSelected) li.classList.add("forge-select__option--selected");
986
- if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
1515
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
987
1516
  li.classList.add("forge-select__option--indeterminate");
1517
+ li.dataset.selectionState = "mixed";
988
1518
  }
989
1519
  if (row.depth > 0) {
990
1520
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
991
1521
  }
992
- if (row.option.disabled) {
1522
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
993
1523
  li.classList.add("forge-select__option--disabled");
994
1524
  li.setAttribute("aria-disabled", "true");
995
1525
  } else {
@@ -1020,6 +1550,28 @@ var ForgeSelect = class {
1020
1550
  * cached content state-free.
1021
1551
  */
1022
1552
  optionContent(option) {
1553
+ if (this.opts.highlightSearch && this.query.trim() && !this.opts.templateResult) {
1554
+ const holder = document.createElement("span");
1555
+ holder.className = "forge-select__option-content";
1556
+ renderOptionContent(holder, option, void 0);
1557
+ const label = holder.querySelector(".forge-select__option-label") ?? holder;
1558
+ const ranges = findNormalizedRanges(option.label, this.query, this.opts.accentInsensitive);
1559
+ if (ranges.length) {
1560
+ label.textContent = "";
1561
+ let cursor = 0;
1562
+ for (const [start, end] of ranges) {
1563
+ if (start < cursor) continue;
1564
+ label.append(document.createTextNode(option.label.slice(cursor, start)));
1565
+ const mark = document.createElement("mark");
1566
+ mark.className = "forge-select__match";
1567
+ mark.textContent = option.label.slice(start, end);
1568
+ label.append(mark);
1569
+ cursor = end;
1570
+ }
1571
+ label.append(document.createTextNode(option.label.slice(cursor)));
1572
+ }
1573
+ return holder;
1574
+ }
1023
1575
  let cached = this.rowContentCache.get(option.value);
1024
1576
  if (!cached) {
1025
1577
  const holder = document.createElement("span");
@@ -1046,8 +1598,8 @@ var ForgeSelect = class {
1046
1598
  (row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
1047
1599
  );
1048
1600
  if (rowIndex >= 0) {
1049
- const rowHeight = this.opts.itemHeight;
1050
- const top = rowIndex * rowHeight;
1601
+ const rowHeight = this.measuredRowHeight(rowIndex);
1602
+ const top = this.rowOffset(rowIndex);
1051
1603
  const viewport = this.list.clientHeight || rowHeight * 8;
1052
1604
  let target = this.list.scrollTop;
1053
1605
  if (top < target) target = top;
@@ -1112,7 +1664,7 @@ var ForgeSelect = class {
1112
1664
  this.ajaxController = null;
1113
1665
  this.page = 0;
1114
1666
  this.hasMore = true;
1115
- this.loading = true;
1667
+ this.setLoading(true);
1116
1668
  this.loadingMore = false;
1117
1669
  this.loadError = null;
1118
1670
  this.renderList();
@@ -1121,6 +1673,55 @@ var ForgeSelect = class {
1121
1673
  void this.loadRemote(query, { requestId });
1122
1674
  }, delay);
1123
1675
  }
1676
+ setLoading(loading) {
1677
+ if (this.loading === loading) return;
1678
+ this.loading = loading;
1679
+ this.emitter.emit("loading", loading);
1680
+ }
1681
+ remoteCacheKey(query, page) {
1682
+ return `${query}\0${page}`;
1683
+ }
1684
+ async requestRemote(query, page, signal) {
1685
+ const ajax = this.opts.ajax;
1686
+ const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1687
+ let lastError;
1688
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1689
+ try {
1690
+ if (ajax.request) return await ajax.request(query, page, signal);
1691
+ const response = await fetch(buildUrl(ajax, query, page), { signal });
1692
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1693
+ return await response.json();
1694
+ } catch (error) {
1695
+ lastError = error;
1696
+ if (signal.aborted || attempt === attempts - 1) throw error;
1697
+ const delay = Math.max(0, ajax.retryDelay ?? 250) * 2 ** attempt;
1698
+ await new Promise((resolve, reject) => {
1699
+ const timer = setTimeout(resolve, delay);
1700
+ signal.addEventListener(
1701
+ "abort",
1702
+ () => {
1703
+ clearTimeout(timer);
1704
+ reject(new DOMException("Aborted", "AbortError"));
1705
+ },
1706
+ { once: true }
1707
+ );
1708
+ });
1709
+ }
1710
+ }
1711
+ throw lastError;
1712
+ }
1713
+ async prefetchRemote(query) {
1714
+ const ajax = this.opts.ajax;
1715
+ if (!ajax || (ajax.cacheTtl ?? 3e4) <= 0) return;
1716
+ const key = this.remoteCacheKey(query, 0);
1717
+ if (this.remoteCache.get(key)) return;
1718
+ const controller = new AbortController();
1719
+ try {
1720
+ const json = await this.requestRemote(query, 0, controller.signal);
1721
+ this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1722
+ } catch {
1723
+ }
1724
+ }
1124
1725
  /**
1125
1726
  * Fires on every list scroll. Only acts when pagination is opted into via
1126
1727
  * `ajax.pagination`; reads real scroll geometry rather than row counts so
@@ -1145,12 +1746,15 @@ var ForgeSelect = class {
1145
1746
  this.ajaxController = controller;
1146
1747
  const page = append ? this.page + 1 : 0;
1147
1748
  try {
1148
- const url = buildUrl(ajax, query, page);
1149
- const response = await fetch(url, { signal: controller.signal });
1150
- if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1151
- const json = await response.json();
1749
+ const key = this.remoteCacheKey(query, page);
1750
+ let result = this.remoteCache.get(key);
1751
+ if (!result) {
1752
+ const json = await this.requestRemote(query, page, controller.signal);
1753
+ result = normalizeRemoteResult(ajax, json);
1754
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1755
+ }
1152
1756
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1153
- const { options, hasMore } = normalizeRemoteResult(ajax, json);
1757
+ const { options, hasMore } = result;
1154
1758
  if (append) {
1155
1759
  const existing = collectValues(this.data);
1156
1760
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
@@ -1175,7 +1779,7 @@ var ForgeSelect = class {
1175
1779
  } finally {
1176
1780
  if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1177
1781
  this.ajaxController = null;
1178
- this.loading = false;
1782
+ this.setLoading(false);
1179
1783
  this.loadingMore = false;
1180
1784
  if (this.isOpen) this.renderList();
1181
1785
  }