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 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 — Tree Select, Async Pagination, Drag & Drop Ordering, and React/Vue/Angular/Svelte wrappers — are tracked in the [Roadmap](#roadmap) below and intentionally not listed above as shipped features.
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
- - [ ] Tree Select
194
+ - [x] Tree Select
192
195
  - [x] Virtualized List
193
- - [ ] Async Pagination
196
+ - [x] Async Pagination
194
197
  - [ ] Drag & Drop Ordering
195
198
  - [x] Theme Builder
196
199
  - [x] CSS Variables
197
- - [ ] React Component
198
- - [ ] Vue Component
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 li = event.target.closest("li[data-nav-index]");
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) this.selected.push(value);
347
- else this.selected = [value];
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
- for (const item of this.data) {
387
- if (isGroup(item)) {
388
- const found = item.options.find((o) => o.value === value);
389
- if (found) return found;
390
- } else if (item.value === value) {
391
- return item;
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 pushOption = (option) => {
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
- this.rows.push({ kind: "option", option, navIndex });
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(matches);
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 (matches(item)) {
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((o) => o.label.toLowerCase() === lowerQuery)) return true;
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
- async loadRemote(query) {
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
- this.data = ajax.transform ? ajax.transform(json) : json;
701
- this.rowContentCache.clear();
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
- this.data = [];
706
- this.rowContentCache.clear();
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
  }