forge-select 0.1.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
@@ -56,18 +56,24 @@ var locales = {
56
56
  en: {
57
57
  noResults: "No results found",
58
58
  loading: "Loading\u2026",
59
+ loadingMore: "Loading more\u2026",
60
+ errorLoading: "Could not load options",
59
61
  createOption: 'Create "{query}"',
60
62
  clearSelection: "Clear selection",
61
63
  removeItem: "Remove {label}",
62
- search: "Search"
64
+ search: "Search",
65
+ reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
63
66
  },
64
67
  vi: {
65
68
  noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
66
69
  loading: "\u0110ang t\u1EA3i\u2026",
70
+ loadingMore: "\u0110ang t\u1EA3i th\xEAm\u2026",
71
+ errorLoading: "Kh\xF4ng th\u1EC3 t\u1EA3i t\xF9y ch\u1ECDn",
67
72
  createOption: 'T\u1EA1o "{query}"',
68
73
  clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
69
74
  removeItem: "X\xF3a {label}",
70
- 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."
71
77
  }
72
78
  };
73
79
  function getStrings(language) {
@@ -80,19 +86,158 @@ function format(template, vars) {
80
86
  return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
81
87
  }
82
88
 
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
171
+ function isGroup(item) {
172
+ return item.options !== void 0;
173
+ }
174
+ function collectDescendantValues(option) {
175
+ if (!option.children) return [];
176
+ const values = [];
177
+ for (const child of option.children) {
178
+ if (!child.disabled) values.push(child.value);
179
+ values.push(...collectDescendantValues(child));
180
+ }
181
+ return values;
182
+ }
183
+ function computeCheckState(option, selected) {
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";
189
+ return "some";
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
+
83
230
  // src/ForgeSelect.ts
84
231
  var DEFAULT_ITEM_HEIGHT = 36;
85
232
  var VIRTUAL_BUFFER = 5;
86
233
  var VIRTUAL_THRESHOLD = 100;
87
234
  var ROW_CACHE_LIMIT = 2e3;
88
235
  var uidCounter = 0;
89
- function isGroup(item) {
90
- return item.options !== void 0;
91
- }
92
236
  var ForgeSelect = class {
93
237
  constructor(target, options = {}) {
94
238
  this.selected = [];
95
239
  this.selectedOptions = /* @__PURE__ */ new Map();
240
+ this.suppressNextTagClick = false;
96
241
  this.emitter = new Emitter();
97
242
  this.uid = `forge-select-${++uidCounter}`;
98
243
  this.searchInput = null;
@@ -104,27 +249,53 @@ var ForgeSelect = class {
104
249
  this.navItems = [];
105
250
  this.highlightedIndex = -1;
106
251
  this.rowContentCache = /* @__PURE__ */ new Map();
252
+ this.expandedValues = /* @__PURE__ */ new Set();
107
253
  this.loading = false;
254
+ this.loadingMore = false;
255
+ this.page = 0;
256
+ this.hasMore = true;
108
257
  this.ajaxTimer = null;
109
258
  this.ajaxRequestId = 0;
259
+ this.ajaxController = null;
110
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;
111
267
  this.onDocumentMouseDown = (event) => {
112
268
  if (!this.root.contains(event.target)) this.close();
113
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
+ };
114
280
  const el = typeof target === "string" ? document.querySelector(target) : target;
115
281
  if (!el) {
116
282
  throw new Error(`ForgeSelect: target element not found: ${String(target)}`);
117
283
  }
118
284
  this.el = el;
119
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;
120
290
  this.opts = {
121
291
  placeholder: options.placeholder ?? "",
122
292
  searchable: options.searchable ?? true,
123
293
  multiple: options.multiple ?? nativeSelect?.multiple ?? false,
124
294
  clearable: options.clearable ?? false,
125
295
  allowCreate: options.allowCreate ?? false,
296
+ sortable: options.sortable ?? false,
126
297
  theme: options.theme ?? "default",
127
- disabled: options.disabled ?? false,
298
+ disabled: options.disabled ?? nativeSelect?.disabled ?? false,
128
299
  data: options.data,
129
300
  ajax: options.ajax,
130
301
  templateResult: options.templateResult,
@@ -138,15 +309,26 @@ var ForgeSelect = class {
138
309
  this.plugins = this.opts.plugins;
139
310
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
140
311
  if (nativeSelect && !this.opts.data) {
141
- for (const option of Array.from(nativeSelect.querySelectorAll("option"))) {
142
- 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);
143
316
  }
144
317
  }
145
318
  this.buildDom();
146
319
  this.renderValue();
147
320
  if (this.opts.disabled) this.disable();
321
+ nativeSelect?.addEventListener("change", this.onNativeChange);
322
+ this.nativeForm?.addEventListener("reset", this.onFormReset);
148
323
  for (const plugin of this.plugins) plugin.onInit?.(this);
149
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
+ }
150
332
  // ---------------------------------------------------------------- public API
151
333
  open() {
152
334
  if (this.isOpen || this.isDisabled || this.destroyed) return;
@@ -184,28 +366,33 @@ var ForgeSelect = class {
184
366
  for (const plugin of this.plugins) plugin.onDestroy?.(this);
185
367
  this.destroyed = true;
186
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);
187
372
  this.rowContentCache.clear();
188
373
  this.root.remove();
189
- this.el.style.display = "";
374
+ this.el.style.display = this.originalDisplay;
375
+ if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
190
376
  this.emitter.clear();
191
377
  }
192
378
  getValue() {
193
379
  if (this.opts.multiple) return [...this.selected];
194
380
  return this.selected[0] ?? null;
195
381
  }
196
- setValue(value) {
382
+ setValue(value, options = {}) {
197
383
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
198
384
  const next = this.opts.multiple ? values : values.slice(0, 1);
199
385
  if (arraysEqual(next, this.selected)) return;
200
386
  this.selected = [];
201
387
  for (const v of next) this.selectValue(v, false);
202
- this.afterSelectionChange();
388
+ this.afterSelectionChange(options.emitChange ?? true);
203
389
  }
204
390
  enable() {
205
391
  this.isDisabled = false;
206
392
  this.root.classList.remove("forge-select--disabled");
207
393
  this.control.tabIndex = 0;
208
394
  this.control.setAttribute("aria-disabled", "false");
395
+ if (this.nativeSelect) this.nativeSelect.disabled = false;
209
396
  }
210
397
  disable() {
211
398
  this.close();
@@ -213,6 +400,7 @@ var ForgeSelect = class {
213
400
  this.root.classList.add("forge-select--disabled");
214
401
  this.control.tabIndex = -1;
215
402
  this.control.setAttribute("aria-disabled", "true");
403
+ if (this.nativeSelect) this.nativeSelect.disabled = true;
216
404
  }
217
405
  on(event, handler) {
218
406
  this.emitter.on(event, handler);
@@ -221,11 +409,35 @@ var ForgeSelect = class {
221
409
  this.emitter.off(event, handler);
222
410
  }
223
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
+ }
224
435
  buildDom() {
225
436
  this.root = document.createElement("div");
226
437
  this.root.className = "forge-select";
227
438
  this.root.dataset.theme = this.opts.theme;
228
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");
229
441
  this.control = document.createElement("div");
230
442
  this.control.className = "forge-select__control";
231
443
  this.control.setAttribute("role", "combobox");
@@ -233,6 +445,7 @@ var ForgeSelect = class {
233
445
  this.control.setAttribute("aria-expanded", "false");
234
446
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
235
447
  this.control.tabIndex = 0;
448
+ this.applyAccessibleName();
236
449
  this.valueEl = document.createElement("div");
237
450
  this.valueEl.className = "forge-select__value";
238
451
  this.clearBtn = document.createElement("button");
@@ -263,7 +476,11 @@ var ForgeSelect = class {
263
476
  this.list.setAttribute("role", "listbox");
264
477
  if (this.opts.multiple) this.list.setAttribute("aria-multiselectable", "true");
265
478
  this.dropdown.append(this.list);
266
- 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);
267
484
  this.el.style.display = "none";
268
485
  this.el.insertAdjacentElement("afterend", this.root);
269
486
  this.bindEvents();
@@ -271,8 +488,13 @@ var ForgeSelect = class {
271
488
  bindEvents() {
272
489
  this.control.addEventListener("click", (event) => {
273
490
  if (event.target === this.clearBtn) return;
491
+ if (this.suppressNextTagClick) {
492
+ this.suppressNextTagClick = false;
493
+ return;
494
+ }
274
495
  if (this.isDisabled) return;
275
- this.isOpen ? this.close() : this.open();
496
+ if (this.isOpen) this.close();
497
+ else this.open();
276
498
  });
277
499
  this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
278
500
  this.clearBtn.addEventListener("click", (event) => {
@@ -294,13 +516,23 @@ var ForgeSelect = class {
294
516
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
295
517
  }
296
518
  this.list.addEventListener("click", (event) => {
297
- const li = event.target.closest("li[data-nav-index]");
519
+ const target = event.target;
520
+ const twisty = target.closest("[data-twisty]");
521
+ if (twisty) {
522
+ const value = twisty.dataset.twisty;
523
+ if (this.expandedValues.has(value)) this.expandedValues.delete(value);
524
+ else this.expandedValues.add(value);
525
+ this.renderList();
526
+ return;
527
+ }
528
+ const li = target.closest("li[data-nav-index]");
298
529
  if (!li) return;
299
530
  const navIndex = Number(li.dataset.navIndex);
300
531
  this.activateNavItem(navIndex);
301
532
  });
302
533
  this.list.addEventListener("scroll", () => {
303
534
  if (this.usesVirtualScroll()) this.renderRows();
535
+ this.maybeLoadNextPage();
304
536
  });
305
537
  }
306
538
  handleKeydown(event) {
@@ -333,6 +565,12 @@ var ForgeSelect = class {
333
565
  this.control.focus();
334
566
  }
335
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;
336
574
  case "Tab":
337
575
  this.close();
338
576
  break;
@@ -343,29 +581,55 @@ var ForgeSelect = class {
343
581
  if (this.selected.includes(value)) return;
344
582
  const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
345
583
  this.selectedOptions.set(value, option);
346
- if (this.opts.multiple) this.selected.push(value);
347
- else this.selected = [value];
584
+ if (this.opts.multiple) {
585
+ this.selected.push(value);
586
+ for (const v of collectDescendantValues(option)) {
587
+ if (!this.selected.includes(v)) this.selected.push(v);
588
+ }
589
+ this.syncTreeAncestors();
590
+ } else {
591
+ this.selected = [value];
592
+ }
348
593
  if (notify) this.afterSelectionChange();
349
594
  }
350
595
  deselectValue(value, notify) {
351
596
  const index = this.selected.indexOf(value);
352
597
  if (index === -1) return;
353
598
  this.selected.splice(index, 1);
599
+ if (this.opts.multiple) {
600
+ const option = this.findOption(value) ?? this.selectedOptions.get(value);
601
+ if (option) {
602
+ for (const v of collectDescendantValues(option)) {
603
+ const i = this.selected.indexOf(v);
604
+ if (i !== -1) this.selected.splice(i, 1);
605
+ }
606
+ }
607
+ this.syncTreeAncestors();
608
+ }
354
609
  if (notify) this.afterSelectionChange();
355
610
  }
611
+ /**
612
+ * Keeps every tree parent's own membership in `selected` consistent with
613
+ * its descendants (post-order, so parents see already-corrected children):
614
+ * a parent counts as selected only when `computeCheckState` says "all".
615
+ * No-op for data with no `children` anywhere.
616
+ */
617
+ syncTreeAncestors() {
618
+ syncTreeAncestors(this.data, this.selected);
619
+ }
356
620
  clearSelection() {
357
621
  if (this.selected.length === 0) return;
358
622
  this.selected = [];
359
623
  this.emitter.emit("clear");
360
624
  this.afterSelectionChange();
361
625
  }
362
- afterSelectionChange() {
626
+ afterSelectionChange(emitChange = true) {
363
627
  this.renderValue();
364
- this.syncNativeSelect();
628
+ this.syncNativeSelect(emitChange);
365
629
  if (this.isOpen) this.renderList();
366
- this.emitter.emit("change", this.getValue());
630
+ if (emitChange) this.emitter.emit("change", this.getValue());
367
631
  }
368
- syncNativeSelect() {
632
+ syncNativeSelect(dispatchChange = true) {
369
633
  if (!(this.el instanceof HTMLSelectElement)) return;
370
634
  const existing = /* @__PURE__ */ new Set();
371
635
  for (const option of Array.from(this.el.options)) {
@@ -380,18 +644,22 @@ var ForgeSelect = class {
380
644
  option.selected = true;
381
645
  this.el.append(option);
382
646
  }
383
- this.el.dispatchEvent(new Event("change", { bubbles: true }));
384
- }
385
- findOption(value) {
386
- for (const item of this.data) {
387
- if (isGroup(item)) {
388
- const found = item.options.find((o) => o.value === value);
389
- if (found) return found;
390
- } else if (item.value === value) {
391
- return item;
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);
392
651
  }
393
652
  }
394
- 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);
395
663
  }
396
664
  createFromQuery() {
397
665
  const label = this.query.trim();
@@ -414,7 +682,8 @@ var ForgeSelect = class {
414
682
  }
415
683
  const { value } = item.option;
416
684
  if (this.opts.multiple) {
417
- 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);
418
687
  } else {
419
688
  this.selectValue(value, true);
420
689
  this.close();
@@ -440,7 +709,7 @@ var ForgeSelect = class {
440
709
  tag.className = "forge-select__tag";
441
710
  const label = document.createElement("span");
442
711
  label.className = "forge-select__tag-label";
443
- this.renderTemplate(label, option, this.opts.templateSelection, "inline");
712
+ renderOptionContent(label, option, this.opts.templateSelection, "inline");
444
713
  const remove = document.createElement("button");
445
714
  remove.type = "button";
446
715
  remove.className = "forge-select__tag-remove";
@@ -451,6 +720,14 @@ var ForgeSelect = class {
451
720
  if (!this.isDisabled) this.deselectValue(value, true);
452
721
  });
453
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
+ }
454
731
  this.valueEl.append(tag);
455
732
  }
456
733
  } else {
@@ -460,46 +737,99 @@ var ForgeSelect = class {
460
737
  };
461
738
  const span = document.createElement("span");
462
739
  span.className = "forge-select__single-value";
463
- this.renderTemplate(span, option, this.opts.templateSelection, "inline");
740
+ renderOptionContent(span, option, this.opts.templateSelection, "inline");
464
741
  this.valueEl.append(span);
465
742
  }
466
743
  }
467
- renderTemplate(container, option, template, variant = "row") {
468
- if (template) {
469
- const result = template(option);
470
- if (typeof result === "string") container.innerHTML = result;
471
- else container.append(result);
472
- return;
473
- }
474
- if (!option.avatar && !option.description) {
475
- container.textContent = option.label;
476
- return;
477
- }
478
- if (option.avatar) {
479
- const avatar = document.createElement("img");
480
- avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
481
- avatar.src = option.avatar;
482
- avatar.alt = "";
483
- avatar.setAttribute("loading", "lazy");
484
- avatar.setAttribute("decoding", "async");
485
- container.append(avatar);
486
- }
487
- if (variant === "row" && option.description) {
488
- const body = document.createElement("span");
489
- body.className = "forge-select__option-body";
490
- const label = document.createElement("span");
491
- label.className = "forge-select__option-label";
492
- label.textContent = option.label;
493
- const desc = document.createElement("span");
494
- desc.className = "forge-select__option-desc";
495
- desc.textContent = option.description;
496
- body.append(label, desc);
497
- container.append(body);
498
- } else {
499
- const label = document.createElement("span");
500
- label.className = "forge-select__option-label";
501
- label.textContent = option.label;
502
- 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
+ }
503
833
  }
504
834
  }
505
835
  buildRows() {
@@ -507,26 +837,40 @@ var ForgeSelect = class {
507
837
  this.navItems = [];
508
838
  const query = this.query.trim().toLowerCase();
509
839
  const matches = (option) => query === "" || option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false);
510
- const pushOption = (option) => {
840
+ const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
841
+ const pushOption = (option, depth, parentValue) => {
511
842
  let navIndex = -1;
512
843
  if (!option.disabled) {
513
844
  navIndex = this.navItems.length;
514
- this.navItems.push({ kind: "option", option });
845
+ this.navItems.push({ kind: "option", option, parentValue });
846
+ }
847
+ const hasChildren = !!option.children && option.children.length > 0;
848
+ this.rows.push({ kind: "option", option, navIndex, depth, hasChildren });
849
+ if (hasChildren) {
850
+ const expanded = query !== "" || this.expandedValues.has(option.value);
851
+ if (expanded) {
852
+ for (const child of option.children) {
853
+ if (subtreeMatches(child)) pushOption(child, depth + 1, option.value);
854
+ }
855
+ }
515
856
  }
516
- this.rows.push({ kind: "option", option, navIndex });
517
857
  };
518
858
  if (this.loading) {
519
859
  this.rows.push({ kind: "loading" });
520
860
  return;
521
861
  }
862
+ if (this.loadError) {
863
+ this.rows.push({ kind: "error" });
864
+ return;
865
+ }
522
866
  for (const item of this.data) {
523
867
  if (isGroup(item)) {
524
- const visible = item.options.filter(matches);
868
+ const visible = item.options.filter(subtreeMatches);
525
869
  if (visible.length === 0) continue;
526
870
  this.rows.push({ kind: "group", label: item.label });
527
- visible.forEach(pushOption);
528
- } else if (matches(item)) {
529
- pushOption(item);
871
+ visible.forEach((o) => pushOption(o, 0));
872
+ } else if (subtreeMatches(item)) {
873
+ pushOption(item, 0);
530
874
  }
531
875
  }
532
876
  if (this.opts.allowCreate && query !== "" && !this.hasExactMatch(query)) {
@@ -535,11 +879,13 @@ var ForgeSelect = class {
535
879
  this.rows.push({ kind: "create", navIndex });
536
880
  }
537
881
  if (this.rows.length === 0) this.rows.push({ kind: "empty" });
882
+ else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
538
883
  }
539
884
  hasExactMatch(lowerQuery) {
885
+ const matchesExactly = (option) => option.label.toLowerCase() === lowerQuery || (option.children ?? []).some(matchesExactly);
540
886
  for (const item of this.data) {
541
887
  const options = isGroup(item) ? item.options : [item];
542
- if (options.some((o) => o.label.toLowerCase() === lowerQuery)) return true;
888
+ if (options.some(matchesExactly)) return true;
543
889
  }
544
890
  return false;
545
891
  }
@@ -549,6 +895,12 @@ var ForgeSelect = class {
549
895
  renderList() {
550
896
  this.buildRows();
551
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;
552
904
  }
553
905
  renderRows() {
554
906
  const scrollTop = this.list.scrollTop;
@@ -593,12 +945,30 @@ var ForgeSelect = class {
593
945
  break;
594
946
  case "empty":
595
947
  li.className = "forge-select__empty";
948
+ li.setAttribute("role", "option");
949
+ li.setAttribute("aria-disabled", "true");
950
+ li.setAttribute("aria-selected", "false");
596
951
  li.textContent = this.strings.noResults;
597
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;
598
960
  case "loading":
599
961
  li.className = "forge-select__loading";
962
+ li.setAttribute("role", "option");
963
+ li.setAttribute("aria-disabled", "true");
964
+ li.setAttribute("aria-selected", "false");
600
965
  li.textContent = this.strings.loading;
601
966
  break;
967
+ case "loading-more":
968
+ li.className = "forge-select__loading-more";
969
+ li.setAttribute("aria-hidden", "true");
970
+ li.textContent = this.strings.loadingMore;
971
+ break;
602
972
  case "create":
603
973
  li.className = "forge-select__option forge-select__option--create";
604
974
  li.setAttribute("role", "option");
@@ -613,6 +983,12 @@ var ForgeSelect = class {
613
983
  const isSelected = this.selected.includes(row.option.value);
614
984
  li.setAttribute("aria-selected", String(isSelected));
615
985
  if (isSelected) li.classList.add("forge-select__option--selected");
986
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
987
+ li.classList.add("forge-select__option--indeterminate");
988
+ }
989
+ if (row.depth > 0) {
990
+ li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
991
+ }
616
992
  if (row.option.disabled) {
617
993
  li.classList.add("forge-select__option--disabled");
618
994
  li.setAttribute("aria-disabled", "true");
@@ -621,6 +997,16 @@ var ForgeSelect = class {
621
997
  li.dataset.navIndex = String(row.navIndex);
622
998
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
623
999
  }
1000
+ if (row.hasChildren) {
1001
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
1002
+ li.setAttribute("aria-expanded", String(expanded));
1003
+ const twisty = document.createElement("span");
1004
+ twisty.className = "forge-select__twisty";
1005
+ twisty.dataset.twisty = row.option.value;
1006
+ twisty.setAttribute("aria-hidden", "true");
1007
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
1008
+ li.append(twisty);
1009
+ }
624
1010
  li.append(this.optionContent(row.option));
625
1011
  break;
626
1012
  }
@@ -638,7 +1024,7 @@ var ForgeSelect = class {
638
1024
  if (!cached) {
639
1025
  const holder = document.createElement("span");
640
1026
  holder.className = "forge-select__option-content";
641
- this.renderTemplate(holder, option, this.opts.templateResult);
1027
+ renderOptionContent(holder, option, this.opts.templateResult);
642
1028
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
643
1029
  const oldest = this.rowContentCache.keys().next().value;
644
1030
  this.rowContentCache.delete(oldest);
@@ -650,7 +1036,10 @@ var ForgeSelect = class {
650
1036
  }
651
1037
  moveHighlight(delta) {
652
1038
  if (this.navItems.length === 0) return;
653
- 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) {
654
1043
  this.highlightedIndex = next;
655
1044
  if (this.usesVirtualScroll()) {
656
1045
  const rowIndex = this.rows.findIndex(
@@ -672,6 +1061,41 @@ var ForgeSelect = class {
672
1061
  highlighted?.scrollIntoView?.({ block: "nearest" });
673
1062
  }
674
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
+ }
675
1099
  updateActiveDescendant() {
676
1100
  const target = this.searchInput ?? this.control;
677
1101
  if (this.highlightedIndex >= 0) {
@@ -683,69 +1107,81 @@ var ForgeSelect = class {
683
1107
  // ---------------------------------------------------------------- remote data
684
1108
  scheduleRemoteLoad(query, delay) {
685
1109
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
1110
+ const requestId = ++this.ajaxRequestId;
1111
+ this.ajaxController?.abort();
1112
+ this.ajaxController = null;
1113
+ this.page = 0;
1114
+ this.hasMore = true;
686
1115
  this.loading = true;
1116
+ this.loadingMore = false;
1117
+ this.loadError = null;
687
1118
  this.renderList();
688
1119
  this.ajaxTimer = setTimeout(() => {
689
- void this.loadRemote(query);
1120
+ this.ajaxTimer = null;
1121
+ void this.loadRemote(query, { requestId });
690
1122
  }, delay);
691
1123
  }
692
- async loadRemote(query) {
1124
+ /**
1125
+ * Fires on every list scroll. Only acts when pagination is opted into via
1126
+ * `ajax.pagination`; reads real scroll geometry rather than row counts so
1127
+ * it works whether or not virtual scrolling is active for this list.
1128
+ */
1129
+ maybeLoadNextPage() {
1130
+ const ajax = this.opts.ajax;
1131
+ if (!ajax?.pagination || !this.hasMore || this.loading || this.loadingMore) return;
1132
+ const { scrollHeight, scrollTop, clientHeight } = this.list;
1133
+ const threshold = this.opts.itemHeight * 2;
1134
+ if (scrollHeight - scrollTop - clientHeight >= threshold) return;
1135
+ this.loadingMore = true;
1136
+ this.renderList();
1137
+ void this.loadRemote(this.query, { append: true });
1138
+ }
1139
+ async loadRemote(query, { append = false, requestId } = {}) {
693
1140
  const ajax = this.opts.ajax;
694
- 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;
1146
+ const page = append ? this.page + 1 : 0;
695
1147
  try {
696
- const url = buildUrl(ajax, query);
697
- const response = await fetch(url);
1148
+ const url = buildUrl(ajax, query, page);
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}`);
698
1151
  const json = await response.json();
699
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
700
- this.data = ajax.transform ? ajax.transform(json) : json;
701
- this.rowContentCache.clear();
1152
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1153
+ const { options, hasMore } = normalizeRemoteResult(ajax, json);
1154
+ if (append) {
1155
+ const existing = collectValues(this.data);
1156
+ this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
1157
+ } else {
1158
+ this.data = options;
1159
+ this.rowContentCache.clear();
1160
+ }
1161
+ this.page = page;
1162
+ this.hasMore = hasMore;
702
1163
  this.remoteLoaded = true;
703
- } catch {
704
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
705
- this.data = [];
706
- this.rowContentCache.clear();
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));
1168
+ if (!append) {
1169
+ this.data = [];
1170
+ this.rowContentCache.clear();
1171
+ }
1172
+ this.hasMore = false;
1173
+ this.loadError = error;
1174
+ this.emitter.emit("error", error);
707
1175
  } finally {
708
- if (requestId === this.ajaxRequestId && !this.destroyed) {
1176
+ if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1177
+ this.ajaxController = null;
709
1178
  this.loading = false;
1179
+ this.loadingMore = false;
710
1180
  if (this.isOpen) this.renderList();
711
1181
  }
712
1182
  }
713
1183
  }
714
1184
  };
715
- function parseNativeOptions(select) {
716
- const data = [];
717
- for (const child of Array.from(select.children)) {
718
- if (child instanceof HTMLOptGroupElement) {
719
- data.push({
720
- label: child.label,
721
- options: Array.from(child.querySelectorAll("option")).map(parseOption)
722
- });
723
- } else if (child instanceof HTMLOptionElement) {
724
- data.push(parseOption(child));
725
- }
726
- }
727
- return data;
728
- }
729
- function parseOption(option) {
730
- return {
731
- value: option.value,
732
- label: option.textContent?.trim() ?? option.value,
733
- disabled: option.disabled || void 0
734
- };
735
- }
736
- function buildUrl(ajax, query) {
737
- if (typeof ajax.url === "function") return ajax.url(query);
738
- if (!ajax.params) return ajax.url;
739
- const params = new URLSearchParams();
740
- for (const [key, value] of Object.entries(ajax.params(query))) {
741
- params.set(key, String(value));
742
- }
743
- const separator = ajax.url.includes("?") ? "&" : "?";
744
- return `${ajax.url}${separator}${params.toString()}`;
745
- }
746
- function arraysEqual(a, b) {
747
- return a.length === b.length && a.every((value, index) => value === b[index]);
748
- }
749
1185
  // Annotate the CommonJS export names for ESM import in node:
750
1186
  0 && (module.exports = {
751
1187
  ForgeSelect