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.js CHANGED
@@ -30,19 +30,23 @@ var locales = {
30
30
  noResults: "No results found",
31
31
  loading: "Loading\u2026",
32
32
  loadingMore: "Loading more\u2026",
33
+ errorLoading: "Could not load options",
33
34
  createOption: 'Create "{query}"',
34
35
  clearSelection: "Clear selection",
35
36
  removeItem: "Remove {label}",
36
- search: "Search"
37
+ search: "Search",
38
+ reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
37
39
  },
38
40
  vi: {
39
41
  noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
40
42
  loading: "\u0110ang t\u1EA3i\u2026",
41
43
  loadingMore: "\u0110ang t\u1EA3i th\xEAm\u2026",
44
+ errorLoading: "Kh\xF4ng th\u1EC3 t\u1EA3i t\xF9y ch\u1ECDn",
42
45
  createOption: 'T\u1EA1o "{query}"',
43
46
  clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
44
47
  removeItem: "X\xF3a {label}",
45
- search: "T\xECm ki\u1EBFm"
48
+ search: "T\xECm ki\u1EBFm",
49
+ reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i."
46
50
  }
47
51
  };
48
52
  function getStrings(language) {
@@ -55,12 +59,88 @@ function format(template, vars) {
55
59
  return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
56
60
  }
57
61
 
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;
62
+ // src/native-select.ts
63
+ function parseNativeOptions(select) {
64
+ const data = [];
65
+ for (const child of Array.from(select.children)) {
66
+ if (child instanceof HTMLOptGroupElement) {
67
+ data.push({ label: child.label, options: Array.from(child.querySelectorAll("option")).map(parseOption) });
68
+ } else if (child instanceof HTMLOptionElement) {
69
+ data.push(parseOption(child));
70
+ }
71
+ }
72
+ return data;
73
+ }
74
+ function parseOption(option) {
75
+ const groupDisabled = option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled;
76
+ return {
77
+ value: option.value,
78
+ label: option.textContent?.trim() ?? option.value,
79
+ disabled: option.disabled || groupDisabled || void 0
80
+ };
81
+ }
82
+
83
+ // src/option-renderer.ts
84
+ function renderOptionContent(container, option, template, variant = "row") {
85
+ if (template) {
86
+ const result = template(option);
87
+ if (typeof result === "string") container.innerHTML = result;
88
+ else container.append(result);
89
+ return;
90
+ }
91
+ if (!option.avatar && !option.description) {
92
+ container.textContent = option.label;
93
+ return;
94
+ }
95
+ if (option.avatar) {
96
+ const avatar = document.createElement("img");
97
+ avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
98
+ avatar.src = option.avatar;
99
+ avatar.alt = "";
100
+ avatar.setAttribute("loading", "lazy");
101
+ avatar.setAttribute("decoding", "async");
102
+ container.append(avatar);
103
+ }
104
+ if (variant === "row" && option.description) {
105
+ const body = document.createElement("span");
106
+ body.className = "forge-select__option-body";
107
+ const label = document.createElement("span");
108
+ label.className = "forge-select__option-label";
109
+ label.textContent = option.label;
110
+ const description = document.createElement("span");
111
+ description.className = "forge-select__option-desc";
112
+ description.textContent = option.description;
113
+ body.append(label, description);
114
+ container.append(body);
115
+ } else {
116
+ const label = document.createElement("span");
117
+ label.className = "forge-select__option-label";
118
+ label.textContent = option.label;
119
+ container.append(label);
120
+ }
121
+ }
122
+
123
+ // src/remote.ts
124
+ function buildUrl(ajax, query, page) {
125
+ if (typeof ajax.url === "function") return ajax.url(query, page);
126
+ if (!ajax.params) return ajax.url;
127
+ const params = new URLSearchParams();
128
+ for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
129
+ const separator = ajax.url.includes("?") ? "&" : "?";
130
+ return `${ajax.url}${separator}${params.toString()}`;
131
+ }
132
+ function normalizeRemoteResult(ajax, response) {
133
+ const result = ajax.transform ? ajax.transform(response) : response;
134
+ if (Array.isArray(result)) return { options: result, hasMore: false };
135
+ if (!result || !Array.isArray(result.options)) {
136
+ throw new Error(
137
+ "ForgeSelect: ajax.transform must return an array of options, or an object shaped like { options: Option[], hasMore?: boolean }."
138
+ );
139
+ }
140
+ return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
141
+ }
142
+
143
+ // src/selection.ts
64
144
  function isGroup(item) {
65
145
  return item.options !== void 0;
66
146
  }
@@ -68,23 +148,69 @@ function collectDescendantValues(option) {
68
148
  if (!option.children) return [];
69
149
  const values = [];
70
150
  for (const child of option.children) {
71
- values.push(child.value, ...collectDescendantValues(child));
151
+ if (!child.disabled) values.push(child.value);
152
+ values.push(...collectDescendantValues(child));
72
153
  }
73
154
  return values;
74
155
  }
75
156
  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";
157
+ if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
158
+ const states = option.children.filter((child) => !child.disabled).map((child) => computeCheckState(child, selected));
159
+ if (states.length === 0) return "none";
160
+ if (states.every((state) => state === "all")) return "all";
161
+ if (states.every((state) => state === "none")) return "none";
82
162
  return "some";
83
163
  }
