forge-select 0.2.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,25 +51,44 @@ 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: {
57
68
  noResults: "No results found",
58
69
  loading: "Loading\u2026",
59
70
  loadingMore: "Loading more\u2026",
71
+ errorLoading: "Could not load options",
60
72
  createOption: 'Create "{query}"',
61
73
  clearSelection: "Clear selection",
62
74
  removeItem: "Remove {label}",
63
- search: "Search"
75
+ search: "Search",
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"
64
79
  },
65
80
  vi: {
66
81
  noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
67
82
  loading: "\u0110ang t\u1EA3i\u2026",
68
83
  loadingMore: "\u0110ang t\u1EA3i th\xEAm\u2026",
84
+ errorLoading: "Kh\xF4ng th\u1EC3 t\u1EA3i t\xF9y ch\u1ECDn",
69
85
  createOption: 'T\u1EA1o "{query}"',
70
86
  clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
71
87
  removeItem: "X\xF3a {label}",
72
- search: "T\xECm ki\u1EBFm"
88
+ search: "T\xECm ki\u1EBFm",
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"
73
92
  }
74
93
  };
75
94
  function getStrings(language) {
@@ -82,39 +101,164 @@ function format(template, vars) {
82
101
  return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
83
102
  }
84
103
 
85
- // src/ForgeSelect.ts
86
- var DEFAULT_ITEM_HEIGHT = 36;
87
- var VIRTUAL_BUFFER = 5;
88
- var VIRTUAL_THRESHOLD = 100;
89
- var ROW_CACHE_LIMIT = 2e3;
90
- var uidCounter = 0;
104
+ // src/native-select.ts
105
+ function parseNativeOptions(select) {
106
+ const data = [];
107
+ for (const child of Array.from(select.children)) {
108
+ if (child instanceof HTMLOptGroupElement) {
109
+ data.push({ label: child.label, options: Array.from(child.querySelectorAll("option")).map(parseOption) });
110
+ } else if (child instanceof HTMLOptionElement) {
111
+ data.push(parseOption(child));
112
+ }
113
+ }
114
+ return data;
115
+ }
116
+ function parseOption(option) {
117
+ const groupDisabled = option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled;
118
+ return {
119
+ value: option.value,
120
+ label: option.textContent?.trim() ?? option.value,
121
+ disabled: option.disabled || groupDisabled || void 0
122
+ };
123
+ }
124
+
125
+ // src/option-renderer.ts
126
+ function renderOptionContent(container, option, template, variant = "row") {
127
+ if (template) {
128
+ const result = template(option);
129
+ if (typeof result === "string") container.innerHTML = result;
130
+ else container.append(result);
131
+ return;
132
+ }
133
+ if (!option.avatar && !option.description) {
134
+ container.textContent = option.label;
135
+ return;
136
+ }
137
+ if (option.avatar) {
138
+ const avatar = document.createElement("img");
139
+ avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
140
+ avatar.src = option.avatar;
141
+ avatar.alt = "";
142
+ avatar.setAttribute("loading", "lazy");
143
+ avatar.setAttribute("decoding", "async");
144
+ container.append(avatar);
145
+ }
146
+ if (variant === "row" && option.description) {
147
+ const body = document.createElement("span");
148
+ body.className = "forge-select__option-body";
149
+ const label = document.createElement("span");
150
+ label.className = "forge-select__option-label";
151
+ label.textContent = option.label;
152
+ const description = document.createElement("span");
153
+ description.className = "forge-select__option-desc";
154
+ description.textContent = option.description;
155
+ body.append(label, description);
156
+ container.append(body);
157
+ } else {
158
+ const label = document.createElement("span");
159
+ label.className = "forge-select__option-label";
160
+ label.textContent = option.label;
161
+ container.append(label);
162
+ }
163
+ }
164
+
165
+ // src/remote.ts
166
+ function buildUrl(ajax, query, page) {
167
+ if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
168
+ if (typeof ajax.url === "function") return ajax.url(query, page);
169
+ if (!ajax.params) return ajax.url;
170
+ const params = new URLSearchParams();
171
+ for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
172
+ const separator = ajax.url.includes("?") ? "&" : "?";
173
+ return `${ajax.url}${separator}${params.toString()}`;
174
+ }
175
+ function normalizeRemoteResult(ajax, response) {
176
+ const result = ajax.transform ? ajax.transform(response) : response;
177
+ if (Array.isArray(result)) return { options: result, hasMore: false };
178
+ if (!result || !Array.isArray(result.options)) {
179
+ throw new Error(
180
+ "ForgeSelect: ajax.transform must return an array of options, or an object shaped like { options: Option[], hasMore?: boolean }."
181
+ );
182
+ }
183
+ return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
184
+ }
185
+
186
+ // src/selection.ts
91
187
  function isGroup(item) {
92
188
  return item.options !== void 0;
93
189
  }
94
- function collectDescendantValues(option) {
190
+ var defaultIsDisabled = (option) => !!option.disabled;
191
+ function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
95
192
  if (!option.children) return [];
96
193
  const values = [];
97
194
  for (const child of option.children) {
98
- values.push(child.value, ...collectDescendantValues(child));
195
+ if (!isDisabled(child)) values.push(child.value);
196
+ values.push(...collectDescendantValues(child, isDisabled));
99
197
  }
100
198
  return values;
101
199
  }
102
- function computeCheckState(option, selected) {
103
- if (!option.children || option.children.length === 0) {
104
- return selected.includes(option.value) ? "all" : "none";
105
- }
106
- const states = option.children.map((child) => computeCheckState(child, selected));
107
- if (states.every((s) => s === "all")) return "all";
108
- if (states.every((s) => s === "none")) return "none";
200
+ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
201
+ if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
202
+ const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
203
+ if (states.length === 0) return "none";
204
+ if (states.every((state) => state === "all")) return "all";
205
+ if (states.every((state) => state === "none")) return "none";
109
206
  return "some";
110
207
  }
208
+ function findOption(items, value) {
209
+ const search = (options) => {
210
+ for (const option of options) {
211
+ if (option.value === value) return option;
212
+ const found = option.children ? search(option.children) : void 0;
213
+ if (found) return found;
214
+ }
215
+ return void 0;
216
+ };
217
+ for (const item of items) {
218
+ const found = search(isGroup(item) ? item.options : [item]);
219
+ if (found) return found;
220
+ }
221
+ return void 0;
222
+ }
223
+ function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
224
+ const sync = (option) => {
225
+ if (!option.children?.length) return;
226
+ for (const child of option.children) sync(child);
227
+ const state = computeCheckState(option, selected, isDisabled);
228
+ const index = selected.indexOf(option.value);
229
+ if (state === "all" && index === -1) selected.push(option.value);
230
+ else if (state !== "all" && index !== -1) selected.splice(index, 1);
231
+ };
232
+ for (const item of items) (isGroup(item) ? item.options : [item]).forEach(sync);
233
+ }
234
+ function collectValues(items) {
235
+ const values = /* @__PURE__ */ new Set();
236
+ const visit = (option) => {
237
+ values.add(option.value);
238
+ option.children?.forEach(visit);
239
+ };
240
+ for (const item of items) (isGroup(item) ? item.options : [item]).forEach(visit);
241
+ return values;
242
+ }
243
+ function arraysEqual(a, b) {
244
+ return a.length === b.length && a.every((value, index) => value === b[index]);
245
+ }
246
+
247
+ // src/ForgeSelect.ts
248
+ var DEFAULT_ITEM_HEIGHT = 36;
249
+ var VIRTUAL_BUFFER = 5;
250
+ var VIRTUAL_THRESHOLD = 100;
251
+ var ROW_CACHE_LIMIT = 2e3;
252
+ var uidCounter = 0;
111
253
  var ForgeSelect = class {
112
254
  constructor(target, options = {}) {
113
255
  this.selected = [];
114
256
  this.selectedOptions = /* @__PURE__ */ new Map();
257
+ this.suppressNextTagClick = false;
115
258
  this.emitter = new Emitter();
116
259
  this.uid = `forge-select-${++uidCounter}`;
117
260
  this.searchInput = null;
261
+ this.portalHost = null;
118
262
  this.isOpen = false;
119
263
  this.isDisabled = false;
120
264
  this.destroyed = false;
@@ -130,9 +274,43 @@ var ForgeSelect = class {
130
274
  this.hasMore = true;
131
275
  this.ajaxTimer = null;
132
276
  this.ajaxRequestId = 0;
277
+ this.ajaxController = null;
133
278
  this.remoteLoaded = false;
279
+ this.loadError = null;
280
+ this.originalDisplay = "";
281
+ this.originalDisabled = false;
282
+ this.nativeSelect = null;
283
+ this.nativeForm = null;
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;
134
288
  this.onDocumentMouseDown = (event) => {
135
- 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();
304
+ };
305
+ this.onNativeChange = () => {
306
+ if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
307
+ const values = Array.from(this.nativeSelect.selectedOptions, (option) => option.value);
308
+ this.applyNativeValues(values);
309
+ };
310
+ this.onFormReset = () => {
311
+ if (!this.nativeSelect || this.destroyed) return;
312
+ const defaults = Array.from(this.nativeSelect.options).filter((option) => option.defaultSelected).map((option) => option.value);
313
+ this.applyNativeValues(defaults);
136
314
  };
137
315
  const el = typeof target === "string" ? document.querySelector(target) : target;
138
316
  if (!el) {
@@ -140,36 +318,63 @@ var ForgeSelect = class {
140
318
  }
141
319
  this.el = el;
142
320
  const nativeSelect = el instanceof HTMLSelectElement ? el : null;
321
+ this.nativeSelect = nativeSelect;
322
+ this.nativeForm = nativeSelect?.form ?? null;
323
+ this.originalDisplay = el.style.display;
324
+ this.originalDisabled = nativeSelect?.disabled ?? false;
143
325
  this.opts = {
144
326
  placeholder: options.placeholder ?? "",
145
327
  searchable: options.searchable ?? true,
146
328
  multiple: options.multiple ?? nativeSelect?.multiple ?? false,
147
329
  clearable: options.clearable ?? false,
148
330
  allowCreate: options.allowCreate ?? false,
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)),
149
334
  theme: options.theme ?? "default",
150
- disabled: options.disabled ?? false,
335
+ disabled: options.disabled ?? nativeSelect?.disabled ?? false,
336
+ required: options.required ?? nativeSelect?.required ?? false,
151
337
  data: options.data,
152
338
  ajax: options.ajax,
153
339
  templateResult: options.templateResult,
154
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,
155
345
  virtualScroll: options.virtualScroll,
156
346
  itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
157
347
  language: options.language ?? "en",
158
- plugins: options.plugins ?? []
348
+ plugins: options.plugins ?? [],
349
+ openOnFocus: options.openOnFocus ?? false,
350
+ dropdownParent: options.dropdownParent
159
351
  };
160
352
  this.strings = getStrings(this.opts.language);
161
353
  this.plugins = this.opts.plugins;
354
+ if (nativeSelect) nativeSelect.required = this.opts.required;
162
355
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
163
356
  if (nativeSelect && !this.opts.data) {
164
- for (const option of Array.from(nativeSelect.querySelectorAll("option"))) {
165
- if (option.hasAttribute("selected")) this.selectValue(option.value, false);
357
+ const nativeOptions = Array.from(nativeSelect.options);
358
+ const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
359
+ for (const option of nativeOptions) {
360
+ if (hasIntentionalSelection && option.selected) this.selectValue(option.value, false);
166
361
  }
167
362
  }
168
363
  this.buildDom();
169
364
  this.renderValue();
170
365
  if (this.opts.disabled) this.disable();
366
+ nativeSelect?.addEventListener("change", this.onNativeChange);
367
+ nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
368
+ this.nativeForm?.addEventListener("reset", this.onFormReset);
171
369
  for (const plugin of this.plugins) plugin.onInit?.(this);
172
370
  }
371
+ applyNativeValues(values) {
372
+ this.selected = [];
373
+ for (const value of this.opts.multiple ? values : values.slice(0, 1)) this.selectValue(value, false);
374
+ this.renderValue();
375
+ if (this.isOpen) this.renderList();
376
+ this.emitter.emit("change", this.getValue());
377
+ }
173
378
  // ---------------------------------------------------------------- public API
174
379
  open() {
175
380
  if (this.isOpen || this.isDisabled || this.destroyed) return;
@@ -182,7 +387,10 @@ var ForgeSelect = class {
182
387
  this.scheduleRemoteLoad(this.query, 0);
183
388
  }
184
389
  this.renderList();
185
- 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();
186
394
  this.emitter.emit("open");
187
395
  for (const plugin of this.plugins) plugin.onOpen?.(this);
188
396
  }
@@ -191,8 +399,11 @@ var ForgeSelect = class {
191
399
  this.isOpen = false;
192
400
  this.dropdown.hidden = true;
193
401
  this.root.classList.remove("forge-select--open");
402
+ this.root.classList.remove("forge-select--drop-up");
194
403
  this.control.setAttribute("aria-expanded", "false");
195
404
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
405
+ window.removeEventListener("resize", this.onWindowResize);
406
+ document.removeEventListener("scroll", this.onAncestorScroll, true);
196
407
  this.highlightedIndex = -1;
197
408
  if (this.searchInput) {
198
409
  this.searchInput.value = "";
@@ -201,34 +412,104 @@ var ForgeSelect = class {
201
412
  this.emitter.emit("close");
202
413
  for (const plugin of this.plugins) plugin.onClose?.(this);
203
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
+ }
204
432
  destroy() {
205
433
  if (this.destroyed) return;
206
434
  this.close();
207
435
  for (const plugin of this.plugins) plugin.onDestroy?.(this);
208
436
  this.destroyed = true;
209
437
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
438
+ this.ajaxController?.abort();
439
+ this.nativeSelect?.removeEventListener("change", this.onNativeChange);
440
+ this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
441
+ this.nativeForm?.removeEventListener("reset", this.onFormReset);
210
442
  this.rowContentCache.clear();
443
+ this.portalHost?.remove();
211
444
  this.root.remove();
212
- this.el.style.display = "";
445
+ this.el.style.display = this.originalDisplay;
446
+ if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
213
447
  this.emitter.clear();
214
448
  }
215
449
  getValue() {
216
450
  if (this.opts.multiple) return [...this.selected];
217
451
  return this.selected[0] ?? null;
218
452
  }
219
- setValue(value) {
453
+ setValue(value, options = {}) {
220
454
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
221
455
  const next = this.opts.multiple ? values : values.slice(0, 1);
222
456
  if (arraysEqual(next, this.selected)) return;
223
457
  this.selected = [];
224
458
  for (const v of next) this.selectValue(v, false);
459
+ this.afterSelectionChange(options.emitChange ?? true);
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
+ }
225
501
  this.afterSelectionChange();
226
502
  }
503
+ /** Clears every selection. Equivalent to `setValue(null)`. */
504
+ clearAll() {
505
+ this.clearSelection();
506
+ }
227
507
  enable() {
228
508
  this.isDisabled = false;
229
509
  this.root.classList.remove("forge-select--disabled");
230
510
  this.control.tabIndex = 0;
231
511
  this.control.setAttribute("aria-disabled", "false");
512
+ if (this.nativeSelect) this.nativeSelect.disabled = false;
232
513
  }
233
514
  disable() {
234
515
  this.close();
@@ -236,6 +517,7 @@ var ForgeSelect = class {
236
517
  this.root.classList.add("forge-select--disabled");
237
518
  this.control.tabIndex = -1;
238
519
  this.control.setAttribute("aria-disabled", "true");
520
+ if (this.nativeSelect) this.nativeSelect.disabled = true;
239
521
  }
240
522
  on(event, handler) {
241
523
  this.emitter.on(event, handler);
@@ -244,18 +526,59 @@ var ForgeSelect = class {
244
526
  this.emitter.off(event, handler);
245
527
  }
246
528
  // ---------------------------------------------------------------- DOM setup
529
+ /**
530
+ * The original target (a hidden native <select> or a plain mount div) can
531
+ * carry an accessible name via aria-label/aria-labelledby, or via a
532
+ * <label for> pointing at its id — but once `this.el` is display:none it
533
+ * drops out of the accessibility tree, so any such association silently
534
+ * stops reaching assistive tech unless we forward it onto the visible,
535
+ * interactive `this.control` ourselves.
536
+ */
537
+ applyAccessibleName() {
538
+ const ariaLabelledby = this.el.getAttribute("aria-labelledby");
539
+ const ariaLabel = this.el.getAttribute("aria-label");
540
+ if (ariaLabelledby) {
541
+ this.control.setAttribute("aria-labelledby", ariaLabelledby);
542
+ } else if (ariaLabel) {
543
+ this.control.setAttribute("aria-label", ariaLabel);
544
+ } else if (this.el.id) {
545
+ const label = Array.from(document.getElementsByTagName("label")).find((el) => el.htmlFor === this.el.id);
546
+ if (label) {
547
+ if (!label.id) label.id = `${this.uid}-label`;
548
+ this.control.setAttribute("aria-labelledby", label.id);
549
+ }
550
+ }
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
+ }
247
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
+ }
248
568
  this.root = document.createElement("div");
249
569
  this.root.className = "forge-select";
250
570
  this.root.dataset.theme = this.opts.theme;
251
571
  this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
572
+ if (this.opts.sortable && this.opts.multiple) this.root.classList.add("forge-select--sortable");
252
573
  this.control = document.createElement("div");
253
574
  this.control.className = "forge-select__control";
254
575
  this.control.setAttribute("role", "combobox");
255
576
  this.control.setAttribute("aria-haspopup", "listbox");
256
577
  this.control.setAttribute("aria-expanded", "false");
257
578
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
579
+ if (this.opts.required) this.control.setAttribute("aria-required", "true");
258
580
  this.control.tabIndex = 0;
581
+ this.applyAccessibleName();
259
582
  this.valueEl = document.createElement("div");
260
583
  this.valueEl.className = "forge-select__value";
261
584
  this.clearBtn = document.createElement("button");
@@ -278,6 +601,7 @@ var ForgeSelect = class {
278
601
  this.searchInput.setAttribute("aria-label", this.strings.search);
279
602
  this.searchInput.setAttribute("aria-autocomplete", "list");
280
603
  this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
604
+ this.searchInput.hidden = !this.shouldShowSearch();
281
605
  this.dropdown.append(this.searchInput);
282
606
  }
283
607
  this.list = document.createElement("ul");
@@ -286,18 +610,45 @@ var ForgeSelect = class {
286
610
  this.list.setAttribute("role", "listbox");
287
611
  if (this.opts.multiple) this.list.setAttribute("aria-multiselectable", "true");
288
612
  this.dropdown.append(this.list);
289
- this.root.append(this.control, this.dropdown);
613
+ this.liveRegion = document.createElement("div");
614
+ this.liveRegion.className = "forge-select__sr-only";
615
+ this.liveRegion.setAttribute("role", "status");
616
+ this.liveRegion.setAttribute("aria-live", "polite");
617
+ this.root.append(this.control, this.liveRegion);
618
+ if (!portalParent) this.root.append(this.dropdown);
290
619
  this.el.style.display = "none";
291
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
+ }
292
629
  this.bindEvents();
293
630
  }
294
631
  bindEvents() {
295
632
  this.control.addEventListener("click", (event) => {
296
633
  if (event.target === this.clearBtn) return;
634
+ if (this.suppressNextTagClick) {
635
+ this.suppressNextTagClick = false;
636
+ return;
637
+ }
297
638
  if (this.isDisabled) return;
298
- this.isOpen ? this.close() : this.open();
639
+ if (this.isOpen) this.close();
640
+ else this.open();
299
641
  });
300
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
+ });
301
652
  this.clearBtn.addEventListener("click", (event) => {
302
653
  event.stopPropagation();
303
654
  this.clearSelection();
@@ -308,13 +659,45 @@ var ForgeSelect = class {
308
659
  this.highlightedIndex = -1;
309
660
  this.list.scrollTop = 0;
310
661
  this.emitter.emit("search", this.query);
311
- 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) {
312
665
  this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
313
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
+ }
314
675
  this.renderList();
315
676
  }
316
677
  });
317
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
+ });
318
701
  }
