forge-select 0.2.0 → 0.4.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 -40
- package/dist/index.cjs +754 -174
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +175 -9
- package/dist/index.d.ts +175 -9
- package/dist/index.global.js +1 -900
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +754 -174
- package/dist/index.js.map +1 -1
- package/package.json +32 -7
- package/styles/forge-select.css +50 -2
package/dist/index.js
CHANGED
|
@@ -24,25 +24,44 @@ var Emitter = class {
|
|
|
24
24
|
}
|
|
25
25
|
};
|
|
26
26
|
|
|
27
|
+
// src/dropdown-position.ts
|
|
28
|
+
function computeDropdownPlacement(controlRect, dropdownHeight, viewportHeight, gap = 4) {
|
|
29
|
+
const spaceBelow = viewportHeight - controlRect.bottom;
|
|
30
|
+
const spaceAbove = controlRect.top;
|
|
31
|
+
const dropUp = dropdownHeight > spaceBelow && spaceAbove > spaceBelow;
|
|
32
|
+
return {
|
|
33
|
+
dropUp,
|
|
34
|
+
top: dropUp ? controlRect.top - dropdownHeight - gap : controlRect.bottom + gap
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
27
38
|
// src/i18n.ts
|
|
28
39
|
var locales = {
|
|
29
40
|
en: {
|
|
30
41
|
noResults: "No results found",
|
|
31
42
|
loading: "Loading\u2026",
|
|
32
43
|
loadingMore: "Loading more\u2026",
|
|
44
|
+
errorLoading: "Could not load options",
|
|
33
45
|
createOption: 'Create "{query}"',
|
|
34
46
|
clearSelection: "Clear selection",
|
|
35
47
|
removeItem: "Remove {label}",
|
|
36
|
-
search: "Search"
|
|
48
|
+
search: "Search",
|
|
49
|
+
reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder.",
|
|
50
|
+
minSearchLength: "Type {count} or more characters to search",
|
|
51
|
+
maximumSelected: "Maximum of {count} selections reached"
|
|
37
52
|
},
|
|
38
53
|
vi: {
|
|
39
54
|
noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
|
|
40
55
|
loading: "\u0110ang t\u1EA3i\u2026",
|
|
41
56
|
loadingMore: "\u0110ang t\u1EA3i th\xEAm\u2026",
|
|
57
|
+
errorLoading: "Kh\xF4ng th\u1EC3 t\u1EA3i t\xF9y ch\u1ECDn",
|
|
42
58
|
createOption: 'T\u1EA1o "{query}"',
|
|
43
59
|
clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
|
|
44
60
|
removeItem: "X\xF3a {label}",
|
|
45
|
-
search: "T\xECm ki\u1EBFm"
|
|
61
|
+
search: "T\xECm ki\u1EBFm",
|
|
62
|
+
reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i.",
|
|
63
|
+
minSearchLength: "Nh\u1EADp th\xEAm {count} k\xFD t\u1EF1 \u0111\u1EC3 t\xECm ki\u1EBFm",
|
|
64
|
+
maximumSelected: "\u0110\xE3 \u0111\u1EA1t t\u1ED1i \u0111a {count} l\u1EF1a ch\u1ECDn"
|
|
46
65
|
}
|
|
47
66
|
};
|
|
48
67
|
function getStrings(language) {
|
|
@@ -55,39 +74,164 @@ function format(template, vars) {
|
|
|
55
74
|
return template.replace(/\{(\w+)\}/g, (match, key) => vars[key] ?? match);
|
|
56
75
|
}
|
|
57
76
|
|
|
58
|
-
// src/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
77
|
+
// src/native-select.ts
|
|
78
|
+
function parseNativeOptions(select) {
|
|
79
|
+
const data = [];
|
|
80
|
+
for (const child of Array.from(select.children)) {
|
|
81
|
+
if (child instanceof HTMLOptGroupElement) {
|
|
82
|
+
data.push({ label: child.label, options: Array.from(child.querySelectorAll("option")).map(parseOption) });
|
|
83
|
+
} else if (child instanceof HTMLOptionElement) {
|
|
84
|
+
data.push(parseOption(child));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return data;
|
|
88
|
+
}
|
|
89
|
+
function parseOption(option) {
|
|
90
|
+
const groupDisabled = option.parentElement instanceof HTMLOptGroupElement && option.parentElement.disabled;
|
|
91
|
+
return {
|
|
92
|
+
value: option.value,
|
|
93
|
+
label: option.textContent?.trim() ?? option.value,
|
|
94
|
+
disabled: option.disabled || groupDisabled || void 0
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/option-renderer.ts
|
|
99
|
+
function renderOptionContent(container, option, template, variant = "row") {
|
|
100
|
+
if (template) {
|
|
101
|
+
const result = template(option);
|
|
102
|
+
if (typeof result === "string") container.innerHTML = result;
|
|
103
|
+
else container.append(result);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (!option.avatar && !option.description) {
|
|
107
|
+
container.textContent = option.label;
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (option.avatar) {
|
|
111
|
+
const avatar = document.createElement("img");
|
|
112
|
+
avatar.className = variant === "row" ? "forge-select__option-avatar" : "forge-select__inline-avatar";
|
|
113
|
+
avatar.src = option.avatar;
|
|
114
|
+
avatar.alt = "";
|
|
115
|
+
avatar.setAttribute("loading", "lazy");
|
|
116
|
+
avatar.setAttribute("decoding", "async");
|
|
117
|
+
container.append(avatar);
|
|
118
|
+
}
|
|
119
|
+
if (variant === "row" && option.description) {
|
|
120
|
+
const body = document.createElement("span");
|
|
121
|
+
body.className = "forge-select__option-body";
|
|
122
|
+
const label = document.createElement("span");
|
|
123
|
+
label.className = "forge-select__option-label";
|
|
124
|
+
label.textContent = option.label;
|
|
125
|
+
const description = document.createElement("span");
|
|
126
|
+
description.className = "forge-select__option-desc";
|
|
127
|
+
description.textContent = option.description;
|
|
128
|
+
body.append(label, description);
|
|
129
|
+
container.append(body);
|
|
130
|
+
} else {
|
|
131
|
+
const label = document.createElement("span");
|
|
132
|
+
label.className = "forge-select__option-label";
|
|
133
|
+
label.textContent = option.label;
|
|
134
|
+
container.append(label);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/remote.ts
|
|
139
|
+
function buildUrl(ajax, query, page) {
|
|
140
|
+
if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
|
|
141
|
+
if (typeof ajax.url === "function") return ajax.url(query, page);
|
|
142
|
+
if (!ajax.params) return ajax.url;
|
|
143
|
+
const params = new URLSearchParams();
|
|
144
|
+
for (const [key, value] of Object.entries(ajax.params(query, page))) params.set(key, String(value));
|
|
145
|
+
const separator = ajax.url.includes("?") ? "&" : "?";
|
|
146
|
+
return `${ajax.url}${separator}${params.toString()}`;
|
|
147
|
+
}
|
|
148
|
+
function normalizeRemoteResult(ajax, response) {
|
|
149
|
+
const result = ajax.transform ? ajax.transform(response) : response;
|
|
150
|
+
if (Array.isArray(result)) return { options: result, hasMore: false };
|
|
151
|
+
if (!result || !Array.isArray(result.options)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
"ForgeSelect: ajax.transform must return an array of options, or an object shaped like { options: Option[], hasMore?: boolean }."
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// src/selection.ts
|
|
64
160
|
function isGroup(item) {
|
|
65
161
|
return item.options !== void 0;
|
|
66
162
|
}
|
|
67
|
-
|
|
163
|
+
var defaultIsDisabled = (option) => !!option.disabled;
|
|
164
|
+
function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
|
|
68
165
|
if (!option.children) return [];
|
|
69
166
|
const values = [];
|
|
70
167
|
for (const child of option.children) {
|
|
71
|
-
values.push(child.value
|
|
168
|
+
if (!isDisabled(child)) values.push(child.value);
|
|
169
|
+
values.push(...collectDescendantValues(child, isDisabled));
|
|
72
170
|
}
|
|
73
171
|
return values;
|
|
74
172
|
}
|
|
75
|
-
function computeCheckState(option, selected) {
|
|
76
|
-
if (!option.children
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if (states.every((
|
|
81
|
-
if (states.every((s) => s === "none")) return "none";
|
|
173
|
+
function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
|
|
174
|
+
if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
|
|
175
|
+
const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
|
|
176
|
+
if (states.length === 0) return "none";
|
|
177
|
+
if (states.every((state) => state === "all")) return "all";
|
|
178
|
+
if (states.every((state) => state === "none")) return "none";
|
|
82
179
|
return "some";
|
|
83
180
|
}
|
|
181
|
+
function findOption(items, value) {
|
|
182
|
+
const search = (options) => {
|
|
183
|
+
for (const option of options) {
|
|
184
|
+
if (option.value === value) return option;
|
|
185
|
+
const found = option.children ? search(option.children) : void 0;
|
|
186
|
+
if (found) return found;
|
|
187
|
+
}
|
|
188
|
+
return void 0;
|
|
189
|
+
};
|
|
190
|
+
for (const item of items) {
|
|
191
|
+
const found = search(isGroup(item) ? item.options : [item]);
|
|
192
|
+
if (found) return found;
|
|
193
|
+
}
|
|
194
|
+
return void 0;
|
|
195
|
+
}
|
|
196
|
+
function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
|
|
197
|
+
const sync = (option) => {
|
|
198
|
+
if (!option.children?.length) return;
|
|
199
|
+
for (const child of option.children) sync(child);
|
|
200
|
+
const state = computeCheckState(option, selected, isDisabled);
|
|
201
|
+
const index = selected.indexOf(option.value);
|
|
202
|
+
if (state === "all" && index === -1) selected.push(option.value);
|
|
203
|
+
else if (state !== "all" && index !== -1) selected.splice(index, 1);
|
|
204
|
+
};
|
|
205
|
+
for (const item of items) (isGroup(item) ? item.options : [item]).forEach(sync);
|
|
206
|
+
}
|
|
207
|
+
function collectValues(items) {
|
|
208
|
+
const values = /* @__PURE__ */ new Set();
|
|
209
|
+
const visit = (option) => {
|
|
210
|
+
values.add(option.value);
|
|
211
|
+
option.children?.forEach(visit);
|
|
212
|
+
};
|
|
213
|
+
for (const item of items) (isGroup(item) ? item.options : [item]).forEach(visit);
|
|
214
|
+
return values;
|
|
215
|
+
}
|
|
216
|
+
function arraysEqual(a, b) {
|
|
217
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/ForgeSelect.ts
|
|
221
|
+
var DEFAULT_ITEM_HEIGHT = 36;
|
|
222
|
+
var VIRTUAL_BUFFER = 5;
|
|
223
|
+
var VIRTUAL_THRESHOLD = 100;
|
|
224
|
+
var ROW_CACHE_LIMIT = 2e3;
|
|
225
|
+
var uidCounter = 0;
|
|
84
226
|
var ForgeSelect = class {
|
|
85
227
|
constructor(target, options = {}) {
|
|
86
228
|
this.selected = [];
|
|
87
229
|
this.selectedOptions = /* @__PURE__ */ new Map();
|
|
230
|
+
this.suppressNextTagClick = false;
|
|
88
231
|
this.emitter = new Emitter();
|
|
89
232
|
this.uid = `forge-select-${++uidCounter}`;
|
|
90
233
|
this.searchInput = null;
|
|
234
|
+
this.portalHost = null;
|
|
91
235
|
this.isOpen = false;
|
|
92
236
|
this.isDisabled = false;
|
|
93
237
|
this.destroyed = false;
|
|
@@ -103,9 +247,43 @@ var ForgeSelect = class {
|
|
|
103
247
|
this.hasMore = true;
|
|
104
248
|
this.ajaxTimer = null;
|
|
105
249
|
this.ajaxRequestId = 0;
|
|
250
|
+
this.ajaxController = null;
|
|
106
251
|
this.remoteLoaded = false;
|
|
252
|
+
this.loadError = null;
|
|
253
|
+
this.originalDisplay = "";
|
|
254
|
+
this.originalDisabled = false;
|
|
255
|
+
this.nativeSelect = null;
|
|
256
|
+
this.nativeForm = null;
|
|
257
|
+
this.syncingNative = false;
|
|
258
|
+
/** Combines the static `disabled` field with the dynamic `isOptionDisabled` callback. */
|
|
259
|
+
this.isOptionDisabled = (option) => option.disabled === true || (this.opts.isOptionDisabled?.(option) ?? false);
|
|
260
|
+
this.pointerDownOnControl = false;
|
|
107
261
|
this.onDocumentMouseDown = (event) => {
|
|
108
|
-
|
|
262
|
+
const target = event.target;
|
|
263
|
+
if (!this.root.contains(target) && !this.portalHost?.contains(target)) this.close();
|
|
264
|
+
};
|
|
265
|
+
this.onWindowResize = () => {
|
|
266
|
+
this.positionDropdown();
|
|
267
|
+
};
|
|
268
|
+
this.onAncestorScroll = () => {
|
|
269
|
+
if (this.portalHost) this.positionDropdown();
|
|
270
|
+
};
|
|
271
|
+
this.onNativeInvalid = (event) => {
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
this.control.classList.add("forge-select__control--invalid");
|
|
274
|
+
this.control.setAttribute("aria-invalid", "true");
|
|
275
|
+
if (!this.isOpen) this.open();
|
|
276
|
+
this.control.focus();
|
|
277
|
+
};
|
|
278
|
+
this.onNativeChange = () => {
|
|
279
|
+
if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
|
|
280
|
+
const values = Array.from(this.nativeSelect.selectedOptions, (option) => option.value);
|
|
281
|
+
this.applyNativeValues(values);
|
|
282
|
+
};
|
|
283
|
+
this.onFormReset = () => {
|
|
284
|
+
if (!this.nativeSelect || this.destroyed) return;
|
|
285
|
+
const defaults = Array.from(this.nativeSelect.options).filter((option) => option.defaultSelected).map((option) => option.value);
|
|
286
|
+
this.applyNativeValues(defaults);
|
|
109
287
|
};
|
|
110
288
|
const el = typeof target === "string" ? document.querySelector(target) : target;
|
|
111
289
|
if (!el) {
|
|
@@ -113,36 +291,63 @@ var ForgeSelect = class {
|
|
|
113
291
|
}
|
|
114
292
|
this.el = el;
|
|
115
293
|
const nativeSelect = el instanceof HTMLSelectElement ? el : null;
|
|
294
|
+
this.nativeSelect = nativeSelect;
|
|
295
|
+
this.nativeForm = nativeSelect?.form ?? null;
|
|
296
|
+
this.originalDisplay = el.style.display;
|
|
297
|
+
this.originalDisabled = nativeSelect?.disabled ?? false;
|
|
116
298
|
this.opts = {
|
|
117
299
|
placeholder: options.placeholder ?? "",
|
|
118
300
|
searchable: options.searchable ?? true,
|
|
119
301
|
multiple: options.multiple ?? nativeSelect?.multiple ?? false,
|
|
120
302
|
clearable: options.clearable ?? false,
|
|
121
303
|
allowCreate: options.allowCreate ?? false,
|
|
304
|
+
sortable: options.sortable ?? false,
|
|
305
|
+
closeOnSelect: options.closeOnSelect ?? false,
|
|
306
|
+
maxSelections: options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections)),
|
|
122
307
|
theme: options.theme ?? "default",
|
|
123
|
-
disabled: options.disabled ?? false,
|
|
308
|
+
disabled: options.disabled ?? nativeSelect?.disabled ?? false,
|
|
309
|
+
required: options.required ?? nativeSelect?.required ?? false,
|
|
124
310
|
data: options.data,
|
|
125
311
|
ajax: options.ajax,
|
|
126
312
|
templateResult: options.templateResult,
|
|
127
313
|
templateSelection: options.templateSelection,
|
|
314
|
+
filterOption: options.filterOption,
|
|
315
|
+
minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
|
|
316
|
+
minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
|
|
317
|
+
isOptionDisabled: options.isOptionDisabled,
|
|
128
318
|
virtualScroll: options.virtualScroll,
|
|
129
319
|
itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
|
|
130
320
|
language: options.language ?? "en",
|
|
131
|
-
plugins: options.plugins ?? []
|
|
321
|
+
plugins: options.plugins ?? [],
|
|
322
|
+
openOnFocus: options.openOnFocus ?? false,
|
|
323
|
+
dropdownParent: options.dropdownParent
|
|
132
324
|
};
|
|
133
325
|
this.strings = getStrings(this.opts.language);
|
|
134
326
|
this.plugins = this.opts.plugins;
|
|
327
|
+
if (nativeSelect) nativeSelect.required = this.opts.required;
|
|
135
328
|
this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
|
|
136
329
|
if (nativeSelect && !this.opts.data) {
|
|
137
|
-
|
|
138
|
-
|
|
330
|
+
const nativeOptions = Array.from(nativeSelect.options);
|
|
331
|
+
const hasIntentionalSelection = nativeSelect.multiple || nativeSelect.selectedIndex > 0 || nativeOptions.some((option) => option.defaultSelected);
|
|
332
|
+
for (const option of nativeOptions) {
|
|
333
|
+
if (hasIntentionalSelection && option.selected) this.selectValue(option.value, false);
|
|
139
334
|
}
|
|
140
335
|
}
|
|
141
336
|
this.buildDom();
|
|
142
337
|
this.renderValue();
|
|
143
338
|
if (this.opts.disabled) this.disable();
|
|
339
|
+
nativeSelect?.addEventListener("change", this.onNativeChange);
|
|
340
|
+
nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
|
|
341
|
+
this.nativeForm?.addEventListener("reset", this.onFormReset);
|
|
144
342
|
for (const plugin of this.plugins) plugin.onInit?.(this);
|
|
145
343
|
}
|
|
344
|
+
applyNativeValues(values) {
|
|
345
|
+
this.selected = [];
|
|
346
|
+
for (const value of this.opts.multiple ? values : values.slice(0, 1)) this.selectValue(value, false);
|
|
347
|
+
this.renderValue();
|
|
348
|
+
if (this.isOpen) this.renderList();
|
|
349
|
+
this.emitter.emit("change", this.getValue());
|
|
350
|
+
}
|
|
146
351
|
// ---------------------------------------------------------------- public API
|
|
147
352
|
open() {
|
|
148
353
|
if (this.isOpen || this.isDisabled || this.destroyed) return;
|
|
@@ -155,7 +360,10 @@ var ForgeSelect = class {
|
|
|
155
360
|
this.scheduleRemoteLoad(this.query, 0);
|
|
156
361
|
}
|
|
157
362
|
this.renderList();
|
|
158
|
-
|
|
363
|
+
this.positionDropdown();
|
|
364
|
+
window.addEventListener("resize", this.onWindowResize);
|
|
365
|
+
document.addEventListener("scroll", this.onAncestorScroll, true);
|
|
366
|
+
if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
|
|
159
367
|
this.emitter.emit("open");
|
|
160
368
|
for (const plugin of this.plugins) plugin.onOpen?.(this);
|
|
161
369
|
}
|
|
@@ -164,8 +372,11 @@ var ForgeSelect = class {
|
|
|
164
372
|
this.isOpen = false;
|
|
165
373
|
this.dropdown.hidden = true;
|
|
166
374
|
this.root.classList.remove("forge-select--open");
|
|
375
|
+
this.root.classList.remove("forge-select--drop-up");
|
|
167
376
|
this.control.setAttribute("aria-expanded", "false");
|
|
168
377
|
document.removeEventListener("mousedown", this.onDocumentMouseDown);
|
|
378
|
+
window.removeEventListener("resize", this.onWindowResize);
|
|
379
|
+
document.removeEventListener("scroll", this.onAncestorScroll, true);
|
|
169
380
|
this.highlightedIndex = -1;
|
|
170
381
|
if (this.searchInput) {
|
|
171
382
|
this.searchInput.value = "";
|
|
@@ -174,34 +385,104 @@ var ForgeSelect = class {
|
|
|
174
385
|
this.emitter.emit("close");
|
|
175
386
|
for (const plugin of this.plugins) plugin.onClose?.(this);
|
|
176
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Flips the dropdown above the control when there isn't enough room below
|
|
390
|
+
* but there is above. Recomputed on open() and on window resize — the
|
|
391
|
+
* dropdown is positioned absolutely inside the relatively-positioned root,
|
|
392
|
+
* so it already tracks the control correctly on page scroll without
|
|
393
|
+
* needing a scroll listener.
|
|
394
|
+
*/
|
|
395
|
+
positionDropdown() {
|
|
396
|
+
const controlRect = this.control.getBoundingClientRect();
|
|
397
|
+
const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
|
|
398
|
+
this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
|
|
399
|
+
if (this.portalHost) {
|
|
400
|
+
this.portalHost.style.top = `${placement.top}px`;
|
|
401
|
+
this.portalHost.style.left = `${controlRect.left}px`;
|
|
402
|
+
this.portalHost.style.width = `${controlRect.width}px`;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
177
405
|
destroy() {
|
|
178
406
|
if (this.destroyed) return;
|
|
179
407
|
this.close();
|
|
180
408
|
for (const plugin of this.plugins) plugin.onDestroy?.(this);
|
|
181
409
|
this.destroyed = true;
|
|
182
410
|
if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
|
|
411
|
+
this.ajaxController?.abort();
|
|
412
|
+
this.nativeSelect?.removeEventListener("change", this.onNativeChange);
|
|
413
|
+
this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
|
|
414
|
+
this.nativeForm?.removeEventListener("reset", this.onFormReset);
|
|
183
415
|
this.rowContentCache.clear();
|
|
416
|
+
this.portalHost?.remove();
|
|
184
417
|
this.root.remove();
|
|
185
|
-
this.el.style.display =
|
|
418
|
+
this.el.style.display = this.originalDisplay;
|
|
419
|
+
if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
|
|
186
420
|
this.emitter.clear();
|
|
187
421
|
}
|
|
188
422
|
getValue() {
|
|
189
423
|
if (this.opts.multiple) return [...this.selected];
|
|
190
424
|
return this.selected[0] ?? null;
|
|
191
425
|
}
|
|
192
|
-
setValue(value) {
|
|
426
|
+
setValue(value, options = {}) {
|
|
193
427
|
const values = value == null ? [] : Array.isArray(value) ? value : [value];
|
|
194
428
|
const next = this.opts.multiple ? values : values.slice(0, 1);
|
|
195
429
|
if (arraysEqual(next, this.selected)) return;
|
|
196
430
|
this.selected = [];
|
|
197
431
|
for (const v of next) this.selectValue(v, false);
|
|
432
|
+
this.afterSelectionChange(options.emitChange ?? true);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Replaces the option list after construction. An open dropdown re-renders
|
|
436
|
+
* immediately; a selection whose value isn't in the new data stays
|
|
437
|
+
* selected (rendered via the already-selected option's own label/avatar,
|
|
438
|
+
* the same fallback used for values selected from a stale ajax page).
|
|
439
|
+
*/
|
|
440
|
+
setData(data) {
|
|
441
|
+
if (this.ajaxTimer) {
|
|
442
|
+
clearTimeout(this.ajaxTimer);
|
|
443
|
+
this.ajaxTimer = null;
|
|
444
|
+
}
|
|
445
|
+
this.ajaxController?.abort();
|
|
446
|
+
this.ajaxController = null;
|
|
447
|
+
this.ajaxRequestId += 1;
|
|
448
|
+
this.loading = false;
|
|
449
|
+
this.loadingMore = false;
|
|
450
|
+
this.loadError = null;
|
|
451
|
+
this.remoteLoaded = true;
|
|
452
|
+
this.page = 0;
|
|
453
|
+
this.hasMore = false;
|
|
454
|
+
this.data = data;
|
|
455
|
+
this.opts.data = data;
|
|
456
|
+
this.updateSearchVisibility();
|
|
457
|
+
this.rowContentCache.clear();
|
|
458
|
+
this.highlightedIndex = -1;
|
|
459
|
+
if (this.isOpen) this.renderList();
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Multi-select only: selects every currently non-disabled option, including
|
|
463
|
+
* nested tree descendants and options inside groups. If `maxSelections` is
|
|
464
|
+
* set, stops once the cap is reached rather than exceeding it. A no-op for
|
|
465
|
+
* single-select.
|
|
466
|
+
*/
|
|
467
|
+
selectAll() {
|
|
468
|
+
if (!this.opts.multiple) return;
|
|
469
|
+
this.selected = [];
|
|
470
|
+
for (const value of this.allSelectableValues()) {
|
|
471
|
+
const option = this.findOption(value);
|
|
472
|
+
if (option && this.canSelectOption(option)) this.selectValue(value, false);
|
|
473
|
+
}
|
|
198
474
|
this.afterSelectionChange();
|
|
199
475
|
}
|
|
476
|
+
/** Clears every selection. Equivalent to `setValue(null)`. */
|
|
477
|
+
clearAll() {
|
|
478
|
+
this.clearSelection();
|
|
479
|
+
}
|
|
200
480
|
enable() {
|
|
201
481
|
this.isDisabled = false;
|
|
202
482
|
this.root.classList.remove("forge-select--disabled");
|
|
203
483
|
this.control.tabIndex = 0;
|
|
204
484
|
this.control.setAttribute("aria-disabled", "false");
|
|
485
|
+
if (this.nativeSelect) this.nativeSelect.disabled = false;
|
|
205
486
|
}
|
|
206
487
|
disable() {
|
|
207
488
|
this.close();
|
|
@@ -209,6 +490,7 @@ var ForgeSelect = class {
|
|
|
209
490
|
this.root.classList.add("forge-select--disabled");
|
|
210
491
|
this.control.tabIndex = -1;
|
|
211
492
|
this.control.setAttribute("aria-disabled", "true");
|
|
493
|
+
if (this.nativeSelect) this.nativeSelect.disabled = true;
|
|
212
494
|
}
|
|
213
495
|
on(event, handler) {
|
|
214
496
|
this.emitter.on(event, handler);
|
|
@@ -217,18 +499,59 @@ var ForgeSelect = class {
|
|
|
217
499
|
this.emitter.off(event, handler);
|
|
218
500
|
}
|
|
219
501
|
// ---------------------------------------------------------------- DOM setup
|
|
502
|
+
/**
|
|
503
|
+
* The original target (a hidden native <select> or a plain mount div) can
|
|
504
|
+
* carry an accessible name via aria-label/aria-labelledby, or via a
|
|
505
|
+
* <label for> pointing at its id — but once `this.el` is display:none it
|
|
506
|
+
* drops out of the accessibility tree, so any such association silently
|
|
507
|
+
* stops reaching assistive tech unless we forward it onto the visible,
|
|
508
|
+
* interactive `this.control` ourselves.
|
|
509
|
+
*/
|
|
510
|
+
applyAccessibleName() {
|
|
511
|
+
const ariaLabelledby = this.el.getAttribute("aria-labelledby");
|
|
512
|
+
const ariaLabel = this.el.getAttribute("aria-label");
|
|
513
|
+
if (ariaLabelledby) {
|
|
514
|
+
this.control.setAttribute("aria-labelledby", ariaLabelledby);
|
|
515
|
+
} else if (ariaLabel) {
|
|
516
|
+
this.control.setAttribute("aria-label", ariaLabel);
|
|
517
|
+
} else if (this.el.id) {
|
|
518
|
+
const label = Array.from(document.getElementsByTagName("label")).find((el) => el.htmlFor === this.el.id);
|
|
519
|
+
if (label) {
|
|
520
|
+
if (!label.id) label.id = `${this.uid}-label`;
|
|
521
|
+
this.control.setAttribute("aria-labelledby", label.id);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
shouldShowSearch() {
|
|
526
|
+
return this.opts.searchable && (this.opts.ajax != null || collectValues(this.data).size >= this.opts.minResultsForSearch);
|
|
527
|
+
}
|
|
528
|
+
updateSearchVisibility() {
|
|
529
|
+
if (!this.searchInput) return;
|
|
530
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
531
|
+
if (this.searchInput.hidden) {
|
|
532
|
+
this.searchInput.value = "";
|
|
533
|
+
this.query = "";
|
|
534
|
+
}
|
|
535
|
+
}
|
|
220
536
|
buildDom() {
|
|
537
|
+
const portalParent = typeof this.opts.dropdownParent === "string" ? document.querySelector(this.opts.dropdownParent) : this.opts.dropdownParent;
|
|
538
|
+
if (this.opts.dropdownParent && !portalParent) {
|
|
539
|
+
throw new Error(`ForgeSelect: dropdown parent not found: ${String(this.opts.dropdownParent)}`);
|
|
540
|
+
}
|
|
221
541
|
this.root = document.createElement("div");
|
|
222
542
|
this.root.className = "forge-select";
|
|
223
543
|
this.root.dataset.theme = this.opts.theme;
|
|
224
544
|
this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
545
|
+
if (this.opts.sortable && this.opts.multiple) this.root.classList.add("forge-select--sortable");
|
|
225
546
|
this.control = document.createElement("div");
|
|
226
547
|
this.control.className = "forge-select__control";
|
|
227
548
|
this.control.setAttribute("role", "combobox");
|
|
228
549
|
this.control.setAttribute("aria-haspopup", "listbox");
|
|
229
550
|
this.control.setAttribute("aria-expanded", "false");
|
|
230
551
|
this.control.setAttribute("aria-controls", `${this.uid}-list`);
|
|
552
|
+
if (this.opts.required) this.control.setAttribute("aria-required", "true");
|
|
231
553
|
this.control.tabIndex = 0;
|
|
554
|
+
this.applyAccessibleName();
|
|
232
555
|
this.valueEl = document.createElement("div");
|
|
233
556
|
this.valueEl.className = "forge-select__value";
|
|
234
557
|
this.clearBtn = document.createElement("button");
|
|
@@ -251,6 +574,7 @@ var ForgeSelect = class {
|
|
|
251
574
|
this.searchInput.setAttribute("aria-label", this.strings.search);
|
|
252
575
|
this.searchInput.setAttribute("aria-autocomplete", "list");
|
|
253
576
|
this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
|
|
577
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
254
578
|
this.dropdown.append(this.searchInput);
|
|
255
579
|
}
|
|
256
580
|
this.list = document.createElement("ul");
|
|
@@ -259,18 +583,45 @@ var ForgeSelect = class {
|
|
|
259
583
|
this.list.setAttribute("role", "listbox");
|
|
260
584
|
if (this.opts.multiple) this.list.setAttribute("aria-multiselectable", "true");
|
|
261
585
|
this.dropdown.append(this.list);
|
|
262
|
-
this.
|
|
586
|
+
this.liveRegion = document.createElement("div");
|
|
587
|
+
this.liveRegion.className = "forge-select__sr-only";
|
|
588
|
+
this.liveRegion.setAttribute("role", "status");
|
|
589
|
+
this.liveRegion.setAttribute("aria-live", "polite");
|
|
590
|
+
this.root.append(this.control, this.liveRegion);
|
|
591
|
+
if (!portalParent) this.root.append(this.dropdown);
|
|
263
592
|
this.el.style.display = "none";
|
|
264
593
|
this.el.insertAdjacentElement("afterend", this.root);
|
|
594
|
+
if (portalParent) {
|
|
595
|
+
this.portalHost = document.createElement("div");
|
|
596
|
+
this.portalHost.className = "forge-select forge-select--portal-host";
|
|
597
|
+
this.portalHost.dataset.theme = this.opts.theme;
|
|
598
|
+
this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
599
|
+
this.portalHost.append(this.dropdown);
|
|
600
|
+
portalParent.append(this.portalHost);
|
|
601
|
+
}
|
|
265
602
|
this.bindEvents();
|
|
266
603
|
}
|
|
267
604
|
bindEvents() {
|
|
268
605
|
this.control.addEventListener("click", (event) => {
|
|
269
606
|
if (event.target === this.clearBtn) return;
|
|
607
|
+
if (this.suppressNextTagClick) {
|
|
608
|
+
this.suppressNextTagClick = false;
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
270
611
|
if (this.isDisabled) return;
|
|
271
|
-
this.isOpen
|
|
612
|
+
if (this.isOpen) this.close();
|
|
613
|
+
else this.open();
|
|
272
614
|
});
|
|
273
615
|
this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
616
|
+
this.control.addEventListener("mousedown", () => {
|
|
617
|
+
this.pointerDownOnControl = true;
|
|
618
|
+
});
|
|
619
|
+
this.control.addEventListener("focus", () => {
|
|
620
|
+
if (this.opts.openOnFocus && !this.pointerDownOnControl && !this.isOpen && !this.isDisabled) {
|
|
621
|
+
this.open();
|
|
622
|
+
}
|
|
623
|
+
this.pointerDownOnControl = false;
|
|
624
|
+
});
|
|
274
625
|
this.clearBtn.addEventListener("click", (event) => {
|
|
275
626
|
event.stopPropagation();
|
|
276
627
|
this.clearSelection();
|
|
@@ -281,13 +632,45 @@ var ForgeSelect = class {
|
|
|
281
632
|
this.highlightedIndex = -1;
|
|
282
633
|
this.list.scrollTop = 0;
|
|
283
634
|
this.emitter.emit("search", this.query);
|
|
284
|
-
|
|
635
|
+
const trimmed = this.query.trim();
|
|
636
|
+
const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
|
|
637
|
+
if (this.opts.ajax && !belowMinLength) {
|
|
285
638
|
this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
|
|
286
639
|
} else {
|
|
640
|
+
if (belowMinLength) {
|
|
641
|
+
if (this.ajaxTimer) {
|
|
642
|
+
clearTimeout(this.ajaxTimer);
|
|
643
|
+
this.ajaxTimer = null;
|
|
644
|
+
}
|
|
645
|
+
this.ajaxController?.abort();
|
|
646
|
+
this.loading = false;
|
|
647
|
+
}
|
|
287
648
|
this.renderList();
|
|
288
649
|
}
|
|
289
650
|
});
|
|
290
651
|
this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
652
|
+
this.searchInput.addEventListener("paste", (event) => {
|
|
653
|
+
if (!this.opts.multiple || !this.opts.allowCreate) return;
|
|
654
|
+
const text = event.clipboardData?.getData("text") ?? "";
|
|
655
|
+
const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
|
|
656
|
+
if (labels.length < 2) return;
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
const created = [];
|
|
659
|
+
for (const label of labels) {
|
|
660
|
+
const result = this.createTag(label);
|
|
661
|
+
if (result) created.push(result);
|
|
662
|
+
}
|
|
663
|
+
if (created.length === 0) return;
|
|
664
|
+
this.searchInput.value = "";
|
|
665
|
+
this.query = "";
|
|
666
|
+
this.afterSelectionChange();
|
|
667
|
+
for (const result of created) {
|
|
668
|
+
if (result.created) this.emitter.emit("create", result.option);
|
|
669
|
+
this.emitter.emit("select", result.option);
|
|
670
|
+
}
|
|
671
|
+
if (this.opts.closeOnSelect) this.close();
|
|
672
|
+
else this.renderList();
|
|
673
|
+
});
|
|
291
674
|
}
|
|
292
675
|
this.list.addEventListener("click", (event) => {
|
|
293
676
|
const target = event.target;
|
|
@@ -300,7 +683,12 @@ var ForgeSelect = class {
|
|
|
300
683
|
return;
|
|
301
684
|
}
|
|
302
685
|
const li = target.closest("li[data-nav-index]");
|
|
303
|
-
if (!li)
|
|
686
|
+
if (!li) {
|
|
687
|
+
const optionRow = target.closest("li[data-option-value]");
|
|
688
|
+
const option = optionRow ? this.findOption(optionRow.dataset.optionValue) : void 0;
|
|
689
|
+
if (option && this.hasReachedMaximum() && !this.selected.includes(option.value)) this.announceMaximum(option);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
304
692
|
const navIndex = Number(li.dataset.navIndex);
|
|
305
693
|
this.activateNavItem(navIndex);
|
|
306
694
|
});
|
|
@@ -339,42 +727,73 @@ var ForgeSelect = class {
|
|
|
339
727
|
this.control.focus();
|
|
340
728
|
}
|
|
341
729
|
break;
|
|
730
|
+
case "ArrowRight":
|
|
731
|
+
if (this.isOpen && this.navigateTree("right")) event.preventDefault();
|
|
732
|
+
break;
|
|
733
|
+
case "ArrowLeft":
|
|
734
|
+
if (this.isOpen && this.navigateTree("left")) event.preventDefault();
|
|
735
|
+
break;
|
|
342
736
|
case "Tab":
|
|
343
737
|
this.close();
|
|
344
738
|
break;
|
|
345
739
|
}
|
|
346
740
|
}
|
|
347
741
|
// ---------------------------------------------------------------- selection
|
|
742
|
+
canSelectOption(option) {
|
|
743
|
+
if (this.opts.maxSelections == null) return true;
|
|
744
|
+
const projected = [...this.selected];
|
|
745
|
+
if (!projected.includes(option.value)) projected.push(option.value);
|
|
746
|
+
for (const value of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
747
|
+
if (!projected.includes(value)) projected.push(value);
|
|
748
|
+
}
|
|
749
|
+
syncTreeAncestors(this.data, projected, this.isOptionDisabled);
|
|
750
|
+
return projected.length <= this.opts.maxSelections;
|
|
751
|
+
}
|
|
752
|
+
hasReachedMaximum() {
|
|
753
|
+
return this.opts.maxSelections != null && this.selected.length >= this.opts.maxSelections;
|
|
754
|
+
}
|
|
755
|
+
announceMaximum(option) {
|
|
756
|
+
const limit = this.opts.maxSelections;
|
|
757
|
+
if (limit == null) return;
|
|
758
|
+
this.liveRegion.textContent = format(this.strings.maximumSelected, { count: String(limit) });
|
|
759
|
+
this.emitter.emit("maximum", { limit, option });
|
|
760
|
+
}
|
|
348
761
|
selectValue(value, notify) {
|
|
349
762
|
if (this.selected.includes(value)) return;
|
|
350
763
|
const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
|
|
351
764
|
this.selectedOptions.set(value, option);
|
|
352
765
|
if (this.opts.multiple) {
|
|
353
766
|
this.selected.push(value);
|
|
354
|
-
for (const v of collectDescendantValues(option)) {
|
|
767
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
355
768
|
if (!this.selected.includes(v)) this.selected.push(v);
|
|
356
769
|
}
|
|
357
770
|
this.syncTreeAncestors();
|
|
358
771
|
} else {
|
|
359
772
|
this.selected = [value];
|
|
360
773
|
}
|
|
361
|
-
if (notify)
|
|
774
|
+
if (notify) {
|
|
775
|
+
this.afterSelectionChange();
|
|
776
|
+
this.emitter.emit("select", option);
|
|
777
|
+
}
|
|
362
778
|
}
|
|
363
779
|
deselectValue(value, notify) {
|
|
364
780
|
const index = this.selected.indexOf(value);
|
|
365
781
|
if (index === -1) return;
|
|
782
|
+
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
366
783
|
this.selected.splice(index, 1);
|
|
367
784
|
if (this.opts.multiple) {
|
|
368
|
-
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
369
785
|
if (option) {
|
|
370
|
-
for (const v of collectDescendantValues(option)) {
|
|
786
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
371
787
|
const i = this.selected.indexOf(v);
|
|
372
788
|
if (i !== -1) this.selected.splice(i, 1);
|
|
373
789
|
}
|
|
374
790
|
}
|
|
375
791
|
this.syncTreeAncestors();
|
|
376
792
|
}
|
|
377
|
-
if (notify)
|
|
793
|
+
if (notify) {
|
|
794
|
+
this.afterSelectionChange();
|
|
795
|
+
this.emitter.emit("unselect", option ?? { value, label: value });
|
|
796
|
+
}
|
|
378
797
|
}
|
|
379
798
|
/**
|
|
380
799
|
* Keeps every tree parent's own membership in `selected` consistent with
|
|
@@ -383,18 +802,7 @@ var ForgeSelect = class {
|
|
|
383
802
|
* No-op for data with no `children` anywhere.
|
|
384
803
|
*/
|
|
385
804
|
syncTreeAncestors() {
|
|
386
|
-
|
|
387
|
-
if (!option.children || option.children.length === 0) return;
|
|
388
|
-
for (const child of option.children) sync(child);
|
|
389
|
-
const state = computeCheckState(option, this.selected);
|
|
390
|
-
const index = this.selected.indexOf(option.value);
|
|
391
|
-
if (state === "all" && index === -1) this.selected.push(option.value);
|
|
392
|
-
else if (state !== "all" && index !== -1) this.selected.splice(index, 1);
|
|
393
|
-
};
|
|
394
|
-
for (const item of this.data) {
|
|
395
|
-
const options = isGroup(item) ? item.options : [item];
|
|
396
|
-
options.forEach(sync);
|
|
397
|
-
}
|
|
805
|
+
syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
|
|
398
806
|
}
|
|
399
807
|
clearSelection() {
|
|
400
808
|
if (this.selected.length === 0) return;
|
|
@@ -402,13 +810,26 @@ var ForgeSelect = class {
|
|
|
402
810
|
this.emitter.emit("clear");
|
|
403
811
|
this.afterSelectionChange();
|
|
404
812
|
}
|
|
405
|
-
|
|
813
|
+
allSelectableValues() {
|
|
814
|
+
const values = [];
|
|
815
|
+
const visit = (option) => {
|
|
816
|
+
if (!this.isOptionDisabled(option)) values.push(option.value);
|
|
817
|
+
option.children?.forEach(visit);
|
|
818
|
+
};
|
|
819
|
+
for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
|
|
820
|
+
return values;
|
|
821
|
+
}
|
|
822
|
+
afterSelectionChange(emitChange = true) {
|
|
406
823
|
this.renderValue();
|
|
407
|
-
this.syncNativeSelect();
|
|
824
|
+
this.syncNativeSelect(emitChange);
|
|
825
|
+
if (!this.opts.required || this.selected.length > 0) {
|
|
826
|
+
this.control.classList.remove("forge-select__control--invalid");
|
|
827
|
+
this.control.removeAttribute("aria-invalid");
|
|
828
|
+
}
|
|
408
829
|
if (this.isOpen) this.renderList();
|
|
409
|
-
this.emitter.emit("change", this.getValue());
|
|
830
|
+
if (emitChange) this.emitter.emit("change", this.getValue());
|
|
410
831
|
}
|
|
411
|
-
syncNativeSelect() {
|
|
832
|
+
syncNativeSelect(dispatchChange = true) {
|
|
412
833
|
if (!(this.el instanceof HTMLSelectElement)) return;
|
|
413
834
|
const existing = /* @__PURE__ */ new Set();
|
|
414
835
|
for (const option of Array.from(this.el.options)) {
|
|
@@ -423,16 +844,30 @@ var ForgeSelect = class {
|
|
|
423
844
|
option.selected = true;
|
|
424
845
|
this.el.append(option);
|
|
425
846
|
}
|
|
426
|
-
this.
|
|
847
|
+
if (this.opts.sortable && this.opts.multiple) {
|
|
848
|
+
for (const value of this.selected) {
|
|
849
|
+
const option = Array.from(this.el.options).find((o) => o.value === value);
|
|
850
|
+
if (option) this.el.append(option);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
if (!dispatchChange) return;
|
|
854
|
+
this.syncingNative = true;
|
|
855
|
+
try {
|
|
856
|
+
this.el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
857
|
+
} finally {
|
|
858
|
+
this.syncingNative = false;
|
|
859
|
+
}
|
|
427
860
|
}
|
|
428
861
|
findOption(value) {
|
|
862
|
+
return findOption(this.data, value);
|
|
863
|
+
}
|
|
864
|
+
findOptionByLabel(label) {
|
|
865
|
+
const lower = label.toLowerCase();
|
|
429
866
|
const search = (options) => {
|
|
430
867
|
for (const option of options) {
|
|
431
|
-
if (option.
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if (found) return found;
|
|
435
|
-
}
|
|
868
|
+
if (option.label.toLowerCase() === lower) return option;
|
|
869
|
+
const found = option.children ? search(option.children) : void 0;
|
|
870
|
+
if (found) return found;
|
|
436
871
|
}
|
|
437
872
|
return void 0;
|
|
438
873
|
};
|
|
@@ -442,17 +877,42 @@ var ForgeSelect = class {
|
|
|
442
877
|
}
|
|
443
878
|
return void 0;
|
|
444
879
|
}
|
|
880
|
+
/** Selects an existing option matching `label` exactly, or creates and selects a new one. */
|
|
881
|
+
createTag(label) {
|
|
882
|
+
const trimmed = label.trim();
|
|
883
|
+
if (!trimmed) return void 0;
|
|
884
|
+
const existing = this.findOptionByLabel(trimmed);
|
|
885
|
+
if (existing) {
|
|
886
|
+
if (this.selected.includes(existing.value)) return void 0;
|
|
887
|
+
if (this.opts.multiple && !this.canSelectOption(existing)) {
|
|
888
|
+
this.announceMaximum(existing);
|
|
889
|
+
return void 0;
|
|
890
|
+
}
|
|
891
|
+
this.selectValue(existing.value, false);
|
|
892
|
+
return { option: existing, created: false };
|
|
893
|
+
}
|
|
894
|
+
const option = { value: trimmed, label: trimmed };
|
|
895
|
+
if (this.opts.multiple && !this.canSelectOption(option)) {
|
|
896
|
+
this.announceMaximum(option);
|
|
897
|
+
return void 0;
|
|
898
|
+
}
|
|
899
|
+
this.data.push(option);
|
|
900
|
+
this.selectValue(option.value, false);
|
|
901
|
+
return { option, created: true };
|
|
902
|
+
}
|
|
445
903
|
createFromQuery() {
|
|
446
904
|
const label = this.query.trim();
|
|
447
905
|
if (!label) return;
|
|
448
|
-
const
|
|
449
|
-
|
|
906
|
+
const result = this.createTag(label);
|
|
907
|
+
if (!result) return;
|
|
450
908
|
if (this.searchInput) {
|
|
451
909
|
this.searchInput.value = "";
|
|
452
910
|
this.query = "";
|
|
453
911
|
}
|
|
454
|
-
this.
|
|
455
|
-
if (
|
|
912
|
+
this.afterSelectionChange();
|
|
913
|
+
if (result.created) this.emitter.emit("create", result.option);
|
|
914
|
+
this.emitter.emit("select", result.option);
|
|
915
|
+
if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
|
|
456
916
|
}
|
|
457
917
|
activateNavItem(navIndex) {
|
|
458
918
|
const item = this.navItems[navIndex];
|
|
@@ -463,7 +923,17 @@ var ForgeSelect = class {
|
|
|
463
923
|
}
|
|
464
924
|
const { value } = item.option;
|
|
465
925
|
if (this.opts.multiple) {
|
|
466
|
-
|
|
926
|
+
let changed = false;
|
|
927
|
+
if (this.selected.includes(value)) {
|
|
928
|
+
this.deselectValue(value, true);
|
|
929
|
+
changed = true;
|
|
930
|
+
} else if (this.canSelectOption(item.option)) {
|
|
931
|
+
this.selectValue(value, true);
|
|
932
|
+
changed = true;
|
|
933
|
+
} else {
|
|
934
|
+
this.announceMaximum(item.option);
|
|
935
|
+
}
|
|
936
|
+
if (changed && this.opts.closeOnSelect) this.close();
|
|
467
937
|
} else {
|
|
468
938
|
this.selectValue(value, true);
|
|
469
939
|
this.close();
|
|
@@ -489,7 +959,7 @@ var ForgeSelect = class {
|
|
|
489
959
|
tag.className = "forge-select__tag";
|
|
490
960
|
const label = document.createElement("span");
|
|
491
961
|
label.className = "forge-select__tag-label";
|
|
492
|
-
|
|
962
|
+
renderOptionContent(label, option, this.opts.templateSelection, "inline");
|
|
493
963
|
const remove = document.createElement("button");
|
|
494
964
|
remove.type = "button";
|
|
495
965
|
remove.className = "forge-select__tag-remove";
|
|
@@ -500,6 +970,14 @@ var ForgeSelect = class {
|
|
|
500
970
|
if (!this.isDisabled) this.deselectValue(value, true);
|
|
501
971
|
});
|
|
502
972
|
tag.append(label, remove);
|
|
973
|
+
if (this.opts.sortable) {
|
|
974
|
+
tag.dataset.value = value;
|
|
975
|
+
tag.tabIndex = 0;
|
|
976
|
+
tag.setAttribute("aria-roledescription", "draggable item");
|
|
977
|
+
tag.setAttribute("aria-label", format(this.strings.reorderHint, { label: option.label }));
|
|
978
|
+
tag.addEventListener("keydown", (event) => this.handleTagKeydown(event, value));
|
|
979
|
+
this.bindTagDrag(tag, value);
|
|
980
|
+
}
|
|
503
981
|
this.valueEl.append(tag);
|
|
504
982
|
}
|
|
505
983
|
} else {
|
|
@@ -509,59 +987,116 @@ var ForgeSelect = class {
|
|
|
509
987
|
};
|
|
510
988
|
const span = document.createElement("span");
|
|
511
989
|
span.className = "forge-select__single-value";
|
|
512
|
-
|
|
990
|
+
renderOptionContent(span, option, this.opts.templateSelection, "inline");
|
|
513
991
|
this.valueEl.append(span);
|
|
514
992
|
}
|
|
515
993
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
994
|
+
/**
|
|
995
|
+
* Pointer-based (mouse/touch/pen) reorder for a single tag. Only the real
|
|
996
|
+
* dragged DOM node is moved during the gesture — a full renderValue()
|
|
997
|
+
* mid-drag would destroy it — so the reordered `this.selected` is only
|
|
998
|
+
* committed on release. The move/up listeners and pointer capture live on
|
|
999
|
+
* the stable `this.valueEl` container rather than the tag itself: `tag`
|
|
1000
|
+
* gets repositioned via `insertBefore` during the drag, and browsers treat
|
|
1001
|
+
* that reparenting as detaching the node, which silently drops pointer
|
|
1002
|
+
* capture (and further move events) if it were captured on `tag`.
|
|
1003
|
+
*/
|
|
1004
|
+
bindTagDrag(tag, value) {
|
|
1005
|
+
const DRAG_THRESHOLD = 4;
|
|
1006
|
+
let startX = 0;
|
|
1007
|
+
let dragging = false;
|
|
1008
|
+
let order = [];
|
|
1009
|
+
const onPointerMove = (event) => {
|
|
1010
|
+
if (!dragging) {
|
|
1011
|
+
if (Math.abs(event.clientX - startX) < DRAG_THRESHOLD) return;
|
|
1012
|
+
dragging = true;
|
|
1013
|
+
order = [...this.selected];
|
|
1014
|
+
if (typeof this.valueEl.setPointerCapture === "function") {
|
|
1015
|
+
this.valueEl.setPointerCapture(event.pointerId);
|
|
1016
|
+
}
|
|
1017
|
+
tag.classList.add("forge-select__tag--dragging");
|
|
1018
|
+
}
|
|
1019
|
+
event.preventDefault();
|
|
1020
|
+
const draggedIndex = order.indexOf(value);
|
|
1021
|
+
const siblings = Array.from(this.valueEl.querySelectorAll(".forge-select__tag"));
|
|
1022
|
+
for (const sibling of siblings) {
|
|
1023
|
+
if (sibling === tag) continue;
|
|
1024
|
+
const siblingValue = sibling.dataset.value;
|
|
1025
|
+
if (!siblingValue) continue;
|
|
1026
|
+
const siblingIndex = order.indexOf(siblingValue);
|
|
1027
|
+
if (siblingIndex === -1) continue;
|
|
1028
|
+
const rect = sibling.getBoundingClientRect();
|
|
1029
|
+
const midX = rect.left + rect.width / 2;
|
|
1030
|
+
const movingRight = draggedIndex < siblingIndex;
|
|
1031
|
+
const crossed = movingRight ? event.clientX > midX : event.clientX < midX;
|
|
1032
|
+
if (!crossed) continue;
|
|
1033
|
+
order.splice(draggedIndex, 1);
|
|
1034
|
+
order.splice(siblingIndex, 0, value);
|
|
1035
|
+
if (movingRight) this.valueEl.insertBefore(tag, sibling.nextSibling);
|
|
1036
|
+
else this.valueEl.insertBefore(tag, sibling);
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
const finishDrag = (event) => {
|
|
1041
|
+
this.valueEl.removeEventListener("pointermove", onPointerMove);
|
|
1042
|
+
this.valueEl.removeEventListener("pointerup", finishDrag);
|
|
1043
|
+
this.valueEl.removeEventListener("pointercancel", finishDrag);
|
|
1044
|
+
if (!dragging) return;
|
|
1045
|
+
if (typeof this.valueEl.releasePointerCapture === "function") {
|
|
1046
|
+
this.valueEl.releasePointerCapture(event.pointerId);
|
|
1047
|
+
}
|
|
1048
|
+
tag.classList.remove("forge-select__tag--dragging");
|
|
1049
|
+
this.selected = order;
|
|
1050
|
+
this.suppressNextTagClick = true;
|
|
1051
|
+
this.afterSelectionChange();
|
|
1052
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
1053
|
+
};
|
|
1054
|
+
tag.addEventListener("pointerdown", (event) => {
|
|
1055
|
+
if (this.isDisabled || event.button !== 0) return;
|
|
1056
|
+
if (event.target.closest(".forge-select__tag-remove")) return;
|
|
1057
|
+
startX = event.clientX;
|
|
1058
|
+
dragging = false;
|
|
1059
|
+
this.valueEl.addEventListener("pointermove", onPointerMove);
|
|
1060
|
+
this.valueEl.addEventListener("pointerup", finishDrag);
|
|
1061
|
+
this.valueEl.addEventListener("pointercancel", finishDrag);
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
/** Alt+Left/Alt+Right on a focused tag: the keyboard-operable equivalent of dragging. */
|
|
1065
|
+
handleTagKeydown(event, value) {
|
|
1066
|
+
if (!event.altKey || event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
|
1067
|
+
const index = this.selected.indexOf(value);
|
|
1068
|
+
const targetIndex = event.key === "ArrowLeft" ? index - 1 : index + 1;
|
|
1069
|
+
if (index === -1 || targetIndex < 0 || targetIndex >= this.selected.length) return;
|
|
1070
|
+
event.preventDefault();
|
|
1071
|
+
event.stopPropagation();
|
|
1072
|
+
const next = [...this.selected];
|
|
1073
|
+
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
|
1074
|
+
this.selected = next;
|
|
1075
|
+
this.afterSelectionChange();
|
|
1076
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
1077
|
+
this.focusTagByValue(value);
|
|
1078
|
+
}
|
|
1079
|
+
focusTagByValue(value) {
|
|
1080
|
+
for (const tag of Array.from(this.valueEl.querySelectorAll(".forge-select__tag"))) {
|
|
1081
|
+
if (tag.dataset.value === value) {
|
|
1082
|
+
tag.focus();
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
552
1085
|
}
|
|
553
1086
|
}
|
|
554
1087
|
buildRows() {
|
|
555
1088
|
this.rows = [];
|
|
556
1089
|
this.navItems = [];
|
|
557
|
-
const
|
|
558
|
-
const
|
|
1090
|
+
const trimmedQuery = this.query.trim();
|
|
1091
|
+
const query = trimmedQuery.toLowerCase();
|
|
1092
|
+
const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false));
|
|
559
1093
|
const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
|
|
560
|
-
const pushOption = (option, depth) => {
|
|
1094
|
+
const pushOption = (option, depth, parentValue) => {
|
|
561
1095
|
let navIndex = -1;
|
|
562
|
-
|
|
1096
|
+
const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
|
|
1097
|
+
if (!interactionDisabled) {
|
|
563
1098
|
navIndex = this.navItems.length;
|
|
564
|
-
this.navItems.push({ kind: "option", option });
|
|
1099
|
+
this.navItems.push({ kind: "option", option, parentValue });
|
|
565
1100
|
}
|
|
566
1101
|
const hasChildren = !!option.children && option.children.length > 0;
|
|
567
1102
|
this.rows.push({ kind: "option", option, navIndex, depth, hasChildren });
|
|
@@ -569,15 +1104,23 @@ var ForgeSelect = class {
|
|
|
569
1104
|
const expanded = query !== "" || this.expandedValues.has(option.value);
|
|
570
1105
|
if (expanded) {
|
|
571
1106
|
for (const child of option.children) {
|
|
572
|
-
if (subtreeMatches(child)) pushOption(child, depth + 1);
|
|
1107
|
+
if (subtreeMatches(child)) pushOption(child, depth + 1, option.value);
|
|
573
1108
|
}
|
|
574
1109
|
}
|
|
575
1110
|
}
|
|
576
1111
|
};
|
|
1112
|
+
if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
|
|
1113
|
+
this.rows.push({ kind: "min-length" });
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
577
1116
|
if (this.loading) {
|
|
578
1117
|
this.rows.push({ kind: "loading" });
|
|
579
1118
|
return;
|
|
580
1119
|
}
|
|
1120
|
+
if (this.loadError) {
|
|
1121
|
+
this.rows.push({ kind: "error" });
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
581
1124
|
for (const item of this.data) {
|
|
582
1125
|
if (isGroup(item)) {
|
|
583
1126
|
const visible = item.options.filter(subtreeMatches);
|
|
@@ -597,12 +1140,7 @@ var ForgeSelect = class {
|
|
|
597
1140
|
else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
|
|
598
1141
|
}
|
|
599
1142
|
hasExactMatch(lowerQuery) {
|
|
600
|
-
|
|
601
|
-
for (const item of this.data) {
|
|
602
|
-
const options = isGroup(item) ? item.options : [item];
|
|
603
|
-
if (options.some(matchesExactly)) return true;
|
|
604
|
-
}
|
|
605
|
-
return false;
|
|
1143
|
+
return !!this.findOptionByLabel(lowerQuery);
|
|
606
1144
|
}
|
|
607
1145
|
usesVirtualScroll() {
|
|
608
1146
|
return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
|
|
@@ -610,6 +1148,12 @@ var ForgeSelect = class {
|
|
|
610
1148
|
renderList() {
|
|
611
1149
|
this.buildRows();
|
|
612
1150
|
this.renderRows();
|
|
1151
|
+
this.announceStatus();
|
|
1152
|
+
}
|
|
1153
|
+
announceStatus() {
|
|
1154
|
+
const first = this.rows[0];
|
|
1155
|
+
const message = this.hasReachedMaximum() ? format(this.strings.maximumSelected, { count: String(this.opts.maxSelections) }) : first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : first?.kind === "min-length" ? format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) }) : "";
|
|
1156
|
+
if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
|
|
613
1157
|
}
|
|
614
1158
|
renderRows() {
|
|
615
1159
|
const scrollTop = this.list.scrollTop;
|
|
@@ -654,10 +1198,30 @@ var ForgeSelect = class {
|
|
|
654
1198
|
break;
|
|
655
1199
|
case "empty":
|
|
656
1200
|
li.className = "forge-select__empty";
|
|
1201
|
+
li.setAttribute("role", "option");
|
|
1202
|
+
li.setAttribute("aria-disabled", "true");
|
|
1203
|
+
li.setAttribute("aria-selected", "false");
|
|
657
1204
|
li.textContent = this.strings.noResults;
|
|
658
1205
|
break;
|
|
1206
|
+
case "min-length":
|
|
1207
|
+
li.className = "forge-select__min-length";
|
|
1208
|
+
li.setAttribute("role", "option");
|
|
1209
|
+
li.setAttribute("aria-disabled", "true");
|
|
1210
|
+
li.setAttribute("aria-selected", "false");
|
|
1211
|
+
li.textContent = format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) });
|
|
1212
|
+
break;
|
|
1213
|
+
case "error":
|
|
1214
|
+
li.className = "forge-select__error";
|
|
1215
|
+
li.setAttribute("role", "option");
|
|
1216
|
+
li.setAttribute("aria-disabled", "true");
|
|
1217
|
+
li.setAttribute("aria-selected", "false");
|
|
1218
|
+
li.textContent = this.strings.errorLoading;
|
|
1219
|
+
break;
|
|
659
1220
|
case "loading":
|
|
660
1221
|
li.className = "forge-select__loading";
|
|
1222
|
+
li.setAttribute("role", "option");
|
|
1223
|
+
li.setAttribute("aria-disabled", "true");
|
|
1224
|
+
li.setAttribute("aria-selected", "false");
|
|
661
1225
|
li.textContent = this.strings.loading;
|
|
662
1226
|
break;
|
|
663
1227
|
case "loading-more":
|
|
@@ -675,17 +1239,19 @@ var ForgeSelect = class {
|
|
|
675
1239
|
break;
|
|
676
1240
|
case "option": {
|
|
677
1241
|
li.className = "forge-select__option";
|
|
1242
|
+
li.dataset.optionValue = row.option.value;
|
|
1243
|
+
if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
|
|
678
1244
|
li.setAttribute("role", "option");
|
|
679
1245
|
const isSelected = this.selected.includes(row.option.value);
|
|
680
1246
|
li.setAttribute("aria-selected", String(isSelected));
|
|
681
1247
|
if (isSelected) li.classList.add("forge-select__option--selected");
|
|
682
|
-
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
|
|
1248
|
+
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
|
|
683
1249
|
li.classList.add("forge-select__option--indeterminate");
|
|
684
1250
|
}
|
|
685
1251
|
if (row.depth > 0) {
|
|
686
1252
|
li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
|
|
687
1253
|
}
|
|
688
|
-
if (row.option.
|
|
1254
|
+
if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
|
|
689
1255
|
li.classList.add("forge-select__option--disabled");
|
|
690
1256
|
li.setAttribute("aria-disabled", "true");
|
|
691
1257
|
} else {
|
|
@@ -694,11 +1260,13 @@ var ForgeSelect = class {
|
|
|
694
1260
|
if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
|
|
695
1261
|
}
|
|
696
1262
|
if (row.hasChildren) {
|
|
1263
|
+
const expanded = this.query !== "" || this.expandedValues.has(row.option.value);
|
|
1264
|
+
li.setAttribute("aria-expanded", String(expanded));
|
|
697
1265
|
const twisty = document.createElement("span");
|
|
698
1266
|
twisty.className = "forge-select__twisty";
|
|
699
1267
|
twisty.dataset.twisty = row.option.value;
|
|
700
1268
|
twisty.setAttribute("aria-hidden", "true");
|
|
701
|
-
twisty.textContent =
|
|
1269
|
+
twisty.textContent = expanded ? "\u25BC" : "\u25B6";
|
|
702
1270
|
li.append(twisty);
|
|
703
1271
|
}
|
|
704
1272
|
li.append(this.optionContent(row.option));
|
|
@@ -718,7 +1286,7 @@ var ForgeSelect = class {
|
|
|
718
1286
|
if (!cached) {
|
|
719
1287
|
const holder = document.createElement("span");
|
|
720
1288
|
holder.className = "forge-select__option-content";
|
|
721
|
-
|
|
1289
|
+
renderOptionContent(holder, option, this.opts.templateResult);
|
|
722
1290
|
if (this.rowContentCache.size >= ROW_CACHE_LIMIT) {
|
|
723
1291
|
const oldest = this.rowContentCache.keys().next().value;
|
|
724
1292
|
this.rowContentCache.delete(oldest);
|
|
@@ -730,7 +1298,10 @@ var ForgeSelect = class {
|
|
|
730
1298
|
}
|
|
731
1299
|
moveHighlight(delta) {
|
|
732
1300
|
if (this.navItems.length === 0) return;
|
|
733
|
-
const next = this.highlightedIndex === -1
|
|
1301
|
+
const next = this.highlightedIndex === -1 ? delta > 0 ? 0 : this.navItems.length - 1 : (this.highlightedIndex + delta + this.navItems.length) % this.navItems.length;
|
|
1302
|
+
this.focusNavIndex(next);
|
|
1303
|
+
}
|
|
1304
|
+
focusNavIndex(next) {
|
|
734
1305
|
this.highlightedIndex = next;
|
|
735
1306
|
if (this.usesVirtualScroll()) {
|
|
736
1307
|
const rowIndex = this.rows.findIndex(
|
|
@@ -752,6 +1323,41 @@ var ForgeSelect = class {
|
|
|
752
1323
|
highlighted?.scrollIntoView?.({ block: "nearest" });
|
|
753
1324
|
}
|
|
754
1325
|
}
|
|
1326
|
+
navigateTree(direction) {
|
|
1327
|
+
const item = this.navItems[this.highlightedIndex];
|
|
1328
|
+
if (!item || item.kind !== "option") return false;
|
|
1329
|
+
const { option, parentValue } = item;
|
|
1330
|
+
const hasChildren = !!option.children?.length;
|
|
1331
|
+
const expanded = this.query !== "" || this.expandedValues.has(option.value);
|
|
1332
|
+
if (direction === "right") {
|
|
1333
|
+
if (hasChildren && !expanded) {
|
|
1334
|
+
this.expandedValues.add(option.value);
|
|
1335
|
+
this.renderList();
|
|
1336
|
+
return true;
|
|
1337
|
+
}
|
|
1338
|
+
if (hasChildren) {
|
|
1339
|
+
const childIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.parentValue === option.value);
|
|
1340
|
+
if (childIndex >= 0) {
|
|
1341
|
+
this.focusNavIndex(childIndex);
|
|
1342
|
+
return true;
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return false;
|
|
1346
|
+
}
|
|
1347
|
+
if (hasChildren && expanded && this.query === "") {
|
|
1348
|
+
this.expandedValues.delete(option.value);
|
|
1349
|
+
this.renderList();
|
|
1350
|
+
return true;
|
|
1351
|
+
}
|
|
1352
|
+
if (parentValue) {
|
|
1353
|
+
const parentIndex = this.navItems.findIndex((nav) => nav.kind === "option" && nav.option.value === parentValue);
|
|
1354
|
+
if (parentIndex >= 0) {
|
|
1355
|
+
this.focusNavIndex(parentIndex);
|
|
1356
|
+
return true;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
755
1361
|
updateActiveDescendant() {
|
|
756
1362
|
const target = this.searchInput ?? this.control;
|
|
757
1363
|
if (this.highlightedIndex >= 0) {
|
|
@@ -763,12 +1369,18 @@ var ForgeSelect = class {
|
|
|
763
1369
|
// ---------------------------------------------------------------- remote data
|
|
764
1370
|
scheduleRemoteLoad(query, delay) {
|
|
765
1371
|
if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
|
|
1372
|
+
const requestId = ++this.ajaxRequestId;
|
|
1373
|
+
this.ajaxController?.abort();
|
|
1374
|
+
this.ajaxController = null;
|
|
766
1375
|
this.page = 0;
|
|
767
1376
|
this.hasMore = true;
|
|
768
1377
|
this.loading = true;
|
|
1378
|
+
this.loadingMore = false;
|
|
1379
|
+
this.loadError = null;
|
|
769
1380
|
this.renderList();
|
|
770
1381
|
this.ajaxTimer = setTimeout(() => {
|
|
771
|
-
|
|
1382
|
+
this.ajaxTimer = null;
|
|
1383
|
+
void this.loadRemote(query, { requestId });
|
|
772
1384
|
}, delay);
|
|
773
1385
|
}
|
|
774
1386
|
/**
|
|
@@ -786,18 +1398,26 @@ var ForgeSelect = class {
|
|
|
786
1398
|
this.renderList();
|
|
787
1399
|
void this.loadRemote(this.query, { append: true });
|
|
788
1400
|
}
|
|
789
|
-
async loadRemote(query, { append = false } = {}) {
|
|
1401
|
+
async loadRemote(query, { append = false, requestId } = {}) {
|
|
790
1402
|
const ajax = this.opts.ajax;
|
|
791
|
-
const
|
|
1403
|
+
const activeRequestId = requestId ?? ++this.ajaxRequestId;
|
|
1404
|
+
if (activeRequestId !== this.ajaxRequestId) return;
|
|
1405
|
+
this.ajaxController?.abort();
|
|
1406
|
+
const controller = new AbortController();
|
|
1407
|
+
this.ajaxController = controller;
|
|
792
1408
|
const page = append ? this.page + 1 : 0;
|
|
793
1409
|
try {
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1410
|
+
let json;
|
|
1411
|
+
if (ajax.request) {
|
|
1412
|
+
json = await ajax.request(query, page, controller.signal);
|
|
1413
|
+
} else {
|
|
1414
|
+
const url = buildUrl(ajax, query, page);
|
|
1415
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1416
|
+
if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
|
|
1417
|
+
json = await response.json();
|
|
1418
|
+
}
|
|
1419
|
+
if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
|
|
1420
|
+
const { options, hasMore } = normalizeRemoteResult(ajax, json);
|
|
801
1421
|
if (append) {
|
|
802
1422
|
const existing = collectValues(this.data);
|
|
803
1423
|
this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
|
|
@@ -808,15 +1428,20 @@ var ForgeSelect = class {
|
|
|
808
1428
|
this.page = page;
|
|
809
1429
|
this.hasMore = hasMore;
|
|
810
1430
|
this.remoteLoaded = true;
|
|
811
|
-
|
|
812
|
-
|
|
1431
|
+
this.loadError = null;
|
|
1432
|
+
} catch (cause) {
|
|
1433
|
+
if (activeRequestId !== this.ajaxRequestId || this.destroyed || controller.signal.aborted) return;
|
|
1434
|
+
const error = cause instanceof Error ? cause : new Error(String(cause));
|
|
813
1435
|
if (!append) {
|
|
814
1436
|
this.data = [];
|
|
815
1437
|
this.rowContentCache.clear();
|
|
816
1438
|
}
|
|
817
1439
|
this.hasMore = false;
|
|
1440
|
+
this.loadError = error;
|
|
1441
|
+
this.emitter.emit("error", error);
|
|
818
1442
|
} finally {
|
|
819
|
-
if (
|
|
1443
|
+
if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
|
|
1444
|
+
this.ajaxController = null;
|
|
820
1445
|
this.loading = false;
|
|
821
1446
|
this.loadingMore = false;
|
|
822
1447
|
if (this.isOpen) this.renderList();
|
|
@@ -824,51 +1449,6 @@ var ForgeSelect = class {
|
|
|
824
1449
|
}
|
|
825
1450
|
}
|
|
826
1451
|
};
|
|
827
|
-
function parseNativeOptions(select) {
|
|
828
|
-
const data = [];
|
|
829
|
-
for (const child of Array.from(select.children)) {
|
|
830
|
-
if (child instanceof HTMLOptGroupElement) {
|
|
831
|
-
data.push({
|
|
832
|
-
label: child.label,
|
|
833
|
-
options: Array.from(child.querySelectorAll("option")).map(parseOption)
|
|
834
|
-
});
|
|
835
|
-
} else if (child instanceof HTMLOptionElement) {
|
|
836
|
-
data.push(parseOption(child));
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
return data;
|
|
840
|
-
}
|
|
841
|
-
function parseOption(option) {
|
|
842
|
-
return {
|
|
843
|
-
value: option.value,
|
|
844
|
-
label: option.textContent?.trim() ?? option.value,
|
|
845
|
-
disabled: option.disabled || void 0
|
|
846
|
-
};
|
|
847
|
-
}
|
|
848
|
-
function buildUrl(ajax, query, page) {
|
|
849
|
-
if (typeof ajax.url === "function") return ajax.url(query);
|
|
850
|
-
if (!ajax.params) return ajax.url;
|
|
851
|
-
const params = new URLSearchParams();
|
|
852
|
-
for (const [key, value] of Object.entries(ajax.params(query, page))) {
|
|
853
|
-
params.set(key, String(value));
|
|
854
|
-
}
|
|
855
|
-
const separator = ajax.url.includes("?") ? "&" : "?";
|
|
856
|
-
return `${ajax.url}${separator}${params.toString()}`;
|
|
857
|
-
}
|
|
858
|
-
function collectValues(items) {
|
|
859
|
-
const values = /* @__PURE__ */ new Set();
|
|
860
|
-
for (const item of items) {
|
|
861
|
-
if (isGroup(item)) {
|
|
862
|
-
for (const option of item.options) values.add(option.value);
|
|
863
|
-
} else {
|
|
864
|
-
values.add(item.value);
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
return values;
|
|
868
|
-
}
|
|
869
|
-
function arraysEqual(a, b) {
|
|
870
|
-
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
871
|
-
}
|
|
872
1452
|
export {
|
|
873
1453
|
ForgeSelect,
|
|
874
1454
|
ForgeSelect as default
|