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.js CHANGED
@@ -24,25 +24,44 @@ 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: {
30
41
  noResults: "No results found",
31
42
  loading: "Loading\u2026",
32
43
  loadingMore: "Loading more\u2026",
44
+ errorLoading: "Could not load options",
33
45
  createOption: 'Create "{query}"',
34
46
  clearSelection: "Clear selection",
35
47
  removeItem: "Remove {label}",
36
- search: "Search"
48
+ search: "Search",
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"
37
52
  },
38
53
  vi: {
39
54
  noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
40
55
  loading: "\u0110ang t\u1EA3i\u2026",
41
56
  loadingMore: "\u0110ang t\u1EA3i th\xEAm\u2026",
57
+ errorLoading: "Kh\xF4ng th\u1EC3 t\u1EA3i t\xF9y ch\u1ECDn",
42
58
  createOption: 'T\u1EA1o "{query}"',
43
59
  clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
44
60
  removeItem: "X\xF3a {label}",
45
- search: "T\xECm ki\u1EBFm"
61
+ search: "T\xECm ki\u1EBFm",
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"
46
65
  }
47
66
  };
48
67
  function getStrings(language) {
@@ -55,39 +74,164 @@ function format(template, vars) {
55
74
  return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
56
75
  }
57
76
 
58
- // src/ForgeSelect.ts
59
- var DEFAULT_ITEM_HEIGHT = 36;
60
- var VIRTUAL_BUFFER = 5;
61
- var VIRTUAL_THRESHOLD = 100;
62
- var ROW_CACHE_LIMIT = 2e3;
63
- var uidCounter = 0;
77
+ // src/native-select.ts
78
+ function parseNativeOptions(select) {
79
+ const data = [];
80
+ for (const child of Array.from(select.children)) {
81
+ if (child instanceof HTMLOptGroupElement) {
82
+ data.push({ label: child.label, options: Array.from(child.querySelectorAll("option")).map(parseOption) });
83
+ } else if (child instanceof HTMLOptionElement) {
84
+ data.push(parseOption(child));
85
+ }
86
+ }
87
+ return data;
88
+ }
89
+ function parseOption(option) {
90
+ const groupDisabled = option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled;
91
+ return {
92
+ value: option.value,
93
+ label: option.textContent?.trim() ?? option.value,
94
+ disabled: option.disabled || groupDisabled || void 0
95
+ };
96
+ }
97
+
98
+ // src/option-renderer.ts
99
+ function renderOptionContent(container, option, template, variant = "row") {
100
+ if (template) {
101
+ const result = template(option);
102
+ if (typeof result === "string") container.innerHTML = result;
103
+ else container.append(result);
104
+ return;
105
+ }
106
+ if (!option.avatar && !option.description) {
107
+ container.textContent = option.label;
108
+ return;
109
+ }
110
+ if (option.avatar) {
111
+ const avatar = document.createElement("img");
112
+ avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
113
+ avatar.src = option.avatar;
114
+ avatar.alt = "";
115
+ avatar.setAttribute("loading", "lazy");
116
+ avatar.setAttribute("decoding", "async");
117
+ container.append(avatar);
118
+ }
119
+ if (variant === "row" && option.description) {
120
+ const body = document.createElement("span");
121
+ body.className = "forge-select__option-body";
122
+ const label = document.createElement("span");
123
+ label.className = "forge-select__option-label";
124
+ label.textContent = option.label;
125
+ const description = document.createElement("span");
126
+ description.className = "forge-select__option-desc";
127
+ description.textContent = option.description;
128
+ body.append(label, description);
129
+ container.append(body);
130
+ } else {
131
+ const label = document.createElement("span");
132
+ label.className = "forge-select__option-label";
133
+ label.textContent = option.label;
134
+ container.append(label);
135
+ }
136
+ }
137
+
138
+ // src/remote.ts
139
+ function buildUrl(ajax, query, page) {
140
+ if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
141
+ if (typeof ajax.url === "function") return ajax.url(query, page);
142
+ if (!ajax.params) return ajax.url;
143
+ const params = new URLSearchParams();
144
+ for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
145
+ const separator = ajax.url.includes("?") ? "&" : "?";
146
+ return `${ajax.url}${separator}${params.toString()}`;
147
+ }
148
+ function normalizeRemoteResult(ajax, response) {
149
+ const result = ajax.transform ? ajax.transform(response) : response;
150
+ if (Array.isArray(result)) return { options: result, hasMore: false };
151
+ if (!result || !Array.isArray(result.options)) {
152
+ throw new Error(
153
+ "ForgeSelect: ajax.transform must return an array of options, or an object shaped like { options: Option[], hasMore?: boolean }."
154
+ );
155
+ }
156
+ return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
157
+ }
158
+
159
+ // src/selection.ts
64
160
  function isGroup(item) {
65
161
  return item.options !== void 0;
66
162
  }
67
- function collectDescendantValues(option) {
163
+ var defaultIsDisabled = (option) => !!option.disabled;
164
+ function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
68
165
  if (!option.children) return [];
69
166
  const values = [];
70
167
  for (const child of option.children) {
71
- values.push(child.value, ...collectDescendantValues(child));
168
+ if (!isDisabled(child)) values.push(child.value);
169
+ values.push(...collectDescendantValues(child, isDisabled));
72
170
  }
73
171
  return values;
74
172
  }
75
- function computeCheckState(option, selected) {
76
- if (!option.children || option.children.length === 0) {
77
- return selected.includes(option.value) ? "all" : "none";
78
- }
79
- const states = option.children.map((child) => computeCheckState(child, selected));
80
- if (states.every((s) => s === "all")) return "all";
81
- if (states.every((s) => s === "none")) return "none";
173
+ function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
174
+ if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
175
+ const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
176
+ if (states.length === 0) return "none";
177
+ if (states.every((state) => state === "all")) return "all";
178
+ if (states.every((state) => state === "none")) return "none";
82
179
  return "some";
83
180
  }
181
+ function findOption(items, value) {
182
+ const search = (options) => {
183
+ for (const option of options) {
184
+ if (option.value === value) return option;
185
+ const found = option.children ? search(option.children) : void 0;
186
+ if (found) return found;
187
+ }
188
+ return void 0;
189
+ };
190
+ for (const item of items) {
191
+ const found = search(isGroup(item) ? item.options : [item]);
192
+ if (found) return found;
193
+ }
194
+ return void 0;
195
+ }
196
+ function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
197
+ const sync = (option) => {
198
+ if (!option.children?.length) return;
199
+ for (const child of option.children) sync(child);
200
+ const state = computeCheckState(option, selected, isDisabled);
201
+ const index = selected.indexOf(option.value);
202
+ if (state === "all" && index === -1) selected.push(option.value);
203
+ else if (state !== "all" && index !== -1) selected.splice(index, 1);
204
+ };
205
+ for (const item of items) (isGroup(item) ? item.options : [item]).forEach(sync);
206
+ }
207
+ function collectValues(items) {
208
+ const values = /* @__PURE__ */ new Set();
209
+ const visit = (option) => {
210
+ values.add(option.value);
211
+ option.children?.forEach(visit);
212
+ };
213
+ for (const item of items) (isGroup(item) ? item.options : [item]).forEach(visit);
214
+ return values;
215
+ }
216
+ function arraysEqual(a, b) {
217
+ return a.length === b.length && a.every((value, index) => value === b[index]);
218
+ }
219
+
220
+ // src/ForgeSelect.ts
221
+ var DEFAULT_ITEM_HEIGHT = 36;
222
+ var VIRTUAL_BUFFER = 5;
223
+ var VIRTUAL_THRESHOLD = 100;
224
+ var ROW_CACHE_LIMIT = 2e3;
225
+ var uidCounter = 0;
84
226
  var ForgeSelect = class {
85
227
  constructor(target, options = {}) {
86
228
  this.selected = [];
87
229
  this.selectedOptions = /* @__PURE__ */ new Map();
230
+ this.suppressNextTagClick = false;
88
231
  this.emitter = new Emitter();
89
232
  this.uid = `forge-select-${++uidCounter}`;
90
233
  this.searchInput = null;
234
+ this.portalHost = null;
91
235
  this.isOpen = false;
92
236
  this.isDisabled = false;
93
237
  this.destroyed = false;
@@ -103,9 +247,43 @@ var ForgeSelect = class {
103
247
  this.hasMore = true;
104
248
  this.ajaxTimer = null;
105
249
  this.ajaxRequestId = 0;
250
+ this.ajaxController = null;
106
251
  this.remoteLoaded = false;
252
+ this.loadError = null;
253
+ this.originalDisplay = "";
254
+ this.originalDisabled = false;
255
+ this.nativeSelect = null;
256
+ this.nativeForm = null;
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;
107
261
  this.onDocumentMouseDown = (event) => {
108
- 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();
277
+ };
278
+ this.onNativeChange = () => {
279
+ if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
280
+ const values = Array.from(this.nativeSelect.selectedOptions, (option) => option.value);
281
+ this.applyNativeValues(values);
282
+ };
283
+ this.onFormReset = () => {
284
+ if (!this.nativeSelect || this.destroyed) return;
285
+ const defaults = Array.from(this.nativeSelect.options).filter((option) => option.defaultSelected).map((option) => option.value);
286
+ this.applyNativeValues(defaults);
109
287
  };
110
288
  const el = typeof target === "string" ? document.querySelector(target) : target;
111
289
  if (!el) {
@@ -113,36 +291,63 @@ var ForgeSelect = class {
113
291
  }
114
292
  this.el = el;
115
293
  const nativeSelect = el instanceof HTMLSelectElement ? el : null;
294
+ this.nativeSelect = nativeSelect;
295
+ this.nativeForm = nativeSelect?.form ?? null;
296
+ this.originalDisplay = el.style.display;
297
+ this.originalDisabled = nativeSelect?.disabled ?? false;
116
298
  this.opts = {
117
299
  placeholder: options.placeholder ?? "",
118
300
  searchable: options.searchable ?? true,
119
301
  multiple: options.multiple ?? nativeSelect?.multiple ?? false,
120
302
  clearable: options.clearable ?? false,
121
303
  allowCreate: options.allowCreate ?? false,
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)),
122
307
  theme: options.theme ?? "default",
123
- disabled: options.disabled ?? false,
308
+ disabled: options.disabled ?? nativeSelect?.disabled ?? false,
309
+ required: options.required ?? nativeSelect?.required ?? false,
124
310
  data: options.data,
125
311
  ajax: options.ajax,
126
312
  templateResult: options.templateResult,
127
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,
128
318
  virtualScroll: options.virtualScroll,
129
319
  itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
130
320
  language: options.language ?? "en",
131
- plugins: options.plugins ?? []
321
+ plugins: options.plugins ?? [],
322
+ openOnFocus: options.openOnFocus ?? false,
323
+ dropdownParent: options.dropdownParent
132
324
  };
133
325
  this.strings = getStrings(this.opts.language);
134
326
  this.plugins = this.opts.plugins;
327
+ if (nativeSelect) nativeSelect.required = this.opts.required;
135
328
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
136
329
  if (nativeSelect && !this.opts.data) {
137
- for (const option of Array.from(nativeSelect.querySelectorAll("option"))) {
138
- if (option.hasAttribute("selected")) this.selectValue(option.value, false);
330
+ const nativeOptions = Array.from(nativeSelect.options);
331
+ const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
332
+ for (const option of nativeOptions) {
333
+ if (hasIntentionalSelection && option.selected) this.selectValue(option.value, false);
139
334
  }
140
335
  }
141
336
  this.buildDom();
142
337
  this.renderValue();
143
338
  if (this.opts.disabled) this.disable();
339
+ nativeSelect?.addEventListener("change", this.onNativeChange);
340
+ nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
341
+ this.nativeForm?.addEventListener("reset", this.onFormReset);
144
342
  for (const plugin of this.plugins) plugin.onInit?.(this);
145
343
  }
344
+ applyNativeValues(values) {
345
+ this.selected = [];
346
+ for (const value of this.opts.multiple ? values : values.slice(0, 1)) this.selectValue(value, false);
347
+ this.renderValue();
348
+ if (this.isOpen) this.renderList();
349
+ this.emitter.emit("change", this.getValue());
350
+ }
146
351
  // ---------------------------------------------------------------- public API
147
352
  open() {
148
353
  if (this.isOpen || this.isDisabled || this.destroyed) return;
@@ -155,7 +360,10 @@ var ForgeSelect = class {
155
360
  this.scheduleRemoteLoad(this.query, 0);
156
361
  }
157
362
  this.renderList();
158
- 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();
159
367
  this.emitter.emit("open");
160
368
  for (const plugin of this.plugins) plugin.onOpen?.(this);
161
369
  }
@@ -164,8 +372,11 @@ var ForgeSelect = class {
164
372
  this.isOpen = false;
165
373
  this.dropdown.hidden = true;
166
374
  this.root.classList.remove("forge-select--open");
375
+ this.root.classList.remove("forge-select--drop-up");
167
376
  this.control.setAttribute("aria-expanded", "false");
168
377
  document.removeEventListener("mousedown", this.onDocumentMouseDown);
378
+ window.removeEventListener("resize", this.onWindowResize);
379
+ document.removeEventListener("scroll", this.onAncestorScroll, true);
169
380
  this.highlightedIndex = -1;
170
381
  if (this.searchInput) {
171
382
  this.searchInput.value = "";
@@ -174,34 +385,104 @@ var ForgeSelect = class {
174
385
  this.emitter.emit("close");
175
386
  for (const plugin of this.plugins) plugin.onClose?.(this);
176
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
+ }
177
405
  destroy() {
178
406
  if (this.destroyed) return;
179
407
  this.close();
180
408
  for (const plugin of this.plugins) plugin.onDestroy?.(this);
181
409
  this.destroyed = true;
182
410
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
411
+ this.ajaxController?.abort();
412
+ this.nativeSelect?.removeEventListener("change", this.onNativeChange);
413
+ this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
414
+ this.nativeForm?.removeEventListener("reset", this.onFormReset);
183
415
  this.rowContentCache.clear();
416
+ this.portalHost?.remove();
184
417
  this.root.remove();
185
- this.el.style.display = "";
418
+ this.el.style.display = this.originalDisplay;
419
+ if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
186
420
  this.emitter.clear();
187
421
  }
188
422
  getValue() {
189
423
  if (this.opts.multiple) return [...this.selected];
190
424
  return this.selected[0] ?? null;
191
425
  }
192
- setValue(value) {
426
+ setValue(value, options = {}) {
193
427
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
194
428
  const next = this.opts.multiple ? values : values.slice(0, 1);
195
429
  if (arraysEqual(next, this.selected)) return;
196
430
  this.selected = [];
197
431
  for (const v of next) this.selectValue(v, false);
432
+ this.afterSelectionChange(options.emitChange ?? true);
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
+ }
198
474
  this.afterSelectionChange();
199
475
  }
476
+ /** Clears every selection. Equivalent to `setValue(null)`. */
477
+ clearAll() {
478
+ this.clearSelection();
479
+ }
200
480
  enable() {
201
481
  this.isDisabled = false;
202
482
  this.root.classList.remove("forge-select--disabled");
203
483
  this.control.tabIndex = 0;
204
484
  this.control.setAttribute("aria-disabled", "false");
485
+ if (this.nativeSelect) this.nativeSelect.disabled = false;
205
486
  }
206
487
  disable() {
207
488
  this.close();
@@ -209,6 +490,7 @@ var ForgeSelect = class {
209
490
  this.root.classList.add("forge-select--disabled");
210
491
  this.control.tabIndex = -1;
211
492
  this.control.setAttribute("aria-disabled", "true");
493
+ if (this.nativeSelect) this.nativeSelect.disabled = true;
212
494
  }
213
495
  on(event, handler) {
214
496
  this.emitter.on(event, handler);
@@ -217,18 +499,59 @@ var ForgeSelect = class {
217
499
  this.emitter.off(event, handler);
218
500
  }
219
501
  // ---------------------------------------------------------------- DOM setup
502
+ /**
503
+ * The original target (a hidden native <select> or a plain mount div) can
504
+ * carry an accessible name via aria-label/aria-labelledby, or via a
505
+ * <label for> pointing at its id — but once `this.el` is display:none it
506
+ * drops out of the accessibility tree, so any such association silently
507
+ * stops reaching assistive tech unless we forward it onto the visible,
508
+ * interactive `this.control` ourselves.
509
+ */
510
+ applyAccessibleName() {
511
+ const ariaLabelledby = this.el.getAttribute("aria-labelledby");
512
+ const ariaLabel = this.el.getAttribute("aria-label");
513
+ if (ariaLabelledby) {
514
+ this.control.setAttribute("aria-labelledby", ariaLabelledby);
515
+ } else if (ariaLabel) {
516
+ this.control.setAttribute("aria-label", ariaLabel);
517
+ } else if (this.el.id) {
518
+ const label = Array.from(document.getElementsByTagName("label")).find((el) => el.htmlFor === this.el.id);
519
+ if (label) {
520
+ if (!label.id) label.id = `${this.uid}-label`;
521
+ this.control.setAttribute("aria-labelledby", label.id);
522
+ }
523
+ }
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
+ }
220
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
+ }
221
541
  this.root = document.createElement("div");
222
542
  this.root.className = "forge-select";
223
543
  this.root.dataset.theme = this.opts.theme;
224
544
  this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
545
+ if (this.opts.sortable && this.opts.multiple) this.root.classList.add("forge-select--sortable");
225
546
  this.control = document.createElement("div");
226
547
  this.control.className = "forge-select__control";
227
548
  this.control.setAttribute("role", "combobox");
228
549
  this.control.setAttribute("aria-haspopup", "listbox");
229
550
  this.control.setAttribute("aria-expanded", "false");
230
551
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
552
+ if (this.opts.required) this.control.setAttribute("aria-required", "true");
231
553
  this.control.tabIndex = 0;
554
+ this.applyAccessibleName();
232
555
  this.valueEl = document.createElement("div");
233
556
  this.valueEl.className = "forge-select__value";
234
557
  this.clearBtn = document.createElement("button");
@@ -251,6 +574,7 @@ var ForgeSelect = class {
251
574
  this.searchInput.setAttribute("aria-label", this.strings.search);
252
575
  this.searchInput.setAttribute("aria-autocomplete", "list");
253
576
  this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
577
+ this.searchInput.hidden = !this.shouldShowSearch();
254
578
  this.dropdown.append(this.searchInput);
255
579
  }
256
580
  this.list = document.createElement("ul");
@@ -259,18 +583,45 @@ var ForgeSelect = class {
259
583
  this.list.setAttribute("role", "listbox");
260
584
  if (this.opts.multiple) this.list.setAttribute("aria-multiselectable", "true");
261
585
  this.dropdown.append(this.list);
262
- this.root.append(this.control, this.dropdown);
586
+ this.liveRegion = document.createElement("div");
587
+ this.liveRegion.className = "forge-select__sr-only";
588
+ this.liveRegion.setAttribute("role", "status");
589
+ this.liveRegion.setAttribute("aria-live", "polite");
590
+ this.root.append(this.control, this.liveRegion);
591
+ if (!portalParent) this.root.append(this.dropdown);
263
592
  this.el.style.display = "none";
264
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
+ }
265
602
  this.bindEvents();
266
603
  }
267
604
  bindEvents() {
268
605
  this.control.addEventListener("click", (event) => {
269
606
  if (event.target === this.clearBtn) return;
607
+ if (this.suppressNextTagClick) {
608
+ this.suppressNextTagClick = false;
609
+ return;
610
+ }
270
611
  if (this.isDisabled) return;
271
- this.isOpen ? this.close() : this.open();
612
+ if (this.isOpen) this.close();
613
+ else this.open();
272
614
  });
273
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
+ });
274
625
  this.clearBtn.addEventListener("click", (event) => {
275
626
  event.stopPropagation();
276
627
  this.clearSelection();
@@ -281,13 +632,45 @@ var ForgeSelect = class {
281
632
  this.highlightedIndex = -1;
282
633
  this.list.scrollTop = 0;
283
634
  this.emitter.emit("search", this.query);
284
- 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) {
285
638
  this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
286
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
+ }
287
648
  this.renderList();
288
649
  }
289
650
  });
290
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
+ });
291
674
  }
