forge-select 0.3.0 → 0.4.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();
@@ -171,18 +187,19 @@ function normalizeRemoteResult(ajax, response) {
171
187
  function isGroup(item) {
172
188
  return item.options !== void 0;
173
189
  }
174
- function collectDescendantValues(option) {
190
+ var defaultIsDisabled = (option) => !!option.disabled;
191
+ function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
175
192
  if (!option.children) return [];
176
193
  const values = [];
177
194
  for (const child of option.children) {
178
- if (!child.disabled) values.push(child.value);
179
- values.push(...collectDescendantValues(child));
195
+ if (!isDisabled(child)) values.push(child.value);
196
+ values.push(...collectDescendantValues(child, isDisabled));
180
197
  }
181
198
  return values;
182
199
  }
183
- function computeCheckState(option, selected) {
200
+ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
184
201
  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));
202
+ const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
186
203
  if (states.length === 0) return "none";
187
204
  if (states.every((state) => state === "all")) return "all";
188
205
  if (states.every((state) => state === "none")) return "none";
@@ -203,11 +220,11 @@ function findOption(items, value) {
203
220
  }
204
221
  return void 0;
205
222
  }
206
- function syncTreeAncestors(items, selected) {
223
+ function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
207
224
  const sync = (option) => {
208
225
  if (!option.children?.length) return;
209
226
  for (const child of option.children) sync(child);
210
- const state = computeCheckState(option, selected);
227
+ const state = computeCheckState(option, selected, isDisabled);
211
228
  const index = selected.indexOf(option.value);
212
229
  if (state === "all" && index === -1) selected.push(option.value);
213
230
  else if (state !== "all" && index !== -1) selected.splice(index, 1);
@@ -241,6 +258,7 @@ var ForgeSelect = class {
241
258
  this.emitter = new Emitter();
242
259
  this.uid = `forge-select-${++uidCounter}`;
243
260
  this.searchInput = null;
261
+ this.portalHost = null;
244
262
  this.isOpen = false;
245
263
  this.isDisabled = false;
246
264
  this.destroyed = false;
@@ -264,8 +282,25 @@ var ForgeSelect = class {
264
282
  this.nativeSelect = null;
265
283
  this.nativeForm = null;
266
284
  this.syncingNative = false;
285
+ /** Combines the static `disabled` field with the dynamic `isOptionDisabled` callback. */
286
+ this.isOptionDisabled = (option) => option.disabled === true || (this.opts.isOptionDisabled?.(option) ?? false);
287
+ this.pointerDownOnControl = false;
267
288
  this.onDocumentMouseDown = (event) => {
268
- if (!this.root.contains(event.target)) this.close();
289
+ const target = event.target;
290
+ if (!this.root.contains(target) && !this.portalHost?.contains(target)) this.close();
291
+ };
292
+ this.onWindowResize = () => {
293
+ this.positionDropdown();
294
+ };
295
+ this.onAncestorScroll = () => {
296
+ if (this.portalHost) this.positionDropdown();
297
+ };
298
+ this.onNativeInvalid = (event) => {
299
+ event.preventDefault();
300
+ this.control.classList.add("forge-select__control--invalid");
301
+ this.control.setAttribute("aria-invalid", "true");
302
+ if (!this.isOpen) this.open();
303
+ this.control.focus();
269
304
  };
270
305
  this.onNativeChange = () => {
271
306
  if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
@@ -294,19 +329,29 @@ var ForgeSelect = class {
294
329
  clearable: options.clearable ?? false,
295
330
  allowCreate: options.allowCreate ?? false,
296
331
  sortable: options.sortable ?? false,
332
+ closeOnSelect: options.closeOnSelect ?? false,
333
+ maxSelections: options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections)),
297
334
  theme: options.theme ?? "default",
298
335
  disabled: options.disabled ?? nativeSelect?.disabled ?? false,
336
+ required: options.required ?? nativeSelect?.required ?? false,
299
337
  data: options.data,
300
338
  ajax: options.ajax,
301
339
  templateResult: options.templateResult,
302
340
  templateSelection: options.templateSelection,
341
+ filterOption: options.filterOption,
342
+ minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
343
+ minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
344
+ isOptionDisabled: options.isOptionDisabled,
303
345
  virtualScroll: options.virtualScroll,
304
346
  itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
305
347
  language: options.language ?? "en",
306
- plugins: options.plugins ?? []
348
+ plugins: options.plugins ?? [],
349
+ openOnFocus: options.openOnFocus ?? false,
350
+ dropdownParent: options.dropdownParent
307
351
  };
308
352
  this.strings = getStrings(this.opts.language);
309
353
  this.plugins = this.opts.plugins;
354
+ if (nativeSelect) nativeSelect.required = this.opts.required;
310
355
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
311
356
  if (nativeSelect && !this.opts.data) {
312
357
  const nativeOptions = Array.from(nativeSelect.options);
@@ -319,6 +364,7 @@ var ForgeSelect = class {
319
364
  this.renderValue();
320
365
  if (this.opts.disabled) this.disable();
321
366
  nativeSelect?.addEventListener("change", this.onNativeChange);
367
+ nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
322
368
  this.nativeForm?.addEventListener("reset", this.onFormReset);
323
369
  for (const plugin of this.plugins) plugin.onInit?.(this);
324
370
  }
@@ -341,7 +387,10 @@ var ForgeSelect = class {
341
387
  this.scheduleRemoteLoad(this.query, 0);
342
388
  }
343
389
  this.renderList();
344
- if (this.searchInput) this.searchInput.focus();
390
+ this.positionDropdown();
391
+ window.addEventListener("resize", this.onWindowResize);
392
+ document.addEventListener("scroll", this.onAncestorScroll, true);
393
+ if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
345
394
  this.emitter.emit("open");
346
395
  for (const plugin of this.plugins) plugin.onOpen?.(this);
347
396
  }
@@ -350,8 +399,11 @@ var ForgeSelect = class {
350
399
  this.isOpen = false;
351
400
  this.dropdown.hidden = true;
352
401
  this.root.classList.remove("forge-select--open");
402
+ this.root.classList.remove("forge-select--drop-up");
353
403
  this.control.setAttribute("aria-expanded", "false");
354
404
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
405
+ window.removeEventListener("resize", this.onWindowResize);
406
+ document.removeEventListener("scroll", this.onAncestorScroll, true);
355
407
  this.highlightedIndex = -1;
356
408
  if (this.searchInput) {
357
409
  this.searchInput.value = "";
@@ -360,6 +412,23 @@ var ForgeSelect = class {
360
412
  this.emitter.emit("close");
361
413
  for (const plugin of this.plugins) plugin.onClose?.(this);
362
414
  }
415
+ /**
416
+ * Flips the dropdown above the control when there isn't enough room below
417
+ * but there is above. Recomputed on open() and on window resize — the
418
+ * dropdown is positioned absolutely inside the relatively-positioned root,
419
+ * so it already tracks the control correctly on page scroll without
420
+ * needing a scroll listener.
421
+ */
422
+ positionDropdown() {
423
+ const controlRect = this.control.getBoundingClientRect();
424
+ const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
425
+ this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
426
+ if (this.portalHost) {
427
+ this.portalHost.style.top = `${placement.top}px`;
428
+ this.portalHost.style.left = `${controlRect.left}px`;
429
+ this.portalHost.style.width = `${controlRect.width}px`;
430
+ }
431
+ }
363
432
  destroy() {
364
433
  if (this.destroyed) return;
365
434
  this.close();
@@ -368,8 +437,10 @@ var ForgeSelect = class {
368
437
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
369
438
  this.ajaxController?.abort();
370
439
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
440
+ this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
371
441
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
372
442
  this.rowContentCache.clear();
443
+ this.portalHost?.remove();
373
444
  this.root.remove();
374
445
  this.el.style.display = this.originalDisplay;
375
446
  if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
@@ -387,6 +458,52 @@ var ForgeSelect = class {
387
458
  for (const v of next) this.selectValue(v, false);
388
459
  this.afterSelectionChange(options.emitChange ?? true);
389
460
  }
461
+ /**
462
+ * Replaces the option list after construction. An open dropdown re-renders
463
+ * immediately; a selection whose value isn't in the new data stays
464
+ * selected (rendered via the already-selected option's own label/avatar,
465
+ * the same fallback used for values selected from a stale ajax page).
466
+ */
467
+ setData(data) {
468
+ if (this.ajaxTimer) {
469
+ clearTimeout(this.ajaxTimer);
470
+ this.ajaxTimer = null;
471
+ }
472
+ this.ajaxController?.abort();
473
+ this.ajaxController = null;
474
+ this.ajaxRequestId += 1;
475
+ this.loading = false;
476
+ this.loadingMore = false;
477
+ this.loadError = null;
478
+ this.remoteLoaded = true;
479
+ this.page = 0;
480
+ this.hasMore = false;
481
+ this.data = data;
482
+ this.opts.data = data;
483
+ this.updateSearchVisibility();
484
+ this.rowContentCache.clear();
485
+ this.highlightedIndex = -1;
486
+ if (this.isOpen) this.renderList();
487
+ }
488
+ /**
489
+ * Multi-select only: selects every currently non-disabled option, including
490
+ * nested tree descendants and options inside groups. If `maxSelections` is
491
+ * set, stops once the cap is reached rather than exceeding it. A no-op for
492
+ * single-select.
493
+ */
494
+ selectAll() {
495
+ if (!this.opts.multiple) return;
496
+ this.selected = [];
497
+ for (const value of this.allSelectableValues()) {
498
+ const option = this.findOption(value);
499
+ if (option && this.canSelectOption(option)) this.selectValue(value, false);
500
+ }
501
+ this.afterSelectionChange();
502
+ }
503
+ /** Clears every selection. Equivalent to `setValue(null)`. */
504
+ clearAll() {
505
+ this.clearSelection();
506
+ }
390
507
  enable() {
391
508
  this.isDisabled = false;
392
509
  this.root.classList.remove("forge-select--disabled");
@@ -432,7 +549,22 @@ var ForgeSelect = class {
432
549
  }
433
550
  }
434
551
  }
552
+ shouldShowSearch() {
553
+ return this.opts.searchable && (this.opts.ajax != null || collectValues(this.data).size >= this.opts.minResultsForSearch);
554
+ }
555
+ updateSearchVisibility() {
556
+ if (!this.searchInput) return;
557
+ this.searchInput.hidden = !this.shouldShowSearch();
558
+ if (this.searchInput.hidden) {
559
+ this.searchInput.value = "";
560
+ this.query = "";
561
+ }
562
+ }
435
563
  buildDom() {
564
+ const portalParent = typeof this.opts.dropdownParent === "string" ? document.querySelector(this.opts.dropdownParent) : this.opts.dropdownParent;
565
+ if (this.opts.dropdownParent && !portalParent) {
566
+ throw new Error(`ForgeSelect: dropdown parent not found: ${String(this.opts.dropdownParent)}`);
567
+ }
436
568
  this.root = document.createElement("div");
437
569
  this.root.className = "forge-select";
438
570
  this.root.dataset.theme = this.opts.theme;
@@ -444,6 +576,7 @@ var ForgeSelect = class {
444
576
  this.control.setAttribute("aria-haspopup", "listbox");
445
577
  this.control.setAttribute("aria-expanded", "false");
446
578
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
579
+ if (this.opts.required) this.control.setAttribute("aria-required", "true");
447
580
  this.control.tabIndex = 0;
448
581
  this.applyAccessibleName();
449
582
  this.valueEl = document.createElement("div");
@@ -468,6 +601,7 @@ var ForgeSelect = class {
468
601
  this.searchInput.setAttribute("aria-label", this.strings.search);
469
602
  this.searchInput.setAttribute("aria-autocomplete", "list");
470
603
  this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
604
+ this.searchInput.hidden = !this.shouldShowSearch();
471
605
  this.dropdown.append(this.searchInput);
472
606
  }
473
607
  this.list = document.createElement("ul");
@@ -480,9 +614,18 @@ var ForgeSelect = class {
480
614
  this.liveRegion.className = "forge-select__sr-only";
481
615
  this.liveRegion.setAttribute("role", "status");
482
616
  this.liveRegion.setAttribute("aria-live", "polite");
483
- this.root.append(this.control, this.dropdown, this.liveRegion);
617
+ this.root.append(this.control, this.liveRegion);
618
+ if (!portalParent) this.root.append(this.dropdown);
484
619
  this.el.style.display = "none";
485
620
  this.el.insertAdjacentElement("afterend", this.root);
621
+ if (portalParent) {
622
+ this.portalHost = document.createElement("div");
623
+ this.portalHost.className = "forge-select forge-select--portal-host";
624
+ this.portalHost.dataset.theme = this.opts.theme;
625
+ this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
626
+ this.portalHost.append(this.dropdown);
627
+ portalParent.append(this.portalHost);
628
+ }
486
629
  this.bindEvents();
487
630
  }
488
631
  bindEvents() {
@@ -497,6 +640,15 @@ var ForgeSelect = class {
497
640
  else this.open();
498
641
  });
499
642
  this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
643
+ this.control.addEventListener("mousedown", () => {
644
+ this.pointerDownOnControl = true;
645
+ });
646
+ this.control.addEventListener("focus", () => {
647
+ if (this.opts.openOnFocus && !this.pointerDownOnControl && !this.isOpen && !this.isDisabled) {
648
+ this.open();
649
+ }
650
+ this.pointerDownOnControl = false;
651
+ });
500
652
  this.clearBtn.addEventListener("click", (event) => {
501
653
  event.stopPropagation();
502
654
  this.clearSelection();
@@ -507,13 +659,45 @@ var ForgeSelect = class {
507
659
  this.highlightedIndex = -1;
508
660
  this.list.scrollTop = 0;
509
661
  this.emitter.emit("search", this.query);
510
- if (this.opts.ajax) {
662
+ const trimmed = this.query.trim();
663
+ const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
664
+ if (this.opts.ajax && !belowMinLength) {
511
665
  this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
512
666
  } else {
667
+ if (belowMinLength) {
668
+ if (this.ajaxTimer) {
669
+ clearTimeout(this.ajaxTimer);
670
+ this.ajaxTimer = null;
671
+ }
672
+ this.ajaxController?.abort();
673
+ this.loading = false;
674
+ }
513
675
  this.renderList();
514
676
  }
515
677
  });
516
678
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
679
+ this.searchInput.addEventListener("paste", (event) => {
680
+ if (!this.opts.multiple || !this.opts.allowCreate) return;
681
+ const text = event.clipboardData?.getData("text") ?? "";
682
+ const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
683
+ if (labels.length < 2) return;
684
+ event.preventDefault();
685
+ const created = [];
686
+ for (const label of labels) {
687
+ const result = this.createTag(label);
688
+ if (result) created.push(result);
689
+ }
690
+ if (created.length === 0) return;
691
+ this.searchInput.value = "";
692
+ this.query = "";
693
+ this.afterSelectionChange();
694
+ for (const result of created) {
695
+ if (result.created) this.emitter.emit("create", result.option);
696
+ this.emitter.emit("select", result.option);
697
+ }
698
+ if (this.opts.closeOnSelect) this.close();
699
+ else this.renderList();
700
+ });
517
701
  }
518
702
  this.list.addEventListener("click", (event) => {
519
703
  const target = event.target;
@@ -526,7 +710,12 @@ var ForgeSelect = class {
526
710
  return;
527
711
  }
528
712
  const li = target.closest("li[data-nav-index]");
529
- if (!li) return;
713
+ if (!li) {
714
+ const optionRow = target.closest("li[data-option-value]");
715
+ const option = optionRow ? this.findOption(optionRow.dataset.optionValue) : void 0;
716
+ if (option && this.hasReachedMaximum() && !this.selected.includes(option.value)) this.announceMaximum(option);
717
+ return;
718
+ }
530
719
  const navIndex = Number(li.dataset.navIndex);
531
720
  this.activateNavItem(navIndex);
532
721
  });
@@ -577,36 +766,61 @@ var ForgeSelect = class {
577
766
  }
578
767
  }
579
768
  // ---------------------------------------------------------------- selection
769
+ canSelectOption(option) {
770
+ if (this.opts.maxSelections == null) return true;
771
+ const projected = [...this.selected];
772
+ if (!projected.includes(option.value)) projected.push(option.value);
773
+ for (const value of collectDescendantValues(option, this.isOptionDisabled)) {
774
+ if (!projected.includes(value)) projected.push(value);
775
+ }
776
+ syncTreeAncestors(this.data, projected, this.isOptionDisabled);
777
+ return projected.length <= this.opts.maxSelections;
778
+ }
779
+ hasReachedMaximum() {
780
+ return this.opts.maxSelections != null && this.selected.length >= this.opts.maxSelections;
781
+ }
782
+ announceMaximum(option) {
783
+ const limit = this.opts.maxSelections;
784
+ if (limit == null) return;
785
+ this.liveRegion.textContent = format(this.strings.maximumSelected, { count: String(limit) });
786
+ this.emitter.emit("maximum", { limit, option });
787
+ }
580
788
  selectValue(value, notify) {
581
789
  if (this.selected.includes(value)) return;
582
790
  const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
583
791
  this.selectedOptions.set(value, option);
584
792
  if (this.opts.multiple) {
585
793
  this.selected.push(value);
586
- for (const v of collectDescendantValues(option)) {
794
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
587
795
  if (!this.selected.includes(v)) this.selected.push(v);
588
796
  }
589
797
  this.syncTreeAncestors();
590
798
  } else {
591
799
  this.selected = [value];
592
800
  }
593
- if (notify) this.afterSelectionChange();
801
+ if (notify) {
802
+ this.afterSelectionChange();
803
+ this.emitter.emit("select", option);
804
+ }
594
805
  }
595
806
  deselectValue(value, notify) {
596
807
  const index = this.selected.indexOf(value);
597
808
  if (index === -1) return;
809
+ const option = this.findOption(value) ?? this.selectedOptions.get(value);
598
810
  this.selected.splice(index, 1);
599
811
  if (this.opts.multiple) {
600
- const option = this.findOption(value) ?? this.selectedOptions.get(value);
601
812
  if (option) {
602
- for (const v of collectDescendantValues(option)) {
813
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
603
814
  const i = this.selected.indexOf(v);
604
815
  if (i !== -1) this.selected.splice(i, 1);
605
816
  }
606
817
  }
607
818
  this.syncTreeAncestors();
608
819
  }
609
- if (notify) this.afterSelectionChange();
820
+ if (notify) {
821
+ this.afterSelectionChange();
822
+ this.emitter.emit("unselect", option ?? { value, label: value });
823
+ }
610
824
  }
611
825
  /**
612
826
  * Keeps every tree parent's own membership in `selected` consistent with
@@ -615,7 +829,7 @@ var ForgeSelect = class {
615
829
  * No-op for data with no `children` anywhere.
616
830
  */
617
831
  syncTreeAncestors() {
618
- syncTreeAncestors(this.data, this.selected);
832
+ syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
619
833
  }
620
834
  clearSelection() {
621
835
  if (this.selected.length === 0) return;
@@ -623,9 +837,22 @@ var ForgeSelect = class {
623
837
  this.emitter.emit("clear");
624
838
  this.afterSelectionChange();
625
839
  }
840
+ allSelectableValues() {
841
+ const values = [];
842
+ const visit = (option) => {
843
+ if (!this.isOptionDisabled(option)) values.push(option.value);
844
+ option.children?.forEach(visit);
845
+ };
846
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
847
+ return values;
848
+ }
626
849
  afterSelectionChange(emitChange = true) {
627
850
  this.renderValue();
628
851
  this.syncNativeSelect(emitChange);
852
+ if (!this.opts.required || this.selected.length > 0) {
853
+ this.control.classList.remove("forge-select__control--invalid");
854
+ this.control.removeAttribute("aria-invalid");
855
+ }
629
856
  if (this.isOpen) this.renderList();
630
857
  if (emitChange) this.emitter.emit("change", this.getValue());
631
858
  }
@@ -661,17 +888,58 @@ var ForgeSelect = class {
661
888
  findOption(value) {
662
889
  return findOption(this.data, value);
663
890
  }
891
+ findOptionByLabel(label) {
892
+ const lower = label.toLowerCase();
893
+ const search = (options) => {
894
+ for (const option of options) {
895
+ if (option.label.toLowerCase() === lower) return option;
896
+ const found = option.children ? search(option.children) : void 0;
897
+ if (found) return found;
898
+ }
899
+ return void 0;
900
+ };
901
+ for (const item of this.data) {
902
+ const found = search(isGroup(item) ? item.options : [item]);
903
+ if (found) return found;
904
+ }
905
+ return void 0;
906
+ }
907
+ /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
908
+ createTag(label) {
909
+ const trimmed = label.trim();
910
+ if (!trimmed) return void 0;
911
+ const existing = this.findOptionByLabel(trimmed);
912
+ if (existing) {
913
+ if (this.selected.includes(existing.value)) return void 0;
914
+ if (this.opts.multiple && !this.canSelectOption(existing)) {
915
+ this.announceMaximum(existing);
916
+ return void 0;
917
+ }
918
+ this.selectValue(existing.value, false);
919
+ return { option: existing, created: false };
920
+ }
921
+ const option = { value: trimmed, label: trimmed };
922
+ if (this.opts.multiple && !this.canSelectOption(option)) {
923
+ this.announceMaximum(option);
924
+ return void 0;
925
+ }
926
+ this.data.push(option);
927
+ this.selectValue(option.value, false);
928
+ return { option, created: true };
929
+ }
664
930
  createFromQuery() {
665
931
  const label = this.query.trim();
666
932
  if (!label) return;
667
- const option = { value: label, label };
668
- this.data.push(option);
933
+ const result = this.createTag(label);
934
+ if (!result) return;
669
935
  if (this.searchInput) {
670
936
  this.searchInput.value = "";
671
937
  this.query = "";
672
938
  }
673
- this.selectValue(option.value, true);
674
- if (!this.opts.multiple) this.close();
939
+ this.afterSelectionChange();
940
+ if (result.created) this.emitter.emit("create", result.option);
941
+ this.emitter.emit("select", result.option);
942
+ if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
675
943
  }
676
944
  activateNavItem(navIndex) {
677
945
  const item = this.navItems[navIndex];
@@ -682,8 +950,17 @@ var ForgeSelect = class {
682
950
  }
683
951
  const { value } = item.option;
684
952
  if (this.opts.multiple) {
685
- if (this.selected.includes(value)) this.deselectValue(value, true);
686
- else this.selectValue(value, true);
953
+ let changed = false;
954
+ if (this.selected.includes(value)) {
955
+ this.deselectValue(value, true);
956
+ changed = true;
957
+ } else if (this.canSelectOption(item.option)) {
958
+ this.selectValue(value, true);
959
+ changed = true;
960
+ } else {
961
+ this.announceMaximum(item.option);
962
+ }
963
+ if (changed && this.opts.closeOnSelect) this.close();
687
964
  } else {
688
965
  this.selectValue(value, true);
689
966
  this.close();
@@ -799,6 +1076,7 @@ var ForgeSelect = class {
799
1076
  this.selected = order;
800
1077
  this.suppressNextTagClick = true;
801
1078
  this.afterSelectionChange();
1079
+ this.emitter.emit("reorder", [...this.selected]);
802
1080
  };
803
1081
  tag.addEventListener("pointerdown", (event) => {
804
1082
  if (this.isDisabled || event.button !== 0) return;
@@ -822,6 +1100,7 @@ var ForgeSelect = class {
822
1100
  [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
823
1101
  this.selected = next;
824
1102
  this.afterSelectionChange();
1103
+ this.emitter.emit("reorder", [...this.selected]);
825
1104
  this.focusTagByValue(value);
826
1105
  }
827
1106
  focusTagByValue(value) {
@@ -835,12 +1114,14 @@ var ForgeSelect = class {
835
1114
  buildRows() {
836
1115
  this.rows = [];
837
1116
  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);
1117
+ const trimmedQuery = this.query.trim();
1118
+ const query = trimmedQuery.toLowerCase();
1119
+ const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false));
840
1120
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
841
1121
  const pushOption = (option, depth, parentValue) => {
842
1122
  let navIndex = -1;
843
- if (!option.disabled) {
1123
+ const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
1124
+ if (!interactionDisabled) {
844
1125
  navIndex = this.navItems.length;
845
1126
  this.navItems.push({ kind: "option", option, parentValue });
846
1127
  }
@@ -855,6 +1136,10 @@ var ForgeSelect = class {
855
1136
  }
856
1137
  }
857
1138
  };
1139
+ if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
1140
+ this.rows.push({ kind: "min-length" });
1141
+ return;
1142
+ }
858
1143
  if (this.loading) {
859
1144
  this.rows.push({ kind: "loading" });
860
1145
  return;
@@ -882,12 +1167,7 @@ var ForgeSelect = class {
882
1167
  else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
883
1168
  }
884
1169
  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;
1170
+ return !!this.findOptionByLabel(lowerQuery);
891
1171
  }
892
1172
  usesVirtualScroll() {
893
1173
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
@@ -899,7 +1179,7 @@ var ForgeSelect = class {
899
1179
  }
900
1180
  announceStatus() {
901
1181
  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 : "";
1182
+ 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
1183
  if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
904
1184
  }
905
1185
  renderRows() {
@@ -950,6 +1230,13 @@ var ForgeSelect = class {
950
1230
  li.setAttribute("aria-selected", "false");
951
1231
  li.textContent = this.strings.noResults;
952
1232
  break;
1233
+ case "min-length":
1234
+ li.className = "forge-select__min-length";
1235
+ li.setAttribute("role", "option");
1236
+ li.setAttribute("aria-disabled", "true");
1237
+ li.setAttribute("aria-selected", "false");
1238
+ li.textContent = format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) });
1239
+ break;
953
1240
  case "error":
954
1241
  li.className = "forge-select__error";
955
1242
  li.setAttribute("role", "option");
@@ -979,17 +1266,19 @@ var ForgeSelect = class {
979
1266
  break;
980
1267
  case "option": {
981
1268
  li.className = "forge-select__option";
1269
+ li.dataset.optionValue = row.option.value;
1270
+ if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
982
1271
  li.setAttribute("role", "option");
983
1272
  const isSelected = this.selected.includes(row.option.value);
984
1273
  li.setAttribute("aria-selected", String(isSelected));
985
1274
  if (isSelected) li.classList.add("forge-select__option--selected");
986
- if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
1275
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
987
1276
  li.classList.add("forge-select__option--indeterminate");
988
1277
  }
989
1278
  if (row.depth > 0) {
990
1279
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
991
1280
  }
992
- if (row.option.disabled) {
1281
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
993
1282
  li.classList.add("forge-select__option--disabled");
994
1283
  li.setAttribute("aria-disabled", "true");
995
1284
  } else {
@@ -1145,10 +1434,15 @@ var ForgeSelect = class {
1145
1434
  this.ajaxController = controller;
1146
1435
  const page = append ? this.page + 1 : 0;
1147
1436
  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();
1437
+ let json;
1438
+ if (ajax.request) {
1439
+ json = await ajax.request(query, page, controller.signal);
1440
+ } else {
1441
+ const url = buildUrl(ajax, query, page);
1442
+ const response = await fetch(url, { signal: controller.signal });
1443
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1444
+ json = await response.json();
1445
+ }
1152
1446
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1153
1447
  const { options, hasMore } = normalizeRemoteResult(ajax, json);
1154
1448
  if (append) {