319
702
  this.list.addEventListener("click", (event) => {
320
703
  const target = event.target;
@@ -327,7 +710,12 @@ var ForgeSelect = class {
327
710
  return;
328
711
  }
329
712
  const li = target.closest("li[data-nav-index]");
330
- 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
+ }
331
719
  const navIndex = Number(li.dataset.navIndex);
332
720
  this.activateNavItem(navIndex);
333
721
  });
@@ -366,42 +754,73 @@ var ForgeSelect = class {
366
754
  this.control.focus();
367
755
  }
368
756
  break;
757
+ case "ArrowRight":
758
+ if (this.isOpen && this.navigateTree("right")) event.preventDefault();
759
+ break;
760
+ case "ArrowLeft":
761
+ if (this.isOpen && this.navigateTree("left")) event.preventDefault();
762
+ break;
369
763
  case "Tab":
370
764
  this.close();
371
765
  break;
372
766
  }
373
767
  }
374
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
+ }
375
788
  selectValue(value, notify) {
376
789
  if (this.selected.includes(value)) return;
377
790
  const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
378
791
  this.selectedOptions.set(value, option);
379
792
  if (this.opts.multiple) {
380
793
  this.selected.push(value);
381
- for (const v of collectDescendantValues(option)) {
794
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
382
795
  if (!this.selected.includes(v)) this.selected.push(v);
383
796
  }
384
797
  this.syncTreeAncestors();
385
798
  } else {
386
799
  this.selected = [value];
387
800
  }
388
- if (notify) this.afterSelectionChange();
801
+ if (notify) {
802
+ this.afterSelectionChange();
803
+ this.emitter.emit("select", option);
804
+ }
389
805
  }
