forge-select 0.2.0 → 0.3.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
@@ -57,19 +57,23 @@ var locales = {
57
57
  noResults: "No results found",
58
58
  loading: "Loading\u2026",
59
59
  loadingMore: "Loading more\u2026",
60
+ errorLoading: "Could not load options",
60
61
  createOption: 'Create "{query}"',
61
62
  clearSelection: "Clear selection",
62
63
  removeItem: "Remove {label}",
63
- search: "Search"
64
+ search: "Search",
65
+ reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
64
66
  },
65
67
  vi: {
66
68
  noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
67
69
  loading: "\u0110ang t\u1EA3i\u2026",
68
70
  loadingMore: "\u0110ang t\u1EA3i th\xEAm\u2026",
71
+ errorLoading: "Kh\xF4ng th\u1EC3 t\u1EA3i t\xF9y ch\u1ECDn",
69
72
  createOption: 'T\u1EA1o "{query}"',
70
73
  clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
71
74
  removeItem: "X\xF3a {label}",
72
- search: "T\xECm ki\u1EBFm"
75
+ search: "T\xECm ki\u1EBFm",
76
+ reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i."
73
77
  }
74
78
  };
75
79
  function getStrings(language) {
@@ -82,12 +86,88 @@ function format(template, vars) {
82
86
  return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
83
87
  }
84
88
 
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;
89
+ // src/native-select.ts
90
+ function parseNativeOptions(select) {
91
+ const data = [];
92
+ for (const child of Array.from(select.children)) {
93
+ if (child instanceof HTMLOptGroupElement) {
94
+ data.push({ label: child.label, options: Array.from(child.querySelectorAll("option")).map(parseOption) });
95
+ } else if (child instanceof HTMLOptionElement) {
96
+ data.push(parseOption(child));
97
+ }
98
+ }
99
+ return data;
100
+ }
101
+ function parseOption(option) {
102
+ const groupDisabled = option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled;
103
+ return {
104
+ value: option.value,
105
+ label: option.textContent?.trim() ?? option.value,
106
+ disabled: option.disabled || groupDisabled || void 0
107
+ };
108
+ }
109
+
110
+ // src/option-renderer.ts
111
+ function renderOptionContent(container, option, template, variant = "row") {
112
+ if (template) {
113
+ const result = template(option);
114
+ if (typeof result === "string") container.innerHTML = result;
115
+ else container.append(result);
116
+ return;
117
+ }
118
+ if (!option.avatar && !option.description) {
119
+ container.textContent = option.label;
120
+ return;
121
+ }
122
+ if (option.avatar) {
123
+ const avatar = document.createElement("img");
124
+ avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
125
+ avatar.src = option.avatar;
126
+ avatar.alt = "";
127
+ avatar.setAttribute("loading", "lazy");
128
+ avatar.setAttribute("decoding", "async");
129
+ container.append(avatar);
130
+ }
131
+ if (variant === "row" && option.description) {
132
+ const body = document.createElement("span");
133
+ body.className = "forge-select__option-body";
134
+ const label = document.createElement("span");
135
+ label.className = "forge-select__option-label";
136
+ label.textContent = option.label;
137
+ const description = document.createElement("span");
138
+ description.className = "forge-select__option-desc";
139
+ description.textContent = option.description;
140
+ body.append(label, description);
141
+ container.append(body);
142
+ } else {
143
+ const label = document.createElement("span");
144
+ label.className = "forge-select__option-label";
145
+ label.textContent = option.label;
146
+ container.append(label);
147
+ }
148
+ }
149
+
150
+ // src/remote.ts
151
+ function buildUrl(ajax, query, page) {
152
+ if (typeof ajax.url === "function") return ajax.url(query, page);
153
+ if (!ajax.params) return ajax.url;
154
+ const params = new URLSearchParams();
155
+ for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
156
+ const separator = ajax.url.includes("?") ? "&" : "?";
157
+ return `${ajax.url}${separator}${params.toString()}`;
158
+ }
159
+ function normalizeRemoteResult(ajax, response) {
160
+ const result = ajax.transform ? ajax.transform(response) : response;
161
+ if (Array.isArray(result)) return { options: result, hasMore: false };
162
+ if (!result || !Array.isArray(result.options)) {
163
+ throw new Error(
164
+ "ForgeSelect: ajax.transform must return an array of options, or an object shaped like { options: Option[], hasMore?: boolean }."
165
+ );
166
+ }
167
+ return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
168
+ }
169
+
170
+ // src/selection.ts
91
171
  function isGroup(item) {
92
172
  return item.options !== void 0;
93
173
  }
@@ -95,23 +175,69 @@ function collectDescendantValues(option) {
95
175
  if (!option.children) return [];
96
176
  const values = [];
97
177
  for (const child of option.children) {
98
- values.push(child.value, ...collectDescendantValues(child));
178
+ if (!child.disabled) values.push(child.value);
179
+ values.push(...collectDescendantValues(child));
99
180
  }
100
181
  return values;
101
182
  }
102
183
  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";
184
+ if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
185
+ const states = option.children.filter((child) => !child.disabled).map((child) => computeCheckState(child, selected));
186
+ if (states.length === 0) return "none";
187
+ if (states.every((state) => state === "all")) return "all";
188
+ if (states.every((state) => state === "none")) return "none";
109
189
  return "some";
110
190
  }