292
675
  this.list.addEventListener("click", (event) => {
293
676
  const target = event.target;
@@ -300,7 +683,12 @@ var ForgeSelect = class {
300
683
  return;
301
684
  }
302
685
  const li = target.closest("li[data-nav-index]");
303
- 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
+ }
304
692
  const navIndex = Number(li.dataset.navIndex);
305
693
  this.activateNavItem(navIndex);
306
694
  });
@@ -339,42 +727,73 @@ var ForgeSelect = class {
339
727
  this.control.focus();
340
728
  }
341
729
  break;
730
+ case "ArrowRight":
731
+ if (this.isOpen && this.navigateTree("right")) event.preventDefault();
732
+ break;
733
+ case "ArrowLeft":
734
+ if (this.isOpen && this.navigateTree("left")) event.preventDefault();
735
+ break;
342
736
  case "Tab":
343
737
  this.close();
344
738
  break;
345
739
  }
346
740
  }
347
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
+ }
348
761
  selectValue(value, notify) {
349
762
  if (this.selected.includes(value)) return;
350
763
  const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
351
764
  this.selectedOptions.set(value, option);
352
765
  if (this.opts.multiple) {
353
766
  this.selected.push(value);
354
- for (const v of collectDescendantValues(option)) {
767
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
355
768
  if (!this.selected.includes(v)) this.selected.push(v);
356
769
  }
357
770
  this.syncTreeAncestors();
358
771
  } else {
359
772
  this.selected = [value];
360
773
  }
361
- if (notify) this.afterSelectionChange();
774
+ if (notify) {
775
+ this.afterSelectionChange();
776
+ this.emitter.emit("select", option);
777
+ }
362
778
  }
