@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,49 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { enabledIndices, wrapMove } from './list-nav'
3
+
4
+ describe('enabledIndices', () => {
5
+ it('lists every index when nothing is disabled', () => {
6
+ expect(enabledIndices(3)).toEqual([0, 1, 2])
7
+ })
8
+
9
+ it('skips indices the predicate marks disabled', () => {
10
+ expect(enabledIndices(5, (i) => i === 1 || i === 3)).toEqual([0, 2, 4])
11
+ })
12
+
13
+ it('returns an empty array for a zero count', () => {
14
+ expect(enabledIndices(0)).toEqual([])
15
+ })
16
+ })
17
+
18
+ describe('wrapMove', () => {
19
+ const list = [0, 2, 4] // e.g. indices 1 and 3 are disabled
20
+
21
+ it('moves forward to the next enabled index', () => {
22
+ expect(wrapMove(list, 2, 1)).toBe(4)
23
+ })
24
+
25
+ it('wraps past the end back to the head', () => {
26
+ expect(wrapMove(list, 4, 1)).toBe(0)
27
+ })
28
+
29
+ it('wraps before the head to the tail', () => {
30
+ expect(wrapMove(list, 0, -1)).toBe(4)
31
+ })
32
+
33
+ it('from an absent current, delta 1 lands on the first entry', () => {
34
+ expect(wrapMove(list, -1, 1)).toBe(0)
35
+ })
36
+
37
+ it('from an absent current, delta -1 lands on the last-but-one', () => {
38
+ expect(wrapMove(list, -1, -1)).toBe(2)
39
+ })
40
+
41
+ it('stays put for a single-entry list', () => {
42
+ expect(wrapMove([5], 5, 1)).toBe(5)
43
+ expect(wrapMove([5], 5, -1)).toBe(5)
44
+ })
45
+
46
+ it('returns -1 for an empty list', () => {
47
+ expect(wrapMove([], 0, 1)).toBe(-1)
48
+ })
49
+ })
@@ -0,0 +1,29 @@
1
+ /**
2
+ * list-nav - the pure, framework-free navigation math shared by the roving-focus
3
+ * cores (createListbox, createPopoverSelect, createMenu). No runes, no DOM - just
4
+ * index arithmetic, so it is unit-testable on its own and behaves identically
5
+ * everywhere. The cores wrap these in `$state`.
6
+ */
7
+
8
+ /** Indices of items that can take roving focus / selection - i.e. the ones the
9
+ * caller does not mark disabled. `count` is the total item count; `isDisabled`
10
+ * is consulted per index (a separator counts as disabled to the caller). */
11
+ export function enabledIndices(count: number, isDisabled?: (index: number) => boolean): number[] {
12
+ const out: number[] = []
13
+ for (let i = 0; i < count; i++) if (!isDisabled?.(i)) out.push(i)
14
+ return out
15
+ }
16
+
17
+ /**
18
+ * The next index when moving `delta` steps through `list`, wrapping around the
19
+ * ends. `current` need not be in `list` (it may sit on a disabled item): when it
20
+ * is absent we start just before the head so `delta: 1` lands on the first entry
21
+ * and `delta: -1` on the last-but-one - the same behaviour every existing core
22
+ * already had. Returns `-1` for an empty list.
23
+ */
24
+ export function wrapMove(list: readonly number[], current: number, delta: number): number {
25
+ const len = list.length
26
+ if (len === 0) return -1
27
+ const pos = list.indexOf(current)
28
+ return list[(pos + delta + len) % len]!
29
+ }
@@ -5,6 +5,8 @@ export type ListOption = {
5
5
  disabled?: boolean
6
6
  /** Optional group heading this option belongs under. */
7
7
  group?: string
8
+ /** Optional color swatch (any CSS color) shown before the label. */
9
+ color?: string
8
10
  }
9
11
 
10
12
  /** Case-insensitive substring filter over option labels. */
