forge-select 0.3.0 → 0.5.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 +24 -15
- package/dist/index.cjs +667 -63
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +178 -9
- package/dist/index.d.ts +178 -9
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +667 -63
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/styles/forge-select.css +37 -1
package/dist/index.js
CHANGED
|
@@ -24,6 +24,17 @@ 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: {
|
|
@@ -35,7 +46,9 @@ var locales = {
|
|
|
35
46
|
clearSelection: "Clear selection",
|
|
36
47
|
removeItem: "Remove {label}",
|
|
37
48
|
search: "Search",
|
|
38
|
-
reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
|
|
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"
|
|
39
52
|
},
|
|
40
53
|
vi: {
|
|
41
54
|
noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
|
|
@@ -46,7 +59,9 @@ var locales = {
|
|
|
46
59
|
clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
|
|
47
60
|
removeItem: "X\xF3a {label}",
|
|
48
61
|
search: "T\xECm ki\u1EBFm",
|
|
49
|
-
reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i."
|
|
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"
|
|
50
65
|
}
|
|
51
66
|
};
|
|
52
67
|
function getStrings(language) {
|
|
@@ -122,6 +137,7 @@ function renderOptionContent(container, option, template, variant = "row") {
|
|
|
122
137
|
|
|
123
138
|
// src/remote.ts
|
|
124
139
|
function buildUrl(ajax, query, page) {
|
|
140
|
+
if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
|
|
125
141
|
if (typeof ajax.url === "function") return ajax.url(query, page);
|
|
126
142
|
if (!ajax.params) return ajax.url;
|
|
127
143
|
const params = new URLSearchParams();
|
|
@@ -140,22 +156,106 @@ function normalizeRemoteResult(ajax, response) {
|
|
|
140
156
|
return { options: result.options, hasMore: ajax.pagination ? Boolean(result.hasMore) : false };
|
|
141
157
|
}
|
|
142
158
|
|
|
159
|
+
// src/remote-cache.ts
|
|
160
|
+
var RemoteCache = class {
|
|
161
|
+
constructor() {
|
|
162
|
+
this.entries = /* @__PURE__ */ new Map();
|
|
163
|
+
}
|
|
164
|
+
get(key, now = Date.now()) {
|
|
165
|
+
const entry = this.entries.get(key);
|
|
166
|
+
if (!entry) return void 0;
|
|
167
|
+
if (entry.expiresAt <= now) {
|
|
168
|
+
this.entries.delete(key);
|
|
169
|
+
return void 0;
|
|
170
|
+
}
|
|
171
|
+
return entry.value;
|
|
172
|
+
}
|
|
173
|
+
set(key, value, ttl, now = Date.now()) {
|
|
174
|
+
if (ttl > 0) this.entries.set(key, { value, expiresAt: now + ttl });
|
|
175
|
+
}
|
|
176
|
+
clear() {
|
|
177
|
+
this.entries.clear();
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// src/search.ts
|
|
182
|
+
function normalizeSearchText(value, accentInsensitive = true) {
|
|
183
|
+
const lower = value.toLocaleLowerCase();
|
|
184
|
+
return accentInsensitive ? lower.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/đ/g, "d") : lower;
|
|
185
|
+
}
|
|
186
|
+
function getSearchField(option, field) {
|
|
187
|
+
if (field === "label") return option.label;
|
|
188
|
+
if (field === "description") return option.description ?? "";
|
|
189
|
+
const path = field.slice(5).split(".");
|
|
190
|
+
let value = option.meta;
|
|
191
|
+
for (const key of path) {
|
|
192
|
+
if (!value || typeof value !== "object") return "";
|
|
193
|
+
value = value[key];
|
|
194
|
+
}
|
|
195
|
+
return value == null ? "" : String(value);
|
|
196
|
+
}
|
|
197
|
+
var SearchIndex = class {
|
|
198
|
+
constructor() {
|
|
199
|
+
this.cache = /* @__PURE__ */ new WeakMap();
|
|
200
|
+
}
|
|
201
|
+
clear() {
|
|
202
|
+
this.cache = /* @__PURE__ */ new WeakMap();
|
|
203
|
+
}
|
|
204
|
+
score(option, query, config) {
|
|
205
|
+
const normalizedQuery = normalizeSearchText(query.trim(), config.accentInsensitive);
|
|
206
|
+
if (!normalizedQuery) return 1;
|
|
207
|
+
if (config.scorer) return config.scorer(option, query.trim(), normalizedQuery);
|
|
208
|
+
const key = `${config.accentInsensitive ? "1" : "0"}:${config.fields.join("\0")}`;
|
|
209
|
+
let variants = this.cache.get(option);
|
|
210
|
+
if (!variants) {
|
|
211
|
+
variants = /* @__PURE__ */ new Map();
|
|
212
|
+
this.cache.set(option, variants);
|
|
213
|
+
}
|
|
214
|
+
let haystacks = variants.get(key);
|
|
215
|
+
if (!haystacks) {
|
|
216
|
+
haystacks = config.fields.map(
|
|
217
|
+
(field) => normalizeSearchText(getSearchField(option, field), config.accentInsensitive)
|
|
218
|
+
);
|
|
219
|
+
variants.set(key, haystacks);
|
|
220
|
+
}
|
|
221
|
+
const tokens = config.tokenSearch ? normalizedQuery.split(/\s+/).filter(Boolean) : [normalizedQuery];
|
|
222
|
+
if (!tokens.every((token) => haystacks.some((field) => field.includes(token)))) return 0;
|
|
223
|
+
const label = haystacks[config.fields.indexOf("label")];
|
|
224
|
+
if (label === normalizedQuery) return 4;
|
|
225
|
+
if (label?.startsWith(normalizedQuery)) return 3;
|
|
226
|
+
if (label?.includes(normalizedQuery)) return 2;
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
function findNormalizedRanges(label, query, accentInsensitive = true) {
|
|
231
|
+
const tokens = normalizeSearchText(query.trim(), accentInsensitive).split(/\s+/).filter(Boolean);
|
|
232
|
+
if (!tokens.length) return [];
|
|
233
|
+
const normalized = normalizeSearchText(label, accentInsensitive);
|
|
234
|
+
const ranges = [];
|
|
235
|
+
for (const token of tokens) {
|
|
236
|
+
const index = normalized.indexOf(token);
|
|
237
|
+
if (index >= 0) ranges.push([index, index + token.length]);
|
|
238
|
+
}
|
|
239
|
+
return ranges.sort((a, b) => a[0] - b[0]);
|
|
240
|
+
}
|
|
241
|
+
|
|
143
242
|
// src/selection.ts
|
|
144
243
|
function isGroup(item) {
|
|
145
244
|
return item.options !== void 0;
|
|
146
245
|
}
|
|
147
|
-
|
|
246
|
+
var defaultIsDisabled = (option) => !!option.disabled;
|
|
247
|
+
function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
|
|
148
248
|
if (!option.children) return [];
|
|
149
249
|
const values = [];
|
|
150
250
|
for (const child of option.children) {
|
|
151
|
-
if (!child
|
|
152
|
-
values.push(...collectDescendantValues(child));
|
|
251
|
+
if (!isDisabled(child)) values.push(child.value);
|
|
252
|
+
values.push(...collectDescendantValues(child, isDisabled));
|
|
153
253
|
}
|
|
154
254
|
return values;
|
|
155
255
|
}
|
|
156
|
-
function computeCheckState(option, selected) {
|
|
256
|
+
function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
|
|
157
257
|
if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
|
|
158
|
-
const states = option.children.filter((child) => !child
|
|
258
|
+
const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
|
|
159
259
|
if (states.length === 0) return "none";
|
|
160
260
|
if (states.every((state) => state === "all")) return "all";
|
|
161
261
|
if (states.every((state) => state === "none")) return "none";
|
|
@@ -176,11 +276,11 @@ function findOption(items, value) {
|
|
|
176
276
|
}
|
|
177
277
|
return void 0;
|
|
178
278
|
}
|
|
179
|
-
function syncTreeAncestors(items, selected) {
|
|
279
|
+
function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
|
|
180
280
|
const sync = (option) => {
|
|
181
281
|
if (!option.children?.length) return;
|
|
182
282
|
for (const child of option.children) sync(child);
|
|
183
|
-
const state = computeCheckState(option, selected);
|
|
283
|
+
const state = computeCheckState(option, selected, isDisabled);
|
|
184
284
|
const index = selected.indexOf(option.value);
|
|
185
285
|
if (state === "all" && index === -1) selected.push(option.value);
|
|
186
286
|
else if (state !== "all" && index !== -1) selected.splice(index, 1);
|
|
@@ -214,6 +314,7 @@ var ForgeSelect = class {
|
|
|
214
314
|
this.emitter = new Emitter();
|
|
215
315
|
this.uid = `forge-select-${++uidCounter}`;
|
|
216
316
|
this.searchInput = null;
|
|
317
|
+
this.portalHost = null;
|
|
217
318
|
this.isOpen = false;
|
|
218
319
|
this.isDisabled = false;
|
|
219
320
|
this.destroyed = false;
|
|
@@ -222,6 +323,8 @@ var ForgeSelect = class {
|
|
|
222
323
|
this.navItems = [];
|
|
223
324
|
this.highlightedIndex = -1;
|
|
224
325
|
this.rowContentCache = /* @__PURE__ */ new Map();
|
|
326
|
+
this.rowHeightCache = /* @__PURE__ */ new Map();
|
|
327
|
+
this.searchIndex = new SearchIndex();
|
|
225
328
|
this.expandedValues = /* @__PURE__ */ new Set();
|
|
226
329
|
this.loading = false;
|
|
227
330
|
this.loadingMore = false;
|
|
@@ -231,14 +334,33 @@ var ForgeSelect = class {
|
|
|
231
334
|
this.ajaxRequestId = 0;
|
|
232
335
|
this.ajaxController = null;
|
|
233
336
|
this.remoteLoaded = false;
|
|
337
|
+
this.remoteCache = new RemoteCache();
|
|
234
338
|
this.loadError = null;
|
|
235
339
|
this.originalDisplay = "";
|
|
236
340
|
this.originalDisabled = false;
|
|
237
341
|
this.nativeSelect = null;
|
|
238
342
|
this.nativeForm = null;
|
|
239
343
|
this.syncingNative = false;
|
|
344
|
+
/** Combines the static `disabled` field with the dynamic `isOptionDisabled` callback. */
|
|
345
|
+
this.isOptionDisabled = (option) => option.disabled === true || (this.opts.isOptionDisabled?.(option) ?? false);
|
|
346
|
+
this.pointerDownOnControl = false;
|
|
240
347
|
this.onDocumentMouseDown = (event) => {
|
|
241
|
-
|
|
348
|
+
const target = event.target;
|
|
349
|
+
if (!this.root.contains(target) && !this.portalHost?.contains(target)) this.close();
|
|
350
|
+
};
|
|
351
|
+
this.onWindowResize = () => {
|
|
352
|
+
this.positionDropdown();
|
|
353
|
+
};
|
|
354
|
+
this.onAncestorScroll = () => {
|
|
355
|
+
if (this.portalHost) this.positionDropdown();
|
|
356
|
+
};
|
|
357
|
+
this.onNativeInvalid = (event) => {
|
|
358
|
+
event.preventDefault();
|
|
359
|
+
this.control.classList.add("forge-select__control--invalid");
|
|
360
|
+
this.control.setAttribute("aria-invalid", "true");
|
|
361
|
+
if (!this.isOpen) this.open();
|
|
362
|
+
this.control.focus();
|
|
363
|
+
this.emitter.emit("invalid", this.nativeSelect?.validationMessage ?? "");
|
|
242
364
|
};
|
|
243
365
|
this.onNativeChange = () => {
|
|
244
366
|
if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
|
|
@@ -267,19 +389,35 @@ var ForgeSelect = class {
|
|
|
267
389
|
clearable: options.clearable ?? false,
|
|
268
390
|
allowCreate: options.allowCreate ?? false,
|
|
269
391
|
sortable: options.sortable ?? false,
|
|
392
|
+
closeOnSelect: options.closeOnSelect ?? false,
|
|
393
|
+
maxSelections: options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections)),
|
|
270
394
|
theme: options.theme ?? "default",
|
|
271
395
|
disabled: options.disabled ?? nativeSelect?.disabled ?? false,
|
|
396
|
+
required: options.required ?? nativeSelect?.required ?? false,
|
|
272
397
|
data: options.data,
|
|
273
398
|
ajax: options.ajax,
|
|
274
399
|
templateResult: options.templateResult,
|
|
275
400
|
templateSelection: options.templateSelection,
|
|
401
|
+
filterOption: options.filterOption,
|
|
402
|
+
searchFields: options.searchFields ?? ["label", "description"],
|
|
403
|
+
tokenSearch: options.tokenSearch ?? true,
|
|
404
|
+
accentInsensitive: options.accentInsensitive ?? true,
|
|
405
|
+
searchScorer: options.searchScorer,
|
|
406
|
+
highlightSearch: options.highlightSearch ?? false,
|
|
407
|
+
minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
|
|
408
|
+
minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
|
|
409
|
+
isOptionDisabled: options.isOptionDisabled,
|
|
276
410
|
virtualScroll: options.virtualScroll,
|
|
277
|
-
itemHeight: options.itemHeight
|
|
411
|
+
itemHeight: typeof options.itemHeight === "number" ? Math.max(1, options.itemHeight) : DEFAULT_ITEM_HEIGHT,
|
|
412
|
+
variableItemHeight: options.itemHeight === "auto",
|
|
278
413
|
language: options.language ?? "en",
|
|
279
|
-
plugins: options.plugins ?? []
|
|
414
|
+
plugins: options.plugins ?? [],
|
|
415
|
+
openOnFocus: options.openOnFocus ?? false,
|
|
416
|
+
dropdownParent: options.dropdownParent
|
|
280
417
|
};
|
|
281
418
|
this.strings = getStrings(this.opts.language);
|
|
282
419
|
this.plugins = this.opts.plugins;
|
|
420
|
+
if (nativeSelect) nativeSelect.required = this.opts.required;
|
|
283
421
|
this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
|
|
284
422
|
if (nativeSelect && !this.opts.data) {
|
|
285
423
|
const nativeOptions = Array.from(nativeSelect.options);
|
|
@@ -292,8 +430,10 @@ var ForgeSelect = class {
|
|
|
292
430
|
this.renderValue();
|
|
293
431
|
if (this.opts.disabled) this.disable();
|
|
294
432
|
nativeSelect?.addEventListener("change", this.onNativeChange);
|
|
433
|
+
nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
|
|
295
434
|
this.nativeForm?.addEventListener("reset", this.onFormReset);
|
|
296
435
|
for (const plugin of this.plugins) plugin.onInit?.(this);
|
|
436
|
+
for (const query of this.opts.ajax?.prefetch ?? []) void this.prefetchRemote(query);
|
|
297
437
|
}
|
|
298
438
|
applyNativeValues(values) {
|
|
299
439
|
this.selected = [];
|
|
@@ -310,11 +450,14 @@ var ForgeSelect = class {
|
|
|
310
450
|
this.root.classList.add("forge-select--open");
|
|
311
451
|
this.control.setAttribute("aria-expanded", "true");
|
|
312
452
|
document.addEventListener("mousedown", this.onDocumentMouseDown);
|
|
313
|
-
if (this.opts.ajax && !this.remoteLoaded) {
|
|
453
|
+
if (this.opts.ajax && (this.opts.ajax.loadOnOpen ?? true) && !this.remoteLoaded) {
|
|
314
454
|
this.scheduleRemoteLoad(this.query, 0);
|
|
315
455
|
}
|
|
316
456
|
this.renderList();
|
|
317
|
-
|
|
457
|
+
this.positionDropdown();
|
|
458
|
+
window.addEventListener("resize", this.onWindowResize);
|
|
459
|
+
document.addEventListener("scroll", this.onAncestorScroll, true);
|
|
460
|
+
if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
|
|
318
461
|
this.emitter.emit("open");
|
|
319
462
|
for (const plugin of this.plugins) plugin.onOpen?.(this);
|
|
320
463
|
}
|
|
@@ -323,8 +466,11 @@ var ForgeSelect = class {
|
|
|
323
466
|
this.isOpen = false;
|
|
324
467
|
this.dropdown.hidden = true;
|
|
325
468
|
this.root.classList.remove("forge-select--open");
|
|
469
|
+
this.root.classList.remove("forge-select--drop-up");
|
|
326
470
|
this.control.setAttribute("aria-expanded", "false");
|
|
327
471
|
document.removeEventListener("mousedown", this.onDocumentMouseDown);
|
|
472
|
+
window.removeEventListener("resize", this.onWindowResize);
|
|
473
|
+
document.removeEventListener("scroll", this.onAncestorScroll, true);
|
|
328
474
|
this.highlightedIndex = -1;
|
|
329
475
|
if (this.searchInput) {
|
|
330
476
|
this.searchInput.value = "";
|
|
@@ -333,6 +479,23 @@ var ForgeSelect = class {
|
|
|
333
479
|
this.emitter.emit("close");
|
|
334
480
|
for (const plugin of this.plugins) plugin.onClose?.(this);
|
|
335
481
|
}
|
|
482
|
+
/**
|
|
483
|
+
* Flips the dropdown above the control when there isn't enough room below
|
|
484
|
+
* but there is above. Recomputed on open() and on window resize — the
|
|
485
|
+
* dropdown is positioned absolutely inside the relatively-positioned root,
|
|
486
|
+
* so it already tracks the control correctly on page scroll without
|
|
487
|
+
* needing a scroll listener.
|
|
488
|
+
*/
|
|
489
|
+
positionDropdown() {
|
|
490
|
+
const controlRect = this.control.getBoundingClientRect();
|
|
491
|
+
const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
|
|
492
|
+
this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
|
|
493
|
+
if (this.portalHost) {
|
|
494
|
+
this.portalHost.style.top = `${placement.top}px`;
|
|
495
|
+
this.portalHost.style.left = `${controlRect.left}px`;
|
|
496
|
+
this.portalHost.style.width = `${controlRect.width}px`;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
336
499
|
destroy() {
|
|
337
500
|
if (this.destroyed) return;
|
|
338
501
|
this.close();
|
|
@@ -341,8 +504,12 @@ var ForgeSelect = class {
|
|
|
341
504
|
if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
|
|
342
505
|
this.ajaxController?.abort();
|
|
343
506
|
this.nativeSelect?.removeEventListener("change", this.onNativeChange);
|
|
507
|
+
this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
|
|
344
508
|
this.nativeForm?.removeEventListener("reset", this.onFormReset);
|
|
345
509
|
this.rowContentCache.clear();
|
|
510
|
+
this.rowHeightCache.clear();
|
|
511
|
+
this.searchIndex.clear();
|
|
512
|
+
this.portalHost?.remove();
|
|
346
513
|
this.root.remove();
|
|
347
514
|
this.el.style.display = this.originalDisplay;
|
|
348
515
|
if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
|
|
@@ -352,6 +519,106 @@ var ForgeSelect = class {
|
|
|
352
519
|
if (this.opts.multiple) return [...this.selected];
|
|
353
520
|
return this.selected[0] ?? null;
|
|
354
521
|
}
|
|
522
|
+
getSearchQuery() {
|
|
523
|
+
return this.query;
|
|
524
|
+
}
|
|
525
|
+
setSearchQuery(query, options = {}) {
|
|
526
|
+
this.applySearchQuery(query, options.emitSearch ?? true);
|
|
527
|
+
}
|
|
528
|
+
isDropdownOpen() {
|
|
529
|
+
return this.isOpen;
|
|
530
|
+
}
|
|
531
|
+
updateOptions(options) {
|
|
532
|
+
if (options.data) this.setData(options.data);
|
|
533
|
+
if ("ajax" in options && options.ajax !== this.opts.ajax) {
|
|
534
|
+
this.opts.ajax = options.ajax;
|
|
535
|
+
this.remoteLoaded = false;
|
|
536
|
+
this.clearRemoteCache();
|
|
537
|
+
}
|
|
538
|
+
if (options.placeholder !== void 0) this.opts.placeholder = options.placeholder;
|
|
539
|
+
if (options.clearable !== void 0) this.opts.clearable = options.clearable;
|
|
540
|
+
if (options.allowCreate !== void 0) this.opts.allowCreate = options.allowCreate;
|
|
541
|
+
if (options.sortable !== void 0) this.opts.sortable = options.sortable;
|
|
542
|
+
if (options.closeOnSelect !== void 0) this.opts.closeOnSelect = options.closeOnSelect;
|
|
543
|
+
if ("maxSelections" in options)
|
|
544
|
+
this.opts.maxSelections = options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections));
|
|
545
|
+
if (options.theme !== void 0) {
|
|
546
|
+
this.opts.theme = options.theme;
|
|
547
|
+
this.root.dataset.theme = options.theme;
|
|
548
|
+
if (this.portalHost) this.portalHost.dataset.theme = options.theme;
|
|
549
|
+
}
|
|
550
|
+
if (options.required !== void 0) {
|
|
551
|
+
this.opts.required = options.required;
|
|
552
|
+
if (options.required) this.control.setAttribute("aria-required", "true");
|
|
553
|
+
else this.control.removeAttribute("aria-required");
|
|
554
|
+
if (this.nativeSelect) this.nativeSelect.required = options.required;
|
|
555
|
+
}
|
|
556
|
+
if (options.templateResult !== void 0) this.opts.templateResult = options.templateResult;
|
|
557
|
+
if (options.templateSelection !== void 0) this.opts.templateSelection = options.templateSelection;
|
|
558
|
+
if (options.filterOption !== void 0) this.opts.filterOption = options.filterOption;
|
|
559
|
+
if (options.searchFields !== void 0) this.opts.searchFields = options.searchFields;
|
|
560
|
+
if (options.tokenSearch !== void 0) this.opts.tokenSearch = options.tokenSearch;
|
|
561
|
+
if (options.accentInsensitive !== void 0) this.opts.accentInsensitive = options.accentInsensitive;
|
|
562
|
+
if (options.searchScorer !== void 0) this.opts.searchScorer = options.searchScorer;
|
|
563
|
+
if (options.highlightSearch !== void 0) this.opts.highlightSearch = options.highlightSearch;
|
|
564
|
+
if (options.minSearchLength !== void 0)
|
|
565
|
+
this.opts.minSearchLength = Math.max(0, Math.floor(options.minSearchLength));
|
|
566
|
+
if (options.minResultsForSearch !== void 0)
|
|
567
|
+
this.opts.minResultsForSearch = Math.max(0, Math.floor(options.minResultsForSearch));
|
|
568
|
+
if (options.isOptionDisabled !== void 0) this.opts.isOptionDisabled = options.isOptionDisabled;
|
|
569
|
+
if (options.virtualScroll !== void 0) this.opts.virtualScroll = options.virtualScroll;
|
|
570
|
+
if (options.itemHeight !== void 0) {
|
|
571
|
+
this.opts.variableItemHeight = options.itemHeight === "auto";
|
|
572
|
+
if (typeof options.itemHeight === "number") this.opts.itemHeight = Math.max(1, options.itemHeight);
|
|
573
|
+
this.root.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
574
|
+
this.portalHost?.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
575
|
+
}
|
|
576
|
+
if (options.language !== void 0) {
|
|
577
|
+
this.opts.language = options.language;
|
|
578
|
+
this.strings = getStrings(options.language);
|
|
579
|
+
this.clearBtn.setAttribute("aria-label", this.strings.clearSelection);
|
|
580
|
+
this.searchInput?.setAttribute("aria-label", this.strings.search);
|
|
581
|
+
}
|
|
582
|
+
if (options.openOnFocus !== void 0) this.opts.openOnFocus = options.openOnFocus;
|
|
583
|
+
if (options.disabled !== void 0) {
|
|
584
|
+
if (options.disabled) this.disable();
|
|
585
|
+
else this.enable();
|
|
586
|
+
}
|
|
587
|
+
this.root.classList.toggle("forge-select--sortable", this.opts.sortable && this.opts.multiple);
|
|
588
|
+
this.updateSearchVisibility();
|
|
589
|
+
this.rowContentCache.clear();
|
|
590
|
+
this.searchIndex.clear();
|
|
591
|
+
this.renderValue();
|
|
592
|
+
if (this.isOpen) this.renderList();
|
|
593
|
+
}
|
|
594
|
+
validate() {
|
|
595
|
+
const valid = (!this.opts.required || this.selected.length > 0) && (this.control.dataset.validationMessage ?? "") === "";
|
|
596
|
+
this.control.classList.toggle("forge-select__control--invalid", !valid);
|
|
597
|
+
this.control.setAttribute("aria-invalid", String(!valid));
|
|
598
|
+
return valid;
|
|
599
|
+
}
|
|
600
|
+
setCustomValidity(message) {
|
|
601
|
+
this.nativeSelect?.setCustomValidity(message);
|
|
602
|
+
this.control.dataset.validationMessage = message;
|
|
603
|
+
}
|
|
604
|
+
reportValidity() {
|
|
605
|
+
const valid = this.validate() && (this.nativeSelect?.checkValidity() ?? true);
|
|
606
|
+
if (!valid) {
|
|
607
|
+
const message = this.nativeSelect?.validationMessage ?? this.control.dataset.validationMessage ?? "";
|
|
608
|
+
if (this.nativeSelect) return this.nativeSelect.reportValidity();
|
|
609
|
+
this.emitter.emit("invalid", message);
|
|
610
|
+
}
|
|
611
|
+
return valid;
|
|
612
|
+
}
|
|
613
|
+
reload() {
|
|
614
|
+
if (!this.opts.ajax) return;
|
|
615
|
+
this.clearRemoteCache();
|
|
616
|
+
this.remoteLoaded = false;
|
|
617
|
+
this.scheduleRemoteLoad(this.query, 0);
|
|
618
|
+
}
|
|
619
|
+
clearRemoteCache() {
|
|
620
|
+
this.remoteCache.clear();
|
|
621
|
+
}
|
|
355
622
|
setValue(value, options = {}) {
|
|
356
623
|
const values = value == null ? [] : Array.isArray(value) ? value : [value];
|
|
357
624
|
const next = this.opts.multiple ? values : values.slice(0, 1);
|
|
@@ -360,6 +627,53 @@ var ForgeSelect = class {
|
|
|
360
627
|
for (const v of next) this.selectValue(v, false);
|
|
361
628
|
this.afterSelectionChange(options.emitChange ?? true);
|
|
362
629
|
}
|
|
630
|
+
/**
|
|
631
|
+
* Replaces the option list after construction. An open dropdown re-renders
|
|
632
|
+
* immediately; a selection whose value isn't in the new data stays
|
|
633
|
+
* selected (rendered via the already-selected option's own label/avatar,
|
|
634
|
+
* the same fallback used for values selected from a stale ajax page).
|
|
635
|
+
*/
|
|
636
|
+
setData(data) {
|
|
637
|
+
if (this.ajaxTimer) {
|
|
638
|
+
clearTimeout(this.ajaxTimer);
|
|
639
|
+
this.ajaxTimer = null;
|
|
640
|
+
}
|
|
641
|
+
this.ajaxController?.abort();
|
|
642
|
+
this.ajaxController = null;
|
|
643
|
+
this.ajaxRequestId += 1;
|
|
644
|
+
this.setLoading(false);
|
|
645
|
+
this.loadingMore = false;
|
|
646
|
+
this.loadError = null;
|
|
647
|
+
this.remoteLoaded = true;
|
|
648
|
+
this.page = 0;
|
|
649
|
+
this.hasMore = false;
|
|
650
|
+
this.data = data;
|
|
651
|
+
this.opts.data = data;
|
|
652
|
+
this.updateSearchVisibility();
|
|
653
|
+
this.rowContentCache.clear();
|
|
654
|
+
this.searchIndex.clear();
|
|
655
|
+
this.highlightedIndex = -1;
|
|
656
|
+
if (this.isOpen) this.renderList();
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Multi-select only: selects every currently non-disabled option, including
|
|
660
|
+
* nested tree descendants and options inside groups. If `maxSelections` is
|
|
661
|
+
* set, stops once the cap is reached rather than exceeding it. A no-op for
|
|
662
|
+
* single-select.
|
|
663
|
+
*/
|
|
664
|
+
selectAll() {
|
|
665
|
+
if (!this.opts.multiple) return;
|
|
666
|
+
this.selected = [];
|
|
667
|
+
for (const value of this.allSelectableValues()) {
|
|
668
|
+
const option = this.findOption(value);
|
|
669
|
+
if (option && this.canSelectOption(option)) this.selectValue(value, false);
|
|
670
|
+
}
|
|
671
|
+
this.afterSelectionChange();
|
|
672
|
+
}
|
|
673
|
+
/** Clears every selection. Equivalent to `setValue(null)`. */
|
|
674
|
+
clearAll() {
|
|
675
|
+
this.clearSelection();
|
|
676
|
+
}
|
|
363
677
|
enable() {
|
|
364
678
|
this.isDisabled = false;
|
|
365
679
|
this.root.classList.remove("forge-select--disabled");
|
|
@@ -405,7 +719,22 @@ var ForgeSelect = class {
|
|
|
405
719
|
}
|
|
406
720
|
}
|
|
407
721
|
}
|
|
722
|
+
shouldShowSearch() {
|
|
723
|
+
return this.opts.searchable && (this.opts.ajax != null || collectValues(this.data).size >= this.opts.minResultsForSearch);
|
|
724
|
+
}
|
|
725
|
+
updateSearchVisibility() {
|
|
726
|
+
if (!this.searchInput) return;
|
|
727
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
728
|
+
if (this.searchInput.hidden) {
|
|
729
|
+
this.searchInput.value = "";
|
|
730
|
+
this.query = "";
|
|
731
|
+
}
|
|
732
|
+
}
|
|
408
733
|
buildDom() {
|
|
734
|
+
const portalParent = typeof this.opts.dropdownParent === "string" ? document.querySelector(this.opts.dropdownParent) : this.opts.dropdownParent;
|
|
735
|
+
if (this.opts.dropdownParent && !portalParent) {
|
|
736
|
+
throw new Error(`ForgeSelect: dropdown parent not found: ${String(this.opts.dropdownParent)}`);
|
|
737
|
+
}
|
|
409
738
|
this.root = document.createElement("div");
|
|
410
739
|
this.root.className = "forge-select";
|
|
411
740
|
this.root.dataset.theme = this.opts.theme;
|
|
@@ -417,6 +746,7 @@ var ForgeSelect = class {
|
|
|
417
746
|
this.control.setAttribute("aria-haspopup", "listbox");
|
|
418
747
|
this.control.setAttribute("aria-expanded", "false");
|
|
419
748
|
this.control.setAttribute("aria-controls", `${this.uid}-list`);
|
|
749
|
+
if (this.opts.required) this.control.setAttribute("aria-required", "true");
|
|
420
750
|
this.control.tabIndex = 0;
|
|
421
751
|
this.applyAccessibleName();
|
|
422
752
|
this.valueEl = document.createElement("div");
|
|
@@ -441,6 +771,7 @@ var ForgeSelect = class {
|
|
|
441
771
|
this.searchInput.setAttribute("aria-label", this.strings.search);
|
|
442
772
|
this.searchInput.setAttribute("aria-autocomplete", "list");
|
|
443
773
|
this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
|
|
774
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
444
775
|
this.dropdown.append(this.searchInput);
|
|
445
776
|
}
|
|
446
777
|
this.list = document.createElement("ul");
|
|
@@ -453,9 +784,18 @@ var ForgeSelect = class {
|
|
|
453
784
|
this.liveRegion.className = "forge-select__sr-only";
|
|
454
785
|
this.liveRegion.setAttribute("role", "status");
|
|
455
786
|
this.liveRegion.setAttribute("aria-live", "polite");
|
|
456
|
-
this.root.append(this.control, this.
|
|
787
|
+
this.root.append(this.control, this.liveRegion);
|
|
788
|
+
if (!portalParent) this.root.append(this.dropdown);
|
|
457
789
|
this.el.style.display = "none";
|
|
458
790
|
this.el.insertAdjacentElement("afterend", this.root);
|
|
791
|
+
if (portalParent) {
|
|
792
|
+
this.portalHost = document.createElement("div");
|
|
793
|
+
this.portalHost.className = "forge-select forge-select--portal-host";
|
|
794
|
+
this.portalHost.dataset.theme = this.opts.theme;
|
|
795
|
+
this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
796
|
+
this.portalHost.append(this.dropdown);
|
|
797
|
+
portalParent.append(this.portalHost);
|
|
798
|
+
}
|
|
459
799
|
this.bindEvents();
|
|
460
800
|
}
|
|
461
801
|
bindEvents() {
|
|
@@ -470,23 +810,46 @@ var ForgeSelect = class {
|
|
|
470
810
|
else this.open();
|
|
471
811
|
});
|
|
472
812
|
this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
813
|
+
this.control.addEventListener("mousedown", () => {
|
|
814
|
+
this.pointerDownOnControl = true;
|
|
815
|
+
});
|
|
816
|
+
this.control.addEventListener("focus", () => {
|
|
817
|
+
if (this.opts.openOnFocus && !this.pointerDownOnControl && !this.isOpen && !this.isDisabled) {
|
|
818
|
+
this.open();
|
|
819
|
+
}
|
|
820
|
+
this.pointerDownOnControl = false;
|
|
821
|
+
});
|
|
473
822
|
this.clearBtn.addEventListener("click", (event) => {
|
|
474
823
|
event.stopPropagation();
|
|
475
824
|
this.clearSelection();
|
|
476
825
|
});
|
|
477
826
|
if (this.searchInput) {
|
|
478
827
|
this.searchInput.addEventListener("input", () => {
|
|
479
|
-
this.
|
|
480
|
-
this.highlightedIndex = -1;
|
|
481
|
-
this.list.scrollTop = 0;
|
|
482
|
-
this.emitter.emit("search", this.query);
|
|
483
|
-
if (this.opts.ajax) {
|
|
484
|
-
this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
|
|
485
|
-
} else {
|
|
486
|
-
this.renderList();
|
|
487
|
-
}
|
|
828
|
+
this.applySearchQuery(this.searchInput.value, true);
|
|
488
829
|
});
|
|
489
830
|
this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
831
|
+
this.searchInput.addEventListener("paste", (event) => {
|
|
832
|
+
if (!this.opts.multiple || !this.opts.allowCreate) return;
|
|
833
|
+
const text = event.clipboardData?.getData("text") ?? "";
|
|
834
|
+
const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
|
|
835
|
+
if (labels.length < 2) return;
|
|
836
|
+
event.preventDefault();
|
|
837
|
+
const created = [];
|
|
838
|
+
for (const label of labels) {
|
|
839
|
+
const result = this.createTag(label);
|
|
840
|
+
if (result) created.push(result);
|
|
841
|
+
}
|
|
842
|
+
if (created.length === 0) return;
|
|
843
|
+
this.searchInput.value = "";
|
|
844
|
+
this.query = "";
|
|
845
|
+
this.afterSelectionChange();
|
|
846
|
+
for (const result of created) {
|
|
847
|
+
if (result.created) this.emitter.emit("create", result.option);
|
|
848
|
+
this.emitter.emit("select", result.option);
|
|
849
|
+
}
|
|
850
|
+
if (this.opts.closeOnSelect) this.close();
|
|
851
|
+
else this.renderList();
|
|
852
|
+
});
|
|
490
853
|
}
|
|
491
854
|
this.list.addEventListener("click", (event) => {
|
|
492
855
|
const target = event.target;
|
|
@@ -499,7 +862,12 @@ var ForgeSelect = class {
|
|
|
499
862
|
return;
|
|
500
863
|
}
|
|
501
864
|
const li = target.closest("li[data-nav-index]");
|
|
502
|
-
if (!li)
|
|
865
|
+
if (!li) {
|
|
866
|
+
const optionRow = target.closest("li[data-option-value]");
|
|
867
|
+
const option = optionRow ? this.findOption(optionRow.dataset.optionValue) : void 0;
|
|
868
|
+
if (option && this.hasReachedMaximum() && !this.selected.includes(option.value)) this.announceMaximum(option);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
503
871
|
const navIndex = Number(li.dataset.navIndex);
|
|
504
872
|
this.activateNavItem(navIndex);
|
|
505
873
|
});
|
|
@@ -508,6 +876,29 @@ var ForgeSelect = class {
|
|
|
508
876
|
this.maybeLoadNextPage();
|
|
509
877
|
});
|
|
510
878
|
}
|
|
879
|
+
applySearchQuery(query, emitSearch) {
|
|
880
|
+
this.query = query;
|
|
881
|
+
if (this.searchInput && this.searchInput.value !== query) this.searchInput.value = query;
|
|
882
|
+
this.highlightedIndex = -1;
|
|
883
|
+
this.list.scrollTop = 0;
|
|
884
|
+
this.rowContentCache.clear();
|
|
885
|
+
if (emitSearch) this.emitter.emit("search", query);
|
|
886
|
+
const trimmed = query.trim();
|
|
887
|
+
const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
|
|
888
|
+
if (this.opts.ajax && !belowMinLength) {
|
|
889
|
+
this.scheduleRemoteLoad(query, this.opts.ajax.debounce ?? 250);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
if (belowMinLength) {
|
|
893
|
+
if (this.ajaxTimer) {
|
|
894
|
+
clearTimeout(this.ajaxTimer);
|
|
895
|
+
this.ajaxTimer = null;
|
|
896
|
+
}
|
|
897
|
+
this.ajaxController?.abort();
|
|
898
|
+
this.setLoading(false);
|
|
899
|
+
}
|
|
900
|
+
this.renderList();
|
|
901
|
+
}
|
|
511
902
|
handleKeydown(event) {
|
|
512
903
|
if (this.isDisabled) return;
|
|
513
904
|
switch (event.key) {
|
|
@@ -550,36 +941,61 @@ var ForgeSelect = class {
|
|
|
550
941
|
}
|
|
551
942
|
}
|
|
552
943
|
// ---------------------------------------------------------------- selection
|
|
944
|
+
canSelectOption(option) {
|
|
945
|
+
if (this.opts.maxSelections == null) return true;
|
|
946
|
+
const projected = [...this.selected];
|
|
947
|
+
if (!projected.includes(option.value)) projected.push(option.value);
|
|
948
|
+
for (const value of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
949
|
+
if (!projected.includes(value)) projected.push(value);
|
|
950
|
+
}
|
|
951
|
+
syncTreeAncestors(this.data, projected, this.isOptionDisabled);
|
|
952
|
+
return projected.length <= this.opts.maxSelections;
|
|
953
|
+
}
|
|
954
|
+
hasReachedMaximum() {
|
|
955
|
+
return this.opts.maxSelections != null && this.selected.length >= this.opts.maxSelections;
|
|
956
|
+
}
|
|
957
|
+
announceMaximum(option) {
|
|
958
|
+
const limit = this.opts.maxSelections;
|
|
959
|
+
if (limit == null) return;
|
|
960
|
+
this.liveRegion.textContent = format(this.strings.maximumSelected, { count: String(limit) });
|
|
961
|
+
this.emitter.emit("maximum", { limit, option });
|
|
962
|
+
}
|
|
553
963
|
selectValue(value, notify) {
|
|
554
964
|
if (this.selected.includes(value)) return;
|
|
555
965
|
const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
|
|
556
966
|
this.selectedOptions.set(value, option);
|
|
557
967
|
if (this.opts.multiple) {
|
|
558
968
|
this.selected.push(value);
|
|
559
|
-
for (const v of collectDescendantValues(option)) {
|
|
969
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
560
970
|
if (!this.selected.includes(v)) this.selected.push(v);
|
|
561
971
|
}
|
|
562
972
|
this.syncTreeAncestors();
|
|
563
973
|
} else {
|
|
564
974
|
this.selected = [value];
|
|
565
975
|
}
|
|
566
|
-
if (notify)
|
|
976
|
+
if (notify) {
|
|
977
|
+
this.afterSelectionChange();
|
|
978
|
+
this.emitter.emit("select", option);
|
|
979
|
+
}
|
|
567
980
|
}
|
|
568
981
|
deselectValue(value, notify) {
|
|
569
982
|
const index = this.selected.indexOf(value);
|
|
570
983
|
if (index === -1) return;
|
|
984
|
+
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
571
985
|
this.selected.splice(index, 1);
|
|
572
986
|
if (this.opts.multiple) {
|
|
573
|
-
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
574
987
|
if (option) {
|
|
575
|
-
for (const v of collectDescendantValues(option)) {
|
|
988
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
576
989
|
const i = this.selected.indexOf(v);
|
|
577
990
|
if (i !== -1) this.selected.splice(i, 1);
|
|
578
991
|
}
|
|
579
992
|
}
|
|
580
993
|
this.syncTreeAncestors();
|
|
581
994
|
}
|
|
582
|
-
if (notify)
|
|
995
|
+
if (notify) {
|
|
996
|
+
this.afterSelectionChange();
|
|
997
|
+
this.emitter.emit("unselect", option ?? { value, label: value });
|
|
998
|
+
}
|
|
583
999
|
}
|
|
584
1000
|
/**
|
|
585
1001
|
* Keeps every tree parent's own membership in `selected` consistent with
|
|
@@ -588,7 +1004,7 @@ var ForgeSelect = class {
|
|
|
588
1004
|
* No-op for data with no `children` anywhere.
|
|
589
1005
|
*/
|
|
590
1006
|
syncTreeAncestors() {
|
|
591
|
-
syncTreeAncestors(this.data, this.selected);
|
|
1007
|
+
syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
|
|
592
1008
|
}
|
|
593
1009
|
clearSelection() {
|
|
594
1010
|
if (this.selected.length === 0) return;
|
|
@@ -596,9 +1012,22 @@ var ForgeSelect = class {
|
|
|
596
1012
|
this.emitter.emit("clear");
|
|
597
1013
|
this.afterSelectionChange();
|
|
598
1014
|
}
|
|
1015
|
+
allSelectableValues() {
|
|
1016
|
+
const values = [];
|
|
1017
|
+
const visit = (option) => {
|
|
1018
|
+
if (!this.isOptionDisabled(option)) values.push(option.value);
|
|
1019
|
+
option.children?.forEach(visit);
|
|
1020
|
+
};
|
|
1021
|
+
for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
|
|
1022
|
+
return values;
|
|
1023
|
+
}
|
|
599
1024
|
afterSelectionChange(emitChange = true) {
|
|
600
1025
|
this.renderValue();
|
|
601
1026
|
this.syncNativeSelect(emitChange);
|
|
1027
|
+
if (!this.opts.required || this.selected.length > 0) {
|
|
1028
|
+
this.control.classList.remove("forge-select__control--invalid");
|
|
1029
|
+
this.control.removeAttribute("aria-invalid");
|
|
1030
|
+
}
|
|
602
1031
|
if (this.isOpen) this.renderList();
|
|
603
1032
|
if (emitChange) this.emitter.emit("change", this.getValue());
|
|
604
1033
|
}
|
|
@@ -634,17 +1063,58 @@ var ForgeSelect = class {
|
|
|
634
1063
|
findOption(value) {
|
|
635
1064
|
return findOption(this.data, value);
|
|
636
1065
|
}
|
|
1066
|
+
findOptionByLabel(label) {
|
|
1067
|
+
const lower = label.toLowerCase();
|
|
1068
|
+
const search = (options) => {
|
|
1069
|
+
for (const option of options) {
|
|
1070
|
+
if (option.label.toLowerCase() === lower) return option;
|
|
1071
|
+
const found = option.children ? search(option.children) : void 0;
|
|
1072
|
+
if (found) return found;
|
|
1073
|
+
}
|
|
1074
|
+
return void 0;
|
|
1075
|
+
};
|
|
1076
|
+
for (const item of this.data) {
|
|
1077
|
+
const found = search(isGroup(item) ? item.options : [item]);
|
|
1078
|
+
if (found) return found;
|
|
1079
|
+
}
|
|
1080
|
+
return void 0;
|
|
1081
|
+
}
|
|
1082
|
+
/** Selects an existing option matching `label` exactly, or creates and selects a new one. */
|
|
1083
|
+
createTag(label) {
|
|
1084
|
+
const trimmed = label.trim();
|
|
1085
|
+
if (!trimmed) return void 0;
|
|
1086
|
+
const existing = this.findOptionByLabel(trimmed);
|
|
1087
|
+
if (existing) {
|
|
1088
|
+
if (this.selected.includes(existing.value)) return void 0;
|
|
1089
|
+
if (this.opts.multiple && !this.canSelectOption(existing)) {
|
|
1090
|
+
this.announceMaximum(existing);
|
|
1091
|
+
return void 0;
|
|
1092
|
+
}
|
|
1093
|
+
this.selectValue(existing.value, false);
|
|
1094
|
+
return { option: existing, created: false };
|
|
1095
|
+
}
|
|
1096
|
+
const option = { value: trimmed, label: trimmed };
|
|
1097
|
+
if (this.opts.multiple && !this.canSelectOption(option)) {
|
|
1098
|
+
this.announceMaximum(option);
|
|
1099
|
+
return void 0;
|
|
1100
|
+
}
|
|
1101
|
+
this.data.push(option);
|
|
1102
|
+
this.selectValue(option.value, false);
|
|
1103
|
+
return { option, created: true };
|
|
1104
|
+
}
|
|
637
1105
|
createFromQuery() {
|
|
638
1106
|
const label = this.query.trim();
|
|
639
1107
|
if (!label) return;
|
|
640
|
-
const
|
|
641
|
-
|
|
1108
|
+
const result = this.createTag(label);
|
|
1109
|
+
if (!result) return;
|
|
642
1110
|
if (this.searchInput) {
|
|
643
1111
|
this.searchInput.value = "";
|
|
644
1112
|
this.query = "";
|
|
645
1113
|
}
|
|
646
|
-
this.
|
|
647
|
-
if (
|
|
1114
|
+
this.afterSelectionChange();
|
|
1115
|
+
if (result.created) this.emitter.emit("create", result.option);
|
|
1116
|
+
this.emitter.emit("select", result.option);
|
|
1117
|
+
if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
|
|
648
1118
|
}
|
|
649
1119
|
activateNavItem(navIndex) {
|
|
650
1120
|
const item = this.navItems[navIndex];
|
|
@@ -655,8 +1125,17 @@ var ForgeSelect = class {
|
|
|
655
1125
|
}
|
|
656
1126
|
const { value } = item.option;
|
|
657
1127
|
if (this.opts.multiple) {
|
|
658
|
-
|
|
659
|
-
|
|
1128
|
+
let changed = false;
|
|
1129
|
+
if (this.selected.includes(value)) {
|
|
1130
|
+
this.deselectValue(value, true);
|
|
1131
|
+
changed = true;
|
|
1132
|
+
} else if (this.canSelectOption(item.option)) {
|
|
1133
|
+
this.selectValue(value, true);
|
|
1134
|
+
changed = true;
|
|
1135
|
+
} else {
|
|
1136
|
+
this.announceMaximum(item.option);
|
|
1137
|
+
}
|
|
1138
|
+
if (changed && this.opts.closeOnSelect) this.close();
|
|
660
1139
|
} else {
|
|
661
1140
|
this.selectValue(value, true);
|
|
662
1141
|
this.close();
|
|
@@ -772,6 +1251,7 @@ var ForgeSelect = class {
|
|
|
772
1251
|
this.selected = order;
|
|
773
1252
|
this.suppressNextTagClick = true;
|
|
774
1253
|
this.afterSelectionChange();
|
|
1254
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
775
1255
|
};
|
|
776
1256
|
tag.addEventListener("pointerdown", (event) => {
|
|
777
1257
|
if (this.isDisabled || event.button !== 0) return;
|
|
@@ -795,6 +1275,7 @@ var ForgeSelect = class {
|
|
|
795
1275
|
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
|
796
1276
|
this.selected = next;
|
|
797
1277
|
this.afterSelectionChange();
|
|
1278
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
798
1279
|
this.focusTagByValue(value);
|
|
799
1280
|
}
|
|
800
1281
|
focusTagByValue(value) {
|
|
@@ -808,12 +1289,19 @@ var ForgeSelect = class {
|
|
|
808
1289
|
buildRows() {
|
|
809
1290
|
this.rows = [];
|
|
810
1291
|
this.navItems = [];
|
|
811
|
-
const
|
|
812
|
-
const
|
|
1292
|
+
const trimmedQuery = this.query.trim();
|
|
1293
|
+
const query = normalizeSearchText(trimmedQuery, this.opts.accentInsensitive);
|
|
1294
|
+
const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : this.searchIndex.score(option, trimmedQuery, {
|
|
1295
|
+
fields: this.opts.searchFields,
|
|
1296
|
+
tokenSearch: this.opts.tokenSearch,
|
|
1297
|
+
accentInsensitive: this.opts.accentInsensitive,
|
|
1298
|
+
scorer: this.opts.searchScorer
|
|
1299
|
+
}) > 0);
|
|
813
1300
|
const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
|
|
814
1301
|
const pushOption = (option, depth, parentValue) => {
|
|
815
1302
|
let navIndex = -1;
|
|
816
|
-
|
|
1303
|
+
const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
|
|
1304
|
+
if (!interactionDisabled) {
|
|
817
1305
|
navIndex = this.navItems.length;
|
|
818
1306
|
this.navItems.push({ kind: "option", option, parentValue });
|
|
819
1307
|
}
|
|
@@ -828,6 +1316,10 @@ var ForgeSelect = class {
|
|
|
828
1316
|
}
|
|
829
1317
|
}
|
|
830
1318
|
};
|
|
1319
|
+
if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
|
|
1320
|
+
this.rows.push({ kind: "min-length" });
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
831
1323
|
if (this.loading) {
|
|
832
1324
|
this.rows.push({ kind: "loading" });
|
|
833
1325
|
return;
|
|
@@ -855,16 +1347,30 @@ var ForgeSelect = class {
|
|
|
855
1347
|
else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
|
|
856
1348
|
}
|
|
857
1349
|
hasExactMatch(lowerQuery) {
|
|
858
|
-
|
|
859
|
-
for (const item of this.data) {
|
|
860
|
-
const options = isGroup(item) ? item.options : [item];
|
|
861
|
-
if (options.some(matchesExactly)) return true;
|
|
862
|
-
}
|
|
863
|
-
return false;
|
|
1350
|
+
return !!this.findOptionByLabel(lowerQuery);
|
|
864
1351
|
}
|
|
865
1352
|
usesVirtualScroll() {
|
|
866
1353
|
return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
|
|
867
1354
|
}
|
|
1355
|
+
rowKey(row, index) {
|
|
1356
|
+
if (row.kind === "option") return `option:${row.option.value}`;
|
|
1357
|
+
if (row.kind === "group") return `group:${row.label}:${index}`;
|
|
1358
|
+
return `${row.kind}:${index}`;
|
|
1359
|
+
}
|
|
1360
|
+
measuredRowHeight(index) {
|
|
1361
|
+
return this.opts.variableItemHeight ? this.rowHeightCache.get(this.rowKey(this.rows[index], index)) ?? this.opts.itemHeight : this.opts.itemHeight;
|
|
1362
|
+
}
|
|
1363
|
+
rowOffset(index) {
|
|
1364
|
+
if (!this.opts.variableItemHeight) return index * this.opts.itemHeight;
|
|
1365
|
+
let offset = 0;
|
|
1366
|
+
for (let i = 0; i < index; i += 1) offset += this.measuredRowHeight(i);
|
|
1367
|
+
return offset;
|
|
1368
|
+
}
|
|
1369
|
+
rowOffsets() {
|
|
1370
|
+
const offsets = [0];
|
|
1371
|
+
for (let i = 0; i < this.rows.length; i += 1) offsets.push(offsets[i] + this.measuredRowHeight(i));
|
|
1372
|
+
return offsets;
|
|
1373
|
+
}
|
|
868
1374
|
renderList() {
|
|
869
1375
|
this.buildRows();
|
|
870
1376
|
this.renderRows();
|
|
@@ -872,7 +1378,7 @@ var ForgeSelect = class {
|
|
|
872
1378
|
}
|
|
873
1379
|
announceStatus() {
|
|
874
1380
|
const first = this.rows[0];
|
|
875
|
-
const message = first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : "";
|
|
1381
|
+
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) }) : "";
|
|
876
1382
|
if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
|
|
877
1383
|
}
|
|
878
1384
|
renderRows() {
|
|
@@ -881,26 +1387,40 @@ var ForgeSelect = class {
|
|
|
881
1387
|
const virtual = this.usesVirtualScroll();
|
|
882
1388
|
this.list.textContent = "";
|
|
883
1389
|
const rowHeight = this.opts.itemHeight;
|
|
1390
|
+
const offsets = this.opts.variableItemHeight ? this.rowOffsets() : null;
|
|
884
1391
|
let start = 0;
|
|
885
1392
|
let end = this.rows.length;
|
|
886
1393
|
if (virtual) {
|
|
887
1394
|
const viewport = clientHeight || rowHeight * 8;
|
|
888
|
-
|
|
889
|
-
|
|
1395
|
+
if (this.opts.variableItemHeight) {
|
|
1396
|
+
while (start < this.rows.length && offsets[start + 1] < scrollTop) start += 1;
|
|
1397
|
+
start = Math.max(0, start - VIRTUAL_BUFFER);
|
|
1398
|
+
end = start;
|
|
1399
|
+
const target = scrollTop + viewport + VIRTUAL_BUFFER * rowHeight;
|
|
1400
|
+
while (end < this.rows.length && offsets[end] < target) end += 1;
|
|
1401
|
+
} else {
|
|
1402
|
+
start = Math.max(0, Math.floor(scrollTop / rowHeight) - VIRTUAL_BUFFER);
|
|
1403
|
+
end = Math.min(this.rows.length, start + Math.ceil(viewport / rowHeight) + VIRTUAL_BUFFER * 2);
|
|
1404
|
+
}
|
|
890
1405
|
const topSpacer = document.createElement("li");
|
|
891
1406
|
topSpacer.className = "forge-select__spacer";
|
|
892
1407
|
topSpacer.setAttribute("aria-hidden", "true");
|
|
893
|
-
topSpacer.style.height = `${start
|
|
1408
|
+
topSpacer.style.height = `${offsets?.[start] ?? this.rowOffset(start)}px`;
|
|
894
1409
|
this.list.append(topSpacer);
|
|
895
1410
|
}
|
|
896
1411
|
for (let i = start; i < end; i++) {
|
|
897
|
-
this.
|
|
1412
|
+
const element = this.renderRow(this.rows[i]);
|
|
1413
|
+
this.list.append(element);
|
|
1414
|
+
if (this.opts.variableItemHeight) {
|
|
1415
|
+
const measured = element.getBoundingClientRect().height || element.offsetHeight;
|
|
1416
|
+
if (measured > 0) this.rowHeightCache.set(this.rowKey(this.rows[i], i), measured);
|
|
1417
|
+
}
|
|
898
1418
|
}
|
|
899
1419
|
if (virtual) {
|
|
900
1420
|
const bottomSpacer = document.createElement("li");
|
|
901
1421
|
bottomSpacer.className = "forge-select__spacer";
|
|
902
1422
|
bottomSpacer.setAttribute("aria-hidden", "true");
|
|
903
|
-
bottomSpacer.style.height = `${
|
|
1423
|
+
bottomSpacer.style.height = `${offsets ? offsets[this.rows.length] - offsets[end] : this.rowOffset(this.rows.length) - this.rowOffset(end)}px`;
|
|
904
1424
|
this.list.append(bottomSpacer);
|
|
905
1425
|
if (this.list.scrollTop !== scrollTop) {
|
|
906
1426
|
this.list.scrollTop = scrollTop;
|
|
@@ -923,6 +1443,13 @@ var ForgeSelect = class {
|
|
|
923
1443
|
li.setAttribute("aria-selected", "false");
|
|
924
1444
|
li.textContent = this.strings.noResults;
|
|
925
1445
|
break;
|
|
1446
|
+
case "min-length":
|
|
1447
|
+
li.className = "forge-select__min-length";
|
|
1448
|
+
li.setAttribute("role", "option");
|
|
1449
|
+
li.setAttribute("aria-disabled", "true");
|
|
1450
|
+
li.setAttribute("aria-selected", "false");
|
|
1451
|
+
li.textContent = format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) });
|
|
1452
|
+
break;
|
|
926
1453
|
case "error":
|
|
927
1454
|
li.className = "forge-select__error";
|
|
928
1455
|
li.setAttribute("role", "option");
|
|
@@ -952,17 +1479,20 @@ var ForgeSelect = class {
|
|
|
952
1479
|
break;
|
|
953
1480
|
case "option": {
|
|
954
1481
|
li.className = "forge-select__option";
|
|
1482
|
+
li.dataset.optionValue = row.option.value;
|
|
1483
|
+
if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
|
|
955
1484
|
li.setAttribute("role", "option");
|
|
956
1485
|
const isSelected = this.selected.includes(row.option.value);
|
|
957
1486
|
li.setAttribute("aria-selected", String(isSelected));
|
|
958
1487
|
if (isSelected) li.classList.add("forge-select__option--selected");
|
|
959
|
-
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
|
|
1488
|
+
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
|
|
960
1489
|
li.classList.add("forge-select__option--indeterminate");
|
|
1490
|
+
li.dataset.selectionState = "mixed";
|
|
961
1491
|
}
|
|
962
1492
|
if (row.depth > 0) {
|
|
963
1493
|
li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
|
|
964
1494
|
}
|
|
965
|
-
if (row.option.
|
|
1495
|
+
if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
|
|
966
1496
|
li.classList.add("forge-select__option--disabled");
|
|
967
1497
|
li.setAttribute("aria-disabled", "true");
|
|
968
1498
|
} else {
|
|
@@ -993,6 +1523,28 @@ var ForgeSelect = class {
|
|
|
993
1523
|
* cached content state-free.
|
|
994
1524
|
*/
|
|
995
1525
|
optionContent(option) {
|
|
1526
|
+
if (this.opts.highlightSearch && this.query.trim() && !this.opts.templateResult) {
|
|
1527
|
+
const holder = document.createElement("span");
|
|
1528
|
+
holder.className = "forge-select__option-content";
|
|
1529
|
+
renderOptionContent(holder, option, void 0);
|
|
1530
|
+
const label = holder.querySelector(".forge-select__option-label") ?? holder;
|
|
1531
|
+
const ranges = findNormalizedRanges(option.label, this.query, this.opts.accentInsensitive);
|
|
1532
|
+
if (ranges.length) {
|
|
1533
|
+
label.textContent = "";
|
|
1534
|
+
let cursor = 0;
|
|
1535
|
+
for (const [start, end] of ranges) {
|
|
1536
|
+
if (start < cursor) continue;
|
|
1537
|
+
label.append(document.createTextNode(option.label.slice(cursor, start)));
|
|
1538
|
+
const mark = document.createElement("mark");
|
|
1539
|
+
mark.className = "forge-select__match";
|
|
1540
|
+
mark.textContent = option.label.slice(start, end);
|
|
1541
|
+
label.append(mark);
|
|
1542
|
+
cursor = end;
|
|
1543
|
+
}
|
|
1544
|
+
label.append(document.createTextNode(option.label.slice(cursor)));
|
|
1545
|
+
}
|
|
1546
|
+
return holder;
|
|
1547
|
+
}
|
|
996
1548
|
let cached = this.rowContentCache.get(option.value);
|
|
997
1549
|
if (!cached) {
|
|
998
1550
|
const holder = document.createElement("span");
|
|
@@ -1019,8 +1571,8 @@ var ForgeSelect = class {
|
|
|
1019
1571
|
(row) => (row.kind === "option" || row.kind === "create") && row.navIndex === next
|
|
1020
1572
|
);
|
|
1021
1573
|
if (rowIndex >= 0) {
|
|
1022
|
-
const rowHeight = this.
|
|
1023
|
-
const top = rowIndex
|
|
1574
|
+
const rowHeight = this.measuredRowHeight(rowIndex);
|
|
1575
|
+
const top = this.rowOffset(rowIndex);
|
|
1024
1576
|
const viewport = this.list.clientHeight || rowHeight * 8;
|
|
1025
1577
|
let target = this.list.scrollTop;
|
|
1026
1578
|
if (top < target) target = top;
|
|
@@ -1085,7 +1637,7 @@ var ForgeSelect = class {
|
|
|
1085
1637
|
this.ajaxController = null;
|
|
1086
1638
|
this.page = 0;
|
|
1087
1639
|
this.hasMore = true;
|
|
1088
|
-
this.
|
|
1640
|
+
this.setLoading(true);
|
|
1089
1641
|
this.loadingMore = false;
|
|
1090
1642
|
this.loadError = null;
|
|
1091
1643
|
this.renderList();
|
|
@@ -1094,6 +1646,55 @@ var ForgeSelect = class {
|
|
|
1094
1646
|
void this.loadRemote(query, { requestId });
|
|
1095
1647
|
}, delay);
|
|
1096
1648
|
}
|
|
1649
|
+
setLoading(loading) {
|
|
1650
|
+
if (this.loading === loading) return;
|
|
1651
|
+
this.loading = loading;
|
|
1652
|
+
this.emitter.emit("loading", loading);
|
|
1653
|
+
}
|
|
1654
|
+
remoteCacheKey(query, page) {
|
|
1655
|
+
return `${query}\0${page}`;
|
|
1656
|
+
}
|
|
1657
|
+
async requestRemote(query, page, signal) {
|
|
1658
|
+
const ajax = this.opts.ajax;
|
|
1659
|
+
const attempts = Math.max(0, Math.floor(ajax.retry ?? 0)) + 1;
|
|
1660
|
+
let lastError;
|
|
1661
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1662
|
+
try {
|
|
1663
|
+
if (ajax.request) return await ajax.request(query, page, signal);
|
|
1664
|
+
const response = await fetch(buildUrl(ajax, query, page), { signal });
|
|
1665
|
+
if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
|
|
1666
|
+
return await response.json();
|
|
1667
|
+
} catch (error) {
|
|
1668
|
+
lastError = error;
|
|
1669
|
+
if (signal.aborted || attempt === attempts - 1) throw error;
|
|
1670
|
+
const delay = Math.max(0, ajax.retryDelay ?? 250) * 2 ** attempt;
|
|
1671
|
+
await new Promise((resolve, reject) => {
|
|
1672
|
+
const timer = setTimeout(resolve, delay);
|
|
1673
|
+
signal.addEventListener(
|
|
1674
|
+
"abort",
|
|
1675
|
+
() => {
|
|
1676
|
+
clearTimeout(timer);
|
|
1677
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
1678
|
+
},
|
|
1679
|
+
{ once: true }
|
|
1680
|
+
);
|
|
1681
|
+
});
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
throw lastError;
|
|
1685
|
+
}
|
|
1686
|
+
async prefetchRemote(query) {
|
|
1687
|
+
const ajax = this.opts.ajax;
|
|
1688
|
+
if (!ajax || (ajax.cacheTtl ?? 3e4) <= 0) return;
|
|
1689
|
+
const key = this.remoteCacheKey(query, 0);
|
|
1690
|
+
if (this.remoteCache.get(key)) return;
|
|
1691
|
+
const controller = new AbortController();
|
|
1692
|
+
try {
|
|
1693
|
+
const json = await this.requestRemote(query, 0, controller.signal);
|
|
1694
|
+
this.remoteCache.set(key, normalizeRemoteResult(ajax, json), ajax.cacheTtl ?? 3e4);
|
|
1695
|
+
} catch {
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1097
1698
|
/**
|
|
1098
1699
|
* Fires on every list scroll. Only acts when pagination is opted into via
|
|
1099
1700
|
* `ajax.pagination`; reads real scroll geometry rather than row counts so
|
|
@@ -1118,12 +1719,15 @@ var ForgeSelect = class {
|
|
|
1118
1719
|
this.ajaxController = controller;
|
|
1119
1720
|
const page = append ? this.page + 1 : 0;
|
|
1120
1721
|
try {
|
|
1121
|
-
const
|
|
1122
|
-
|
|
1123
|
-
if (
|
|
1124
|
-
|
|
1722
|
+
const key = this.remoteCacheKey(query, page);
|
|
1723
|
+
let result = this.remoteCache.get(key);
|
|
1724
|
+
if (!result) {
|
|
1725
|
+
const json = await this.requestRemote(query, page, controller.signal);
|
|
1726
|
+
result = normalizeRemoteResult(ajax, json);
|
|
1727
|
+
this.remoteCache.set(key, result, ajax.cacheTtl ?? 3e4);
|
|
1728
|
+
}
|
|
1125
1729
|
if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
|
|
1126
|
-
const { options, hasMore } =
|
|
1730
|
+
const { options, hasMore } = result;
|
|
1127
1731
|
if (append) {
|
|
1128
1732
|
const existing = collectValues(this.data);
|
|
1129
1733
|
this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
|
|
@@ -1148,7 +1752,7 @@ var ForgeSelect = class {
|
|
|
1148
1752
|
} finally {
|
|
1149
1753
|
if (activeRequestId === this.ajaxRequestId && !this.destroyed) {
|
|
1150
1754
|
this.ajaxController = null;
|
|
1151
|
-
this.
|
|
1755
|
+
this.setLoading(false);
|
|
1152
1756
|
this.loadingMore = false;
|
|
1153
1757
|
if (this.isOpen) this.renderList();
|
|
1154
1758
|
}
|