390
806
  deselectValue(value, notify) {
391
807
  const index = this.selected.indexOf(value);
392
808
  if (index === -1) return;
809
+ const option = this.findOption(value) ?? this.selectedOptions.get(value);
393
810
  this.selected.splice(index, 1);
394
811
  if (this.opts.multiple) {
395
- const option = this.findOption(value) ?? this.selectedOptions.get(value);
396
812
  if (option) {
397
- for (const v of collectDescendantValues(option)) {
813
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
398
814
  const i = this.selected.indexOf(v);
399
815
  if (i !== -1) this.selected.splice(i, 1);
400
816
  }
401
817
  }
402
818
  this.syncTreeAncestors();
403
819
  }
404
- if (notify) this.afterSelectionChange();
820
+ if (notify) {
821
+ this.afterSelectionChange();
822
+ this.emitter.emit("unselect", option ?? { value, label: value });
823
+ }
405
824
  }
406
825
  /**
407
826
  * Keeps every tree parent's own membership in `selected` consistent with
@@ -410,18 +829,7 @@ var ForgeSelect = class {
410
829
  * No-op for data with no `children` anywhere.
411
830
  */
412
831
  syncTreeAncestors() {
413
- const sync = (option) => {
414
- if (!option.children || option.children.length === 0) return;
415
- for (const child of option.children) sync(child);
416
- const state = computeCheckState(option, this.selected);
417
- const index = this.selected.indexOf(option.value);
418
- if (state === "all" && index === -1) this.selected.push(option.value);
419
- else if (state !== "all" && index !== -1) this.selected.splice(index, 1);
420
- };
421
- for (const item of this.data) {
422
- const options = isGroup(item) ? item.options : [item];
423
- options.forEach(sync);
424
- }
832
+ syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
425
833
  }