363
779
  deselectValue(value, notify) {
364
780
  const index = this.selected.indexOf(value);
365
781
  if (index === -1) return;
782
+ const option = this.findOption(value) ?? this.selectedOptions.get(value);
366
783
  this.selected.splice(index, 1);
367
784
  if (this.opts.multiple) {
368
- const option = this.findOption(value) ?? this.selectedOptions.get(value);
369
785
  if (option) {
370
- for (const v of collectDescendantValues(option)) {
786
+ for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
371
787
  const i = this.selected.indexOf(v);
372
788
  if (i !== -1) this.selected.splice(i, 1);
373
789
  }
374
790
  }
375
791
  this.syncTreeAncestors();
376
792
  }
377
- if (notify) this.afterSelectionChange();
793
+ if (notify) {
794
+ this.afterSelectionChange();
795
+ this.emitter.emit("unselect", option ?? { value, label: value });
796
+ }
378
797
  }
379
798
  /**
380
799
  * Keeps every tree parent's own membership in `selected` consistent with
@@ -383,18 +802,7 @@ var ForgeSelect = class {
383
802
  * No-op for data with no `children` anywhere.
384
803
  */
385
804
  syncTreeAncestors() {
386
- const sync = (option) => {
387
- if (!option.children || option.children.length === 0) return;
388
- for (const child of option.children) sync(child);
389
- const state = computeCheckState(option, this.selected);
390
- const index = this.selected.indexOf(option.value);
391
- if (state === "all" && index === -1) this.selected.push(option.value);
392
- else if (state !== "all" && index !== -1) this.selected.splice(index, 1);
393
- };
394
- for (const item of this.data) {
395
- const options = isGroup(item) ? item.options : [item];
396
- options.forEach(sync);
397
- }
805
+ syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
398
806
  }
