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.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();
@@ -144,18 +160,19 @@ function normalizeRemoteResult(ajax, response) {
144
160
  function isGroup(item) {
145
161
  return item.options !== void 0;
146
162
  }
147
- function collectDescendantValues(option) {
163
+ var defaultIsDisabled = (option) => !!option.disabled;
164
+ function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
148
165
  if (!option.children) return [];
149
166
  const values = [];
150
167
  for (const child of option.children) {
151
- if (!child.disabled) values.push(child.value);
152
- values.push(...collectDescendantValues(child));
168
+ if (!isDisabled(child)) values.push(child.value);
169
+ values.push(...collectDescendantValues(child, isDisabled));
153
170
  }
154
171
  return values;
155
172
  }
156
- function computeCheckState(option, selected) {
173
+ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
157
174
  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));
175
+ const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
159
176
  if (states.length === 0) return "none";
160
177
  if (states.every((state) => state === "all")) return "all";
161
178
  if (states.every((state) => state === "none")) return "none";
@@ -176,11 +193,11 @@ function findOption(items, value) {
176
193
  }
177
194
  return void 0;
178
195
  }
179
- function syncTreeAncestors(items, selected) {
196
+ function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
180
197
  const sync = (option) => {
181
198
  if (!option.children?.length) return;
182
199
  for (const child of option.children) sync(child);
183
- const state = computeCheckState(option, selected);
200
+ const state = computeCheckState(option, selected, isDisabled);
184
201
  const index = selected.indexOf(option.value);
185
202
  if (state === "all" && index === -1) selected.push(option.value);
186
203
  else if (state !== "all" && index !== -1) selected.splice(index, 1);
@@ -214,6 +231,7 @@ var ForgeSelect = class {
214
231
  this.emitter = new Emitter();
215
232
  this.uid = `forge-select-${++uidCounter}`;
216
233
  this.searchInput = null;
234
+ this.portalHost = null;
217
235
  this.isOpen = false;
218
236
  this.isDisabled = false;
219
237
  this.destroyed = false;
@@ -237,8 +255,25 @@ var ForgeSelect = class {
237
255
  this.nativeSelect = null;
238
256
  this.nativeForm = null;
239
257
  this.syncingNative = false;
258
+ /** Combines the static `disabled` field with the dynamic `isOptionDisabled` callback. */
259
+ this.isOptionDisabled = (option) => option.disabled === true || (this.opts.isOptionDisabled?.(option) ?? false);
260
+ this.pointerDownOnControl = false;
240
261
  this.onDocumentMouseDown = (event) => {
241
- if (!this.root.contains(event.target)) this.close();
262
+ const target = event.target;
263
+ if (!this.root.contains(target) && !this.portalHost?.contains(target)) this.close();
264
+ };
265
+ this.onWindowResize = () => {
266
+ this.positionDropdown();
267
+ };
268
+ this.onAncestorScroll = () => {
269
+ if (this.portalHost) this.positionDropdown();
270
+ };
271
+ this.onNativeInvalid = (event) => {
272
+ event.preventDefault();
273
+ this.control.classList.add("forge-select__control--invalid");
274
+ this.control.setAttribute("aria-invalid", "true");
275
+ if (!this.isOpen) this.open();
276
+ this.control.focus();
242
277
  };
243
278
  this.onNativeChange = () => {
244
279
  if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
@@ -267,19 +302,29 @@ var ForgeSelect = class {
267
302
  clearable: options.clearable ?? false,
268
303
  allowCreate: options.allowCreate ?? false,
269
304
  sortable: options.sortable ?? false,
305
+ closeOnSelect: options.closeOnSelect ?? false,
306
+ maxSelections: options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections)),
270
307
  theme: options.theme ?? "default",
271
308
  disabled: options.disabled ?? nativeSelect?.disabled ?? false,
309
+ required: options.required ?? nativeSelect?.required ?? false,
272
310
  data: options.data,
273
311
  ajax: options.ajax,
274
312
  templateResult: options.templateResult,
275
313
  templateSelection: options.templateSelection,
314
+ filterOption: options.filterOption,
315
+ minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
316
+ minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
317
+ isOptionDisabled: options.isOptionDisabled,
276
318
  virtualScroll: options.virtualScroll,
277
319
  itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
278
320
  language: options.language ?? "en",
279
- plugins: options.plugins ?? []
321
+ plugins: options.plugins ?? [],
322
+ openOnFocus: options.openOnFocus ?? false,
323
+ dropdownParent: options.dropdownParent
280
324
  };
281
325
  this.strings = getStrings(this.opts.language);
282
326
  this.plugins = this.opts.plugins;
327
+ if (nativeSelect) nativeSelect.required = this.opts.required;
283
328
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
284
329
  if (nativeSelect && !this.opts.data) {
285
330
  const nativeOptions = Array.from(nativeSelect.options);
@@ -292,6 +337,7 @@ var ForgeSelect = class {
292
337
  this.renderValue();
293
338
  if (this.opts.disabled) this.disable();
294
339
  nativeSelect?.addEventListener("change", this.onNativeChange);
340
+ nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
295
341
  this.nativeForm?.addEventListener("reset", this.onFormReset);
296
342
  for (const plugin of this.plugins) plugin.onInit?.(this);
297
343
  }
@@ -314,7 +360,10 @@ var ForgeSelect = class {
314
360
  this.scheduleRemoteLoad(this.query, 0);
315
361
  }
316
362
  this.renderList();
317
- if (this.searchInput) this.searchInput.focus();
363
+ this.positionDropdown();
364
+ window.addEventListener("resize", this.onWindowResize);
365
+ document.addEventListener("scroll", this.onAncestorScroll, true);
366
+ if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
318
367
  this.emitter.emit("open");
319
368
  for (const plugin of this.plugins) plugin.onOpen?.(this);
320
369
  }
@@ -323,8 +372,11 @@ var ForgeSelect = class {
323
372
  this.isOpen = false;
324
373
  this.dropdown.hidden = true;
325
374
  this.root.classList.remove("forge-select--open");
375
+ this.root.classList.remove("forge-select--drop-up");
326
376
  this.control.setAttribute("aria-expanded", "false");
327
377
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
378
+ window.removeEventListener("resize", this.onWindowResize);
379
+ document.removeEventListener("scroll", this.onAncestorScroll, true);
328
380
  this.highlightedIndex = -1;
329
381
  if (this.searchInput) {
330
382
  this.searchInput.value = "";
@@ -333,6 +385,23 @@ var ForgeSelect = class {
333
385
  this.emitter.emit("close");
334
386
  for (const plugin of this.plugins) plugin.onClose?.(this);
335
387
  }
388
+ /**
389
+ * Flips the dropdown above the control when there isn't enough room below
390
+ * but there is above. Recomputed on open() and on window resize — the
391
+ * dropdown is positioned absolutely inside the relatively-positioned root,
392
+ * so it already tracks the control correctly on page scroll without
393
+ * needing a scroll listener.
394
+ */
395
+ positionDropdown() {
396
+ const controlRect = this.control.getBoundingClientRect();
397
+ const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
398
+ this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
399
+ if (this.portalHost) {
400
+ this.portalHost.style.top = `${placement.top}px`;
401
+ this.portalHost.style.left = `${controlRect.left}px`;
402
+ this.portalHost.style.width = `${controlRect.width}px`;
403
+ }
404
+ }
336
405
  destroy() {
337
406
  if (this.destroyed) return;
338
407
  this.close();
@@ -341,8 +410,10 @@ var ForgeSelect = class {
341
410
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
342
411
  this.ajaxController?.abort();
343
412
  this.nativeSelect?.removeEventListener("change", this.onNativeChange);
413
+ this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
344
414
  this.nativeForm?.removeEventListener("reset", this.onFormReset);
345
415
  this.rowContentCache.clear();
416
+ this.portalHost?.remove();
346
417
  this.root.remove();
347
418
  this.el.style.display = this.originalDisplay;
348
419
  if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
@@ -360,6 +431,52 @@ var ForgeSelect = class {
360
431
  for (const v of next) this.selectValue(v, false);
361
432
  this.afterSelectionChange(options.emitChange ?? true);
362
433
  }
434
+ /**
435
+ * Replaces the option list after construction. An open dropdown re-renders
436
+ * immediately; a selection whose value isn't in the new data stays
437
+ * selected (rendered via the already-selected option's own label/avatar,
438
+ * the same fallback used for values selected from a stale ajax page).
439
+ */
440
+ setData(data) {
441
+ if (this.ajaxTimer) {
442
+ clearTimeout(this.ajaxTimer);
443
+ this.ajaxTimer = null;
444
+ }
445
+ this.ajaxController?.abort();
446
+ this.ajaxController = null;
447
+ this.ajaxRequestId += 1;
448
+ this.loading = false;
449
+ this.loadingMore = false;
450
+ this.loadError = null;
451
+ this.remoteLoaded = true;
452
+ this.page = 0;
453
+ this.hasMore = false;
454
+ this.data = data;
455
+ this.opts.data = data;
456
+ this.updateSearchVisibility();
457
+ this.rowContentCache.clear();
458
+ this.highlightedIndex = -1;
459
+ if (this.isOpen) this.renderList();
460
+ }
461
+ /**
462
+ * Multi-select only: selects every currently non-disabled option, including
463
+ * nested tree descendants and options inside groups. If `maxSelections` is
464
+ * set, stops once the cap is reached rather than exceeding it. A no-op for
465
+ * single-select.
466
+ */
467
+ selectAll() {
468
+ if (!this.opts.multiple) return;
469
+ this.selected = [];
470
+ for (const value of this.allSelectableValues()) {
471
+ const option = this.findOption(value);
472
+ if (option && this.canSelectOption(option)) this.selectValue(value, false);
473
+ }
474
+ this.afterSelectionChange();
475
+ }
476
+ /** Clears every selection. Equivalent to `setValue(null)`. */
477
+ clearAll() {
478
+ this.clearSelection();
479
+ }
363
480
  enable() {
364
481
  this.isDisabled = false;
365
482
  this.root.classList.remove("forge-select--disabled");
@@ -405,7 +522,22 @@ var ForgeSelect = class {
405
522
  }
406
523
  }
407
524
  }
525
+ shouldShowSearch() {
526
+ return this.opts.searchable && (this.opts.ajax != null || collectValues(this.data).size >= this.opts.minResultsForSearch);
527
+ }
528
+ updateSearchVisibility() {
529
+ if (!this.searchInput) return;
530
+ this.searchInput.hidden = !this.shouldShowSearch();
531
+ if (this.searchInput.hidden) {
532
+ this.searchInput.value = "";
533
+ this.query = "";
534
+ }
535
+ }
408
536
  buildDom() {
537
+ const portalParent = typeof this.opts.dropdownParent === "string" ? document.querySelector(this.opts.dropdownParent) : this.opts.dropdownParent;
538
+ if (this.opts.dropdownParent && !portalParent) {
539
+ throw new Error(`ForgeSelect: dropdown parent not found: ${String(this.opts.dropdownParent)}`);
540
+ }
409
541
  this.root = document.createElement("div");
410
542
  this.root.className = "forge-select";
411
543
  this.root.dataset.theme = this.opts.theme;
@@ -417,6 +549,7 @@ var ForgeSelect = class {
417
549
  this.control.setAttribute("aria-haspopup", "listbox");
418
550
  this.control.setAttribute("aria-expanded", "false");
419
551
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
552
+ if (this.opts.required) this.control.setAttribute("aria-required", "true");
420
553
  this.control.tabIndex = 0;
421
554
  this.applyAccessibleName();
422
555
  this.valueEl = document.createElement("div");
@@ -441,6 +574,7 @@ var ForgeSelect = class {
441
574
  this.searchInput.setAttribute("aria-label", this.strings.search);
442
575
  this.searchInput.setAttribute("aria-autocomplete", "list");
443
576
  this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
577
+ this.searchInput.hidden = !this.shouldShowSearch();
444
578
  this.dropdown.append(this.searchInput);
445
579
  }
446
580
  this.list = document.createElement("ul");
@@ -453,9 +587,18 @@ var ForgeSelect = class {
453
587
  this.liveRegion.className = "forge-select__sr-only";
454
588
  this.liveRegion.setAttribute("role", "status");
455
589
  this.liveRegion.setAttribute("aria-live", "polite");
456
- this.root.append(this.control, this.dropdown, this.liveRegion);
590
+ this.root.append(this.control, this.liveRegion);
591
+ if (!portalParent) this.root.append(this.dropdown);
457
592
  this.el.style.display = "none";
458
593
  this.el.insertAdjacentElement("afterend", this.root);
594
+ if (portalParent) {
595
+ this.portalHost = document.createElement("div");
596
+ this.portalHost.className = "forge-select forge-select--portal-host";
597
+ this.portalHost.dataset.theme = this.opts.theme;
598
+ this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
599
+ this.portalHost.append(this.dropdown);
600
+ portalParent.append(this.portalHost);
601
+ }
459
602
  this.bindEvents();
460
603
  }
461
604
  bindEvents() {
@@ -470,6 +613,15 @@ var ForgeSelect = class {
470
613
  else this.open();
471
614
  });
472
615
  this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
616
+ this.control.addEventListener("mousedown", () => {
617
+ this.pointerDownOnControl = true;
618
+ });
619
+ this.control.addEventListener("focus", () => {
620
+ if (this.opts.openOnFocus && !this.pointerDownOnControl && !this.isOpen && !this.isDisabled) {
621
+ this.open();
622
+ }
623
+ this.pointerDownOnControl = false;
624
+ });
473
625
  this.clearBtn.addEventListener("click", (event) => {
474
626
  event.stopPropagation();
475
627
  this.clearSelection();
@@ -480,13 +632,45 @@ var ForgeSelect = class {
480
632
  this.highlightedIndex = -1;
481
633
  this.list.scrollTop = 0;
482
634
  this.emitter.emit("search", this.query);
483
- if (this.opts.ajax) {
635
+ const trimmed = this.query.trim();
636
+ const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
637
+ if (this.opts.ajax && !belowMinLength) {
484
638
  this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
485
639
  } else {
640
+ if (belowMinLength) {
641
+ if (this.ajaxTimer) {
642
+ clearTimeout(this.ajaxTimer);
643
+ this.ajaxTimer = null;
644
+ }
645
+ this.ajaxController?.abort();
646
+ this.loading = false;
647
+ }
486
648
  this.renderList();
487
649
  }
488
650
  });
489
651
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
652
+ this.searchInput.addEventListener("paste", (event) => {
653
+ if (!this.opts.multiple || !this.opts.allowCreate) return;
654
+ const text = event.clipboardData?.getData("text") ?? "";
655
+ const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
656
+ if (labels.length < 2) return;
657
+ event.preventDefault();
658
+ const created = [];
659
+ for (const label of labels) {
660
+ const result = this.createTag(label);
661
+ if (result) created.push(result);
662
+ }
663
+ if (created.length === 0) return;
664
+ this.searchInput.value = "";
665
+ this.query = "";
666
+ this.afterSelectionChange();
667
+ for (const result of created) {
668
+ if (result.created) this.emitter.emit("create", result.option);
669
+ this.emitter.emit("select", result.option);
670
+ }
671
+ if (this.opts.closeOnSelect) this.close();
672
+ else this.renderList();
673
+ });
490
674
  }
491
675
  this.list.addEventListener("click", (event) => {
492
676
  const target = event.target;
@@ -499,7 +683,12 @@ var ForgeSelect = class {
499
683
  return;
500
684
  }
501
685
  const li = target.closest("li[data-nav-index]");
502
- if (!li) return;
686
+ if (!li) {
687
+ const optionRow = target.closest("li[data-option-value]");
688
+ const option = optionRow ? this.findOption(optionRow.dataset.optionValue) : void 0;
689
+ if (option && this.hasReachedMaximum() && !this.selected.includes(option.value)) this.announceMaximum(option);
690
+ return;
691
+ }
503
692
  const navIndex = Number(li.dataset.navIndex);
504
693
  this.activateNavItem(navIndex);
505
694
  });
@@ -550,36 +739,61 @@ var ForgeSelect = class {
550
739
  }
551
740
  }
552
741
  // ---------------------------------------------------------------- selection
742
+ canSelectOption(option) {
743
+ if (this.opts.maxSelections == null) return true;
744
+ const projected = [...this.selected];
745
+ if (!projected.includes(option.value)) projected.push(option.value);
746
+ for (const value of collectDescendantValues(option, this.isOptionDisabled)) {
747
+ if (!projected.includes(value)) projected.push(value);
748
+ }
749
+ syncTreeAncestors(this.data, projected, this.isOptionDisabled);
750
+ return projected.length <= this.opts.maxSelections;
751
+ }
752
+ hasReachedMaximum() {
753
+ return this.opts.maxSelections != null && this.selected.length >= this.opts.maxSelections;
754
+ }
755
+ announceMaximum(option) {
756
+ const limit = this.opts.maxSelections;
757
+ if (limit == null) return;
758
+ this.liveRegion.textContent = format(this.strings.maximumSelected, { count: String(limit) });
759
+ this.emitter.emit("maximum", { limit, option });
760
+ }
553
761
  selectValue(value, notify) {
554
762
  if (this.selected.includes(value)) return;
555
763
  const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
556
764
  this.selectedOptions.set(value, option);
557
765
  if (this.opts.multiple) {
558
766
  this.selected.push(value);
559
- for (const v of collectDescendantValues(option)) {
767
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
560
768
  if (!this.selected.includes(v)) this.selected.push(v);
561
769
  }
562
770
  this.syncTreeAncestors();
563
771
  } else {
564
772
  this.selected = [value];
565
773
  }
566
- if (notify) this.afterSelectionChange();
774
+ if (notify) {
775
+ this.afterSelectionChange();
776
+ this.emitter.emit("select", option);
777
+ }
567
778
  }
568
779
  deselectValue(value, notify) {
569
780
  const index = this.selected.indexOf(value);
570
781
  if (index === -1) return;
782
+ const option = this.findOption(value) ?? this.selectedOptions.get(value);
571
783
  this.selected.splice(index, 1);
572
784
  if (this.opts.multiple) {
573
- const option = this.findOption(value) ?? this.selectedOptions.get(value);
574
785
  if (option) {
575
- for (const v of collectDescendantValues(option)) {
786
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
576
787
  const i = this.selected.indexOf(v);
577
788
  if (i !== -1) this.selected.splice(i, 1);
578
789
  }
579
790
  }
580
791
  this.syncTreeAncestors();
581
792
  }
582
- if (notify) this.afterSelectionChange();
793
+ if (notify) {
794
+ this.afterSelectionChange();
795
+ this.emitter.emit("unselect", option ?? { value, label: value });
796
+ }
583
797
  }
584
798
  /**
585
799
  * Keeps every tree parent's own membership in `selected` consistent with
@@ -588,7 +802,7 @@ var ForgeSelect = class {
588
802
  * No-op for data with no `children` anywhere.
589
803
  */
590
804
  syncTreeAncestors() {
591
- syncTreeAncestors(this.data, this.selected);
805
+ syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
592
806
  }
593
807
  clearSelection() {
594
808
  if (this.selected.length === 0) return;
@@ -596,9 +810,22 @@ var ForgeSelect = class {
596
810
  this.emitter.emit("clear");
597
811
  this.afterSelectionChange();
598
812
  }
813
+ allSelectableValues() {
814
+ const values = [];
815
+ const visit = (option) => {
816
+ if (!this.isOptionDisabled(option)) values.push(option.value);
817
+ option.children?.forEach(visit);
818
+ };
819
+ for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
820
+ return values;
821
+ }
599
822
  afterSelectionChange(emitChange = true) {
600
823
  this.renderValue();
601
824
  this.syncNativeSelect(emitChange);
825
+ if (!this.opts.required || this.selected.length > 0) {
826
+ this.control.classList.remove("forge-select__control--invalid");
827
+ this.control.removeAttribute("aria-invalid");
828
+ }
602
829
  if (this.isOpen) this.renderList();
603
830
  if (emitChange) this.emitter.emit("change", this.getValue());
604
831
  }
@@ -634,17 +861,58 @@ var ForgeSelect = class {
634
861
  findOption(value) {
635
862
  return findOption(this.data, value);
636
863
  }
864
+ findOptionByLabel(label) {
865
+ const lower = label.toLowerCase();
866
+ const search = (options) => {
867
+ for (const option of options) {
868
+ if (option.label.toLowerCase() === lower) return option;
869
+ const found = option.children ? search(option.children) : void 0;
870
+ if (found) return found;
871
+ }
872
+ return void 0;
873
+ };
874
+ for (const item of this.data) {
875
+ const found = search(isGroup(item) ? item.options : [item]);
876
+ if (found) return found;
877
+ }
878
+ return void 0;
879
+ }
880
+ /** Selects an existing option matching `label` exactly, or creates and selects a new one. */
881
+ createTag(label) {
882
+ const trimmed = label.trim();
883
+ if (!trimmed) return void 0;
884
+ const existing = this.findOptionByLabel(trimmed);
885
+ if (existing) {
886
+ if (this.selected.includes(existing.value)) return void 0;
887
+ if (this.opts.multiple && !this.canSelectOption(existing)) {
888
+ this.announceMaximum(existing);
889
+ return void 0;
890
+ }
891
+ this.selectValue(existing.value, false);
892
+ return { option: existing, created: false };
893
+ }
894
+ const option = { value: trimmed, label: trimmed };
895
+ if (this.opts.multiple && !this.canSelectOption(option)) {
896
+ this.announceMaximum(option);
897
+ return void 0;
898
+ }
899
+ this.data.push(option);
900
+ this.selectValue(option.value, false);
901
+ return { option, created: true };
902
+ }
637
903
  createFromQuery() {
638
904
  const label = this.query.trim();
639
905
  if (!label) return;
640
- const option = { value: label, label };
641
- this.data.push(option);
906
+ const result = this.createTag(label);
907
+ if (!result) return;
642
908
  if (this.searchInput) {
643
909
  this.searchInput.value = "";
644
910
  this.query = "";
645
911
  }
646
- this.selectValue(option.value, true);
647
- if (!this.opts.multiple) this.close();
912
+ this.afterSelectionChange();
913
+ if (result.created) this.emitter.emit("create", result.option);
914
+ this.emitter.emit("select", result.option);
915
+ if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
648
916
  }
649
917
  activateNavItem(navIndex) {
650
918
  const item = this.navItems[navIndex];
@@ -655,8 +923,17 @@ var ForgeSelect = class {
655
923
  }
656
924
  const { value } = item.option;
657
925
  if (this.opts.multiple) {
658
- if (this.selected.includes(value)) this.deselectValue(value, true);
659
- else this.selectValue(value, true);
926
+ let changed = false;
927
+ if (this.selected.includes(value)) {
928
+ this.deselectValue(value, true);
929
+ changed = true;
930
+ } else if (this.canSelectOption(item.option)) {
931
+ this.selectValue(value, true);
932
+ changed = true;
933
+ } else {
934
+ this.announceMaximum(item.option);
935
+ }
936
+ if (changed && this.opts.closeOnSelect) this.close();
660
937
  } else {
661
938
  this.selectValue(value, true);
662
939
  this.close();
@@ -772,6 +1049,7 @@ var ForgeSelect = class {
772
1049
  this.selected = order;
773
1050
  this.suppressNextTagClick = true;
774
1051
  this.afterSelectionChange();
1052
+ this.emitter.emit("reorder", [...this.selected]);
775
1053
  };
776
1054
  tag.addEventListener("pointerdown", (event) => {
777
1055
  if (this.isDisabled || event.button !== 0) return;
@@ -795,6 +1073,7 @@ var ForgeSelect = class {
795
1073
  [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
796
1074
  this.selected = next;
797
1075
  this.afterSelectionChange();
1076
+ this.emitter.emit("reorder", [...this.selected]);
798
1077
  this.focusTagByValue(value);
799
1078
  }
800
1079
  focusTagByValue(value) {
@@ -808,12 +1087,14 @@ var ForgeSelect = class {
808
1087
  buildRows() {
809
1088
  this.rows = [];
810
1089
  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);
1090
+ const trimmedQuery = this.query.trim();
1091
+ const query = trimmedQuery.toLowerCase();
1092
+ const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false));
813
1093
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
814
1094
  const pushOption = (option, depth, parentValue) => {
815
1095
  let navIndex = -1;
816
- if (!option.disabled) {
1096
+ const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
1097
+ if (!interactionDisabled) {
817
1098
  navIndex = this.navItems.length;
818
1099
  this.navItems.push({ kind: "option", option, parentValue });
819
1100
  }
@@ -828,6 +1109,10 @@ var ForgeSelect = class {
828
1109
  }
829
1110
  }
830
1111
  };
1112
+ if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
1113
+ this.rows.push({ kind: "min-length" });
1114
+ return;
1115
+ }
831
1116
  if (this.loading) {
832
1117
  this.rows.push({ kind: "loading" });
833
1118
  return;
@@ -855,12 +1140,7 @@ var ForgeSelect = class {
855
1140
  else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
856
1141
  }
857
1142
  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;
1143
+ return !!this.findOptionByLabel(lowerQuery);
864
1144
  }
865
1145
  usesVirtualScroll() {
866
1146
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
@@ -872,7 +1152,7 @@ var ForgeSelect = class {
872
1152
  }
873
1153
  announceStatus() {
874
1154
  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 : "";
1155
+ 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
1156
  if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
877
1157
  }
878
1158
  renderRows() {
@@ -923,6 +1203,13 @@ var ForgeSelect = class {
923
1203
  li.setAttribute("aria-selected", "false");
924
1204
  li.textContent = this.strings.noResults;
925
1205
  break;
1206
+ case "min-length":
1207
+ li.className = "forge-select__min-length";
1208
+ li.setAttribute("role", "option");
1209
+ li.setAttribute("aria-disabled", "true");
1210
+ li.setAttribute("aria-selected", "false");
1211
+ li.textContent = format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) });
1212
+ break;
926
1213
  case "error":
927
1214
  li.className = "forge-select__error";
928
1215
  li.setAttribute("role", "option");
@@ -952,17 +1239,19 @@ var ForgeSelect = class {
952
1239
  break;
953
1240
  case "option": {
954
1241
  li.className = "forge-select__option";
1242
+ li.dataset.optionValue = row.option.value;
1243
+ if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
955
1244
  li.setAttribute("role", "option");
956
1245
  const isSelected = this.selected.includes(row.option.value);
957
1246
  li.setAttribute("aria-selected", String(isSelected));
958
1247
  if (isSelected) li.classList.add("forge-select__option--selected");
959
- if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
1248
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
960
1249
  li.classList.add("forge-select__option--indeterminate");
961
1250
  }
962
1251
  if (row.depth > 0) {
963
1252
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
964
1253
  }
965
- if (row.option.disabled) {
1254
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
966
1255
  li.classList.add("forge-select__option--disabled");
967
1256
  li.setAttribute("aria-disabled", "true");
968
1257
  } else {
@@ -1118,10 +1407,15 @@ var ForgeSelect = class {
1118
1407
  this.ajaxController = controller;
1119
1408
  const page = append ? this.page + 1 : 0;
1120
1409
  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();
1410
+ let json;
1411
+ if (ajax.request) {
1412
+ json = await ajax.request(query, page, controller.signal);
1413
+ } else {
1414
+ const url = buildUrl(ajax, query, page);
1415
+ const response = await fetch(url, { signal: controller.signal });
1416
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
1417
+ json = await response.json();
1418
+ }
1125
1419
  if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1126
1420
  const { options, hasMore } = normalizeRemoteResult(ajax, json);
1127
1421
  if (append) {