@svgrid/grid 1.1.0 → 1.1.1

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 (117) hide show
  1. package/dist/GridMenus.svelte +205 -127
  2. package/dist/SvGrid.controller.svelte.d.ts +44 -1
  3. package/dist/SvGrid.controller.svelte.js +223 -38
  4. package/dist/SvGrid.css +2012 -1709
  5. package/dist/SvGrid.helpers.test.d.ts +1 -0
  6. package/dist/SvGrid.helpers.test.js +298 -0
  7. package/dist/SvGrid.svelte +2346 -2043
  8. package/dist/SvGrid.types.d.ts +91 -4
  9. package/dist/aligned-grids.d.ts +6 -0
  10. package/dist/aligned-grids.js +84 -0
  11. package/dist/aligned-grids.test.d.ts +1 -0
  12. package/dist/aligned-grids.test.js +75 -0
  13. package/dist/build-api.coverage.test.d.ts +20 -0
  14. package/dist/build-api.coverage.test.js +505 -0
  15. package/dist/build-api.js +59 -29
  16. package/dist/cell-render.test.d.ts +1 -0
  17. package/dist/cell-render.test.js +338 -0
  18. package/dist/chart-export.test.d.ts +1 -0
  19. package/dist/chart-export.test.js +302 -0
  20. package/dist/chart.coverage.test.d.ts +1 -0
  21. package/dist/chart.coverage.test.js +748 -0
  22. package/dist/clipboard.js +88 -24
  23. package/dist/clipboard.test.d.ts +1 -0
  24. package/dist/clipboard.test.js +700 -0
  25. package/dist/collaboration.coverage.test.d.ts +1 -0
  26. package/dist/collaboration.coverage.test.js +200 -0
  27. package/dist/column-groups.d.ts +19 -0
  28. package/dist/column-groups.js +62 -0
  29. package/dist/column-groups.test.d.ts +1 -0
  30. package/dist/column-groups.test.js +56 -0
  31. package/dist/column-types.d.ts +10 -0
  32. package/dist/column-types.js +63 -0
  33. package/dist/column-types.test.d.ts +1 -0
  34. package/dist/column-types.test.js +62 -0
  35. package/dist/columns.test.d.ts +1 -0
  36. package/dist/columns.test.js +625 -0
  37. package/dist/core.d.ts +85 -0
  38. package/dist/editing.d.ts +7 -0
  39. package/dist/editing.js +191 -5
  40. package/dist/editing.test.d.ts +1 -0
  41. package/dist/editing.test.js +732 -0
  42. package/dist/editors/cell-editors.coverage.test.d.ts +1 -0
  43. package/dist/editors/cell-editors.coverage.test.js +139 -0
  44. package/dist/facet-buckets.test.d.ts +1 -0
  45. package/dist/facet-buckets.test.js +296 -0
  46. package/dist/filter-operators.test.d.ts +1 -0
  47. package/dist/filter-operators.test.js +135 -0
  48. package/dist/hyperformula-adapter.test.d.ts +1 -0
  49. package/dist/hyperformula-adapter.test.js +205 -0
  50. package/dist/index.d.ts +5 -2
  51. package/dist/index.js +4 -1
  52. package/dist/keyboard-handlers.coverage.test.d.ts +1 -0
  53. package/dist/keyboard-handlers.coverage.test.js +495 -0
  54. package/dist/keyboard-handlers.js +7 -2
  55. package/dist/menus.js +1 -0
  56. package/dist/menus.test.d.ts +1 -0
  57. package/dist/menus.test.js +560 -0
  58. package/dist/named-views.coverage.test.d.ts +1 -0
  59. package/dist/named-views.coverage.test.js +180 -0
  60. package/dist/row-drag.d.ts +49 -0
  61. package/dist/row-drag.js +221 -0
  62. package/dist/row-drag.test.d.ts +1 -0
  63. package/dist/row-drag.test.js +142 -0
  64. package/dist/row-resize.test.d.ts +1 -0
  65. package/dist/row-resize.test.js +329 -0
  66. package/dist/scroll-sync.js +3 -0
  67. package/dist/scroll-sync.test.d.ts +1 -0
  68. package/dist/scroll-sync.test.js +290 -0
  69. package/dist/selection.d.ts +7 -1
  70. package/dist/selection.js +76 -36
  71. package/dist/selection.multi-range.test.d.ts +1 -0
  72. package/dist/selection.multi-range.test.js +55 -0
  73. package/dist/selection.test.d.ts +1 -0
  74. package/dist/selection.test.js +647 -0
  75. package/dist/server-data-source.coverage.test.d.ts +1 -0
  76. package/dist/server-data-source.coverage.test.js +154 -0
  77. package/dist/spreadsheet.d.ts +30 -0
  78. package/dist/spreadsheet.js +48 -0
  79. package/dist/spreadsheet.test.d.ts +1 -0
  80. package/dist/spreadsheet.test.js +446 -0
  81. package/dist/sv-grid-scrollbar.js +13 -1
  82. package/dist/svgrid-wrapper.types.d.ts +19 -0
  83. package/dist/svgrid.behavior.test.js +20 -0
  84. package/dist/svgrid.interaction.test.js +31 -0
  85. package/dist/svgrid.new-features.wrapper.test.js +34 -2
  86. package/dist/test-setup.js +9 -3
  87. package/dist/virtualization/scroll-scaling.d.ts +17 -0
  88. package/dist/virtualization/scroll-scaling.js +35 -0
  89. package/dist/virtualization/scroll-scaling.test.js +42 -1
  90. package/package.json +2 -1
  91. package/src/GridMenus.svelte +205 -127
  92. package/src/SvGrid.controller.svelte.ts +2352 -2195
  93. package/src/SvGrid.css +2012 -1747
  94. package/src/SvGrid.svelte +2346 -2047
  95. package/src/SvGrid.types.ts +537 -456
  96. package/src/aligned-grids.test.ts +80 -0
  97. package/src/aligned-grids.ts +87 -0
  98. package/src/build-api.ts +683 -663
  99. package/src/clipboard.test.ts +49 -0
  100. package/src/clipboard.ts +51 -23
  101. package/src/column-groups.test.ts +59 -0
  102. package/src/column-groups.ts +80 -0
  103. package/src/column-types.test.ts +68 -0
  104. package/src/column-types.ts +82 -0
  105. package/src/core.ts +78 -0
  106. package/src/editing.ts +669 -513
  107. package/src/index.ts +12 -0
  108. package/src/menus.ts +1 -0
  109. package/src/row-drag.test.ts +168 -0
  110. package/src/row-drag.ts +255 -0
  111. package/src/scroll-sync.ts +2 -0
  112. package/src/selection.multi-range.test.ts +61 -0
  113. package/src/selection.ts +71 -37
  114. package/src/spreadsheet.test.ts +489 -445
  115. package/src/spreadsheet.ts +304 -246
  116. package/src/svgrid-wrapper.types.ts +19 -0
  117. package/src/svgrid.new-features.wrapper.test.ts +2 -2
