@svgrid/grid 2.1.21 → 2.1.23

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 (219) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/GridMenus.svelte +83 -3
  7. package/dist/SvAutoComplete.svelte +5 -3
  8. package/dist/SvCarousel.svelte +20 -42
  9. package/dist/SvColorInput.svelte +4 -3
  10. package/dist/SvColorInput.svelte.d.ts +1 -1
  11. package/dist/SvComboBox.svelte +7 -3
  12. package/dist/SvCommand.svelte +28 -85
  13. package/dist/SvCountryInput.svelte +1 -1
  14. package/dist/SvDateRangeInput.svelte +1 -1
  15. package/dist/SvDateTimePicker.svelte +9 -3
  16. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  17. package/dist/SvDockLayout.svelte +244 -0
  18. package/dist/SvDockLayout.svelte.d.ts +77 -0
  19. package/dist/SvDockManager.svelte +922 -0
  20. package/dist/SvDockManager.svelte.d.ts +106 -0
  21. package/dist/SvDrawer.svelte +69 -47
  22. package/dist/SvDrawer.svelte.d.ts +4 -0
  23. package/dist/SvDropDownList.svelte +85 -13
  24. package/dist/SvDropDownList.svelte.d.ts +3 -0
  25. package/dist/SvField.svelte +21 -4
  26. package/dist/SvField.svelte.d.ts +1 -0
  27. package/dist/SvForm.svelte +201 -91
  28. package/dist/SvForm.svelte.d.ts +15 -22
  29. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  30. package/dist/SvGrid.controller.svelte.js +156 -27
  31. package/dist/SvGrid.css +193 -5
  32. package/dist/SvGrid.svelte +357 -12
  33. package/dist/SvGrid.types.d.ts +452 -1
  34. package/dist/SvGridDropdown.svelte +8 -1
  35. package/dist/SvGridSelect.svelte +1 -0
  36. package/dist/SvListBox.svelte +36 -14
  37. package/dist/SvMaskedInput.svelte +4 -3
  38. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  39. package/dist/SvMenu.svelte +1 -1
  40. package/dist/SvMenuList.svelte +23 -71
  41. package/dist/SvMenuList.svelte.d.ts +2 -13
  42. package/dist/SvModal.svelte +12 -29
  43. package/dist/SvMultiSelect.svelte +1 -0
  44. package/dist/SvNumberInput.svelte +4 -3
  45. package/dist/SvNumberInput.svelte.d.ts +1 -1
  46. package/dist/SvPagination.svelte +18 -18
  47. package/dist/SvPasswordInput.svelte +3 -2
  48. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  49. package/dist/SvPhoneInput.svelte +3 -2
  50. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  51. package/dist/SvPopover.svelte +1 -1
  52. package/dist/SvStepper.svelte +6 -9
  53. package/dist/SvTagsInput.svelte +3 -2
  54. package/dist/SvTagsInput.svelte.d.ts +1 -1
  55. package/dist/SvTooltip.svelte +12 -23
  56. package/dist/SvTour.svelte +32 -4
  57. package/dist/SvTreeSelect.svelte +1 -0
  58. package/dist/build-api.js +76 -54
  59. package/dist/cdn/svgrid.js +10907 -6444
  60. package/dist/cdn/svgrid.svelte-external.js +9622 -5475
  61. package/dist/columns.js +6 -0
  62. package/dist/conditional-formatting.d.ts +67 -3
  63. package/dist/conditional-formatting.js +124 -17
  64. package/dist/createCarousel.svelte.d.ts +72 -0
  65. package/dist/createCarousel.svelte.js +80 -0
  66. package/dist/createCommand.svelte.d.ts +62 -0
  67. package/dist/createCommand.svelte.js +109 -0
  68. package/dist/createForm.svelte.d.ts +62 -0
  69. package/dist/createForm.svelte.js +374 -0
  70. package/dist/createMenu.svelte.d.ts +88 -0
  71. package/dist/createMenu.svelte.js +153 -0
  72. package/dist/createOverlay.svelte.d.ts +38 -0
  73. package/dist/createOverlay.svelte.js +68 -0
  74. package/dist/createPagination.d.ts +74 -0
  75. package/dist/createPagination.js +71 -0
  76. package/dist/createPopoverSelect.svelte.js +6 -12
  77. package/dist/createStepper.d.ts +44 -0
  78. package/dist/createStepper.js +26 -0
  79. package/dist/createTooltip.svelte.d.ts +32 -0
  80. package/dist/createTooltip.svelte.js +61 -0
  81. package/dist/datetime/timezone.d.ts +36 -0
  82. package/dist/datetime/timezone.js +110 -0
  83. package/dist/dock-context.d.ts +52 -0
  84. package/dist/dock-context.js +1 -0
  85. package/dist/dock-manager-model.d.ts +136 -0
  86. package/dist/dock-manager-model.js +514 -0
  87. package/dist/dock-model.d.ts +105 -0
  88. package/dist/dock-model.js +288 -0
  89. package/dist/dock-popout.d.ts +30 -0
  90. package/dist/dock-popout.js +81 -0
  91. package/dist/editor-contract.d.ts +2 -0
  92. package/dist/filter-operators.d.ts +6 -0
  93. package/dist/filter-operators.js +35 -2
  94. package/dist/filtering/excel-filters.d.ts +18 -1
  95. package/dist/filtering/excel-filters.js +56 -0
  96. package/dist/form-field.d.ts +73 -0
  97. package/dist/form-field.js +15 -0
  98. package/dist/index.d.ts +25 -5
  99. package/dist/index.js +40 -4
  100. package/dist/list-nav.d.ts +18 -0
  101. package/dist/list-nav.js +30 -0
  102. package/dist/list-option.d.ts +2 -0
  103. package/dist/menu-item.d.ts +20 -0
  104. package/dist/menu-item.js +1 -0
  105. package/dist/menus.d.ts +5 -0
  106. package/dist/menus.js +68 -16
  107. package/dist/panel-resize.d.ts +25 -0
  108. package/dist/panel-resize.js +40 -0
  109. package/dist/popover.d.ts +29 -3
  110. package/dist/popover.js +22 -6
  111. package/dist/recurrence.d.ts +33 -7
  112. package/dist/recurrence.js +151 -13
  113. package/dist/row-drag.d.ts +8 -0
  114. package/dist/row-drag.js +11 -0
  115. package/dist/scheduler-ical.d.ts +30 -0
  116. package/dist/scheduler-ical.js +180 -0
  117. package/dist/scheduler-model.d.ts +269 -0
  118. package/dist/scheduler-model.js +602 -0
  119. package/dist/scheduler-view.svelte.d.ts +22 -0
  120. package/dist/scheduler-view.svelte.js +13 -0
  121. package/dist/spreadsheet.js +20 -12
  122. package/dist/ui-app.types.d.ts +3 -0
  123. package/package.json +5 -1
  124. package/src/DockNodeView.svelte +306 -0
  125. package/src/DockPopoutHost.svelte +17 -0
  126. package/src/GridMenus.svelte +83 -3
  127. package/src/SvAutoComplete.svelte +5 -3
  128. package/src/SvCarousel.svelte +20 -42
  129. package/src/SvCarousel.test.ts +68 -0
  130. package/src/SvColorInput.svelte +4 -3
  131. package/src/SvComboBox.svelte +7 -3
  132. package/src/SvCommand.svelte +28 -85
  133. package/src/SvCommand.test.ts +78 -0
  134. package/src/SvCountryInput.svelte +1 -1
  135. package/src/SvDateRangeInput.svelte +1 -1
  136. package/src/SvDateTimePicker.svelte +9 -3
  137. package/src/SvDockLayout.svelte +244 -0
  138. package/src/SvDockManager.svelte +922 -0
  139. package/src/SvDrawer.svelte +69 -47
  140. package/src/SvDropDownList.svelte +85 -13
  141. package/src/SvField.svelte +21 -4
  142. package/src/SvForm.svelte +201 -91
  143. package/src/SvForm.test.ts +299 -0
  144. package/src/SvGrid.controller.svelte.ts +152 -28
  145. package/src/SvGrid.css +193 -5
  146. package/src/SvGrid.svelte +357 -12
  147. package/src/SvGrid.types.ts +463 -1
  148. package/src/SvGridDropdown.svelte +8 -1
  149. package/src/SvGridSelect.svelte +1 -0
  150. package/src/SvListBox.svelte +36 -14
  151. package/src/SvMaskedInput.svelte +4 -3
  152. package/src/SvMenu.svelte +1 -1
  153. package/src/SvMenu.test.ts +24 -0
  154. package/src/SvMenuList.svelte +23 -71
  155. package/src/SvModal.svelte +12 -29
  156. package/src/SvModal.test.ts +82 -0
  157. package/src/SvMultiSelect.svelte +1 -0
  158. package/src/SvNumberInput.svelte +4 -3
  159. package/src/SvPagination.svelte +18 -18
  160. package/src/SvPasswordInput.svelte +3 -2
  161. package/src/SvPhoneInput.svelte +3 -2
  162. package/src/SvPopover.svelte +1 -1
  163. package/src/SvStepper.svelte +6 -9
  164. package/src/SvTagsInput.svelte +3 -2
  165. package/src/SvTooltip.svelte +12 -23
  166. package/src/SvTooltip.test.ts +73 -0
  167. package/src/SvTour.svelte +32 -4
  168. package/src/SvTreeSelect.svelte +1 -0
  169. package/src/build-api.ts +74 -51
  170. package/src/columns.ts +5 -0
  171. package/src/conditional-formatting.test.ts +104 -0
  172. package/src/conditional-formatting.ts +176 -19
  173. package/src/createCarousel.svelte.ts +104 -0
  174. package/src/createCommand.svelte.ts +125 -0
  175. package/src/createForm.svelte.ts +333 -0
  176. package/src/createMenu.svelte.ts +202 -0
  177. package/src/createOverlay.svelte.ts +101 -0
  178. package/src/createPagination.test.ts +65 -0
  179. package/src/createPagination.ts +84 -0
  180. package/src/createPopoverSelect.svelte.ts +5 -9
  181. package/src/createStepper.test.ts +62 -0
  182. package/src/createStepper.ts +61 -0
  183. package/src/createTooltip.svelte.ts +86 -0
  184. package/src/datetime/timezone.test.ts +69 -0
  185. package/src/datetime/timezone.ts +134 -0
  186. package/src/dock-context.ts +47 -0
  187. package/src/dock-manager-model.test.ts +275 -0
  188. package/src/dock-manager-model.ts +593 -0
  189. package/src/dock-model.test.ts +163 -0
  190. package/src/dock-model.ts +368 -0
  191. package/src/dock-popout.ts +94 -0
  192. package/src/editor-contract.ts +2 -0
  193. package/src/filter-operators.test.ts +59 -0
  194. package/src/filter-operators.ts +35 -2
  195. package/src/filtering/excel-filters.test.ts +86 -1
  196. package/src/filtering/excel-filters.ts +65 -0
  197. package/src/form-field.ts +87 -0
  198. package/src/index.ts +158 -4
  199. package/src/list-nav.test.ts +49 -0
  200. package/src/list-nav.ts +29 -0
  201. package/src/list-option.ts +2 -0
  202. package/src/menu-item.ts +21 -0
  203. package/src/menus.test.ts +48 -0
  204. package/src/menus.ts +73 -16
  205. package/src/panel-resize.ts +52 -0
  206. package/src/popover.bounds.test.ts +93 -0
  207. package/src/popover.ts +51 -7
  208. package/src/recurrence.test.ts +88 -1
  209. package/src/recurrence.ts +166 -15
  210. package/src/row-drag.ts +10 -0
  211. package/src/scheduler-ical.test.ts +68 -0
  212. package/src/scheduler-ical.ts +180 -0
  213. package/src/scheduler-model.test.ts +546 -0
  214. package/src/scheduler-model.ts +853 -0
  215. package/src/scheduler-view.svelte.ts +33 -0
  216. package/src/spreadsheet.ts +20 -12
  217. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  218. package/src/ui-app.types.ts +3 -0
  219. package/src/virtual.test.ts +6 -4
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Component tests for SvTooltip, exercising the shared createTooltip core: it
3
+ * opens after the show delay on hover, hides on Escape, respects `disabled`, and
4
+ * wires aria-describedby onto the focusable child while open.
5
+ */
6
+ import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'
7
+ import { mount, unmount, flushSync, createRawSnippet } from 'svelte'
8
+ import SvTooltip from './SvTooltip.svelte'
9
+
10
+ const child = createRawSnippet(() => ({ render: () => `<button class="btn">Del</button>` }))
11
+
12
+ function mountTip(props: Record<string, unknown>) {
13
+ const target = document.createElement('div')
14
+ document.body.appendChild(target)
15
+ const app = mount(SvTooltip, { target, props: { children: child, ...props } as any })
16
+ flushSync()
17
+ return { target, destroy: () => { unmount(app); target.remove() } }
18
+ }
19
+ const tip = () => document.body.querySelector<HTMLElement>('.sv-tip')
20
+
21
+ describe('SvTooltip', () => {
22
+ beforeEach(() => vi.useFakeTimers())
23
+ afterEach(() => {
24
+ vi.useRealTimers()
25
+ document.querySelectorAll('.sv-tip').forEach((n) => n.remove())
26
+ })
27
+
28
+ it('shows after the delay on pointerenter and hides on Escape', () => {
29
+ const { target, destroy } = mountTip({ text: 'Delete row', delay: 300 })
30
+ try {
31
+ const anchor = target.querySelector<HTMLElement>('.sv-tip__anchor')!
32
+ expect(tip()).toBeNull()
33
+ anchor.dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
34
+ // Nothing yet - still within the delay window.
35
+ flushSync()
36
+ expect(tip()).toBeNull()
37
+ vi.advanceTimersByTime(300)
38
+ flushSync()
39
+ const el = tip()!
40
+ expect(el).not.toBeNull()
41
+ expect(el.getAttribute('role')).toBe('tooltip')
42
+ expect(el.textContent).toContain('Delete row')
43
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
44
+ flushSync()
45
+ expect(tip()).toBeNull()
46
+ } finally { destroy() }
47
+ })
48
+
49
+ it('does not show while disabled', () => {
50
+ const { target, destroy } = mountTip({ text: 'x', disabled: true })
51
+ try {
52
+ target.querySelector<HTMLElement>('.sv-tip__anchor')!
53
+ .dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
54
+ vi.advanceTimersByTime(500)
55
+ flushSync()
56
+ expect(tip()).toBeNull()
57
+ } finally { destroy() }
58
+ })
59
+
60
+ it('wires aria-describedby onto the focusable child while open', () => {
61
+ const { target, destroy } = mountTip({ text: 'Delete row', delay: 100 })
62
+ try {
63
+ target.querySelector<HTMLElement>('.sv-tip__anchor')!
64
+ .dispatchEvent(new PointerEvent('pointerenter', { bubbles: true }))
65
+ vi.advanceTimersByTime(100)
66
+ flushSync()
67
+ const btn = target.querySelector<HTMLElement>('.btn')!
68
+ const describedby = btn.getAttribute('aria-describedby')
69
+ expect(describedby).toBeTruthy()
70
+ expect(document.getElementById(describedby!)?.getAttribute('role')).toBe('tooltip')
71
+ } finally { destroy() }
72
+ })
73
+ })
package/src/SvTour.svelte CHANGED
@@ -98,14 +98,34 @@
98
98
  })