@@ -0,0 +1,21 @@
1
+ import type { Snippet } from 'svelte'
2
+
3
+ /**
4
+ * A single item in a menu (SvMenu / SvMenuList / SvContextMenu) and the headless
5
+ * `createMenu` core. Kept in a plain `.ts` module so the core can import the type
6
+ * without pulling in the `.svelte` component - which would create a
7
+ * `.svelte.ts` -> `.svelte` -> `.svelte.ts` import cycle that Vite's dev-time
8
+ * import-analysis fails to resolve.
9
+ */
10
+ export type MenuItem = {
11
+ /** Item text. Omit + set `separator` for a divider. */
12
+ label?: string
13
+ separator?: boolean
14
+ icon?: Snippet
15
+ /** Right-aligned hint, e.g. a keyboard shortcut. */
16
+ shortcut?: string
17
+ disabled?: boolean
18
+ /** Nested submenu items (renders a flyout). */
19
+ children?: MenuItem[]
20
+ onSelect?: () => void
21
+ }
package/src/menus.test.ts CHANGED
@@ -98,6 +98,54 @@ describe('createMenus - filter row/menu value updates', () => {
98
98
  expect(ctx.filterRowValues.name).toBe('foo')
99
99
  })
100
100
 
101
+ it('addFilterToken builds a newline-serialised in/notIn list, ignoring dupes/blanks', () => {
102
+ const ctx = makeCtx()
103
+ const m = createMenus(ctx)
104
+ m.addFilterToken('symbol', 'TSM')
105
+ m.addFilterToken('symbol', 'BP')
106
+ m.addFilterToken('symbol', 'TSM') // dupe - ignored
107
+ m.addFilterToken('symbol', ' ') // blank - ignored
108
+ expect(ctx.filterRowValues.symbol).toBe('TSM\nBP')
109
+ // mirrored into the menu model too, so both filter surfaces agree
110
+ expect(ctx.filterMenuValues.symbol.value).toBe('TSM\nBP')
111
+ })
112
+
113
+ it('removeFilterToken drops a value from the list', () => {
114
+ const ctx = makeCtx({ filterRowValues: { symbol: 'TSM\nBP\nBABA' } })
115
+ const m = createMenus(ctx)
116
+ m.removeFilterToken('symbol', 'BP')
117
+ expect(ctx.filterRowValues.symbol).toBe('TSM\nBABA')
118
+ })
119
+
120
+ it('toggleFilterToken adds when absent and removes when present', () => {
121
+ const ctx = makeCtx()
122
+ const m = createMenus(ctx)
123
+ m.toggleFilterToken('symbol', 'BP')
124
+ expect(ctx.filterRowValues.symbol).toBe('BP')
125
+ m.toggleFilterToken('symbol', 'BP')
126
+ expect(ctx.filterRowValues.symbol).toBe('')
127
+ })
128
+
129
+ it('openInSuggest anchors the dropdown and targets the column; closeInSuggest clears it', () => {
130
+ const ctx = makeCtx()
131
+ const m = createMenus(ctx)
132
+ const anchor = {
133
+ getBoundingClientRect: () => ({ left: 40, bottom: 80 }),
134
+ } as unknown as HTMLElement
135
+ m.openInSuggest(anchor, 'symbol')
136
+ expect(ctx.inSuggestFor).toBe('symbol')
137
+ expect(ctx.inSuggestPos).toMatchObject({ y: 84 })
138
+ m.closeInSuggest()
139
+ expect(ctx.inSuggestFor).toBe(null)
140
+ })
141
+
142
+ it('closeMenus also dismisses the in-suggest dropdown', () => {
143
+ const ctx = makeCtx({ inSuggestFor: 'symbol' })
144
+ const m = createMenus(ctx)
145
+ m.closeMenus()
146
+ expect(ctx.inSuggestFor).toBe(null)
147
+ })
148
+
101
149
  it('updateFilterMenuValueTo sets the upper bound for between', () => {
102
150
  const ctx = makeCtx({ filterMenuValues: { age: { operator: 'between', value: '5' } } })
103
151
  const m = createMenus(ctx)
package/src/menus.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 {
@@ -115,6 +116,7 @@ import {
115
116
  buildBuckets,
116
117
  isInBucket,
117
118
  } from "./facet-buckets";
119
+ import { splitInTokens, joinInTokens } from "./filtering/excel-filters";
118
120
  import {
119
121
  getColumnBaseValue,
120
122
  isGroupRow,
@@ -179,6 +181,34 @@ export function createMenus<
179
181
  };
180
182
  }
181
183
 
184
+ /** Current `in` / `notIn` token list for a column (from the filter-row value). */
185
+ function filterTokens(columnId: string): string[] {
186
+ return splitInTokens(ctx.filterRowValues[columnId] ?? "");
187
+ }
188
+
189
+ /** Add a value to a column's `in` / `notIn` list (no-op if already present). */
190
+ function addFilterToken(columnId: string, token: string) {
191
+ const t = token.trim();
192
+ if (!t) return;
193
+ const tokens = filterTokens(columnId);
194
+ if (!tokens.includes(t)) tokens.push(t);
195
+ updateFilterRow(columnId, joinInTokens(tokens));
196
+ }
197
+
198
+ /** Remove a value from a column's `in` / `notIn` list. */
199
+ function removeFilterToken(columnId: string, token: string) {
200
+ updateFilterRow(
201
+ columnId,
202
+ joinInTokens(filterTokens(columnId).filter((t) => t !== token)),
203
+ );
204
+ }
205
+
206
+ /** Toggle a value in a column's `in` / `notIn` list (checklist behaviour). */
207
+ function toggleFilterToken(columnId: string, token: string) {
208
+ if (filterTokens(columnId).includes(token)) removeFilterToken(columnId, token);
209
+ else addFilterToken(columnId, token);
210
+ }
211
+
182
212
  function toggleCheckboxWithKeyboard(
183
213
  event: KeyboardEvent,
184
214
  toggle: () => void,
@@ -193,7 +223,9 @@ export function createMenus<
193
223
  const menuFilter = ctx.filterMenuValues[columnId];
194
224
  return Boolean(
195
225
  menuFilter &&
196
- (menuFilter.operator === "isBlank" || menuFilter.value.trim()),
226
+ (menuFilter.operator === "isBlank" ||
227
+ menuFilter.operator === "isNotBlank" ||
228
+ menuFilter.value.trim()),
197
229
  );
198
230
  }
199
231
 
@@ -201,9 +233,23 @@ export function createMenus<
201
233
  ctx.columnMenuFor = null;
202
234
  ctx.filterMenuFor = null;
203
235
  ctx.operatorMenuFor = null;
236
+ ctx.inSuggestFor = null;
204
237
  ctx.chooseColumnsPos = null;
205
238
  }
206
239
 
240
+ /** Open the `in` / `notIn` value-suggestions dropdown under a chip input. */
241
+ function openInSuggest(anchor: HTMLElement, columnId: string) {
242
+ const rect = anchor.getBoundingClientRect();
243
+ ctx.inSuggestPos = { x: clampMenuX(rect.left, 220), y: rect.bottom + 4 };
244
+ ctx.inSuggestQuery = "";
245
+ ctx.inSuggestFor = columnId;
246
+ }
247
+
248
+ function closeInSuggest() {
249
+ ctx.inSuggestFor = null;
250
+ ctx.inSuggestQuery = "";
251
+ }
252
+
207
253
  function openChooseColumns(event: MouseEvent) {
208
254
  event.stopPropagation();
209
255
  // Submenu behavior: keep the parent operations menu open and float the
@@ -351,21 +397,27 @@ export function createMenus<
351
397
  }
352
398
 
353
399
  function clearColumnFilter(columnId: string) {
354
- if (ctx.valueFilters[columnId]) {
355
- const next = { ...ctx.valueFilters };
356
- delete next[columnId];
357
- ctx.valueFilters = next;
358
- }
359
- if (ctx.filterMenuValues[columnId]) {
360
- const next = { ...ctx.filterMenuValues };
361
- delete next[columnId];
362
- ctx.filterMenuValues = next;
363
- }
364
- if (ctx.filterRowValues[columnId]) {
365
- const next = { ...ctx.filterRowValues };
366
- delete next[columnId];
367
- ctx.filterRowValues = next;
368
- }
400
+ // `untrack`: read-modify-write on the filter maps. Keeps the method safe to
401
+ // call from a consumer's reactive `$effect` (e.g. via `api.clearFilter`)
402
+ // without capturing these reads as a dependency and looping. See
403
+ // `build-api`'s `setFilter` for the full rationale.
404
+ untrack(() => {
405
+ if (ctx.valueFilters[columnId]) {
406
+ const next = { ...ctx.valueFilters };
407
+ delete next[columnId];
408
+ ctx.valueFilters = next;
409
+ }
410
+ if (ctx.filterMenuValues[columnId]) {
411
+ const next = { ...ctx.filterMenuValues };
412
+ delete next[columnId];
413
+ ctx.filterMenuValues = next;
414
+ }
415
+ if (ctx.filterRowValues[columnId]) {
416
+ const next = { ...ctx.filterRowValues };
417
+ delete next[columnId];
418
+ ctx.filterRowValues = next;
419
+ }
420
+ });
369
421
  }
370
422
 
371
423
  // External pagination is controlled: emit the requested page/size and let
@@ -553,9 +605,14 @@ export function createMenus<
553
605
  updateFilterOperator,
554
606
  updateFilterMenuValue,
555
607
  updateFilterMenuValueTo,
608
+ addFilterToken,
609
+ removeFilterToken,
610
+ toggleFilterToken,
556
611
  toggleCheckboxWithKeyboard,
557
612
  isColumnFiltered,
558
613
  closeMenus,
614
+ openInSuggest,
615
+ closeInSuggest,
559
616
  openChooseColumns,
560
617
  openColumnMenu,
561
618
  openFilterMenu,
@@ -0,0 +1,52 @@
1
+ /**
2
+ * panel-resize - the drag math behind a dropdown panel's bottom resize grip
3
+ * (the "····" handle). Rune-free and DOM-only so every popover select can reuse
4
+ * it: on the grip's `pointerdown` it tracks the pointer and reports a clamped
5
+ * height as it moves, releasing its listeners on `pointerup`.
6
+ *
7
+ * The grip is only meaningful when the panel opens DOWNWARD (it grows toward the
8
+ * bottom of the screen); components hide it on an upward flip, where there is no
9
+ * room below to grow into.
10
+ */
11
+
12
+ export type PanelResizeOptions = {
13
+ /** Panel height (px) at the moment the drag starts. */
14
+ startHeight: number
15
+ /** Smallest height the panel may shrink to. Default 80. */
16
+ min?: number
17
+ /** Largest height the panel may grow to - the live viewport ceiling. */
18
+ max: () => number
19
+ /** Called with the new clamped height on each pointer move. */
20
+ onHeight: (height: number) => void
21
+ }
22
+
23
+ /**
24
+ * Begin a vertical resize from a grip `pointerdown`. Returns nothing; the drag
25
+ * ends itself on `pointerup`. Safe to call from an `onpointerdown` handler.
26
+ */
27
+ export function startPanelResize(event: PointerEvent, opts: PanelResizeOptions): void {
28
+ // Ignore secondary buttons; only a primary drag resizes.
29
+ if (event.button !== 0) return
30
+ event.preventDefault()
31
+ event.stopPropagation()
32
+ const startY = event.clientY
33
+ const min = opts.min ?? 80
34
+
35
+ const clamp = (h: number) => Math.max(min, Math.min(opts.max(), Math.round(h)))
36
+ const onMove = (e: PointerEvent) => {
37
+ opts.onHeight(clamp(opts.startHeight + (e.clientY - startY)))
38
+ }
39
+ const onUp = () => {
40
+ window.removeEventListener('pointermove', onMove)
41
+ window.removeEventListener('pointerup', onUp)
42
+ window.removeEventListener('pointercancel', onUp)
43
+ document.body.style.userSelect = ''
44
+ document.body.style.cursor = ''
45
+ }
46
+ // Suppress text selection + keep the resize cursor for the whole drag.
47
+ document.body.style.userSelect = 'none'
48
+ document.body.style.cursor = 'ns-resize'
49
+ window.addEventListener('pointermove', onMove)
50
+ window.addEventListener('pointerup', onUp)
51
+ window.addEventListener('pointercancel', onUp)
52
+ }
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Bounds detection for anchored popover panels: flip up/down by available room,
3
+ * cap the panel height to the viewport, and never let an upward flip start
4
+ * off-screen. Pairs with the drag math in panel-resize.
5
+ */
6
+ import { describe, expect, it, beforeAll } from 'vitest'
7
+ import { anchoredRect } from './popover'
8
+
9
+ const VIEWPORT_H = 800
10
+ const VIEWPORT_W = 1200
11
+
12
+ beforeAll(() => {
13
+ Object.defineProperty(window, 'innerHeight', { value: VIEWPORT_H, configurable: true })
14
+ Object.defineProperty(window, 'innerWidth', { value: VIEWPORT_W, configurable: true })
15
+ })
16
+
17
+ // Minimal DOMRect stand-in - anchoredRect only reads top/bottom/left/width.
18
+ const rect = (top: number, height: number, left = 100, width = 200) =>
19
+ ({ top, bottom: top + height, left, width, right: left + width, height, x: left, y: top, toJSON() {} }) as DOMRect
20
+
21
+ describe('anchoredRect - flip direction', () => {
22
+ it('opens downward with room below', () => {
23
+ const r = anchoredRect(rect(100, 32), { estimatedHeight: 280 })
24
+ expect(r.openUpward).toBe(false)
25
+ expect(r.top).toBe(132 + 2) // trigger bottom + gap
26
+ })
27
+
28
+ it('flips upward when room below is short and there is more above', () => {
29
+ // Trigger near the bottom: 60px below, ~740px above.
30
+ const r = anchoredRect(rect(VIEWPORT_H - 92, 32), { estimatedHeight: 280 })
31
+ expect(r.openUpward).toBe(true)
32
+ // Panel sits above the trigger; its top must stay on-screen.
33
+ expect(r.top).toBeGreaterThanOrEqual(8)
34
+ expect(r.top).toBeLessThan(VIEWPORT_H - 92)
35
+ })
36
+ })
37
+
38
+ describe('anchoredRect - height bounds', () => {
39
+ it('caps maxHeight to the room below (never overflows the viewport)', () => {
40
+ // Short viewport so both sides are smaller than the 280 estimate; trigger in
41
+ // the middle with slightly more room below keeps it opening downward.
42
+ Object.defineProperty(window, 'innerHeight', { value: 260, configurable: true })
43
+ try {
44
+ const r = anchoredRect(rect(110, 32), { estimatedHeight: 280 })
45
+ // room below = 260 - 142 - gap(2) - margin(8) = 108
46
+ expect(r.openUpward).toBe(false)
47
+ expect(r.maxHeight).toBeLessThanOrEqual(108)
48
+ expect(r.maxHeight).toBeGreaterThan(0)
49
+ // availHeight is the hard ceiling and is >= the comfortable maxHeight.
50
+ expect(r.availHeight).toBeGreaterThanOrEqual(r.maxHeight)
51
+ } finally {
52
+ Object.defineProperty(window, 'innerHeight', { value: VIEWPORT_H, configurable: true })
53
+ }
54
+ })
55
+
56
+ it('maxHeight is the estimate when there is ample room', () => {
57
+ const r = anchoredRect(rect(100, 32), { estimatedHeight: 200 })
58
+ expect(r.maxHeight).toBe(200)
59
+ expect(r.availHeight).toBeGreaterThanOrEqual(200)
60
+ })
61
+
62
+ it('never returns a maxHeight below the minHeight floor', () => {
63
+ // Trigger pinned to the very bottom: almost no room below, lots above -> flips.
64
+ const r = anchoredRect(rect(VIEWPORT_H - 20, 20), { estimatedHeight: 300, minHeight: 96 })
65
+ expect(r.maxHeight).toBeGreaterThanOrEqual(96)
66
+ })
67
+ })
68
+
69
+ describe('anchoredRect - bottom anchor (natural-height upward panels)', () => {
70
+ it('returns a bottom offset that pins the panel just above the trigger', () => {
71
+ // Trigger top at 500, gap 2 -> panel bottom edge at 498 -> bottom offset
72
+ // from the viewport bottom = 800 - 500 + 2 = 302. Independent of any height
73
+ // estimate, so a bottom-anchored panel grows to its real content height.
74
+ const r = anchoredRect(rect(500, 32), { estimatedHeight: 999 })
75
+ expect(r.bottom).toBe(VIEWPORT_H - 500 + 2)
76
+ })
77
+
78
+ it('bottom offset does not depend on the (possibly wrong) height estimate', () => {
79
+ const trigger = rect(600, 30)
80
+ const a = anchoredRect(trigger, { estimatedHeight: 40 })
81
+ const b = anchoredRect(trigger, { estimatedHeight: 500 })
82
+ expect(a.bottom).toBe(b.bottom)
83
+ })
84
+ })
85
+
86
+ describe('anchoredRect - horizontal', () => {
87
+ it('clamps a wide panel within the viewport', () => {
88
+ const r = anchoredRect(rect(100, 32, VIEWPORT_W - 50, 40), { estimatedHeight: 100, minWidth: 300 })
89
+ expect(r.width).toBe(300)
90
+ expect(r.left + r.width).toBeLessThanOrEqual(VIEWPORT_W)
91
+ expect(r.left).toBeGreaterThanOrEqual(4)
92
+ })
93
+ })
package/src/popover.ts CHANGED
@@ -68,7 +68,31 @@ export function popIn(node: HTMLElement, param: { up?: boolean; duration?: numbe
68
68
  return {}
69
69
  }
70
70
 
71
- export type AnchoredRect = { top: number; left: number; width: number; openUpward: boolean }
71
+ export type AnchoredRect = {
72
+ top: number
73
+ left: number
74
+ width: number
75
+ openUpward: boolean
76
+ /**
77
+ * Comfortable panel height: the estimate, capped to the room on the chosen
78
+ * side so the panel never leaves the viewport. Apply as the panel's
79
+ * `max-height` and long content scrolls inside instead of overflowing.
80
+ */
81
+ maxHeight: number
82
+ /**
83
+ * Absolute height the panel may occupy on the chosen side (room minus the
84
+ * viewport margin). The hard ceiling a user resize must clamp to.
85
+ */
86
+ availHeight: number
87
+ /**
88
+ * For an upward-flipped panel: distance from the viewport bottom to where the
89
+ * panel's bottom edge sits (just above the trigger). Bottom-anchoring an
90
+ * upward panel with `style:bottom` lets it grow to its natural content height
91
+ * instead of being positioned from a (possibly wrong) height estimate.
92
+ * Undefined until `anchoredRect` computes it.
93
+ */
94
+ bottom?: number
95
+ }
72
96
 
73
97
  export type AnchorOptions = {
74
98
  /** Estimated panel height, used to decide whether to flip upward. */
@@ -79,19 +103,30 @@ export type AnchorOptions = {
79
103
  minWidth?: number
80
104
  /** Keep the panel within the viewport horizontally. Default true. */
81
105
  clampHorizontal?: boolean
106
+ /** Viewport edge kept clear top/bottom so a panel never touches it. Default 8. */
107
+ viewportMargin?: number
108
+ /** Floor for `maxHeight`/`availHeight` so a cramped panel stays usable. Default 96. */
109
+ minHeight?: number
82
110
  }
83
111
 
84
112
  /**
85
113
  * Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the
86
- * trigger when there isn't room below and there's more room above. Mirrors the
87
- * behavior SvGridDropdown shipped with, generalized with min-width + horizontal
88
- * clamping for wider panels (date/time popovers).
114
+ * trigger when there isn't room below and there's more room above. Generalized
115
+ * with min-width + horizontal clamping for wider panels (date/time popovers),
116
+ * and with vertical bounds detection: `maxHeight` caps the panel to the room on
117
+ * the chosen side (minus a viewport margin) so a long list near a screen edge
118
+ * scrolls internally rather than overflowing, and an upward flip is clamped so
119
+ * its top never leaves the viewport.
89
120
  */
90
121
  export function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): AnchoredRect {
91
122
  const gap = opts.gap ?? 2
92
- const spaceBelow = window.innerHeight - triggerRect.bottom
93
- const spaceAbove = triggerRect.top
123
+ const margin = opts.viewportMargin ?? 8
124
+ const minH = opts.minHeight ?? 96
125
+ const spaceBelow = window.innerHeight - triggerRect.bottom - gap - margin
126
+ const spaceAbove = triggerRect.top - gap - margin
94
127
  const openUpward = spaceBelow < opts.estimatedHeight && spaceAbove > spaceBelow
128
+ const availHeight = Math.max(minH, Math.floor(openUpward ? spaceAbove : spaceBelow))
129
+ const maxHeight = Math.min(opts.estimatedHeight, availHeight)
95
130
  const width = Math.max(triggerRect.width, opts.minWidth ?? 0)
96
131
  let left = triggerRect.left
97
132
  if (opts.clampHorizontal !== false) {
@@ -99,9 +134,18 @@ export function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): Anchore
99
134
  left = Math.max(4, Math.min(left, maxLeft))
100
135
  }
101
136
  return {
102
- top: openUpward ? triggerRect.top - opts.estimatedHeight - gap : triggerRect.bottom + gap,
137
+ // Upward: pin by the comfortable height and clamp to the top margin so the
138
+ // panel can never start off-screen. Downward: sit just below the trigger.
139
+ top: openUpward
140
+ ? Math.max(margin, triggerRect.top - gap - maxHeight)
141
+ : triggerRect.bottom + gap,
103
142
  left,
104
143
  width,
105
144
  openUpward,
145
+ maxHeight,
146
+ availHeight,
147
+ // Bottom-anchor value for upward panels: viewport bottom -> panel bottom
148
+ // edge (which sits `gap` above the trigger top).
149
+ bottom: Math.round(window.innerHeight - triggerRect.top + gap),
106
150
  }
107
151
  }
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest'
2
- import { matchesRecurrence, expandRecurrence, type RecurrenceRule } from './recurrence'
2
+ import { matchesRecurrence, expandRecurrence, describeRecurrence, type RecurrenceRule } from './recurrence'
3
3
 
4
4
  // Fixed reference dates (local time). 2026-07-01 is a Wednesday.
5
5
  const D = (s: string) => new Date(`${s}T00:00:00`)
@@ -82,10 +82,97 @@ describe('matchesRecurrence - bounds + combining', () => {
82
82
  })
83
83
  })
