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