191
+ function findOption(items, value) {
192
+ const search = (options) => {
193
+ for (const option of options) {
194
+ if (option.value === value) return option;
195
+ const found = option.children ? search(option.children) : void 0;
196
+ if (found) return found;
197
+ }
198
+ return void 0;
199
+ };
200
+ for (const item of items) {
201
+ const found = search(isGroup(item) ? item.options : [item]);
202
+ if (found) return found;
203
+ }
204
+ return void 0;
205
+ }
206
+ function syncTreeAncestors(items, selected) {
207
+ const sync = (option) => {
208
+ if (!option.children?.length) return;
209
+ for (const child of option.children) sync(child);
210
+ const state = computeCheckState(option, selected);
211
+ const index = selected.indexOf(option.value);
212
+ if (state === "all" && index === -1) selected.push(option.value);
213
+ else if (state !== "all" && index !== -1) selected.splice(index, 1);
214
+ };
215
+ for (const item of items) (isGroup(item) ? item.options : [item]).forEach(sync);
216
+ }
217
+ function collectValues(items) {
218
+ const values = /* @__PURE__ */ new Set();
219
+ const visit = (option) => {
220
+ values.add(option.value);
221
+ option.children?.forEach(visit);
222
+ };
223
+ for (const item of items) (isGroup(item) ? item.options : [item]).forEach(visit);
224
+ return values;
225
+ }
226
+ function arraysEqual(a, b) {
227
+ return a.length === b.length && a.every((value, index) => value === b[index]);
228
+ }
229
+
230
+ // src/ForgeSelect.ts
231
+ var DEFAULT_ITEM_HEIGHT = 36;
232
+ var VIRTUAL_BUFFER = 5;
233
+ var VIRTUAL_THRESHOLD = 100;
234
+ var ROW_CACHE_LIMIT = 2e3;
235
+ var uidCounter = 0;
111
236
  var ForgeSelect = class {
112
237
  constructor(target, options = {}) {
113
238
  this.selected = [];
114
239
  this.selectedOptions = /* @__PURE__ */ new Map();
240
+ this.suppressNextTagClick = false;
115
241
  this.emitter = new Emitter();
116
242
  this.uid = `forge-select-${++uidCounter}`;
117
243
  this.searchInput = null;
@@ -130,24 +256,46 @@ var ForgeSelect = class {
130
256
  this.hasMore = true;
131
257
  this.ajaxTimer = null;
132
258
  this.ajaxRequestId = 0;
259
+ this.ajaxController = null;
133
260
  this.remoteLoaded = false;
261
+ this.loadError = null;
262
+ this.originalDisplay = "";
263
+ this.originalDisabled = false;
264
+ this.nativeSelect = null;
265
+ this.nativeForm = null;
266
+ this.syncingNative = false;
134
267
  this.onDocumentMouseDown = (event) => {
135
268
  if (!this.root.contains(event.target)) this.close();
136
269
  };
270
+ this.onNativeChange = () => {
271
+ if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
272
+ const values = Array.from(this.nativeSelect.selectedOptions, (option) => option.value);
273
+ this.applyNativeValues(values);
274
+ };
275
+ this.onFormReset = () => {
276
+ if (!this.nativeSelect || this.destroyed) return;
277
+ const defaults = Array.from(this.nativeSelect.options).filter((option) => option.defaultSelected).map((option) => option.value);
278
+ this.applyNativeValues(defaults);
279
+ };
137
280
  const el = typeof target === "string" ? document.querySelector(target) : target;
138
281
  if (!el) {
139
282
  throw new Error(`ForgeSelect: target element not found: ${String(target)}`);
140
283
  }
141
284
  this.el = el;
142
285
  const nativeSelect = el instanceof HTMLSelectElement ? el : null;
286
+ this.nativeSelect = nativeSelect;
287
+ this.nativeForm = nativeSelect?.form ?? null;
288
+ this.originalDisplay = el.style.display;
289
+ this.originalDisabled = nativeSelect?.disabled ?? false;
143
290
  this.opts = {
144
291
  placeholder: options.placeholder ?? "",
145
292
  searchable: options.searchable ?? true,
146
293
  multiple: options.multiple ?? nativeSelect?.multiple ?? false,
147
294
  clearable: options.clearable ?? false,
148
295
  allowCreate: options.allowCreate ?? false,
296
+ sortable: options.sortable ?? false,
149
297
  theme: options.theme ?? "default",
150
- disabled: options.disabled ?? false,
298
+ disabled: options.disabled ?? nativeSelect?.disabled ?? false,
151
299
  data: options.data,
152
300
  ajax: options.ajax,
153
301
  templateResult: options.templateResult,
@@ -161,15 +309,26 @@ var ForgeSelect = class {
161
309
  this.plugins = this.opts.plugins;
162
310
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
163
311
  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);
312
+ const nativeOptions = Array.from(nativeSelect.options);
313
+ const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
314
+ for (const option of nativeOptions) {
315
+ if (hasIntentionalSelection && option.selected) this.selectValue(option.value, false);
166
316
  }
167
317
  }
168
318
  this.buildDom();
169
319
  this.renderValue();
170
320
  if (this.opts.disabled) this.disable();
321
+ nativeSelect?.addEventListener("change", this.onNativeChange);
322
+ this.nativeForm?.addEventListener("reset", this.onFormReset);
171
323
  for (const plugin of this.plugins) plugin.onInit?.(this);
172
324
  }
325
+ applyNativeValues(values) {
326
+ this.selected = [];
327
+ for (const value of this.opts.multiple ? values : values.slice(0, 1)) this.selectValue(value, false);
328
+ this.renderValue();
329
+ if (this.isOpen) this.renderList();
330
+ this.emitter.emit("change", this.getValue());
331
+ }
173
332
  // ---------------------------------------------------------------- public API
174
333
  open() {
175
334
  if (this.isOpen || this.isDisabled || this.destroyed) return;
@@ -207,28 +366,33 @@ var ForgeSelect = class {
207
366
  for (const plugin of this.plugins) plugin.onDestroy?.(this);
208
367
  this.destroyed = true;
209
368
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
369
+ this.ajaxController?.abort();
370
+ this.nativeSelect?.removeEventListener("change", this.onNativeChange);
371
+ this.nativeForm?.removeEventListener("reset", this.onFormReset);
210
372
  this.rowContentCache.clear();
211
373
  this.root.remove();
212
- this.el.style.display = "";
374
+ this.el.style.display = this.originalDisplay;
375
+ if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
213
376
  this.emitter.clear();
214
377
  }
215
378
  getValue() {
216
379
  if (this.opts.multiple) return [...this.selected];
217
380
  return this.selected[0] ?? null;
218
381
  }
219
- setValue(value) {
382
+ setValue(value, options = {}) {
220
383
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
221
384
  const next = this.opts.multiple ? values : values.slice(0, 1);
222
385
  if (arraysEqual(next, this.selected)) return;
223
386
  this.selected = [];
224
387
  for (const v of next) this.selectValue(v, false);
225
- this.afterSelectionChange();
388
+ this.afterSelectionChange(options.emitChange ?? true);
226
389
  }
227
390
  enable() {
228
391
  this.isDisabled = false;
229
392
  this.root.classList.remove("forge-select--disabled");
230
393
  this.control.tabIndex = 0;
231
394
  this.control.setAttribute("aria-disabled", "false");
395
+ if (this.nativeSelect) this.nativeSelect.disabled = false;
232
396
  }
233
397
  disable() {
234
398
  this.close();
@@ -236,6 +400,7 @@ var ForgeSelect = class {
236
400
  this.root.classList.add("forge-select--disabled");
237
401
  this.control.tabIndex = -1;
238
402
  this.control.setAttribute("aria-disabled", "true");
403
+ if (this.nativeSelect) this.nativeSelect.disabled = true;
239
404
  }
240
405
  on(event, handler) {
241
406
  this.emitter.on(event, handler);
@@ -244,11 +409,35 @@ var ForgeSelect = class {
244
409
  this.emitter.off(event, handler);
245
410
  }
246
411
  // ---------------------------------------------------------------- DOM setup
412
+ /**
413
+ * The original target (a hidden native <select> or a plain mount div) can
414
+ * carry an accessible name via aria-label/aria-labelledby, or via a
415
+ * <label for> pointing at its id — but once `this.el` is display:none it
416
+ * drops out of the accessibility tree, so any such association silently
417
+ * stops reaching assistive tech unless we forward it onto the visible,
418
+ * interactive `this.control` ourselves.
419
+ */
420
+ applyAccessibleName() {
421
+ const ariaLabelledby = this.el.getAttribute("aria-labelledby");
422
+ const ariaLabel = this.el.getAttribute("aria-label");
423
+ if (ariaLabelledby) {
424
+ this.control.setAttribute("aria-labelledby", ariaLabelledby);
425
+ } else if (ariaLabel) {
426
+ this.control.setAttribute("aria-label", ariaLabel);
427
+ } else if (this.el.id) {
428
+ const label = Array.from(document.getElementsByTagName("label")).find((el) => el.htmlFor === this.el.id);
429
+ if (label) {
430
+ if (!label.id) label.id = `${this.uid}-label`;
431
+ this.control.setAttribute("aria-labelledby", label.id);
432
+ }
433
+ }
434
+ }
247
435
  buildDom() {
248
436
  this.root = document.createElement("div");
249
437
  this.root.className = "forge-select";
250
438
  this.root.dataset.theme = this.opts.theme;
251
439
  this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
440
+ if (this.opts.sortable && this.opts.multiple) this.root.classList.add("forge-select--sortable");
252
441
  this.control = document.createElement("div");
253
442
  this.control.className = "forge-select__control";
254
443
  this.control.setAttribute("role", "combobox");
@@ -256,6 +445,7 @@ var ForgeSelect = class {
256
445
  this.control.setAttribute("aria-expanded", "false");
257
446
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
258
447
  this.control.tabIndex = 0;
448
+ this.applyAccessibleName();
259
449
  this.valueEl = document.createElement("div");
260
450
  this.valueEl.className = "forge-select__value";
261
451
  this.clearBtn = document.createElement("button");
@@ -286,7 +476,11 @@ var ForgeSelect = class {
286
476
  this.list.setAttribute("role", "listbox");
287
477
  if (this.opts.multiple) this.list.setAttribute("aria-multiselectable", "true");
288
478
  this.dropdown.append(this.list);
289
- this.root.append(this.control, this.dropdown);
479
+ this.liveRegion = document.createElement("div");
480
+ this.liveRegion.className = "forge-select__sr-only";
481
+ this.liveRegion.setAttribute("role", "status");
482
+ this.liveRegion.setAttribute("aria-live", "polite");
483
+ this.root.append(this.control, this.dropdown, this.liveRegion);
290
484
  this.el.style.display = "none";
291
485
  this.el.insertAdjacentElement("afterend", this.root);
292
486
  this.bindEvents();
@@ -294,8 +488,13 @@ var ForgeSelect = class {
294
488
  bindEvents() {
295
489
  this.control.addEventListener("click", (event) => {
296
490
  if (event.target === this.clearBtn) return;
491
+ if (this.suppressNextTagClick) {
492
+ this.suppressNextTagClick = false;
493
+ return;
494
+ }
297
495
  if (this.isDisabled) return;
298
- this.isOpen ? this.close() : this.open();
496
+ if (this.isOpen) this.close();
497
+ else this.open();
299
498
  });
300
499
  this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
301
500
  this.clearBtn.addEventListener("click", (event) => {
@@ -366,6 +565,12 @@ var ForgeSelect = class {
366
565
  this.control.focus();
367
566
  }
368
567
  break;
568
+ case "ArrowRight":
569
+ if (this.isOpen && this.navigateTree("right")) event.preventDefault();
570
+ break;
571
+ case "ArrowLeft":
572
+ if (this.isOpen && this.navigateTree("left")) event.preventDefault();
573
+ break;
369
574
  case "Tab":
370
575
  this.close();
371
576
  break;
@@ -410,18 +615,7 @@ var ForgeSelect = class {
410
615
  * No-op for data with no `children` anywhere.
411
616
  */
412
617
  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
- }
618
+ syncTreeAncestors(this.data, this.selected);
425
619
  }
426
620
  clearSelection() {
427
621
  if (this.selected.length === 0) return;
@@ -429,13 +623,13 @@ var ForgeSelect = class {
429
623
  this.emitter.emit("clear");
430
624
  this.afterSelectionChange();
431
625
  }
432
- afterSelectionChange() {
626
+ afterSelectionChange(emitChange = true) {
433
627
  this.renderValue();
434
- this.syncNativeSelect();
628
+ this.syncNativeSelect(emitChange);
435
629
  if (this.isOpen) this.renderList();
436
- this.emitter.emit("change", this.getValue());
630
+ if (emitChange) this.emitter.emit("change", this.getValue());
437
631
  }
438
- syncNativeSelect() {
632
+ syncNativeSelect(dispatchChange = true) {
439
633
  if (!(this.el instanceof HTMLSelectElement)) return;
440
634
  const existing = /* @__PURE__ */ new Set();
441
635
  for (const option of Array.from(this.el.options)) {
@@ -450,24 +644,22 @@ var ForgeSelect = class {
450
644
  option.selected = true;
451
645
  this.el.append(option);
452
646
  }
453
- this.el.dispatchEvent(new Event("change", { bubbles: true }));
454
- }
455
- findOption(value) {
456
- const search = (options) => {
457
- 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
- }
647
+ if (this.opts.sortable && this.opts.multiple) {
648
+ for (const value of this.selected) {
649
+ const option = Array.from(this.el.options).find((o) => o.value === value);
650
+ if (option) this.el.append(option);
463
651
  }
464
- return void 0;
465
- };
466
- for (const item of this.data) {
467
- const found = search(isGroup(item) ? item.options : [item]);
468
- if (found) return found;
469
652
  }
470
- return void 0;
653
+ if (!dispatchChange) return;
654
+ this.syncingNative = true;
655
+ try {
656
+ this.el.dispatchEvent(new Event("change", { bubbles: true }));
657
+ } finally {
658
+ this.syncingNative = false;
659
+ }
660
+ }
661
+ findOption(value) {
662
+ return findOption(this.data, value);
471
663
  }
472
664
  createFromQuery() {
473
665
  const label = this.query.trim();
@@ -490,7 +682,8 @@ var ForgeSelect = class {
490
682
  }
491
683
  const { value } = item.option;
492
684
  if (this.opts.multiple) {
493
- this.selected.includes(value) ? this.deselectValue(value, true) : this.selectValue(value, true);
685
+ if (this.selected.includes(value)) this.deselectValue(value, true);
686
+ else this.selectValue(value, true);
494
687
  } else {
495
688
  this.selectValue(value, true);
496
689
  this.close();
@@ -516,7 +709,7 @@ var ForgeSelect = class {
516
709
  tag.className = "forge-select__tag";
517
710
  const label = document.createElement("span");
518
711
  label.className = "forge-select__tag-label";
519
- this.renderTemplate(label, option, this.opts.templateSelection, "inline");
712
+ renderOptionContent(label, option, this.opts.templateSelection, "inline");
520
713
  const remove = document.createElement("button");
521
714
  remove.type = "button";
522
715
  remove.className = "forge-select__tag-remove";
@@ -527,6 +720,14 @@ var ForgeSelect = class {
527
720
  if (!this.isDisabled) this.deselectValue(value, true);
528
721
  });
529
722
  tag.append(label, remove);
723
+ if (this.opts.sortable) {
724
+ tag.dataset.value = value;
725
+ tag.tabIndex = 0;
726
+ tag.setAttribute("aria-roledescription", "draggable item");
727
+ tag.setAttribute("aria-label", format(this.strings.reorderHint, { label: option.label }));
728
+ tag.addEventListener("keydown", (event) => this.handleTagKeydown(event, value));
729
+ this.bindTagDrag(tag, value);
730
+ }
530
731
  this.valueEl.append(tag);
531
732
  }
532
733
  } else {
@@ -536,46 +737,99 @@ var ForgeSelect = class {
536
737
  };
537
738
  const span = document.createElement("span");
538
739
  span.className = "forge-select__single-value";
539
- this.renderTemplate(span, option, this.opts.templateSelection, "inline");
740
+ renderOptionContent(span, option, this.opts.templateSelection, "inline");
540
741
  this.valueEl.append(span);
541
742
  }
542
743
  }
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);
744
+ /**
745
+ * Pointer-based (mouse/touch/pen) reorder for a single tag. Only the real
746
+ * dragged DOM node is moved during the gesture — a full renderValue()
747
+ * mid-drag would destroy it — so the reordered `this.selected` is only
748
+ * committed on release. The move/up listeners and pointer capture live on
749
+ * the stable `this.valueEl` container rather than the tag itself: `tag`
750
+ * gets repositioned via `insertBefore` during the drag, and browsers treat
751
+ * that reparenting as detaching the node, which silently drops pointer
752
+ * capture (and further move events) if it were captured on `tag`.
753
+ */
754
+ bindTagDrag(tag, value) {
755
+ const DRAG_THRESHOLD = 4;
756
+ let startX = 0;
757
+ let dragging = false;
758
+ let order = [];
759
+ const onPointerMove = (event) => {
760
+ if (!dragging) {
761
+ if (Math.abs(event.clientX - startX) < DRAG_THRESHOLD) return;
762
+ dragging = true;
763
+ order = [...this.selected];
764
+ if (typeof this.valueEl.setPointerCapture === "function") {
765
+ this.valueEl.setPointerCapture(event.pointerId);
766
+ }
767
+ tag.classList.add("forge-select__tag--dragging");
768
+ }
769
+ event.preventDefault();
770
+ const draggedIndex = order.indexOf(value);
771
+ const siblings = Array.from(this.valueEl.querySelectorAll(".forge-select__tag"));
772
+ for (const sibling of siblings) {
773
+ if (sibling === tag) continue;
774
+ const siblingValue = sibling.dataset.value;
775
+ if (!siblingValue) continue;
776
+ const siblingIndex = order.indexOf(siblingValue);
777
+ if (siblingIndex === -1) continue;
778
+ const rect = sibling.getBoundingClientRect();
779
+ const midX = rect.left + rect.width / 2;
780
+ const movingRight = draggedIndex < siblingIndex;
781
+ const crossed = movingRight ? event.clientX > midX : event.clientX < midX;
782
+ if (!crossed) continue;
783
+ order.splice(draggedIndex, 1);
784
+ order.splice(siblingIndex, 0, value);
785
+ if (movingRight) this.valueEl.insertBefore(tag, sibling.nextSibling);
786
+ else this.valueEl.insertBefore(tag, sibling);
787
+ break;
788
+ }
789
+ };
790
+ const finishDrag = (event) => {
791
+ this.valueEl.removeEventListener("pointermove", onPointerMove);
792
+ this.valueEl.removeEventListener("pointerup", finishDrag);
793
+ this.valueEl.removeEventListener("pointercancel", finishDrag);
794
+ if (!dragging) return;
795
+ if (typeof this.valueEl.releasePointerCapture === "function") {
796
+ this.valueEl.releasePointerCapture(event.pointerId);
797
+ }
798
+ tag.classList.remove("forge-select__tag--dragging");
799
+ this.selected = order;
800
+ this.suppressNextTagClick = true;
801
+ this.afterSelectionChange();
802
+ };
803
+ tag.addEventListener("pointerdown", (event) => {
804
+ if (this.isDisabled || event.button !== 0) return;
805
+ if (event.target.closest(".forge-select__tag-remove")) return;
806
+ startX = event.clientX;
807
+ dragging = false;
808
+ this.valueEl.addEventListener("pointermove", onPointerMove);
809
+ this.valueEl.addEventListener("pointerup", finishDrag);
810
+ this.valueEl.addEventListener("pointercancel", finishDrag);
811
+ });
812
+ }
813
+ /** Alt+Left/Alt+Right on a focused tag: the keyboard-operable equivalent of dragging. */
814
+ handleTagKeydown(event, value) {
815
+ if (!event.altKey || event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
816
+ const index = this.selected.indexOf(value);
817
+ const targetIndex = event.key === "ArrowLeft" ? index - 1 : index + 1;
818
+ if (index === -1 || targetIndex < 0 || targetIndex >= this.selected.length) return;
819
+ event.preventDefault();
820
+ event.stopPropagation();
821
+ const next = [...this.selected];
822
+ [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
823
+ this.selected = next;
824
+ this.afterSelectionChange();
825
+ this.focusTagByValue(value);
826
+ }
827
+ focusTagByValue(value) {
828
+ for (const tag of Array.from(this.valueEl.querySelectorAll(".forge-select__tag"))) {
829
+ if (tag.dataset.value === value) {
830
+ tag.focus();
831
+ return;
832
+ }
579
833
  }
580
834
  }
581
835
  buildRows() {
@@ -584,11 +838,11 @@ var ForgeSelect = class {
584
838
  const query = this.query.trim().toLowerCase();
585
839
  const matches = (option) => query === "" || option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false);
586
840
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
587
- const pushOption = (option, depth) => {
841
+ const pushOption = (option, depth, parentValue) => {
588
842
  let navIndex = -1;
589
843
  if (!option.disabled) {
590
844
  navIndex = this.navItems.length;
591
- this.navItems.push({ kind: "option", option });
845
+ this.navItems.push({ kind: "option", option, parentValue });
592
846
  }
593
847
  const hasChildren = !!option.children && option.children.length > 0;
594
848
  this.rows.push({ kind: "option", option, navIndex, depth, hasChildren });
@@ -596,7 +850,7 @@ var ForgeSelect = class {
596
850
  const expanded = query !== "" || this.expandedValues.has(option.value);
597
851
  if (expanded) {
598
852
  for (const child of option.children) {
599
- if (subtreeMatches(child)) pushOption(child, depth + 1);
853
+ if (subtreeMatches(child)) pushOption(child, depth + 1, option.value);
600
854
  }
601
855
  }
602
856
  }
@@ -605,6 +859,10 @@ var ForgeSelect = class {
605
859
  this.rows.push({ kind: "loading" });
606
860
  return;
607
861
  }
862
+ if (this.loadError) {
863
+ this.rows.push({ kind: "error" });
864
+ return;
865
+ }
608
866
  for (const item of this.data) {
609
867
  if (isGroup(item)) {
610
868
  const visible = item.options.filter(subtreeMatches);
@@ -637,6 +895,12 @@ var ForgeSelect = class {
637
895
  renderList() {
638
896
  this.buildRows();
639
897
  this.renderRows();
898
+ this.announceStatus();
899
+ }
900
+ announceStatus() {
901
+ const first = this.rows[0];
902
+ const message = first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : "";
903
+ if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
640
904
  }
641
905
  renderRows() {
642
906
  const scrollTop = this.list.scrollTop;
@@ -681,10 +945,23 @@ var ForgeSelect = class {
681
945
  break;
682
946
  case "empty":
683
947
  li.className = "forge-select__empty";
948
+ li.setAttribute("role", "option");
949
+ li.setAttribute("aria-disabled", "true");
950
+ li.setAttribute("aria-selected", "false");
684
951
  li.textContent = this.strings.noResults;
685
952
  break;
953
+ case "error":
954
+ li.className = "forge-select__error";
955
+ li.setAttribute("role", "option");
956
+ li.setAttribute("aria-disabled", "true");
957
+ li.setAttribute("aria-selected", "false");
958
+ li.textContent = this.strings.errorLoading;
959
+ break;
686
960
  case "loading":
687
961
  li.className = "forge-select__loading";
962
+ li.setAttribute("role", "option");
963
+ li.setAttribute("aria-disabled", "true");
964
+ li.setAttribute("aria-selected", "false");
688
965
  li.textContent = this.strings.loading;
689
966
  break;
690
967
  case "loading-more":
@@ -721,11 +998,13 @@ var ForgeSelect = class {
721
998
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
722
999
  }
723
1000
  if (row.hasChildren) {
1001
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1002
+ li.setAttribute("aria-expanded", String(expanded));
724
1003
  const twisty = document.createElement("span");
725
1004
  twisty.className = "forge-select__twisty";
726
1005
  twisty.dataset.twisty = row.option.value;
727
1006
  twisty.setAttribute("aria-hidden", "true");
728
- twisty.textContent = this.expandedValues.has(row.option.value) ? "\u25BC" : "\u25B6";
1007
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
729
1008
  li.append(twisty);
730
1009
  }
731
1010
  li.append(this.optionContent(row.option));
@@ -745,7 +1024,7 @@ var ForgeSelect = class {
745
1024
  if (!cached) {
746
1025
  const holder = document.createElement("span");
747
1026
  holder.className = "forge-select__option-content";
748
- this.renderTemplate(holder, option, this.opts.templateResult);
1027
+ renderOptionContent(holder, option, this.opts.templateResult);
749
1028
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
750
1029
  const oldest = this.rowContentCache.keys().next().value;
751
1030
  this.rowContentCache.delete(oldest);
@@ -757,7 +1036,10 @@ var ForgeSelect = class {
757
1036
  }
758
1037
  moveHighlight(delta) {
759
1038
  if (this.navItems.length === 0) return;
760
- const next = this.highlightedIndex === -1 && delta > 0 ? 0 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
1039
+ const next = this.highlightedIndex === -1 ? delta > 0 ? 0 : this.navItems.length - 1 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
1040
+ this.focusNavIndex(next);
1041
+ }
1042
+ focusNavIndex(next) {
761
1043
  this.highlightedIndex = next;
762
1044
  if (this.usesVirtualScroll()) {
763
1045
  const rowIndex = this.rows.findIndex(
@@ -779,6 +1061,41 @@ var ForgeSelect = class {
779
1061
  highlighted?.scrollIntoView?.({ block: "nearest" });
780
1062
  }
781
1063
  }
1064
+ navigateTree(direction) {
1065
+ const item = this.navItems[this.highlightedIndex];
1066
+ if (!item || item.kind !== "option") return false;
1067
+ const { option, parentValue } = item;
1068
+ const hasChildren = !!option.children?.length;
1069
+ const expanded = this.query !== "" || this.expandedValues.has(option.value);
1070
+ if (direction === "right") {
1071
+ if (hasChildren && !expanded) {
1072
+ this.expandedValues.add(option.value);
1073
+ this.renderList();
1074
+ return true;
1075
+ }
1076
+ if (hasChildren) {
1077
+ const childIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.parentValue === option.value);
1078
+ if (childIndex >= 0) {
1079
+ this.focusNavIndex(childIndex);
1080
+ return true;
1081
+ }
1082
+ }
1083
+ return false;
1084
+ }
1085
+ if (hasChildren && expanded && this.query === "") {
1086
+ this.expandedValues.delete(option.value);
1087
+ this.renderList();
1088
+ return true;
1089
+ }
1090
+ if (parentValue) {
1091
+ const parentIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.option.value === parentValue);
1092
+ if (parentIndex >= 0) {
1093
+ this.focusNavIndex(parentIndex);
1094
+ return true;
1095
+ }
1096
+ }
1097
+ return false;
1098
+ }
782
1099
  updateActiveDescendant() {
783
1100
  const target = this.searchInput ?? this.control;
784
1101
  if (this.highlightedIndex >= 0) {
@@ -790,12 +1107,18 @@ var ForgeSelect = class {
790
1107
  // ---------------------------------------------------------------- remote data
791
1108
  scheduleRemoteLoad(query, delay) {
792
1109
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
1110
+ const requestId = ++this.ajaxRequestId;
1111
+ this.ajaxController?.abort();
1112
+ this.ajaxController = null;
793
1113
  this.page = 0;
794
1114
  this.hasMore = true;
795
1115
  this.loading = true;
1116
+ this.loadingMore = false;
1117
+ this.loadError = null;
796
1118
  this.renderList();
797
1119
  this.ajaxTimer = setTimeout(() => {
798
- void this.loadRemote(query);
1120
+ this.ajaxTimer = null;
1121
+ void this.loadRemote(query, { requestId });
799
1122
  }, delay);
800
1123
  }
801
1124
  /**
@@ -813,18 +1136,21 @@ var ForgeSelect = class {
813
1136
  this.renderList();
814
1137
  void this.loadRemote(this.query, { append: true });
815
1138
  }
816
- async loadRemote(query, { append = false } = {}) {
1139
+ async loadRemote(query, { append = false, requestId } = {}) {
817
1140
  const ajax = this.opts.ajax;
818
- const requestId = ++this.ajaxRequestId;
1141
+ const activeRequestId = requestId ?? ++this.ajaxRequestId;
1142
+ if (activeRequestId !== this.ajaxRequestId) return;
1143
+ this.ajaxController?.abort();
1144
+ const controller = new AbortController();
1145
+ this.ajaxController = controller;
819
1146
  const page = append ? this.page + 1 : 0;
820
1147
  try {
821
1148
  const url = buildUrl(ajax, query, page);
822
- const response = await fetch(url);
1149
+ const response = await fetch(url, { signal: controller.signal });
1150
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
823
1151
  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;
1152
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1153
+ const { options, hasMore } = normalizeRemoteResult(ajax, json);
828
1154
  if (append) {
829
1155
  const existing = collectValues(this.data);
830
1156
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
@@ -835,15 +1161,20 @@ var ForgeSelect = class {
835
1161
  this.page = page;
836
1162
  this.hasMore = hasMore;
837
1163
  this.remoteLoaded = true;
838
- } catch {
839
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
1164
+ this.loadError = null;
1165
+ } catch (cause) {
1166
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1167
+ const error = cause instanceof Error ? cause : new Error(String(cause));
840
1168
  if (!append) {
841
1169
  this.data = [];
842
1170
  this.rowContentCache.clear();
843
1171
  }
844
1172
  this.hasMore = false;
1173
+ this.loadError = error;
1174
+ this.emitter.emit("error", error);
845
1175
  } finally {
846
- if (requestId === this.ajaxRequestId && !this.destroyed) {
1176
+ if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1177
+ this.ajaxController = null;
847
1178
  this.loading = false;
848
1179
  this.loadingMore = false;
849
1180
  if (this.isOpen) this.renderList();
@@ -851,51 +1182,6 @@ var ForgeSelect = class {
851
1182
  }
852
1183
  }
853
1184
  };
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
1185
  // Annotate the CommonJS export names for ESM import in node:
900
1186
  0 && (module.exports = {
901
1187
  ForgeSelect