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.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();
|
|
@@ -144,18 +160,19 @@ function normalizeRemoteResult(ajax, response) {
|
|
|
144
160
|
function isGroup(item) {
|
|
145
161
|
return item.options !== void 0;
|
|
146
162
|
}
|
|
147
|
-
|
|
163
|
+
var defaultIsDisabled = (option) => !!option.disabled;
|
|
164
|
+
function collectDescendantValues(option, isDisabled = defaultIsDisabled) {
|
|
148
165
|
if (!option.children) return [];
|
|
149
166
|
const values = [];
|
|
150
167
|
for (const child of option.children) {
|
|
151
|
-
if (!child
|
|
152
|
-
values.push(...collectDescendantValues(child));
|
|
168
|
+
if (!isDisabled(child)) values.push(child.value);
|
|
169
|
+
values.push(...collectDescendantValues(child, isDisabled));
|
|
153
170
|
}
|
|
154
171
|
return values;
|
|
155
172
|
}
|
|
156
|
-
function computeCheckState(option, selected) {
|
|
173
|
+
function computeCheckState(option, selected, isDisabled = defaultIsDisabled) {
|
|
157
174
|
if (!option.children?.length) return selected.includes(option.value) ? "all" : "none";
|
|
158
|
-
const states = option.children.filter((child) => !child
|
|
175
|
+
const states = option.children.filter((child) => !isDisabled(child)).map((child) => computeCheckState(child, selected, isDisabled));
|
|
159
176
|
if (states.length === 0) return "none";
|
|
160
177
|
if (states.every((state) => state === "all")) return "all";
|
|
161
178
|
if (states.every((state) => state === "none")) return "none";
|
|
@@ -176,11 +193,11 @@ function findOption(items, value) {
|
|
|
176
193
|
}
|
|
177
194
|
return void 0;
|
|
178
195
|
}
|
|
179
|
-
function syncTreeAncestors(items, selected) {
|
|
196
|
+
function syncTreeAncestors(items, selected, isDisabled = defaultIsDisabled) {
|
|
180
197
|
const sync = (option) => {
|
|
181
198
|
if (!option.children?.length) return;
|
|
182
199
|
for (const child of option.children) sync(child);
|
|
183
|
-
const state = computeCheckState(option, selected);
|
|
200
|
+
const state = computeCheckState(option, selected, isDisabled);
|
|
184
201
|
const index = selected.indexOf(option.value);
|
|
185
202
|
if (state === "all" && index === -1) selected.push(option.value);
|
|
186
203
|
else if (state !== "all" && index !== -1) selected.splice(index, 1);
|
|
@@ -214,6 +231,7 @@ var ForgeSelect = class {
|
|
|
214
231
|
this.emitter = new Emitter();
|
|
215
232
|
this.uid = `forge-select-${++uidCounter}`;
|
|
216
233
|
this.searchInput = null;
|
|
234
|
+
this.portalHost = null;
|
|
217
235
|
this.isOpen = false;
|
|
218
236
|
this.isDisabled = false;
|
|
219
237
|
this.destroyed = false;
|
|
@@ -237,8 +255,25 @@ var ForgeSelect = class {
|
|
|
237
255
|
this.nativeSelect = null;
|
|
238
256
|
this.nativeForm = null;
|
|
239
257
|
this.syncingNative = false;
|
|
258
|
+
/** Combines the static `disabled` field with the dynamic `isOptionDisabled` callback. */
|
|
259
|
+
this.isOptionDisabled = (option) => option.disabled === true || (this.opts.isOptionDisabled?.(option) ?? false);
|
|
260
|
+
this.pointerDownOnControl = false;
|
|
240
261
|
this.onDocumentMouseDown = (event) => {
|
|
241
|
-
|
|
262
|
+
const target = event.target;
|
|
263
|
+
if (!this.root.contains(target) && !this.portalHost?.contains(target)) this.close();
|
|
264
|
+
};
|
|
265
|
+
this.onWindowResize = () => {
|
|
266
|
+
this.positionDropdown();
|
|
267
|
+
};
|
|
268
|
+
this.onAncestorScroll = () => {
|
|
269
|
+
if (this.portalHost) this.positionDropdown();
|
|
270
|
+
};
|
|
271
|
+
this.onNativeInvalid = (event) => {
|
|
272
|
+
event.preventDefault();
|
|
273
|
+
this.control.classList.add("forge-select__control--invalid");
|
|
274
|
+
this.control.setAttribute("aria-invalid", "true");
|
|
275
|
+
if (!this.isOpen) this.open();
|
|
276
|
+
this.control.focus();
|
|
242
277
|
};
|
|
243
278
|
this.onNativeChange = () => {
|
|
244
279
|
if (!this.nativeSelect || this.destroyed || this.syncingNative) return;
|
|
@@ -267,19 +302,29 @@ var ForgeSelect = class {
|
|
|
267
302
|
clearable: options.clearable ?? false,
|
|
268
303
|
allowCreate: options.allowCreate ?? false,
|
|
269
304
|
sortable: options.sortable ?? false,
|
|
305
|
+
closeOnSelect: options.closeOnSelect ?? false,
|
|
306
|
+
maxSelections: options.maxSelections == null || !Number.isFinite(options.maxSelections) ? void 0 : Math.max(0, Math.floor(options.maxSelections)),
|
|
270
307
|
theme: options.theme ?? "default",
|
|
271
308
|
disabled: options.disabled ?? nativeSelect?.disabled ?? false,
|
|
309
|
+
required: options.required ?? nativeSelect?.required ?? false,
|
|
272
310
|
data: options.data,
|
|
273
311
|
ajax: options.ajax,
|
|
274
312
|
templateResult: options.templateResult,
|
|
275
313
|
templateSelection: options.templateSelection,
|
|
314
|
+
filterOption: options.filterOption,
|
|
315
|
+
minSearchLength: Math.max(0, Math.floor(options.minSearchLength ?? 0)),
|
|
316
|
+
minResultsForSearch: Math.max(0, Math.floor(options.minResultsForSearch ?? 0)),
|
|
317
|
+
isOptionDisabled: options.isOptionDisabled,
|
|
276
318
|
virtualScroll: options.virtualScroll,
|
|
277
319
|
itemHeight: options.itemHeight ?? DEFAULT_ITEM_HEIGHT,
|
|
278
320
|
language: options.language ?? "en",
|
|
279
|
-
plugins: options.plugins ?? []
|
|
321
|
+
plugins: options.plugins ?? [],
|
|
322
|
+
openOnFocus: options.openOnFocus ?? false,
|
|
323
|
+
dropdownParent: options.dropdownParent
|
|
280
324
|
};
|
|
281
325
|
this.strings = getStrings(this.opts.language);
|
|
282
326
|
this.plugins = this.opts.plugins;
|
|
327
|
+
if (nativeSelect) nativeSelect.required = this.opts.required;
|
|
283
328
|
this.data = this.opts.data ?? (nativeSelect ? parseNativeOptions(nativeSelect) : []);
|
|
284
329
|
if (nativeSelect && !this.opts.data) {
|
|
285
330
|
const nativeOptions = Array.from(nativeSelect.options);
|
|
@@ -292,6 +337,7 @@ var ForgeSelect = class {
|
|
|
292
337
|
this.renderValue();
|
|
293
338
|
if (this.opts.disabled) this.disable();
|
|
294
339
|
nativeSelect?.addEventListener("change", this.onNativeChange);
|
|
340
|
+
nativeSelect?.addEventListener("invalid", this.onNativeInvalid);
|
|
295
341
|
this.nativeForm?.addEventListener("reset", this.onFormReset);
|
|
296
342
|
for (const plugin of this.plugins) plugin.onInit?.(this);
|
|
297
343
|
}
|
|
@@ -314,7 +360,10 @@ var ForgeSelect = class {
|
|
|
314
360
|
this.scheduleRemoteLoad(this.query, 0);
|
|
315
361
|
}
|
|
316
362
|
this.renderList();
|
|
317
|
-
|
|
363
|
+
this.positionDropdown();
|
|
364
|
+
window.addEventListener("resize", this.onWindowResize);
|
|
365
|
+
document.addEventListener("scroll", this.onAncestorScroll, true);
|
|
366
|
+
if (this.searchInput && !this.searchInput.hidden) this.searchInput.focus();
|
|
318
367
|
this.emitter.emit("open");
|
|
319
368
|
for (const plugin of this.plugins) plugin.onOpen?.(this);
|
|
320
369
|
}
|
|
@@ -323,8 +372,11 @@ var ForgeSelect = class {
|
|
|
323
372
|
this.isOpen = false;
|
|
324
373
|
this.dropdown.hidden = true;
|
|
325
374
|
this.root.classList.remove("forge-select--open");
|
|
375
|
+
this.root.classList.remove("forge-select--drop-up");
|
|
326
376
|
this.control.setAttribute("aria-expanded", "false");
|
|
327
377
|
document.removeEventListener("mousedown", this.onDocumentMouseDown);
|
|
378
|
+
window.removeEventListener("resize", this.onWindowResize);
|
|
379
|
+
document.removeEventListener("scroll", this.onAncestorScroll, true);
|
|
328
380
|
this.highlightedIndex = -1;
|
|
329
381
|
if (this.searchInput) {
|
|
330
382
|
this.searchInput.value = "";
|
|
@@ -333,6 +385,23 @@ var ForgeSelect = class {
|
|
|
333
385
|
this.emitter.emit("close");
|
|
334
386
|
for (const plugin of this.plugins) plugin.onClose?.(this);
|
|
335
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* Flips the dropdown above the control when there isn't enough room below
|
|
390
|
+
* but there is above. Recomputed on open() and on window resize — the
|
|
391
|
+
* dropdown is positioned absolutely inside the relatively-positioned root,
|
|
392
|
+
* so it already tracks the control correctly on page scroll without
|
|
393
|
+
* needing a scroll listener.
|
|
394
|
+
*/
|
|
395
|
+
positionDropdown() {
|
|
396
|
+
const controlRect = this.control.getBoundingClientRect();
|
|
397
|
+
const placement = computeDropdownPlacement(controlRect, this.dropdown.offsetHeight, window.innerHeight);
|
|
398
|
+
this.root.classList.toggle("forge-select--drop-up", placement.dropUp);
|
|
399
|
+
if (this.portalHost) {
|
|
400
|
+
this.portalHost.style.top = `${placement.top}px`;
|
|
401
|
+
this.portalHost.style.left = `${controlRect.left}px`;
|
|
402
|
+
this.portalHost.style.width = `${controlRect.width}px`;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
336
405
|
destroy() {
|
|
337
406
|
if (this.destroyed) return;
|
|
338
407
|
this.close();
|
|
@@ -341,8 +410,10 @@ var ForgeSelect = class {
|
|
|
341
410
|
if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
|
|
342
411
|
this.ajaxController?.abort();
|
|
343
412
|
this.nativeSelect?.removeEventListener("change", this.onNativeChange);
|
|
413
|
+
this.nativeSelect?.removeEventListener("invalid", this.onNativeInvalid);
|
|
344
414
|
this.nativeForm?.removeEventListener("reset", this.onFormReset);
|
|
345
415
|
this.rowContentCache.clear();
|
|
416
|
+
this.portalHost?.remove();
|
|
346
417
|
this.root.remove();
|
|
347
418
|
this.el.style.display = this.originalDisplay;
|
|
348
419
|
if (this.nativeSelect) this.nativeSelect.disabled = this.originalDisabled;
|
|
@@ -360,6 +431,52 @@ var ForgeSelect = class {
|
|
|
360
431
|
for (const v of next) this.selectValue(v, false);
|
|
361
432
|
this.afterSelectionChange(options.emitChange ?? true);
|
|
362
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* Replaces the option list after construction. An open dropdown re-renders
|
|
436
|
+
* immediately; a selection whose value isn't in the new data stays
|
|
437
|
+
* selected (rendered via the already-selected option's own label/avatar,
|
|
438
|
+
* the same fallback used for values selected from a stale ajax page).
|
|
439
|
+
*/
|
|
440
|
+
setData(data) {
|
|
441
|
+
if (this.ajaxTimer) {
|
|
442
|
+
clearTimeout(this.ajaxTimer);
|
|
443
|
+
this.ajaxTimer = null;
|
|
444
|
+
}
|
|
445
|
+
this.ajaxController?.abort();
|
|
446
|
+
this.ajaxController = null;
|
|
447
|
+
this.ajaxRequestId += 1;
|
|
448
|
+
this.loading = false;
|
|
449
|
+
this.loadingMore = false;
|
|
450
|
+
this.loadError = null;
|
|
451
|
+
this.remoteLoaded = true;
|
|
452
|
+
this.page = 0;
|
|
453
|
+
this.hasMore = false;
|
|
454
|
+
this.data = data;
|
|
455
|
+
this.opts.data = data;
|
|
456
|
+
this.updateSearchVisibility();
|
|
457
|
+
this.rowContentCache.clear();
|
|
458
|
+
this.highlightedIndex = -1;
|
|
459
|
+
if (this.isOpen) this.renderList();
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Multi-select only: selects every currently non-disabled option, including
|
|
463
|
+
* nested tree descendants and options inside groups. If `maxSelections` is
|
|
464
|
+
* set, stops once the cap is reached rather than exceeding it. A no-op for
|
|
465
|
+
* single-select.
|
|
466
|
+
*/
|
|
467
|
+
selectAll() {
|
|
468
|
+
if (!this.opts.multiple) return;
|
|
469
|
+
this.selected = [];
|
|
470
|
+
for (const value of this.allSelectableValues()) {
|
|
471
|
+
const option = this.findOption(value);
|
|
472
|
+
if (option && this.canSelectOption(option)) this.selectValue(value, false);
|
|
473
|
+
}
|
|
474
|
+
this.afterSelectionChange();
|
|
475
|
+
}
|
|
476
|
+
/** Clears every selection. Equivalent to `setValue(null)`. */
|
|
477
|
+
clearAll() {
|
|
478
|
+
this.clearSelection();
|
|
479
|
+
}
|
|
363
480
|
enable() {
|
|
364
481
|
this.isDisabled = false;
|
|
365
482
|
this.root.classList.remove("forge-select--disabled");
|
|
@@ -405,7 +522,22 @@ var ForgeSelect = class {
|
|
|
405
522
|
}
|
|
406
523
|
}
|
|
407
524
|
}
|
|
525
|
+
shouldShowSearch() {
|
|
526
|
+
return this.opts.searchable && (this.opts.ajax != null || collectValues(this.data).size >= this.opts.minResultsForSearch);
|
|
527
|
+
}
|
|
528
|
+
updateSearchVisibility() {
|
|
529
|
+
if (!this.searchInput) return;
|
|
530
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
531
|
+
if (this.searchInput.hidden) {
|
|
532
|
+
this.searchInput.value = "";
|
|
533
|
+
this.query = "";
|
|
534
|
+
}
|
|
535
|
+
}
|
|
408
536
|
buildDom() {
|
|
537
|
+
const portalParent = typeof this.opts.dropdownParent === "string" ? document.querySelector(this.opts.dropdownParent) : this.opts.dropdownParent;
|
|
538
|
+
if (this.opts.dropdownParent && !portalParent) {
|
|
539
|
+
throw new Error(`ForgeSelect: dropdown parent not found: ${String(this.opts.dropdownParent)}`);
|
|
540
|
+
}
|
|
409
541
|
this.root = document.createElement("div");
|
|
410
542
|
this.root.className = "forge-select";
|
|
411
543
|
this.root.dataset.theme = this.opts.theme;
|
|
@@ -417,6 +549,7 @@ var ForgeSelect = class {
|
|
|
417
549
|
this.control.setAttribute("aria-haspopup", "listbox");
|
|
418
550
|
this.control.setAttribute("aria-expanded", "false");
|
|
419
551
|
this.control.setAttribute("aria-controls", `${this.uid}-list`);
|
|
552
|
+
if (this.opts.required) this.control.setAttribute("aria-required", "true");
|
|
420
553
|
this.control.tabIndex = 0;
|
|
421
554
|
this.applyAccessibleName();
|
|
422
555
|
this.valueEl = document.createElement("div");
|
|
@@ -441,6 +574,7 @@ var ForgeSelect = class {
|
|
|
441
574
|
this.searchInput.setAttribute("aria-label", this.strings.search);
|
|
442
575
|
this.searchInput.setAttribute("aria-autocomplete", "list");
|
|
443
576
|
this.searchInput.setAttribute("aria-controls", `${this.uid}-list`);
|
|
577
|
+
this.searchInput.hidden = !this.shouldShowSearch();
|
|
444
578
|
this.dropdown.append(this.searchInput);
|
|
445
579
|
}
|
|
446
580
|
this.list = document.createElement("ul");
|
|
@@ -453,9 +587,18 @@ var ForgeSelect = class {
|
|
|
453
587
|
this.liveRegion.className = "forge-select__sr-only";
|
|
454
588
|
this.liveRegion.setAttribute("role", "status");
|
|
455
589
|
this.liveRegion.setAttribute("aria-live", "polite");
|
|
456
|
-
this.root.append(this.control, this.
|
|
590
|
+
this.root.append(this.control, this.liveRegion);
|
|
591
|
+
if (!portalParent) this.root.append(this.dropdown);
|
|
457
592
|
this.el.style.display = "none";
|
|
458
593
|
this.el.insertAdjacentElement("afterend", this.root);
|
|
594
|
+
if (portalParent) {
|
|
595
|
+
this.portalHost = document.createElement("div");
|
|
596
|
+
this.portalHost.className = "forge-select forge-select--portal-host";
|
|
597
|
+
this.portalHost.dataset.theme = this.opts.theme;
|
|
598
|
+
this.portalHost.style.setProperty("--fs-item-height", `${this.opts.itemHeight}px`);
|
|
599
|
+
this.portalHost.append(this.dropdown);
|
|
600
|
+
portalParent.append(this.portalHost);
|
|
601
|
+
}
|
|
459
602
|
this.bindEvents();
|
|
460
603
|
}
|
|
461
604
|
bindEvents() {
|
|
@@ -470,6 +613,15 @@ var ForgeSelect = class {
|
|
|
470
613
|
else this.open();
|
|
471
614
|
});
|
|
472
615
|
this.control.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
616
|
+
this.control.addEventListener("mousedown", () => {
|
|
617
|
+
this.pointerDownOnControl = true;
|
|
618
|
+
});
|
|
619
|
+
this.control.addEventListener("focus", () => {
|
|
620
|
+
if (this.opts.openOnFocus && !this.pointerDownOnControl && !this.isOpen && !this.isDisabled) {
|
|
621
|
+
this.open();
|
|
622
|
+
}
|
|
623
|
+
this.pointerDownOnControl = false;
|
|
624
|
+
});
|
|
473
625
|
this.clearBtn.addEventListener("click", (event) => {
|
|
474
626
|
event.stopPropagation();
|
|
475
627
|
this.clearSelection();
|
|
@@ -480,13 +632,45 @@ var ForgeSelect = class {
|
|
|
480
632
|
this.highlightedIndex = -1;
|
|
481
633
|
this.list.scrollTop = 0;
|
|
482
634
|
this.emitter.emit("search", this.query);
|
|
483
|
-
|
|
635
|
+
const trimmed = this.query.trim();
|
|
636
|
+
const belowMinLength = trimmed !== "" && trimmed.length < this.opts.minSearchLength;
|
|
637
|
+
if (this.opts.ajax && !belowMinLength) {
|
|
484
638
|
this.scheduleRemoteLoad(this.query, this.opts.ajax.debounce ?? 250);
|
|
485
639
|
} else {
|
|
640
|
+
if (belowMinLength) {
|
|
641
|
+
if (this.ajaxTimer) {
|
|
642
|
+
clearTimeout(this.ajaxTimer);
|
|
643
|
+
this.ajaxTimer = null;
|
|
644
|
+
}
|
|
645
|
+
this.ajaxController?.abort();
|
|
646
|
+
this.loading = false;
|
|
647
|
+
}
|
|
486
648
|
this.renderList();
|
|
487
649
|
}
|
|
488
650
|
});
|
|
489
651
|
this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
652
|
+
this.searchInput.addEventListener("paste", (event) => {
|
|
653
|
+
if (!this.opts.multiple || !this.opts.allowCreate) return;
|
|
654
|
+
const text = event.clipboardData?.getData("text") ?? "";
|
|
655
|
+
const labels = text.split(/[,\n]+/).map((s) => s.trim()).filter(Boolean);
|
|
656
|
+
if (labels.length < 2) return;
|
|
657
|
+
event.preventDefault();
|
|
658
|
+
const created = [];
|
|
659
|
+
for (const label of labels) {
|
|
660
|
+
const result = this.createTag(label);
|
|
661
|
+
if (result) created.push(result);
|
|
662
|
+
}
|
|
663
|
+
if (created.length === 0) return;
|
|
664
|
+
this.searchInput.value = "";
|
|
665
|
+
this.query = "";
|
|
666
|
+
this.afterSelectionChange();
|
|
667
|
+
for (const result of created) {
|
|
668
|
+
if (result.created) this.emitter.emit("create", result.option);
|
|
669
|
+
this.emitter.emit("select", result.option);
|
|
670
|
+
}
|
|
671
|
+
if (this.opts.closeOnSelect) this.close();
|
|
672
|
+
else this.renderList();
|
|
673
|
+
});
|
|
490
674
|
}
|
|
491
675
|
this.list.addEventListener("click", (event) => {
|
|
492
676
|
const target = event.target;
|
|
@@ -499,7 +683,12 @@ var ForgeSelect = class {
|
|
|
499
683
|
return;
|
|
500
684
|
}
|
|
501
685
|
const li = target.closest("li[data-nav-index]");
|
|
502
|
-
if (!li)
|
|
686
|
+
if (!li) {
|
|
687
|
+
const optionRow = target.closest("li[data-option-value]");
|
|
688
|
+
const option = optionRow ? this.findOption(optionRow.dataset.optionValue) : void 0;
|
|
689
|
+
if (option && this.hasReachedMaximum() && !this.selected.includes(option.value)) this.announceMaximum(option);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
503
692
|
const navIndex = Number(li.dataset.navIndex);
|
|
504
693
|
this.activateNavItem(navIndex);
|
|
505
694
|
});
|
|
@@ -550,36 +739,61 @@ var ForgeSelect = class {
|
|
|
550
739
|
}
|
|
551
740
|
}
|
|
552
741
|
// ---------------------------------------------------------------- selection
|
|
742
|
+
canSelectOption(option) {
|
|
743
|
+
if (this.opts.maxSelections == null) return true;
|
|
744
|
+
const projected = [...this.selected];
|
|
745
|
+
if (!projected.includes(option.value)) projected.push(option.value);
|
|
746
|
+
for (const value of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
747
|
+
if (!projected.includes(value)) projected.push(value);
|
|
748
|
+
}
|
|
749
|
+
syncTreeAncestors(this.data, projected, this.isOptionDisabled);
|
|
750
|
+
return projected.length <= this.opts.maxSelections;
|
|
751
|
+
}
|
|
752
|
+
hasReachedMaximum() {
|
|
753
|
+
return this.opts.maxSelections != null && this.selected.length >= this.opts.maxSelections;
|
|
754
|
+
}
|
|
755
|
+
announceMaximum(option) {
|
|
756
|
+
const limit = this.opts.maxSelections;
|
|
757
|
+
if (limit == null) return;
|
|
758
|
+
this.liveRegion.textContent = format(this.strings.maximumSelected, { count: String(limit) });
|
|
759
|
+
this.emitter.emit("maximum", { limit, option });
|
|
760
|
+
}
|
|
553
761
|
selectValue(value, notify) {
|
|
554
762
|
if (this.selected.includes(value)) return;
|
|
555
763
|
const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
|
|
556
764
|
this.selectedOptions.set(value, option);
|
|
557
765
|
if (this.opts.multiple) {
|
|
558
766
|
this.selected.push(value);
|
|
559
|
-
for (const v of collectDescendantValues(option)) {
|
|
767
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
560
768
|
if (!this.selected.includes(v)) this.selected.push(v);
|
|
561
769
|
}
|
|
562
770
|
this.syncTreeAncestors();
|
|
563
771
|
} else {
|
|
564
772
|
this.selected = [value];
|
|
565
773
|
}
|
|
566
|
-
if (notify)
|
|
774
|
+
if (notify) {
|
|
775
|
+
this.afterSelectionChange();
|
|
776
|
+
this.emitter.emit("select", option);
|
|
777
|
+
}
|
|
567
778
|
}
|
|
568
779
|
deselectValue(value, notify) {
|
|
569
780
|
const index = this.selected.indexOf(value);
|
|
570
781
|
if (index === -1) return;
|
|
782
|
+
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
571
783
|
this.selected.splice(index, 1);
|
|
572
784
|
if (this.opts.multiple) {
|
|
573
|
-
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
574
785
|
if (option) {
|
|
575
|
-
for (const v of collectDescendantValues(option)) {
|
|
786
|
+
for (const v of collectDescendantValues(option, this.isOptionDisabled)) {
|
|
576
787
|
const i = this.selected.indexOf(v);
|
|
577
788
|
if (i !== -1) this.selected.splice(i, 1);
|
|
578
789
|
}
|
|
579
790
|
}
|
|
580
791
|
this.syncTreeAncestors();
|
|
581
792
|
}
|
|
582
|
-
if (notify)
|
|
793
|
+
if (notify) {
|
|
794
|
+
this.afterSelectionChange();
|
|
795
|
+
this.emitter.emit("unselect", option ?? { value, label: value });
|
|
796
|
+
}
|
|
583
797
|
}
|
|
584
798
|
/**
|
|
585
799
|
* Keeps every tree parent's own membership in `selected` consistent with
|
|
@@ -588,7 +802,7 @@ var ForgeSelect = class {
|
|
|
588
802
|
* No-op for data with no `children` anywhere.
|
|
589
803
|
*/
|
|
590
804
|
syncTreeAncestors() {
|
|
591
|
-
syncTreeAncestors(this.data, this.selected);
|
|
805
|
+
syncTreeAncestors(this.data, this.selected, this.isOptionDisabled);
|
|
592
806
|
}
|
|
593
807
|
clearSelection() {
|
|
594
808
|
if (this.selected.length === 0) return;
|
|
@@ -596,9 +810,22 @@ var ForgeSelect = class {
|
|
|
596
810
|
this.emitter.emit("clear");
|
|
597
811
|
this.afterSelectionChange();
|
|
598
812
|
}
|
|
813
|
+
allSelectableValues() {
|
|
814
|
+
const values = [];
|
|
815
|
+
const visit = (option) => {
|
|
816
|
+
if (!this.isOptionDisabled(option)) values.push(option.value);
|
|
817
|
+
option.children?.forEach(visit);
|
|
818
|
+
};
|
|
819
|
+
for (const item of this.data) (isGroup(item) ? item.options : [item]).forEach(visit);
|
|
820
|
+
return values;
|
|
821
|
+
}
|
|
599
822
|
afterSelectionChange(emitChange = true) {
|
|
600
823
|
this.renderValue();
|
|
601
824
|
this.syncNativeSelect(emitChange);
|
|
825
|
+
if (!this.opts.required || this.selected.length > 0) {
|
|
826
|
+
this.control.classList.remove("forge-select__control--invalid");
|
|
827
|
+
this.control.removeAttribute("aria-invalid");
|
|
828
|
+
}
|
|
602
829
|
if (this.isOpen) this.renderList();
|
|
603
830
|
if (emitChange) this.emitter.emit("change", this.getValue());
|
|
604
831
|
}
|
|
@@ -634,17 +861,58 @@ var ForgeSelect = class {
|
|
|
634
861
|
findOption(value) {
|
|
635
862
|
return findOption(this.data, value);
|
|
636
863
|
}
|
|
864
|
+
findOptionByLabel(label) {
|
|
865
|
+
const lower = label.toLowerCase();
|
|
866
|
+
const search = (options) => {
|
|
867
|
+
for (const option of options) {
|
|
868
|
+
if (option.label.toLowerCase() === lower) return option;
|
|
869
|
+
const found = option.children ? search(option.children) : void 0;
|
|
870
|
+
if (found) return found;
|
|
871
|
+
}
|
|
872
|
+
return void 0;
|
|
873
|
+
};
|
|
874
|
+
for (const item of this.data) {
|
|
875
|
+
const found = search(isGroup(item) ? item.options : [item]);
|
|
876
|
+
if (found) return found;
|
|
877
|
+
}
|
|
878
|
+
return void 0;
|
|
879
|
+
}
|
|
880
|
+
/** Selects an existing option matching `label` exactly, or creates and selects a new one. */
|
|
881
|
+
createTag(label) {
|
|
882
|
+
const trimmed = label.trim();
|
|
883
|
+
if (!trimmed) return void 0;
|
|
884
|
+
const existing = this.findOptionByLabel(trimmed);
|
|
885
|
+
if (existing) {
|
|
886
|
+
if (this.selected.includes(existing.value)) return void 0;
|
|
887
|
+
if (this.opts.multiple && !this.canSelectOption(existing)) {
|
|
888
|
+
this.announceMaximum(existing);
|
|
889
|
+
return void 0;
|
|
890
|
+
}
|
|
891
|
+
this.selectValue(existing.value, false);
|
|
892
|
+
return { option: existing, created: false };
|
|
893
|
+
}
|
|
894
|
+
const option = { value: trimmed, label: trimmed };
|
|
895
|
+
if (this.opts.multiple && !this.canSelectOption(option)) {
|
|
896
|
+
this.announceMaximum(option);
|
|
897
|
+
return void 0;
|
|
898
|
+
}
|
|
899
|
+
this.data.push(option);
|
|
900
|
+
this.selectValue(option.value, false);
|
|
901
|
+
return { option, created: true };
|
|
902
|
+
}
|
|
637
903
|
createFromQuery() {
|
|
638
904
|
const label = this.query.trim();
|
|
639
905
|
if (!label) return;
|
|
640
|
-
const
|
|
641
|
-
|
|
906
|
+
const result = this.createTag(label);
|
|
907
|
+
if (!result) return;
|
|
642
908
|
if (this.searchInput) {
|
|
643
909
|
this.searchInput.value = "";
|
|
644
910
|
this.query = "";
|
|
645
911
|
}
|
|
646
|
-
this.
|
|
647
|
-
if (
|
|
912
|
+
this.afterSelectionChange();
|
|
913
|
+
if (result.created) this.emitter.emit("create", result.option);
|
|
914
|
+
this.emitter.emit("select", result.option);
|
|
915
|
+
if (!this.opts.multiple || this.opts.closeOnSelect) this.close();
|
|
648
916
|
}
|
|
649
917
|
activateNavItem(navIndex) {
|
|
650
918
|
const item = this.navItems[navIndex];
|
|
@@ -655,8 +923,17 @@ var ForgeSelect = class {
|
|
|
655
923
|
}
|
|
656
924
|
const { value } = item.option;
|
|
657
925
|
if (this.opts.multiple) {
|
|
658
|
-
|
|
659
|
-
|
|
926
|
+
let changed = false;
|
|
927
|
+
if (this.selected.includes(value)) {
|
|
928
|
+
this.deselectValue(value, true);
|
|
929
|
+
changed = true;
|
|
930
|
+
} else if (this.canSelectOption(item.option)) {
|
|
931
|
+
this.selectValue(value, true);
|
|
932
|
+
changed = true;
|
|
933
|
+
} else {
|
|
934
|
+
this.announceMaximum(item.option);
|
|
935
|
+
}
|
|
936
|
+
if (changed && this.opts.closeOnSelect) this.close();
|
|
660
937
|
} else {
|
|
661
938
|
this.selectValue(value, true);
|
|
662
939
|
this.close();
|
|
@@ -772,6 +1049,7 @@ var ForgeSelect = class {
|
|
|
772
1049
|
this.selected = order;
|
|
773
1050
|
this.suppressNextTagClick = true;
|
|
774
1051
|
this.afterSelectionChange();
|
|
1052
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
775
1053
|
};
|
|
776
1054
|
tag.addEventListener("pointerdown", (event) => {
|
|
777
1055
|
if (this.isDisabled || event.button !== 0) return;
|
|
@@ -795,6 +1073,7 @@ var ForgeSelect = class {
|
|
|
795
1073
|
[next[index], next[targetIndex]] = [next[targetIndex], next[index]];
|
|
796
1074
|
this.selected = next;
|
|
797
1075
|
this.afterSelectionChange();
|
|
1076
|
+
this.emitter.emit("reorder", [...this.selected]);
|
|
798
1077
|
this.focusTagByValue(value);
|
|
799
1078
|
}
|
|
800
1079
|
focusTagByValue(value) {
|
|
@@ -808,12 +1087,14 @@ var ForgeSelect = class {
|
|
|
808
1087
|
buildRows() {
|
|
809
1088
|
this.rows = [];
|
|
810
1089
|
this.navItems = [];
|
|
811
|
-
const
|
|
812
|
-
const
|
|
1090
|
+
const trimmedQuery = this.query.trim();
|
|
1091
|
+
const query = trimmedQuery.toLowerCase();
|
|
1092
|
+
const matches = (option) => query === "" || (this.opts.filterOption ? this.opts.filterOption(option, trimmedQuery) : option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false));
|
|
813
1093
|
const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
|
|
814
1094
|
const pushOption = (option, depth, parentValue) => {
|
|
815
1095
|
let navIndex = -1;
|
|
816
|
-
|
|
1096
|
+
const interactionDisabled = this.isOptionDisabled(option) || this.hasReachedMaximum() && !this.selected.includes(option.value);
|
|
1097
|
+
if (!interactionDisabled) {
|
|
817
1098
|
navIndex = this.navItems.length;
|
|
818
1099
|
this.navItems.push({ kind: "option", option, parentValue });
|
|
819
1100
|
}
|
|
@@ -828,6 +1109,10 @@ var ForgeSelect = class {
|
|
|
828
1109
|
}
|
|
829
1110
|
}
|
|
830
1111
|
};
|
|
1112
|
+
if (trimmedQuery !== "" && trimmedQuery.length < this.opts.minSearchLength) {
|
|
1113
|
+
this.rows.push({ kind: "min-length" });
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
831
1116
|
if (this.loading) {
|
|
832
1117
|
this.rows.push({ kind: "loading" });
|
|
833
1118
|
return;
|
|
@@ -855,12 +1140,7 @@ var ForgeSelect = class {
|
|
|
855
1140
|
else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
|
|
856
1141
|
}
|
|
857
1142
|
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;
|
|
1143
|
+
return !!this.findOptionByLabel(lowerQuery);
|
|
864
1144
|
}
|
|
865
1145
|
usesVirtualScroll() {
|
|
866
1146
|
return this.opts.virtualScroll !== false && this.rows.length > VIRTUAL_THRESHOLD;
|
|
@@ -872,7 +1152,7 @@ var ForgeSelect = class {
|
|
|
872
1152
|
}
|
|
873
1153
|
announceStatus() {
|
|
874
1154
|
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 : "";
|
|
1155
|
+
const message = this.hasReachedMaximum() ? format(this.strings.maximumSelected, { count: String(this.opts.maxSelections) }) : first?.kind === "loading" ? this.strings.loading : first?.kind === "error" ? this.strings.errorLoading : first?.kind === "empty" ? this.strings.noResults : first?.kind === "min-length" ? format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) }) : "";
|
|
876
1156
|
if (this.liveRegion.textContent !== message) this.liveRegion.textContent = message;
|
|
877
1157
|
}
|
|
878
1158
|
renderRows() {
|
|
@@ -923,6 +1203,13 @@ var ForgeSelect = class {
|
|
|
923
1203
|
li.setAttribute("aria-selected", "false");
|
|
924
1204
|
li.textContent = this.strings.noResults;
|
|
925
1205
|
break;
|
|
1206
|
+
case "min-length":
|
|
1207
|
+
li.className = "forge-select__min-length";
|
|
1208
|
+
li.setAttribute("role", "option");
|
|
1209
|
+
li.setAttribute("aria-disabled", "true");
|
|
1210
|
+
li.setAttribute("aria-selected", "false");
|
|
1211
|
+
li.textContent = format(this.strings.minSearchLength, { count: String(this.opts.minSearchLength) });
|
|
1212
|
+
break;
|
|
926
1213
|
case "error":
|
|
927
1214
|
li.className = "forge-select__error";
|
|
928
1215
|
li.setAttribute("role", "option");
|
|
@@ -952,17 +1239,19 @@ var ForgeSelect = class {
|
|
|
952
1239
|
break;
|
|
953
1240
|
case "option": {
|
|
954
1241
|
li.className = "forge-select__option";
|
|
1242
|
+
li.dataset.optionValue = row.option.value;
|
|
1243
|
+
if (row.option.className) li.classList.add(...row.option.className.trim().split(/\s+/).filter(Boolean));
|
|
955
1244
|
li.setAttribute("role", "option");
|
|
956
1245
|
const isSelected = this.selected.includes(row.option.value);
|
|
957
1246
|
li.setAttribute("aria-selected", String(isSelected));
|
|
958
1247
|
if (isSelected) li.classList.add("forge-select__option--selected");
|
|
959
|
-
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
|
|
1248
|
+
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected, this.isOptionDisabled) === "some") {
|
|
960
1249
|
li.classList.add("forge-select__option--indeterminate");
|
|
961
1250
|
}
|
|
962
1251
|
if (row.depth > 0) {
|
|
963
1252
|
li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
|
|
964
1253
|
}
|
|
965
|
-
if (row.option.
|
|
1254
|
+
if (this.isOptionDisabled(row.option) || this.hasReachedMaximum() && !this.selected.includes(row.option.value)) {
|
|
966
1255
|
li.classList.add("forge-select__option--disabled");
|
|
967
1256
|
li.setAttribute("aria-disabled", "true");
|
|
968
1257
|
} else {
|
|
@@ -1118,10 +1407,15 @@ var ForgeSelect = class {
|
|
|
1118
1407
|
this.ajaxController = controller;
|
|
1119
1408
|
const page = append ? this.page + 1 : 0;
|
|
1120
1409
|
try {
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1410
|
+
let json;
|
|
1411
|
+
if (ajax.request) {
|
|
1412
|
+
json = await ajax.request(query, page, controller.signal);
|
|
1413
|
+
} else {
|
|
1414
|
+
const url = buildUrl(ajax, query, page);
|
|
1415
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1416
|
+
if (response.ok === false) throw new Error(`ForgeSelect: remote request failed with HTTP ${response.status}`);
|
|
1417
|
+
json = await response.json();
|
|
1418
|
+
}
|
|
1125
1419
|
if (activeRequestId !== this.ajaxRequestId || this.destroyed) return;
|
|
1126
1420
|
const { options, hasMore } = normalizeRemoteResult(ajax, json);
|
|
1127
1421
|
if (append) {
|