@aquera/nile-elements 1.7.2 → 1.7.3

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.
Files changed (47) hide show
  1. package/README.md +4 -0
  2. package/dist/index.cjs.js +1 -1
  3. package/dist/index.esm.js +1 -1
  4. package/dist/index.js +387 -265
  5. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.cjs.js +1 -1
  6. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.cjs.js.map +1 -1
  7. package/dist/nile-breadcrumb-item/nile-breadcrumb-item.esm.js +8 -6
  8. package/dist/nile-combobox/group-utils.cjs.js +2 -0
  9. package/dist/nile-combobox/group-utils.cjs.js.map +1 -0
  10. package/dist/nile-combobox/group-utils.esm.js +1 -0
  11. package/dist/nile-combobox/index.cjs.js +1 -1
  12. package/dist/nile-combobox/index.esm.js +1 -1
  13. package/dist/nile-combobox/nile-combobox.cjs.js +1 -1
  14. package/dist/nile-combobox/nile-combobox.cjs.js.map +1 -1
  15. package/dist/nile-combobox/nile-combobox.css.cjs.js +1 -1
  16. package/dist/nile-combobox/nile-combobox.css.cjs.js.map +1 -1
  17. package/dist/nile-combobox/nile-combobox.css.esm.js +77 -4
  18. package/dist/nile-combobox/nile-combobox.esm.js +13 -8
  19. package/dist/nile-combobox/renderer.cjs.js +1 -1
  20. package/dist/nile-combobox/renderer.cjs.js.map +1 -1
  21. package/dist/nile-combobox/renderer.esm.js +84 -42
  22. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.js +4 -2
  23. package/dist/src/nile-breadcrumb-item/nile-breadcrumb-item.js.map +1 -1
  24. package/dist/src/nile-combobox/group-utils.d.ts +26 -0
  25. package/dist/src/nile-combobox/group-utils.js +140 -0
  26. package/dist/src/nile-combobox/group-utils.js.map +1 -0
  27. package/dist/src/nile-combobox/nile-combobox.css.js +77 -4
  28. package/dist/src/nile-combobox/nile-combobox.css.js.map +1 -1
  29. package/dist/src/nile-combobox/nile-combobox.d.ts +33 -0
  30. package/dist/src/nile-combobox/nile-combobox.js +171 -34
  31. package/dist/src/nile-combobox/nile-combobox.js.map +1 -1
  32. package/dist/src/nile-combobox/renderer.d.ts +4 -0
  33. package/dist/src/nile-combobox/renderer.js +71 -2
  34. package/dist/src/nile-combobox/renderer.js.map +1 -1
  35. package/dist/src/nile-combobox/types.d.ts +30 -0
  36. package/dist/src/nile-combobox/types.js.map +1 -1
  37. package/dist/src/version.js +1 -1
  38. package/dist/src/version.js.map +1 -1
  39. package/dist/tsconfig.tsbuildinfo +1 -1
  40. package/package.json +1 -1
  41. package/src/nile-breadcrumb-item/nile-breadcrumb-item.ts +4 -2
  42. package/src/nile-combobox/group-utils.ts +157 -0
  43. package/src/nile-combobox/nile-combobox.css.ts +77 -4
  44. package/src/nile-combobox/nile-combobox.ts +223 -70
  45. package/src/nile-combobox/renderer.ts +119 -2
  46. package/src/nile-combobox/types.ts +36 -0
  47. package/vscode-html-custom-data.json +6 -1
@@ -38,12 +38,14 @@ import type {
38
38
  ComboboxTagLayout,
39
39
  ComboboxSize,
40
40
  ComboboxPlacement,
41
+ ComboboxRow,
41
42
  NileRemoveEvent,
42
43
  } from './types.js';
43
44
  import { ComboboxSelectionManager } from './selection-manager.js';
44
45
  import { ComboboxSearchManager } from './search-manager.js';
45
46
  import { ComboboxRenderer } from './renderer.js';
46
47
  import { ComboboxPortalManager } from './portal-manager.js';