399
807
  clearSelection() {
400
808
  if (this.selected.length === 0) return;
@@ -402,13 +810,26 @@ var ForgeSelect = class {
402
810
  this.emitter.emit("clear");
403
811
  this.afterSelectionChange();
404
812
  }
405
- afterSelectionChange() {
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
+ }
822
+ afterSelectionChange(emitChange = true) {
406
823
  this.renderValue();
407
- this.syncNativeSelect();
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
+ }
408
829
  if (this.isOpen) this.renderList();
409
- this.emitter.emit("change", this.getValue());
830
+ if (emitChange) this.emitter.emit("change", this.getValue());
410
831
  }
411
- syncNativeSelect() {
832
+ syncNativeSelect(dispatchChange = true) {
412
833
  if (!(this.el instanceof HTMLSelectElement)) return;
413
834
  const existing = /* @__PURE__ */ new Set();
414
835
  for (const option of Array.from(this.el.options)) {
@@ -423,16 +844,30 @@ var ForgeSelect = class {
423
844
  option.selected = true;
424
845
  this.el.append(option);
425
846
  }
426
- this.el.dispatchEvent(new Event("change", { bubbles: true }));
847
+ if (this.opts.sortable && this.opts.multiple) {
848
+ for (const value of this.selected) {
849
+ const option = Array.from(this.el.options).find((o) => o.value === value);
850
+ if (option) this.el.append(option);
851
+ }
852
+ }
853
+ if (!dispatchChange) return;
854
+ this.syncingNative = true;
855
+ try {
856
+ this.el.dispatchEvent(new Event("change", { bubbles: true }));
857
+ } finally {
858
+ this.syncingNative = false;
859
+ }
427
860
  }
428
861
  findOption(value) {
862
+ return findOption(this.data, value);
863
+ }
864
+ findOptionByLabel(label) {
865
+ const lower = label.toLowerCase();
429
866
  const search = (options) => {
430
867
  for (const option of options) {
431
- if (option.value === value) return option;
432
- if (option.children) {
433
- const found = search(option.children);
434
- if (found) return found;
435
- }
868
+ if (option.label.toLowerCase() === lower) return option;
869
+ const found = option.children ? search(option.children) : void 0;
870
+ if (found) return found;
436
871
  }
437
872
  return void 0;
438
873
  };
@@ -442,17 +877,42 @@ var ForgeSelect = class {
442
877
  }
443
878
  return void 0;
444
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
+ }
445
903
  createFromQuery() {
446
904
  const label = this.query.trim();
447
905
  if (!label) return;
448
- const option = { value: label, label };
449
- this.data.push(option);
906
+ const result = this.createTag(label);
907
+ if (!result) return;
450
908
  if (this.searchInput) {
451
909
  this.searchInput.value = "";
452
910
  this.query = "";
453
911
  }
454
- this.selectValue(option.value, true);
455
- 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();
456
916
  }
457
917
  activateNavItem(navIndex) {
458
918
  const item = this.navItems[navIndex];
@@ -463,7 +923,17 @@ var ForgeSelect = class {
463
923
  }
464
924
  const { value } = item.option;
465
925
  if (this.opts.multiple) {
466
- this.selected.includes(value) ? this.deselectValue(value, true) : 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();
467
937
  } else {
468
938
  this.selectValue(value, true);
469
939
  this.close();
@@ -489,7 +959,7 @@ var ForgeSelect = class {
489
959
  tag.className = "forge-select__tag";
490
960
  const label = document.createElement("span");
491
961
  label.className = "forge-select__tag-label";
492
- this.renderTemplate(label, option, this.opts.templateSelection, "inline");
962
+ renderOptionContent(label, option, this.opts.templateSelection, "inline");
493
963
  const remove = document.createElement("button");
494
964
  remove.type = "button";
495
965
  remove.className = "forge-select__tag-remove";
@@ -500,6 +970,14 @@ var ForgeSelect = class {
500
970
  if (!this.isDisabled) this.deselectValue(value, true);
501
971
  });
502
972
  tag.append(label, remove);
973
+ if (this.opts.sortable) {
974
+ tag.dataset.value = value;
975
+ tag.tabIndex = 0;
976
+ tag.setAttribute("aria-roledescription", "draggable item");
977
+ tag.setAttribute("aria-label", format(this.strings.reorderHint, { label: option.label }));
978
+ tag.addEventListener("keydown", (event) => this.handleTagKeydown(event, value));
979
+ this.bindTagDrag(tag, value);
980
+ }
503
981
  this.valueEl.append(tag);
504
982
  }
505
983
  } else {
@@ -509,59 +987,116 @@ var ForgeSelect = class {
509
987
  };
510
988
  const span = document.createElement("span");
511
989
  span.className = "forge-select__single-value";
512
- this.renderTemplate(span, option, this.opts.templateSelection, "inline");
990
+ renderOptionContent(span, option, this.opts.templateSelection, "inline");
513
991
  this.valueEl.append(span);
514
992
  }
515
993
  }
516
- renderTemplate(container, option, template, variant = "row") {
517
- if (template) {
518
- const result = template(option);
519
- if (typeof result === "string") container.innerHTML = result;
520
- else container.append(result);
521
- return;
522
- }
523
- if (!option.avatar && !option.description) {
524
- container.textContent = option.label;
525
- return;
526
- }
527
- if (option.avatar) {
528
- const avatar = document.createElement("img");
529
- avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
530
- avatar.src = option.avatar;
531
- avatar.alt = "";
532
- avatar.setAttribute("loading", "lazy");
533
- avatar.setAttribute("decoding", "async");
534
- container.append(avatar);
535
- }
536
- if (variant === "row" && option.description) {
537
- const body = document.createElement("span");
538
- body.className = "forge-select__option-body";
539
- const label = document.createElement("span");
540
- label.className = "forge-select__option-label";
541
- label.textContent = option.label;
542
- const desc = document.createElement("span");
543
- desc.className = "forge-select__option-desc";
544
- desc.textContent = option.description;
545
- body.append(label, desc);
546
- container.append(body);
547
- } else {
548
- const label = document.createElement("span");
549
- label.className = "forge-select__option-label";
550
- label.textContent = option.label;
551
- container.append(label);
994
+ /**
995
+ * Pointer-based (mouse/touch/pen) reorder for a single tag. Only the real
996
+ * dragged DOM node is moved during the gesture — a full renderValue()
997
+ * mid-drag would destroy it — so the reordered `this.selected` is only
998
+ * committed on release. The move/up listeners and pointer capture live on
999
+ * the stable `this.valueEl` container rather than the tag itself: `tag`
1000
+ * gets repositioned via `insertBefore` during the drag, and browsers treat
1001
+ * that reparenting as detaching the node, which silently drops pointer
1002
+ * capture (and further move events) if it were captured on `tag`.
1003
+ */
1004
+ bindTagDrag(tag, value) {
1005
+ const DRAG_THRESHOLD = 4;
1006
+ let startX = 0;
1007
+ let dragging = false;
1008
+ let order = [];
1009
+ const onPointerMove = (event) => {
1010
+ if (!dragging) {
1011
+ if (Math.abs(event.clientX - startX) < DRAG_THRESHOLD) return;
1012
+ dragging = true;
1013
+ order = [...this.selected];
1014
+ if (typeof this.valueEl.setPointerCapture === "function") {
1015
+ this.valueEl.setPointerCapture(event.pointerId);
1016
+ }
1017
+ tag.classList.add("forge-select__tag--dragging");
1018
+ }
1019
+ event.preventDefault();
1020
+ const draggedIndex = order.indexOf(value);
1021
+ const siblings = Array.from(this.valueEl.querySelectorAll(".forge-select__tag"));
1022
+ for (const sibling of siblings) {
1023
+ if (sibling === tag) continue;
1024
+ const siblingValue = sibling.dataset.value;
1025
+ if (!siblingValue) continue;
1026
+ const siblingIndex = order.indexOf(siblingValue);
1027
+ if (siblingIndex === -1) continue;
1028
+ const rect = sibling.getBoundingClientRect();
1029
+ const midX = rect.left + rect.width / 2;
1030
+ const movingRight = draggedIndex < siblingIndex;
1031
+ const crossed = movingRight ? event.clientX > midX : event.clientX < midX;
1032
+ if (!crossed) continue;
1033
+ order.splice(draggedIndex, 1);
1034
+ order.splice(siblingIndex, 0, value);
1035
+ if (movingRight) this.valueEl.insertBefore(tag, sibling.nextSibling);
1036
+ else this.valueEl.insertBefore(tag, sibling);
1037
+ break;
1038
+ }
1039
+ };
1040
+ const finishDrag = (event) => {
1041
+ this.valueEl.removeEventListener("pointermove", onPointerMove);
1042
+ this.valueEl.removeEventListener("pointerup", finishDrag);
1043
+ this.valueEl.removeEventListener("pointercancel", finishDrag);
1044
+ if (!dragging) return;
1045
+ if (typeof this.valueEl.releasePointerCapture === "function") {
1046
+ this.valueEl.releasePointerCapture(event.pointerId);
1047
+ }
1048
+ tag.classList.remove("forge-select__tag--dragging");
1049
+ this.selected = order;
1050
+ this.suppressNextTagClick = true;
1051
+ this.afterSelectionChange();
1052
+ this.emitter.emit("reorder", [...this.selected]);
1053
+ };
1054
+ tag.addEventListener("pointerdown", (event) => {
1055
+ if (this.isDisabled || event.button !== 0) return;
1056
+ if (event.target.closest(".forge-select__tag-remove")) return;
1057
+ startX = event.clientX;
1058
+ dragging = false;
1059
+ this.valueEl.addEventListener("pointermove", onPointerMove);
1060
+ this.valueEl.addEventListener("pointerup", finishDrag);
1061
+ this.valueEl.addEventListener("pointercancel", finishDrag);
1062
+ });
1063
+ }
1064
+ /** Alt+Left/Alt+Right on a focused tag: the keyboard-operable equivalent of dragging. */
1065
+ handleTagKeydown(event, value) {
1066
+ if (!event.altKey || event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
1067
+ const index = this.selected.indexOf(value);
1068
+ const targetIndex = event.key === "ArrowLeft" ? index - 1 : index + 1;
1069
+ if (index === -1 || targetIndex < 0 || targetIndex >= this.selected.length) return;
1070
+ event.preventDefault();
1071
+ event.stopPropagation();
1072
+ const next = [...this.selected];
1073
+ [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
1074
+ this.selected = next;
1075
+ this.afterSelectionChange();
1076
+ this.emitter.emit("reorder", [...this.selected]);
1077
+ this.focusTagByValue(value);
1078
+ }
1079
+ focusTagByValue(value) {
1080
+ for (const tag of Array.from(this.valueEl.querySelectorAll(".forge-select__tag"))) {
1081
+ if (tag.dataset.value === value) {
1082
+ tag.focus();
1083
+ return;
1084
+ }
552
1085
  }
553
1086
  }
554
1087
  buildRows() {
555
1088
  this.rows = [];
556
1089
  this.navItems = [];
557
- const query = this.query.trim().toLowerCase();
558
- 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));
559
1093
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
560
- const pushOption = (option, depth) => {
1094
+ const pushOption = (option, depth, parentValue) => {
561
1095
  let navIndex = -1;
562
- if (!option.disabled) {
1096
+ const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
1097
+ if (!interactionDisabled) {
563
1098
  navIndex = this.navItems.length;
564
- this.navItems.push({ kind: "option", option });
1099
+ this.navItems.push({ kind: "option", option, parentValue });
565
1100
  }
566
1101
  const hasChildren = !!option.children && option.children.length > 0;
567
1102
  this.rows.push({ kind: "option", option, navIndex, depth, hasChildren });
@@ -569,15 +1104,23 @@ var ForgeSelect = class {
569
1104
  const expanded = query !== "" || this.expandedValues.has(option.value);
570
1105
  if (expanded) {
571
1106
  for (const child of option.children) {
572
- if (subtreeMatches(child)) pushOption(child, depth + 1);
1107
+ if (subtreeMatches(child)) pushOption(child, depth + 1, option.value);
573
1108
  }
574
1109
  }
575
1110
  }
576
1111
  };
1112
+ if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
1113
+ this.rows.push({ kind: "min-length" });
1114
+ return;
1115
+ }
577
1116
  if (this.loading) {
578
1117
  this.rows.push({ kind: "loading" });
579
1118
  return;
580
1119
  }
1120
+ if (this.loadError) {
1121
+ this.rows.push({ kind: "error" });
1122
+ return;
1123
+ }
581
1124
  for (const item of this.data) {
582
1125
  if (isGroup(item)) {
583
1126
  const visible = item.options.filter(subtreeMatches);
@@ -597,12 +1140,7 @@ var ForgeSelect = class {
597
1140
  else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
598
1141
  }
599
1142
  hasExactMatch(lowerQuery) {
600
- const matchesExactly = (option) => option.label.toLowerCase() === lowerQuery || (option.children ?? []).some(matchesExactly);
601
- for (const item of this.data) {
602
- const options = isGroup(item) ? item.options : [item];
603
- if (options.some(matchesExactly)) return true;
604
- }
605
- return false;
1143
+ return !!this.findOptionByLabel(lowerQuery);
606
1144
  }
607
1145
  usesVirtualScroll() {
608
1146
  return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
@@ -610,6 +1148,12 @@ var ForgeSelect = class {
610
1148
  renderList() {
611
1149
  this.buildRows();
612
1150
  this.renderRows();
1151
+ this.announceStatus();
1152
+ }
1153
+ announceStatus() {
1154
+ const first = this.rows[0];
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) }) : "";
1156
+ if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
613
1157
  }
614
1158
  renderRows() {
615
1159
  const scrollTop = this.list.scrollTop;
@@ -654,10 +1198,30 @@ var ForgeSelect = class {
654
1198
  break;
655
1199
  case "empty":
656
1200
  li.className = "forge-select__empty";
1201
+ li.setAttribute("role", "option");
1202
+ li.setAttribute("aria-disabled", "true");
1203
+ li.setAttribute("aria-selected", "false");
657
1204
  li.textContent = this.strings.noResults;
658
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;
1213
+ case "error":
1214
+ li.className = "forge-select__error";
1215
+ li.setAttribute("role", "option");
1216
+ li.setAttribute("aria-disabled", "true");
1217
+ li.setAttribute("aria-selected", "false");
1218
+ li.textContent = this.strings.errorLoading;
1219
+ break;
659
1220
  case "loading":
660
1221
  li.className = "forge-select__loading";
1222
+ li.setAttribute("role", "option");
1223
+ li.setAttribute("aria-disabled", "true");
1224
+ li.setAttribute("aria-selected", "false");
661
1225
  li.textContent = this.strings.loading;
662
1226
  break;
663
1227
  case "loading-more":
@@ -675,17 +1239,19 @@ var ForgeSelect = class {
675
1239
  break;
676
1240
  case "option": {
677
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));
678
1244
  li.setAttribute("role", "option");
679
1245
  const isSelected = this.selected.includes(row.option.value);
680
1246
  li.setAttribute("aria-selected", String(isSelected));
681
1247
  if (isSelected) li.classList.add("forge-select__option--selected");
682
- 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") {
683
1249
  li.classList.add("forge-select__option--indeterminate");
684
1250
  }
685
1251
  if (row.depth > 0) {
686
1252
  li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
687
1253
  }
688
- if (row.option.disabled) {
1254
+ if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
689
1255
  li.classList.add("forge-select__option--disabled");
690
1256
  li.setAttribute("aria-disabled", "true");
691
1257
  } else {
@@ -694,11 +1260,13 @@ var ForgeSelect = class {
694
1260
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
695
1261
  }
696
1262
  if (row.hasChildren) {
1263
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1264
+ li.setAttribute("aria-expanded", String(expanded));
697
1265
  const twisty = document.createElement("span");
698
1266
  twisty.className = "forge-select__twisty";
699
1267
  twisty.dataset.twisty = row.option.value;
700
1268
  twisty.setAttribute("aria-hidden", "true");
701
- twisty.textContent = this.expandedValues.has(row.option.value) ? "\u25BC" : "\u25B6";
1269
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
702
1270
  li.append(twisty);
703
1271
  }
704
1272
  li.append(this.optionContent(row.option));
@@ -718,7 +1286,7 @@ var ForgeSelect = class {
718
1286
  if (!cached) {
719
1287
  const holder = document.createElement("span");
720
1288
  holder.className = "forge-select__option-content";
721
- this.renderTemplate(holder, option, this.opts.templateResult);
1289
+ renderOptionContent(holder, option, this.opts.templateResult);
722
1290
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
723
1291
  const oldest = this.rowContentCache.keys().next().value;
724
1292
  this.rowContentCache.delete(oldest);
@@ -730,7 +1298,10 @@ var ForgeSelect = class {
730
1298
  }
731
1299
  moveHighlight(delta) {
732
1300
  if (this.navItems.length === 0) return;
733
- const next = this.highlightedIndex === -1 && delta > 0 ? 0 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
1301
+ const next = this.highlightedIndex === -1 ? delta > 0 ? 0 : this.navItems.length - 1 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
1302
+ this.focusNavIndex(next);
1303
+ }
1304
+ focusNavIndex(next) {
734
1305
  this.highlightedIndex = next;
735
1306
  if (this.usesVirtualScroll()) {
736
1307
  const rowIndex = this.rows.findIndex(
@@ -752,6 +1323,41 @@ var ForgeSelect = class {
752
1323
  highlighted?.scrollIntoView?.({ block: "nearest" });
753
1324
  }
754
1325
  }
1326
+ navigateTree(direction) {
1327
+ const item = this.navItems[this.highlightedIndex];
1328
+ if (!item || item.kind !== "option") return false;
1329
+ const { option, parentValue } = item;
1330
+ const hasChildren = !!option.children?.length;
1331
+ const expanded = this.query !== "" || this.expandedValues.has(option.value);
1332
+ if (direction === "right") {
1333
+ if (hasChildren && !expanded) {
1334
+ this.expandedValues.add(option.value);
1335
+ this.renderList();
1336
+ return true;
1337
+ }
1338
+ if (hasChildren) {
1339
+ const childIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.parentValue === option.value);
1340
+ if (childIndex >= 0) {
1341
+ this.focusNavIndex(childIndex);
1342
+ return true;
1343
+ }
1344
+ }
1345
+ return false;
1346
+ }
1347
+ if (hasChildren && expanded && this.query === "") {
1348
+ this.expandedValues.delete(option.value);
1349
+ this.renderList();
1350
+ return true;
1351
+ }
1352
+ if (parentValue) {
1353
+ const parentIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.option.value === parentValue);
1354
+ if (parentIndex >= 0) {
1355
+ this.focusNavIndex(parentIndex);
1356
+ return true;
1357
+ }
1358
+ }
1359
+ return false;
1360
+ }
755
1361
  updateActiveDescendant() {
756
1362
  const target = this.searchInput ?? this.control;
757
1363
  if (this.highlightedIndex >= 0) {
@@ -763,12 +1369,18 @@ var ForgeSelect = class {
763
1369
  // ---------------------------------------------------------------- remote data
764
1370
  scheduleRemoteLoad(query, delay) {
765
1371
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
1372
+ const requestId = ++this.ajaxRequestId;
1373
+ this.ajaxController?.abort();
1374
+ this.ajaxController = null;
766
1375
  this.page = 0;
767
1376
  this.hasMore = true;
768
1377
  this.loading = true;
1378
+ this.loadingMore = false;
1379
+ this.loadError = null;
769
1380
  this.renderList();
770
1381
  this.ajaxTimer = setTimeout(() => {
771
- void this.loadRemote(query);
1382
+ this.ajaxTimer = null;
1383
+ void this.loadRemote(query, { requestId });
772
1384
  }, delay);
773
1385
  }
774
1386
  /**
@@ -786,18 +1398,26 @@ var ForgeSelect = class {
786
1398
  this.renderList();
787
1399
  void this.loadRemote(this.query, { append: true });
788
1400
  }
789
- async loadRemote(query, { append = false } = {}) {
1401
+ async loadRemote(query, { append = false, requestId } = {}) {
790
1402
  const ajax = this.opts.ajax;
791
- const requestId = ++this.ajaxRequestId;
1403
+ const activeRequestId = requestId ?? ++this.ajaxRequestId;
1404
+ if (activeRequestId !== this.ajaxRequestId) return;
1405
+ this.ajaxController?.abort();
1406
+ const controller = new AbortController();
1407
+ this.ajaxController = controller;
792
1408
  const page = append ? this.page + 1 : 0;
793
1409
  try {
794
- const url = buildUrl(ajax, query, page);
795
- const response = await fetch(url);
796
- const json = await response.json();
797
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
798
- const result = ajax.transform ? ajax.transform(json) : json;
799
- const options = Array.isArray(result) ? result : result.options;
800
- const hasMore = ajax.pagination ? Array.isArray(result) ? false : result.hasMore : false;
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
+ }
1419
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1420
+ const { options, hasMore } = normalizeRemoteResult(ajax, json);
801
1421
  if (append) {
802
1422
  const existing = collectValues(this.data);
803
1423
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
@@ -808,15 +1428,20 @@ var ForgeSelect = class {
808
1428
  this.page = page;
809
1429
  this.hasMore = hasMore;
810
1430
  this.remoteLoaded = true;
811
- } catch {
812
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
1431
+ this.loadError = null;
1432
+ } catch (cause) {
1433
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1434
+ const error = cause instanceof Error ? cause : new Error(String(cause));
813
1435
  if (!append) {
814
1436
  this.data = [];
815
1437
  this.rowContentCache.clear();
816
1438
  }
817
1439
  this.hasMore = false;
1440
+ this.loadError = error;
1441
+ this.emitter.emit("error", error);
818
1442
  } finally {
819
- if (requestId === this.ajaxRequestId && !this.destroyed) {
1443
+ if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1444
+ this.ajaxController = null;
820
1445
  this.loading = false;
821
1446
  this.loadingMore = false;
822
1447
  if (this.isOpen) this.renderList();
@@ -824,51 +1449,6 @@ var ForgeSelect = class {
824
1449
  }
825
1450
  }
826
1451
  };
827
- function parseNativeOptions(select) {
828
- const data = [];
829
- for (const child of Array.from(select.children)) {
830
- if (child instanceof HTMLOptGroupElement) {
831
- data.push({
832
- label: child.label,
833
- options: Array.from(child.querySelectorAll("option")).map(parseOption)
834
- });
835
- } else if (child instanceof HTMLOptionElement) {
836
- data.push(parseOption(child));
837
- }
838
- }
839
- return data;
840
- }
841
- function parseOption(option) {
842
- return {
843
- value: option.value,
844
- label: option.textContent?.trim() ?? option.value,
845
- disabled: option.disabled || void 0
846
- };
847
- }
848
- function buildUrl(ajax, query, page) {
849
- if (typeof ajax.url === "function") return ajax.url(query);
850
- if (!ajax.params) return ajax.url;
851
- const params = new URLSearchParams();
852
- for (const [key, value] of Object.entries(ajax.params(query, page))) {
853
- params.set(key, String(value));
854
- }
855
- const separator = ajax.url.includes("?") ? "&" : "?";
856
- return `${ajax.url}${separator}${params.toString()}`;
857
- }
858
- function collectValues(items) {
859
- const values = /* @__PURE__ */ new Set();
860
- for (const item of items) {
861
- if (isGroup(item)) {
862
- for (const option of item.options) values.add(option.value);
863
- } else {
864
- values.add(item.value);
865
- }
866
- }
867
- return values;
868
- }
869
- function arraysEqual(a, b) {
870
- return a.length === b.length && a.every((value, index) => value === b[index]);
871
- }
872
1452
  export {
873
1453
  ForgeSelect,
874
1454
  ForgeSelect as default