forge-select 0.3.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 +24 -15
- package/dist/index.cjs +337 -43
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +125 -7
- package/dist/index.d.ts +125 -7
- package/dist/index.global.js +1 -1
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +337 -43
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/styles/forge-select.css +25 -1
package/dist/index.cjs
CHANGED
|
@@ -51,6 +51,17 @@ 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: {
|
|
@@ -62,7 +73,9 @@ var locales = {
|
|
|
62
73
|
clearSelection: "Clear selection",
|
|
63
74
|
removeItem: "Remove {label}",
|
|
64
75
|
search: "Search",
|
|
65
|
-
reorderHint: "{label}. Press Alt+Left or Alt+Right to reorder."
|
|
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"
|
|
66
79
|
},
|
|
67
80
|
vi: {
|
|
68
81
|
noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
|
|
@@ -73,7 +86,9 @@ var locales = {
|
|
|
73
86
|
clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
|
|
74
87
|
removeItem: "X\xF3a {label}",
|
|
75
88
|
search: "T\xECm ki\u1EBFm",
|
|
76
|
-
reorderHint: "{label}. Nh\u1EA5n Alt+Tr\xE1i ho\u1EB7c Alt+Ph\u1EA3i \u0111\u1EC3 s\u1EAFp x\u1EBFp l\u1EA1i."
|
|
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"
|
|
77
92
|
}
|
|
78
93
|
};
|
|
79
94
|
function getStrings(language) {
|
|
@@ -149,6 +164,7 @@ function renderOptionContent(container, option, template, variant = "row") {
|
|
|
149
164
|
|
|
150
165
|
// src/remote.ts
|
|
151
166
|
function buildUrl(ajax, query, page) {
|
|
167
|
+
if (!ajax.url) throw new Error("ForgeSelect: ajax requires either url or request.");
|
|
152
168
|
if (typeof ajax.url === "function") return ajax.url(query, page);
|
|
153
169
|
if (!ajax.params) return ajax.url;
|
|
154
170
|
const params = new URLSearchParams();
|
|
@@ -171,18 +187,19 @@ function normalizeRemoteResult(ajax, response) {
|
|
|
171
187
|
function isGroup(item) {
|
|
172
188
|
return item.options !== void 0;
|
|
173
189
|
}
|
|
174
|
-
|
|
190
|
+
var defaultIsDisabled = (option) => !!option.disabled;
|
|
191
|
+
function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
|
|
175
192
|
if (!option.children) return [];
|
|
176
193
|
const values = [];
|
|
177
194
|
for (const child of option.children) {
|
|
178
|
-
if (!child
|
|
179
|
-
values.push(...collectDescendantValues(child));
|
|
195
|
+
if (!isDisabled(child)) values.push(child.value);
|
|
196
|
+
values.push(...collectDescendantValues(child, isDisabled));
|
|
180
197
|
}
|
|
181
198
|
return values;
|
|
182
199
|
}
|
|
183
|
-
function computeCheckState(option, selected) {
|
|
200
|
+
function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
|
|
184
201
|
if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
|
|
185
|
-
const states = option.children.filter((child) => !child
|
|
202
|
+
const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
|
|
186
203
|
if (states.length === 0) return "none";
|
|
187
204
|
if (states.every((state) => state === "all")) return "all";
|
|
188
205
|
if (states.every((state) => state === "none")) return "none";
|
|
@@ -203,11 +220,11 @@ function findOption(items, value) {
|
|
|
203
220
|
}
|
|
204
221
|
return void 0;
|
|
205
222
|
}
|
|
206
|
-
function syncTreeAncestors(items, selected) {
|
|
223
|
+
function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
|
|
207
224
|
const sync = (option) => {
|
|
208
225
|
if (!option.children?.length) return;
|
|
209
226
|
for (const child of option.children) sync(child);
|
|
210
|
-
const state = computeCheckState(option, selected);
|
|
227
|
+
const state = computeCheckState(option, selected, isDisabled);
|
|
211
228
|
const index = selected.indexOf(option.value);
|
|
212
229
|
if (state === "all" && index === -1) selected.push(option.value);
|
|
213
230
|
else if (state !== "all" && index !== -1) selected.splice(index, 1);
|
|
@@ -241,6 +258,7 @@ var ForgeSelect = class {
|
|
|
241
258
|
this.emitter = new Emitter();
|
|
242
259
|
this.uid = `forge-select-${++uidCounter}`;
|
|
243
260
|
this.searchInput = null;
|
|
261
|
+
this.portalHost = null;
|
|
244
262
|
this.isOpen = false;
|
|
245
263
|
this.isDisabled = false;
|
|
246
264
|
this.destroyed = false;
|
|
@@ -264,8 +282,25 @@ var ForgeSelect = class {
|
|
|
264
282
|
this.nativeSelect = null;
|
|
265
283
|
this.nativeForm = null;
|
|
266
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;
|
|
267
288
|
this.onDocumentMouseDown = (event) => {
|
|
268
|
-
|
|
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();
|
|
269
304
|
};
|
|
270
305
|
this.onNativeChange = () => {
|
|
271
306
|
if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
|
|
@@ -294,19 +329,29 @@ var ForgeSelect = class {
|
|
|
294
329
|
clearable: options.clearable ?? false,
|
|
295
330
|
allowCreate: options.allowCreate ?? false,
|
|
296
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)),
|
|
297
334
|
theme: options.theme ?? "default",
|
|
298
335
|
disabled: options.disabled ?? nativeSelect?.disabled ?? false,
|
|
336
|
+
required: options.required ?? nativeSelect?.required ?? false,
|
|
299
337
|
data: options.data,
|
|
300
338
|
ajax: options.ajax,
|
|
301
339
|
templateResult: options.templateResult,
|
|
302
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,
|
|
303
345
|
virtualScroll: options.virtualScroll,
|
|
304
346
|
itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
|
|
305
347
|
language: options.language ?? "en",
|
|
306
|
-
plugins: options.plugins ?? []
|
|
348
|
+
plugins: options.plugins ?? [],
|
|
349
|
+
openOnFocus: options.openOnFocus ?? false,
|
|
350
|
+
dropdownParent: options.dropdownParent
|
|
307
351
|
};
|
|
308
352
|
this.strings = getStrings(this.opts.language);
|
|
309
353
|
this.plugins = this.opts.plugins;
|
|
354
|
+
if (nativeSelect) nativeSelect.required = this.opts.required;
|
|
310
355
|
this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
|
|
311
356
|
if (nativeSelect && !this.opts.data) {
|
|
312
357
|
const nativeOptions = Array.from(nativeSelect.options);
|
|
@@ -319,6 +364,7 @@ var ForgeSelect = class {
|
|
|
319
364
|
this.renderValue();
|
|
320
365
|
if (this.opts.disabled) this.disable();
|
|
321
366
|
nativeSelect?.addEventListener("change", this.onNativeChange);
|
|
367
|
+
nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
|
|
322
368
|
this.nativeForm?.addEventListener("reset", this.onFormReset);
|
|
323
369
|
for (const plugin of this.plugins) plugin.onInit?.(this);
|
|
324
370
|
}
|
|
@@ -341,7 +387,10 @@ var ForgeSelect = class {
|
|
|
341
387
|
this.scheduleRemoteLoad(this.query, 0);
|
|
342
388
|
}
|
|
343
389
|
this.renderList();
|
|
344
|
-
|
|
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();
|
|
345
394
|
this.emitter.emit("open");
|
|
346
395
|
for (const plugin of this.plugins) plugin.onOpen?.(this);
|
|
347
396
|
}
|
|
@@ -350,8 +399,11 @@ var ForgeSelect = class {
|
|
|
350
399
|
this.isOpen = false;
|
|
351
400
|
this.dropdown.hidden = true;
|
|
352
401
|
this.root.classList.remove("forge-select--open");
|
|
402
|
+
this.root.classList.remove("forge-select--drop-up");
|
|
353
403
|
this.control.setAttribute("aria-expanded", "false");
|
|
354
404
|
document.removeEventListener("mousedown", this.onDocumentMouseDown);
|
|
405
|
+
window.removeEventListener("resize", this.onWindowResize);
|
|
406
|
+
document.removeEventListener("scroll", this.onAncestorScroll, true);
|
|
355
407
|
this.highlightedIndex = -1;
|
|
356
408
|
if (this.searchInput) {
|
|
357
409
|
this.searchInput.value = "";
|
|
@@ -360,6 +412,23 @@ var ForgeSelect = class {
|
|
|
360
412
|
this.emitter.emit("close");
|
|
361
413
|
for (const plugin of this.plugins) plugin.onClose?.(this);
|
|
362
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
|
+
}
|
|
363
432
|
destroy() {
|
|
364
433
|
if (this.destroyed) return;
|
|
365
434
|
this.close();
|
|
@@ -368,8 +437,10 @@ var ForgeSelect = class {
|
|
|
368
437
|
if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
|
|
369
438
|
this.ajaxController?.abort();
|
|
370
439
|
this.nativeSelect?.removeEventListener("change", this.onNativeChange);
|
|
440
|
+
this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
|
|
371
441
|
this.nativeForm?.removeEventListener("reset", this.onFormReset);
|
|
372
442
|
this.rowContentCache.clear();
|
|
443
|
+
this.portalHost?.remove();
|
|
373
444
|
this.root.remove();
|
|
374
445
|
this.el.style.display = this.originalDisplay;
|
|
375
446
|
if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
|
|
@@ -387,6 +458,52 @@ var ForgeSelect = class {
|
|
|
387
458
|
for (const v of next) this.selectValue(v, false);
|
|
388
459
|
this.afterSelectionChange(options.emitChange ?? true);
|
|
389
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
|
+
}
|
|
501
|
+
this.afterSelectionChange();
|
|
502
|
+
}
|
|
503
|
+
/** Clears every selection. Equivalent to `setValue(null)`. */
|
|
504
|
+
clearAll() {
|
|
505
|
+
this.clearSelection();
|
|
506
|
+
}
|
|
390
507
|
enable() {
|
|
391
508
|
this.isDisabled = false;
|
|
392
509
|
this.root.classList.remove("forge-select--disabled");
|
|
@@ -432,7 +549,22 @@ var ForgeSelect = class {
|
|
|
432
549
|
}
|
|
433
550
|
}
|
|
434
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
|
+
}
|
|
435
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
|
+
}
|
|
436
568
|
this.root = document.createElement("div");
|
|
437
569
|
this.root.className = "forge-select";
|
|
438
570
|
this.root.dataset.theme = this.opts.theme;
|
|
@@ -444,6 +576,7 @@ var ForgeSelect = class {
|
|
|
444
576
|
this.control.setAttribute("aria-haspopup", "listbox");
|
|
445
577
|
this.control.setAttribute("aria-expanded", "false");
|
|
446
578
|
this.control.setAttribute("aria-controls", `${this.uid}-list`);
|
|
579
|
+
if (this.opts.required) this.control.setAttribute("aria-required", "true");
|
|
447
580
|
this.control.tabIndex = 0;
|
|
448
581
|
this.applyAccessibleName();
|
|
449
582
|
this.valueEl = document.createElement("div");
|
|
@@ -468,6 +601,7 @@ var ForgeSelect = class {
|
|
|
468
601
|
this.searchInput.setAttribute("aria-label", this.strings.search);
|
|
469
602
|
this.searchInput.setAttribute("aria-autocomplete", "list");
|
|
470
603
|
this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
|
|
604
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
471
605
|
this.dropdown.append(this.searchInput);
|
|
472
606
|
}
|
|
473
607
|
this.list = document.createElement("ul");
|
|
@@ -480,9 +614,18 @@ var ForgeSelect = class {
|
|
|
480
614
|
this.liveRegion.className = "forge-select__sr-only";
|
|
481
615
|
this.liveRegion.setAttribute("role", "status");
|
|
482
616
|
this.liveRegion.setAttribute("aria-live", "polite");
|
|
483
|
-
this.root.append(this.control, this.
|
|
617
|
+
this.root.append(this.control, this.liveRegion);
|
|
618
|
+
if (!portalParent) this.root.append(this.dropdown);
|
|
484
619
|
this.el.style.display = "none";
|
|
485
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
|
+
}
|
|
486
629
|
this.bindEvents();
|
|
487
630
|
}
|
|
488
631
|
bindEvents() {
|
|
@@ -497,6 +640,15 @@ var ForgeSelect = class {
|
|
|
497
640
|
else this.open();
|
|
498
641
|
});
|
|
499
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
|
+
});
|
|
500
652
|
this.clearBtn.addEventListener("click", (event) => {
|
|
501
653
|
event.stopPropagation();
|
|
502
654
|
this.clearSelection();
|
|
@@ -507,13 +659,45 @@ var ForgeSelect = class {
|
|
|
507
659
|
this.highlightedIndex = -1;
|
|
508
660
|
this.list.scrollTop = 0;
|
|
509
661
|
this.emitter.emit("search", this.query);
|
|
510
|
-
|
|
662
|
+
const trimmed = this.query.trim();
|
|
663
|
+
const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
|
|
664
|
+
if (this.opts.ajax && !belowMinLength) {
|
|
511
665
|
this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
|
|
512
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
|
+
}
|
|
513
675
|
this.renderList();
|
|
514
676
|
}
|
|
515
677
|
});
|
|
516
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
|
+
});
|
|
517
701
|
}
|
|
518
702
|
this.list.addEventListener("click", (event) => {
|
|
519
703
|
const target = event.target;
|
|
@@ -526,7 +710,12 @@ var ForgeSelect = class {
|
|
|
526
710
|
return;
|
|
527
711
|
}
|
|
528
712
|
const li = target.closest("li[data-nav-index]");
|
|
529
|
-
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
|
+
}
|
|
530
719
|
const navIndex = Number(li.dataset.navIndex);
|
|
531
720
|
this.activateNavItem(navIndex);
|
|
532
721
|
});
|
|
@@ -577,36 +766,61 @@ var ForgeSelect = class {
|
|
|
577
766
|
}
|
|
578
767
|
}
|
|
579
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
|
+
}
|
|
580
788
|
selectValue(value, notify) {
|
|
581
789
|
if (this.selected.includes(value)) return;
|
|
582
790
|
const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
|
|
583
791
|
this.selectedOptions.set(value, option);
|
|
584
792
|
if (this.opts.multiple) {
|
|
585
793
|
this.selected.push(value);
|
|
586
|
-
for (const v of collectDescendantValues(option)) {
|
|
794
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
587
795
|
if (!this.selected.includes(v)) this.selected.push(v);
|
|
588
796
|
}
|
|
589
797
|
this.syncTreeAncestors();
|
|
590
798
|
} else {
|
|
591
799
|
this.selected = [value];
|
|
592
800
|
}
|
|
593
|
-
if (notify)
|
|
801
|
+
if (notify) {
|
|
802
|
+
this.afterSelectionChange();
|
|
803
|
+
this.emitter.emit("select", option);
|
|
804
|
+
}
|
|
594
805
|
}
|
|
595
806
|
deselectValue(value, notify) {
|
|
596
807
|
const index = this.selected.indexOf(value);
|
|
597
808
|
if (index === -1) return;
|
|
809
|
+
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
598
810
|
this.selected.splice(index, 1);
|
|
599
811
|
if (this.opts.multiple) {
|
|
600
|
-
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
601
812
|
if (option) {
|
|
602
|
-
for (const v of collectDescendantValues(option)) {
|
|
813
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
603
814
|
const i = this.selected.indexOf(v);
|
|
604
815
|
if (i !== -1) this.selected.splice(i, 1);
|
|
605
816
|
}
|
|
606
817
|
}
|
|
607
818
|
this.syncTreeAncestors();
|
|
608
819
|
}
|
|
609
|
-
if (notify)
|
|
820
|
+
if (notify) {
|
|
821
|
+
this.afterSelectionChange();
|
|
822
|
+
this.emitter.emit("unselect", option ?? { value, label: value });
|
|
823
|
+
}
|
|
610
824
|
}
|
|
611
825
|
/**
|
|
612
826
|
* Keeps every tree parent's own membership in `selected` consistent with
|
|
@@ -615,7 +829,7 @@ var ForgeSelect = class {
|
|
|
615
829
|
* No-op for data with no `children` anywhere.
|
|
616
830
|
*/
|
|
617
831
|
syncTreeAncestors() {
|
|
618
|
-
syncTreeAncestors(this.data, this.selected);
|
|
832
|
+
syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
|
|
619
833
|
}
|
|
620
834
|
clearSelection() {
|
|
621
835
|
if (this.selected.length === 0) return;
|
|
@@ -623,9 +837,22 @@ var ForgeSelect = class {
|
|
|
623
837
|
this.emitter.emit("clear");
|
|
624
838
|
this.afterSelectionChange();
|
|
625
839
|
}
|
|
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
|
+
}
|
|
626
849
|
afterSelectionChange(emitChange = true) {
|
|
627
850
|
this.renderValue();
|
|
628
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
|
+
}
|
|
629
856
|
if (this.isOpen) this.renderList();
|
|
630
857
|
if (emitChange) this.emitter.emit("change", this.getValue());
|
|
631
858
|
}
|
|
@@ -661,17 +888,58 @@ var ForgeSelect = class {
|
|
|
661
888
|
findOption(value) {
|
|
662
889
|
return findOption(this.data, value);
|
|
663
890
|
}
|
|
891
|
+
findOptionByLabel(label) {
|
|
892
|
+
const lower = label.toLowerCase();
|
|
893
|
+
const search = (options) => {
|
|
894
|
+
for (const option of options) {
|
|
895
|
+
if (option.label.toLowerCase() === lower) return option;
|
|
896
|
+
const found = option.children ? search(option.children) : void 0;
|
|
897
|
+
if (found) return found;
|
|
898
|
+
}
|
|
899
|
+
return void 0;
|
|
900
|
+
};
|
|
901
|
+
for (const item of this.data) {
|
|
902
|
+
const found = search(isGroup(item) ? item.options : [item]);
|
|
903
|
+
if (found) return found;
|
|
904
|
+
}
|
|
905
|
+
return void 0;
|
|
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
|
+
}
|
|
664
930
|
createFromQuery() {
|
|
665
931
|
const label = this.query.trim();
|
|
666
932
|
if (!label) return;
|
|
667
|
-
const
|
|
668
|
-
|
|
933
|
+
const result = this.createTag(label);
|
|
934
|
+
if (!result) return;
|
|
669
935
|
if (this.searchInput) {
|
|
670
936
|
this.searchInput.value = "";
|
|
671
937
|
this.query = "";
|
|
672
938
|
}
|
|
673
|
-
this.
|
|
674
|
-
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();
|
|
675
943
|
}
|
|
676
944
|
activateNavItem(navIndex) {
|
|
677
945
|
const item = this.navItems[navIndex];
|
|
@@ -682,8 +950,17 @@ var ForgeSelect = class {
|
|
|
682
950
|
}
|
|
683
951
|
const { value } = item.option;
|
|
684
952
|
if (this.opts.multiple) {
|
|
685
|
-
|
|
686
|
-
|
|
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();
|
|
687
964
|
} else {
|
|
688
965
|
this.selectValue(value, true);
|
|
689
966
|
this.close();
|
|
@@ -799,6 +1076,7 @@ var ForgeSelect = class {
|
|
|
799
1076
|
this.selected = order;
|
|
800
1077
|
this.suppressNextTagClick = true;
|
|
801
1078
|
this.afterSelectionChange();
|
|
1079
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
802
1080
|
};
|
|
803
1081
|
tag.addEventListener("pointerdown", (event) => {
|
|
804
1082
|
if (this.isDisabled || event.button !== 0) return;
|
|
@@ -822,6 +1100,7 @@ var ForgeSelect = class {
|
|
|
822
1100
|
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
|
823
1101
|
this.selected = next;
|
|
824
1102
|
this.afterSelectionChange();
|
|
1103
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
825
1104
|
this.focusTagByValue(value);
|
|
826
1105
|
}
|
|
827
1106
|
focusTagByValue(value) {
|
|
@@ -835,12 +1114,14 @@ var ForgeSelect = class {
|
|
|
835
1114
|
buildRows() {
|
|
836
1115
|
this.rows = [];
|
|
837
1116
|
this.navItems = [];
|
|
838
|
-
const
|
|
839
|
-
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));
|
|
840
1120
|
const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
|
|
841
1121
|
const pushOption = (option, depth, parentValue) => {
|
|
842
1122
|
let navIndex = -1;
|
|
843
|
-
|
|
1123
|
+
const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
|
|
1124
|
+
if (!interactionDisabled) {
|
|
844
1125
|
navIndex = this.navItems.length;
|
|
845
1126
|
this.navItems.push({ kind: "option", option, parentValue });
|
|
846
1127
|
}
|
|
@@ -855,6 +1136,10 @@ var ForgeSelect = class {
|
|
|
855
1136
|
}
|
|
856
1137
|
}
|
|
857
1138
|
};
|
|
1139
|
+
if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
|
|
1140
|
+
this.rows.push({ kind: "min-length" });
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
858
1143
|
if (this.loading) {
|
|
859
1144
|
this.rows.push({ kind: "loading" });
|
|
860
1145
|
return;
|
|
@@ -882,12 +1167,7 @@ var ForgeSelect = class {
|
|
|
882
1167
|
else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
|
|
883
1168
|
}
|
|
884
1169
|
hasExactMatch(lowerQuery) {
|
|
885
|
-
|
|
886
|
-
for (const item of this.data) {
|
|
887
|
-
const options = isGroup(item) ? item.options : [item];
|
|
888
|
-
if (options.some(matchesExactly)) return true;
|
|
889
|
-
}
|
|
890
|
-
return false;
|
|
1170
|
+
return !!this.findOptionByLabel(lowerQuery);
|
|
891
1171
|
}
|
|
892
1172
|
usesVirtualScroll() {
|
|
893
1173
|
return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
|
|
@@ -899,7 +1179,7 @@ var ForgeSelect = class {
|
|
|
899
1179
|
}
|
|
900
1180
|
announceStatus() {
|
|
901
1181
|
const first = this.rows[0];
|
|
902
|
-
const message = first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : "";
|
|
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) }) : "";
|
|
903
1183
|
if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
|
|
904
1184
|
}
|
|
905
1185
|
renderRows() {
|
|
@@ -950,6 +1230,13 @@ var ForgeSelect = class {
|
|
|
950
1230
|
li.setAttribute("aria-selected", "false");
|
|
951
1231
|
li.textContent = this.strings.noResults;
|
|
952
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;
|
|
953
1240
|
case "error":
|
|
954
1241
|
li.className = "forge-select__error";
|
|
955
1242
|
li.setAttribute("role", "option");
|
|
@@ -979,17 +1266,19 @@ var ForgeSelect = class {
|
|
|
979
1266
|
break;
|
|
980
1267
|
case "option": {
|
|
981
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));
|
|
982
1271
|
li.setAttribute("role", "option");
|
|
983
1272
|
const isSelected = this.selected.includes(row.option.value);
|
|
984
1273
|
li.setAttribute("aria-selected", String(isSelected));
|
|
985
1274
|
if (isSelected) li.classList.add("forge-select__option--selected");
|
|
986
|
-
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") {
|
|
987
1276
|
li.classList.add("forge-select__option--indeterminate");
|
|
988
1277
|
}
|
|
989
1278
|
if (row.depth > 0) {
|
|
990
1279
|
li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
|
|
991
1280
|
}
|
|
992
|
-
if (row.option.
|
|
1281
|
+
if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
|
|
993
1282
|
li.classList.add("forge-select__option--disabled");
|
|
994
1283
|
li.setAttribute("aria-disabled", "true");
|
|
995
1284
|
} else {
|
|
@@ -1145,10 +1434,15 @@ var ForgeSelect = class {
|
|
|
1145
1434
|
this.ajaxController = controller;
|
|
1146
1435
|
const page = append ? this.page + 1 : 0;
|
|
1147
1436
|
try {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
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
|
+
}
|
|
1152
1446
|
if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
|
|
1153
1447
|
const { options, hasMore } = normalizeRemoteResult(ajax, json);
|
|
1154
1448
|
if (append) {
|