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.js CHANGED
@@ -29,18 +29,24 @@ var locales = {
29
29
  en: {
30
30
  noResults: "No results found",
31
31
  loading: "Loading\u2026",
32
+ loadingMore: "Loading more\u2026",
33
+ errorLoading: "Could not load options",
32
34
  createOption: 'Create "{query}"',
33
35
  clearSelection: "Clear selection",
34
36
  removeItem: "Remove {label}",
35
- search: "Search"
37
+ search: "Search",
38
+ reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
36
39
  },
37
40
  vi: {
38
41
  noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
39
42
  loading: "\u0110ang t\u1EA3i\u2026",
43
+ loadingMore: "\u0110ang t\u1EA3i th\xEAm\u2026",
44
+ errorLoading: "Kh\xF4ng th\u1EC3 t\u1EA3i t\xF9y ch\u1ECDn",
40
45
  createOption: 'T\u1EA1o "{query}"',
41
46
  clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
42
47
  removeItem: "X\xF3a {label}",
43
- 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."
44
50
  }
45
51
  };
46
52
  function getStrings(language) {
@@ -53,19 +59,158 @@ function format(template, vars) {
53
59
  return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
54
60
  }
55
61
 
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
144
+ function isGroup(item) {
145
+ return item.options !== void 0;
146
+ }
147
+ function collectDescendantValues(option) {
148
+ if (!option.children) return [];
149
+ const values = [];
150
+ for (const child of option.children) {
151
+ if (!child.disabled) values.push(child.value);
152
+ values.push(...collectDescendantValues(child));
153
+ }
154
+ return values;
155
+ }
156
+ function computeCheckState(option, selected) {
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";
162
+ return "some";
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
+
56
203
  // src/ForgeSelect.ts
57
204
  var DEFAULT_ITEM_HEIGHT = 36;
58
205
  var VIRTUAL_BUFFER = 5;
59
206
  var VIRTUAL_THRESHOLD = 100;
60
207
  var ROW_CACHE_LIMIT = 2e3;
61
208
  var uidCounter = 0;
62
- function isGroup(item) {
63
- return item.options !== void 0;
64
- }
65
209
  var ForgeSelect = class {
66
210
  constructor(target, options = {}) {
67
211
  this.selected = [];
68
212
  this.selectedOptions = /* @__PURE__ */ new Map();
213
+ this.suppressNextTagClick = false;
69
214
  this.emitter = new Emitter();
70
215
  this.uid = `forge-select-${++uidCounter}`;
71
216
  this.searchInput = null;
@@ -77,27 +222,53 @@ var ForgeSelect = class {
77
222
  this.navItems = [];
78
223
  this.highlightedIndex = -1;
79
224
  this.rowContentCache = /* @__PURE__ */ new Map();
225
+ this.expandedValues = /* @__PURE__ */ new Set();
80
226
  this.loading = false;
227
+ this.loadingMore = false;
228
+ this.page = 0;
229
+ this.hasMore = true;
81
230
  this.ajaxTimer = null;
82
231
  this.ajaxRequestId = 0;
232
+ this.ajaxController = null;
83
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;
84
240
  this.onDocumentMouseDown = (event) => {
85
241
  if (!this.root.contains(event.target)) this.close();
86
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
+ };
87
253
  const el = typeof target === "string" ? document.querySelector(target) : target;
88
254
  if (!el) {
89
255
  throw new Error(`ForgeSelect: target element not found: ${String(target)}`);
90
256
  }
91
257
  this.el = el;
92
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;
93
263
  this.opts = {
94
264
  placeholder: options.placeholder ?? "",
95
265
  searchable: options.searchable ?? true,
96
266
  multiple: options.multiple ?? nativeSelect?.multiple ?? false,
97
267
  clearable: options.clearable ?? false,
98
268
  allowCreate: options.allowCreate ?? false,
269
+ sortable: options.sortable ?? false,
99
270
  theme: options.theme ?? "default",
100
- disabled: options.disabled ?? false,
271
+ disabled: options.disabled ?? nativeSelect?.disabled ?? false,
101
272
  data: options.data,
102
273
  ajax: options.ajax,
103
274
  templateResult: options.templateResult,
@@ -111,15 +282,26 @@ var ForgeSelect = class {
111
282
  this.plugins = this.opts.plugins;
112
283
  this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
113
284
  if (nativeSelect && !this.opts.data) {
114
- for (const option of Array.from(nativeSelect.querySelectorAll("option"))) {
115
- 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);
116
289
  }
117
290
  }
118
291
  this.buildDom();
119
292
  this.renderValue();
120
293
  if (this.opts.disabled) this.disable();
294
+ nativeSelect?.addEventListener("change", this.onNativeChange);
295
+ this.nativeForm?.addEventListener("reset", this.onFormReset);
121
296
  for (const plugin of this.plugins) plugin.onInit?.(this);
122
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
+ }
123
305
  // ---------------------------------------------------------------- public API
124
306
  open() {
125
307
  if (this.isOpen || this.isDisabled || this.destroyed) return;
@@ -157,28 +339,33 @@ var ForgeSelect = class {
157
339
  for (const plugin of this.plugins) plugin.onDestroy?.(this);
158
340
  this.destroyed = true;
159
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);
160
345
  this.rowContentCache.clear();
161
346
  this.root.remove();
162
- this.el.style.display = "";
347
+ this.el.style.display = this.originalDisplay;
348
+ if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
163
349
  this.emitter.clear();
164
350
  }
165
351
  getValue() {
166
352
  if (this.opts.multiple) return [...this.selected];
167
353
  return this.selected[0] ?? null;
168
354
  }
169
- setValue(value) {
355
+ setValue(value, options = {}) {
170
356
  const values = value == null ? [] : Array.isArray(value) ? value : [value];
171
357
  const next = this.opts.multiple ? values : values.slice(0, 1);
172
358
  if (arraysEqual(next, this.selected)) return;
173
359
  this.selected = [];
174
360
  for (const v of next) this.selectValue(v, false);
175
- this.afterSelectionChange();
361
+ this.afterSelectionChange(options.emitChange ?? true);
176
362
  }
177
363
  enable() {
178
364
  this.isDisabled = false;
179
365
  this.root.classList.remove("forge-select--disabled");
180
366
  this.control.tabIndex = 0;
181
367
  this.control.setAttribute("aria-disabled", "false");
368
+ if (this.nativeSelect) this.nativeSelect.disabled = false;
182
369
  }
183
370
  disable() {
184
371
  this.close();
@@ -186,6 +373,7 @@ var ForgeSelect = class {
186
373
  this.root.classList.add("forge-select--disabled");
187
374
  this.control.tabIndex = -1;
188
375
  this.control.setAttribute("aria-disabled", "true");
376
+ if (this.nativeSelect) this.nativeSelect.disabled = true;
189
377
  }
190
378
  on(event, handler) {
191
379
  this.emitter.on(event, handler);
@@ -194,11 +382,35 @@ var ForgeSelect = class {
194
382
  this.emitter.off(event, handler);
195
383
  }
196
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
+ }
197
408
  buildDom() {
198
409
  this.root = document.createElement("div");
199
410
  this.root.className = "forge-select";
200
411
  this.root.dataset.theme = this.opts.theme;
201
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");
202
414
  this.control = document.createElement("div");
203
415
  this.control.className = "forge-select__control";
204
416
  this.control.setAttribute("role", "combobox");
@@ -206,6 +418,7 @@ var ForgeSelect = class {
206
418
  this.control.setAttribute("aria-expanded", "false");
207
419
  this.control.setAttribute("aria-controls", `${this.uid}-list`);
208
420
  this.control.tabIndex = 0;
421
+ this.applyAccessibleName();
209
422
  this.valueEl = document.createElement("div");
210
423
  this.valueEl.className = "forge-select__value";
211
424
  this.clearBtn = document.createElement("button");
@@ -236,7 +449,11 @@ var ForgeSelect = class {
236
449
  this.list.setAttribute("role", "listbox");
237
450
  if (this.opts.multiple) this.list.setAttribute("aria-multiselectable", "true");
238
451
  this.dropdown.append(this.list);
239
- 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);
240
457
  this.el.style.display = "none";
241
458
  this.el.insertAdjacentElement("afterend", this.root);
242
459
  this.bindEvents();
@@ -244,8 +461,13 @@ var ForgeSelect = class {
244
461
  bindEvents() {
245
462
  this.control.addEventListener("click", (event) => {
246
463
  if (event.target === this.clearBtn) return;
464
+ if (this.suppressNextTagClick) {
465
+ this.suppressNextTagClick = false;
466
+ return;
467
+ }
247
468
  if (this.isDisabled) return;
248
- this.isOpen ? this.close() : this.open();
469
+ if (this.isOpen) this.close();
470
+ else this.open();
249
471
  });
250
472
  this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
251
473
  this.clearBtn.addEventListener("click", (event) => {
@@ -267,13 +489,23 @@ var ForgeSelect = class {
267
489
  this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
268
490
  }
269
491
  this.list.addEventListener("click", (event) => {
270
- const li = event.target.closest("li[data-nav-index]");
492
+ const target = event.target;
493
+ const twisty = target.closest("[data-twisty]");
494
+ if (twisty) {
495
+ const value = twisty.dataset.twisty;
496
+ if (this.expandedValues.has(value)) this.expandedValues.delete(value);
497
+ else this.expandedValues.add(value);
498
+ this.renderList();
499
+ return;
500
+ }
501
+ const li = target.closest("li[data-nav-index]");
271
502
  if (!li) return;
272
503
  const navIndex = Number(li.dataset.navIndex);
273
504
  this.activateNavItem(navIndex);
274
505
  });
275
506
  this.list.addEventListener("scroll", () => {
276
507
  if (this.usesVirtualScroll()) this.renderRows();
508
+ this.maybeLoadNextPage();
277
509
  });
278
510
  }
279
511
  handleKeydown(event) {
@@ -306,6 +538,12 @@ var ForgeSelect = class {
306
538
  this.control.focus();
307
539
  }
308
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;
309
547
  case "Tab":
310
548
  this.close();
311
549
  break;
@@ -316,29 +554,55 @@ var ForgeSelect = class {
316
554
  if (this.selected.includes(value)) return;
317
555
  const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
318
556
  this.selectedOptions.set(value, option);
319
- if (this.opts.multiple) this.selected.push(value);
320
- else this.selected = [value];
557
+ if (this.opts.multiple) {
558
+ this.selected.push(value);
559
+ for (const v of collectDescendantValues(option)) {
560
+ if (!this.selected.includes(v)) this.selected.push(v);
561
+ }
562
+ this.syncTreeAncestors();
563
+ } else {
564
+ this.selected = [value];
565
+ }
321
566
  if (notify) this.afterSelectionChange();
322
567
  }
323
568
  deselectValue(value, notify) {
324
569
  const index = this.selected.indexOf(value);
325
570
  if (index === -1) return;
326
571
  this.selected.splice(index, 1);
572
+ if (this.opts.multiple) {
573
+ const option = this.findOption(value) ?? this.selectedOptions.get(value);
574
+ if (option) {
575
+ for (const v of collectDescendantValues(option)) {
576
+ const i = this.selected.indexOf(v);
577
+ if (i !== -1) this.selected.splice(i, 1);
578
+ }
579
+ }
580
+ this.syncTreeAncestors();
581
+ }
327
582
  if (notify) this.afterSelectionChange();
328
583
  }
584
+ /**
585
+ * Keeps every tree parent's own membership in `selected` consistent with
586
+ * its descendants (post-order, so parents see already-corrected children):
587
+ * a parent counts as selected only when `computeCheckState` says "all".
588
+ * No-op for data with no `children` anywhere.
589
+ */
590
+ syncTreeAncestors() {
591
+ syncTreeAncestors(this.data, this.selected);
592
+ }
329
593
  clearSelection() {
330
594
  if (this.selected.length === 0) return;
331
595
  this.selected = [];
332
596
  this.emitter.emit("clear");
333
597
  this.afterSelectionChange();
334
598
  }
335
- afterSelectionChange() {
599
+ afterSelectionChange(emitChange = true) {
336
600
  this.renderValue();
337
- this.syncNativeSelect();
601
+ this.syncNativeSelect(emitChange);
338
602
  if (this.isOpen) this.renderList();
339
- this.emitter.emit("change", this.getValue());
603
+ if (emitChange) this.emitter.emit("change", this.getValue());
340
604
  }
341
- syncNativeSelect() {
605
+ syncNativeSelect(dispatchChange = true) {
342
606
  if (!(this.el instanceof HTMLSelectElement)) return;
343
607
  const existing = /* @__PURE__ */ new Set();
344
608
  for (const option of Array.from(this.el.options)) {
@@ -353,18 +617,22 @@ var ForgeSelect = class {
353
617
  option.selected = true;
354
618
  this.el.append(option);
355
619
  }
356
- this.el.dispatchEvent(new Event("change", { bubbles: true }));
357
- }
358
- findOption(value) {
359
- for (const item of this.data) {
360
- if (isGroup(item)) {
361
- const found = item.options.find((o) => o.value === value);
362
- if (found) return found;
363
- } else if (item.value === value) {
364
- return item;
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);
365
624
  }
366
625
  }
367
- 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);
368
636
  }
369
637
  createFromQuery() {
370
638
  const label = this.query.trim();
@@ -387,7 +655,8 @@ var ForgeSelect = class {
387
655
  }
388
656
  const { value } = item.option;
389
657
  if (this.opts.multiple) {
390
- 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);
391
660
  } else {
392
661
  this.selectValue(value, true);
393
662
  this.close();
@@ -413,7 +682,7 @@ var ForgeSelect = class {
413
682
  tag.className = "forge-select__tag";
414
683
  const label = document.createElement("span");
415
684
  label.className = "forge-select__tag-label";
416
- this.renderTemplate(label, option, this.opts.templateSelection, "inline");
685
+ renderOptionContent(label, option, this.opts.templateSelection, "inline");
417
686
  const remove = document.createElement("button");
418
687
  remove.type = "button";
419
688
  remove.className = "forge-select__tag-remove";
@@ -424,6 +693,14 @@ var ForgeSelect = class {
424
693
  if (!this.isDisabled) this.deselectValue(value, true);
425
694
  });
426
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
+ }
427
704
  this.valueEl.append(tag);
428
705
  }
429
706
  } else {
@@ -433,46 +710,99 @@ var ForgeSelect = class {
433
710
  };
434
711
  const span = document.createElement("span");
435
712
  span.className = "forge-select__single-value";
436
- this.renderTemplate(span, option, this.opts.templateSelection, "inline");
713
+ renderOptionContent(span, option, this.opts.templateSelection, "inline");
437
714
  this.valueEl.append(span);
438
715
  }
439
716
  }
440
- renderTemplate(container, option, template, variant = "row") {
441
- if (template) {
442
- const result = template(option);
443
- if (typeof result === "string") container.innerHTML = result;
444
- else container.append(result);
445
- return;
446
- }
447
- if (!option.avatar && !option.description) {
448
- container.textContent = option.label;
449
- return;
450
- }
451
- if (option.avatar) {
452
- const avatar = document.createElement("img");
453
- avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
454
- avatar.src = option.avatar;
455
- avatar.alt = "";
456
- avatar.setAttribute("loading", "lazy");
457
- avatar.setAttribute("decoding", "async");
458
- container.append(avatar);
459
- }
460
- if (variant === "row" && option.description) {
461
- const body = document.createElement("span");
462
- body.className = "forge-select__option-body";
463
- const label = document.createElement("span");
464
- label.className = "forge-select__option-label";
465
- label.textContent = option.label;
466
- const desc = document.createElement("span");
467
- desc.className = "forge-select__option-desc";
468
- desc.textContent = option.description;
469
- body.append(label, desc);
470
- container.append(body);
471
- } else {
472
- const label = document.createElement("span");
473
- label.className = "forge-select__option-label";
474
- label.textContent = option.label;
475
- 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
+ }
476
806
  }
477
807
  }
478
808
  buildRows() {
@@ -480,26 +810,40 @@ var ForgeSelect = class {
480
810
  this.navItems = [];
481
811
  const query = this.query.trim().toLowerCase();
482
812
  const matches = (option) => query === "" || option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false);
483
- const pushOption = (option) => {
813
+ const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
814
+ const pushOption = (option, depth, parentValue) => {
484
815
  let navIndex = -1;
485
816
  if (!option.disabled) {
486
817
  navIndex = this.navItems.length;
487
- this.navItems.push({ kind: "option", option });
818
+ this.navItems.push({ kind: "option", option, parentValue });
819
+ }
820
+ const hasChildren = !!option.children && option.children.length > 0;
821
+ this.rows.push({ kind: "option", option, navIndex, depth, hasChildren });
822
+ if (hasChildren) {
823
+ const expanded = query !== "" || this.expandedValues.has(option.value);
824
+ if (expanded) {
825
+ for (const child of option.children) {
826
+ if (subtreeMatches(child)) pushOption(child, depth + 1, option.value);
827
+ }
828
+ }
488
829
  }
489
- this.rows.push({ kind: "option", option, navIndex });
490
830
  };
491
831
  if (this.loading) {
492
832
  this.rows.push({ kind: "loading" });
493
833
  return;
494
834
  }
835
+ if (this.loadError) {
836
+ this.rows.push({ kind: "error" });
837
+ return;
838
+ }
495
839
  for (const item of this.data) {
496
840
  if (isGroup(item)) {
497
- const visible = item.options.filter(matches);
841
+ const visible = item.options.filter(subtreeMatches);
498
842
  if (visible.length === 0) continue;
499
843
  this.rows.push({ kind: "group", label: item.label });
500
- visible.forEach(pushOption);
501
- } else if (matches(item)) {
502
- pushOption(item);
844
+ visible.forEach((o) => pushOption(o, 0));
845
+ } else if (subtreeMatches(item)) {
846
+ pushOption(item, 0);
503
847
  }
504
848
  }
505
849
  if (this.opts.allowCreate && query !== "" && !this.hasExactMatch(query)) {
@@ -508,11 +852,13 @@ var ForgeSelect = class {
508
852
  this.rows.push({ kind: "create", navIndex });
509
853
  }
510
854
  if (this.rows.length === 0) this.rows.push({ kind: "empty" });
855
+ else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
511
856
  }
512
857
  hasExactMatch(lowerQuery) {
858
+ const matchesExactly = (option) => option.label.toLowerCase() === lowerQuery || (option.children ?? []).some(matchesExactly);
513
859
  for (const item of this.data) {
514
860
  const options = isGroup(item) ? item.options : [item];
515
- if (options.some((o) => o.label.toLowerCase() === lowerQuery)) return true;
861
+ if (options.some(matchesExactly)) return true;
516
862
  }
517
863
  return false;
518
864
  }
@@ -522,6 +868,12 @@ var ForgeSelect = class {
522
868
  renderList() {
523
869
  this.buildRows();
524
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;
525
877
  }
526
878
  renderRows() {
527
879
  const scrollTop = this.list.scrollTop;
@@ -566,12 +918,30 @@ var ForgeSelect = class {
566
918
  break;
567
919
  case "empty":
568
920
  li.className = "forge-select__empty";
921
+ li.setAttribute("role", "option");
922
+ li.setAttribute("aria-disabled", "true");
923
+ li.setAttribute("aria-selected", "false");
569
924
  li.textContent = this.strings.noResults;
570
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;
571
933
  case "loading":
572
934
  li.className = "forge-select__loading";
935
+ li.setAttribute("role", "option");
936
+ li.setAttribute("aria-disabled", "true");
937
+ li.setAttribute("aria-selected", "false");
573
938
  li.textContent = this.strings.loading;
574
939
  break;
940
+ case "loading-more":
941
+ li.className = "forge-select__loading-more";
942
+ li.setAttribute("aria-hidden", "true");
943
+ li.textContent = this.strings.loadingMore;
944
+ break;
575
945
  case "create":
576
946
  li.className = "forge-select__option forge-select__option--create";
577
947
  li.setAttribute("role", "option");
@@ -586,6 +956,12 @@ var ForgeSelect = class {
586
956
  const isSelected = this.selected.includes(row.option.value);
587
957
  li.setAttribute("aria-selected", String(isSelected));
588
958
  if (isSelected) li.classList.add("forge-select__option--selected");
959
+ if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
960
+ li.classList.add("forge-select__option--indeterminate");
961
+ }
962
+ if (row.depth > 0) {
963
+ li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
964
+ }
589
965
  if (row.option.disabled) {
590
966
  li.classList.add("forge-select__option--disabled");
591
967
  li.setAttribute("aria-disabled", "true");
@@ -594,6 +970,16 @@ var ForgeSelect = class {
594
970
  li.dataset.navIndex = String(row.navIndex);
595
971
  if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
596
972
  }
973
+ if (row.hasChildren) {
974
+ const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
975
+ li.setAttribute("aria-expanded", String(expanded));
976
+ const twisty = document.createElement("span");
977
+ twisty.className = "forge-select__twisty";
978
+ twisty.dataset.twisty = row.option.value;
979
+ twisty.setAttribute("aria-hidden", "true");
980
+ twisty.textContent = expanded ? "\u25BC" : "\u25B6";
981
+ li.append(twisty);
982
+ }
597
983
  li.append(this.optionContent(row.option));
598
984
  break;
599
985
  }
@@ -611,7 +997,7 @@ var ForgeSelect = class {
611
997
  if (!cached) {
612
998
  const holder = document.createElement("span");
613
999
  holder.className = "forge-select__option-content";
614
- this.renderTemplate(holder, option, this.opts.templateResult);
1000
+ renderOptionContent(holder, option, this.opts.templateResult);
615
1001
  if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
616
1002
  const oldest = this.rowContentCache.keys().next().value;
617
1003
  this.rowContentCache.delete(oldest);
@@ -623,7 +1009,10 @@ var ForgeSelect = class {
623
1009
  }
624
1010
  moveHighlight(delta) {
625
1011
  if (this.navItems.length === 0) return;
626
- 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) {
627
1016
  this.highlightedIndex = next;
628
1017
  if (this.usesVirtualScroll()) {
629
1018
  const rowIndex = this.rows.findIndex(
@@ -645,6 +1034,41 @@ var ForgeSelect = class {
645
1034
  highlighted?.scrollIntoView?.({ block: "nearest" });
646
1035
  }
647
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
+ }
648
1072
  updateActiveDescendant() {
649
1073
  const target = this.searchInput ?? this.control;
650
1074
  if (this.highlightedIndex >= 0) {
@@ -656,69 +1080,81 @@ var ForgeSelect = class {
656
1080
  // ---------------------------------------------------------------- remote data
657
1081
  scheduleRemoteLoad(query, delay) {
658
1082
  if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
1083
+ const requestId = ++this.ajaxRequestId;
1084
+ this.ajaxController?.abort();
1085
+ this.ajaxController = null;
1086
+ this.page = 0;
1087
+ this.hasMore = true;
659
1088
  this.loading = true;
1089
+ this.loadingMore = false;
1090
+ this.loadError = null;
660
1091
  this.renderList();
661
1092
  this.ajaxTimer = setTimeout(() => {
662
- void this.loadRemote(query);
1093
+ this.ajaxTimer = null;
1094
+ void this.loadRemote(query, { requestId });
663
1095
  }, delay);
664
1096
  }
665
- async loadRemote(query) {
1097
+ /**
1098
+ * Fires on every list scroll. Only acts when pagination is opted into via
1099
+ * `ajax.pagination`; reads real scroll geometry rather than row counts so
1100
+ * it works whether or not virtual scrolling is active for this list.
1101
+ */
1102
+ maybeLoadNextPage() {
1103
+ const ajax = this.opts.ajax;
1104
+ if (!ajax?.pagination || !this.hasMore || this.loading || this.loadingMore) return;
1105
+ const { scrollHeight, scrollTop, clientHeight } = this.list;
1106
+ const threshold = this.opts.itemHeight * 2;
1107
+ if (scrollHeight - scrollTop - clientHeight >= threshold) return;
1108
+ this.loadingMore = true;
1109
+ this.renderList();
1110
+ void this.loadRemote(this.query, { append: true });
1111
+ }
1112
+ async loadRemote(query, { append = false, requestId } = {}) {
666
1113
  const ajax = this.opts.ajax;
667
- 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;
1119
+ const page = append ? this.page + 1 : 0;
668
1120
  try {
669
- const url = buildUrl(ajax, query);
670
- const response = await fetch(url);
1121
+ const url = buildUrl(ajax, query, page);
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}`);
671
1124
  const json = await response.json();
672
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
673
- this.data = ajax.transform ? ajax.transform(json) : json;
674
- this.rowContentCache.clear();
1125
+ if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
1126
+ const { options, hasMore } = normalizeRemoteResult(ajax, json);
1127
+ if (append) {
1128
+ const existing = collectValues(this.data);
1129
+ this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
1130
+ } else {
1131
+ this.data = options;
1132
+ this.rowContentCache.clear();
1133
+ }
1134
+ this.page = page;
1135
+ this.hasMore = hasMore;
675
1136
  this.remoteLoaded = true;
676
- } catch {
677
- if (requestId !== this.ajaxRequestId || this.destroyed) return;
678
- this.data = [];
679
- this.rowContentCache.clear();
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));
1141
+ if (!append) {
1142
+ this.data = [];
1143
+ this.rowContentCache.clear();
1144
+ }
1145
+ this.hasMore = false;
1146
+ this.loadError = error;
1147
+ this.emitter.emit("error", error);
680
1148
  } finally {
681
- if (requestId === this.ajaxRequestId && !this.destroyed) {
1149
+ if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
1150
+ this.ajaxController = null;
682
1151
  this.loading = false;
1152
+ this.loadingMore = false;
683
1153
  if (this.isOpen) this.renderList();
684
1154
  }
685
1155
  }
686
1156
  }
687
1157
  };
688
- function parseNativeOptions(select) {
689
- const data = [];
690
- for (const child of Array.from(select.children)) {
691
- if (child instanceof HTMLOptGroupElement) {
692
- data.push({
693
- label: child.label,
694
- options: Array.from(child.querySelectorAll("option")).map(parseOption)
695
- });
696
- } else if (child instanceof HTMLOptionElement) {
697
- data.push(parseOption(child));
698
- }
699
- }
700
- return data;
701
- }
702
- function parseOption(option) {
703
- return {
704
- value: option.value,
705
- label: option.textContent?.trim() ?? option.value,
706
- disabled: option.disabled || void 0
707
- };
708
- }
709
- function buildUrl(ajax, query) {
710
- if (typeof ajax.url === "function") return ajax.url(query);
711
- if (!ajax.params) return ajax.url;
712
- const params = new URLSearchParams();
713
- for (const [key, value] of Object.entries(ajax.params(query))) {
714
- params.set(key, String(value));
715
- }
716
- const separator = ajax.url.includes("?") ? "&" : "?";
717
- return `${ajax.url}${separator}${params.toString()}`;
718
- }
719
- function arraysEqual(a, b) {
720
- return a.length === b.length && a.every((value, index) => value === b[index]);
721
- }
722
1158
  export {
723
1159
  ForgeSelect,
724
1160
  ForgeSelect as default