@svgrid/grid 2.2.28 → 2.2.29
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/dist/GridMenus.svelte +148 -100
- package/dist/SvGrid.controller.svelte.d.ts +14 -1
- package/dist/SvGrid.controller.svelte.js +112 -28
- package/dist/SvGrid.css +15 -34
- package/dist/SvGrid.svelte +49 -21
- package/dist/SvGrid.types.d.ts +20 -5
- package/dist/SvListBox.svelte +42 -3
- package/dist/SvListBox.svelte.d.ts +10 -1
- package/dist/cdn/GridMenus-Da_p7SKS.js +486 -0
- package/dist/cdn/GridMenus-Ds3OpzZT.js +490 -0
- package/dist/cdn/{src-BxNEslEo.js → src-BLJfdyL0.js} +4014 -3869
- package/dist/cdn/{src-C4yKQZ5t.js → src-DYDCiC0D.js} +7036 -6891
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/column-groups.js +8 -10
- package/dist/column-id.d.ts +9 -0
- package/dist/column-id.js +25 -0
- package/dist/core.js +2 -1
- package/dist/createListbox.svelte.d.ts +15 -2
- package/dist/createListbox.svelte.js +27 -6
- package/dist/editing.d.ts +1 -0
- package/dist/editing.js +45 -10
- package/dist/filtering/excel-filters.d.ts +30 -10
- package/dist/filtering/excel-filters.js +96 -15
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/menus.d.ts +2 -0
- package/dist/menus.js +25 -0
- package/dist/row-resize.js +40 -0
- package/dist/selection.js +1 -1
- package/package.json +1 -1
- package/src/GridMenus.svelte +148 -100
- package/src/SvGrid.controller.svelte.ts +123 -27
- package/src/SvGrid.css +15 -34
- package/src/SvGrid.svelte +49 -21
- package/src/SvGrid.types.ts +20 -5
- package/src/SvListBox.svelte +42 -3
- package/src/column-groups.test.ts +48 -0
- package/src/column-groups.ts +8 -10
- package/src/column-id.ts +30 -0
- package/src/core.ts +2 -1
- package/src/createListbox.svelte.ts +39 -7
- package/src/editing.test.ts +90 -0
- package/src/editing.ts +46 -12
- package/src/filtering/excel-filters.test.ts +44 -1
- package/src/filtering/excel-filters.ts +91 -14
- package/src/index.ts +2 -0
- package/src/menus.test.ts +40 -4
- package/src/menus.ts +27 -0
- package/src/row-resize.test.ts +100 -0
- package/src/row-resize.ts +36 -0
- package/src/selection.ts +1 -1
- package/src/svgrid.api-extensions.test.ts +8 -3
- package/src/svgrid.cell-dom-ids.test.ts +96 -0
- package/src/svgrid.conditional-stat-scope.test.ts +111 -0
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +308 -0
- package/src/svgrid.filter-menu-scroll.test.ts +3 -1
- package/src/svgrid.group-header-ids.test.ts +121 -0
- package/dist/cdn/GridMenus-BbzEvTYO.js +0 -421
- package/dist/cdn/GridMenus-E220X2kM.js +0 -417
package/src/GridMenus.svelte
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
applyExcelFilter,
|
|
4
4
|
normalizeForFilter,
|
|
5
5
|
splitInTokens,
|
|
6
|
+
trailingInToken,
|
|
6
7
|
createColumnVirtualizer,
|
|
7
8
|
createCoreRowModel,
|
|
8
9
|
createExpandedRowModel,
|
|
@@ -61,6 +62,7 @@
|
|
|
61
62
|
type ResolvedCellFormat,
|
|
62
63
|
} from "./conditional-formatting";
|
|
63
64
|
import SvGridDropdown from "./SvGridDropdown.svelte";
|
|
65
|
+
import SvListBox from "./SvListBox.svelte";
|
|
64
66
|
import type {
|
|
65
67
|
Props,
|
|
66
68
|
SelectionPoint,
|
|
@@ -129,9 +131,6 @@
|
|
|
129
131
|
const filterRowValues = $derived(ctrl.filterRowValues);
|
|
130
132
|
const inSuggestFor = $derived(ctrl.inSuggestFor);
|
|
131
133
|
const inSuggestPos = $derived(ctrl.inSuggestPos);
|
|
132
|
-
const inSuggestValues = $derived(ctrl.inSuggestValues);
|
|
133
|
-
const toggleFilterToken = $derived(ctrl.toggleFilterToken);
|
|
134
|
-
const facetValuesForColumn = $derived(ctrl.facetValuesForColumn);
|
|
135
134
|
|
|
136
135
|
// Patch a column's menu filter (used for the optional 2nd condition + join
|
|
137
136
|
// of multi-condition filtering). Merges into the existing entry, creating a
|
|
@@ -174,10 +173,60 @@
|
|
|
174
173
|
const clearGroupingFromMenu = $derived(ctrl.clearGroupingFromMenu);
|
|
175
174
|
const columnMenuFacetValues = $derived(ctrl.columnMenuFacetValues);
|
|
176
175
|
const columnMenuVisibleFacets = $derived(ctrl.columnMenuVisibleFacets);
|
|
177
|
-
const
|
|
178
|
-
const
|
|
176
|
+
const columnMenuFacetOptions = $derived(ctrl.columnMenuFacetOptions);
|
|
177
|
+
const columnMenuSelectedFacets = $derived(ctrl.columnMenuSelectedFacets);
|
|
178
|
+
const setFacetSelection = $derived(ctrl.setFacetSelection);
|
|
179
|
+
const setFilterTokens = $derived(ctrl.setFilterTokens);
|
|
180
|
+
const inSuggestOptions = $derived(ctrl.inSuggestOptions);
|
|
181
|
+
const openInSuggest = $derived(ctrl.openInSuggest);
|
|
182
|
+
const closeInSuggest = $derived(ctrl.closeInSuggest);
|
|
179
183
|
const isAllFacetsChecked = $derived(ctrl.isAllFacetsChecked);
|
|
180
184
|
const toggleAllFacets = $derived(ctrl.toggleAllFacets);
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Above this many rows the checklists switch to the windowed listbox. Short
|
|
188
|
+
* lists stay fully rendered so the popover hugs its content instead of
|
|
189
|
+
* reserving a fixed `rows`-tall box.
|
|
190
|
+
*/
|
|
191
|
+
const FACET_VIRTUAL_THRESHOLD = 40;
|
|
192
|
+
|
|
193
|
+
// The filter panel's `in` / `notIn` box holds the WHOLE token list
|
|
194
|
+
// ("AAPL, MSFT"), so the suggestion query is the trailing fragment being
|
|
195
|
+
// typed - not the full value, which would match nothing.
|
|
196
|
+
function onSetOpFilterFocus(input: HTMLInputElement, columnId: string) {
|
|
197
|
+
openInSuggest(input, columnId);
|
|
198
|
+
ctrl.inSuggestQuery = trailingInToken(input.value);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function onSetOpFilterInput(input: HTMLInputElement, columnId: string) {
|
|
202
|
+
ctrl.inSuggestQuery = trailingInToken(input.value);
|
|
203
|
+
if (ctrl.inSuggestFor !== columnId) openInSuggest(input, columnId);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Columns as checklist options, labelled by their header when it's a string. */
|
|
207
|
+
function columnVisibilityOptions(columns: Array<Column<TData>>) {
|
|
208
|
+
return columns.map((column) => ({
|
|
209
|
+
value: column.id,
|
|
210
|
+
label: typeof column.columnDef.header === "string" ? column.columnDef.header : column.id,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Currently visible column ids - the checklist's selection set. */
|
|
215
|
+
function visibleColumnIds(columns: Array<Column<TData>>) {
|
|
216
|
+
const set = new Set<string | number>();
|
|
217
|
+
for (const column of columns) if (!ctrl.hiddenColumns[column.id]) set.add(column.id);
|
|
218
|
+
return set;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Write a checklist selection back as the complementary `hiddenColumns` map. */
|
|
222
|
+
function applyColumnVisibility(columns: Array<Column<TData>>, visible: Set<string>) {
|
|
223
|
+
const next: Record<string, boolean> = { ...ctrl.hiddenColumns };
|
|
224
|
+
for (const column of columns) {
|
|
225
|
+
if (visible.has(column.id)) delete next[column.id];
|
|
226
|
+
else next[column.id] = true;
|
|
227
|
+
}
|
|
228
|
+
ctrl.hiddenColumns = next;
|
|
229
|
+
}
|
|
181
230
|
const clearColumnFilter = $derived(ctrl.clearColumnFilter);
|
|
182
231
|
const contextMenuFor = $derived(ctrl.contextMenuFor);
|
|
183
232
|
const contextMenuPos = $derived(ctrl.contextMenuPos);
|
|
@@ -234,10 +283,12 @@
|
|
|
234
283
|
{#if menuActiveOperator !== "isBlank" && menuActiveOperator !== "isNotBlank"}
|
|
235
284
|
{@const isSetOp =
|
|
236
285
|
menuActiveOperator === "in" || menuActiveOperator === "notIn"}
|
|
286
|
+
<!-- in/notIn drives the shared value-suggestions dropdown (a windowed
|
|
287
|
+
checklist) rather than a native datalist, which capped at 200
|
|
288
|
+
values and couldn't show which are already selected. -->
|
|
237
289
|
<input
|
|
238
290
|
class="sv-grid-menu-condition-value"
|
|
239
291
|
type={menuActiveOperator === "regex" ? "text" : menuInputType}
|
|
240
|
-
list={isSetOp ? `sv-in-list-${colId}` : undefined}
|
|
241
292
|
value={filterMenuValues[colId]?.value ?? ""}
|
|
242
293
|
placeholder={menuActiveOperator === "between"
|
|
243
294
|
? "From"
|
|
@@ -246,15 +297,16 @@
|
|
|
246
297
|
: menuActiveOperator === "regex"
|
|
247
298
|
? "Pattern..."
|
|
248
299
|
: "Filter value..."}
|
|
249
|
-
|
|
300
|
+
onfocus={isSetOp
|
|
301
|
+
? (event) => onSetOpFilterFocus(event.currentTarget as HTMLInputElement, colId)
|
|
302
|
+
: undefined}
|
|
303
|
+
onblur={isSetOp ? () => closeInSuggest() : undefined}
|
|
304
|
+
oninput={(event) => {
|
|
305
|
+
const input = event.currentTarget as HTMLInputElement;
|
|
306
|
+
updateFilterMenuValue(colId, input.value);
|
|
307
|
+
if (isSetOp) onSetOpFilterInput(input, colId);
|
|
308
|
+
}}
|
|
250
309
|
/>
|
|
251
|
-
{#if isSetOp}
|
|
252
|
-
<datalist id={`sv-in-list-${colId}`}>
|
|
253
|
-
{#each facetValuesForColumn(colId).slice(0, 200) as v (v)}
|
|
254
|
-
<option value={v}></option>
|
|
255
|
-
{/each}
|
|
256
|
-
</datalist>
|
|
257
|
-
{/if}
|
|
258
310
|
{#if menuActiveOperator === "between"}
|
|
259
311
|
<input
|
|
260
312
|
class="sv-grid-menu-condition-value"
|
|
@@ -323,16 +375,27 @@
|
|
|
323
375
|
<input type="checkbox" checked={isAllFacetsChecked(colId)} onchange={() => toggleAllFacets(colId)} />
|
|
324
376
|
<span class="sv-grid-facet-label">(Select all)</span>
|
|
325
377
|
</label>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
378
|
+
<!-- Windowed checklist. A high-cardinality column (10k distinct symbols
|
|
379
|
+
on a live feed) would otherwise mount a label + checkbox per value -
|
|
380
|
+
and 10k focusable checkboxes are 10k tab stops. SvListBox renders
|
|
381
|
+
only the visible rows and is one roving-tabindex stop. -->
|
|
382
|
+
{#if columnMenuFacetOptions.length}
|
|
383
|
+
<SvListBox
|
|
384
|
+
block
|
|
385
|
+
checkbox
|
|
386
|
+
multiple
|
|
387
|
+
virtual={columnMenuFacetOptions.length > FACET_VIRTUAL_THRESHOLD}
|
|
388
|
+
rows={7}
|
|
389
|
+
rowHeight={24}
|
|
390
|
+
size="sm"
|
|
391
|
+
ariaLabel="Filter values"
|
|
392
|
+
options={columnMenuFacetOptions}
|
|
393
|
+
value={columnMenuSelectedFacets}
|
|
394
|
+
onChange={(next) => setFacetSelection(colId, next as Set<string>)}
|
|
395
|
+
/>
|
|
396
|
+
{:else}
|
|
397
|
+
<div class="sv-grid-facet-empty">No values</div>
|
|
398
|
+
{/if}
|
|
336
399
|
{#if columnMenuFacetValues.length > columnMenuVisibleFacets.length}
|
|
337
400
|
<div class="sv-grid-facet-note">
|
|
338
401
|
Showing {columnMenuVisibleFacets.length} of {columnMenuFacetValues.length}
|
|
@@ -383,30 +446,21 @@
|
|
|
383
446
|
{#if columnMenuTabsEnabled && menuTab === "filter" && menuCanFilter && showColumnFiltersEffective}
|
|
384
447
|
{@render filterPanelBody(menuColumnId)}
|
|
385
448
|
{:else if columnMenuTabsEnabled && menuTab === "columns"}
|
|
449
|
+
{@const tabColumns = grid.getAllColumns()}
|
|
386
450
|
<div class="sv-grid-menu-filter-head">Visible columns</div>
|
|
387
|
-
<
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
ctrl.hiddenColumns = { ...ctrl.hiddenColumns, [column.id]: true };
|
|
401
|
-
}
|
|
402
|
-
}}
|
|
403
|
-
/>
|
|
404
|
-
<span class="sv-grid-facet-label"
|
|
405
|
-
>{typeof column.columnDef.header === "string" ? column.columnDef.header : column.id}</span
|
|
406
|
-
>
|
|
407
|
-
</label>
|
|
408
|
-
{/each}
|
|
409
|
-
</div>
|
|
451
|
+
<SvListBox
|
|
452
|
+
block
|
|
453
|
+
checkbox
|
|
454
|
+
multiple
|
|
455
|
+
virtual={tabColumns.length > FACET_VIRTUAL_THRESHOLD}
|
|
456
|
+
rows={9}
|
|
457
|
+
rowHeight={24}
|
|
458
|
+
size="sm"
|
|
459
|
+
ariaLabel="Visible columns"
|
|
460
|
+
options={columnVisibilityOptions(tabColumns)}
|
|
461
|
+
value={visibleColumnIds(tabColumns)}
|
|
462
|
+
onChange={(next) => applyColumnVisibility(tabColumns, next as Set<string>)}
|
|
463
|
+
/>
|
|
410
464
|
{:else}
|
|
411
465
|
{#if menuCanSort}
|
|
412
466
|
<button
|
|
@@ -570,32 +624,19 @@
|
|
|
570
624
|
style={`left: ${chooseColumnsPos.x}px; top: ${chooseColumnsPos.y}px;`}
|
|
571
625
|
>
|
|
572
626
|
<div class="sv-grid-menu-filter-head">Visible columns</div>
|
|
573
|
-
<
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
} else {
|
|
587
|
-
ctrl.hiddenColumns = { ...ctrl.hiddenColumns, [column.id]: true };
|
|
588
|
-
}
|
|
589
|
-
}}
|
|
590
|
-
/>
|
|
591
|
-
<span class="sv-grid-facet-label"
|
|
592
|
-
>{typeof column.columnDef.header === "string"
|
|
593
|
-
? column.columnDef.header
|
|
594
|
-
: column.id}</span
|
|
595
|
-
>
|
|
596
|
-
</label>
|
|
597
|
-
{/each}
|
|
598
|
-
</div>
|
|
627
|
+
<SvListBox
|
|
628
|
+
block
|
|
629
|
+
checkbox
|
|
630
|
+
multiple
|
|
631
|
+
virtual={everyColumn.length > FACET_VIRTUAL_THRESHOLD}
|
|
632
|
+
rows={11}
|
|
633
|
+
rowHeight={24}
|
|
634
|
+
size="sm"
|
|
635
|
+
ariaLabel="Visible columns"
|
|
636
|
+
options={columnVisibilityOptions(everyColumn)}
|
|
637
|
+
value={visibleColumnIds(everyColumn)}
|
|
638
|
+
onChange={(next) => applyColumnVisibility(everyColumn, next as Set<string>)}
|
|
639
|
+
/>
|
|
599
640
|
</div>
|
|
600
641
|
{/if}
|
|
601
642
|
|
|
@@ -632,42 +673,49 @@
|
|
|
632
673
|
|
|
633
674
|
{#if inSuggestFor}
|
|
634
675
|
{@const suggestColId = inSuggestFor}
|
|
635
|
-
{@const selectedTokens = new Set(
|
|
676
|
+
{@const selectedTokens = new Set<string | number>(
|
|
636
677
|
splitInTokens(filterRowValues[suggestColId] ?? ""),
|
|
637
678
|
)}
|
|
638
|
-
<!-- Value suggestions for an `in` / `notIn`
|
|
639
|
-
`
|
|
640
|
-
|
|
641
|
-
|
|
679
|
+
<!-- Value suggestions for an `in` / `notIn` input. The wrapper swallows
|
|
680
|
+
`mousedown` so clicking a row keeps focus in the input and the
|
|
681
|
+
dropdown stays open for multi-select; the input's own blur handler
|
|
682
|
+
closes it when focus truly leaves. The list still picks on `click`.
|
|
683
|
+
Tokens the query is currently hiding ride along in the set, so a
|
|
684
|
+
click never silently drops them. -->
|
|
685
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
642
686
|
<div
|
|
643
687
|
class="sv-grid-menu sv-grid-in-suggest"
|
|
644
|
-
role="listbox"
|
|
645
|
-
aria-multiselectable="true"
|
|
646
|
-
aria-label="Filter values"
|
|
647
688
|
style={`left: ${inSuggestPos.x}px; top: ${inSuggestPos.y}px;`}
|
|
689
|
+
onmousedown={(event) => event.preventDefault()}
|
|
648
690
|
>
|
|
649
|
-
{#
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
691
|
+
{#if inSuggestOptions.length}
|
|
692
|
+
<SvListBox
|
|
693
|
+
block
|
|
694
|
+
checkbox
|
|
695
|
+
multiple
|
|
696
|
+
virtual={inSuggestOptions.length > FACET_VIRTUAL_THRESHOLD}
|
|
697
|
+
rows={8}
|
|
698
|
+
rowHeight={26}
|
|
699
|
+
size="sm"
|
|
700
|
+
ariaLabel="Filter values"
|
|
701
|
+
options={inSuggestOptions}
|
|
702
|
+
value={selectedTokens}
|
|
703
|
+
onChange={(next) => {
|
|
704
|
+
const picked = new Set(next as Set<string>);
|
|
705
|
+
// Boxes that hold the whole list ("AAPL, MS") leave a half-typed
|
|
706
|
+
// fragment in the token set. The pick REPLACES that fragment
|
|
707
|
+
// rather than landing beside it as a stray filter value. The chip
|
|
708
|
+
// input keeps its fragment out of `filterRowValues`, so this is a
|
|
709
|
+
// no-op there.
|
|
710
|
+
const fragment = trailingInToken(filterRowValues[suggestColId] ?? "");
|
|
711
|
+
if (fragment) picked.delete(fragment);
|
|
712
|
+
setFilterTokens(suggestColId, [...picked]);
|
|
713
|
+
ctrl.inSuggestQuery = "";
|
|
659
714
|
}}
|
|
660
|
-
|
|
661
|
-
<span class="sv-grid-in-suggest-check" aria-hidden="true"
|
|
662
|
-
>{checked ? "✓" : ""}</span
|
|
663
|
-
>
|
|
664
|
-
<span class="sv-grid-in-suggest-label"
|
|
665
|
-
>{value === "" ? "(Blanks)" : value}</span
|
|
666
|
-
>
|
|
667
|
-
</button>
|
|
715
|
+
/>
|
|
668
716
|
{:else}
|
|
669
717
|
<div class="sv-grid-facet-empty">No values</div>
|
|
670
|
-
{/
|
|
718
|
+
{/if}
|
|
671
719
|
</div>
|
|
672
720
|
{/if}
|
|
673
721
|
|
|
@@ -87,6 +87,7 @@ import {
|
|
|
87
87
|
computeColumnGroupMeta,
|
|
88
88
|
hiddenLeavesForCollapse,
|
|
89
89
|
} from "./column-groups";
|
|
90
|
+
import { resolveColumnId } from "./column-id";
|
|
90
91
|
import {
|
|
91
92
|
createGridApi,
|
|
92
93
|
} from "./build-api";
|
|
@@ -123,6 +124,7 @@ import {
|
|
|
123
124
|
getColumnAccessorValue,
|
|
124
125
|
columnDefMatchesId,
|
|
125
126
|
} from "./cell-values";
|
|
127
|
+
import { untrack } from "svelte";
|
|
126
128
|
|
|
127
129
|
/**
|
|
128
130
|
* Conservative fallback for the browser's max element height, used during SSR
|
|
@@ -270,7 +272,16 @@ export type SvGridController<
|
|
|
270
272
|
export function createSvGridController<
|
|
271
273
|
TFeatures extends TableFeatures = TableFeatures,
|
|
272
274
|
TData extends RowData = RowData,
|
|
273
|
-
>(rawProps: Props<TFeatures, TData
|
|
275
|
+
>(rawProps: Props<TFeatures, TData>, domIdBase?: string) {
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Base for every DOM id this grid mints (`<base>_cell_<row>_<col>`). The view
|
|
279
|
+
* passes Svelte's per-instance `$props.id()`, which is stable across SSR and
|
|
280
|
+
* hydration; two `<SvGrid>` instances on one page used to emit identical cell
|
|
281
|
+
* ids, so `aria-activedescendant` on the second pointed into the first (#77).
|
|
282
|
+
* Falls back to the historical literal for direct controller construction.
|
|
283
|
+
*/
|
|
284
|
+
const gridDomId = domIdBase ?? "svgrid";
|
|
274
285
|
|
|
275
286
|
// Runtime option overrides set via the imperative api (`api.setOption(key, value)`).
|
|
276
287
|
// Every controller read of a prop goes through the `props` proxy below (and the view
|
|
@@ -867,9 +878,6 @@ export function createSvGridController<
|
|
|
867
878
|
// compute pixel widths for group cells.
|
|
868
879
|
type LeafEntry = { id: string; widthPx: number };
|
|
869
880
|
const leafEntries: LeafEntry[] = [];
|
|
870
|
-
function buildId(def: ColumnDef<any, TData>, parentId: string | undefined, fallbackIx: number): string {
|
|
871
|
-
return def.id ?? def.field ?? `${parentId ?? 'col'}_d_${fallbackIx}`;
|
|
872
|
-
}
|
|
873
881
|
// Leaves hidden by a collapsed/expanded group are skipped everywhere here,
|
|
874
882
|
// so group colSpan + widthPx exclude them and stay aligned with the leaves
|
|
875
883
|
// the body actually renders.
|
|
@@ -880,7 +888,7 @@ export function createSvGridController<
|
|
|
880
888
|
depthHere: number,
|
|
881
889
|
): void {
|
|
882
890
|
defs.forEach((def, ix) => {
|
|
883
|
-
const id =
|
|
891
|
+
const id = resolveColumnId(def, parentId, depthHere, ix);
|
|
884
892
|
if (def.columns?.length) {
|
|
885
893
|
collectLeaves(def.columns, id, depthHere + 1);
|
|
886
894
|
} else if (!hiddenLeaf[id]) {
|
|
@@ -897,16 +905,22 @@ export function createSvGridController<
|
|
|
897
905
|
defs: Array<ColumnDef<any, TData>>,
|
|
898
906
|
parentId: string | undefined,
|
|
899
907
|
cursor: { leaf: number },
|
|
908
|
+
depthHere: number,
|
|
900
909
|
): NodeAt[] {
|
|
901
910
|
const nodes: NodeAt[] = [];
|
|
902
|
-
|
|
903
|
-
|
|
911
|
+
// Walk by array index, not by a count of pushed nodes: the `continue`
|
|
912
|
+
// below skips hidden leaves without pushing, so a counter would drift and
|
|
913
|
+
// hand the next unnamed sibling a different id than `collectLeaves` and
|
|
914
|
+
// the engine give it (#63).
|
|
915
|
+
for (let ix = 0; ix < defs.length; ix += 1) {
|
|
916
|
+
const def = defs[ix]!;
|
|
917
|
+
const id = resolveColumnId(def, parentId, depthHere, ix);
|
|
904
918
|
// Skip leaves the collapse state hides, so leaf indices/colSpans match
|
|
905
919
|
// `leafEntries` (and the body's rendered columns) exactly.
|
|
906
920
|
if (!def.columns?.length && hiddenLeaf[id]) continue;
|
|
907
921
|
const leafStart = cursor.leaf;
|
|
908
922
|
if (def.columns?.length) {
|
|
909
|
-
indexTree(def.columns, id, cursor);
|
|
923
|
+
indexTree(def.columns, id, cursor, depthHere + 1);
|
|
910
924
|
} else {
|
|
911
925
|
cursor.leaf += 1;
|
|
912
926
|
}
|
|
@@ -916,7 +930,7 @@ export function createSvGridController<
|
|
|
916
930
|
return nodes;
|
|
917
931
|
}
|
|
918
932
|
const cursor = { leaf: 0 };
|
|
919
|
-
const topNodes = indexTree(userCols, undefined, cursor);
|
|
933
|
+
const topNodes = indexTree(userCols, undefined, cursor, 0);
|
|
920
934
|
|
|
921
935
|
function nodesAtDepth(
|
|
922
936
|
nodes: NodeAt[],
|
|
@@ -928,7 +942,7 @@ export function createSvGridController<
|
|
|
928
942
|
for (const n of nodes) {
|
|
929
943
|
if (n.def.columns?.length) {
|
|
930
944
|
const childCursor = { leaf: n.leafStart };
|
|
931
|
-
const children = indexTree(n.def.columns, n.id, childCursor);
|
|
945
|
+
const children = indexTree(n.def.columns, n.id, childCursor, currentDepth + 1);
|
|
932
946
|
out.push(...nodesAtDepth(children, currentDepth + 1, targetDepth));
|
|
933
947
|
} else {
|
|
934
948
|
// Leaf reached early - emit a placeholder at this row so the
|
|
@@ -1033,10 +1047,13 @@ export function createSvGridController<
|
|
|
1033
1047
|
const map = new Map<string, ColumnStat>();
|
|
1034
1048
|
const formats = props.conditionalFormats;
|
|
1035
1049
|
if (!formats?.length || !formatsNeedingStats(formats)) return map;
|
|
1036
|
-
// `
|
|
1037
|
-
//
|
|
1038
|
-
//
|
|
1039
|
-
|
|
1050
|
+
// `filtered` (default): every row that survives the filters, ignoring the
|
|
1051
|
+
// page slice - so a value keeps the same colour as you page through (#61).
|
|
1052
|
+
// `visible`: only the rows on screen, rescaling per page. `all`: the full
|
|
1053
|
+
// unfiltered dataset, for a scale that stays put as you filter.
|
|
1054
|
+
const scope = props.conditionalStatScope ?? "filtered";
|
|
1055
|
+
const scanAll = scope === "all";
|
|
1056
|
+
const scanRows = scope === "visible" ? allRows : allRowsBeforePagination;
|
|
1040
1057
|
for (const column of allColumns) {
|
|
1041
1058
|
const needs = formats.some(
|
|
1042
1059
|
(f) => formatNeedsStats(f) && (!f.columns || f.columns.includes(column.id)),
|
|
@@ -1054,7 +1071,7 @@ export function createSvGridController<
|
|
|
1054
1071
|
}
|
|
1055
1072
|
})()
|
|
1056
1073
|
: (function* () {
|
|
1057
|
-
for (const row of
|
|
1074
|
+
for (const row of scanRows) {
|
|
1058
1075
|
yield fieldFn
|
|
1059
1076
|
? fieldFn(row.original)
|
|
1060
1077
|
: field
|
|
@@ -2063,7 +2080,7 @@ export function createSvGridController<
|
|
|
2063
2080
|
const inRows = active.rowIndex >= 0 && active.rowIndex < allRows.length;
|
|
2064
2081
|
const inCols = active.colIndex >= 0 && active.colIndex < allColumns.length;
|
|
2065
2082
|
if (!inRows || !inCols) return null;
|
|
2066
|
-
return getGridCellDomId(
|
|
2083
|
+
return getGridCellDomId(gridDomId, active.rowIndex, active.colIndex);
|
|
2067
2084
|
});
|
|
2068
2085
|
|
|
2069
2086
|
|
|
@@ -2135,7 +2152,7 @@ export function createSvGridController<
|
|
|
2135
2152
|
grid.setActiveCell({
|
|
2136
2153
|
rowIndex: 0,
|
|
2137
2154
|
colIndex: 0,
|
|
2138
|
-
cellId: getGridCellDomId(
|
|
2155
|
+
cellId: getGridCellDomId(gridDomId, 0, 0),
|
|
2139
2156
|
});
|
|
2140
2157
|
});
|
|
2141
2158
|
|
|
@@ -2592,12 +2609,58 @@ export function createSvGridController<
|
|
|
2592
2609
|
);
|
|
2593
2610
|
}
|
|
2594
2611
|
|
|
2612
|
+
/**
|
|
2613
|
+
* Facet values for the open menu, SNAPSHOTTED when it opens.
|
|
2614
|
+
*
|
|
2615
|
+
* `facetValuesForColumn` walks every row and natural-sorts the distinct
|
|
2616
|
+
* values, so deriving it live re-ran that whole scan on every data change.
|
|
2617
|
+
* On a streaming grid (the trading-desk demo replaces its row array a couple
|
|
2618
|
+
* of times a second) that meant rebuilding a 10k-entry set + sort - and
|
|
2619
|
+
* re-keying the rendered value list - continuously for as long as the menu
|
|
2620
|
+
* sat open. The offered values are frozen for the life of the popover
|
|
2621
|
+
* instead, which is also what a spreadsheet's filter checklist does.
|
|
2622
|
+
* Filtering itself is unaffected: `valueFilters` matches live row values.
|
|
2623
|
+
*
|
|
2624
|
+
* The cache is a plain local, not `$state`, so writing it here can't feed
|
|
2625
|
+
* back into this derived.
|
|
2626
|
+
*/
|
|
2627
|
+
// Stable identities for the closed-menu case, so the derives below don't hand
|
|
2628
|
+
// out a fresh empty array/set on every read.
|
|
2629
|
+
const EMPTY_FACETS: Array<string> = [];
|
|
2630
|
+
const EMPTY_FACET_SET: ReadonlySet<string> = new Set<string>();
|
|
2631
|
+
let facetCache: {
|
|
2632
|
+
columnId: string;
|
|
2633
|
+
source: Array<string> | null;
|
|
2634
|
+
values: Array<string>;
|
|
2635
|
+
} | null = null;
|
|
2595
2636
|
const columnMenuFacetValues = $derived.by(() => {
|
|
2596
2637
|
// The funnel popover drives via `filterMenuFor`; the column menu's Filter
|
|
2597
2638
|
// tab drives via `columnMenuFor`. Support whichever is open.
|
|
2598
2639
|
const columnId = filterMenuFor ?? columnMenuFor;
|
|
2599
|
-
if (!columnId) return
|
|
2600
|
-
|
|
2640
|
+
if (!columnId) return EMPTY_FACETS;
|
|
2641
|
+
// Server-provided values land asynchronously, so they stay TRACKED and
|
|
2642
|
+
// re-snapshot when they arrive. Locally derived values are read untracked
|
|
2643
|
+
// so a live data tick can't retrigger the scan.
|
|
2644
|
+
const source = props.serverFilterValues
|
|
2645
|
+
? (serverFacetValues[columnId] ?? null)
|
|
2646
|
+
: null;
|
|
2647
|
+
if (facetCache?.columnId === columnId && facetCache.source === source) {
|
|
2648
|
+
return facetCache.values;
|
|
2649
|
+
}
|
|
2650
|
+
facetCache = {
|
|
2651
|
+
columnId,
|
|
2652
|
+
source,
|
|
2653
|
+
values: untrack(() => facetValuesForColumn(columnId)),
|
|
2654
|
+
};
|
|
2655
|
+
return facetCache.values;
|
|
2656
|
+
});
|
|
2657
|
+
|
|
2658
|
+
// Drop the snapshot when the menu closes, so reopening the SAME column
|
|
2659
|
+
// re-scans and picks up whatever the data has become meanwhile. This has to
|
|
2660
|
+
// be an effect rather than a branch in the derived above: the derived is
|
|
2661
|
+
// lazy, and nothing reads it while the menu is shut.
|
|
2662
|
+
$effect(() => {
|
|
2663
|
+
if (!(filterMenuFor ?? columnMenuFor)) facetCache = null;
|
|
2601
2664
|
});
|
|
2602
2665
|
|
|
2603
2666
|
const columnMenuVisibleFacets = $derived.by(() => {
|
|
@@ -2608,20 +2671,46 @@ export function createSvGridController<
|
|
|
2608
2671
|
);
|
|
2609
2672
|
});
|
|
2610
2673
|
|
|
2674
|
+
/** Visible facets as listbox options ((Blanks) label for the empty value). */
|
|
2675
|
+
const columnMenuFacetOptions = $derived(
|
|
2676
|
+
columnMenuVisibleFacets.map((value) => ({
|
|
2677
|
+
value,
|
|
2678
|
+
label: value === "" ? "(Blanks)" : value,
|
|
2679
|
+
})),
|
|
2680
|
+
);
|
|
2681
|
+
|
|
2682
|
+
/**
|
|
2683
|
+
* Checked facets for the open menu. An ABSENT `valueFilters` entry means
|
|
2684
|
+
* "everything checked", so that case materializes the set once here rather
|
|
2685
|
+
* than every consumer re-deriving it.
|
|
2686
|
+
*/
|
|
2687
|
+
const columnMenuSelectedFacets = $derived.by(() => {
|
|
2688
|
+
const columnId = filterMenuFor ?? columnMenuFor;
|
|
2689
|
+
if (!columnId) return EMPTY_FACET_SET;
|
|
2690
|
+
return valueFilters[columnId] ?? new Set(columnMenuFacetValues);
|
|
2691
|
+
});
|
|
2692
|
+
|
|
2611
2693
|
// Distinct values offered in the `in` / `notIn` suggestions dropdown for the
|
|
2612
|
-
// active chip input, narrowed by whatever the user has typed.
|
|
2613
|
-
// high-cardinality column
|
|
2614
|
-
|
|
2694
|
+
// active chip input, narrowed by whatever the user has typed. Uncapped - the
|
|
2695
|
+
// dropdown windows its rows, so a high-cardinality column costs a list of
|
|
2696
|
+
// strings, not thousands of nodes.
|
|
2615
2697
|
const inSuggestValues = $derived.by(() => {
|
|
2616
|
-
if (!inSuggestFor) return
|
|
2698
|
+
if (!inSuggestFor) return EMPTY_FACETS;
|
|
2617
2699
|
const query = inSuggestQuery.trim().toLowerCase();
|
|
2618
2700
|
const all = facetValuesForColumn(inSuggestFor);
|
|
2619
|
-
|
|
2701
|
+
return query
|
|
2620
2702
|
? all.filter((value) => value.toLowerCase().includes(query))
|
|
2621
2703
|
: all;
|
|
2622
|
-
return matched.slice(0, IN_SUGGEST_LIMIT);
|
|
2623
2704
|
});
|
|
2624
2705
|
|
|
2706
|
+
/** `in` / `notIn` suggestions as listbox options. */
|
|
2707
|
+
const inSuggestOptions = $derived(
|
|
2708
|
+
inSuggestValues.map((value) => ({
|
|
2709
|
+
value,
|
|
2710
|
+
label: value === "" ? "(Blanks)" : value,
|
|
2711
|
+
})),
|
|
2712
|
+
);
|
|
2713
|
+
|
|
2625
2714
|
|
|
2626
2715
|
|
|
2627
2716
|
|
|
@@ -2767,6 +2856,7 @@ export function createSvGridController<
|
|
|
2767
2856
|
get inSuggestQuery() { return inSuggestQuery; },
|
|
2768
2857
|
set inSuggestQuery(v) { inSuggestQuery = v as never; },
|
|
2769
2858
|
get inSuggestValues() { return inSuggestValues; },
|
|
2859
|
+
get inSuggestOptions() { return inSuggestOptions; },
|
|
2770
2860
|
get facetValuesForColumn() { return facetValuesForColumn; },
|
|
2771
2861
|
get chooseColumnsPos() { return chooseColumnsPos; },
|
|
2772
2862
|
set chooseColumnsPos(v) { chooseColumnsPos = v as never; },
|
|
@@ -3052,6 +3142,7 @@ export function createSvGridController<
|
|
|
3052
3142
|
get columnWindowRightSpacer() { return columnWindowRightSpacer; },
|
|
3053
3143
|
get activeCell() { return activeCell; },
|
|
3054
3144
|
get activeDescendantId() { return activeDescendantId; },
|
|
3145
|
+
get gridDomId() { return gridDomId; },
|
|
3055
3146
|
get formatSummaryNumeric() { return formatSummaryNumeric; },
|
|
3056
3147
|
get computeSummaries() { return computeSummaries; },
|
|
3057
3148
|
get SUMMARY_DEFER_CELL_LIMIT() { return SUMMARY_DEFER_CELL_LIMIT; },
|
|
@@ -3134,6 +3225,7 @@ export function createSvGridController<
|
|
|
3134
3225
|
get applyHistoryStep() { return applyHistoryStep; },
|
|
3135
3226
|
get updateEditingCellValue() { return updateEditingCellValue; },
|
|
3136
3227
|
get onEditorKeyDown() { return onEditorKeyDown; },
|
|
3228
|
+
get commitAndMoveByTab() { return commitAndMoveByTab; },
|
|
3137
3229
|
get focusOnMount() { return focusOnMount; },
|
|
3138
3230
|
get onHeaderSortClick() { return onHeaderSortClick; },
|
|
3139
3231
|
get onGridKeyDown() { return onGridKeyDown; },
|
|
@@ -3174,6 +3266,7 @@ export function createSvGridController<
|
|
|
3174
3266
|
get addFilterToken() { return addFilterToken; },
|
|
3175
3267
|
get removeFilterToken() { return removeFilterToken; },
|
|
3176
3268
|
get toggleFilterToken() { return toggleFilterToken; },
|
|
3269
|
+
get setFilterTokens() { return setFilterTokens; },
|
|
3177
3270
|
get sortColumnFromMenu() { return sortColumnFromMenu; },
|
|
3178
3271
|
get clearColumnSort() { return clearColumnSort; },
|
|
3179
3272
|
get groupByColumnFromMenu() { return groupByColumnFromMenu; },
|
|
@@ -3185,8 +3278,11 @@ export function createSvGridController<
|
|
|
3185
3278
|
get serverFacetLoading() { return serverFacetLoading; },
|
|
3186
3279
|
get columnMenuFacetValues() { return columnMenuFacetValues; },
|
|
3187
3280
|
get columnMenuVisibleFacets() { return columnMenuVisibleFacets; },
|
|
3281
|
+
get columnMenuFacetOptions() { return columnMenuFacetOptions; },
|
|
3282
|
+
get columnMenuSelectedFacets() { return columnMenuSelectedFacets; },
|
|
3188
3283
|
get isFacetChecked() { return isFacetChecked; },
|
|
3189
3284
|
get toggleFacetValue() { return toggleFacetValue; },
|
|
3285
|
+
get setFacetSelection() { return setFacetSelection; },
|
|
3190
3286
|
get isAllFacetsChecked() { return isAllFacetsChecked; },
|
|
3191
3287
|
get toggleAllFacets() { return toggleAllFacets; },
|
|
3192
3288
|
get clearColumnFilter() { return clearColumnFilter; },
|
|
@@ -3200,9 +3296,9 @@ export function createSvGridController<
|
|
|
3200
3296
|
const { showTooltipFor, hideTooltip, flushScheduledScrollSync, scheduleScrollSync, onBodyScroll } = createScrollSync<TFeatures, TData>(ctx);
|
|
3201
3297
|
const { onGridKeyDown, onWindowKeydown, onHeaderSortClick } = createKeyboard<TFeatures, TData>(ctx);
|
|
3202
3298
|
const { computeSummaries, hasRenderedColumn } = createSummaries<TFeatures, TData>(ctx);
|
|
3203
|
-
const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, addFilterToken, removeFilterToken, toggleFilterToken, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openInSuggest, closeInSuggest, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus<TFeatures, TData>(ctx);
|
|
3299
|
+
const { updateFilterRow, updateFilterOperator, updateFilterMenuValue, updateFilterMenuValueTo, addFilterToken, removeFilterToken, toggleFilterToken, toggleCheckboxWithKeyboard, isColumnFiltered, closeMenus, openInSuggest, closeInSuggest, openChooseColumns, openColumnMenu, openFilterMenu, openOperatorMenu, sortColumnFromMenu, clearColumnSort, groupByColumnFromMenu, clearGroupingFromMenu, isFacetChecked, toggleFacetValue, setFacetSelection, setFilterTokens, isAllFacetsChecked, toggleAllFacets, clearColumnFilter, changePage, goToPage, setPageSize, openContextMenu, closeContextMenu, contextMenuItems, saveComment, removeComment, closeCommentEditor } = createMenus<TFeatures, TData>(ctx);
|
|
3204
3300
|
const { cellConditionalFormat, computeRowClass, computeCellClass, computeCellTooltip, computeCellValidity, computeCellNote, getColumnEditorOptions, formatListCellValue, formatCellValue, formatPinnedValue, computePinnedCellClass } = createCellRender<TFeatures, TData>(ctx);
|
|
3205
|
-
const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
|
|
3301
|
+
const { isCellEditable, isCellEditableAt, getRowColumnValue, getCellDisplayValue, startEditingWithChar, startEditing, stopEditing, startFullRowEdit, setFullRowDraft, commitFullRowEdit, cancelFullRowEdit, saveEditingCell, applyHistoryStep, updateEditingCellValue, onEditorKeyDown, commitAndMoveByTab, focusOnMount, onCellDoubleClick, pasteFromClipboard, onGridPaste } = createEditing<TFeatures, TData>(ctx);
|
|
3206
3302
|
const { isRowSelected, toggleRowSelectionById, toggleSelectAllRows, setActiveCell, scrollActiveCellIntoView, setSelection, extendSelection, isCellInSelectedRange, getCellRangeEdges, getSelectionRects, isInFillPreview, fillMarqueeEdges, findColumnById, onCellPointerDown, onCellPointerEnter, endDragSelection, onWindowPointerMove, onCellClick, emitCellDoubleClick } = createSelection<TFeatures, TData>(ctx);
|
|
3207
3303
|
const { cellPinStyle, isColumnPinned, getCurrentColumnOrder, emitColumnOrder, setColumnOrderInternal, applyColumnDrop, onColumnHeaderDragStart, onColumnHeaderDragOver, onColumnHeaderDragLeave, onColumnHeaderDrop, onColumnHeaderDragEnd, pinColumnLeft, pinColumnRight, unpinColumn, toggleColumnVisibleInPanel, moveColumnInPanel, toggleGroupInPanel, getColumnBaseWidth, getColumnWidth, startColumnResize, onColumnResizeMove, endColumnResize, measureText, autosizeColumn, autosizeAllColumns, resetColumns } = createColumns<TFeatures, TData>(ctx);
|
|
3208
3304
|
const { onRowDragStart, onRowDragOver, onRowDragLeave, onRowDrop, onRowsContainerDragOver, onRowsContainerDrop, onRowDragEnd, destroyRowDrag } = createRowDrag<TFeatures, TData>(ctx);
|