84
84
 
85
+ describe('matchesRecurrence - positional weekday-of-month', () => {
86
+ it('the 1st Tuesday of the month', () => {
87
+ const r: RecurrenceRule = { freq: 'monthly', weekdays: [2], weekOfMonth: 1 }
88
+ expect(matchesRecurrence(D('2026-07-07'), r)).toBe(true) // 1st Tue of Jul
89
+ expect(matchesRecurrence(D('2026-07-14'), r)).toBe(false) // 2nd Tue
90
+ expect(matchesRecurrence(D('2026-08-04'), r)).toBe(true) // 1st Tue of Aug
91
+ })
92
+
93
+ it('the last Friday of the month', () => {
94
+ const r: RecurrenceRule = { freq: 'monthly', weekdays: [5], weekOfMonth: -1 }
95
+ expect(matchesRecurrence(D('2026-07-31'), r)).toBe(true) // last Fri of Jul
96
+ expect(matchesRecurrence(D('2026-07-24'), r)).toBe(false) // 4th but not last
97
+ expect(matchesRecurrence(D('2026-08-28'), r)).toBe(true) // last Fri of Aug
98
+ })
99
+
100
+ it('yearly positional: the 2nd Monday of November', () => {
101
+ const r: RecurrenceRule = { freq: 'yearly', month: 10, weekdays: [1], weekOfMonth: 2 }
102
+ expect(matchesRecurrence(D('2026-11-09'), r)).toBe(true) // 2nd Mon of Nov 2026
103
+ expect(matchesRecurrence(D('2026-11-16'), r)).toBe(false) // 3rd Mon
104
+ expect(matchesRecurrence(D('2026-10-12'), r)).toBe(false) // wrong month
105
+ })
106
+ })
107
+
108
+ describe('matchesRecurrence - last day of month + count', () => {
109
+ it('the last day of the month (day: -1) across month lengths', () => {
110
+ const r: RecurrenceRule = { freq: 'monthly', day: -1 }
111
+ expect(matchesRecurrence(D('2026-07-31'), r)).toBe(true) // 31-day month
112
+ expect(matchesRecurrence(D('2026-02-28'), r)).toBe(true) // 28-day Feb
113
+ expect(matchesRecurrence(D('2026-02-27'), r)).toBe(false)
114
+ expect(matchesRecurrence(D('2026-06-30'), r)).toBe(true) // 30-day month
115
+ })
116
+
117
+ it('ends after N occurrences (count)', () => {
118
+ // 3 weekly Mondays from Jul 6: Jul 6, 13, 20 - then stop.
119
+ const r: RecurrenceRule = { freq: 'weekly', weekdays: [1], from: D('2026-07-06'), count: 3 }
120
+ expect(matchesRecurrence(D('2026-07-06'), r)).toBe(true)
121
+ expect(matchesRecurrence(D('2026-07-20'), r)).toBe(true) // 3rd
122
+ expect(matchesRecurrence(D('2026-07-27'), r)).toBe(false) // 4th - past the count
123
+ })
124
+ })
125
+
85
126
  describe('expandRecurrence', () => {
86
127
  it('lists the matching dates in a range', () => {
87
128
  const r: RecurrenceRule = { freq: 'weekly', weekdays: [1] } // Mondays
88
129
  const dates = expandRecurrence(r, D('2026-07-01'), D('2026-07-31')).map((d) => d.getDate())
89
130
  expect(dates).toEqual([6, 13, 20, 27])
90
131
  })
132
+
133
+ it('stops after `count` occurrences even in a wider window', () => {
134
+ const r: RecurrenceRule = { freq: 'weekly', weekdays: [1], from: D('2026-07-06'), count: 3 }
135
+ const dates = expandRecurrence(r, D('2026-07-01'), D('2026-08-31')).map((d) => d.getDate())
136
+ expect(dates).toEqual([6, 13, 20]) // exactly 3, not every Monday
137
+ })
138
+ })
139
+
140
+ describe('describeRecurrence - human-readable summaries', () => {
141
+ it('empty / falsy rules read as the never label (default blank)', () => {
142
+ expect(describeRecurrence(null)).toBe('')
143
+ expect(describeRecurrence([])).toBe('')
144
+ expect(describeRecurrence(undefined, { never: 'Does not repeat' })).toBe('Does not repeat')
145
+ })
146
+
147
+ it('daily + weekly', () => {
148
+ expect(describeRecurrence({ freq: 'daily' })).toBe('Daily')
149
+ expect(describeRecurrence({ freq: 'daily', interval: 2 })).toBe('Every 2 days')
150
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [1, 2, 3, 4, 5] })).toBe('Weekly on weekdays')
151
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [0, 6] })).toBe('Weekly on weekends')
152
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [1, 3] })).toBe('Weekly on Mon, Wed')
153
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [5], interval: 2 })).toBe('Every 2 weeks on Fri')
154
+ })
155
+
156
+ it('monthly + yearly, positional + last day', () => {
157
+ expect(describeRecurrence({ freq: 'monthly', day: 1 })).toBe('Monthly on day 1')
158
+ expect(describeRecurrence({ freq: 'monthly', day: -1 })).toBe('Monthly on the last day')
159
+ expect(describeRecurrence({ freq: 'monthly', weekdays: [5], weekOfMonth: -1 })).toBe('Monthly on the last Fri')
160
+ expect(describeRecurrence({ freq: 'yearly', month: 10, weekdays: [4], weekOfMonth: 4 })).toBe(
161
+ 'Yearly on the fourth Thu of Nov',
162
+ )
163
+ expect(describeRecurrence({ freq: 'yearly', month: 11, day: 25 })).toBe('Yearly on day 25 of Dec')
164
+ })
165
+
166
+ it('appends the end condition (count / until)', () => {
167
+ expect(describeRecurrence({ freq: 'weekly', weekdays: [1], count: 8 })).toBe('Weekly on Mon, 8 times')
168
+ expect(describeRecurrence({ freq: 'daily', until: D('2026-12-31') })).toBe('Daily until Dec 31, 2026')
169
+ })
170
+
171
+ it('joins a list of rules', () => {
172
+ const rules: RecurrenceRule[] = [
173
+ { freq: 'weekly', weekdays: [1] },
174
+ { freq: 'monthly', day: 1 },
175
+ ]
176
+ expect(describeRecurrence(rules)).toBe('Weekly on Mon; Monthly on day 1')
177
+ })
91
178
  })