@@ -8,6 +8,23 @@ export type RowScrollScaling = {
8
8
  /** Map a virtualizer logical offset back into a DOM scrollTop. */
9
9
  logicalToDom(logical: number): number;
10
10
  };
11
+ /**
12
+ * Resolve the usable max element height from the two raw DOM signals.
13
+ *
14
+ * `layoutCap` is a tall probe's clamped `offsetHeight` - the height layout
15
+ * assigns the element. `scrollCap` is the `scrollHeight` a real `overflow:auto`
16
+ * container exposes for that same probe - the height the user can actually
17
+ * scroll through. They differ on mobile / high-DPR engines, which report a
18
+ * generous `offsetHeight` but then expose a SMALLER scrollable range (the
19
+ * physical limit is in device px, so a 3x-DPR phone has ~1/3 the CSS-px scroll
20
+ * cap). Trusting `offsetHeight` alone is exactly what strands the last rows of
21
+ * a huge grid on a phone, so we take the smaller of the two.
22
+ *
23
+ * A junk reading (<= the sanity floor, or so large the browser clearly didn't
24
+ * clamp) is dropped; if neither signal survives, the caller's conservative
25
+ * `fallback` is returned unchanged.
26
+ */
27
+ export declare function resolveMaxDomHeight(layoutCap: number, scrollCap: number, fallback: number): number;
11
28
  /**
12
29
  * Build the scaling mapping for one axis.
13
30
  *
@@ -21,6 +21,41 @@ function clamp01(value) {
21
21
  return 1;
22
22
  return value;
23
23
  }
24
+ /** Below this a reading is junk (0 from jsdom, a sub-cap layout quirk, etc). */
25
+ const MAX_DOM_HEIGHT_SANITY_FLOOR = 100000;
26
+ /** At/above this the browser didn't clamp (returned ~the 1e9 we asked for). */
27
+ const MAX_DOM_HEIGHT_SANITY_CEIL = 900000000;
28
+ /**
29
+ * Shave a hair off the detected cap so the spacer never sits at the exact
30
+ * physical edge, where sub-pixel rounding could leave the final row a touch
31
+ * out of reach. 0.5% is invisible at desktop caps (~33.5M -> ~33.3M) yet
32
+ * keeps a safety gap proportional to the cap.
33
+ */
34
+ const MAX_DOM_HEIGHT_SAFETY = 0.995;
35
+ /**
36
+ * Resolve the usable max element height from the two raw DOM signals.
37
+ *
38
+ * `layoutCap` is a tall probe's clamped `offsetHeight` - the height layout
39
+ * assigns the element. `scrollCap` is the `scrollHeight` a real `overflow:auto`
40
+ * container exposes for that same probe - the height the user can actually
41
+ * scroll through. They differ on mobile / high-DPR engines, which report a
42
+ * generous `offsetHeight` but then expose a SMALLER scrollable range (the
43
+ * physical limit is in device px, so a 3x-DPR phone has ~1/3 the CSS-px scroll
44
+ * cap). Trusting `offsetHeight` alone is exactly what strands the last rows of
45
+ * a huge grid on a phone, so we take the smaller of the two.
46
+ *
47
+ * A junk reading (<= the sanity floor, or so large the browser clearly didn't
48
+ * clamp) is dropped; if neither signal survives, the caller's conservative
49
+ * `fallback` is returned unchanged.
50
+ */
51
+ export function resolveMaxDomHeight(layoutCap, scrollCap, fallback) {
52
+ const usable = [layoutCap, scrollCap].filter((v) => Number.isFinite(v) &&
53
+ v > MAX_DOM_HEIGHT_SANITY_FLOOR &&
54
+ v < MAX_DOM_HEIGHT_SANITY_CEIL);
55
+ if (usable.length === 0)
56
+ return fallback;
57
+ return Math.floor(Math.min(...usable) * MAX_DOM_HEIGHT_SAFETY);
58
+ }
24
59
  /**
25
60
  * Build the scaling mapping for one axis.
26
61
  *
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { createRowScrollScaling } from './scroll-scaling';
2
+ import { createRowScrollScaling, resolveMaxDomHeight } from './scroll-scaling';
3
3
  const VIEWPORT = 600;
4
+ const FALLBACK = 8000000;
4
5
  describe('createRowScrollScaling - inert (content fits under the cap)', () => {
5
6
  // 1M rows * 18px = 18M, under a 33.5M (Chrome) cap -> no scaling.
6
7
  const s = createRowScrollScaling(18000000, 33554400, VIEWPORT);
@@ -62,6 +63,46 @@ describe('createRowScrollScaling - active (content exceeds the cap)', () => {
62
63
  }
63
64
  });
64
65
  });
66
+ describe('resolveMaxDomHeight - picks the smaller, real scrollable cap', () => {
67
+ it('keeps the desktop cap when both signals agree', () => {
68
+ // Chrome ~33.5M; offsetHeight and scrollHeight match. 0.5% safety shave.
69
+ const cap = resolveMaxDomHeight(33554400, 33554400, FALLBACK);
70
+ expect(cap).toBeLessThan(33554400);
71
+ expect(cap).toBeGreaterThan(33000000);
72
+ });
73
+ it('uses the SCROLL cap when a phone over-reports offsetHeight', () => {
74
+ // High-DPR mobile: layout says 24M, but the container only scrolls ~6M.
75
+ // Trusting offsetHeight is exactly what stranded the last rows.
76
+ const cap = resolveMaxDomHeight(24000000, 6000000, FALLBACK);
77
+ expect(cap).toBeLessThanOrEqual(6000000);
78
+ expect(cap).toBeGreaterThan(5900000);
79
+ });
80
+ it('shaves a safety margin so the spacer never sits at the exact edge', () => {
81
+ const cap = resolveMaxDomHeight(10000000, 10000000, FALLBACK);
82
+ expect(cap).toBeLessThan(10000000);
83
+ });
84
+ it('falls back when both readings are junk (jsdom returns 0)', () => {
85
+ expect(resolveMaxDomHeight(0, 0, FALLBACK)).toBe(FALLBACK);
86
+ });
87
+ it('falls back when the browser did not clamp (returned ~1e9)', () => {
88
+ expect(resolveMaxDomHeight(1000000000, 1000000000, FALLBACK)).toBe(FALLBACK);
89
+ });
90
+ it('ignores a junk signal but trusts the valid one', () => {
91
+ // offsetHeight unclamped/garbage, scrollHeight is the real cap.
92
+ const cap = resolveMaxDomHeight(1000000000, 5000000, FALLBACK);
93
+ expect(cap).toBeLessThanOrEqual(5000000);
94
+ expect(cap).toBeGreaterThan(4900000);
95
+ });
96
+ it('feeds straight into the scaler so the last row stays reachable', () => {
97
+ const maxDom = resolveMaxDomHeight(24000000, 6000000, FALLBACK);
98
+ const trueTotal = 500000 * 32; // 16M px of rows, over the phone cap
99
+ const s = createRowScrollScaling(trueTotal, maxDom, VIEWPORT);
100
+ expect(s.active).toBe(true);
101
+ expect(s.domTotal).toBeLessThanOrEqual(maxDom);
102
+ const domMax = s.domTotal - VIEWPORT;
103
+ expect(s.domToLogical(domMax)).toBeCloseTo(trueTotal - VIEWPORT, 0);
104
+ });
105
+ });
65
106
  describe('createRowScrollScaling - edge cases', () => {
66
107
  it('handles an empty grid without dividing by zero', () => {
67
108
  const s = createRowScrollScaling(0, 17895697, VIEWPORT);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@svgrid/grid",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "SvGrid - a headless-first, Svelte 5-native data grid engine and render component.",
5
5
  "author": "jQWidgets <boikom@jqwidgets.com>",
6
6
  "license": "MIT",
@@ -60,6 +60,7 @@
60
60
  "@vitest/coverage-v8": "^4.1.5",
61
61
  "eslint-plugin-svelte": "^3.17.1",
62
62
  "jsdom": "^29.1.1",
63
+ "publint": "^0.3.21",
63
64
  "svelte": "^5.55.5",
64
65
  "svelte-check": "^4.4.6"
65
66
  },
@@ -108,6 +108,7 @@
108
108
  const operatorMenuPos = $derived(ctrl.operatorMenuPos);
109
109
  const chooseColumnsPos = $derived(ctrl.chooseColumnsPos);
110
110
  const showColumnFiltersEffective = $derived(ctrl.showColumnFiltersEffective);
111
+ const columnMenuTabsEnabled = $derived(ctrl.props.columnMenuTabs === true);
111
112
  const grid = $derived(ctrl.grid);
112
113
  const allColumns = $derived(ctrl.allColumns);
113
114
  const isColumnPinned = $derived(ctrl.isColumnPinned);
@@ -123,6 +124,15 @@
123
124
  const operatorsForColumn = $derived(ctrl.operatorsForColumn);
124
125
  const defaultOperatorFor = $derived(ctrl.defaultOperatorFor);
125
126
  const operatorLabelFor = $derived(ctrl.operatorLabelFor);
127
+
128
+ // Patch a column's menu filter (used for the optional 2nd condition + join
129
+ // of multi-condition filtering). Merges into the existing entry, creating a
130
+ // base entry from the column's default operator when none exists yet.
131
+ function patchFilter(columnId: string, patch: Record<string, unknown>) {
132
+ const col = allColumns.find((c) => c.id === columnId);
133
+ const cur = ctrl.filterMenuValues[columnId] ?? { operator: defaultOperatorFor(col), value: "" };
134
+ ctrl.filterMenuValues = { ...ctrl.filterMenuValues, [columnId]: { ...cur, ...patch } };
135
+ }
126
136
  const isColumnFiltered = $derived(ctrl.isColumnFiltered);
127
137
  const closeMenus = $derived(ctrl.closeMenus);
128
138
  const autosizeColumn = $derived(ctrl.autosizeColumn);
@@ -169,15 +179,190 @@
169
179
  ></div>
170
180
  {/if}
171
181
 
182
+ <!-- Shared filter UI: rendered both in the funnel popover and in the column
183
+ menu's Filter tab, so the two never diverge. -->
184
+ {#snippet filterPanelBody(colId: string)}
185
+ {@const menuColumn = allColumns.find((c) => c.id === colId)}
186
+ {@const menuOperatorOptions = operatorsForColumn(menuColumn)}
187
+ {@const menuActiveOperator = filterMenuValues[colId]?.operator ?? defaultOperatorFor(menuColumn)}
188
+ {@const menuInputType = getEditorInputType((menuColumn?.columnDef.editorType ?? "text") as CellEditorType)}
189
+ <div class="sv-grid-menu-filter">
190
+ <div class="sv-grid-menu-filter-head">
191
+ <span class="sv-grid-header-icon">{@render icon("filter")}</span> Filter
192
+ condition
193
+ </div>
194
+ <select
195
+ class="sv-grid-menu-operator-select"
196
+ aria-label="Filter condition"
197
+ value={menuActiveOperator}
198
+ onchange={(event) =>
199
+ updateFilterOperator(colId, (event.currentTarget as HTMLSelectElement).value as FilterOperator)}
200
+ >
201
+ {#each menuOperatorOptions as option (option.value)}
202
+ <option value={option.value}>{operatorLabelFor(option, menuColumn)}</option>
203
+ {/each}
204
+ </select>
205
+ {#if menuActiveOperator !== "isBlank"}
206
+ <input
207
+ class="sv-grid-menu-condition-value"
208
+ type={menuInputType}
209
+ value={filterMenuValues[colId]?.value ?? ""}
210
+ placeholder={menuActiveOperator === "between" ? "From" : "Filter value..."}
211
+ oninput={(event) => updateFilterMenuValue(colId, (event.currentTarget as HTMLInputElement).value)}
212
+ />
213
+ {#if menuActiveOperator === "between"}
214
+ <input
215
+ class="sv-grid-menu-condition-value"
216
+ type={menuInputType}
217
+ value={filterMenuValues[colId]?.valueTo ?? ""}
218
+ placeholder="To"
219
+ oninput={(event) => updateFilterMenuValueTo(colId, (event.currentTarget as HTMLInputElement).value)}
220
+ />
221
+ {/if}
222
+ {@const mf = filterMenuValues[colId]}
223
+ {#if mf?.operator2}
224
+ {@const op2 = mf.operator2 ?? defaultOperatorFor(menuColumn)}
225
+ <div class="sv-grid-menu-join" role="radiogroup" aria-label="Combine conditions">
226
+ <button type="button" class:is-on={(mf.join ?? "AND") === "AND"}
227
+ onclick={() => patchFilter(colId, { join: "AND" })}>AND</button>
228
+ <button type="button" class:is-on={mf.join === "OR"}
229
+ onclick={() => patchFilter(colId, { join: "OR" })}>OR</button>
230
+ <button type="button" class="sv-grid-menu-join-x" title="Remove second condition"
231
+ aria-label="Remove second condition"
232
+ onclick={() => patchFilter(colId, { operator2: undefined, value2: undefined, valueTo2: undefined, join: undefined })}>×</button>
233
+ </div>
234
+ <select
235
+ class="sv-grid-menu-operator-select"
236
+ aria-label="Second filter condition"
237
+ value={op2}
238
+ onchange={(event) =>
239
+ patchFilter(colId, { operator2: (event.currentTarget as HTMLSelectElement).value as FilterOperator })}
240
+ >
241
+ {#each menuOperatorOptions as option (option.value)}
242
+ <option value={option.value}>{operatorLabelFor(option, menuColumn)}</option>
243
+ {/each}
244
+ </select>
245
+ {#if op2 !== "isBlank"}
246
+ <input
247
+ class="sv-grid-menu-condition-value"
248
+ type={menuInputType}
249
+ value={mf.value2 ?? ""}
250
+ placeholder={op2 === "between" ? "From" : "Filter value..."}
251
+ oninput={(event) => patchFilter(colId, { value2: (event.currentTarget as HTMLInputElement).value })}
252
+ />
253
+ {#if op2 === "between"}
254
+ <input
255
+ class="sv-grid-menu-condition-value"
256
+ type={menuInputType}
257
+ value={mf.valueTo2 ?? ""}
258
+ placeholder="To"
259
+ oninput={(event) => patchFilter(colId, { valueTo2: (event.currentTarget as HTMLInputElement).value })}
260
+ />
261
+ {/if}
262
+ {/if}
263
+ {:else}
264
+ <button type="button" class="sv-grid-menu-add-cond"
265
+ onclick={() => patchFilter(colId, { operator2: defaultOperatorFor(menuColumn), value2: "", join: "AND" })}>
266
+ + Add condition
267
+ </button>
268
+ {/if}
269
+ {/if}
270
+ <div class="sv-grid-menu-sep"></div>
271
+ <div class="sv-grid-menu-filter-head">Values</div>
272
+ <input
273
+ class="sv-grid-menu-search"
274
+ placeholder="Search values..."
275
+ bind:value={ctrl.columnMenuSearch}
276
+ />
277
+ <label class="sv-grid-facet sv-grid-facet-all">
278
+ <input type="checkbox" checked={isAllFacetsChecked(colId)} onchange={() => toggleAllFacets(colId)} />
279
+ <span class="sv-grid-facet-label">(Select all)</span>
280
+ </label>
281
+ <div class="sv-grid-facet-list">
282
+ {#each columnMenuVisibleFacets as value (value)}
283
+ <label class="sv-grid-facet">
284
+ <input type="checkbox" checked={isFacetChecked(colId, value)} onchange={() => toggleFacetValue(colId, value)} />
285
+ <span class="sv-grid-facet-label">{value === "" ? "(Blanks)" : value}</span>
286
+ </label>
287
+ {:else}
288
+ <div class="sv-grid-facet-empty">No values</div>
289
+ {/each}
290
+ </div>
291
+ {#if columnMenuFacetValues.length > columnMenuVisibleFacets.length}
292
+ <div class="sv-grid-facet-note">
293
+ Showing {columnMenuVisibleFacets.length} of {columnMenuFacetValues.length}
294
+ </div>
295
+ {/if}
296
+ <div class="sv-grid-menu-actions">
297
+ <button
298
+ type="button"
299
+ class="sv-grid-menu-btn"
300
+ disabled={!isColumnFiltered(colId)}
301
+ onclick={() => clearColumnFilter(colId)}
302
+ >
303
+ Clear filter
304
+ </button>
305
+ <button type="button" class="sv-grid-menu-btn sv-grid-menu-btn-primary" onclick={closeMenus}>
306
+ Done
307
+ </button>
308
+ </div>
309
+ </div>
310
+ {/snippet}
311
+
172
312
  {#if columnMenuFor}
173
313
  {@const menuColumnId = columnMenuFor}
174
314
  {@const menuCol = allColumns.find((c) => c.id === menuColumnId)}
175
315
  {@const menuCanSort = menuCol?.getCanSort?.() ?? false}
316
+ {@const menuCanFilter = (menuCol?.columnDef.field != null) && (menuCol?.columnDef.filterable !== false)}
317
+ {@const menuTab = ctrl.columnMenuTab}
176
318
  <div
177
319
  class="sv-grid-menu sv-grid-column-menu"
178
320
  role="menu"
179
- style={`left: ${columnMenuPos.x}px; top: ${columnMenuPos.y}px;`}
321
+ style={`left: ${columnMenuPos.x}px; top: ${columnMenuPos.y}px; max-height: calc(100vh - ${columnMenuPos.y}px - 12px);`}
180
322
  >
323
+ {#if columnMenuTabsEnabled}
324
+ <div class="sv-grid-menu-tabs" role="tablist">
325
+ <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "general"}
326
+ role="tab" aria-selected={menuTab === "general"}
327
+ onclick={() => (ctrl.columnMenuTab = "general")}>General</button>
328
+ {#if menuCanFilter && showColumnFiltersEffective}
329
+ <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "filter"}
330
+ role="tab" aria-selected={menuTab === "filter"}
331
+ onclick={() => (ctrl.columnMenuTab = "filter")}>Filter</button>
332
+ {/if}
333
+ <button type="button" class="sv-grid-menu-tab" class:is-active={menuTab === "columns"}
334
+ role="tab" aria-selected={menuTab === "columns"}
335
+ onclick={() => (ctrl.columnMenuTab = "columns")}>Columns</button>
336
+ </div>
337
+ {/if}
338
+ {#if columnMenuTabsEnabled && menuTab === "filter" && menuCanFilter && showColumnFiltersEffective}
339
+ {@render filterPanelBody(menuColumnId)}
340
+ {:else if columnMenuTabsEnabled && menuTab === "columns"}
341
+ <div class="sv-grid-menu-filter-head">Visible columns</div>
342
+ <div class="sv-grid-facet-list">
343
+ {#each grid.getAllColumns() as column (column.id)}
344
+ <label class="sv-grid-facet">
345
+ <input
346
+ type="checkbox"
347
+ checked={!ctrl.hiddenColumns[column.id]}
348
+ onchange={(event) => {
349
+ const visible = (event.currentTarget as HTMLInputElement).checked;
350
+ if (visible) {
351
+ const next = { ...ctrl.hiddenColumns };
352
+ delete next[column.id];
353
+ ctrl.hiddenColumns = next;
354
+ } else {
355
+ ctrl.hiddenColumns = { ...ctrl.hiddenColumns, [column.id]: true };
356
+ }
357
+ }}
358
+ />
359
+ <span class="sv-grid-facet-label"
360
+ >{typeof column.columnDef.header === "string" ? column.columnDef.header : column.id}</span
361
+ >
362
+ </label>
363
+ {/each}
364
+ </div>
365
+ {:else}
181
366
  {#if menuCanSort}
182
367
  <button
183
368
  type="button"
@@ -290,21 +475,23 @@
290
475
  </button>
291
476
  {/if}
292
477
  <div class="sv-grid-menu-sep"></div>
293
- <button
294
- type="button"
295
- class="sv-grid-menu-item"
296
- class:is-open={chooseColumnsPos !== null}
297
- role="menuitem"
298
- aria-haspopup="menu"
299
- aria-expanded={chooseColumnsPos !== null}
300
- onclick={(event) => openChooseColumns(event)}
301
- >
302
- <span class="sv-grid-header-icon">{@render icon("columns")}</span>
303
- Choose columns
304
- <span class="sv-grid-header-icon sv-grid-menu-item-chevron"
305
- >{@render icon("chevron-down")}</span
478
+ {#if !columnMenuTabsEnabled}
479
+ <!-- Flat menu: reach the column chooser via a submenu (tabbed mode has
480
+ a Columns tab instead). -->
481
+ <button
482
+ type="button"
483
+ class="sv-grid-menu-item"
484
+ class:is-open={chooseColumnsPos !== null}
485
+ role="menuitem"
486
+ aria-haspopup="menu"
487
+ aria-expanded={chooseColumnsPos !== null}
488
+ onclick={(event) => openChooseColumns(event)}
306
489
  >
307
- </button>
490
+ <span class="sv-grid-header-icon">{@render icon("columns")}</span>
491
+ Choose columns
492
+ <span class="sv-grid-header-icon sv-grid-menu-item-chevron">{@render icon("chevron-down")}</span>
493
+ </button>
494
+ {/if}
308
495
  <button
309
496
  type="button"
310
497
  class="sv-grid-menu-item"
@@ -316,126 +503,17 @@
316
503
  >
317
504
  <span class="sv-grid-header-icon">{@render icon("reset")}</span> Reset columns
318
505
  </button>
506
+ {/if}
319
507
  </div>
320
508
  {/if}
321
509
 
322
510
  {#if filterMenuFor && showColumnFiltersEffective}
323
- {@const menuColumnId = filterMenuFor}
324
- {@const menuColumn = allColumns.find((c) => c.id === menuColumnId)}
325
- {@const menuOperatorOptions = operatorsForColumn(menuColumn)}
326
- {@const menuActiveOperator =
327
- filterMenuValues[menuColumnId]?.operator ??
328
- defaultOperatorFor(menuColumn)}
329
- {@const menuInputType = getEditorInputType(
330
- (menuColumn?.columnDef.editorType ?? "text") as CellEditorType,
331
- )}
332
511
  <div
333
512
  class="sv-grid-menu sv-grid-filter-menu"
334
513
  role="menu"
335
- style={`left: ${filterMenuPos.x}px; top: ${filterMenuPos.y}px;`}
514
+ style={`left: ${filterMenuPos.x}px; top: ${filterMenuPos.y}px; max-height: calc(100vh - ${filterMenuPos.y}px - 12px);`}
336
515
  >
337
- <div class="sv-grid-menu-filter">
338
- <div class="sv-grid-menu-filter-head">
339
- <span class="sv-grid-header-icon">{@render icon("filter")}</span> Filter
340
- condition
341
- </div>
342
- <select
343
- class="sv-grid-menu-operator-select"
344
- aria-label="Filter condition"
345
- value={menuActiveOperator}
346
- onchange={(event) =>
347
- updateFilterOperator(
348
- menuColumnId,
349
- (event.currentTarget as HTMLSelectElement)
350
- .value as FilterOperator,
351
- )}
352
- >
353
- {#each menuOperatorOptions as option (option.value)}
354
- <option value={option.value}
355
- >{operatorLabelFor(option, menuColumn)}</option
356
- >
357
- {/each}
358
- </select>
359
- {#if menuActiveOperator !== "isBlank"}
360
- <input
361
- class="sv-grid-menu-condition-value"
362
- type={menuInputType}
363
- value={filterMenuValues[menuColumnId]?.value ?? ""}
364
- placeholder={menuActiveOperator === "between" ? "From" : "Filter value..."}
365
- oninput={(event) =>
366
- updateFilterMenuValue(
367
- menuColumnId,
368
- (event.currentTarget as HTMLInputElement).value,
369
- )}
370
- />
371
- {#if menuActiveOperator === "between"}
372
- <input
373
- class="sv-grid-menu-condition-value"
374
- type={menuInputType}
375
- value={filterMenuValues[menuColumnId]?.valueTo ?? ""}
376
- placeholder="To"
377
- oninput={(event) =>
378
- updateFilterMenuValueTo(
379
- menuColumnId,
380
- (event.currentTarget as HTMLInputElement).value,
381
- )}
382
- />
383
- {/if}
384
- {/if}
385
- <div class="sv-grid-menu-sep"></div>
386
- <div class="sv-grid-menu-filter-head">Values</div>
387
- <input
388
- class="sv-grid-menu-search"
389
- placeholder="Search values..."
390
- bind:value={ctrl.columnMenuSearch}
391
- />
392
- <label class="sv-grid-facet sv-grid-facet-all">
393
- <input
394
- type="checkbox"
395
- checked={isAllFacetsChecked(menuColumnId)}
396
- onchange={() => toggleAllFacets(menuColumnId)}
397
- />
398
- <span class="sv-grid-facet-label">(Select all)</span>
399
- </label>
400
- <div class="sv-grid-facet-list">
401
- {#each columnMenuVisibleFacets as value (value)}
402
- <label class="sv-grid-facet">
403
- <input
404
- type="checkbox"
405
- checked={isFacetChecked(menuColumnId, value)}
406
- onchange={() => toggleFacetValue(menuColumnId, value)}
407
- />
408
- <span class="sv-grid-facet-label"
409
- >{value === "" ? "(Blanks)" : value}</span
410
- >
411
- </label>
412
- {:else}
413
- <div class="sv-grid-facet-empty">No values</div>
414
- {/each}
415
- </div>
416
- {#if columnMenuFacetValues.length > columnMenuVisibleFacets.length}
417
- <div class="sv-grid-facet-note">
418
- Showing {columnMenuVisibleFacets.length} of {columnMenuFacetValues.length}
419
- </div>
420
- {/if}
421
- <div class="sv-grid-menu-actions">
422
- <button
423
- type="button"
424
- class="sv-grid-menu-btn"
425
- disabled={!isColumnFiltered(menuColumnId)}
426
- onclick={() => clearColumnFilter(menuColumnId)}
427
- >
428
- Clear filter
429
- </button>
430
- <button
431
- type="button"
432
- class="sv-grid-menu-btn sv-grid-menu-btn-primary"
433
- onclick={closeMenus}
434
- >
435
- Done
436
- </button>
437
- </div>
438
- </div>
516
+ {@render filterPanelBody(filterMenuFor)}
439
517
  </div>
440
518
  {/if}
441
519