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/README.md +62 -46
- package/dist/index.cjs +562 -126
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +90 -7
- package/dist/index.d.ts +90 -7
- package/dist/index.global.js +1 -750
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +562 -126
- package/dist/index.js.map +1 -1
- package/package.json +34 -6
- package/styles/forge-select.css +42 -2
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
|
-
|
|
142
|
-
|
|
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.
|
|
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
|
|
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
|
|
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)
|
|
347
|
-
|
|
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.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
740
|
+
renderOptionContent(span, option, this.opts.templateSelection, "inline");
|
|
464
741
|
this.valueEl.append(span);
|
|
465
742
|
}
|
|
466
743
|
}
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
const
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1120
|
+
this.ajaxTimer = null;
|
|
1121
|
+
void this.loadRemote(query, { requestId });
|
|
690
1122
|
}, delay);
|
|
691
1123
|
}
|
|
692
|
-
|
|
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
|
|
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 (
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
this.
|
|
706
|
-
|
|
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 (
|
|
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
|