48
+ import { hasGroups, flattenRows, filterRows, getOptionRows } from './group-utils.js';
47
49
  import { VisibilityManager } from '../utilities/visibility-manager.js';
48
50
 
49
51
  /**
@@ -149,6 +151,15 @@ export class NileCombobox extends NileElement implements NileFormControl {
149
151
  /** The items displayed after filtering. Renderer reads from this. */
150
152
  @state() filteredData: any[] = [];
151
153
 
154
+ /**
155
+ * Mixed (header + option) row list, only populated when `data` contains
156
+ * group entries (`type: 'group'`). When non-empty, the listbox renders from
157
+ * this instead of `filteredData`. `filteredData` stays in sync as the
158
+ * option-only projection so existing select-all / strict-match / etc. logic
159
+ * keeps working unchanged.
160
+ */
161
+ @state() private filteredRows: ComboboxRow[] = [];
162
+
152
163
  /** The complete unfiltered dataset (preserved for re-filtering). */
153
164
  @state() private originalData: any[] = [];
154
165
 
@@ -159,6 +170,13 @@ export class NileCombobox extends NileElement implements NileFormControl {
159
170
  @state() private selectAllChecked = false;
160
171
  @state() private selectAllIndeterminate = false;
161
172
 
173
+ /**
174
+ * Index into `filteredRows` of the group header that should be pinned at
175
+ * the top of the (virtualized) listbox right now. -1 means none.
176
+ * Recomputed on scroll.
177
+ */
178
+ @state() private stickyHeaderIndex = -1;
179
+
162
180
  // ── Public properties ──
163
181
 
164
182
  @property() name = '';
@@ -230,6 +248,14 @@ export class NileCombobox extends NileElement implements NileFormControl {
230
248
  */
231
249
  @property({ type: Boolean, reflect: true, attribute: 'select-all-enabled' }) selectAllEnabled = false;
232
250
 
251
+ /**
252
+ * When true (default), data-driven group headers stick to the top of the
253
+ * listbox while scrolling through that group's options (Atlassian-style).
254
+ * Works in both plain and virtualized rendering modes. Set to false for
255
+ * inline-only headers that scroll away with their options.
256
+ */
257
+ @property({ type: Boolean, reflect: true, attribute: 'sticky-group-header' }) stickyGroupHeader = true;
258
+
233
259
  @property({ type: Boolean, reflect: true }) portal = false;
234
260
 
235
261
  @property({ type: Boolean }) hoist = false;
@@ -359,6 +385,48 @@ export class NileCombobox extends NileElement implements NileFormControl {
359
385
  return this.gridRows > 0 && this.gridColumns <= 1;
360
386
  }
361
387
 
388
+ /** True when the source data contains at least one group entry. */
389
+ private get hasGroupedData(): boolean {
390
+ const base = this.originalData.length > 0 ? this.originalData : this.data;
391
+ return hasGroups(base);
392
+ }
393
+
394
+ /**
395
+ * Walk filteredRows and find the index of the deepest group header whose
396
+ * virtual position is at or above `scrollTop`. That's the header that
397
+ * should be pinned at the top of the listbox right now.
398
+ */
399
+ private updateStickyHeader(scrollTop: number): void {
400
+ if (!this.stickyGroupHeader || !this.hasGroupedData) {
401
+ if (this.stickyHeaderIndex !== -1) this.stickyHeaderIndex = -1;
402
+ return;
403
+ }
404
+ let offset = 0;
405
+ let stuck = -1;
406
+ for (let i = 0; i < this.filteredRows.length; i++) {
407
+ const row = this.filteredRows[i];
408
+ const size = row.kind === 'header' ? 32 : 38;
409
+ if (offset > scrollTop) break;
410
+ if (row.kind === 'header') stuck = i;
411
+ offset += size;
412
+ }
413
+ if (stuck !== this.stickyHeaderIndex) this.stickyHeaderIndex = stuck;
414
+ }
415
+
416
+ /** Recursively keep only options whose value is in `selectedSet`; drop empty groups. */
417
+ private pruneTreeBySelection(items: any[], selectedSet: Set<string>): any[] {
418
+ const out: any[] = [];
419
+ for (const item of items) {
420
+ if (item && typeof item === 'object' && item.type === 'group' && Array.isArray(item.options)) {
421
+ const kept = this.pruneTreeBySelection(item.options, selectedSet);
422
+ if (kept.length > 0) out.push({ ...item, options: kept });
423
+ } else if (selectedSet.has(String(this.getItemValue(item)))) {
424
+ out.push(item);
425
+ }
426
+ }
427
+ return out;
428
+ }
429
+
362
430
  private get hasActiveFilter(): boolean {
363
431
  return !!this.searchValue || this.showSelectedOnly;
364
432
  }
@@ -378,6 +446,9 @@ export class NileCombobox extends NileElement implements NileFormControl {
378
446
  if (this.gridColumns > 1) {
379
447
  return Math.ceil(this.filteredData.length / this.gridColumns);
380
448
  }
449
+ if (this.hasGroupedData) {
450
+ return this.filteredRows.length;
451
+ }
381
452
  return this.filteredData.length;
382
453
  }
383
454
 
@@ -400,16 +471,26 @@ export class NileCombobox extends NileElement implements NileFormControl {
400
471
  } else {
401
472
  const virtualizer = this.virtualizerCtrl.getVirtualizer();
402
473
  const count = this.virtualRowCount;
403
- if (virtualizer.options.count !== count) {
474
+ const grouped = this.hasGroupedData;
475
+ const countChanged = virtualizer.options.count !== count;
476
+ const modeChanged = this.lastVirtualizerGrouped !== grouped;
477
+ if (countChanged || modeChanged) {
478
+ const estimateSize = grouped
479
+ ? (index: number) => (this.filteredRows[index]?.kind === 'header' ? 32 : 38)
480
+ : () => 38;
404
481
  virtualizer.setOptions({
405
482
  ...virtualizer.options,
406
483
  count,
484
+ estimateSize,
407
485
  });
408
486
  virtualizer.measure();
487
+ this.lastVirtualizerGrouped = grouped;
409
488
  }
410
489
  }
411
490
  }
412
491
 
492
+ private lastVirtualizerGrouped = false;
493
+
413
494
  // ── Data helpers ──
414
495
 
415
496
  private getDisplayText(item: any): string {
@@ -442,7 +523,10 @@ export class NileCombobox extends NileElement implements NileFormControl {
442
523
  // ── Selection ──
443
524
 
444
525
  private syncSelection(): void {
445
- const items = this.originalData.length > 0 ? this.originalData : this.data;
526
+ const baseData = this.originalData.length > 0 ? this.originalData : this.data;
527
+ const items = this.hasGroupedData
528
+ ? getOptionRows(flattenRows(baseData)).map(r => r.item)
529
+ : baseData;
446
530
  this.selectedOptions = ComboboxSelectionManager.createOptionsFromValues(
447
531
  this.value,
448
532
  items,
@@ -785,19 +869,38 @@ export class NileCombobox extends NileElement implements NileFormControl {
785
869
 
786
870
  private filterOptions(search: string, preserveScroll = false): void {
787
871
  const baseData = this.originalData.length > 0 ? this.originalData : this.data;
788
- let source = baseData;
789
- if (this.showSelectedOnly) {
790
- const selectedValues = Array.isArray(this.value) ? this.value : [this.value];
791
- const selectedSet = new Set(selectedValues.map(v => String(v)));
792
- source = baseData.filter((item: any) => selectedSet.has(String(this.getItemValue(item))));
872
+
873
+ if (this.hasGroupedData) {
874
+ // Grouped path: filter the tree, derive options-only projection.
875
+ let tree = baseData;
876
+ if (this.showSelectedOnly) {
877
+ const selectedValues = Array.isArray(this.value) ? this.value : [this.value];
878
+ const selectedSet = new Set(selectedValues.map(v => String(v)));
879
+ tree = this.pruneTreeBySelection(baseData, selectedSet);
880
+ }
881
+ const { rows } = filterRows(tree, search, this.getSearchText.bind(this));
882
+ this.filteredRows = rows;
883
+ this.filteredData = getOptionRows(rows).map(r => r.item);
884
+ this.showNoResults = this.filteredData.length === 0;
885
+ // Recompute sticky header at current scroll (defaults to top on filter).
886
+ const st = this.scrollElementRef.value?.scrollTop ?? 0;
887
+ this.updateStickyHeader(st);
888
+ } else {
889
+ let source = baseData;
890
+ if (this.showSelectedOnly) {
891
+ const selectedValues = Array.isArray(this.value) ? this.value : [this.value];
892
+ const selectedSet = new Set(selectedValues.map(v => String(v)));
893
+ source = baseData.filter((item: any) => selectedSet.has(String(this.getItemValue(item))));
894
+ }
895
+ const { filteredItems, showNoResults } = this.searchManager.filter(
896
+ search,
897
+ source,
898
+ this.getSearchText.bind(this),
899
+ );
900
+ this.filteredData = filteredItems;
901
+ this.filteredRows = [];
902
+ this.showNoResults = showNoResults;
793
903
  }
794
- const { filteredItems, showNoResults } = this.searchManager.filter(
795
- search,
796
- source,
797
- this.getSearchText.bind(this),
798
- );
799
- this.filteredData = filteredItems;
800
- this.showNoResults = showNoResults;
801
904
 
802
905
  this.portalManager.resetMeasuredHeight();
803
906
  if (!preserveScroll) {
@@ -829,7 +932,10 @@ export class NileCombobox extends NileElement implements NileFormControl {
829
932
 
830
933
  if (!this.multiple) {
831
934
  if (this.strict) {
832
- const allItems = this.originalData.length > 0 ? this.originalData : this.data;
935
+ const baseData = this.originalData.length > 0 ? this.originalData : this.data;
936
+ const allItems = this.hasGroupedData
937
+ ? getOptionRows(flattenRows(baseData)).map(r => r.item)
938
+ : baseData;
833
939
  const match = allItems.find(
834
940
  (item: any) => this.getDisplayText(item).toLowerCase() === this.searchValue.toLowerCase(),
835
941
  );
@@ -975,28 +1081,14 @@ export class NileCombobox extends NileElement implements NileFormControl {
975
1081
  if (this.selectedOptions.length === 0) return;
976
1082
 
977
1083
  this.showSelectedOnly = !this.showSelectedOnly;
978
-
979
- if (this.showSelectedOnly) {
980
- this.searchValue = '';
981
- const selectedValues = Array.isArray(this.value) ? this.value : [this.value];
982
- this.filteredData = this.originalData.filter((item: any) => {
983
- const iv = this.getItemValue(item);
984
- return selectedValues.some(val => String(val) === String(iv));
985
- });
986
- } else {
987
- this.filteredData = [...this.originalData];
988
- }
989
-
990
- this.portalManager.resetMeasuredHeight();
991
- this.resetScrollPosition();
992
- this.updateSelectAllState();
993
- this.requestUpdate();
1084
+ if (this.showSelectedOnly) this.searchValue = '';
1085
+ this.filterOptions(this.searchValue);
994
1086
  }
995
1087
 
996
1088
  private clearAll(): void {
997
1089
  this.showSelectedOnly = false;
998
1090
  this.value = this.multiple ? [] : '';
999
- this.filteredData = [...this.originalData];
1091
+ this.filterOptions('');
1000
1092
  this.syncSelection();
1001
1093
  this.emit('nile-change', { value: this.value, name: this.name });
1002
1094
  this.emit('nile-clear', { value: this.value, name: this.name });
@@ -1006,8 +1098,9 @@ export class NileCombobox extends NileElement implements NileFormControl {
1006
1098
  // ── Scroll ──
1007
1099
 
1008
1100
  private onScroll(e: Event): void {
1009
- if (this.showSelectedOnly) return;
1010
1101
  const target = e.target as HTMLElement;
1102
+ this.updateStickyHeader(target.scrollTop);
1103
+ if (this.showSelectedOnly) return;
1011
1104
 
1012
1105
  this.emit('nile-scroll', { scrollTop: target.scrollTop, scrollLeft: target.scrollLeft, name: this.name });
1013
1106
 
@@ -1067,8 +1160,13 @@ export class NileCombobox extends NileElement implements NileFormControl {
1067
1160
  if (this.originalData.length === 0 && this.data.length > 0) {
1068
1161
  this.originalData = [...this.data];
1069
1162
  }
1070
- this.filteredData = [...(this.originalData.length > 0 ? this.originalData : this.data)];
1071
- this.showNoResults = this.filteredData.length === 0;
1163
+ if (this.hasGroupedData) {
1164
+ this.filterOptions(this.searchValue);
1165
+ } else {
1166
+ this.filteredData = [...(this.originalData.length > 0 ? this.originalData : this.data)];
1167
+ this.filteredRows = [];
1168
+ this.showNoResults = this.filteredData.length === 0;
1169
+ }
1072
1170
 
1073
1171
  await stopAnimations(this);
1074
1172
 
@@ -1137,7 +1235,12 @@ export class NileCombobox extends NileElement implements NileFormControl {
1137
1235
  if (this.data.length > 0 && !this.showSelectedOnly) {
1138
1236
  this.originalData = [...this.data];
1139
1237
  }
1140
- this.filteredData = [...this.data];
1238
+ if (this.hasGroupedData) {
1239
+ this.filterOptions(this.searchValue);
1240
+ } else {
1241
+ this.filteredData = [...this.data];
1242
+ this.filteredRows = [];
1243
+ }
1141
1244
  this.syncSelection();
1142
1245
  this.updateSelectAllState();
1143
1246
 
@@ -1404,6 +1507,19 @@ export class NileCombobox extends NileElement implements NileFormControl {
1404
1507
  `;
1405
1508
  }
1406
1509
 
1510
+ private renderStickyHeaderOverlay(grouped: boolean, useVirtual: boolean): TemplateResult {
1511
+ if (!this.stickyGroupHeader || !grouped || !useVirtual) return html``;
1512
+ const idx = this.stickyHeaderIndex;
1513
+ if (idx < 0 || idx >= this.filteredRows.length) return html``;
1514
+ const row = this.filteredRows[idx];
1515
+ if (row.kind !== 'header') return html``;
1516
+ return html`
1517
+ <div class="combobox__group-sticky-overlay">
1518
+ ${ComboboxRenderer.renderGroupHeader(row)}
1519
+ </div>
1520
+ `;
1521
+ }
1522
+
1407
1523
  private renderListbox(): TemplateResult {
1408
1524
  const showAddOption = this.allowCustomValue
1409
1525
  && this.searchValue.trim()
@@ -1421,7 +1537,10 @@ export class NileCombobox extends NileElement implements NileFormControl {
1421
1537
  }
1422
1538
 
1423
1539
  const isGrid = this.gridColumns > 1 || this.isBidirectionalGrid;
1424
- const useVirtual = ComboboxRenderer.shouldUseVirtualizer(this.filteredData, this.gridColumns);
1540
+ const grouped = this.hasGroupedData && !isGrid;
1541
+ const useVirtual = grouped
1542
+ ? this.filteredRows.length >= 5
1543
+ : ComboboxRenderer.shouldUseVirtualizer(this.filteredData, this.gridColumns);
1425
1544
  const virtualizer = this.virtualizerCtrl.getVirtualizer();
1426
1545
  const virtualItems = (useVirtual || isGrid) ? virtualizer.getVirtualItems() : [];
1427
1546
  const totalSize = (useVirtual || isGrid) ? virtualizer.getTotalSize() : 0;
@@ -1443,6 +1562,7 @@ export class NileCombobox extends NileElement implements NileFormControl {
1443
1562
  >
1444
1563
  ${this.renderLoader()}
1445
1564
  ${this.renderSelectAll()}
1565
+ ${this.renderStickyHeaderOverlay(grouped, useVirtual)}
1446
1566
  ${this.showNoResults && !this.optionsLoading && !this.loading
1447
1567
  ? this.renderEmptyState()
1448
1568
  : isGrid
@@ -1462,39 +1582,72 @@ export class NileCombobox extends NileElement implements NileFormControl {
1462
1582
  this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1463
1583
  this.isBidirectionalGrid ? this.gridColumnWidth : undefined,
1464
1584
  )
1465
- : useVirtual
1466
- ? ComboboxRenderer.renderVirtualizedOptions(
1467
- virtualItems,
1468
- totalSize,
1469
- this.filteredData,
1470
- this.value,
1471
- this.multiple,
1472
- this.getDisplayText.bind(this),
1473
- this.getItemValue.bind(this),
1474
- this.optionsLoading || this.loading,
1475
- this.allowHtmlLabel,
1476
- virtualizer.measureElement,
1477
- this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1478
- this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1479
- this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1480
- )
1481
- : ComboboxRenderer.renderPlainOptions(
1482
- this.filteredData,
1483
- this.value,
1484
- this.multiple,
1485
- this.getDisplayText.bind(this),
1486
- this.getItemValue.bind(this),
1487
- this.showNoResults,
1488
- this.noResultsMessage,
1489
- this.optionsLoading || this.loading,
1490
- this.onScroll.bind(this),
1491
- this.allowHtmlLabel,
1492
- this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1493
- this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1494
- this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1495
- undefined,
1496
- this.hasActiveFilter ? this.noResultsSubtitle : undefined,
1497
- )
1585
+ : grouped
1586
+ ? (useVirtual
1587
+ ? ComboboxRenderer.renderRowsVirtualized(
1588
+ virtualItems,
1589
+ totalSize,
1590
+ this.filteredRows,
1591
+ this.value,
1592
+ this.multiple,
1593
+ this.getDisplayText.bind(this),
1594
+ this.getItemValue.bind(this),
1595
+ this.optionsLoading || this.loading,
1596
+ this.allowHtmlLabel,
1597
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1598
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1599
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1600
+ )
1601
+ : ComboboxRenderer.renderRowsPlain(
1602
+ this.filteredRows,
1603
+ this.value,
1604
+ this.multiple,
1605
+ this.getDisplayText.bind(this),
1606
+ this.getItemValue.bind(this),
1607
+ this.showNoResults,
1608
+ this.noResultsMessage,
1609
+ this.optionsLoading || this.loading,
1610
+ this.onScroll.bind(this),
1611
+ this.allowHtmlLabel,
1612
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1613
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1614
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1615
+ undefined,
1616
+ this.hasActiveFilter ? this.noResultsSubtitle : undefined,
1617
+ ))
1618
+ : useVirtual
1619
+ ? ComboboxRenderer.renderVirtualizedOptions(
1620
+ virtualItems,
1621
+ totalSize,
1622
+ this.filteredData,
1623
+ this.value,
1624
+ this.multiple,
1625
+ this.getDisplayText.bind(this),
1626
+ this.getItemValue.bind(this),
1627
+ this.optionsLoading || this.loading,
1628
+ this.allowHtmlLabel,
1629
+ virtualizer.measureElement,
1630
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1631
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1632
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1633
+ )
1634
+ : ComboboxRenderer.renderPlainOptions(
1635
+ this.filteredData,
1636
+ this.value,
1637
+ this.multiple,
1638
+ this.getDisplayText.bind(this),
1639
+ this.getItemValue.bind(this),
1640
+ this.showNoResults,
1641
+ this.noResultsMessage,
1642
+ this.optionsLoading || this.loading,
1643
+ this.onScroll.bind(this),
1644
+ this.allowHtmlLabel,
1645
+ this.renderItemConfig?.getDescription ? this.getItemDescription.bind(this) : undefined,
1646
+ this.renderItemConfig?.getPrefix ? this.getItemPrefix.bind(this) : undefined,
1647
+ this.renderItemConfig?.getSuffix ? this.getItemSuffix.bind(this) : undefined,
1648
+ undefined,
1649
+ this.hasActiveFilter ? this.noResultsSubtitle : undefined,
1650
+ )
1498
1651
  }
1499
1652
  ${showAddOption ? html`
1500
1653
  <div @mouseup=${(e: MouseEvent) => { e.stopPropagation(); this.addCustomValue(this.searchValue.trim()); }}>
@@ -10,9 +10,127 @@ import { unsafeHTML } from 'lit/directives/unsafe-html.js';
10
10
  import { classMap } from 'lit/directives/class-map.js';
11
11
  import { repeat } from 'lit/directives/repeat.js';
12
12
  import type { VirtualItem } from '@tanstack/virtual-core';
13
+ import type { ComboboxRow, ComboboxHeaderRow } from './types';
13
14
 
14
15
  export class ComboboxRenderer {
15
16
 
17
+ static renderGroupHeader(row: ComboboxHeaderRow): TemplateResult {
18
+ return html`
19
+ <div
20
+ part="group-header"
21
+ class="combobox__group-header"
22
+ role="presentation"
23
+ data-group-id=${row.id}
24
+ style=${`--group-depth:${row.depth}`}
25
+ >
26
+ ${row.prefix
27
+ ? html`<nile-icon
28
+ class="combobox__group-prefix"
29
+ name=${row.prefix}
30
+ size="14"
31
+ method="fill"
32
+ ></nile-icon>`
33
+ : ''}
34
+ <span class="combobox__group-label">${row.label}</span>
35
+ </div>
36
+ `;
37
+ }
38
+
39
+ static renderRowsPlain(
40
+ rows: ComboboxRow[],
41
+ value: string | string[],
42
+ multiple: boolean,
43
+ getDisplayText: (item: any) => string,
44
+ getItemValue: (item: any) => string,
45
+ showNoResults: boolean,
46
+ noResultsMessage: string,
47
+ isLoading: boolean,
48
+ onScroll: (e: Event) => void,
49
+ allowHtmlLabel: boolean,
50
+ getItemDescription?: (item: any) => string,
51
+ getItemPrefix?: (item: any) => string,
52
+ getItemSuffix?: (item: any) => string,
53
+ enableDescription?: boolean,
54
+ noResultsSubtitle?: string,
55
+ ): TemplateResult {
56
+ if (showNoResults && !isLoading && rows.length === 0) {
57
+ return ComboboxRenderer.renderNoResults(noResultsMessage, noResultsSubtitle);
58
+ }
59
+
60
+ return html`
61
+ <div
62
+ part="select-options"
63
+ class="combobox__options ${isLoading ? 'loading' : ''}"
64
+ >
65
+ <div class="combobox__options-plain" @scroll=${onScroll}>
66
+ ${rows.map((row) =>
67
+ row.kind === 'header'
68
+ ? ComboboxRenderer.renderGroupHeader(row)
69
+ : ComboboxRenderer.renderItem(
70
+ row.item, value, multiple, getDisplayText, getItemValue,
71
+ allowHtmlLabel, getItemDescription, getItemPrefix,
72
+ getItemSuffix, enableDescription,
73
+ ),
74
+ )}
75
+ </div>
76
+ </div>
77
+ `;
78
+ }
79
+
80
+ static renderRowsVirtualized(
81
+ virtualItems: VirtualItem[],
82
+ totalSize: number,
83
+ rows: ComboboxRow[],
84
+ value: string | string[],
85
+ multiple: boolean,
86
+ getDisplayText: (item: any) => string,
87
+ getItemValue: (item: any) => string,
88
+ isLoading: boolean,
89
+ allowHtmlLabel: boolean,
90
+ getItemDescription?: (item: any) => string,
91
+ getItemPrefix?: (item: any) => string,
92
+ getItemSuffix?: (item: any) => string,
93
+ enableDescription?: boolean,
94
+ ): TemplateResult {
95
+ return html`
96
+ <div
97
+ part="select-options"
98
+ class="combobox__options ${isLoading ? 'loading' : ''}"
99
+ >
100
+ <div style="position:relative;height:${totalSize}px;width:100%;">
101
+ ${repeat(
102
+ virtualItems,
103
+ (vItem) => vItem.key,
104
+ (vItem) => {
105
+ const row = rows[vItem.index];
106
+ if (!row) return html``;
107
+ const posStyle =
108
+ `position:absolute;top:0;left:0;right:0;` +
109
+ `transform:translateY(${vItem.start}px);` +
110
+ `height:${vItem.size}px;`;
111
+ if (row.kind === 'header') {
112
+ return html`
113
+ <div style=${posStyle} class="combobox__group-header-slot">
114
+ ${ComboboxRenderer.renderGroupHeader(row)}
115
+ </div>
116
+ `;
117
+ }
118
+ return html`
119
+ <div style=${posStyle}>
120
+ ${ComboboxRenderer.renderItem(
121
+ row.item, value, multiple, getDisplayText, getItemValue,
122
+ allowHtmlLabel, getItemDescription, getItemPrefix,
123
+ getItemSuffix, enableDescription,
124
+ )}
125
+ </div>
126
+ `;
127
+ },
128
+ )}
129
+ </div>
130
+ </div>
131
+ `;
132
+ }
133
+
16
134
  static renderVirtualizedOptions(
17
135
  virtualItems: VirtualItem[],
18
136
  totalSize: number,
@@ -45,7 +163,7 @@ export class ComboboxRenderer {
45
163
  const item = data[vItem.index];
46
164
  return ComboboxRenderer.renderMeasuredItem(
47
165
  item, vItem.index, value, multiple, getDisplayText, getItemValue,
48
- allowHtmlLabel, measureElement, getItemDescription, getItemPrefix,
166
+ allowHtmlLabel, getItemDescription, getItemPrefix,
49
167
  getItemSuffix, enableDescription,
50
168
  );
51
169
  },
@@ -130,7 +248,6 @@ export class ComboboxRenderer {
130
248
  getDisplayText: (item: any) => string,
131
249
  getItemValue: (item: any) => string,
132
250
  allowHtmlLabel: boolean,
133
- measureElement: (el: Element | null) => void,
134
251
  getItemDescription?: (item: any) => string,
135
252
  getItemPrefix?: (item: any) => string,
136
253
  getItemSuffix?: (item: any) => string,
@@ -25,3 +25,39 @@ export interface ComboboxRenderItemConfig {
25
25
  export type ComboboxTagLayout = 'single-line' | 'wrap' | 'fixed-height';
26
26
  export type ComboboxSize = 'small' | 'medium' | 'large';
27
27
  export type ComboboxPlacement = 'top' | 'bottom';
28
+
29
+ export interface ComboboxGroupItem {
30
+ type: 'group';
31
+ id: string;
32
+ label: string;
33
+ prefix?: string;
34
+ options: ComboboxDataItem[];
35
+ collapsible?: boolean;
36
+ }
37
+
38
+ export interface ComboboxOptionItem {
39
+ type?: 'option';
40
+ value: string;
41
+ label?: string;
42
+ [key: string]: any;
43
+ }
44
+
45
+ export type ComboboxDataItem = ComboboxGroupItem | ComboboxOptionItem;
46
+
47
+ export interface ComboboxHeaderRow {
48
+ kind: 'header';
49
+ id: string;
50
+ label: string;
51
+ prefix?: string;
52
+ depth: number;
53
+ optionCount: number;
54
+ }
55
+
56
+ export interface ComboboxOptionRow {
57
+ kind: 'option';
58
+ item: ComboboxOptionItem;
59
+ depth: number;
60
+ parentIds: string[];
61
+ }
62
+
63
+ export type ComboboxRow = ComboboxHeaderRow | ComboboxOptionRow;