99
99
 
100
100
  const POP_W = 300
101
+ // Measured popover height, so placement can keep the WHOLE popover on-screen
102
+ // (long content + tall targets used to push it off the top).
103
+ let popH = $state(200)
101
104
  const pop = $derived.by(() => {
102
105
  if (typeof window === 'undefined' || !rect) {
103
106
  return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)', place: 'center' as const }
104
107
  }
105
- const left = Math.max(12, Math.min(rect.left, window.innerWidth - POP_W - 12))
106
- const below = window.innerHeight - rect.bottom
107
- if (below > 190) return { top: `${rect.bottom + 10}px`, left: `${left}px`, transform: 'none', place: 'bottom' as const }
108
- return { top: `${rect.top - 10}px`, left: `${left}px`, transform: 'translateY(-100%)', place: 'top' as const }
108
+ const vw = window.innerWidth
109
+ const vh = window.innerHeight
110
+ const gap = 12
111
+ const h = popH || 200
112
+ const clampTop = (t: number) => Math.max(gap, Math.min(t, vh - h - gap))
113
+ const clampLeft = (l: number) => Math.max(gap, Math.min(l, vw - POP_W - gap))
114
+ // Prefer directly below the target, then directly above - but only when the
115
+ // target is short enough to leave room for the whole popover.
116
+ if (vh - rect.bottom >= h + gap) {
117
+ return { top: `${rect.bottom + 10}px`, left: `${clampLeft(rect.left)}px`, transform: 'none', place: 'bottom' as const }
118
+ }
119
+ if (rect.top >= h + gap) {
120
+ return { top: `${clampTop(rect.top - 10 - h)}px`, left: `${clampLeft(rect.left)}px`, transform: 'none', place: 'top' as const }
121
+ }
122
+ // Tall target (a full-height rail / canvas / inspector): sit BESIDE it,
123
+ // vertically centred on the target, on whichever side has room.
124
+ const top = clampTop(rect.top + rect.height / 2 - h / 2)
125
+ if (vw - rect.right >= POP_W + gap) return { top: `${top}px`, left: `${rect.right + 10}px`, transform: 'none', place: 'center' as const }
126
+ if (rect.left >= POP_W + gap) return { top: `${top}px`, left: `${rect.left - POP_W - 10}px`, transform: 'none', place: 'center' as const }
127
+ // No side room either - centre horizontally (still fully visible).
128
+ return { top: `${top}px`, left: `${clampLeft((vw - POP_W) / 2)}px`, transform: 'none', place: 'center' as const }
109
129
  })