426
834
  clearSelection() {
427
835
  if (this.selected.length === 0) return;
@@ -429,13 +837,26 @@ var ForgeSelect = class {
429
837
  this.emitter.emit("clear");
430
838
  this.afterSelectionChange();
431
839
  }
432
- afterSelectionChange() {
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
+ }
849
+ afterSelectionChange(emitChange = true) {
433
850
  this.renderValue();
434
- this.syncNativeSelect();
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
+ }
435
856
  if (this.isOpen) this.renderList();
436
- this.emitter.emit("change", this.getValue());
857
+ if (emitChange) this.emitter.emit("change", this.getValue());
437
858
  }
438
- syncNativeSelect() {
859
+ syncNativeSelect(dispatchChange = true) {
439
860
  if (!(this.el instanceof HTMLSelectElement)) return;
440
861
  const existing = /* @__PURE__ */ new Set();
441
862
  for (const option of Array.from(this.el.options)) {
@@ -450,16 +871,30 @@ var ForgeSelect = class {
450
871
  option.selected = true;
451
872
  this.el.append(option);
452
873
  }
453
- this.el.dispatchEvent(new Event("change", { bubbles: true }));
874
+ if (this.opts.sortable && this.opts.multiple) {
875
+ for (const value of this.selected) {
876
+ const option = Array.from(this.el.options).find((o) => o.value === value);
877
+ if (option) this.el.append(option);
878
+ }
879
+ }
880
+ if (!dispatchChange) return;
881
+ this.syncingNative = true;
882
+ try {
883
+ this.el.dispatchEvent(new Event("change", { bubbles: true }));
884
+ } finally {
885
+ this.syncingNative = false;
886
+ }
454
887
  }
455
888
  findOption(value) {
889
+ return findOption(this.data, value);
890
+ }
891
+ findOptionByLabel(label) {
892
+ const lower = label.toLowerCase();
456
893
  const search = (options) => {
457
894
  for (const option of options) {
458
- if (option.value === value) return option;
459
- if (option.children) {
460
- const found = search(option.children);
461
- if (found) return found;
462
- }
895
+ if (option.label.toLowerCase() === lower) return option;
896
+ const found = option.children ? search(option.children) : void 0;
897
+ if (found) return found;
463
898
  }
464
899
  return void 0;
465
900
  };
@@ -469,17 +904,42 @@ var ForgeSelect = class {
469
904
  }
470
905
  return void 0;
471
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
+ }
472
930
  createFromQuery() {
473
931
  const label = this.query.trim();
474
932
  if (!label) return;
475
- const option = { value: label, label };
476
- this.data.push(option);
933
+ const result = this.createTag(label);
934
+ if (!result) return;
477
935
  if (this.searchInput) {
478
936
  this.searchInput.value = "";
479
937
  this.query = "";
480
938
  }
481
- this.selectValue(option.value, true);
482
- 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();
483
943
  }
484
944
  activateNavItem(navIndex) {
485
945
  const item = this.navItems[navIndex];
@@ -490,7 +950,17 @@ var ForgeSelect = class {
490
950
  }
491
951
  const { value } = item.option;
492
952
  if (this.opts.multiple) {
493
- this.selected.includes(value) ? this.deselectValue(value, true) : 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();
494
964
  } else {
495
965
  this.selectValue(value, true);
496
966
  this.close();
@@ -516,7 +986,7 @@ var ForgeSelect = class {
516
986
  tag.className = "forge-select__tag";
517
987
  const label = document.createElement("span");
518
988
  label.className = "forge-select__tag-label";
519
- this.renderTemplate(label, option, this.opts.templateSelection, "inline");
989
+ renderOptionContent(label, option, this.opts.templateSelection, "inline");
520
990
  const remove = document.createElement("button");
521
991
  remove.type = "button";
522
992
  remove.className = "forge-select__tag-remove";
@@ -527,6 +997,14 @@ var ForgeSelect = class {
527
997
  if (!this.isDisabled) this.deselectValue(value, true);
528
998
  });
529
999
  tag.append(label, remove);
1000
+ if (this.opts.sortable) {
1001
+ tag.dataset.value = value;
1002
+ tag.tabIndex = 0;
1003
+ tag.setAttribute("aria-roledescription", "draggable item");
1004
+ tag.setAttribute("aria-label", format(this.strings.reorderHint, { label: option.label }));
1005
+ tag.addEventListener("keydown", (event) => this.handleTagKeydown(event, value));
1006
+ this.bindTagDrag(tag, value);
1007
+ }
530
1008
  this.valueEl.append(tag);
531
1009
  }
532
1010
  } else {
@@ -536,59 +1014,116 @@ var ForgeSelect = class {
536
1014
  };
537
1015
  const span = document.createElement("span");
538
1016
  span.className = "forge-select__single-value";
539
- this.renderTemplate(span, option, this.opts.templateSelection, "inline");
1017
+ renderOptionContent(span, option, this.opts.templateSelection, "inline");
540
1018
  this.valueEl.append(span);
541
1019
  }
542
1020
  }
543
- renderTemplate(container, option, template, variant = "row") {
544
- if (template) {
545
- const result = template(option);
546
- if (typeof result === "string") container.innerHTML = result;
547
- else container.append(result);
548
- return;
549
- }
550
- if (!option.avatar && !option.description) {
551
- container.textContent = option.label;
552
- return;
553
- }
554
- if (option.avatar) {
555
- const avatar = document.createElement("img");
556
- avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
557
- avatar.src = option.avatar;
558
- avatar.alt = "";
559
- avatar.setAttribute("loading", "lazy");
560
- avatar.setAttribute("decoding", "async");
561
- container.append(avatar);
562
- }
563
- if (variant === "row" && option.description) {
564
- const body = document.createElement("span");
565
- body.className = "forge-select__option-body";
566
- const label = document.createElement("span");
567
- label.className = "forge-select__option-label";
568
- label.textContent = option.label;
569
- const desc = document.createElement("span");
570
- desc.className = "forge-select__option-desc";
571
- desc.textContent = option.description;
572
- body.append(label, desc);
573
- container.append(body);
574
- } else {
575
- const label = document.createElement("span");
576
- label.className = "forge-select__option-label";
577
- label.textContent = option.label;
578
- container.append(label);
1021
+ /**
1022
+ * Pointer-based (mouse/touch/pen) reorder for a single tag. Only the real
1023
+ * dragged DOM node is moved during the gesture — a full renderValue()
1024
+ * mid-drag would destroy it — so the reordered `this.selected` is only
1025
+ * committed on release. The move/up listeners and pointer capture live on
1026
+ * the stable `this.valueEl` container rather than the tag itself: `tag`
1027
+ * gets repositioned via `insertBefore` during the drag, and browsers treat
1028
+ * that reparenting as detaching the node, which silently drops pointer
1029
+ * capture (and further move events) if it were captured on `tag`.
1030
+ */
1031
+ bindTagDrag(tag, value) {
1032
+ const DRAG_THRESHOLD = 4;
1033
+ let startX = 0;
1034
+ let dragging = false;
1035
+ let order = [];
1036
+ const onPointerMove = (event) => {
1037
+ if (!dragging) {
1038
+ if (Math.abs(event.clientX - startX) < DRAG_THRESHOLD) return;
1039
+ dragging = true;
1040
+ order = [...this.selected];
1041
+ if (typeof this.valueEl.setPointerCapture === "function") {
1042
+ this.valueEl.setPointerCapture(event.pointerId);
1043
+ }
1044
+ tag.classList.add("forge-select__tag--dragging");
1045
+ }
1046
+ event.preventDefault();
1047
+ const draggedIndex = order.indexOf(value);
1048
+ const siblings = Array.from(this.valueEl.querySelectorAll(".forge-select__tag"));
1049
+ for (const sibling of siblings) {
1050
+ if (sibling === tag) continue;
1051
+ const siblingValue = sibling.dataset.value;
1052
+ if (!siblingValue) continue;
1053
+ const siblingIndex = order.indexOf(siblingValue);
1054
+ if (siblingIndex === -1) continue;
1055
+ const rect = sibling.getBoundingClientRect();
1056
+ const midX = rect.left + rect.width / 2;
1057
+ const movingRight = draggedIndex < siblingIndex;
1058
+ const crossed = movingRight ? event.clientX > midX : event.clientX < midX;
1059
+ if (!crossed) continue;
1060
+ order.splice(draggedIndex, 1);
1061
+ order.splice(siblingIndex, 0, value);
1062
+ if (movingRight) this.valueEl.insertBefore(tag, sibling.nextSibling);
1063
+ else this.valueEl.insertBefore(tag, sibling);
1064
+ break;
1065
+ }
1066
+ };
1067
+ const finishDrag = (event) => {
1068
+ this.valueEl.removeEventListener("pointermove", onPointerMove);
1069
+ this.valueEl.removeEventListener("pointerup", finishDrag);
1070
+ this.valueEl.removeEventListener("pointercancel", finishDrag);
1071
+ if (!dragging) return;
1072
+ if (typeof this.valueEl.releasePointerCapture === "function") {
1073
+ this.valueEl.releasePointerCapture(event.pointerId);
1074
+ }
1075
+ tag.classList.remove("forge-select__tag--dragging");
1076
+ this.selected = order;
1077
+ this.suppressNextTagClick = true;
1078
+ this.afterSelectionChange();
1079
+ this.emitter.emit("reorder", [...this.selected]);
1080
+ };
1081
+ tag.addEventListener("pointerdown", (event) => {
1082
+ if (this.isDisabled || event.button !== 0) return;
1083
+ if (event.target.closest(".forge-select__tag-remove")) return;
1084
+ startX = event.clientX;
1085
+ dragging = false;
1086
+ this.valueEl.addEventListener("pointermove", onPointerMove);
1087
+ this.valueEl.addEventListener("pointerup", finishDrag);
1088
+ this.valueEl.addEventListener("pointercancel", finishDrag);
1089
+ });
1090
+ }
1091
+ /** Alt+Left/Alt+Right on a focused tag: the keyboard-operable equivalent of dragging. */
1092
+ handleTagKeydown(event, value) {
1093
+ if (!event.altKey || event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
1094
+ const index = this.selected.indexOf(value);
1095
+ const targetIndex = event.key === "ArrowLeft" ? index - 1 : index + 1;
1096
+ if (index === -1 || targetIndex < 0 || targetIndex >= this.selected.length) return;
1097
+ event.preventDefault();
1098
+ event.stopPropagation();
1099
+ const next = [...this.selected];
1100
+ [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
1101
+ this.selected = next;
1102
+ this.afterSelectionChange();
1103
+ this.emitter.emit("reorder", [...this.selected]);
1104
+ this.focusTagByValue(value);
1105
+ }
1106
+ focusTagByValue(value) {
1107
+ for (const tag of Array.from(this.valueEl.querySelectorAll(".forge-select__tag"))) {
1108
+ if (tag.dataset.value === value) {
1109
+ tag.focus();
1110
+ return;
1111
+ }
579
1112
  }
580
1113
  }
581
1114
  buildRows() {
582
1115
  this.rows = [];
583
1116
  this.navItems = [];
584
- const query = this.query.trim().toLowerCase();
585
- 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));
586
1120
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
587
- const pushOption = (option, depth) => {
1121
+ const pushOption = (option, depth, parentValue) => {
588
1122
  let navIndex = -1;
589
- if (!option.disabled) {
1123
+ const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
1124
+ if (!interactionDisabled) {
590
1125
  navIndex = this.navItems.length;
591
- this.navItems.push({ kind: "option", option });
1126
+ this.navItems.push({ kind: "option", option, parentValue });
592
1127
  }
593
1128
  const hasChildren = !!option.children && option.children.length > 0;
594
1129
  this.rows.push({ kind: "option", option, navIndex, depth, hasChildren });
@@ -596,15 +1131,23 @@ var ForgeSelect = class {
596
1131
  const expanded = query !== "" || this.expandedValues.has(option.value);
597
1132
  if (expanded) {
598
1133
  for (const child of option.children) {
599
- if (subtreeMatches(child)) pushOption(child, depth + 1);
1134
+ if (subtreeMatches(child)) pushOption(child, depth + 1, option.value);
600
1135
  }
601
1136
  }
602
1137
  }
603
1138
  };
1139
+ if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
1140
+ this.rows.push({ kind: "min-length" });
1141
+ return;
1142
+ }
604
1143
  if (this.loading) {
605
1144
  this.rows.push({ kind: "loading" });
606
1145
  return;
607
1146
  }
1147
+ if (this.loadError) {
1148
+ this.rows.push({ kind: "error" });
1149
+ return;
1150
+ }
608
1151
  for (const item of this.data) {
609
1152
  if (isGroup(item)) {
610
1153
  const visible = item.options.filter(subtreeMatches);
@@ -624,12 +1167,7 @@ var ForgeSelect = class {
624
1167
  else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
625
1168
  }
626
1169
  hasExactMatch(lowerQuery) {
627
- const matchesExactly = (option) => option.label.toLowerCase() === lowerQuery || (option.children ?? []).some(matchesExactly);
628
- for (const item of this.data) {
629
- const options = isGroup(item) ? item.options : [item];
630
- if (options.some(matchesExactly)) return true;
631
- }
632
- return false;
1170
+ return !!this.findOptionByLabel(lowerQuery);
633
1171
  }
634
1172
  usesVirtualScroll() {
635
1173
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
@@ -637,6 +1175,12 @@ var ForgeSelect = class {
637
1175
  renderList() {
638
1176
  this.buildRows();
639
1177
  this.renderRows();
1178
+ this.announceStatus();
1179
+ }
1180
+ announceStatus() {
1181
+ const first = this.rows[0];
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) }) : "";
1183
+ if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
640
1184
  }
641
1185
  renderRows() {
642
1186
  const scrollTop = this.list.scrollTop;
@@ -681,10 +1225,30 @@ var ForgeSelect = class {
681
1225
  break;
682
1226
  case "empty":
683
1227
  li.className = "forge-select__empty";
1228
+ li.setAttribute("role", "option");
1229
+ li.setAttribute("aria-disabled", "true");
1230
+ li.setAttribute("aria-selected", "false");
684
1231
  li.textContent = this.strings.noResults;
685
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;
1240
+ case "error":
1241
+ li.className = "forge-select__error";
1242
+ li.setAttribute("role", "option");
1243
+ li.setAttribute("aria-disabled", "true");
1244
+ li.setAttribute("aria-selected", "false");
1245
+ li.textContent = this.strings.errorLoading;
1246
+ break;
686
1247
  case "loading":
687
1248
  li.className = "forge-select__loading";
1249
+ li.setAttribute("role", "option");
1250
+ li.setAttribute("aria-disabled", "true");
1251
+ li.setAttribute("aria-selected", "false");
688
1252
  li.textContent = this.strings.loading;
689
1253
  break;
690
1254
  case "loading-more":
@@ -702,17 +1266,19 @@ var ForgeSelect = class {
702
1266
  break;
703
1267
  case "option": {
704
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));
705
1271
  li.setAttribute("role", "option");
706
1272
  const isSelected = this.selected.includes(row.option.value);
707
1273
  li.setAttribute("aria-selected", String(isSelected));
708
1274
  if (isSelected) li.classList.add("forge-select__option--selected");
709
- 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") {
710
1276
  li.classList.add("forge-select__option--indeterminate");
711
1277
  }
712
1278
  if (row.depth > 0) {
713
1279
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
714
1280
  }
715
- if (row.option.disabled) {
1281
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
716
1282
  li.classList.add("forge-select__option--disabled");
717
1283
  li.setAttribute("aria-disabled", "true");
718
1284
  } else {
@@ -721,11 +1287,13 @@ var ForgeSelect = class {
721
1287
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
722
1288
  }
723
1289
  if (row.hasChildren) {
1290
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1291
+ li.setAttribute("aria-expanded", String(expanded));
724
1292
  const twisty = document.createElement("span");
725
1293
  twisty.className = "forge-select__twisty";
726
1294
  twisty.dataset.twisty = row.option.value;
727
1295
  twisty.setAttribute("aria-hidden", "true");
728
- twisty.textContent = this.expandedValues.has(row.option.value) ? "\u25BC" : "\u25B6";
1296
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
729
1297
  li.append(twisty);
730
1298
  }
731
1299
  li.append(this.optionContent(row.option));
@@ -745,7 +1313,7 @@ var ForgeSelect = class {
745
1313
  if (!cached) {
746
1314
  const holder = document.createElement("span");
747
1315
  holder.className = "forge-select__option-content";
748
- this.renderTemplate(holder, option, this.opts.templateResult);
1316
+ renderOptionContent(holder, option, this.opts.templateResult);
749
1317
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
750
1318
  const oldest = this.rowContentCache.keys().next().value;
751
1319
  this.rowContentCache.delete(oldest);
@@ -757,7 +1325,10 @@ var ForgeSelect = class {
757
1325
  }
758
1326
  moveHighlight(delta) {
759
1327
  if (this.navItems.length === 0) return;
760
- const next = this.highlightedIndex === -1 && delta > 0 ? 0 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
1328
+ const next = this.highlightedIndex === -1 ? delta > 0 ? 0 : this.navItems.length - 1 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
1329
+ this.focusNavIndex(next);
1330
+ }
1331
+ focusNavIndex(next) {
761
1332
  this.highlightedIndex = next;
762
1333
  if (this.usesVirtualScroll()) {
763
1334
  const rowIndex = this.rows.findIndex(
@@ -779,6 +1350,41 @@ var ForgeSelect = class {
779
1350
  highlighted?.scrollIntoView?.({ block: "nearest" });
780
1351
  }
781
1352
  }
1353
+ navigateTree(direction) {
1354
+ const item = this.navItems[this.highlightedIndex];
1355
+ if (!item || item.kind !== "option") return false;
1356
+ const { option, parentValue } = item;
1357
+ const hasChildren = !!option.children?.length;
1358
+ const expanded = this.query !== "" || this.expandedValues.has(option.value);
1359
+ if (direction === "right") {
1360
+ if (hasChildren && !expanded) {
1361
+ this.expandedValues.add(option.value);
1362
+ this.renderList();
1363
+ return true;
1364
+ }
1365
+ if (hasChildren) {
1366
+ const childIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.parentValue === option.value);
1367
+ if (childIndex >= 0) {
1368
+ this.focusNavIndex(childIndex);
1369
+ return true;
1370
+ }
1371
+ }
1372
+ return false;
1373
+ }
1374
+ if (hasChildren && expanded && this.query === "") {
1375
+ this.expandedValues.delete(option.value);
1376
+ this.renderList();
1377
+ return true;
1378
+ }
1379
+ if (parentValue) {
1380
+ const parentIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.option.value === parentValue);
1381
+ if (parentIndex >= 0) {
1382
+ this.focusNavIndex(parentIndex);
1383
+ return true;
1384
+ }
1385
+ }
1386
+ return false;
1387
+ }
782
1388
  updateActiveDescendant() {
783
1389
  const target = this.searchInput ?? this.control;
784
1390
  if (this.highlightedIndex >= 0) {
@@ -790,12 +1396,18 @@ var ForgeSelect = class {
790
1396
  // ---------------------------------------------------------------- remote data
791
1397
  scheduleRemoteLoad(query, delay) {
792
1398
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
1399
+ const requestId = ++this.ajaxRequestId;
1400
+ this.ajaxController?.abort();
1401
+ this.ajaxController = null;
793
1402
  this.page = 0;
794
1403
  this.hasMore = true;
795
1404
  this.loading = true;
1405
+ this.loadingMore = false;
1406
+ this.loadError = null;
796
1407
  this.renderList();
797
1408
  this.ajaxTimer = setTimeout(() => {
798
- void this.loadRemote(query);
1409
+ this.ajaxTimer = null;
1410
+ void this.loadRemote(query, { requestId });
799
1411
  }, delay);
800
1412
  }
801
1413
  /**
@@ -813,18 +1425,26 @@ var ForgeSelect = class {
813
1425
  this.renderList();
814
1426
  void this.loadRemote(this.query, { append: true });
815
1427
  }
816
- async loadRemote(query, { append = false } = {}) {
1428
+ async loadRemote(query, { append = false, requestId } = {}) {
817
1429
  const ajax = this.opts.ajax;
818
- const requestId = ++this.ajaxRequestId;
1430
+ const activeRequestId = requestId ?? ++this.ajaxRequestId;
1431
+ if (activeRequestId !== this.ajaxRequestId) return;
1432
+ this.ajaxController?.abort();
1433
+ const controller = new AbortController();
1434
+ this.ajaxController = controller;
819
1435
  const page = append ? this.page + 1 : 0;
820
1436
  try {
821
- const url = buildUrl(ajax, query, page);
822
- const response = await fetch(url);
823
- const json = await response.json();
824
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
825
- const result = ajax.transform ? ajax.transform(json) : json;
826
- const options = Array.isArray(result) ? result : result.options;
827
- const hasMore = ajax.pagination ? Array.isArray(result) ? false : result.hasMore : false;
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
+ }
1446
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1447
+ const { options, hasMore } = normalizeRemoteResult(ajax, json);
828
1448
  if (append) {
829
1449
  const existing = collectValues(this.data);
830
1450
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
@@ -835,15 +1455,20 @@ var ForgeSelect = class {
835
1455
  this.page = page;
836
1456
  this.hasMore = hasMore;
837
1457
  this.remoteLoaded = true;
838
- } catch {
839
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
1458
+ this.loadError = null;
1459
+ } catch (cause) {
1460
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1461
+ const error = cause instanceof Error ? cause : new Error(String(cause));
840
1462
  if (!append) {
841
1463
  this.data = [];
842
1464
  this.rowContentCache.clear();
843
1465
  }
844
1466
  this.hasMore = false;
1467
+ this.loadError = error;
1468
+ this.emitter.emit("error", error);
845
1469
  } finally {
846
- if (requestId === this.ajaxRequestId && !this.destroyed) {
1470
+ if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1471
+ this.ajaxController = null;
847
1472
  this.loading = false;
848
1473
  this.loadingMore = false;
849
1474
  if (this.isOpen) this.renderList();
@@ -851,51 +1476,6 @@ var ForgeSelect = class {
851
1476
  }
852
1477
  }
853
1478
  };
854
- function parseNativeOptions(select) {
855
- const data = [];
856
- for (const child of Array.from(select.children)) {
857
- if (child instanceof HTMLOptGroupElement) {
858
- data.push({
859
- label: child.label,
860
- options: Array.from(child.querySelectorAll("option")).map(parseOption)
861
- });
862
- } else if (child instanceof HTMLOptionElement) {
863
- data.push(parseOption(child));
864
- }
865
- }
866
- return data;
867
- }
868
- function parseOption(option) {
869
- return {
870
- value: option.value,
871
- label: option.textContent?.trim() ?? option.value,
872
- disabled: option.disabled || void 0
873
- };
874
- }
875
- function buildUrl(ajax, query, page) {
876
- if (typeof ajax.url === "function") return ajax.url(query);
877
- if (!ajax.params) return ajax.url;
878
- const params = new URLSearchParams();
879
- for (const [key, value] of Object.entries(ajax.params(query, page))) {
880
- params.set(key, String(value));
881
- }
882
- const separator = ajax.url.includes("?") ? "&" : "?";
883
- return `${ajax.url}${separator}${params.toString()}`;
884
- }
885
- function collectValues(items) {
886
- const values = /* @__PURE__ */ new Set();
887
- for (const item of items) {
888
- if (isGroup(item)) {
889
- for (const option of item.options) values.add(option.value);
890
- } else {
891
- values.add(item.value);
892
- }
893
- }
894
- return values;
895
- }
896
- function arraysEqual(a, b) {
897
- return a.length === b.length && a.every((value, index) => value === b[index]);
898
- }
899
1479
  // Annotate the CommonJS export names for ESM import in node:
900
1480
  0 && (module.exports = {
901
1481
  ForgeSelect