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.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
|
-
|
|
115
|
-
|
|
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.
|
|
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
|
|
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
|
|
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)
|
|
320
|
-
|
|
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.
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
713
|
+
renderOptionContent(span, option, this.opts.templateSelection, "inline");
|
|
437
714
|
this.valueEl.append(span);
|
|
438
715
|
}
|
|
439
716
|
}
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
const
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1093
|
+
this.ajaxTimer = null;
|
|
1094
|
+
void this.loadRemote(query, { requestId });
|
|
663
1095
|
}, delay);
|
|
664
1096
|
}
|
|
665
|
-
|
|
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
|
|
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 (
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
this.
|
|
679
|
-
|
|
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 (
|
|
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
|