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.js CHANGED
@@ -24,6 +24,17 @@ var Emitter = class {
24
24
  }
25
25
  };
26
26
 
27
+ // src/dropdown-position.ts
28
+ function computeDropdownPlacement(controlRect, dropdownHeight, viewportHeight, gap = 4) {
29
+ const spaceBelow = viewportHeight - controlRect.bottom;
30
+ const spaceAbove = controlRect.top;
31
+ const dropUp = dropdownHeight > spaceBelow && spaceAbove > spaceBelow;
32
+ return {
33
+ dropUp,
34
+ top: dropUp ? controlRect.top - dropdownHeight - gap : controlRect.bottom + gap
35
+ };
36
+ }
37
+
27
38
  // src/i18n.ts
28
39
  var locales = {
29
40
  en: {
@@ -35,7 +46,9 @@ var locales = {
35
46
  clearSelection: "Clear selection",
36
47
  removeItem: "Remove {label}",
37
48
  search: "Search",
38
- reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
49
+ reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder.",
50
+ minSearchLength: "Type {count} or more characters to search",
51
+ maximumSelected: "Maximum of {count} selections reached"
39
52
  },
40
53
  vi: {
41
54
  noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
@@ -46,7 +59,9 @@ var locales = {
46
59
  clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
47
60
  removeItem: "X\xF3a {label}",
48
61
  search: "T\xECm ki\u1EBFm",
49
- reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i."
62
+ reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i.",
63
+ minSearchLength: "Nh\u1EADp th\xEAm {count} k\xFD t\u1EF1 \u0111\u1EC3 t\xECm ki\u1EBFm",
64
+ maximumSelected: "\u0110\xE3 \u0111\u1EA1t t\u1ED1i \u0111a {count} l\u1EF1a ch\u1ECDn"
50
65
  }
51
66
  };
52
67
  function getStrings(language) {
@@ -122,6 +137,7 @@ function renderOptionContent(container, option, template, variant = "row") {
122
137
 
123
138
  // src/remote.ts
124
139
  function buildUrl(ajax, query, page) {
140
+ if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
125
141
  if (typeof ajax.url === "function") return ajax.url(query, page);
126
142
  if (!ajax.params) return ajax.url;
127
143
  const params = new URLSearchParams();
@@ -140,22 +156,106 @@ function normalizeRemoteResult(ajax, response) {
140
156
  return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
141
157
  }
142
158
 
159
+ // src/remote-cache.ts
160
+ var RemoteCache = class {
161
+ constructor() {
162
+ this.entries = /* @__PURE__ */ new Map();
163
+ }
164
+ get(key, now = Date.now()) {
165
+ const entry = this.entries.get(key);
166
+ if (!entry) return void 0;
167
+ if (entry.expiresAt <= now) {
168
+ this.entries.delete(key);
169
+ return void 0;
170
+ }
171
+ return entry.value;
172
+ }
173
+ set(key, value, ttl, now = Date.now()) {
174
+ if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
175
+ }
176
+ clear() {
177
+ this.entries.clear();
178
+ }
179
+ };
180
+
181
+ // src/search.ts
182
+ function normalizeSearchText(value, accentInsensitive = true) {
183
+ const lower = value.toLocaleLowerCase();
184
+ return accentInsensitive ? lower.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d") : lower;
185
+ }
186
+ function getSearchField(option, field) {
187
+ if (field === "label") return option.label;
188
+ if (field === "description") return option.description ?? "";
189
+ const path = field.slice(5).split(".");
190
+ let value = option.meta;
191
+ for (const key of path) {
192
+ if (!value || typeof value !== "object") return "";
193
+ value = value[key];
194
+ }
195
+ return value == null ? "" : String(value);
196
+ }
197
+ var SearchIndex = class {
198
+ constructor() {
199
+ this.cache = /* @__PURE__ */ new WeakMap();
200
+ }
201
+ clear() {
202
+ this.cache = /* @__PURE__ */ new WeakMap();
203
+ }
204
+ score(option, query, config) {
205
+ const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
206
+ if (!normalizedQuery) return 1;
207
+ if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
208
+ const key = `${config.accentInsensitive ? "1" : "0"}:${config.fields.join("\0")}`;
209
+ let variants = this.cache.get(option);
210
+ if (!variants) {
211
+ variants = /* @__PURE__ */ new Map();
212
+ this.cache.set(option, variants);
213
+ }
214
+ let haystacks = variants.get(key);
215
+ if (!haystacks) {
216
+ haystacks = config.fields.map(
217
+ (field) => normalizeSearchText(getSearchField(option, field), config.accentInsensitive)
218
+ );
219
+ variants.set(key, haystacks);
220
+ }
221
+ const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
222
+ if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
223
+ const label = haystacks[config.fields.indexOf("label")];
224
+ if (label === normalizedQuery) return 4;
225
+ if (label?.startsWith(normalizedQuery)) return 3;
226
+ if (label?.includes(normalizedQuery)) return 2;
227
+ return 1;
228
+ }
229
+ };
230
+ function findNormalizedRanges(label, query, accentInsensitive = true) {
231
+ const tokens = normalizeSearchText(query.trim(), accentInsensitive).split(/\s+/).filter(Boolean);
232
+ if (!tokens.length) return [];
233
+ const normalized = normalizeSearchText(label, accentInsensitive);
234
+ const ranges = [];
235
+ for (const token of tokens) {
236
+ const index = normalized.indexOf(token);
237
+ if (index >= 0) ranges.push([index, index + token.length]);
238
+ }
239
+ return ranges.sort((a, b) => a[0] - b[0]);
240
+ }
241
+
143
242
  // src/selection.ts
144
243
  function isGroup(item) {
145
244
  return item.options !== void 0;
146
245
  }
147
- function collectDescendantValues(option) {
246
+ var defaultIsDisabled = (option) => !!option.disabled;
247
+ function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
148
248
  if (!option.children) return [];
149
249
  const values = [];
150
250
  for (const child of option.children) {
151
- if (!child.disabled) values.push(child.value);
152
- values.push(...collectDescendantValues(child));
251
+ if (!isDisabled(child)) values.push(child.value);
252
+ values.push(...collectDescendantValues(child, isDisabled));
153
253
  }
154
254
  return values;
155
255
  }
156
- function computeCheckState(option, selected) {
256
+ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
157
257
  if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
158
- const states = option.children.filter((child) => !child.disabled).map((child) => computeCheckState(child, selected));
258
+ const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
159
259
  if (states.length === 0) return "none";
160
260
  if (states.every((state) => state === "all")) return "all";
161
261
  if (states.every((state) => state === "none")) return "none";
@@ -176,11 +276,11 @@ function findOption(items, value) {
176
276
  }
177
277
  return void 0;
178
278
  }
179
- function syncTreeAncestors(items, selected) {
279
+ function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
180
280
  const sync = (option) => {
181
281
  if (!option.children?.length) return;
182
282
  for (const child of option.children) sync(child);
183
- const state = computeCheckState(option, selected);
283
+ const state = computeCheckState(option, selected, isDisabled);
184
284
  const index = selected.indexOf(option.value);
185
285
  if (state === "all" && index === -1) selected.push(option.value);
186
286
  else if (state !== "all" && index !== -1) selected.splice(index, 1);
@@ -214,6 +314,7 @@ var ForgeSelect = class {
214
314
  this.emitter = new Emitter();
215
315
  this.uid = `forge-select-${++uidCounter}`;
216
316
  this.searchInput = null;
317
+ this.portalHost = null;
217
318
  this.isOpen = false;
218
319
  this.isDisabled = false;
219
320
  this.destroyed = false;
@@ -222,6 +323,8 @@ var ForgeSelect = class {
222
323
  this.navItems = [];
223
324
  this.highlightedIndex = -1;
224
325
  this.rowContentCache = /* @__PURE__ */ new Map();
326
+ this.rowHeightCache = /* @__PURE__ */ new Map();
327
+ this.searchIndex = new SearchIndex();
225
328
  this.expandedValues = /* @__PURE__ */ new Set();
226
329
  this.loading = false;
227
330
  this.loadingMore = false;
@@ -231,14 +334,33 @@ var ForgeSelect = class {
231
334
  this.ajaxRequestId = 0;
232
335
  this.ajaxController = null;
233
336
  this.remoteLoaded = false;
337
+ this.remoteCache = new RemoteCache();
234
338
  this.loadError = null;
235
339
  this.originalDisplay = "";
236
340
  this.originalDisabled = false;
237
341
  this.nativeSelect = null;
238
342
  this.nativeForm = null;
239
343
  this.syncingNative = false;
344
+ /** Combines the static `disabled` field with the dynamic `isOptionDisabled` callback. */
345
+ this.isOptionDisabled = (option) => option.disabled === true || (this.opts.isOptionDisabled?.(option) ?? false);
346
+ this.pointerDownOnControl = false;
240
347
  this.onDocumentMouseDown = (event) => {
241
- if (!this.root.contains(event.target)) this.close();
348
+ const target = event.target;
349
+ if (!this.root.contains(target) && !this.portalHost?.contains(target)) this.close();
350
+ };
351
+ this.onWindowResize = () => {
352
+ this.positionDropdown();
353
+ };
354
+ this.onAncestorScroll = () => {
355
+ if (this.portalHost) this.positionDropdown();
356
+ };
357
+ this.onNativeInvalid = (event) => {
358
+ event.preventDefault();
359
+ this.control.classList.add("forge-select__control--invalid");
360
+ this.control.setAttribute("aria-invalid", "true");
361
+ if (!this.isOpen) this.open();
362
+ this.control.focus();
363
+ this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
242
364
  };
243
365
  this.onNativeChange = () => {
244
366
  if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
@@ -267,19 +389,35 @@ var ForgeSelect = class {
267
389
  clearable: options.clearable ?? false,
268
390
  allowCreate: options.allowCreate ?? false,
269
391
  sortable: options.sortable ?? false,
392
+ closeOnSelect: options.closeOnSelect ?? false,
393
+ maxSelections: options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections)),
270
394
  theme: options.theme ?? "default",
271
395
  disabled: options.disabled ?? nativeSelect?.disabled ?? false,
396
+ required: options.required ?? nativeSelect?.required ?? false,
272
397
  data: options.data,
273
398
  ajax: options.ajax,
274
399
  templateResult: options.templateResult,
275
400
  templateSelection: options.templateSelection,
401
+ filterOption: options.filterOption,
402
+ searchFields: options.searchFields ?? ["label", "description"],
403
+ tokenSearch: options.tokenSearch ?? true,
404
+ accentInsensitive: options.accentInsensitive ?? true,
405
+ searchScorer: options.searchScorer,
406
+ highlightSearch: options.highlightSearch ?? false,
407
+ minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
408
+ minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
409
+ isOptionDisabled: options.isOptionDisabled,
276
410
  virtualScroll: options.virtualScroll,
277
- itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
411
+ itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
412
+ variableItemHeight: options.itemHeight === "auto",
278
413
  language: options.language ?? "en",
279
- plugins: options.plugins ?? []
414
+ plugins: options.plugins ?? [],
415
+ openOnFocus: options.openOnFocus ?? false,
416
+ dropdownParent: options.dropdownParent
280
417
  };
281
418
  this.strings = getStrings(this.opts.language);
282
419
  this.plugins = this.opts.plugins;
420
+ if (nativeSelect) nativeSelect.required = this.opts.required;
283
421
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
284
422
  if (nativeSelect && !this.opts.data) {
285
423
  const nativeOptions = Array.from(nativeSelect.options);
@@ -292,8 +430,10 @@ var ForgeSelect = class {
292
430
  this.renderValue();
293
431
  if (this.opts.disabled) this.disable();
294
432
  nativeSelect?.addEventListener("change", this.onNativeChange);
433
+ nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
295
434
  this.nativeForm?.addEventListener("reset", this.onFormReset);
296
435
  for (const plugin of this.plugins) plugin.onInit?.(this);
436
+ for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
297
437
  }
298
438
  applyNativeValues(values) {
299
439
  this.selected = [];
@@ -310,11 +450,14 @@ var ForgeSelect = class {
310
450
  this.root.classList.add("forge-select--open");
311
451
  this.control.setAttribute("aria-expanded", "true");
312
452
  document.addEventListener("mousedown", this.onDocumentMouseDown);
313
- if (this.opts.ajax && !this.remoteLoaded) {
453
+ if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
314
454
  this.scheduleRemoteLoad(this.query, 0);
315
455
  }
316
456
  this.renderList();
317
- if (this.searchInput) this.searchInput.focus();
457
+ this.positionDropdown();
458
+ window.addEventListener("resize", this.onWindowResize);
459
+ document.addEventListener("scroll", this.onAncestorScroll, true);
460
+ if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
318
461
  this.emitter.emit("open");
319
462
  for (const plugin of this.plugins) plugin.onOpen?.(this);
320
463
  }
@@ -323,8 +466,11 @@ var ForgeSelect = class {
323
466
  this.isOpen = false;
324
467
  this.dropdown.hidden = true;
325
468
  this.root.classList.remove("forge-select--open");
469
+ this.root.classList.remove("forge-select--drop-up");
326
470
  this.control.setAttribute("aria-expanded", "false");
327
471
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
472
+ window.removeEventListener("resize", this.onWindowResize);
473
+ document.removeEventListener("scroll", this.onAncestorScroll, true);
328
474
  this.highlightedIndex = -1;
329
475
  if (this.searchInput) {
330
476
  this.searchInput.value = "";
@@ -333,6 +479,23 @@ var ForgeSelect = class {
333
479
  this.emitter.emit("close");
334
480
  for (const plugin of this.plugins) plugin.onClose?.(this);
335
481
  }
482
+ /**
483
+ * Flips the dropdown above the control when there isn't enough room below
484
+ * but there is above. Recomputed on open() and on window resize — the
485
+ * dropdown is positioned absolutely inside the relatively-positioned root,
486
+ * so it already tracks the control correctly on page scroll without
487
+ * needing a scroll listener.
488
+ */
489
+ positionDropdown() {
490
+ const controlRect = this.control.getBoundingClientRect();
491
+ const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
492
+ this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
493
+ if (this.portalHost) {
494
+ this.portalHost.style.top = `${placement.top}px`;
495
+ this.portalHost.style.left = `${controlRect.left}px`;
496
+ this.portalHost.style.width = `${controlRect.width}px`;
497
+ }
498
+ }
336
499
  destroy() {
337
500
  if (this.destroyed) return;
338
501
  this.close();
@@ -341,8 +504,12 @@ var ForgeSelect = class {
341
504
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
342
505
  this.ajaxController?.abort();
343
506
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
507
+ this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
344
508
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
345
509
  this.rowContentCache.clear();
510
+ this.rowHeightCache.clear();
511
+ this.searchIndex.clear();
512
+ this.portalHost?.remove();
346
513
  this.root.remove();
347
514
  this.el.style.display = this.originalDisplay;
348
515
  if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
@@ -352,6 +519,106 @@ var ForgeSelect = class {
352
519
  if (this.opts.multiple) return [...this.selected];
353
520
  return this.selected[0] ?? null;
354
521
  }
522
+ getSearchQuery() {
523
+ return this.query;
524
+ }
525
+ setSearchQuery(query, options = {}) {
526
+ this.applySearchQuery(query, options.emitSearch ?? true);
527
+ }
528
+ isDropdownOpen() {
529
+ return this.isOpen;
530
+ }
531
+ updateOptions(options) {
532
+ if (options.data) this.setData(options.data);
533
+ if ("ajax" in options && options.ajax !== this.opts.ajax) {
534
+ this.opts.ajax = options.ajax;
535
+ this.remoteLoaded = false;
536
+ this.clearRemoteCache();
537
+ }
538
+ if (options.placeholder !== void 0) this.opts.placeholder = options.placeholder;
539
+ if (options.clearable !== void 0) this.opts.clearable = options.clearable;
540
+ if (options.allowCreate !== void 0) this.opts.allowCreate = options.allowCreate;
541
+ if (options.sortable !== void 0) this.opts.sortable = options.sortable;
542
+ if (options.closeOnSelect !== void 0) this.opts.closeOnSelect = options.closeOnSelect;
543
+ if ("maxSelections" in options)
544
+ this.opts.maxSelections = options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections));
545
+ if (options.theme !== void 0) {
546
+ this.opts.theme = options.theme;
547
+ this.root.dataset.theme = options.theme;
548
+ if (this.portalHost) this.portalHost.dataset.theme = options.theme;
549
+ }
550
+ if (options.required !== void 0) {
551
+ this.opts.required = options.required;
552
+ if (options.required) this.control.setAttribute("aria-required", "true");
553
+ else this.control.removeAttribute("aria-required");
554
+ if (this.nativeSelect) this.nativeSelect.required = options.required;
555
+ }
556
+ if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
557
+ if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
558
+ if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
559
+ if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
560
+ if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
561
+ if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
562
+ if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
563
+ if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
564
+ if (options.minSearchLength !== void 0)
565
+ this.opts.minSearchLength = Math.max(0, Math.floor(options.minSearchLength));
566
+ if (options.minResultsForSearch !== void 0)
567
+ this.opts.minResultsForSearch = Math.max(0, Math.floor(options.minResultsForSearch));
568
+ if (options.isOptionDisabled !== void 0) this.opts.isOptionDisabled = options.isOptionDisabled;
569
+ if (options.virtualScroll !== void 0) this.opts.virtualScroll = options.virtualScroll;
570
+ if (options.itemHeight !== void 0) {
571
+ this.opts.variableItemHeight = options.itemHeight === "auto";
572
+ if (typeof options.itemHeight === "number") this.opts.itemHeight = Math.max(1, options.itemHeight);
573
+ this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
574
+ this.portalHost?.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
575
+ }
576
+ if (options.language !== void 0) {
577
+ this.opts.language = options.language;
578
+ this.strings = getStrings(options.language);
579
+ this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
580
+ this.searchInput?.setAttribute("aria-label", this.strings.search);
581
+ }
582
+ if (options.openOnFocus !== void 0) this.opts.openOnFocus = options.openOnFocus;
583
+ if (options.disabled !== void 0) {
584
+ if (options.disabled) this.disable();
585
+ else this.enable();
586
+ }
587
+ this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
588
+ this.updateSearchVisibility();
589
+ this.rowContentCache.clear();
590
+ this.searchIndex.clear();
591
+ this.renderValue();
592
+ if (this.isOpen) this.renderList();
593
+ }
594
+ validate() {
595
+ const valid = (!this.opts.required || this.selected.length > 0) && (this.control.dataset.validationMessage ?? "") === "";
596
+ this.control.classList.toggle("forge-select__control--invalid", !valid);
597
+ this.control.setAttribute("aria-invalid", String(!valid));
598
+ return valid;
599
+ }
600
+ setCustomValidity(message) {
601
+ this.nativeSelect?.setCustomValidity(message);
602
+ this.control.dataset.validationMessage = message;
603
+ }
604
+ reportValidity() {
605
+ const valid = this.validate() && (this.nativeSelect?.checkValidity() ?? true);
606
+ if (!valid) {
607
+ const message = this.nativeSelect?.validationMessage ?? this.control.dataset.validationMessage ?? "";
608
+ if (this.nativeSelect) return this.nativeSelect.reportValidity();
609
+ this.emitter.emit("invalid", message);
610
+ }
611
+ return valid;
612
+ }
613
+ reload() {
614
+ if (!this.opts.ajax) return;
615
+ this.clearRemoteCache();
616
+ this.remoteLoaded = false;
617
+ this.scheduleRemoteLoad(this.query, 0);
618
+ }
619
+ clearRemoteCache() {
620
+ this.remoteCache.clear();
621
+ }
355
622
  setValue(value, options = {}) {
356
623
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
357
624
  const next = this.opts.multiple ? values : values.slice(0, 1);
@@ -360,6 +627,53 @@ var ForgeSelect = class {
360
627
  for (const v of next) this.selectValue(v, false);
361
628
  this.afterSelectionChange(options.emitChange ?? true);
362
629
  }
630
+ /**
631
+ * Replaces the option list after construction. An open dropdown re-renders
632
+ * immediately; a selection whose value isn't in the new data stays
633
+ * selected (rendered via the already-selected option's own label/avatar,
634
+ * the same fallback used for values selected from a stale ajax page).
635
+ */
636
+ setData(data) {
637
+ if (this.ajaxTimer) {
638
+ clearTimeout(this.ajaxTimer);
639
+ this.ajaxTimer = null;
640
+ }
641
+ this.ajaxController?.abort();
642
+ this.ajaxController = null;
643
+ this.ajaxRequestId += 1;
644
+ this.setLoading(false);
645
+ this.loadingMore = false;
646
+ this.loadError = null;
647
+ this.remoteLoaded = true;
648
+ this.page = 0;
649
+ this.hasMore = false;
650
+ this.data = data;
651
+ this.opts.data = data;
652
+ this.updateSearchVisibility();
653
+ this.rowContentCache.clear();
654
+ this.searchIndex.clear();
655
+ this.highlightedIndex = -1;
656
+ if (this.isOpen) this.renderList();
657
+ }
658
+ /**
659
+ * Multi-select only: selects every currently non-disabled option, including
660
+ * nested tree descendants and options inside groups. If `maxSelections` is
661
+ * set, stops once the cap is reached rather than exceeding it. A no-op for
662
+ * single-select.
663
+ */
664
+ selectAll() {
665
+ if (!this.opts.multiple) return;
666
+ this.selected = [];
667
+ for (const value of this.allSelectableValues()) {
668
+ const option = this.findOption(value);
669
+ if (option && this.canSelectOption(option)) this.selectValue(value, false);
670
+ }
671
+ this.afterSelectionChange();
672
+ }
673
+ /** Clears every selection. Equivalent to `setValue(null)`. */
674
+ clearAll() {
675
+ this.clearSelection();
676
+ }
363
677
  enable() {
364
678
  this.isDisabled = false;
365
679
  this.root.classList.remove("forge-select--disabled");
@@ -405,7 +719,22 @@ var ForgeSelect = class {
405
719
  }
406
720
  }
407
721
  }
722
+ shouldShowSearch() {
723
+ return this.opts.searchable && (this.opts.ajax != null || collectValues(this.data).size >= this.opts.minResultsForSearch);
724
+ }
725
+ updateSearchVisibility() {
726
+ if (!this.searchInput) return;
727
+ this.searchInput.hidden = !this.shouldShowSearch();
728
+ if (this.searchInput.hidden) {
729
+ this.searchInput.value = "";
730
+ this.query = "";
731
+ }
732
+ }
408
733
  buildDom() {
734
+ const portalParent = typeof this.opts.dropdownParent === "string" ? document.querySelector(this.opts.dropdownParent) : this.opts.dropdownParent;
735
+ if (this.opts.dropdownParent && !portalParent) {
736
+ throw new Error(`ForgeSelect: dropdown parent not found: ${String(this.opts.dropdownParent)}`);
737
+ }
409
738
  this.root = document.createElement("div");
410
739
  this.root.className = "forge-select";
411
740
  this.root.dataset.theme = this.opts.theme;
@@ -417,6 +746,7 @@ var ForgeSelect = class {
417
746
  this.control.setAttribute("aria-haspopup", "listbox");
418
747
  this.control.setAttribute("aria-expanded", "false");
419
748
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
749
+ if (this.opts.required) this.control.setAttribute("aria-required", "true");
420
750
  this.control.tabIndex = 0;
421
751
  this.applyAccessibleName();
422
752
  this.valueEl = document.createElement("div");
@@ -441,6 +771,7 @@ var ForgeSelect = class {
441
771
  this.searchInput.setAttribute("aria-label", this.strings.search);
442
772
  this.searchInput.setAttribute("aria-autocomplete", "list");
443
773
  this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
774
+ this.searchInput.hidden = !this.shouldShowSearch();
444
775
  this.dropdown.append(this.searchInput);
445
776
  }
446
777
  this.list = document.createElement("ul");
@@ -453,9 +784,18 @@ var ForgeSelect = class {
453
784
  this.liveRegion.className = "forge-select__sr-only";
454
785
  this.liveRegion.setAttribute("role", "status");
455
786
  this.liveRegion.setAttribute("aria-live", "polite");
456
- this.root.append(this.control, this.dropdown, this.liveRegion);
787
+ this.root.append(this.control, this.liveRegion);
788
+ if (!portalParent) this.root.append(this.dropdown);
457
789
  this.el.style.display = "none";
458
790
  this.el.insertAdjacentElement("afterend", this.root);
791
+ if (portalParent) {
792
+ this.portalHost = document.createElement("div");
793
+ this.portalHost.className = "forge-select forge-select--portal-host";
794
+ this.portalHost.dataset.theme = this.opts.theme;
795
+ this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
796
+ this.portalHost.append(this.dropdown);
797
+ portalParent.append(this.portalHost);
798
+ }
459
799
  this.bindEvents();
460
800
  }
461
801
  bindEvents() {
@@ -470,23 +810,46 @@ var ForgeSelect = class {
470
810
  else this.open();
471
811
  });
472
812
  this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
813
+ this.control.addEventListener("mousedown", () => {
814
+ this.pointerDownOnControl = true;
815
+ });
816
+ this.control.addEventListener("focus", () => {
817
+ if (this.opts.openOnFocus && !this.pointerDownOnControl && !this.isOpen && !this.isDisabled) {
818
+ this.open();
819
+ }
820
+ this.pointerDownOnControl = false;
821
+ });
473
822
  this.clearBtn.addEventListener("click", (event) => {
474
823
  event.stopPropagation();
475
824
  this.clearSelection();
476
825
  });
477
826
  if (this.searchInput) {
478
827
  this.searchInput.addEventListener("input", () => {
479
- this.query = this.searchInput.value;
480
- this.highlightedIndex = -1;
481
- this.list.scrollTop = 0;
482
- this.emitter.emit("search", this.query);
483
- if (this.opts.ajax) {
484
- this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
485
- } else {
486
- this.renderList();
487
- }
828
+ this.applySearchQuery(this.searchInput.value, true);
488
829
  });
489
830
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
831
+ this.searchInput.addEventListener("paste", (event) => {
832
+ if (!this.opts.multiple || !this.opts.allowCreate) return;
833
+ const text = event.clipboardData?.getData("text") ?? "";
834
+ const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
835
+ if (labels.length < 2) return;
836
+ event.preventDefault();
837
+ const created = [];
838
+ for (const label of labels) {
839
+ const result = this.createTag(label);
840
+ if (result) created.push(result);
841
+ }
842
+ if (created.length === 0) return;
843
+ this.searchInput.value = "";
844
+ this.query = "";
845
+ this.afterSelectionChange();
846
+ for (const result of created) {
847
+ if (result.created) this.emitter.emit("create", result.option);
848
+ this.emitter.emit("select", result.option);
849
+ }
850
+ if (this.opts.closeOnSelect) this.close();
851
+ else this.renderList();
852
+ });
490
853
  }
491
854
  this.list.addEventListener("click", (event) => {
492
855
  const target = event.target;
@@ -499,7 +862,12 @@ var ForgeSelect = class {
499
862
  return;
500
863
  }
501
864
  const li = target.closest("li[data-nav-index]");
502
- if (!li) return;
865
+ if (!li) {
866
+ const optionRow = target.closest("li[data-option-value]");
867
+ const option = optionRow ? this.findOption(optionRow.dataset.optionValue) : void 0;
868
+ if (option && this.hasReachedMaximum() && !this.selected.includes(option.value)) this.announceMaximum(option);
869
+ return;
870
+ }
503
871
  const navIndex = Number(li.dataset.navIndex);
504
872
  this.activateNavItem(navIndex);
505
873
  });
@@ -508,6 +876,29 @@ var ForgeSelect = class {
508
876
  this.maybeLoadNextPage();
509
877
  });
510
878
  }
879
+ applySearchQuery(query, emitSearch) {
880
+ this.query = query;
881
+ if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
882
+ this.highlightedIndex = -1;
883
+ this.list.scrollTop = 0;
884
+ this.rowContentCache.clear();
885
+ if (emitSearch) this.emitter.emit("search", query);
886
+ const trimmed = query.trim();
887
+ const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
888
+ if (this.opts.ajax && !belowMinLength) {
889
+ this.scheduleRemoteLoad(query, this.opts.ajax.debounce ?? 250);
890
+ return;
891
+ }
892
+ if (belowMinLength) {
893
+ if (this.ajaxTimer) {
894
+ clearTimeout(this.ajaxTimer);
895
+ this.ajaxTimer = null;
896
+ }
897
+ this.ajaxController?.abort();
898
+ this.setLoading(false);
899
+ }
900
+ this.renderList();
901
+ }
511
902
  handleKeydown(event) {
512
903
  if (this.isDisabled) return;
513
904
  switch (event.key) {
@@ -550,36 +941,61 @@ var ForgeSelect = class {
550
941
  }
551
942
  }
552
943
  // ---------------------------------------------------------------- selection
944
+ canSelectOption(option) {
945
+ if (this.opts.maxSelections == null) return true;
946
+ const projected = [...this.selected];
947
+ if (!projected.includes(option.value)) projected.push(option.value);
948
+ for (const value of collectDescendantValues(option, this.isOptionDisabled)) {
949
+ if (!projected.includes(value)) projected.push(value);
950
+ }
951
+ syncTreeAncestors(this.data, projected, this.isOptionDisabled);
952
+ return projected.length <= this.opts.maxSelections;
953
+ }
954
+ hasReachedMaximum() {
955
+ return this.opts.maxSelections != null && this.selected.length >= this.opts.maxSelections;
956
+ }
957
+ announceMaximum(option) {
958
+ const limit = this.opts.maxSelections;
959
+ if (limit == null) return;
960
+ this.liveRegion.textContent = format(this.strings.maximumSelected, { count: String(limit) });
961
+ this.emitter.emit("maximum", { limit, option });
962
+ }
553
963
  selectValue(value, notify) {
554
964
  if (this.selected.includes(value)) return;
555
965
  const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
556
966
  this.selectedOptions.set(value, option);
557
967
  if (this.opts.multiple) {
558
968
  this.selected.push(value);
559
- for (const v of collectDescendantValues(option)) {
969
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
560
970
  if (!this.selected.includes(v)) this.selected.push(v);
561
971
  }
562
972
  this.syncTreeAncestors();
563
973
  } else {
564
974
  this.selected = [value];
565
975
  }
566
- if (notify) this.afterSelectionChange();
976
+ if (notify) {
977
+ this.afterSelectionChange();
978
+ this.emitter.emit("select", option);
979
+ }
567
980
  }
568
981
  deselectValue(value, notify) {
569
982
  const index = this.selected.indexOf(value);
570
983
  if (index === -1) return;
984
+ const option = this.findOption(value) ?? this.selectedOptions.get(value);
571
985
  this.selected.splice(index, 1);
572
986
  if (this.opts.multiple) {
573
- const option = this.findOption(value) ?? this.selectedOptions.get(value);
574
987
  if (option) {
575
- for (const v of collectDescendantValues(option)) {
988
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
576
989
  const i = this.selected.indexOf(v);
577
990
  if (i !== -1) this.selected.splice(i, 1);
578
991
  }
579
992
  }
580
993
  this.syncTreeAncestors();
581
994
  }
582
- if (notify) this.afterSelectionChange();
995
+ if (notify) {
996
+ this.afterSelectionChange();
997
+ this.emitter.emit("unselect", option ?? { value, label: value });
998
+ }
583
999
  }
584
1000
  /**
585
1001
  * Keeps every tree parent's own membership in `selected` consistent with
@@ -588,7 +1004,7 @@ var ForgeSelect = class {
588
1004
  * No-op for data with no `children` anywhere.
589
1005
  */
590
1006
  syncTreeAncestors() {
591
- syncTreeAncestors(this.data, this.selected);
1007
+ syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
592
1008
  }
593
1009
  clearSelection() {
594
1010
  if (this.selected.length === 0) return;
@@ -596,9 +1012,22 @@ var ForgeSelect = class {
596
1012
  this.emitter.emit("clear");
597
1013
  this.afterSelectionChange();
598
1014
  }
1015
+ allSelectableValues() {
1016
+ const values = [];
1017
+ const visit = (option) => {
1018
+ if (!this.isOptionDisabled(option)) values.push(option.value);
1019
+ option.children?.forEach(visit);
1020
+ };
1021
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
1022
+ return values;
1023
+ }
599
1024
  afterSelectionChange(emitChange = true) {
600
1025
  this.renderValue();
601
1026
  this.syncNativeSelect(emitChange);
1027
+ if (!this.opts.required || this.selected.length > 0) {
1028
+ this.control.classList.remove("forge-select__control--invalid");
1029
+ this.control.removeAttribute("aria-invalid");
1030
+ }
602
1031
  if (this.isOpen) this.renderList();
603
1032
  if (emitChange) this.emitter.emit("change", this.getValue());
604
1033
  }
@@ -634,17 +1063,58 @@ var ForgeSelect = class {
634
1063
  findOption(value) {
635
1064
  return findOption(this.data, value);
636
1065
  }
1066
+ findOptionByLabel(label) {
1067
+ const lower = label.toLowerCase();
1068
+ const search = (options) => {
1069
+ for (const option of options) {
1070
+ if (option.label.toLowerCase() === lower) return option;
1071
+ const found = option.children ? search(option.children) : void 0;
1072
+ if (found) return found;
1073
+ }
1074
+ return void 0;
1075
+ };
1076
+ for (const item of this.data) {
1077
+ const found = search(isGroup(item) ? item.options : [item]);
1078
+ if (found) return found;
1079
+ }
1080
+ return void 0;
1081
+ }
1082
+ /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
1083
+ createTag(label) {
1084
+ const trimmed = label.trim();
1085
+ if (!trimmed) return void 0;
1086
+ const existing = this.findOptionByLabel(trimmed);
1087
+ if (existing) {
1088
+ if (this.selected.includes(existing.value)) return void 0;
1089
+ if (this.opts.multiple && !this.canSelectOption(existing)) {
1090
+ this.announceMaximum(existing);
1091
+ return void 0;
1092
+ }
1093
+ this.selectValue(existing.value, false);
1094
+ return { option: existing, created: false };
1095
+ }
1096
+ const option = { value: trimmed, label: trimmed };
1097
+ if (this.opts.multiple && !this.canSelectOption(option)) {
1098
+ this.announceMaximum(option);
1099
+ return void 0;
1100
+ }
1101
+ this.data.push(option);
1102
+ this.selectValue(option.value, false);
1103
+ return { option, created: true };
1104
+ }
637
1105
  createFromQuery() {
638
1106
  const label = this.query.trim();
639
1107
  if (!label) return;
640
- const option = { value: label, label };
641
- this.data.push(option);
1108
+ const result = this.createTag(label);
1109
+ if (!result) return;
642
1110
  if (this.searchInput) {
643
1111
  this.searchInput.value = "";
644
1112
  this.query = "";
645
1113
  }
646
- this.selectValue(option.value, true);
647
- if (!this.opts.multiple) this.close();
1114
+ this.afterSelectionChange();
1115
+ if (result.created) this.emitter.emit("create", result.option);
1116
+ this.emitter.emit("select", result.option);
1117
+ if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
648
1118
  }
649
1119
  activateNavItem(navIndex) {
650
1120
  const item = this.navItems[navIndex];
@@ -655,8 +1125,17 @@ var ForgeSelect = class {
655
1125
  }
656
1126
  const { value } = item.option;
657
1127
  if (this.opts.multiple) {
658
- if (this.selected.includes(value)) this.deselectValue(value, true);
659
- else this.selectValue(value, true);
1128
+ let changed = false;
1129
+ if (this.selected.includes(value)) {
1130
+ this.deselectValue(value, true);
1131
+ changed = true;
1132
+ } else if (this.canSelectOption(item.option)) {
1133
+ this.selectValue(value, true);
1134
+ changed = true;
1135
+ } else {
1136
+ this.announceMaximum(item.option);
1137
+ }
1138
+ if (changed && this.opts.closeOnSelect) this.close();
660
1139
  } else {
661
1140
  this.selectValue(value, true);
662
1141
  this.close();
@@ -772,6 +1251,7 @@ var ForgeSelect = class {
772
1251
  this.selected = order;
773
1252
  this.suppressNextTagClick = true;
774
1253
  this.afterSelectionChange();
1254
+ this.emitter.emit("reorder", [...this.selected]);
775
1255
  };
776
1256
  tag.addEventListener("pointerdown", (event) => {
777
1257
  if (this.isDisabled || event.button !== 0) return;
@@ -795,6 +1275,7 @@ var ForgeSelect = class {
795
1275
  [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
796
1276
  this.selected = next;
797
1277
  this.afterSelectionChange();
1278
+ this.emitter.emit("reorder", [...this.selected]);
798
1279
  this.focusTagByValue(value);
799
1280
  }
800
1281
  focusTagByValue(value) {
@@ -808,12 +1289,19 @@ var ForgeSelect = class {
808
1289
  buildRows() {
809
1290
  this.rows = [];
810
1291
  this.navItems = [];
811
- const query = this.query.trim().toLowerCase();
812
- const matches = (option) => query === "" || option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false);
1292
+ const trimmedQuery = this.query.trim();
1293
+ const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
1294
+ const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
1295
+ fields: this.opts.searchFields,
1296
+ tokenSearch: this.opts.tokenSearch,
1297
+ accentInsensitive: this.opts.accentInsensitive,
1298
+ scorer: this.opts.searchScorer
1299
+ }) > 0);
813
1300
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
814
1301
  const pushOption = (option, depth, parentValue) => {
815
1302
  let navIndex = -1;
816
- if (!option.disabled) {
1303
+ const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
1304
+ if (!interactionDisabled) {
817
1305
  navIndex = this.navItems.length;
818
1306
  this.navItems.push({ kind: "option", option, parentValue });
819
1307
  }
@@ -828,6 +1316,10 @@ var ForgeSelect = class {
828
1316
  }
829
1317
  }
830
1318
  };
1319
+ if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
1320
+ this.rows.push({ kind: "min-length" });
1321
+ return;
1322
+ }
831
1323
  if (this.loading) {
832
1324
  this.rows.push({ kind: "loading" });
833
1325
  return;
@@ -855,16 +1347,30 @@ var ForgeSelect = class {
855
1347
  else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
856
1348
  }
857
1349
  hasExactMatch(lowerQuery) {
858
- const matchesExactly = (option) => option.label.toLowerCase() === lowerQuery || (option.children ?? []).some(matchesExactly);
859
- for (const item of this.data) {
860
- const options = isGroup(item) ? item.options : [item];
861
- if (options.some(matchesExactly)) return true;
862
- }
863
- return false;
1350
+ return !!this.findOptionByLabel(lowerQuery);
864
1351
  }
865
1352
  usesVirtualScroll() {
866
1353
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
867
1354
  }
1355
+ rowKey(row, index) {
1356
+ if (row.kind === "option") return `option:${row.option.value}`;
1357
+ if (row.kind === "group") return `group:${row.label}:${index}`;
1358
+ return `${row.kind}:${index}`;
1359
+ }
1360
+ measuredRowHeight(index) {
1361
+ return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
1362
+ }
1363
+ rowOffset(index) {
1364
+ if (!this.opts.variableItemHeight) return index * this.opts.itemHeight;
1365
+ let offset = 0;
1366
+ for (let i = 0; i < index; i += 1) offset += this.measuredRowHeight(i);
1367
+ return offset;
1368
+ }
1369
+ rowOffsets() {
1370
+ const offsets = [0];
1371
+ for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
1372
+ return offsets;
1373
+ }
868
1374
  renderList() {
869
1375
  this.buildRows();
870
1376
  this.renderRows();
@@ -872,7 +1378,7 @@ var ForgeSelect = class {
872
1378
  }
873
1379
  announceStatus() {
874
1380
  const first = this.rows[0];
875
- const message = first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : "";
1381
+ 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) }) : "";
876
1382
  if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
877
1383
  }
878
1384
  renderRows() {
@@ -881,26 +1387,40 @@ var ForgeSelect = class {
881
1387
  const virtual = this.usesVirtualScroll();
882
1388
  this.list.textContent = "";
883
1389
  const rowHeight = this.opts.itemHeight;
1390
+ const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
884
1391
  let start = 0;
885
1392
  let end = this.rows.length;
886
1393
  if (virtual) {
887
1394
  const viewport = clientHeight || rowHeight * 8;
888
- start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
889
- end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1395
+ if (this.opts.variableItemHeight) {
1396
+ while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
1397
+ start = Math.max(0, start - VIRTUAL_BUFFER);
1398
+ end = start;
1399
+ const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
1400
+ while (end < this.rows.length && offsets[end] < target) end += 1;
1401
+ } else {
1402
+ start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
1403
+ end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
1404
+ }
890
1405
  const topSpacer = document.createElement("li");
891
1406
  topSpacer.className = "forge-select__spacer";
892
1407
  topSpacer.setAttribute("aria-hidden", "true");
893
- topSpacer.style.height = `${start * rowHeight}px`;
1408
+ topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
894
1409
  this.list.append(topSpacer);
895
1410
  }
896
1411
  for (let i = start; i < end; i++) {
897
- this.list.append(this.renderRow(this.rows[i]));
1412
+ const element = this.renderRow(this.rows[i]);
1413
+ this.list.append(element);
1414
+ if (this.opts.variableItemHeight) {
1415
+ const measured = element.getBoundingClientRect().height || element.offsetHeight;
1416
+ if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
1417
+ }
898
1418
  }
899
1419
  if (virtual) {
900
1420
  const bottomSpacer = document.createElement("li");
901
1421
  bottomSpacer.className = "forge-select__spacer";
902
1422
  bottomSpacer.setAttribute("aria-hidden", "true");
903
- bottomSpacer.style.height = `${(this.rows.length - end) * rowHeight}px`;
1423
+ bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
904
1424
  this.list.append(bottomSpacer);
905
1425
  if (this.list.scrollTop !== scrollTop) {
906
1426
  this.list.scrollTop = scrollTop;
@@ -923,6 +1443,13 @@ var ForgeSelect = class {
923
1443
  li.setAttribute("aria-selected", "false");
924
1444
  li.textContent = this.strings.noResults;
925
1445
  break;
1446
+ case "min-length":
1447
+ li.className = "forge-select__min-length";
1448
+ li.setAttribute("role", "option");
1449
+ li.setAttribute("aria-disabled", "true");
1450
+ li.setAttribute("aria-selected", "false");
1451
+ li.textContent = format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) });
1452
+ break;
926
1453
  case "error":
927
1454
  li.className = "forge-select__error";
928
1455
  li.setAttribute("role", "option");
@@ -952,17 +1479,20 @@ var ForgeSelect = class {
952
1479
  break;
953
1480
  case "option": {
954
1481
  li.className = "forge-select__option";
1482
+ li.dataset.optionValue = row.option.value;
1483
+ if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
955
1484
  li.setAttribute("role", "option");
956
1485
  const isSelected = this.selected.includes(row.option.value);
957
1486
  li.setAttribute("aria-selected", String(isSelected));
958
1487
  if (isSelected) li.classList.add("forge-select__option--selected");
959
- if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
1488
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
960
1489
  li.classList.add("forge-select__option--indeterminate");
1490
+ li.dataset.selectionState = "mixed";
961
1491
  }
962
1492
  if (row.depth > 0) {
963
1493
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
964
1494
  }
965
- if (row.option.disabled) {
1495
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
966
1496
  li.classList.add("forge-select__option--disabled");
967
1497
  li.setAttribute("aria-disabled", "true");
968
1498
  } else {
@@ -993,6 +1523,28 @@ var ForgeSelect = class {
993
1523
  * cached content state-free.
994
1524
  */
995
1525
  optionContent(option) {
1526
+ if (this.opts.highlightSearch && this.query.trim() && !this.opts.templateResult) {
1527
+ const holder = document.createElement("span");
1528
+ holder.className = "forge-select__option-content";
1529
+ renderOptionContent(holder, option, void 0);
1530
+ const label = holder.querySelector(".forge-select__option-label") ?? holder;
1531
+ const ranges = findNormalizedRanges(option.label, this.query, this.opts.accentInsensitive);
1532
+ if (ranges.length) {
1533
+ label.textContent = "";
1534
+ let cursor = 0;
1535
+ for (const [start, end] of ranges) {
1536
+ if (start < cursor) continue;
1537
+ label.append(document.createTextNode(option.label.slice(cursor, start)));
1538
+ const mark = document.createElement("mark");
1539
+ mark.className = "forge-select__match";
1540
+ mark.textContent = option.label.slice(start, end);
1541
+ label.append(mark);
1542
+ cursor = end;
1543
+ }
1544
+ label.append(document.createTextNode(option.label.slice(cursor)));
1545
+ }
1546
+ return holder;
1547
+ }
996
1548
  let cached = this.rowContentCache.get(option.value);
997
1549
  if (!cached) {
998
1550
  const holder = document.createElement("span");
@@ -1019,8 +1571,8 @@ var ForgeSelect = class {
1019
1571
  (row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
1020
1572
  );
1021
1573
  if (rowIndex >= 0) {
1022
- const rowHeight = this.opts.itemHeight;
1023
- const top = rowIndex * rowHeight;
1574
+ const rowHeight = this.measuredRowHeight(rowIndex);
1575
+ const top = this.rowOffset(rowIndex);
1024
1576
  const viewport = this.list.clientHeight || rowHeight * 8;
1025
1577
  let target = this.list.scrollTop;
1026
1578
  if (top < target) target = top;
@@ -1085,7 +1637,7 @@ var ForgeSelect = class {
1085
1637
  this.ajaxController = null;
1086
1638
  this.page = 0;
1087
1639
  this.hasMore = true;
1088
- this.loading = true;
1640
+ this.setLoading(true);
1089
1641
  this.loadingMore = false;
1090
1642
  this.loadError = null;
1091
1643
  this.renderList();
@@ -1094,6 +1646,55 @@ var ForgeSelect = class {
1094
1646
  void this.loadRemote(query, { requestId });
1095
1647
  }, delay);
1096
1648
  }
1649
+ setLoading(loading) {
1650
+ if (this.loading === loading) return;
1651
+ this.loading = loading;
1652
+ this.emitter.emit("loading", loading);
1653
+ }
1654
+ remoteCacheKey(query, page) {
1655
+ return `${query}\0${page}`;
1656
+ }
1657
+ async requestRemote(query, page, signal) {
1658
+ const ajax = this.opts.ajax;
1659
+ const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
1660
+ let lastError;
1661
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
1662
+ try {
1663
+ if (ajax.request) return await ajax.request(query, page, signal);
1664
+ const response = await fetch(buildUrl(ajax, query, page), { signal });
1665
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1666
+ return await response.json();
1667
+ } catch (error) {
1668
+ lastError = error;
1669
+ if (signal.aborted || attempt === attempts - 1) throw error;
1670
+ const delay = Math.max(0, ajax.retryDelay ?? 250) * 2 ** attempt;
1671
+ await new Promise((resolve, reject) => {
1672
+ const timer = setTimeout(resolve, delay);
1673
+ signal.addEventListener(
1674
+ "abort",
1675
+ () => {
1676
+ clearTimeout(timer);
1677
+ reject(new DOMException("Aborted", "AbortError"));
1678
+ },
1679
+ { once: true }
1680
+ );
1681
+ });
1682
+ }
1683
+ }
1684
+ throw lastError;
1685
+ }
1686
+ async prefetchRemote(query) {
1687
+ const ajax = this.opts.ajax;
1688
+ if (!ajax || (ajax.cacheTtl ?? 3e4) <= 0) return;
1689
+ const key = this.remoteCacheKey(query, 0);
1690
+ if (this.remoteCache.get(key)) return;
1691
+ const controller = new AbortController();
1692
+ try {
1693
+ const json = await this.requestRemote(query, 0, controller.signal);
1694
+ this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
1695
+ } catch {
1696
+ }
1697
+ }
1097
1698
  /**
1098
1699
  * Fires on every list scroll. Only acts when pagination is opted into via
1099
1700
  * `ajax.pagination`; reads real scroll geometry rather than row counts so
@@ -1118,12 +1719,15 @@ var ForgeSelect = class {
1118
1719
  this.ajaxController = controller;
1119
1720
  const page = append ? this.page + 1 : 0;
1120
1721
  try {
1121
- const url = buildUrl(ajax, query, page);
1122
- const response = await fetch(url, { signal: controller.signal });
1123
- if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1124
- const json = await response.json();
1722
+ const key = this.remoteCacheKey(query, page);
1723
+ let result = this.remoteCache.get(key);
1724
+ if (!result) {
1725
+ const json = await this.requestRemote(query, page, controller.signal);
1726
+ result = normalizeRemoteResult(ajax, json);
1727
+ this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
1728
+ }
1125
1729
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1126
- const { options, hasMore } = normalizeRemoteResult(ajax, json);
1730
+ const { options, hasMore } = result;
1127
1731
  if (append) {
1128
1732
  const existing = collectValues(this.data);
1129
1733
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
@@ -1148,7 +1752,7 @@ var ForgeSelect = class {
1148
1752
  } finally {
1149
1753
  if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1150
1754
  this.ajaxController = null;
1151
- this.loading = false;
1755
+ this.setLoading(false);
1152
1756
  this.loadingMore = false;
1153
1757
  if (this.isOpen) this.renderList();
1154
1758
  }