forge-select 0.1.0 → 0.2.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 +13 -10
- package/dist/index.cjs +174 -24
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -2
- package/dist/index.d.ts +37 -2
- package/dist/index.global.js +174 -24
- package/dist/index.global.js.map +1 -1
- package/dist/index.js +174 -24
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
- package/styles/forge-select.css +17 -1
package/README.md
CHANGED
|
@@ -43,7 +43,7 @@ Browse the documentation website at **<https://cmm-cmm.github.io/ForgeSelect/doc
|
|
|
43
43
|
- Single Select
|
|
44
44
|
- Multiple Select
|
|
45
45
|
- Searchable Dropdown
|
|
46
|
-
- Async Data Source (AJAX with debounce)
|
|
46
|
+
- Async Data Source (AJAX with debounce, optional infinite-scroll pagination)
|
|
47
47
|
- Rich Item Rendering (avatar + label + description, XSS-safe built-in fields)
|
|
48
48
|
- Virtual Scrolling (automatic for large lists, with per-option render caching)
|
|
49
49
|
- Custom Templates
|
|
@@ -51,6 +51,7 @@ Browse the documentation website at **<https://cmm-cmm.github.io/ForgeSelect/doc
|
|
|
51
51
|
- Keyboard Navigation
|
|
52
52
|
- Disabled Options
|
|
53
53
|
- Option Groups
|
|
54
|
+
- Tree Select (nested options with expand/collapse and cascading multi-select)
|
|
54
55
|
- Clear Selection
|
|
55
56
|
- Placeholder
|
|
56
57
|
- Custom Themes (CSS variables, dark mode included)
|
|
@@ -59,7 +60,7 @@ Browse the documentation website at **<https://cmm-cmm.github.io/ForgeSelect/doc
|
|
|
59
60
|
- Internationalization (en/vi built in, custom string tables)
|
|
60
61
|
- TypeScript Support (written in strict TypeScript, ships `.d.ts`)
|
|
61
62
|
|
|
62
|
-
> Planned/in-progress capabilities —
|
|
63
|
+
> Planned/in-progress capabilities — Drag & Drop Ordering and Angular/Svelte wrappers — are tracked in the [Roadmap](#roadmap) below and intentionally not listed above as shipped features.
|
|
63
64
|
|
|
64
65
|
## Installation
|
|
65
66
|
|
|
@@ -161,11 +162,13 @@ new ForgeSelect("#country", { theme: "dark" });
|
|
|
161
162
|
|
|
162
163
|
## Framework Support
|
|
163
164
|
|
|
165
|
+
ForgeSelect is vanilla TypeScript/JavaScript, so it can be mounted inside any framework today. Official wrapper packages exist for a couple of them:
|
|
166
|
+
|
|
164
167
|
- Vanilla JavaScript
|
|
165
|
-
- React
|
|
166
|
-
- Vue
|
|
167
|
-
- Angular
|
|
168
|
-
- Svelte
|
|
168
|
+
- React — via [`@forge-select/react`](./packages/react/README.md) (`ForgeSelectReact` component, controlled `value`/`onChange`)
|
|
169
|
+
- Vue — via [`@forge-select/vue`](./packages/vue/README.md) (`ForgeSelectVue` component, `v-model` support)
|
|
170
|
+
- Angular — mount manually for now; a dedicated wrapper is on the [Roadmap](#roadmap)
|
|
171
|
+
- Svelte — mount manually for now; a dedicated wrapper is on the [Roadmap](#roadmap)
|
|
169
172
|
- Next.js
|
|
170
173
|
- Nuxt
|
|
171
174
|
- Astro
|
|
@@ -188,14 +191,14 @@ Performance benchmarking (bundle size, init time, search latency, virtual scroll
|
|
|
188
191
|
|
|
189
192
|
## Roadmap
|
|
190
193
|
|
|
191
|
-
- [
|
|
194
|
+
- [x] Tree Select
|
|
192
195
|
- [x] Virtualized List
|
|
193
|
-
- [
|
|
196
|
+
- [x] Async Pagination
|
|
194
197
|
- [ ] Drag & Drop Ordering
|
|
195
198
|
- [x] Theme Builder
|
|
196
199
|
- [x] CSS Variables
|
|
197
|
-
- [
|
|
198
|
-
- [
|
|
200
|
+
- [x] React Component
|
|
201
|
+
- [x] Vue Component
|
|
199
202
|
- [ ] Angular Component
|
|
200
203
|
- [ ] Svelte Component
|
|
201
204
|
|
package/dist/index.cjs
CHANGED
|
@@ -56,6 +56,7 @@ var locales = {
|
|
|
56
56
|
en: {
|
|
57
57
|
noResults: "No results found",
|
|
58
58
|
loading: "Loading\u2026",
|
|
59
|
+
loadingMore: "Loading more\u2026",
|
|
59
60
|
createOption: 'Create "{query}"',
|
|
60
61
|
clearSelection: "Clear selection",
|
|
61
62
|
removeItem: "Remove {label}",
|
|
@@ -64,6 +65,7 @@ var locales = {
|
|
|
64
65
|
vi: {
|
|
65
66
|
noResults: "Kh\xF4ng t\xECm th\u1EA5y k\u1EBFt qu\u1EA3",
|
|
66
67
|
loading: "\u0110ang t\u1EA3i\u2026",
|
|
68
|
+
loadingMore: "\u0110ang t\u1EA3i th\xEAm\u2026",
|
|
67
69
|
createOption: 'T\u1EA1o "{query}"',
|
|
68
70
|
clearSelection: "X\xF3a l\u1EF1a ch\u1ECDn",
|
|
69
71
|
removeItem: "X\xF3a {label}",
|
|
@@ -89,6 +91,23 @@ var uidCounter = 0;
|
|
|
89
91
|
function isGroup(item) {
|
|
90
92
|
return item.options !== void 0;
|
|
91
93
|
}
|
|
94
|
+
function collectDescendantValues(option) {
|
|
95
|
+
if (!option.children) return [];
|
|
96
|
+
const values = [];
|
|
97
|
+
for (const child of option.children) {
|
|
98
|
+
values.push(child.value, ...collectDescendantValues(child));
|
|
99
|
+
}
|
|
100
|
+
return values;
|
|
101
|
+
}
|
|
102
|
+
function computeCheckState(option, selected) {
|
|
103
|
+
if (!option.children || option.children.length === 0) {
|
|
104
|
+
return selected.includes(option.value) ? "all" : "none";
|
|
105
|
+
}
|
|
106
|
+
const states = option.children.map((child) => computeCheckState(child, selected));
|
|
107
|
+
if (states.every((s) => s === "all")) return "all";
|
|
108
|
+
if (states.every((s) => s === "none")) return "none";
|
|
109
|
+
return "some";
|
|
110
|
+
}
|
|
92
111
|
var ForgeSelect = class {
|
|
93
112
|
constructor(target, options = {}) {
|
|
94
113
|
this.selected = [];
|
|
@@ -104,7 +123,11 @@ var ForgeSelect = class {
|
|
|
104
123
|
this.navItems = [];
|
|
105
124
|
this.highlightedIndex = -1;
|
|
106
125
|
this.rowContentCache = /* @__PURE__ */ new Map();
|
|
126
|
+
this.expandedValues = /* @__PURE__ */ new Set();
|
|
107
127
|
this.loading = false;
|
|
128
|
+
this.loadingMore = false;
|
|
129
|
+
this.page = 0;
|
|
130
|
+
this.hasMore = true;
|
|
108
131
|
this.ajaxTimer = null;
|
|
109
132
|
this.ajaxRequestId = 0;
|
|
110
133
|
this.remoteLoaded = false;
|
|
@@ -294,13 +317,23 @@ var ForgeSelect = class {
|
|
|
294
317
|
this.searchInput.addEventListener("keydown", (event) => this.handleKeydown(event));
|
|
295
318
|
}
|
|
296
319
|
this.list.addEventListener("click", (event) => {
|
|
297
|
-
const
|
|
320
|
+
const target = event.target;
|
|
321
|
+
const twisty = target.closest("[data-twisty]");
|
|
322
|
+
if (twisty) {
|
|
323
|
+
const value = twisty.dataset.twisty;
|
|
324
|
+
if (this.expandedValues.has(value)) this.expandedValues.delete(value);
|
|
325
|
+
else this.expandedValues.add(value);
|
|
326
|
+
this.renderList();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const li = target.closest("li[data-nav-index]");
|
|
298
330
|
if (!li) return;
|
|
299
331
|
const navIndex = Number(li.dataset.navIndex);
|
|
300
332
|
this.activateNavItem(navIndex);
|
|
301
333
|
});
|
|
302
334
|
this.list.addEventListener("scroll", () => {
|
|
303
335
|
if (this.usesVirtualScroll()) this.renderRows();
|
|
336
|
+
this.maybeLoadNextPage();
|
|
304
337
|
});
|
|
305
338
|
}
|
|
306
339
|
handleKeydown(event) {
|
|
@@ -343,16 +376,53 @@ var ForgeSelect = class {
|
|
|
343
376
|
if (this.selected.includes(value)) return;
|
|
344
377
|
const option = this.findOption(value) ?? this.selectedOptions.get(value) ?? { value, label: value };
|
|
345
378
|
this.selectedOptions.set(value, option);
|
|
346
|
-
if (this.opts.multiple)
|
|
347
|
-
|
|
379
|
+
if (this.opts.multiple) {
|
|
380
|
+
this.selected.push(value);
|
|
381
|
+
for (const v of collectDescendantValues(option)) {
|
|
382
|
+
if (!this.selected.includes(v)) this.selected.push(v);
|
|
383
|
+
}
|
|
384
|
+
this.syncTreeAncestors();
|
|
385
|
+
} else {
|
|
386
|
+
this.selected = [value];
|
|
387
|
+
}
|
|
348
388
|
if (notify) this.afterSelectionChange();
|
|
349
389
|
}
|
|
350
390
|
deselectValue(value, notify) {
|
|
351
391
|
const index = this.selected.indexOf(value);
|
|
352
392
|
if (index === -1) return;
|
|
353
393
|
this.selected.splice(index, 1);
|
|
394
|
+
if (this.opts.multiple) {
|
|
395
|
+
const option = this.findOption(value) ?? this.selectedOptions.get(value);
|
|
396
|
+
if (option) {
|
|
397
|
+
for (const v of collectDescendantValues(option)) {
|
|
398
|
+
const i = this.selected.indexOf(v);
|
|
399
|
+
if (i !== -1) this.selected.splice(i, 1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
this.syncTreeAncestors();
|
|
403
|
+
}
|
|
354
404
|
if (notify) this.afterSelectionChange();
|
|
355
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Keeps every tree parent's own membership in `selected` consistent with
|
|
408
|
+
* its descendants (post-order, so parents see already-corrected children):
|
|
409
|
+
* a parent counts as selected only when `computeCheckState` says "all".
|
|
410
|
+
* No-op for data with no `children` anywhere.
|
|
411
|
+
*/
|
|
412
|
+
syncTreeAncestors() {
|
|
413
|
+
const sync = (option) => {
|
|
414
|
+
if (!option.children || option.children.length === 0) return;
|
|
415
|
+
for (const child of option.children) sync(child);
|
|
416
|
+
const state = computeCheckState(option, this.selected);
|
|
417
|
+
const index = this.selected.indexOf(option.value);
|
|
418
|
+
if (state === "all" && index === -1) this.selected.push(option.value);
|
|
419
|
+
else if (state !== "all" && index !== -1) this.selected.splice(index, 1);
|
|
420
|
+
};
|
|
421
|
+
for (const item of this.data) {
|
|
422
|
+
const options = isGroup(item) ? item.options : [item];
|
|
423
|
+
options.forEach(sync);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
356
426
|
clearSelection() {
|
|
357
427
|
if (this.selected.length === 0) return;
|
|
358
428
|
this.selected = [];
|
|
@@ -383,13 +453,19 @@ var ForgeSelect = class {
|
|
|
383
453
|
this.el.dispatchEvent(new Event("change", { bubbles: true }));
|
|
384
454
|
}
|
|
385
455
|
findOption(value) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
if (
|
|
390
|
-
|
|
391
|
-
|
|
456
|
+
const search = (options) => {
|
|
457
|
+
for (const option of options) {
|
|
458
|
+
if (option.value === value) return option;
|
|
459
|
+
if (option.children) {
|
|
460
|
+
const found = search(option.children);
|
|
461
|
+
if (found) return found;
|
|
462
|
+
}
|
|
392
463
|
}
|
|
464
|
+
return void 0;
|
|
465
|
+
};
|
|
466
|
+
for (const item of this.data) {
|
|
467
|
+
const found = search(isGroup(item) ? item.options : [item]);
|
|
468
|
+
if (found) return found;
|
|
393
469
|
}
|
|
394
470
|
return void 0;
|
|
395
471
|
}
|
|
@@ -507,13 +583,23 @@ var ForgeSelect = class {
|
|
|
507
583
|
this.navItems = [];
|
|
508
584
|
const query = this.query.trim().toLowerCase();
|
|
509
585
|
const matches = (option) => query === "" || option.label.toLowerCase().includes(query) || (option.description?.toLowerCase().includes(query) ?? false);
|
|
510
|
-
const
|
|
586
|
+
const subtreeMatches = (option) => query === "" || matches(option) || (option.children ?? []).some(subtreeMatches);
|
|
587
|
+
const pushOption = (option, depth) => {
|
|
511
588
|
let navIndex = -1;
|
|
512
589
|
if (!option.disabled) {
|
|
513
590
|
navIndex = this.navItems.length;
|
|
514
591
|
this.navItems.push({ kind: "option", option });
|
|
515
592
|
}
|
|
516
|
-
|
|
593
|
+
const hasChildren = !!option.children && option.children.length > 0;
|
|
594
|
+
this.rows.push({ kind: "option", option, navIndex, depth, hasChildren });
|
|
595
|
+
if (hasChildren) {
|
|
596
|
+
const expanded = query !== "" || this.expandedValues.has(option.value);
|
|
597
|
+
if (expanded) {
|
|
598
|
+
for (const child of option.children) {
|
|
599
|
+
if (subtreeMatches(child)) pushOption(child, depth + 1);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
517
603
|
};
|
|
518
604
|
if (this.loading) {
|
|
519
605
|
this.rows.push({ kind: "loading" });
|
|
@@ -521,12 +607,12 @@ var ForgeSelect = class {
|
|
|
521
607
|
}
|
|
522
608
|
for (const item of this.data) {
|
|
523
609
|
if (isGroup(item)) {
|
|
524
|
-
const visible = item.options.filter(
|
|
610
|
+
const visible = item.options.filter(subtreeMatches);
|
|
525
611
|
if (visible.length === 0) continue;
|
|
526
612
|
this.rows.push({ kind: "group", label: item.label });
|
|
527
|
-
visible.forEach(pushOption);
|
|
528
|
-
} else if (
|
|
529
|
-
pushOption(item);
|
|
613
|
+
visible.forEach((o) => pushOption(o, 0));
|
|
614
|
+
} else if (subtreeMatches(item)) {
|
|
615
|
+
pushOption(item, 0);
|
|
530
616
|
}
|
|
531
617
|
}
|
|
532
618
|
if (this.opts.allowCreate && query !== "" && !this.hasExactMatch(query)) {
|
|
@@ -535,11 +621,13 @@ var ForgeSelect = class {
|
|
|
535
621
|
this.rows.push({ kind: "create", navIndex });
|
|
536
622
|
}
|
|
537
623
|
if (this.rows.length === 0) this.rows.push({ kind: "empty" });
|
|
624
|
+
else if (this.loadingMore) this.rows.push({ kind: "loading-more" });
|
|
538
625
|
}
|
|
539
626
|
hasExactMatch(lowerQuery) {
|
|
627
|
+
const matchesExactly = (option) => option.label.toLowerCase() === lowerQuery || (option.children ?? []).some(matchesExactly);
|
|
540
628
|
for (const item of this.data) {
|
|
541
629
|
const options = isGroup(item) ? item.options : [item];
|
|
542
|
-
if (options.some(
|
|
630
|
+
if (options.some(matchesExactly)) return true;
|
|
543
631
|
}
|
|
544
632
|
return false;
|
|
545
633
|
}
|
|
@@ -599,6 +687,11 @@ var ForgeSelect = class {
|
|
|
599
687
|
li.className = "forge-select__loading";
|
|
600
688
|
li.textContent = this.strings.loading;
|
|
601
689
|
break;
|
|
690
|
+
case "loading-more":
|
|
691
|
+
li.className = "forge-select__loading-more";
|
|
692
|
+
li.setAttribute("aria-hidden", "true");
|
|
693
|
+
li.textContent = this.strings.loadingMore;
|
|
694
|
+
break;
|
|
602
695
|
case "create":
|
|
603
696
|
li.className = "forge-select__option forge-select__option--create";
|
|
604
697
|
li.setAttribute("role", "option");
|
|
@@ -613,6 +706,12 @@ var ForgeSelect = class {
|
|
|
613
706
|
const isSelected = this.selected.includes(row.option.value);
|
|
614
707
|
li.setAttribute("aria-selected", String(isSelected));
|
|
615
708
|
if (isSelected) li.classList.add("forge-select__option--selected");
|
|
709
|
+
if (this.opts.multiple && row.hasChildren && computeCheckState(row.option, this.selected) === "some") {
|
|
710
|
+
li.classList.add("forge-select__option--indeterminate");
|
|
711
|
+
}
|
|
712
|
+
if (row.depth > 0) {
|
|
713
|
+
li.style.paddingLeft = `calc(12px + ${row.depth} * var(--fs-tree-indent, 18px))`;
|
|
714
|
+
}
|
|
616
715
|
if (row.option.disabled) {
|
|
617
716
|
li.classList.add("forge-select__option--disabled");
|
|
618
717
|
li.setAttribute("aria-disabled", "true");
|
|
@@ -621,6 +720,14 @@ var ForgeSelect = class {
|
|
|
621
720
|
li.dataset.navIndex = String(row.navIndex);
|
|
622
721
|
if (row.navIndex === this.highlightedIndex) li.classList.add("forge-select__option--highlighted");
|
|
623
722
|
}
|
|
723
|
+
if (row.hasChildren) {
|
|
724
|
+
const twisty = document.createElement("span");
|
|
725
|
+
twisty.className = "forge-select__twisty";
|
|
726
|
+
twisty.dataset.twisty = row.option.value;
|
|
727
|
+
twisty.setAttribute("aria-hidden", "true");
|
|
728
|
+
twisty.textContent = this.expandedValues.has(row.option.value) ? "\u25BC" : "\u25B6";
|
|
729
|
+
li.append(twisty);
|
|
730
|
+
}
|
|
624
731
|
li.append(this.optionContent(row.option));
|
|
625
732
|
break;
|
|
626
733
|
}
|
|
@@ -683,30 +790,62 @@ var ForgeSelect = class {
|
|
|
683
790
|
// ---------------------------------------------------------------- remote data
|
|
684
791
|
scheduleRemoteLoad(query, delay) {
|
|
685
792
|
if (this.ajaxTimer) clearTimeout(this.ajaxTimer);
|
|
793
|
+
this.page = 0;
|
|
794
|
+
this.hasMore = true;
|
|
686
795
|
this.loading = true;
|
|
687
796
|
this.renderList();
|
|
688
797
|
this.ajaxTimer = setTimeout(() => {
|
|
689
798
|
void this.loadRemote(query);
|
|
690
799
|
}, delay);
|
|
691
800
|
}
|
|
692
|
-
|
|
801
|
+
/**
|
|
802
|
+
* Fires on every list scroll. Only acts when pagination is opted into via
|
|
803
|
+
* `ajax.pagination`; reads real scroll geometry rather than row counts so
|
|
804
|
+
* it works whether or not virtual scrolling is active for this list.
|
|
805
|
+
*/
|
|
806
|
+
maybeLoadNextPage() {
|
|
807
|
+
const ajax = this.opts.ajax;
|
|
808
|
+
if (!ajax?.pagination || !this.hasMore || this.loading || this.loadingMore) return;
|
|
809
|
+
const { scrollHeight, scrollTop, clientHeight } = this.list;
|
|
810
|
+
const threshold = this.opts.itemHeight * 2;
|
|
811
|
+
if (scrollHeight - scrollTop - clientHeight >= threshold) return;
|
|
812
|
+
this.loadingMore = true;
|
|
813
|
+
this.renderList();
|
|
814
|
+
void this.loadRemote(this.query, { append: true });
|
|
815
|
+
}
|
|
816
|
+
async loadRemote(query, { append = false } = {}) {
|
|
693
817
|
const ajax = this.opts.ajax;
|
|
694
818
|
const requestId = ++this.ajaxRequestId;
|
|
819
|
+
const page = append ? this.page + 1 : 0;
|
|
695
820
|
try {
|
|
696
|
-
const url = buildUrl(ajax, query);
|
|
821
|
+
const url = buildUrl(ajax, query, page);
|
|
697
822
|
const response = await fetch(url);
|
|
698
823
|
const json = await response.json();
|
|
699
824
|
if (requestId !== this.ajaxRequestId || this.destroyed) return;
|
|
700
|
-
|
|
701
|
-
|
|
825
|
+
const result = ajax.transform ? ajax.transform(json) : json;
|
|
826
|
+
const options = Array.isArray(result) ? result : result.options;
|
|
827
|
+
const hasMore = ajax.pagination ? Array.isArray(result) ? false : result.hasMore : false;
|
|
828
|
+
if (append) {
|
|
829
|
+
const existing = collectValues(this.data);
|
|
830
|
+
this.data = [...this.data, ...options.filter((o) => !existing.has(o.value))];
|
|
831
|
+
} else {
|
|
832
|
+
this.data = options;
|
|
833
|
+
this.rowContentCache.clear();
|
|
834
|
+
}
|
|
835
|
+
this.page = page;
|
|
836
|
+
this.hasMore = hasMore;
|
|
702
837
|
this.remoteLoaded = true;
|
|
703
838
|
} catch {
|
|
704
839
|
if (requestId !== this.ajaxRequestId || this.destroyed) return;
|
|
705
|
-
|
|
706
|
-
|
|
840
|
+
if (!append) {
|
|
841
|
+
this.data = [];
|
|
842
|
+
this.rowContentCache.clear();
|
|
843
|
+
}
|
|
844
|
+
this.hasMore = false;
|
|
707
845
|
} finally {
|
|
708
846
|
if (requestId === this.ajaxRequestId && !this.destroyed) {
|
|
709
847
|
this.loading = false;
|
|
848
|
+
this.loadingMore = false;
|
|
710
849
|
if (this.isOpen) this.renderList();
|
|
711
850
|
}
|
|
712
851
|
}
|
|
@@ -733,16 +872,27 @@ function parseOption(option) {
|
|
|
733
872
|
disabled: option.disabled || void 0
|
|
734
873
|
};
|
|
735
874
|
}
|
|
736
|
-
function buildUrl(ajax, query) {
|
|
875
|
+
function buildUrl(ajax, query, page) {
|
|
737
876
|
if (typeof ajax.url === "function") return ajax.url(query);
|
|
738
877
|
if (!ajax.params) return ajax.url;
|
|
739
878
|
const params = new URLSearchParams();
|
|
740
|
-
for (const [key, value] of Object.entries(ajax.params(query))) {
|
|
879
|
+
for (const [key, value] of Object.entries(ajax.params(query, page))) {
|
|
741
880
|
params.set(key, String(value));
|
|
742
881
|
}
|
|
743
882
|
const separator = ajax.url.includes("?") ? "&" : "?";
|
|
744
883
|
return `${ajax.url}${separator}${params.toString()}`;
|
|
745
884
|
}
|
|
885
|
+
function collectValues(items) {
|
|
886
|
+
const values = /* @__PURE__ */ new Set();
|
|
887
|
+
for (const item of items) {
|
|
888
|
+
if (isGroup(item)) {
|
|
889
|
+
for (const option of item.options) values.add(option.value);
|
|
890
|
+
} else {
|
|
891
|
+
values.add(item.value);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return values;
|
|
895
|
+
}
|
|
746
896
|
function arraysEqual(a, b) {
|
|
747
897
|
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
748
898
|
}
|