164
+ function findOption(items, value) {
165
+ const search = (options) => {
166
+ for (const option of options) {
167
+ if (option.value === value) return option;
168
+ const found = option.children ? search(option.children) : void 0;
169
+ if (found) return found;
170
+ }
171
+ return void 0;
172
+ };
173
+ for (const item of items) {
174
+ const found = search(isGroup(item) ? item.options : [item]);
175
+ if (found) return found;
176
+ }
177
+ return void 0;
178
+ }
179
+ function syncTreeAncestors(items, selected) {
180
+ const sync = (option) => {
181
+ if (!option.children?.length) return;
182
+ for (const child of option.children) sync(child);
183
+ const state = computeCheckState(option, selected);
184
+ const index = selected.indexOf(option.value);
185
+ if (state === "all" && index === -1) selected.push(option.value);
186
+ else if (state !== "all" && index !== -1) selected.splice(index, 1);
187
+ };
188
+ for (const item of items) (isGroup(item) ? item.options : [item]).forEach(sync);
189
+ }
190
+ function collectValues(items) {
191
+ const values = /* @__PURE__ */ new Set();
192
+ const visit = (option) => {
193
+ values.add(option.value);
194
+ option.children?.forEach(visit);
195
+ };
196
+ for (const item of items) (isGroup(item) ? item.options : [item]).forEach(visit);
197
+ return values;
198
+ }
199
+ function arraysEqual(a, b) {
200
+ return a.length === b.length && a.every((value, index) => value === b[index]);
201
+ }
202
+
203
+ // src/ForgeSelect.ts
204
+ var DEFAULT_ITEM_HEIGHT = 36;
205
+ var VIRTUAL_BUFFER = 5;
206
+ var VIRTUAL_THRESHOLD = 100;
207
+ var ROW_CACHE_LIMIT = 2e3;
208
+ var uidCounter = 0;
84
209
  var ForgeSelect = class {
85
210
  constructor(target, options = {}) {
86
211
  this.selected = [];
87
212
  this.selectedOptions = /* @__PURE__ */ new Map();
213
+ this.suppressNextTagClick = false;
88
214
  this.emitter = new Emitter();
89
215
  this.uid = `forge-select-${++uidCounter}`;
90
216
  this.searchInput = null;
@@ -103,24 +229,46 @@ var ForgeSelect = class {
103
229
  this.hasMore = true;
104
230
  this.ajaxTimer = null;
105
231
  this.ajaxRequestId = 0;
232
+ this.ajaxController = null;
106
233
  this.remoteLoaded = false;
234
+ this.loadError = null;
235
+ this.originalDisplay = "";
236
+ this.originalDisabled = false;
237
+ this.nativeSelect = null;
238
+ this.nativeForm = null;
239
+ this.syncingNative = false;
107
240
  this.onDocumentMouseDown = (event) => {
108
241
  if (!this.root.contains(event.target)) this.close();
109
242
  };
243
+ this.onNativeChange = () => {
244
+ if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
245
+ const values = Array.from(this.nativeSelect.selectedOptions, (option) => option.value);
246
+ this.applyNativeValues(values);
247
+ };
248
+ this.onFormReset = () => {
249
+ if (!this.nativeSelect || this.destroyed) return;
250
+ const defaults = Array.from(this.nativeSelect.options).filter((option) => option.defaultSelected).map((option) => option.value);
251
+ this.applyNativeValues(defaults);
252
+ };
110
253
  const el = typeof target === "string" ? document.querySelector(target) : target;
111
254
  if (!el) {
112
255
  throw new Error(`ForgeSelect: target element not found: ${String(target)}`);
113
256
  }
114
257
  this.el = el;
115
258
  const nativeSelect = el instanceof HTMLSelectElement ? el : null;
259
+ this.nativeSelect = nativeSelect;
260
+ this.nativeForm = nativeSelect?.form ?? null;
261
+ this.originalDisplay = el.style.display;
262
+ this.originalDisabled = nativeSelect?.disabled ?? false;
116
263
  this.opts = {
117
264
  placeholder: options.placeholder ?? "",
118
265
  searchable: options.searchable ?? true,
119
266
  multiple: options.multiple ?? nativeSelect?.multiple ?? false,
120
267
  clearable: options.clearable ?? false,
121
268
  allowCreate: options.allowCreate ?? false,
269
+ sortable: options.sortable ?? false,
122
270
  theme: options.theme ?? "default",
123
- disabled: options.disabled ?? false,
271
+ disabled: options.disabled ?? nativeSelect?.disabled ?? false,
124
272
  data: options.data,
125
273
  ajax: options.ajax,
126
274
  templateResult: options.templateResult,
@@ -134,15 +282,26 @@ var ForgeSelect = class {
134
282
  this.plugins = this.opts.plugins;
135
283
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
136
284
  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);
285
+ const nativeOptions = Array.from(nativeSelect.options);
286
+ const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
287
+ for (const option of nativeOptions) {
288
+ if (hasIntentionalSelection && option.selected) this.selectValue(option.value, false);
139
289
  }
140
290
  }
141
291
  this.buildDom();
142
292
  this.renderValue();
143
293
  if (this.opts.disabled) this.disable();
294
+ nativeSelect?.addEventListener("change", this.onNativeChange);
295
+ this.nativeForm?.addEventListener("reset", this.onFormReset);
144
296
  for (const plugin of this.plugins) plugin.onInit?.(this);
145
297
  }
298
+ applyNativeValues(values) {
299
+ this.selected = [];
300
+ for (const value of this.opts.multiple ? values : values.slice(0, 1)) this.selectValue(value, false);
301
+ this.renderValue();
302
+ if (this.isOpen) this.renderList();
303
+ this.emitter.emit("change", this.getValue());
304
+ }
146
305
  // ---------------------------------------------------------------- public API
147
306
  open() {
148
307
  if (this.isOpen || this.isDisabled || this.destroyed) return;
@@ -180,28 +339,33 @@ var ForgeSelect = class {
180
339
  for (const plugin of this.plugins) plugin.onDestroy?.(this);
181
340
  this.destroyed = true;
182
341
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
342
+ this.ajaxController?.abort();
343
+ this.nativeSelect?.removeEventListener("change", this.onNativeChange);
344
+ this.nativeForm?.removeEventListener("reset", this.onFormReset);
183
345
  this.rowContentCache.clear();
184
346
  this.root.remove();
185
- this.el.style.display = "";
347
+ this.el.style.display = this.originalDisplay;
348
+ if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
186
349
  this.emitter.clear();
187
350
  }
188
351
  getValue() {
189
352
  if (this.opts.multiple) return [...this.selected];
190
353
  return this.selected[0] ?? null;
191
354
  }
192
- setValue(value) {
355
+ setValue(value, options = {}) {
193
356
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
194
357
  const next = this.opts.multiple ? values : values.slice(0, 1);
195
358
  if (arraysEqual(next, this.selected)) return;
196
359
  this.selected = [];
197
360
  for (const v of next) this.selectValue(v, false);
198
- this.afterSelectionChange();
361
+ this.afterSelectionChange(options.emitChange ?? true);
199
362
  }
200
363
  enable() {
201
364
  this.isDisabled = false;
202
365
  this.root.classList.remove("forge-select--disabled");
203
366
  this.control.tabIndex = 0;
204
367
  this.control.setAttribute("aria-disabled", "false");
368
+ if (this.nativeSelect) this.nativeSelect.disabled = false;
205
369
  }
206
370
  disable() {
207
371
  this.close();
@@ -209,6 +373,7 @@ var ForgeSelect = class {
209
373
  this.root.classList.add("forge-select--disabled");
210
374
  this.control.tabIndex = -1;
211
375
  this.control.setAttribute("aria-disabled", "true");
376
+ if (this.nativeSelect) this.nativeSelect.disabled = true;
212
377
  }
213
378
  on(event, handler) {
214
379
  this.emitter.on(event, handler);
@@ -217,11 +382,35 @@ var ForgeSelect = class {
217
382
  this.emitter.off(event, handler);
218
383
  }
219
384
  // ---------------------------------------------------------------- DOM setup
385
+ /**
386
+ * The original target (a hidden native <select> or a plain mount div) can
387
+ * carry an accessible name via aria-label/aria-labelledby, or via a
388
+ * <label for> pointing at its id — but once `this.el` is display:none it
389
+ * drops out of the accessibility tree, so any such association silently
390
+ * stops reaching assistive tech unless we forward it onto the visible,
391
+ * interactive `this.control` ourselves.
392
+ */
393
+ applyAccessibleName() {
394
+ const ariaLabelledby = this.el.getAttribute("aria-labelledby");
395
+ const ariaLabel = this.el.getAttribute("aria-label");
396
+ if (ariaLabelledby) {
397
+ this.control.setAttribute("aria-labelledby", ariaLabelledby);
398
+ } else if (ariaLabel) {
399
+ this.control.setAttribute("aria-label", ariaLabel);
400
+ } else if (this.el.id) {
401
+ const label = Array.from(document.getElementsByTagName("label")).find((el) => el.htmlFor === this.el.id);
402
+ if (label) {
403
+ if (!label.id) label.id = `${this.uid}-label`;
404
+ this.control.setAttribute("aria-labelledby", label.id);
405
+ }
406
+ }
407
+ }
220
408
  buildDom() {
221
409
  this.root = document.createElement("div");
222
410
  this.root.className = "forge-select";
223
411
  this.root.dataset.theme = this.opts.theme;
224
412
  this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
413
+ if (this.opts.sortable && this.opts.multiple) this.root.classList.add("forge-select--sortable");
225
414
  this.control = document.createElement("div");
226
415
  this.control.className = "forge-select__control";
227
416
  this.control.setAttribute("role", "combobox");
@@ -229,6 +418,7 @@ var ForgeSelect = class {
229
418
  this.control.setAttribute("aria-expanded", "false");
230
419
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
231
420
  this.control.tabIndex = 0;
421
+ this.applyAccessibleName();
232
422
  this.valueEl = document.createElement("div");
233
423
  this.valueEl.className = "forge-select__value";
234
424
  this.clearBtn = document.createElement("button");
@@ -259,7 +449,11 @@ var ForgeSelect = class {
259
449
  this.list.setAttribute("role", "listbox");
260
450
  if (this.opts.multiple) this.list.setAttribute("aria-multiselectable", "true");
261
451
  this.dropdown.append(this.list);
262
- this.root.append(this.control, this.dropdown);
452
+ this.liveRegion = document.createElement("div");
453
+ this.liveRegion.className = "forge-select__sr-only";
454
+ this.liveRegion.setAttribute("role", "status");
455
+ this.liveRegion.setAttribute("aria-live", "polite");
456
+ this.root.append(this.control, this.dropdown, this.liveRegion);
263
457
  this.el.style.display = "none";
264
458
  this.el.insertAdjacentElement("afterend", this.root);
265
459
  this.bindEvents();
@@ -267,8 +461,13 @@ var ForgeSelect = class {
267
461
  bindEvents() {
268
462
  this.control.addEventListener("click", (event) => {
269
463
  if (event.target === this.clearBtn) return;
464
+ if (this.suppressNextTagClick) {
465
+ this.suppressNextTagClick = false;
466
+ return;
467
+ }
270
468
  if (this.isDisabled) return;
271
- this.isOpen ? this.close() : this.open();
469
+ if (this.isOpen) this.close();
470
+ else this.open();
272
471
  });
273
472
  this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
274
473
  this.clearBtn.addEventListener("click", (event) => {
@@ -339,6 +538,12 @@ var ForgeSelect = class {
339
538
  this.control.focus();
340
539
  }
341
540
  break;
541
+ case "ArrowRight":
542
+ if (this.isOpen && this.navigateTree("right")) event.preventDefault();
543
+ break;
544
+ case "ArrowLeft":
545
+ if (this.isOpen && this.navigateTree("left")) event.preventDefault();
546
+ break;
342
547
  case "Tab":
343
548
  this.close();
344
549
  break;
@@ -383,18 +588,7 @@ var ForgeSelect = class {
383
588
  * No-op for data with no `children` anywhere.
384
589
  */
385
590
  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
- }
591
+ syncTreeAncestors(this.data, this.selected);
398
592
  }
399
593
  clearSelection() {
400
594
  if (this.selected.length === 0) return;
@@ -402,13 +596,13 @@ var ForgeSelect = class {
402
596
  this.emitter.emit("clear");
403
597
  this.afterSelectionChange();
404
598
  }
405
- afterSelectionChange() {
599
+ afterSelectionChange(emitChange = true) {
406
600
  this.renderValue();
407
- this.syncNativeSelect();
601
+ this.syncNativeSelect(emitChange);
408
602
  if (this.isOpen) this.renderList();
409
- this.emitter.emit("change", this.getValue());
603
+ if (emitChange) this.emitter.emit("change", this.getValue());
410
604
  }
411
- syncNativeSelect() {
605
+ syncNativeSelect(dispatchChange = true) {
412
606
  if (!(this.el instanceof HTMLSelectElement)) return;
413
607
  const existing = /* @__PURE__ */ new Set();
414
608
  for (const option of Array.from(this.el.options)) {
@@ -423,24 +617,22 @@ var ForgeSelect = class {
423
617
  option.selected = true;
424
618
  this.el.append(option);
425
619
  }
426
- this.el.dispatchEvent(new Event("change", { bubbles: true }));
427
- }
428
- findOption(value) {
429
- const search = (options) => {
430
- 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
- }
620
+ if (this.opts.sortable && this.opts.multiple) {
621
+ for (const value of this.selected) {
622
+ const option = Array.from(this.el.options).find((o) => o.value === value);
623
+ if (option) this.el.append(option);
436
624
  }
437
- return void 0;
438
- };
439
- for (const item of this.data) {
440
- const found = search(isGroup(item) ? item.options : [item]);
441
- if (found) return found;
442
625
  }
443
- return void 0;
626
+ if (!dispatchChange) return;
627
+ this.syncingNative = true;
628
+ try {
629
+ this.el.dispatchEvent(new Event("change", { bubbles: true }));
630
+ } finally {
631
+ this.syncingNative = false;
632
+ }
633
+ }
634
+ findOption(value) {
635
+ return findOption(this.data, value);
444
636
  }
445
637
  createFromQuery() {
446
638
  const label = this.query.trim();
@@ -463,7 +655,8 @@ var ForgeSelect = class {
463
655
  }
464
656
  const { value } = item.option;
465
657
  if (this.opts.multiple) {
466
- this.selected.includes(value) ? this.deselectValue(value, true) : this.selectValue(value, true);
658
+ if (this.selected.includes(value)) this.deselectValue(value, true);
659
+ else this.selectValue(value, true);
467
660
  } else {
468
661
  this.selectValue(value, true);
469
662
  this.close();
@@ -489,7 +682,7 @@ var ForgeSelect = class {
489
682
  tag.className = "forge-select__tag";
490
683
  const label = document.createElement("span");
491
684
  label.className = "forge-select__tag-label";
492
- this.renderTemplate(label, option, this.opts.templateSelection, "inline");
685
+ renderOptionContent(label, option, this.opts.templateSelection, "inline");
493
686
  const remove = document.createElement("button");
494
687
  remove.type = "button";
495
688
  remove.className = "forge-select__tag-remove";
@@ -500,6 +693,14 @@ var ForgeSelect = class {
500
693
  if (!this.isDisabled) this.deselectValue(value, true);
501
694
  });
502
695
  tag.append(label, remove);
696
+ if (this.opts.sortable) {
697
+ tag.dataset.value = value;
698
+ tag.tabIndex = 0;
699
+ tag.setAttribute("aria-roledescription", "draggable item");
700
+ tag.setAttribute("aria-label", format(this.strings.reorderHint, { label: option.label }));
701
+ tag.addEventListener("keydown", (event) => this.handleTagKeydown(event, value));
702
+ this.bindTagDrag(tag, value);
703
+ }
503
704
  this.valueEl.append(tag);
504
705
  }
505
706
  } else {
@@ -509,46 +710,99 @@ var ForgeSelect = class {
509
710
  };
510
711
  const span = document.createElement("span");
511
712
  span.className = "forge-select__single-value";
512
- this.renderTemplate(span, option, this.opts.templateSelection, "inline");
713
+ renderOptionContent(span, option, this.opts.templateSelection, "inline");
513
714
  this.valueEl.append(span);
514
715
  }
515
716
  }
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);
717
+ /**
718
+ * Pointer-based (mouse/touch/pen) reorder for a single tag. Only the real
719
+ * dragged DOM node is moved during the gesture — a full renderValue()
720
+ * mid-drag would destroy it — so the reordered `this.selected` is only
721
+ * committed on release. The move/up listeners and pointer capture live on
722
+ * the stable `this.valueEl` container rather than the tag itself: `tag`
723
+ * gets repositioned via `insertBefore` during the drag, and browsers treat
724
+ * that reparenting as detaching the node, which silently drops pointer
725
+ * capture (and further move events) if it were captured on `tag`.
726
+ */
727
+ bindTagDrag(tag, value) {
728
+ const DRAG_THRESHOLD = 4;
729
+ let startX = 0;
730
+ let dragging = false;
731
+ let order = [];
732
+ const onPointerMove = (event) => {
733
+ if (!dragging) {
734
+ if (Math.abs(event.clientX - startX) < DRAG_THRESHOLD) return;
735
+ dragging = true;
736
+ order = [...this.selected];
737
+ if (typeof this.valueEl.setPointerCapture === "function") {
738
+ this.valueEl.setPointerCapture(event.pointerId);
739
+ }
740
+ tag.classList.add("forge-select__tag--dragging");
741
+ }
742
+ event.preventDefault();
743
+ const draggedIndex = order.indexOf(value);
744
+ const siblings = Array.from(this.valueEl.querySelectorAll(".forge-select__tag"));
745
+ for (const sibling of siblings) {
746
+ if (sibling === tag) continue;
747
+ const siblingValue = sibling.dataset.value;
748
+ if (!siblingValue) continue;
749
+ const siblingIndex = order.indexOf(siblingValue);
750
+ if (siblingIndex === -1) continue;
751
+ const rect = sibling.getBoundingClientRect();
752
+ const midX = rect.left + rect.width / 2;
753
+ const movingRight = draggedIndex < siblingIndex;
754
+ const crossed = movingRight ? event.clientX > midX : event.clientX < midX;
755
+ if (!crossed) continue;
756
+ order.splice(draggedIndex, 1);
757
+ order.splice(siblingIndex, 0, value);
758
+ if (movingRight) this.valueEl.insertBefore(tag, sibling.nextSibling);
759
+ else this.valueEl.insertBefore(tag, sibling);
760
+ break;
761
+ }
762
+ };
763
+ const finishDrag = (event) => {
764
+ this.valueEl.removeEventListener("pointermove", onPointerMove);
765
+ this.valueEl.removeEventListener("pointerup", finishDrag);
766
+ this.valueEl.removeEventListener("pointercancel", finishDrag);
767
+ if (!dragging) return;
768
+ if (typeof this.valueEl.releasePointerCapture === "function") {
769
+ this.valueEl.releasePointerCapture(event.pointerId);
770
+ }
771
+ tag.classList.remove("forge-select__tag--dragging");
772
+ this.selected = order;
773
+ this.suppressNextTagClick = true;
774
+ this.afterSelectionChange();
775
+ };
776
+ tag.addEventListener("pointerdown", (event) => {
777
+ if (this.isDisabled || event.button !== 0) return;
778
+ if (event.target.closest(".forge-select__tag-remove")) return;
779
+ startX = event.clientX;
780
+ dragging = false;
781
+ this.valueEl.addEventListener("pointermove", onPointerMove);
782
+ this.valueEl.addEventListener("pointerup", finishDrag);
783
+ this.valueEl.addEventListener("pointercancel", finishDrag);
784
+ });
785
+ }
786
+ /** Alt+Left/Alt+Right on a focused tag: the keyboard-operable equivalent of dragging. */
787
+ handleTagKeydown(event, value) {
788
+ if (!event.altKey || event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
789
+ const index = this.selected.indexOf(value);
790
+ const targetIndex = event.key === "ArrowLeft" ? index - 1 : index + 1;
791
+ if (index === -1 || targetIndex < 0 || targetIndex >= this.selected.length) return;
792
+ event.preventDefault();
793
+ event.stopPropagation();
794
+ const next = [...this.selected];
795
+ [next[index], next[targetIndex]] = [next[targetIndex], next[index]];
796
+ this.selected = next;
797
+ this.afterSelectionChange();
798
+ this.focusTagByValue(value);
799
+ }
800
+ focusTagByValue(value) {
801
+ for (const tag of Array.from(this.valueEl.querySelectorAll(".forge-select__tag"))) {
802
+ if (tag.dataset.value === value) {
803
+ tag.focus();
804
+ return;
805
+ }
552
806
  }
553
807
  }
554
808
  buildRows() {
@@ -557,11 +811,11 @@ var ForgeSelect = class {
557
811
  const query = this.query.trim().toLowerCase();
558
812
  const matches = (option) => query === "" || option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false);
559
813
  const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
560
- const pushOption = (option, depth) => {
814
+ const pushOption = (option, depth, parentValue) => {
561
815
  let navIndex = -1;
562
816
  if (!option.disabled) {
563
817
  navIndex = this.navItems.length;
564
- this.navItems.push({ kind: "option", option });
818
+ this.navItems.push({ kind: "option", option, parentValue });
565
819
  }
566
820
  const hasChildren = !!option.children && option.children.length > 0;
567
821
  this.rows.push({ kind: "option", option, navIndex, depth, hasChildren });
@@ -569,7 +823,7 @@ var ForgeSelect = class {
569
823
  const expanded = query !== "" || this.expandedValues.has(option.value);
570
824
  if (expanded) {
571
825
  for (const child of option.children) {
572
- if (subtreeMatches(child)) pushOption(child, depth + 1);
826
+ if (subtreeMatches(child)) pushOption(child, depth + 1, option.value);
573
827
  }
574
828
  }
575
829
  }
@@ -578,6 +832,10 @@ var ForgeSelect = class {
578
832
  this.rows.push({ kind: "loading" });
579
833
  return;
580
834
  }
835
+ if (this.loadError) {
836
+ this.rows.push({ kind: "error" });
837
+ return;
838
+ }
581
839
  for (const item of this.data) {
582
840
  if (isGroup(item)) {
583
841
  const visible = item.options.filter(subtreeMatches);
@@ -610,6 +868,12 @@ var ForgeSelect = class {
610
868
  renderList() {
611
869
  this.buildRows();
612
870
  this.renderRows();
871
+ this.announceStatus();
872
+ }
873
+ announceStatus() {
874
+ const first = this.rows[0];
875
+ const message = first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : "";
876
+ if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
613
877
  }
614
878
  renderRows() {
615
879
  const scrollTop = this.list.scrollTop;
@@ -654,10 +918,23 @@ var ForgeSelect = class {
654
918
  break;
655
919
  case "empty":
656
920
  li.className = "forge-select__empty";
921
+ li.setAttribute("role", "option");
922
+ li.setAttribute("aria-disabled", "true");
923
+ li.setAttribute("aria-selected", "false");
657
924
  li.textContent = this.strings.noResults;
658
925
  break;
926
+ case "error":
927
+ li.className = "forge-select__error";
928
+ li.setAttribute("role", "option");
929
+ li.setAttribute("aria-disabled", "true");
930
+ li.setAttribute("aria-selected", "false");
931
+ li.textContent = this.strings.errorLoading;
932
+ break;
659
933
  case "loading":
660
934
  li.className = "forge-select__loading";
935
+ li.setAttribute("role", "option");
936
+ li.setAttribute("aria-disabled", "true");
937
+ li.setAttribute("aria-selected", "false");
661
938
  li.textContent = this.strings.loading;
662
939
  break;
663
940
  case "loading-more":
@@ -694,11 +971,13 @@ var ForgeSelect = class {
694
971
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
695
972
  }
696
973
  if (row.hasChildren) {
974
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
975
+ li.setAttribute("aria-expanded", String(expanded));
697
976
  const twisty = document.createElement("span");
698
977
  twisty.className = "forge-select__twisty";
699
978
  twisty.dataset.twisty = row.option.value;
700
979
  twisty.setAttribute("aria-hidden", "true");
701
- twisty.textContent = this.expandedValues.has(row.option.value) ? "\u25BC" : "\u25B6";
980
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
702
981
  li.append(twisty);
703
982
  }
704
983
  li.append(this.optionContent(row.option));
@@ -718,7 +997,7 @@ var ForgeSelect = class {
718
997
  if (!cached) {
719
998
  const holder = document.createElement("span");
720
999
  holder.className = "forge-select__option-content";
721
- this.renderTemplate(holder, option, this.opts.templateResult);
1000
+ renderOptionContent(holder, option, this.opts.templateResult);
722
1001
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
723
1002
  const oldest = this.rowContentCache.keys().next().value;
724
1003
  this.rowContentCache.delete(oldest);
@@ -730,7 +1009,10 @@ var ForgeSelect = class {
730
1009
  }
731
1010
  moveHighlight(delta) {
732
1011
  if (this.navItems.length === 0) return;
733
- const next = this.highlightedIndex === -1 && delta > 0 ? 0 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
1012
+ const next = this.highlightedIndex === -1 ? delta > 0 ? 0 : this.navItems.length - 1 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
1013
+ this.focusNavIndex(next);
1014
+ }
1015
+ focusNavIndex(next) {
734
1016
  this.highlightedIndex = next;
735
1017
  if (this.usesVirtualScroll()) {
736
1018
  const rowIndex = this.rows.findIndex(
@@ -752,6 +1034,41 @@ var ForgeSelect = class {
752
1034
  highlighted?.scrollIntoView?.({ block: "nearest" });
753
1035
  }
754
1036
  }
1037
+ navigateTree(direction) {
1038
+ const item = this.navItems[this.highlightedIndex];
1039
+ if (!item || item.kind !== "option") return false;
1040
+ const { option, parentValue } = item;
1041
+ const hasChildren = !!option.children?.length;
1042
+ const expanded = this.query !== "" || this.expandedValues.has(option.value);
1043
+ if (direction === "right") {
1044
+ if (hasChildren && !expanded) {
1045
+ this.expandedValues.add(option.value);
1046
+ this.renderList();
1047
+ return true;
1048
+ }
1049
+ if (hasChildren) {
1050
+ const childIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.parentValue === option.value);
1051
+ if (childIndex >= 0) {
1052
+ this.focusNavIndex(childIndex);
1053
+ return true;
1054
+ }
1055
+ }
1056
+ return false;
1057
+ }
1058
+ if (hasChildren && expanded && this.query === "") {
1059
+ this.expandedValues.delete(option.value);
1060
+ this.renderList();
1061
+ return true;
1062
+ }
1063
+ if (parentValue) {
1064
+ const parentIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.option.value === parentValue);
1065
+ if (parentIndex >= 0) {
1066
+ this.focusNavIndex(parentIndex);
1067
+ return true;
1068
+ }
1069
+ }
1070
+ return false;
1071
+ }
755
1072
  updateActiveDescendant() {
756
1073
  const target = this.searchInput ?? this.control;
757
1074
  if (this.highlightedIndex >= 0) {
@@ -763,12 +1080,18 @@ var ForgeSelect = class {
763
1080
  // ---------------------------------------------------------------- remote data
764
1081
  scheduleRemoteLoad(query, delay) {
765
1082
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
1083
+ const requestId = ++this.ajaxRequestId;
1084
+ this.ajaxController?.abort();
1085
+ this.ajaxController = null;
766
1086
  this.page = 0;
767
1087
  this.hasMore = true;
768
1088
  this.loading = true;
1089
+ this.loadingMore = false;
1090
+ this.loadError = null;
769
1091
  this.renderList();
770
1092
  this.ajaxTimer = setTimeout(() => {
771
- void this.loadRemote(query);
1093
+ this.ajaxTimer = null;
1094
+ void this.loadRemote(query, { requestId });
772
1095
  }, delay);
773
1096
  }
774
1097
  /**
@@ -786,18 +1109,21 @@ var ForgeSelect = class {
786
1109
  this.renderList();
787
1110
  void this.loadRemote(this.query, { append: true });
788
1111
  }
789
- async loadRemote(query, { append = false } = {}) {
1112
+ async loadRemote(query, { append = false, requestId } = {}) {
790
1113
  const ajax = this.opts.ajax;
791
- const requestId = ++this.ajaxRequestId;
1114
+ const activeRequestId = requestId ?? ++this.ajaxRequestId;
1115
+ if (activeRequestId !== this.ajaxRequestId) return;
1116
+ this.ajaxController?.abort();
1117
+ const controller = new AbortController();
1118
+ this.ajaxController = controller;
792
1119
  const page = append ? this.page + 1 : 0;
793
1120
  try {
794
1121
  const url = buildUrl(ajax, query, page);
795
- const response = await fetch(url);
1122
+ const response = await fetch(url, { signal: controller.signal });
1123
+ if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
796
1124
  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;
1125
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1126
+ const { options, hasMore } = normalizeRemoteResult(ajax, json);
801
1127
  if (append) {
802
1128
  const existing = collectValues(this.data);
803
1129
  this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
@@ -808,15 +1134,20 @@ var ForgeSelect = class {
808
1134
  this.page = page;
809
1135
  this.hasMore = hasMore;
810
1136
  this.remoteLoaded = true;
811
- } catch {
812
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
1137
+ this.loadError = null;
1138
+ } catch (cause) {
1139
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
1140
+ const error = cause instanceof Error ? cause : new Error(String(cause));
813
1141
  if (!append) {
814
1142
  this.data = [];
815
1143
  this.rowContentCache.clear();
816
1144
  }
817
1145
  this.hasMore = false;
1146
+ this.loadError = error;
1147
+ this.emitter.emit("error", error);
818
1148
  } finally {
819
- if (requestId === this.ajaxRequestId && !this.destroyed) {
1149
+ if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1150
+ this.ajaxController = null;
820
1151
  this.loading = false;
821
1152
  this.loadingMore = false;
822
1153
  if (this.isOpen) this.renderList();
@@ -824,51 +1155,6 @@ var ForgeSelect = class {
824
1155
  }
825
1156
  }
826
1157
  };
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
1158
  export {
873
1159
  ForgeSelect,
874
1160
  ForgeSelect as default