@svgrid/grid 2.2.28 → 2.2.30
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/SvGrid.css
CHANGED
|
@@ -1899,35 +1899,15 @@ select.sv-grid-fr-editor {
|
|
|
1899
1899
|
|
|
1900
1900
|
/* In / Not-in value-suggestions dropdown (anchored under the chip input).
|
|
1901
1901
|
Defined AFTER .sv-grid-menu so its tighter sizing wins - the element
|
|
1902
|
-
carries both classes.
|
|
1902
|
+
carries both classes. The rows come from SvListBox, which owns its own
|
|
1903
|
+
scrolling (windowed past ~40 values), so this is just the frame. */
|
|
1903
1904
|
.sv-grid-menu.sv-grid-in-suggest {
|
|
1904
1905
|
min-width: 140px;
|
|
1905
1906
|
max-width: 260px;
|
|
1906
|
-
|
|
1907
|
-
overflow
|
|
1907
|
+
padding: 0;
|
|
1908
|
+
overflow: visible;
|
|
1908
1909
|
font-size: 12px;
|
|
1909
1910
|
}
|
|
1910
|
-
.sv-grid-in-suggest-item {
|
|
1911
|
-
gap: 6px;
|
|
1912
|
-
padding: 3px 8px;
|
|
1913
|
-
border-radius: 4px;
|
|
1914
|
-
line-height: 1.4;
|
|
1915
|
-
}
|
|
1916
|
-
.sv-grid-in-suggest-item[aria-selected="true"] {
|
|
1917
|
-
color: var(--sg-accent, #0b63f3);
|
|
1918
|
-
font-weight: 600;
|
|
1919
|
-
}
|
|
1920
|
-
.sv-grid-in-suggest-check {
|
|
1921
|
-
flex: none;
|
|
1922
|
-
width: 12px;
|
|
1923
|
-
text-align: center;
|
|
1924
|
-
color: var(--sg-accent, #0b63f3);
|
|
1925
|
-
}
|
|
1926
|
-
.sv-grid-in-suggest-label {
|
|
1927
|
-
overflow: hidden;
|
|
1928
|
-
text-overflow: ellipsis;
|
|
1929
|
-
white-space: nowrap;
|
|
1930
|
-
}
|
|
1931
1911
|
|
|
1932
1912
|
.sv-grid-menu-item[aria-checked="true"] {
|
|
1933
1913
|
background: var(--sg-selection-bg, #eaf2ff);
|
|
@@ -2062,12 +2042,13 @@ select.sv-grid-fr-editor {
|
|
|
2062
2042
|
padding: 2px 7px !important;
|
|
2063
2043
|
}
|
|
2064
2044
|
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2045
|
+
/* The value / column checklists are SvListBox instances (windowed past ~40
|
|
2046
|
+
rows). They draw their own box off the shared --sg-input-border/--sg-border
|
|
2047
|
+
tokens, so all the menu adds is scroll containment - without it a wheel past
|
|
2048
|
+
the end of a non-virtual list scrolls the page behind the popover, which
|
|
2049
|
+
closes it. */
|
|
2050
|
+
.sv-grid-menu .sv-listbox {
|
|
2068
2051
|
overscroll-behavior: contain;
|
|
2069
|
-
border: 1px solid var(--sg-border, #eaeef4);
|
|
2070
|
-
border-radius: 5px;
|
|
2071
2052
|
}
|
|
2072
2053
|
|
|
2073
2054
|
.sv-grid-facet {
|
|
@@ -2082,9 +2063,11 @@ select.sv-grid-fr-editor {
|
|
|
2082
2063
|
background: var(--sg-row-hover-bg, #f4f6fa);
|
|
2083
2064
|
}
|
|
2084
2065
|
|
|
2066
|
+
/* Sits directly above the checklist, which draws its own frame - so no rule
|
|
2067
|
+
here, or the two borders stack. */
|
|
2085
2068
|
.sv-grid-facet-all {
|
|
2086
|
-
border-bottom: 1px solid var(--sg-border, #eaeef4);
|
|
2087
2069
|
font-weight: 600;
|
|
2070
|
+
padding-inline-start: 3px;
|
|
2088
2071
|
}
|
|
2089
2072
|
|
|
2090
2073
|
.sv-grid-facet input {
|
|
@@ -2464,11 +2447,9 @@ select.sv-grid-fr-editor {
|
|
|
2464
2447
|
.sv-grid-choose-columns-menu {
|
|
2465
2448
|
width: 240px;
|
|
2466
2449
|
}
|
|
2467
|
-
.sv-grid-choose-columns-menu .sv-grid-facet-list {
|
|
2468
|
-
max-height: 280px;
|
|
2469
|
-
}
|
|
2470
2450
|
|
|
2471
|
-
/* Native
|
|
2451
|
+
/* Native checkbox on the "(Select all)" row picks up the theme accent. The
|
|
2452
|
+
checklist rows below it draw SvListBox's own box, not a native input. */
|
|
2472
2453
|
.sv-grid-facet input[type="checkbox"] {
|
|
2473
2454
|
accent-color: var(--sg-accent, #2563eb);
|
|
2474
2455
|
width: 14px;
|
package/src/SvGrid.svelte
CHANGED
|
@@ -56,6 +56,7 @@
|
|
|
56
56
|
} from "./SvGrid.helpers";
|
|
57
57
|
import {
|
|
58
58
|
splitInTokens,
|
|
59
|
+
trailingInToken,
|
|
59
60
|
joinInTokens,
|
|
60
61
|
} from "./filtering/excel-filters";
|
|
61
62
|
import { createSvGridController } from "./SvGrid.controller.svelte";
|
|
@@ -68,7 +69,13 @@
|
|
|
68
69
|
import { getBoardView } from "./board-view.svelte";
|
|
69
70
|
import { getChartView } from "./chart-view.svelte";
|
|
70
71
|
let props: Props<TFeatures, TData> = $props();
|
|
71
|
-
|
|
72
|
+
// Per-instance base for every DOM id the grid mints. `$props.id()` is stable
|
|
73
|
+
// across SSR and hydration, so two grids on one page no longer collide on
|
|
74
|
+
// `svgrid_cell_0_0` and point `aria-activedescendant` at each other (#77).
|
|
75
|
+
// `$props.id()` must be its own declaration initializer - it can't be passed
|
|
76
|
+
// inline as a call argument.
|
|
77
|
+
const gridInstanceId = $props.id();
|
|
78
|
+
const ctrl = createSvGridController(props, gridInstanceId);
|
|
72
79
|
// Effective props: the controller's `override ?? prop` proxy that backs
|
|
73
80
|
// `api.setOption(...)`. Reading `opt.X` (instead of `props.X`) makes a view-direct
|
|
74
81
|
// prop honor a runtime override. Svelte binds template `props.X` reads to the prop
|
|
@@ -151,6 +158,10 @@
|
|
|
151
158
|
ctrl.contextMenuFor ||
|
|
152
159
|
ctrl.filterMenuFor ||
|
|
153
160
|
ctrl.operatorMenuFor ||
|
|
161
|
+
// The value-suggestions dropdown lives in the overlay too, and it can be
|
|
162
|
+
// the FIRST thing a user opens (focusing an in/notIn filter input), so it
|
|
163
|
+
// has to pull the chunk in rather than assume a menu already did.
|
|
164
|
+
ctrl.inSuggestFor ||
|
|
154
165
|
ctrl.chooseColumnsPos;
|
|
155
166
|
if (menuOpen && !MenusOverlay)
|
|
156
167
|
import("./GridMenus.svelte").then((m) => (MenusOverlay = m.default));
|
|
@@ -474,7 +485,6 @@
|
|
|
474
485
|
const closeInSuggest = $derived(ctrl.closeInSuggest);
|
|
475
486
|
const addFilterToken = $derived(ctrl.addFilterToken);
|
|
476
487
|
const removeFilterToken = $derived(ctrl.removeFilterToken);
|
|
477
|
-
const facetValuesForColumn = $derived(ctrl.facetValuesForColumn);
|
|
478
488
|
const onWindowKeydown = $derived(ctrl.onWindowKeydown);
|
|
479
489
|
|
|
480
490
|
// True when a `regex` filter's pattern won't compile - used to flag the
|
|
@@ -522,6 +532,21 @@
|
|
|
522
532
|
if (ctrl.inSuggestFor !== columnId) openInSuggest(input, columnId);
|
|
523
533
|
}
|
|
524
534
|
|
|
535
|
+
// The tool-panel / filter-menu `in` / `notIn` inputs hold the WHOLE token
|
|
536
|
+
// list ("AAPL, MSFT"), unlike the chip input which holds one token. So the
|
|
537
|
+
// suggestion query is the trailing fragment being typed, not the full value.
|
|
538
|
+
function onSetOpFilterFocus(event: FocusEvent, columnId: string): void {
|
|
539
|
+
const input = event.currentTarget as HTMLInputElement;
|
|
540
|
+
openInSuggest(input, columnId);
|
|
541
|
+
ctrl.inSuggestQuery = trailingInToken(input.value);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function onSetOpFilterInput(event: Event, columnId: string): void {
|
|
545
|
+
const input = event.currentTarget as HTMLInputElement;
|
|
546
|
+
ctrl.inSuggestQuery = trailingInToken(input.value);
|
|
547
|
+
if (ctrl.inSuggestFor !== columnId) openInSuggest(input, columnId);
|
|
548
|
+
}
|
|
549
|
+
|
|
525
550
|
// Overflow layout for the `in` / `notIn` chip row: render every chip, then
|
|
526
551
|
// measure how many fit on one line and collapse the rest into the "+N" pill.
|
|
527
552
|
// A guess-by-width heuristic can't work (labels vary), so we measure real
|
|
@@ -1280,10 +1305,14 @@
|
|
|
1280
1305
|
return;
|
|
1281
1306
|
}
|
|
1282
1307
|
// Tab and Ctrl/Cmd+Enter both commit. Plain Enter inserts a newline.
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1308
|
+
// Tab goes through the shared helper so it advances the active cell
|
|
1309
|
+
// like every other editor does (#48).
|
|
1310
|
+
if (event.key === "Tab") {
|
|
1311
|
+
event.preventDefault();
|
|
1312
|
+
ctrl.commitAndMoveByTab(event.shiftKey);
|
|
1313
|
+
return;
|
|
1314
|
+
}
|
|
1315
|
+
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
|
1287
1316
|
event.preventDefault();
|
|
1288
1317
|
saveEditingCell();
|
|
1289
1318
|
ctrl.gridRootEl?.focus({ preventScroll: true });
|
|
@@ -2583,7 +2612,7 @@
|
|
|
2583
2612
|
),
|
|
2584
2613
|
}}
|
|
2585
2614
|
{...getGridCellA11yProps({
|
|
2586
|
-
id: getGridCellDomId(
|
|
2615
|
+
id: getGridCellDomId(ctrl.gridDomId, rowIndex, colIndex),
|
|
2587
2616
|
rowIndex: rowIndex + 1,
|
|
2588
2617
|
colIndex: colIndex + 1,
|
|
2589
2618
|
selected: isRowSelected(row.id),
|
|
@@ -2833,7 +2862,7 @@
|
|
|
2833
2862
|
),
|
|
2834
2863
|
}}
|
|
2835
2864
|
{...getGridCellA11yProps({
|
|
2836
|
-
id: getGridCellDomId(
|
|
2865
|
+
id: getGridCellDomId(ctrl.gridDomId, rowIndex, colIndex),
|
|
2837
2866
|
rowIndex: rowIndex + 1,
|
|
2838
2867
|
colIndex: colIndex + 1,
|
|
2839
2868
|
selected: isRowSelected(row.id),
|
|
@@ -3273,10 +3302,13 @@
|
|
|
3273
3302
|
</select>
|
|
3274
3303
|
{#if active !== "isBlank" && active !== "isNotBlank"}
|
|
3275
3304
|
{@const tpIsSetOp = active === "in" || active === "notIn"}
|
|
3305
|
+
<!-- in/notIn drives the shared value-suggestions dropdown
|
|
3306
|
+
(a windowed checklist) rather than a native datalist,
|
|
3307
|
+
which capped at 200 values and couldn't show which
|
|
3308
|
+
are already selected. -->
|
|
3276
3309
|
<input
|
|
3277
3310
|
class="sv-grid-tp-filter-input"
|
|
3278
3311
|
type={active === "regex" ? "text" : fType}
|
|
3279
|
-
list={tpIsSetOp ? `sv-tp-in-list-${column.id}` : undefined}
|
|
3280
3312
|
placeholder={active === "between"
|
|
3281
3313
|
? "From"
|
|
3282
3314
|
: tpIsSetOp
|
|
@@ -3285,19 +3317,15 @@
|
|
|
3285
3317
|
? "Pattern…"
|
|
3286
3318
|
: "Filter…"}
|
|
3287
3319
|
value={filterMenuValues[column.id]?.value ?? ""}
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3291
|
-
|
|
3292
|
-
|
|
3320
|
+
onfocus={tpIsSetOp
|
|
3321
|
+
? (e) => onSetOpFilterFocus(e, column.id)
|
|
3322
|
+
: undefined}
|
|
3323
|
+
onblur={tpIsSetOp ? () => closeInSuggest() : undefined}
|
|
3324
|
+
oninput={(e) => {
|
|
3325
|
+
updateFilterMenuValue(column.id, e.currentTarget.value);
|
|
3326
|
+
if (tpIsSetOp) onSetOpFilterInput(e, column.id);
|
|
3327
|
+
}}
|
|
3293
3328
|
/>
|
|
3294
|
-
{#if tpIsSetOp}
|
|
3295
|
-
<datalist id={`sv-tp-in-list-${column.id}`}>
|
|
3296
|
-
{#each facetValuesForColumn(column.id).slice(0, 200) as v (v)}
|
|
3297
|
-
<option value={v}></option>
|
|
3298
|
-
{/each}
|
|
3299
|
-
</datalist>
|
|
3300
|
-
{/if}
|
|
3301
3329
|
{#if active === "between"}
|
|
3302
3330
|
<input
|
|
3303
3331
|
class="sv-grid-tp-filter-input"
|
package/src/SvGrid.types.ts
CHANGED
|
@@ -1370,11 +1370,18 @@ export type Props<TFeatures extends TableFeatures = TableFeatures, TData extends
|
|
|
1370
1370
|
conditionalFormats?: ReadonlyArray<ConditionalFormat<TData>>;
|
|
1371
1371
|
/**
|
|
1372
1372
|
* Which rows feed the min/max range that `colorScale` / `dataBar` formats
|
|
1373
|
-
* scale against.
|
|
1374
|
-
*
|
|
1375
|
-
*
|
|
1376
|
-
|
|
1377
|
-
|
|
1373
|
+
* scale against.
|
|
1374
|
+
*
|
|
1375
|
+
* - `filtered` (default): every row that survives the filters, ignoring the
|
|
1376
|
+
* page slice. The scale follows what you filtered to but does not shift
|
|
1377
|
+
* when you page, so the same value keeps the same colour on page 1 and
|
|
1378
|
+
* page 2.
|
|
1379
|
+
* - `visible`: only the rows currently on screen. Rescales per page - use it
|
|
1380
|
+
* when you want each page's heat map normalized to that page.
|
|
1381
|
+
* - `all`: the full unfiltered dataset, for a scale that stays put as you
|
|
1382
|
+
* filter.
|
|
1383
|
+
*/
|
|
1384
|
+
conditionalStatScope?: "filtered" | "visible" | "all";
|
|
1378
1385
|
onCellValueChange?: (event: {
|
|
1379
1386
|
rowIndex: number;
|
|
1380
1387
|
columnId: string;
|
|
@@ -1589,6 +1596,14 @@ export type CellEditState = {
|
|
|
1589
1596
|
columnId: string;
|
|
1590
1597
|
editorType: CellEditorType;
|
|
1591
1598
|
value: unknown;
|
|
1599
|
+
/**
|
|
1600
|
+
* The row's underlying data object, captured when the edit started. The
|
|
1601
|
+
* commit path resolves the row by id first; this is the fallback for when
|
|
1602
|
+
* the row has left the row model mid-edit (a filter typed into the filter
|
|
1603
|
+
* row while an editor is open), so the typed value still lands instead of
|
|
1604
|
+
* being silently dropped.
|
|
1605
|
+
*/
|
|
1606
|
+
rowRef?: unknown;
|
|
1592
1607
|
} | null;
|
|
1593
1608
|
export type FilterOperator =
|
|
1594
1609
|
| "contains"
|
package/src/SvListBox.svelte
CHANGED
|
@@ -23,9 +23,18 @@
|
|
|
23
23
|
|
|
24
24
|
type Props = SvEditorProps & {
|
|
25
25
|
options: ReadonlyArray<ListOption>
|
|
26
|
-
value?: string | number |
|
|
26
|
+
value?: string | number | ReadonlyArray<string | number> | ReadonlySet<string | number> | null
|
|
27
27
|
onChange?: (value: any) => void
|
|
28
28
|
multiple?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Checklist styling: each row draws a checkbox and the selected-row accent
|
|
31
|
+
* highlight is suppressed, because a list that starts fully selected (a
|
|
32
|
+
* filter checklist) would otherwise paint every row. State reads off the
|
|
33
|
+
* box; `aria-selected` still carries it for assistive tech.
|
|
34
|
+
*/
|
|
35
|
+
checkbox?: boolean
|
|
36
|
+
/** Stretch to the container width instead of the default 220px. */
|
|
37
|
+
block?: boolean
|
|
29
38
|
/** Visible height in rows before scrolling. */
|
|
30
39
|
rows?: number
|
|
31
40
|
/** Window the list (render only visible rows) for large option sets. */
|
|
@@ -46,6 +55,8 @@
|
|
|
46
55
|
value = null,
|
|
47
56
|
onChange,
|
|
48
57
|
multiple = false,
|
|
58
|
+
checkbox = false,
|
|
59
|
+
block = false,
|
|
49
60
|
disabled = false,
|
|
50
61
|
size = 'md',
|
|
51
62
|
rows = 7,
|
|
@@ -152,13 +163,15 @@
|
|
|
152
163
|
})
|
|
153
164
|
</script>
|
|
154
165
|
|
|
155
|
-
<SvField id={uid} {label} {hint} {error} {required} {dir}>
|
|
166
|
+
<SvField id={uid} {label} {hint} {error} {required} {dir} {block}>
|
|
156
167
|
<!-- Div-based listbox (role comes from `lb.rootProps()`). -->
|
|
157
168
|
<div
|
|
158
169
|
bind:this={listEl}
|
|
159
170
|
class="sv-listbox sv-listbox--{size}"
|
|
160
171
|
class:is-invalid={invalid}
|
|
161
172
|
class:is-virtual={useVirtual}
|
|
173
|
+
class:is-checkbox={checkbox}
|
|
174
|
+
class:is-block={block}
|
|
162
175
|
style:--sv-rows={rows}
|
|
163
176
|
style:--sv-row-h={`${rowHeightBase}px`}
|
|
164
177
|
onwheel={useVirtual ? scroller.onWheel : undefined}
|
|
@@ -211,7 +224,11 @@
|
|
|
211
224
|
</SvField>
|
|
212
225
|
|
|
213
226
|
{#snippet optionInner(opt: ListOption)}
|
|
214
|
-
{#if
|
|
227
|
+
{#if checkbox}
|
|
228
|
+
<span class="sv-listbox__box" class:is-on={isSel(opt)} aria-hidden="true"></span>
|
|
229
|
+
{:else if multiple}
|
|
230
|
+
<span class="sv-listbox__check" aria-hidden="true">{isSel(opt) ? '✓' : ''}</span>
|
|
231
|
+
{/if}
|
|
215
232
|
{#if item ?? itemTemplate}{@render (item ?? itemTemplate)!(opt)}{:else}<span class="sv-listbox__label">{opt.label}</span>{/if}
|
|
216
233
|
{/snippet}
|
|
217
234
|
|
|
@@ -249,6 +266,7 @@
|
|
|
249
266
|
padding: 0; overflow: hidden; position: relative;
|
|
250
267
|
height: calc(var(--sv-rows, 7) * var(--sv-row-h, 32px) + 8px);
|
|
251
268
|
}
|
|
269
|
+
.sv-listbox.is-block { width: 100%; }
|
|
252
270
|
.sv-listbox--sm { --_fs: 12px; }
|
|
253
271
|
.sv-listbox--lg { --_fs: 15px; }
|
|
254
272
|
.sv-listbox:focus-visible { border-color: var(--_accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--_accent) 22%, transparent); }
|
|
@@ -284,4 +302,25 @@
|
|
|
284
302
|
}
|
|
285
303
|
.sv-listbox__check { width: 14px; text-align: center; color: var(--_accent); }
|
|
286
304
|
.sv-listbox__label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
305
|
+
|
|
306
|
+
/* ---- Checklist variant ---- */
|
|
307
|
+
.sv-listbox__box {
|
|
308
|
+
width: 14px; height: 14px; flex: none; box-sizing: border-box; position: relative;
|
|
309
|
+
border: 1px solid var(--sg-input-border, var(--sg-border, #cbd5e1));
|
|
310
|
+
border-radius: 3px; background: var(--sg-input-bg, #fff);
|
|
311
|
+
}
|
|
312
|
+
.sv-listbox__box.is-on { background: var(--_accent); border-color: var(--_accent); }
|
|
313
|
+
.sv-listbox__box.is-on::after {
|
|
314
|
+
content: ''; position: absolute; left: 4px; top: 1px; width: 3px; height: 7px;
|
|
315
|
+
border: solid var(--sg-input-bg, #fff); border-width: 0 2px 2px 0; transform: rotate(45deg);
|
|
316
|
+
}
|
|
317
|
+
/* The box carries the state, so selected rows keep neutral chrome - only the
|
|
318
|
+
roving highlight tints a row. */
|
|
319
|
+
.sv-listbox.is-checkbox .sv-listbox__opt.is-selected {
|
|
320
|
+
background: none; color: inherit; font-weight: inherit;
|
|
321
|
+
}
|
|
322
|
+
.sv-listbox.is-checkbox .sv-listbox__opt.is-active,
|
|
323
|
+
.sv-listbox.is-checkbox .sv-listbox__opt.is-selected.is-active {
|
|
324
|
+
background: var(--sg-row-hover-bg, #f1f5f9);
|
|
325
|
+
}
|
|
287
326
|
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { computeColumnGroupMeta, hiddenLeavesForCollapse } from './column-groups'
|
|
3
|
+
import { resolveColumnId } from './column-id'
|
|
3
4
|
|
|
4
5
|
// A group "Q1" with an always-on Total plus Jan/Feb that only show when open.
|
|
5
6
|
const columns = [
|
|
@@ -57,3 +58,50 @@ describe('hiddenLeavesForCollapse', () => {
|
|
|
57
58
|
expect(hidden.apr).toBeUndefined() // 'open' shows when expanded
|
|
58
59
|
})
|
|
59
60
|
})
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Id agreement with the engine
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
describe('unnamed columns', () => {
|
|
67
|
+
// Columns with neither `id` nor `field` get an id synthesized from their
|
|
68
|
+
// position. The engine, this module, and the group-header derivation all
|
|
69
|
+
// have to synthesize the SAME one - they used to disagree (`q1_d_1` here vs
|
|
70
|
+
// `q1_1_1` in the engine), so `columnGroupShow` on an unnamed column looked
|
|
71
|
+
// wired up but never actually hid anything.
|
|
72
|
+
const unnamed = [
|
|
73
|
+
{ field: 'name' },
|
|
74
|
+
{
|
|
75
|
+
id: 'q1',
|
|
76
|
+
header: 'Q1',
|
|
77
|
+
columns: [
|
|
78
|
+
{ field: 'jan', columnGroupShow: 'open' },
|
|
79
|
+
{ header: 'Closed', columnGroupShow: 'closed' },
|
|
80
|
+
{ header: 'Always' },
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
it('keys leafControl by the id the engine assigns', () => {
|
|
86
|
+
const meta = computeColumnGroupMeta(unnamed)
|
|
87
|
+
// `<parentId>_<depth>_<index>`: depth 1 under "q1", index 1 in its siblings.
|
|
88
|
+
expect(meta.leafControl.has('q1_1_1')).toBe(true)
|
|
89
|
+
expect(meta.leafControl.get('q1_1_1')).toEqual({ groupId: 'q1', show: 'closed' })
|
|
90
|
+
// The untagged sibling is not controlled, and no stale `_d_` key survives.
|
|
91
|
+
expect(meta.leafControl.has('q1_1_2')).toBe(false)
|
|
92
|
+
expect([...meta.leafControl.keys()].some((k) => k.includes('_d_'))).toBe(false)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('hides an unnamed closed-mode leaf once the group is expanded', () => {
|
|
96
|
+
const meta = computeColumnGroupMeta(unnamed)
|
|
97
|
+
expect(hiddenLeavesForCollapse(meta, new Set<string>())).toEqual({ q1_1_1: true })
|
|
98
|
+
expect(hiddenLeavesForCollapse(meta, new Set(['q1']))).toEqual({ jan: true })
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('matches resolveColumnId at every depth', () => {
|
|
102
|
+
expect(resolveColumnId({ field: 'jan' }, 'q1', 1, 0)).toBe('jan')
|
|
103
|
+
expect(resolveColumnId({ id: 'q1' }, undefined, 0, 1)).toBe('q1')
|
|
104
|
+
expect(resolveColumnId({ header: 'X' }, 'q1', 1, 2)).toBe('q1_1_2')
|
|
105
|
+
expect(resolveColumnId({ header: 'X' }, undefined, 0, 3)).toBe('col_0_3')
|
|
106
|
+
})
|
|
107
|
+
})
|
package/src/column-groups.ts
CHANGED
|
@@ -20,12 +20,7 @@ export type ColumnGroupMeta = {
|
|
|
20
20
|
leafControl: Map<string, { groupId: string; show: ColumnGroupShow }>;
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
// leaf ids line up across both.
|
|
25
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
26
|
-
function idOf(def: any, parentId: string | undefined, ix: number): string {
|
|
27
|
-
return def.id ?? def.field ?? `${parentId ?? "col"}_d_${ix}`;
|
|
28
|
-
}
|
|
23
|
+
import { resolveColumnId } from "./column-id";
|
|
29
24
|
|
|
30
25
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
31
26
|
export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroupMeta {
|
|
@@ -33,10 +28,13 @@ export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroup
|
|
|
33
28
|
const defaultOpen = new Map<string, boolean>();
|
|
34
29
|
const leafControl = new Map<string, { groupId: string; show: ColumnGroupShow }>();
|
|
35
30
|
|
|
31
|
+
// `depth` is threaded because it is part of the id of an unnamed column -
|
|
32
|
+
// drop it and the ids here stop matching the ones the engine assigns, so
|
|
33
|
+
// `leafControl` lookups silently miss for those columns.
|
|
36
34
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
-
function walk(defs: ReadonlyArray<any>, parentId: string | undefined, controllingGroup: string | undefined) {
|
|
35
|
+
function walk(defs: ReadonlyArray<any>, parentId: string | undefined, controllingGroup: string | undefined, depth: number) {
|
|
38
36
|
defs.forEach((def, ix) => {
|
|
39
|
-
const id =
|
|
37
|
+
const id = resolveColumnId(def, parentId, depth, ix);
|
|
40
38
|
if (def.columns?.length) {
|
|
41
39
|
// A group is collapsible when any DIRECT child declares columnGroupShow.
|
|
42
40
|
const collapsible = def.columns.some(
|
|
@@ -47,7 +45,7 @@ export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroup
|
|
|
47
45
|
collapsibleGroupIds.add(id);
|
|
48
46
|
defaultOpen.set(id, def.openByDefault === true);
|
|
49
47
|
}
|
|
50
|
-
walk(def.columns, id, collapsible ? id : controllingGroup);
|
|
48
|
+
walk(def.columns, id, collapsible ? id : controllingGroup, depth + 1);
|
|
51
49
|
} else {
|
|
52
50
|
const show = def.columnGroupShow;
|
|
53
51
|
if ((show === "open" || show === "closed") && controllingGroup) {
|
|
@@ -57,7 +55,7 @@ export function computeColumnGroupMeta(columns: ReadonlyArray<any>): ColumnGroup
|
|
|
57
55
|
});
|
|
58
56
|
}
|
|
59
57
|
|
|
60
|
-
walk(columns, undefined, undefined);
|
|
58
|
+
walk(columns, undefined, undefined, 0);
|
|
61
59
|
return { collapsibleGroupIds, defaultOpen, leafControl };
|
|
62
60
|
}
|
|
63
61
|
|
package/src/column-id.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The single definition of a column's id.
|
|
2
|
+
//
|
|
3
|
+
// A column def normally names itself with `id` or `field`. When it does
|
|
4
|
+
// neither, the id is synthesized from where the def sits in the tree. That
|
|
5
|
+
// synthesized form is what makes this module necessary: it used to be spelled
|
|
6
|
+
// out separately in the engine, in the collapsible-group metadata, and in the
|
|
7
|
+
// group-header derivation, and the copies did not agree. The engine keyed
|
|
8
|
+
// columns by `<parent>_<depth>_<index>` while the other two used
|
|
9
|
+
// `<parent>_d_<index>`, so for an unnamed column every cross-lookup missed -
|
|
10
|
+
// `columnGroupShow` never hid it, and its group header measured nothing.
|
|
11
|
+
//
|
|
12
|
+
// Anything that needs a column's id derives it here.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The id the grid engine assigns to a column def.
|
|
16
|
+
*
|
|
17
|
+
* @param def the column def (only `id` / `field` are read)
|
|
18
|
+
* @param parentId id of the enclosing column group, if any
|
|
19
|
+
* @param depth nesting depth of `def` - 0 at the top level
|
|
20
|
+
* @param index position of `def` within its own sibling array
|
|
21
|
+
*/
|
|
22
|
+
export function resolveColumnId(
|
|
23
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
24
|
+
def: any,
|
|
25
|
+
parentId: string | undefined,
|
|
26
|
+
depth: number,
|
|
27
|
+
index: number,
|
|
28
|
+
): string {
|
|
29
|
+
return def.id ?? def.field ?? `${parentId ?? "col"}_${depth}_${index}`;
|
|
30
|
+
}
|
package/src/core.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SparklineConfig } from './sparkline'
|
|
2
|
+
import { resolveColumnId } from './column-id'
|
|
2
3
|
|
|
3
4
|
export type RowData = Record<string, unknown>
|
|
4
5
|
export type Updater<T> = T | ((prev: T) => T)
|
|
@@ -913,7 +914,7 @@ export function createSvGridCore<TFeatures extends TableFeatures, TData extends
|
|
|
913
914
|
): Array<Column<TData>> => {
|
|
914
915
|
const leaves: Array<Column<TData>> = []
|
|
915
916
|
defs.forEach((columnDef, index) => {
|
|
916
|
-
const id = columnDef
|
|
917
|
+
const id = resolveColumnId(columnDef, parentId, depth, index)
|
|
917
918
|
if (columnDef.columns?.length) {
|
|
918
919
|
leaves.push(...build(columnDef.columns, depth + 1, id))
|
|
919
920
|
return
|
|
@@ -25,14 +25,25 @@
|
|
|
25
25
|
import { createTypeaheadBuffer, isTypeaheadKey, nextTypeaheadIndex, type ListOption } from './list-option'
|
|
26
26
|
import { editorAria, type EditorAriaState } from './editor-contract'
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* A `Set` is accepted (and echoed back) in multi-select mode so a caller whose
|
|
30
|
+
* selection is already set-shaped - a filter checklist whose default state is
|
|
31
|
+
* "every value selected" - never has to materialize it as an array to drive the
|
|
32
|
+
* listbox, or to spread it back out again on every click.
|
|
33
|
+
*/
|
|
34
|
+
export type ListboxValue =
|
|
35
|
+
| string
|
|
36
|
+
| number
|
|
37
|
+
| ReadonlyArray<string | number>
|
|
38
|
+
| ReadonlySet<string | number>
|
|
39
|
+
| null
|
|
29
40
|
|
|
30
41
|
/** Reactive inputs are passed as getters so the core tracks live prop changes
|
|
31
42
|
* (the same controlled pattern Svelte headless libraries use). */
|
|
32
43
|
export type ListboxConfig = {
|
|
33
44
|
options: () => ReadonlyArray<ListOption>
|
|
34
45
|
value: () => ListboxValue
|
|
35
|
-
onChange?: (value: string | number | Array<string | number>) => void
|
|
46
|
+
onChange?: (value: string | number | Array<string | number> | Set<string | number>) => void
|
|
36
47
|
multiple?: () => boolean
|
|
37
48
|
disabled?: () => boolean
|
|
38
49
|
ariaLabel?: () => string | undefined
|
|
@@ -95,8 +106,23 @@ let uid = 0
|
|
|
95
106
|
|
|
96
107
|
/** Normalize a listbox value to an array (pure). */
|
|
97
108
|
export function toSelectedArray(value: ListboxValue, multiple: boolean): Array<string | number> {
|
|
98
|
-
if (
|
|
99
|
-
|
|
109
|
+
if (value == null) return []
|
|
110
|
+
if (value instanceof Set || Array.isArray(value)) {
|
|
111
|
+
const all = [...(value as Iterable<string | number>)]
|
|
112
|
+
return multiple ? all : all.slice(0, 1)
|
|
113
|
+
}
|
|
114
|
+
return [value as string | number]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Membership set for a listbox value (pure). A `Set` value is reused as-is, so
|
|
119
|
+
* `isSelected` stays O(1) without copying - the difference between a linear
|
|
120
|
+
* scan per rendered row and a hash hit when the selection runs to thousands of
|
|
121
|
+
* entries.
|
|
122
|
+
*/
|
|
123
|
+
export function toSelectedSet(value: ListboxValue, multiple: boolean): ReadonlySet<string | number> {
|
|
124
|
+
if (value instanceof Set) return value
|
|
125
|
+
return new Set(toSelectedArray(value, multiple))
|
|
100
126
|
}
|
|
101
127
|
|
|
102
128
|
export function createListbox(config: ListboxConfig): Listbox {
|
|
@@ -106,6 +132,9 @@ export function createListbox(config: ListboxConfig): Listbox {
|
|
|
106
132
|
const disabled = () => config.disabled?.() ?? false
|
|
107
133
|
|
|
108
134
|
let active = $state(0)
|
|
135
|
+
// `selectedSet` drives membership; `selected` (the ordered array) stays a
|
|
136
|
+
// lazy derived so a large selection is only flattened when someone reads it.
|
|
137
|
+
const selectedSet = $derived(toSelectedSet(config.value(), multiple()))
|
|
109
138
|
const selected = $derived(toSelectedArray(config.value(), multiple()))
|
|
110
139
|
const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
|
|
111
140
|
|
|
@@ -115,7 +144,7 @@ export function createListbox(config: ListboxConfig): Listbox {
|
|
|
115
144
|
if (!o || o.disabled) active = enabledIdx[0] ?? 0
|
|
116
145
|
})
|
|
117
146
|
|
|
118
|
-
const isSelected = (value: string | number) =>
|
|
147
|
+
const isSelected = (value: string | number) => selectedSet.has(value)
|
|
119
148
|
const isActive = (index: number) => index === active
|
|
120
149
|
const optionId = (index: number) => `${id}-opt-${index}`
|
|
121
150
|
|
|
@@ -129,9 +158,12 @@ export function createListbox(config: ListboxConfig): Listbox {
|
|
|
129
158
|
if (!o || o.disabled || disabled()) return
|
|
130
159
|
active = index
|
|
131
160
|
if (multiple()) {
|
|
132
|
-
const set = new Set(
|
|
161
|
+
const set = new Set(selectedSet)
|
|
133
162
|
set.has(o.value) ? set.delete(o.value) : set.add(o.value)
|
|
134
|
-
|
|
163
|
+
// Echo back the shape we were handed. Toggling one option must not
|
|
164
|
+
// disturb selected values that aren't currently in `options` (search has
|
|
165
|
+
// narrowed the list, say) - they ride along in the set untouched.
|
|
166
|
+
config.onChange?.(config.value() instanceof Set ? set : [...set])
|
|
135
167
|
} else {
|
|
136
168
|
config.onChange?.(o.value)
|
|
137
169
|
}
|