110
130
  </script>
111
131
 
@@ -125,6 +145,7 @@
125
145
 
126
146
  <div
127
147
  bind:this={popEl}
148
+ bind:clientHeight={popH}
128
149
  class="sv-tour__pop sv-tour__pop--{pop.place}"
129
150
  style:top={pop.top}
130
151
  style:left={pop.left}
@@ -133,6 +154,11 @@
133
154
  aria-modal="true"
134
155
  aria-label={step.title ?? 'Tour step'}
135
156
  >
157
+ {#if step.image}
158
+ <div class="sv-tour__media">
159
+ {#if /^(https?:|data:|\/)/.test(step.image)}<img class="sv-tour__img" src={step.image} alt="" />{:else}{@html step.image}{/if}
160
+ </div>
161
+ {/if}
136
162
  {#if step.title}<div class="sv-tour__title">{step.title}</div>{/if}
137
163
  <div class="sv-tour__body">{step.content}</div>
138
164
  <div class="sv-tour__foot">
@@ -163,6 +189,8 @@
163
189
  }
164
190
  @keyframes sv-tour-in { from { opacity: 0; transform: translateY(4px) } }
165
191
  :global(.sv-tour__pop--top) { animation: none; }
192
+ :global(.sv-tour__media) { margin: -2px -4px 12px; border-radius: 9px; overflow: hidden; line-height: 0; }
193
+ :global(.sv-tour__media svg), :global(.sv-tour__img) { display: block; width: 100%; height: auto; }
166
194
  :global(.sv-tour__title) { font-size: 14.5px; font-weight: 650; margin-bottom: 4px; }
167
195
  :global(.sv-tour__body) { font-size: 13px; line-height: 1.55; color: var(--sg-fg, #334155); }
168
196
  :global(.sv-tour__foot) { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 14px; }
@@ -166,6 +166,7 @@
166
166
  aria-label={label ?? ariaLabel ?? 'Tree'}
167
167
  aria-activedescendant={ts.activeDescendant()}
168
168
  tabindex="0"
169
+ style:max-height={`${ts.rect.maxHeight}px`}
169
170
  onkeydown={ts.onPanelKeydown}
170
171
  >
171
172
  {#each rows as row, i (row.node.value)}
package/src/build-api.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  type TableFeatures,
41
41
  } from "./index";
42
42
  import "./sv-grid-scrollbar";
43
+ import { untrack } from "svelte";
43
44
  import type { Snippet } from "svelte";
44
45
  import { getKeyboardIntent, getNextActiveCell } from "./keyboard";
45
46
  import {
@@ -372,63 +373,77 @@ export function createGridApi<
372
373
  ctx.grid.setGrouping([...columnIds]);
373
374
  },
374
375
  setFilter(columnId, filter) {
375
- // Honour the column's `filterable` opt-out. Same approach as
376
- // `setSort` - `getCanFilter()` already combines the feature flag
377
- // and the per-column field.
378
- const col = ctx.allColumns.find((c: any) => c.id === columnId);
379
- if (col && !col.getCanFilter?.()) return;
380
- if (!filter) {
381
- this.clearFilter(columnId);
382
- return;
383
- }
384
- ctx.filterMenuValues = {
385
- ...ctx.filterMenuValues,
386
- [columnId]: {
387
- operator: filter.operator,
388
- value: filter.value ?? "",
389
- ...(filter.operator === "between"
390
- ? { valueTo: filter.valueTo ?? "" }
391
- : {}),
392
- // Optional second condition + join (multi-condition filtering).
393
- ...(filter.operator2
394
- ? {
395
- operator2: filter.operator2,
396
- value2: filter.value2 ?? "",
397
- join: filter.join ?? "AND",
398
- ...(filter.operator2 === "between"
399
- ? { valueTo2: filter.valueTo2 ?? "" }
400
- : {}),
401
- }
402
- : {}),
403
- },
404
- };
376
+ // `untrack`: these imperative mutators read the same filter state they
377
+ // write (read-modify-write on `filterMenuValues`). When a consumer
378
+ // calls them from inside a reactive `$effect`, that internal read would
379
+ // otherwise be captured as a dependency of the caller's effect, and the
380
+ // write would re-dirty it - an infinite update loop. Untracking the
381
+ // body keeps the API safe to drive from an effect.
382
+ untrack(() => {
383
+ // Honour the column's `filterable` opt-out. Same approach as
384
+ // `setSort` - `getCanFilter()` already combines the feature flag
385
+ // and the per-column field.
386
+ const col = ctx.allColumns.find((c: any) => c.id === columnId);
387
+ if (col && !col.getCanFilter?.()) return;
388
+ if (!filter) {
389
+ this.clearFilter(columnId);
390
+ return;
391
+ }
392
+ ctx.filterMenuValues = {
393
+ ...ctx.filterMenuValues,
394
+ [columnId]: {
395
+ operator: filter.operator,
396
+ value: filter.value ?? "",
397
+ ...(filter.operator === "between"
398
+ ? { valueTo: filter.valueTo ?? "" }
399
+ : {}),
400
+ // Optional second condition + join (multi-condition filtering).
401
+ ...(filter.operator2
402
+ ? {
403
+ operator2: filter.operator2,
404
+ value2: filter.value2 ?? "",
405
+ join: filter.join ?? "AND",
406
+ ...(filter.operator2 === "between"
407
+ ? { valueTo2: filter.valueTo2 ?? "" }
408
+ : {}),
409
+ }
410
+ : {}),
411
+ },
412
+ };
413
+ });
405
414
  },
406
415
  setFacetFilter(columnId, values) {
407
- // Empty array OR null both mean "clear this column's facet" -
408
- // mirrors how the column menu treats unchecking the last value.
409
- if (!values || values.length === 0) {
410
- if (ctx.valueFilters[columnId]) {
411
- const next = { ...ctx.valueFilters };
412
- delete next[columnId];
413
- ctx.valueFilters = next;
416
+ // See `setFilter` for why the read-modify-write is untracked.
417
+ untrack(() => {
418
+ // Empty array OR null both mean "clear this column's facet" -
419
+ // mirrors how the column menu treats unchecking the last value.
420
+ if (!values || values.length === 0) {
421
+ if (ctx.valueFilters[columnId]) {
422
+ const next = { ...ctx.valueFilters };
423
+ delete next[columnId];
424
+ ctx.valueFilters = next;
425
+ }
426
+ return;
414
427
  }
415
- return;
416
- }
417
- ctx.valueFilters = {
418
- ...ctx.valueFilters,
419
- [columnId]: new Set(values),
420
- };
428
+ ctx.valueFilters = {
429
+ ...ctx.valueFilters,
430
+ [columnId]: new Set(values),
431
+ };
432
+ });
421
433
  },
422
434
  clearFilter(columnId) {
423
435
  ctx.clearColumnFilter(columnId);
424
436
  },
425
437
  clearAllFilters() {
426
- // Wipe every filter surface in one go: column-menu, filter-row, set
427
- // filters, and the global search box.
428
- ctx.filterMenuValues = {};
429
- ctx.filterRowValues = {};
430
- ctx.valueFilters = {};
431
- if (ctx.globalFilter !== "") ctx.globalFilter = "";
438
+ // See `setFilter` for why the read-modify-write is untracked.
439
+ untrack(() => {
440
+ // Wipe every filter surface in one go: column-menu, filter-row, set
441
+ // filters, and the global search box.
442
+ ctx.filterMenuValues = {};
443
+ ctx.filterRowValues = {};
444
+ ctx.valueFilters = {};
445
+ if (ctx.globalFilter !== "") ctx.globalFilter = "";
446
+ });
432
447
  },
433
448
  getFilters() {
434
449
  // Return a defensive copy so callers can't mutate internal state.
@@ -793,7 +808,12 @@ export function createGridApi<
793
808
  const sorting = state.sorting.map((s) => ({ id: s.id, desc: s.desc }));
794
809
  ctx.grid.store.setState((prev: any) => ({ ...prev, sorting }));
795
810
  }
796
- if (state.grouping) ctx.grid.setGrouping([...state.grouping]);
811
+ if (state.grouping) {
812
+ // Drop grouping by columns that no longer exist (#53) - otherwise a
813
+ // saved view collapses every row into a group named 'undefined'.
814
+ const existing = new Set((ctx.grid.getAllColumns() as any[]).map((c) => c.id));
815
+ ctx.grid.setGrouping(state.grouping.filter((id: string) => existing.has(id)));
816
+ }
797
817
  if (state.pagination) {
798
818
  const p = state.pagination;
799
819
  ctx.grid.setPagination((prev: any) => ({ ...prev, ...p }));
@@ -806,7 +826,10 @@ export function createGridApi<
806
826
  };
807
827
  if (state.columnOrder) ctx.setColumnOrderInternal(state.columnOrder);
808
828
  if (state.hiddenColumns) {
809
- const next: Record<string, boolean> = {};
829
+ // Seed from columns declared `visible: false` so a partial/empty saved
830
+ // state never un-hides an app-declared-hidden column (#54); then layer
831
+ // the saved user-driven hides on top.
832
+ const next: Record<string, boolean> = { ...ctx.declaredHiddenColumns };
810
833
  for (const id of state.hiddenColumns) next[id] = true;
811
834
  ctx.hiddenColumns = next;
812
835
  }
package/src/columns.ts CHANGED
@@ -283,6 +283,11 @@ export function createColumns<
283
283
  ctx.resizingColumnId = columnId;
284
284
  ctx.resizeStartX = event.clientX;
285
285
  ctx.resizeStartWidth = getColumnWidth(columnId);
286
+ // Capture the pointer so the drag keeps tracking when it leaves the 4px
287
+ // handle - without this, touch (and fast mouse) drags drop mid-resize (#59).
288
+ try {
289
+ (event.currentTarget as HTMLElement | null)?.setPointerCapture?.(event.pointerId);
290
+ } catch { /* capture is best-effort */ }
286
291
  document.addEventListener("pointermove", onColumnResizeMove);
287
292
  document.addEventListener("pointerup", endColumnResize);
288
293
  document.addEventListener("pointercancel", endColumnResize);
@@ -104,4 +104,108 @@ describe('resolveCellFormat', () => {
104
104
  formatsNeedingStats([{ type: 'dataBar', color: '#000' }]),
105
105
  ).toBe(true)
106
106
  })
107
+
108
+ it('compareColumn formats do not need column stats', () => {
109
+ expect(
110
+ formatsNeedingStats([
111
+ { type: 'colorScale', min: '#000', max: '#fff', compareColumn: 'target' },
112
+ ]),
113
+ ).toBe(false)
114
+ })
115
+ })
116
+
117
+ describe('resolveCellFormat - gradient extensions', () => {
118
+ const stat = { min: 0, max: 100 }
119
+
120
+ it('alpha mode interpolates opacity over a single base color', () => {
121
+ const fmts: ConditionalFormat[] = [
122
+ { type: 'colorScale', mode: 'alpha', base: '#2563eb', alphaBounds: [0, 1] },
123
+ ]
124
+ expect(resolveCellFormat(0, {}, 'c', fmts, stat).background).toBe('rgba(37, 99, 235, 0.000)')
125
+ expect(resolveCellFormat(100, {}, 'c', fmts, stat).background).toBe('rgba(37, 99, 235, 1.000)')
126
+ expect(resolveCellFormat(50, {}, 'c', fmts, stat).background).toBe('rgba(37, 99, 235, 0.500)')
127
+ })
128
+
129
+ it('alpha mode leaves the text color untouched (translucent tint)', () => {
130
+ const fmts: ConditionalFormat[] = [{ type: 'colorScale', mode: 'alpha' }]
131
+ expect(resolveCellFormat(80, {}, 'c', fmts, stat).color).toBeUndefined()
132
+ })
133
+
134
+ it('reverse flips the ramp position', () => {
135
+ const fmts: ConditionalFormat[] = [
136
+ { type: 'colorScale', min: '#ff0000', max: '#00ff00', reverse: true },
137
+ ]
138
+ expect(resolveCellFormat(0, {}, 'c', fmts, stat).background).toBe('#00ff00')
139
+ expect(resolveCellFormat(100, {}, 'c', fmts, stat).background).toBe('#ff0000')
140
+ })
141
+
142
+ it('zeroCentred maps 0 to the midpoint symmetrically', () => {
143
+ const fmts: ConditionalFormat[] = [
144
+ { type: 'colorScale', min: '#ff0000', mid: '#ffffff', max: '#00ff00', zeroCentred: true },
145
+ ]
146
+ const s = { min: -80, max: 100 } // bound = max(|-80|,|100|) = 100
147
+ expect(resolveCellFormat(0, {}, 'c', fmts, s).background).toBe('#ffffff')
148
+ expect(resolveCellFormat(100, {}, 'c', fmts, s).background).toBe('#00ff00')
149
+ expect(resolveCellFormat(-100, {}, 'c', fmts, s).background).toBe('#ff0000')
150
+ })
151
+
152
+ it('percent bounds map 0..100 onto the column span', () => {
153
+ const fmts: ConditionalFormat[] = [
154
+ { type: 'colorScale', min: '#000000', max: '#ffffff', bounds: 'percent', minValue: 50, maxValue: 100 },
155
+ ]
156
+ // span 0..100, so minValue 50 -> data 50, below it clamps to min color
157
+ expect(resolveCellFormat(50, {}, 'c', fmts, stat).background).toBe('#000000')
158
+ expect(resolveCellFormat(100, {}, 'c', fmts, stat).background).toBe('#ffffff')
159
+ expect(resolveCellFormat(25, {}, 'c', fmts, stat).background).toBe('#000000')
160
+ })
161
+
162
+ it('N-stop scale interpolates within a band', () => {
163
+ const fmts: ConditionalFormat[] = [
164
+ {
165
+ type: 'colorScale',
166
+ stops: [
167
+ { offset: 0, color: '#ff0000' },
168
+ { offset: 0.5, color: '#ffffff' },
169
+ { offset: 1, color: '#00ff00' },
170
+ ],
171
+ },
172
+ ]
173
+ expect(resolveCellFormat(50, {}, 'c', fmts, stat).background).toBe('#ffffff')
174
+ expect(resolveCellFormat(0, {}, 'c', fmts, stat).background).toBe('#ff0000')
175
+ })
176
+
177
+ it('compareColumn tints by proportion of another field, no column stats', () => {
178
+ const fmts: ConditionalFormat[] = [
179
+ { type: 'colorScale', min: '#000000', max: '#ffffff', compareColumn: 'target' },
180
+ ]
181
+ expect(resolveCellFormat(50, { target: 100 }, 'c', fmts, null).background).toBe('#808080')
182
+ expect(resolveCellFormat(100, { target: 100 }, 'c', fmts, null).background).toBe('#ffffff')
183
+ // zero / missing target -> skip
184
+ expect(resolveCellFormat(50, { target: 0 }, 'c', fmts, null).background).toBeUndefined()
185
+ })
186
+
187
+ it('dataBar compareColumn sizes the bar against another field', () => {
188
+ const fmts: ConditionalFormat[] = [
189
+ { type: 'dataBar', color: '#2563eb', compareColumn: 'target' },
190
+ ]
191
+ expect(resolveCellFormat(30, { target: 120 }, 'c', fmts, null).dataBar?.percent).toBeCloseTo(25)
192
+ })
193
+
194
+ it('tooltip surfaces value or percent position', () => {
195
+ const valFmt: ConditionalFormat[] = [
196
+ { type: 'colorScale', min: '#000', max: '#fff', tooltip: 'value' },
197
+ ]
198
+ expect(resolveCellFormat(42, {}, 'c', valFmt, stat).title).toBe('42')
199
+ const pctFmt: ConditionalFormat[] = [
200
+ { type: 'colorScale', min: '#000', max: '#fff', tooltip: 'percent' },
201
+ ]
202
+ expect(resolveCellFormat(25, {}, 'c', pctFmt, stat).title).toBe('25%')
203
+ })
204
+
205
+ it('data bar gradient flag flows through to the resolved bar', () => {
206
+ const fmts: ConditionalFormat[] = [
207
+ { type: 'dataBar', color: '#2563eb', gradient: true },
208
+ ]
209
+ expect(resolveCellFormat(50, {}, 'c', fmts, stat).dataBar?.gradient).toBe(true)
210
+ })
107
211
  })