@svgrid/grid 2.1.22 → 2.2.0

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 (295) 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/FlexRender.svelte +96 -96
  7. package/dist/GridFooter.svelte +178 -178
  8. package/dist/GridMenus.svelte +83 -3
  9. package/dist/SvAutoComplete.svelte +5 -3
  10. package/dist/SvCarousel.svelte +141 -163
  11. package/dist/SvColorInput.svelte +187 -186
  12. package/dist/SvColorInput.svelte.d.ts +1 -1
  13. package/dist/SvComboBox.svelte +7 -3
  14. package/dist/SvCommand.svelte +28 -85
  15. package/dist/SvContextMenu.svelte +116 -116
  16. package/dist/SvCountryInput.svelte +1 -1
  17. package/dist/SvDateRangeInput.svelte +1 -1
  18. package/dist/SvDateTimePicker.svelte +9 -3
  19. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  20. package/dist/SvDockLayout.svelte +244 -0
  21. package/dist/SvDockLayout.svelte.d.ts +77 -0
  22. package/dist/SvDockManager.svelte +922 -0
  23. package/dist/SvDockManager.svelte.d.ts +106 -0
  24. package/dist/SvDrawer.svelte +250 -228
  25. package/dist/SvDrawer.svelte.d.ts +4 -0
  26. package/dist/SvDropDownList.svelte +85 -13
  27. package/dist/SvDropDownList.svelte.d.ts +3 -0
  28. package/dist/SvField.svelte +21 -4
  29. package/dist/SvField.svelte.d.ts +1 -0
  30. package/dist/SvForm.svelte +201 -91
  31. package/dist/SvForm.svelte.d.ts +15 -22
  32. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  33. package/dist/SvGrid.controller.svelte.js +156 -27
  34. package/dist/SvGrid.css +193 -5
  35. package/dist/SvGrid.svelte +3131 -2786
  36. package/dist/SvGrid.types.d.ts +452 -1
  37. package/dist/SvGridBoard.svelte +2339 -2339
  38. package/dist/SvGridChart.svelte +1724 -1724
  39. package/dist/SvGridChartPanel.svelte +485 -485
  40. package/dist/SvGridDropdown.svelte +689 -682
  41. package/dist/SvGridSelect.svelte +1 -0
  42. package/dist/SvGroupCell.svelte +109 -109
  43. package/dist/SvListBox.svelte +36 -14
  44. package/dist/SvMaskedInput.svelte +4 -3
  45. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  46. package/dist/SvMenu.svelte +124 -124
  47. package/dist/SvMenuList.svelte +117 -165
  48. package/dist/SvMenuList.svelte.d.ts +2 -13
  49. package/dist/SvModal.svelte +12 -29
  50. package/dist/SvMultiSelect.svelte +1 -0
  51. package/dist/SvNumberInput.svelte +131 -130
  52. package/dist/SvNumberInput.svelte.d.ts +1 -1
  53. package/dist/SvOtpInput.svelte +158 -158
  54. package/dist/SvPagination.svelte +18 -18
  55. package/dist/SvPasswordInput.svelte +3 -2
  56. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  57. package/dist/SvPhoneInput.svelte +3 -2
  58. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  59. package/dist/SvPopover.svelte +139 -139
  60. package/dist/SvRowGroupPanel.svelte +170 -170
  61. package/dist/SvScrollArea.svelte +61 -61
  62. package/dist/SvStepper.svelte +6 -9
  63. package/dist/SvTagsInput.svelte +3 -2
  64. package/dist/SvTagsInput.svelte.d.ts +1 -1
  65. package/dist/SvTooltip.svelte +127 -138
  66. package/dist/SvTour.svelte +204 -176
  67. package/dist/SvTree.svelte +377 -377
  68. package/dist/SvTreeSelect.svelte +230 -229
  69. package/dist/build-api.js +76 -54
  70. package/dist/cdn/svgrid.js +10913 -6450
  71. package/dist/cdn/svgrid.svelte-external.js +9628 -5481
  72. package/dist/chart-export.js +8 -8
  73. package/dist/columns.js +6 -0
  74. package/dist/conditional-formatting.d.ts +67 -3
  75. package/dist/conditional-formatting.js +124 -17
  76. package/dist/createCarousel.svelte.d.ts +72 -0
  77. package/dist/createCarousel.svelte.js +80 -0
  78. package/dist/createCommand.svelte.d.ts +62 -0
  79. package/dist/createCommand.svelte.js +109 -0
  80. package/dist/createForm.svelte.d.ts +62 -0
  81. package/dist/createForm.svelte.js +374 -0
  82. package/dist/createMenu.svelte.d.ts +88 -0
  83. package/dist/createMenu.svelte.js +153 -0
  84. package/dist/createOverlay.svelte.d.ts +38 -0
  85. package/dist/createOverlay.svelte.js +68 -0
  86. package/dist/createPagination.d.ts +74 -0
  87. package/dist/createPagination.js +71 -0
  88. package/dist/createPopoverSelect.svelte.js +6 -12
  89. package/dist/createStepper.d.ts +44 -0
  90. package/dist/createStepper.js +26 -0
  91. package/dist/createTooltip.svelte.d.ts +32 -0
  92. package/dist/createTooltip.svelte.js +61 -0
  93. package/dist/datetime/timezone.d.ts +36 -0
  94. package/dist/datetime/timezone.js +110 -0
  95. package/dist/dock-context.d.ts +52 -0
  96. package/dist/dock-context.js +1 -0
  97. package/dist/dock-manager-model.d.ts +136 -0
  98. package/dist/dock-manager-model.js +514 -0
  99. package/dist/dock-model.d.ts +105 -0
  100. package/dist/dock-model.js +288 -0
  101. package/dist/dock-popout.d.ts +30 -0
  102. package/dist/dock-popout.js +81 -0
  103. package/dist/editor-contract.d.ts +2 -0
  104. package/dist/filter-operators.d.ts +6 -0
  105. package/dist/filter-operators.js +35 -2
  106. package/dist/filtering/excel-filters.d.ts +18 -1
  107. package/dist/filtering/excel-filters.js +56 -0
  108. package/dist/form-field.d.ts +73 -0
  109. package/dist/form-field.js +15 -0
  110. package/dist/index.d.ts +25 -5
  111. package/dist/index.js +40 -4
  112. package/dist/list-nav.d.ts +18 -0
  113. package/dist/list-nav.js +30 -0
  114. package/dist/list-option.d.ts +2 -0
  115. package/dist/menu-item.d.ts +20 -0
  116. package/dist/menu-item.js +1 -0
  117. package/dist/menus.d.ts +5 -0
  118. package/dist/menus.js +68 -16
  119. package/dist/panel-resize.d.ts +25 -0
  120. package/dist/panel-resize.js +40 -0
  121. package/dist/popover.d.ts +29 -3
  122. package/dist/popover.js +22 -6
  123. package/dist/recurrence.d.ts +33 -7
  124. package/dist/recurrence.js +151 -13
  125. package/dist/row-drag.d.ts +8 -0
  126. package/dist/row-drag.js +11 -0
  127. package/dist/scheduler-ical.d.ts +30 -0
  128. package/dist/scheduler-ical.js +180 -0
  129. package/dist/scheduler-model.d.ts +269 -0
  130. package/dist/scheduler-model.js +602 -0
  131. package/dist/scheduler-view.svelte.d.ts +22 -0
  132. package/dist/scheduler-view.svelte.js +13 -0
  133. package/dist/spreadsheet.js +20 -12
  134. package/dist/ui-app.types.d.ts +3 -0
  135. package/package.json +14 -10
  136. package/src/DockNodeView.svelte +306 -0
  137. package/src/DockPopoutHost.svelte +17 -0
  138. package/src/FlexRender.svelte +96 -96
  139. package/src/GridFooter.svelte +178 -178
  140. package/src/GridMenus.svelte +83 -3
  141. package/src/SvAutoComplete.svelte +5 -3
  142. package/src/SvCarousel.svelte +141 -163
  143. package/src/SvCarousel.test.ts +68 -0
  144. package/src/SvColorInput.svelte +187 -186
  145. package/src/SvComboBox.svelte +7 -3
  146. package/src/SvCommand.svelte +28 -85
  147. package/src/SvCommand.test.ts +78 -0
  148. package/src/SvContextMenu.svelte +116 -116
  149. package/src/SvCountryInput.svelte +1 -1
  150. package/src/SvDateRangeInput.svelte +1 -1
  151. package/src/SvDateTimePicker.svelte +9 -3
  152. package/src/SvDockLayout.svelte +244 -0
  153. package/src/SvDockManager.svelte +922 -0
  154. package/src/SvDrawer.svelte +250 -228
  155. package/src/SvDropDownList.svelte +85 -13
  156. package/src/SvField.svelte +21 -4
  157. package/src/SvForm.svelte +201 -91
  158. package/src/SvForm.test.ts +299 -0
  159. package/src/SvGrid.controller.svelte.ts +3158 -3034
  160. package/src/SvGrid.css +193 -5
  161. package/src/SvGrid.svelte +3131 -2786
  162. package/src/SvGrid.types.ts +1489 -1027
  163. package/src/SvGridBoard.svelte +2339 -2339
  164. package/src/SvGridChart.svelte +1724 -1724
  165. package/src/SvGridChartPanel.svelte +485 -485
  166. package/src/SvGridDropdown.svelte +689 -682
  167. package/src/SvGridSelect.svelte +1 -0
  168. package/src/SvGroupCell.svelte +109 -109
  169. package/src/SvListBox.svelte +36 -14
  170. package/src/SvMaskedInput.svelte +4 -3
  171. package/src/SvMenu.svelte +124 -124
  172. package/src/SvMenu.test.ts +97 -73
  173. package/src/SvMenuList.svelte +117 -165
  174. package/src/SvModal.svelte +12 -29
  175. package/src/SvModal.test.ts +82 -0
  176. package/src/SvMultiSelect.svelte +1 -0
  177. package/src/SvNumberInput.svelte +131 -130
  178. package/src/SvOtpInput.svelte +158 -158
  179. package/src/SvPagination.svelte +18 -18
  180. package/src/SvPasswordInput.svelte +3 -2
  181. package/src/SvPhoneInput.svelte +3 -2
  182. package/src/SvPopover.svelte +139 -139
  183. package/src/SvRowGroupPanel.svelte +170 -170
  184. package/src/SvScrollArea.svelte +61 -61
  185. package/src/SvStepper.svelte +6 -9
  186. package/src/SvTagsInput.svelte +3 -2
  187. package/src/SvTooltip.svelte +127 -138
  188. package/src/SvTooltip.test.ts +73 -0
  189. package/src/SvTour.svelte +204 -176
  190. package/src/SvTree.svelte +377 -377
  191. package/src/SvTreeSelect.svelte +230 -229
  192. package/src/a11y.contract.test.ts +49 -49
  193. package/src/a11y.test.ts +59 -59
  194. package/src/a11y.ts +61 -61
  195. package/src/build-api.ts +870 -847
  196. package/src/cell-formatting.ts +169 -169
  197. package/src/cell-render.ts +469 -469
  198. package/src/chart-export.ts +201 -201
  199. package/src/chart.ts +2296 -2296
  200. package/src/collaboration.test.ts +104 -104
  201. package/src/collaboration.ts +167 -167
  202. package/src/columns.ts +5 -0
  203. package/src/conditional-formatting.test.ts +104 -0
  204. package/src/conditional-formatting.ts +176 -19
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1111 -1111
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCarousel.svelte.ts +104 -0
  209. package/src/createCombobox.svelte.ts +179 -179
  210. package/src/createCommand.svelte.ts +125 -0
  211. package/src/createCountryInput.svelte.ts +157 -157
  212. package/src/createDropdownList.svelte.ts +162 -162
  213. package/src/createForm.svelte.ts +333 -0
  214. package/src/createGrid.svelte.ts +42 -42
  215. package/src/createGrid.test.ts +10 -10
  216. package/src/createGridState.svelte.ts +17 -17
  217. package/src/createMenu.svelte.ts +202 -0
  218. package/src/createOverlay.svelte.ts +101 -0
  219. package/src/createPagination.test.ts +65 -0
  220. package/src/createPagination.ts +84 -0
  221. package/src/createPopoverSelect.svelte.ts +206 -210
  222. package/src/createStepper.test.ts +62 -0
  223. package/src/createStepper.ts +61 -0
  224. package/src/createTooltip.svelte.ts +86 -0
  225. package/src/createTree.svelte.ts +319 -319
  226. package/src/datetime/timezone.test.ts +69 -0
  227. package/src/datetime/timezone.ts +134 -0
  228. package/src/dock-context.ts +47 -0
  229. package/src/dock-manager-model.test.ts +275 -0
  230. package/src/dock-manager-model.ts +593 -0
  231. package/src/dock-model.test.ts +163 -0
  232. package/src/dock-model.ts +368 -0
  233. package/src/dock-popout.ts +94 -0
  234. package/src/editing.test.ts +859 -859
  235. package/src/editing.ts +675 -675
  236. package/src/editor-contract.ts +2 -0
  237. package/src/export-data-api.test.ts +126 -126
  238. package/src/export-format.test.ts +107 -107
  239. package/src/export-format.ts +598 -598
  240. package/src/filter-operators.test.ts +59 -0
  241. package/src/filter-operators.ts +35 -2
  242. package/src/filtering/excel-filters.test.ts +86 -1
  243. package/src/filtering/excel-filters.ts +65 -0
  244. package/src/flex-render.ts +3 -3
  245. package/src/form-field.ts +87 -0
  246. package/src/index.ts +623 -469
  247. package/src/keyboard.test.ts +59 -59
  248. package/src/keyboard.ts +97 -97
  249. package/src/list-nav.test.ts +49 -0
  250. package/src/list-nav.ts +29 -0
  251. package/src/list-option.ts +2 -0
  252. package/src/menu-item.ts +21 -0
  253. package/src/menus.test.ts +48 -0
  254. package/src/menus.ts +639 -582
  255. package/src/merge-objects.ts +48 -48
  256. package/src/overlays.test.ts +90 -90
  257. package/src/panel-resize.ts +52 -0
  258. package/src/popover.bounds.test.ts +93 -0
  259. package/src/popover.ts +51 -7
  260. package/src/recurrence.test.ts +88 -1
  261. package/src/recurrence.ts +166 -15
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag.ts +10 -0
  264. package/src/scheduler-ical.test.ts +68 -0
  265. package/src/scheduler-ical.ts +180 -0
  266. package/src/scheduler-model.test.ts +546 -0
  267. package/src/scheduler-model.ts +853 -0
  268. package/src/scheduler-view.svelte.ts +33 -0
  269. package/src/selection.test.ts +754 -754
  270. package/src/selection.ts +600 -600
  271. package/src/server-data-source.test.ts +289 -289
  272. package/src/server-data-source.ts +413 -413
  273. package/src/sparkline.test.ts +68 -68
  274. package/src/sparkline.ts +169 -169
  275. package/src/spreadsheet.test.ts +489 -489
  276. package/src/spreadsheet.ts +312 -304
  277. package/src/static-functions.ts +11 -11
  278. package/src/subscribe.ts +38 -38
  279. package/src/svgrid-wrapper.types.ts +501 -501
  280. package/src/svgrid.behavior.test.ts +706 -706
  281. package/src/svgrid.charting.test.ts +527 -527
  282. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  283. package/src/svgrid.features.test.ts +157 -157
  284. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  285. package/src/svgrid.wrapper.test.ts +40 -40
  286. package/src/test-setup.ts +62 -62
  287. package/src/themes/index.ts +159 -159
  288. package/src/ui-app.types.ts +3 -0
  289. package/src/virtual.test.ts +6 -4
  290. package/src/virtualization/column-virtualizer.test.ts +27 -27
  291. package/src/virtualization/column-virtualizer.ts +30 -30
  292. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  293. package/src/virtualization/types.ts +30 -30
  294. package/src/virtualization/virtualizer.test.ts +47 -47
  295. package/src/virtualization/virtualizer.ts +296 -296
package/src/recurrence.ts CHANGED
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * recurrence - a small, pure repeat-pattern engine for SvCalendar (and anywhere
3
3
  * you need "does this date match a recurring rule"). Supports daily / weekly /
4
- * monthly / yearly frequencies with an interval, weekday lists, a day-of-month,
5
- * an anchor (`from`) and an inclusive `until`. Framework-free + pure so it is
6
- * unit-tested directly - no RRULE dependency.
4
+ * monthly / yearly frequencies with an interval, weekday lists, a day-of-month
5
+ * (incl. negative = counted from the end), a positional weekday-of-month
6
+ * (`weekOfMonth`, e.g. the 2nd Tuesday / last Friday), an anchor (`from`) and two
7
+ * end conditions - an inclusive `until` date or a `count` of occurrences.
8
+ * Framework-free + pure so it is unit-tested directly - no RRULE dependency.
7
9
  *
8
10
  * ```ts
9
11
  * const standup = { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] } // every weekday
10
- * const payday = { freq: 'monthly', day: 1 } // the 1st
12
+ * const payday = { freq: 'monthly', day: -1 } // the last day
11
13
  * const sprint = { freq: 'weekly', weekdays: [1], interval: 2, from: '2026-01-05' } // every other Monday
12
- * matchesRecurrence(someDate, [standup, payday, sprint])
14
+ * const board = { freq: 'monthly', weekdays: [2], weekOfMonth: 1 } // 1st Tuesday
15
+ * const review = { freq: 'weekly', weekdays: [5], count: 8 } // 8 Fridays then stop
16
+ * matchesRecurrence(someDate, [standup, payday, sprint, board, review])
13
17
  * ```
14
18
  */
15
19
  import { startOfDay, startOfWeek, toDate, type DateLike } from './datetime/date-core'
@@ -20,23 +24,42 @@ export type RecurrenceRule = {
20
24
  freq: RecurrenceFreq
21
25
  /** Repeat every N units of `freq`. Default 1. Uses `from` as the phase anchor. */
22
26
  interval?: number
23
- /** weekly: the weekdays it lands on (0 = Sunday .. 6 = Saturday). */
27
+ /** weekly: the weekdays it lands on (0 = Sunday .. 6 = Saturday). Also the
28
+ * weekday for a positional monthly / yearly rule (with `weekOfMonth`). */
24
29
  weekdays?: ReadonlyArray<number>
25
- /** monthly / yearly: day of the month (1..31). */
30
+ /** monthly / yearly: day of the month. 1..31, or negative to count from the
31
+ * end (-1 = the last day, -2 = the second to last). */
26
32
  day?: number
33
+ /** monthly / yearly: the ordinal week the `weekdays` land on within the month -
34
+ * 1..4 (first..fourth) or -1 (last). E.g. `{ weekdays:[2], weekOfMonth:-1 }`
35
+ * = the last Tuesday. Takes precedence over `day` when both are set. */
36
+ weekOfMonth?: number
27
37
  /** yearly: the month (0 = January .. 11 = December). */
28
38
  month?: number
29
39
  /** First occurrence + the phase for `interval`. */
30
40
  from?: DateLike
31
41
  /** Last day the pattern applies (inclusive). */
32
42
  until?: DateLike | null
43
+ /** End after this many occurrences (counted from `from`). Requires `from`. */
44
+ count?: number
33
45
  }
34
46
 
35
47
  const MS_DAY = 86_400_000
36
48
  /** Whole-day index, rounded so DST transitions don't shift it. */
37
49
  const dayIndex = (d: Date) => Math.round(startOfDay(d).getTime() / MS_DAY)
50
+ /** Days in the month that `d` falls in. */
51
+ const daysInMonth = (d: Date) => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()
52
+ /** Whether `d` is the `nth` occurrence of its own weekday within its month -
53
+ * nth 1..4 (first..fourth) or -1 (last). */
54
+ function isNthWeekdayOfMonth(d: Date, nth: number): boolean {
55
+ if (nth === -1) return d.getDate() + 7 > daysInMonth(d) // no same weekday later this month
56
+ return Math.floor((d.getDate() - 1) / 7) + 1 === nth
57
+ }
38
58
 
39
- function matchesRule(date: Date, r: RecurrenceRule): boolean {
59
+ function matchesRule(date: Date, rule: RecurrenceRule): boolean {
60
+ // Fold a `count` end-condition into an equivalent `until` first (pure), so the
61
+ // rest of the matcher stays a stateless per-date test.
62
+ const r = rule.count && rule.count >= 1 && rule.from != null ? resolveCount(rule) : rule
40
63
  const d = startOfDay(date)
41
64
  const fromD = r.from != null ? startOfDay(toDate(r.from) ?? d) : null
42
65
  const untilD = r.until != null ? startOfDay(toDate(r.until) ?? d) : null
@@ -64,23 +87,59 @@ function matchesRule(date: Date, r: RecurrenceRule): boolean {
64
87
  }
65
88
 
66
89
  case 'monthly': {
67
- const dom = r.day ?? fromD?.getDate()
68
- if (dom != null && d.getDate() !== dom) return false
69
- if (!fromD) return dom != null
90
+ if (!matchesMonthDay(d, r)) return false
91
+ if (!fromD) return true
70
92
  const months = (d.getFullYear() - fromD.getFullYear()) * 12 + (d.getMonth() - fromD.getMonth())
71
93
  return months % interval === 0
72
94
  }
73
95
 
74
96
  case 'yearly': {
75
97
  if (r.month != null && d.getMonth() !== r.month) return false
76
- const dom = r.day ?? fromD?.getDate()
77
- if (dom != null && d.getDate() !== dom) return false
78
- if (!fromD) return r.month != null || dom != null
98
+ if (!matchesMonthDay(d, r)) return false
99
+ if (!fromD) return r.month != null || r.day != null || r.weekOfMonth != null
79
100
  return (d.getFullYear() - fromD.getFullYear()) % interval === 0
80
101
  }
81
102
  }
82
103
  }
83
104
 
105
+ // The day-within-the-month test shared by monthly + yearly: a positional
106
+ // weekday (`weekOfMonth` + `weekdays`), else a day-of-month (`day`, negative =
107
+ // from the end), else - for a `from`-anchored rule - the anchor's day-of-month.
108
+ function matchesMonthDay(d: Date, r: RecurrenceRule): boolean {
109
+ if (r.weekOfMonth != null && r.weekdays && r.weekdays.length) {
110
+ return r.weekdays.includes(d.getDay()) && isNthWeekdayOfMonth(d, r.weekOfMonth)
111
+ }
112
+ const fromDom = r.from != null ? (toDate(r.from) ?? d).getDate() : null
113
+ const dom = r.day ?? fromDom
114
+ if (dom == null) return false
115
+ const target = dom < 0 ? daysInMonth(d) + dom + 1 : dom
116
+ return d.getDate() === target
117
+ }
118
+
119
+ /** Fold a `count` rule into an equivalent `until`-bounded rule by walking
120
+ * forward from `from` to the Nth occurrence. Pure; count is then stripped. */
121
+ function resolveCount(rule: RecurrenceRule): RecurrenceRule {
122
+ const from = toDate(rule.from!)
123
+ if (!from) return { ...rule, count: undefined }
124
+ const base: RecurrenceRule = { ...rule, count: undefined }
125
+ let cur = startOfDay(from)
126
+ let n = 0
127
+ let lastMatch = cur
128
+ let guard = 0
129
+ while (guard++ < 4000 * 12) {
130
+ if (matchesRule(cur, base)) {
131
+ n++
132
+ lastMatch = cur
133
+ if (n >= rule.count!) break
134
+ }
135
+ cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1)
136
+ }
137
+ const untilFromCount = lastMatch
138
+ const existingUntil = rule.until != null ? startOfDay(toDate(rule.until) ?? untilFromCount) : null
139
+ const until = existingUntil && existingUntil < untilFromCount ? existingUntil : untilFromCount
140
+ return { ...base, until }
141
+ }
142
+
84
143
  /** Whether `date` matches any of the given recurrence rule(s). */
85
144
  export function matchesRecurrence(
86
145
  date: Date,
@@ -99,13 +158,105 @@ export function expandRecurrence(
99
158
  end: Date,
100
159
  ): Date[] {
101
160
  if (!rules) return []
161
+ // Pre-resolve any `count` end-condition into a plain `until` once (rather than
162
+ // re-walking to the Nth occurrence for every day in the window).
163
+ const list = (Array.isArray(rules) ? rules : [rules]).map((r) =>
164
+ r.count && r.count >= 1 && r.from != null ? resolveCount(r) : r,
165
+ )
102
166
  const out: Date[] = []
103
167
  const last = startOfDay(end)
104
168
  let cur = startOfDay(start)
105
169
  let guard = 0
106
170
  while (cur <= last && guard++ < 4000) {
107
- if (matchesRecurrence(cur, rules)) out.push(cur)
171
+ if (matchesRecurrence(cur, list)) out.push(cur)
108
172
  cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1)
109
173
  }
110
174
  return out
111
175
  }
176
+
177
+ // --- human-readable summaries (for a table cell, a chip, a tooltip) ----------
178
+
179
+ /** Optional English strings a caller can override for i18n. */
180
+ export type RecurrenceLabels = {
181
+ weekdays?: ReadonlyArray<string> // Sun..Sat
182
+ months?: ReadonlyArray<string> // Jan..Dec
183
+ ordinals?: Record<number, string> // 1..5 + -1
184
+ never?: string
185
+ }
186
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
187
+ const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
188
+ const ORD: Record<number, string> = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', [-1]: 'last' }
189
+
190
+ // Where in the month a monthly / yearly rule lands, as a phrase.
191
+ function whereInMonth(r: RecurrenceRule, wd: ReadonlyArray<string>, ord: Record<number, string>): string {
192
+ if (r.weekOfMonth != null && r.weekdays && r.weekdays.length) {
193
+ return `the ${ord[r.weekOfMonth] ?? r.weekOfMonth} ${wd[r.weekdays[0]]}`
194
+ }
195
+ if (r.day === -1) return 'the last day'
196
+ if (r.day != null && r.day < 0) return `the ${-r.day} day(s) from the end`
197
+ if (r.day != null) return `day ${r.day}`
198
+ return ''
199
+ }
200
+
201
+ function describeRule(r: RecurrenceRule, L: Required<RecurrenceLabels>): string {
202
+ const n = Math.max(1, Math.floor(r.interval ?? 1))
203
+ let s = ''
204
+ switch (r.freq) {
205
+ case 'daily':
206
+ s = n === 1 ? 'Daily' : `Every ${n} days`
207
+ break
208
+ case 'weekly': {
209
+ s = n === 1 ? 'Weekly' : `Every ${n} weeks`
210
+ const wds = r.weekdays && r.weekdays.length ? [...r.weekdays].sort((a, b) => a - b) : []
211
+ if (wds.length) {
212
+ const isWeekdays = wds.length === 5 && [1, 2, 3, 4, 5].every((d) => wds.includes(d))
213
+ const isWeekend = wds.length === 2 && wds.includes(0) && wds.includes(6)
214
+ s += isWeekdays ? ' on weekdays' : isWeekend ? ' on weekends' : ` on ${wds.map((d) => L.weekdays[d]).join(', ')}`
215
+ }
216
+ break
217
+ }
218
+ case 'monthly': {
219
+ s = n === 1 ? 'Monthly' : `Every ${n} months`
220
+ const where = whereInMonth(r, L.weekdays, L.ordinals)
221
+ if (where) s += ` on ${where}`
222
+ break
223
+ }
224
+ case 'yearly': {
225
+ s = n === 1 ? 'Yearly' : `Every ${n} years`
226
+ const where = whereInMonth(r, L.weekdays, L.ordinals)
227
+ if (r.month != null && where) s += ` on ${where} of ${L.months[r.month]}`
228
+ else if (r.month != null) s += ` in ${L.months[r.month]}`
229
+ else if (where) s += ` on ${where}`
230
+ break
231
+ }
232
+ }
233
+ // End condition.
234
+ if (r.count && r.count >= 1) s += `, ${r.count} time${r.count === 1 ? '' : 's'}`
235
+ else if (r.until) {
236
+ const u = toDate(r.until)
237
+ if (u) s += ` until ${L.months[u.getMonth()]} ${u.getDate()}, ${u.getFullYear()}`
238
+ }
239
+ return s
240
+ }
241
+
242
+ /**
243
+ * A short, human-readable summary of a recurrence rule (or list) - e.g.
244
+ * "Weekly on weekdays", "Every 2 weeks on Fri, 8 times", "Monthly on the last
245
+ * Friday", "Yearly on the fourth Thursday of Nov". Returns the `never` label
246
+ * (default "") for an empty / falsy rule, so it drops cleanly into a table cell.
247
+ */
248
+ export function describeRecurrence(
249
+ rules: RecurrenceRule | ReadonlyArray<RecurrenceRule> | null | undefined,
250
+ labels?: RecurrenceLabels,
251
+ ): string {
252
+ const L: Required<RecurrenceLabels> = {
253
+ weekdays: labels?.weekdays ?? WD,
254
+ months: labels?.months ?? MO,
255
+ ordinals: labels?.ordinals ?? ORD,
256
+ never: labels?.never ?? '',
257
+ }
258
+ if (!rules) return L.never
259
+ const list = (Array.isArray(rules) ? rules : [rules]).filter((r) => r && r.freq)
260
+ if (!list.length) return L.never
261
+ return list.map((r) => describeRule(r, L)).join('; ')
262
+ }
@@ -1,28 +1,28 @@
1
- import type { Component, ComponentProps, Snippet } from 'svelte'
2
-
3
- export class RenderComponentConfig<TComponent extends Component> {
4
- constructor(
5
- public component: TComponent,
6
- public props?: ComponentProps<TComponent> | Record<string, never>,
7
- ) {}
8
- }
9
-
10
- export class RenderSnippetConfig<TProps> {
11
- constructor(
12
- public snippet: Snippet<[TProps]>,
13
- public params?: TProps,
14
- ) {}
15
- }
16
-
17
- export const renderComponent = <
18
- TComponent extends Component<any>,
19
- TProps extends ComponentProps<TComponent>,
20
- >(
21
- component: TComponent,
22
- props?: TProps,
23
- ) => new RenderComponentConfig(component, props)
24
-
25
- export const renderSnippet = <TProps>(
26
- snippet: Snippet<[TProps]>,
27
- params?: TProps,
28
- ) => new RenderSnippetConfig(snippet, params)
1
+ import type { Component, ComponentProps, Snippet } from 'svelte'
2
+
3
+ export class RenderComponentConfig<TComponent extends Component> {
4
+ constructor(
5
+ public component: TComponent,
6
+ public props?: ComponentProps<TComponent> | Record<string, never>,
7
+ ) {}
8
+ }
9
+
10
+ export class RenderSnippetConfig<TProps> {
11
+ constructor(
12
+ public snippet: Snippet<[TProps]>,
13
+ public params?: TProps,
14
+ ) {}
15
+ }
16
+
17
+ export const renderComponent = <
18
+ TComponent extends Component<any>,
19
+ TProps extends ComponentProps<TComponent>,
20
+ >(
21
+ component: TComponent,
22
+ props?: TProps,
23
+ ) => new RenderComponentConfig(component, props)
24
+
25
+ export const renderSnippet = <TProps>(
26
+ snippet: Snippet<[TProps]>,
27
+ params?: TProps,
28
+ ) => new RenderSnippetConfig(snippet, params)
package/src/row-drag.ts CHANGED
@@ -178,6 +178,16 @@ export function createRowDrag<TFeatures, TData>(ctx: any) {
178
178
  onRowsContainerDrop,
179
179
  onRowDragEnd,
180
180
  rowDragGridId: gridId,
181
+ /**
182
+ * Clear any managed drag this grid currently owns. Call on unmount so an
183
+ * interrupted drag (route change / `{#if}` toggle mid-drag) doesn't leave a
184
+ * stale module-level `bus` that fires `removeFromSource` against a destroyed
185
+ * grid's data on the next grid instance (#68). Guarded by `gridId` so it
186
+ * never disturbs a concurrent drag owned by another grid.
187
+ */
188
+ destroyRowDrag() {
189
+ if (bus?.gridId === gridId) bus = null;
190
+ },
181
191
  };
182
192
  }
183
193
 
@@ -0,0 +1,68 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { toICS, fromICS, ruleToRRule, rruleToRule, type ICalEvent } from './scheduler-ical'
3
+ import type { RecurrenceRule } from './recurrence'
4
+
5
+ describe('iCal RRULE round-trip', () => {
6
+ const cases: [RecurrenceRule, string][] = [
7
+ [{ freq: 'weekly', weekdays: [1, 3, 5] }, 'FREQ=WEEKLY;BYDAY=MO,WE,FR'],
8
+ [{ freq: 'daily', interval: 2 }, 'FREQ=DAILY;INTERVAL=2'],
9
+ [{ freq: 'monthly', day: -1 }, 'FREQ=MONTHLY;BYMONTHDAY=-1'],
10
+ [{ freq: 'monthly', weekdays: [2], weekOfMonth: 1 }, 'FREQ=MONTHLY;BYDAY=1TU'],
11
+ [{ freq: 'monthly', weekdays: [5], weekOfMonth: -1 }, 'FREQ=MONTHLY;BYDAY=-1FR'],
12
+ [{ freq: 'yearly', month: 10, weekdays: [4], weekOfMonth: 4 }, 'FREQ=YEARLY;BYDAY=4TH;BYMONTH=11'],
13
+ [{ freq: 'weekly', weekdays: [1], count: 8 }, 'FREQ=WEEKLY;BYDAY=MO;COUNT=8'],
14
+ ]
15
+ it('serializes rules to RRULE', () => {
16
+ for (const [rule, str] of cases) expect(ruleToRRule(rule)).toBe(str)
17
+ })
18
+ it('parses RRULE back to an equivalent rule', () => {
19
+ for (const [rule, str] of cases) expect(rruleToRule(str)).toMatchObject(rule)
20
+ })
21
+ })
22
+
23
+ describe('toICS / fromICS', () => {
24
+ const events: ICalEvent[] = [
25
+ { uid: 'a@x', title: 'Kickoff, prep', start: new Date('2026-07-06T09:00:00Z'), end: new Date('2026-07-06T10:00:00Z'), status: 'confirmed' },
26
+ { uid: 'b@x', title: 'Offsite', start: new Date('2026-07-08T00:00:00Z'), end: new Date('2026-07-10T00:00:00Z'), allDay: true },
27
+ { uid: 'c@x', title: 'Standup', start: new Date('2026-07-06T09:30:00Z'), end: new Date('2026-07-06T09:45:00Z'), rrule: { freq: 'weekly', weekdays: [1, 2, 3, 4, 5] } },
28
+ ]
29
+
30
+ it('produces a valid VCALENDAR with one VEVENT per event', () => {
31
+ const ics = toICS(events)
32
+ expect(ics).toMatch(/^BEGIN:VCALENDAR/)
33
+ expect(ics).toContain('BEGIN:VEVENT')
34
+ expect((ics.match(/BEGIN:VEVENT/g) ?? []).length).toBe(3)
35
+ expect(ics).toContain('DTSTART:20260706T090000Z')
36
+ expect(ics).toContain('DTSTART;VALUE=DATE:20260708')
37
+ expect(ics).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR')
38
+ expect(ics).toContain('SUMMARY:Kickoff\\, prep') // comma escaped
39
+ expect(ics.endsWith('END:VCALENDAR')).toBe(true)
40
+ })
41
+
42
+ it('round-trips events through export -> import', () => {
43
+ const back = fromICS(toICS(events))
44
+ expect(back).toHaveLength(3)
45
+ expect(back[0]!.title).toBe('Kickoff, prep') // unescaped
46
+ expect(back[0]!.start.toISOString()).toBe('2026-07-06T09:00:00.000Z')
47
+ expect(back[1]!.allDay).toBe(true)
48
+ expect(back[2]!.rrule).toMatchObject({ freq: 'weekly', weekdays: [1, 2, 3, 4, 5] })
49
+ })
50
+
51
+ it('imports a hand-written ICS (folded lines + CRLF)', () => {
52
+ const ics = [
53
+ 'BEGIN:VCALENDAR',
54
+ 'VERSION:2.0',
55
+ 'BEGIN:VEVENT',
56
+ 'UID:z@x',
57
+ 'DTSTART:20260706T140000Z',
58
+ 'DTEND:20260706T150000Z',
59
+ 'SUMMARY:Client call',
60
+ 'END:VEVENT',
61
+ 'END:VCALENDAR',
62
+ ].join('\r\n')
63
+ const out = fromICS(ics)
64
+ expect(out).toHaveLength(1)
65
+ expect(out[0]!.title).toBe('Client call')
66
+ expect(out[0]!.end.toISOString()).toBe('2026-07-06T15:00:00.000Z')
67
+ })
68
+ })
@@ -0,0 +1,180 @@
1
+ /**
2
+ * scheduler-ical - pure, dependency-free iCalendar (RFC 5545) import / export for
3
+ * the scheduler. Works on a small {@link ICalEvent} shape so it stays data-model
4
+ * agnostic: map your rows to/from it. Handles timed + all-day events, RRULE
5
+ * round-trip (via the shared {@link RecurrenceRule}), STATUS, DESCRIPTION and
6
+ * LOCATION. No `Date.now()` in the core - `toICS` accepts a stamp.
7
+ */
8
+ import type { RecurrenceRule } from './recurrence'
9
+
10
+ export type ICalEvent = {
11
+ uid?: string
12
+ title: string
13
+ description?: string
14
+ location?: string
15
+ start: Date
16
+ end: Date
17
+ allDay?: boolean
18
+ status?: 'confirmed' | 'tentative' | 'cancelled'
19
+ rrule?: RecurrenceRule | null
20
+ }
21
+
22
+ const WD = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const
23
+ const pad = (n: number) => String(n).padStart(2, '0')
24
+
25
+ const utcStamp = (d: Date) =>
26
+ `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`
27
+ const dateOnly = (d: Date) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`
28
+
29
+ const escText = (s: string) =>
30
+ s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n')
31
+ const unescText = (s: string) =>
32
+ s.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\')
33
+
34
+ /** Fold a content line at 74 octets (RFC 5545 line folding). */
35
+ function fold(line: string): string {
36
+ if (line.length <= 74) return line
37
+ let out = line.slice(0, 74)
38
+ let rest = line.slice(74)
39
+ while (rest.length > 73) {
40
+ out += '\r\n ' + rest.slice(0, 73)
41
+ rest = rest.slice(73)
42
+ }
43
+ return out + '\r\n ' + rest
44
+ }
45
+
46
+ // --- RRULE serialize / parse ------------------------------------------------
47
+
48
+ /** A {@link RecurrenceRule} -> an iCal `RRULE` value (without the `RRULE:` name). */
49
+ export function ruleToRRule(rule: RecurrenceRule): string {
50
+ const parts = [`FREQ=${rule.freq.toUpperCase()}`]
51
+ if (rule.interval && rule.interval > 1) parts.push(`INTERVAL=${rule.interval}`)
52
+ if (rule.weekdays?.length) {
53
+ if (rule.weekOfMonth != null && rule.weekdays.length === 1) {
54
+ parts.push(`BYDAY=${rule.weekOfMonth}${WD[rule.weekdays[0]!]}`)
55
+ } else {
56
+ parts.push(`BYDAY=${rule.weekdays.map((w) => WD[w]).join(',')}`)
57
+ }
58
+ }
59
+ if (rule.day != null && rule.weekOfMonth == null) parts.push(`BYMONTHDAY=${rule.day}`)
60
+ if (rule.month != null) parts.push(`BYMONTH=${rule.month + 1}`)
61
+ if (rule.count != null) parts.push(`COUNT=${rule.count}`)
62
+ if (rule.until != null) parts.push(`UNTIL=${utcStamp(new Date(rule.until as string))}`)
63
+ return parts.join(';')
64
+ }
65
+
66
+ /** An iCal `RRULE` value -> a {@link RecurrenceRule}. */
67
+ export function rruleToRule(value: string): RecurrenceRule | null {
68
+ const map: Record<string, string> = {}
69
+ for (const kv of value.split(';')) {
70
+ const [k, v] = kv.split('=')
71
+ if (k && v) map[k.toUpperCase()] = v
72
+ }
73
+ const freq = (map.FREQ ?? '').toLowerCase()
74
+ if (!['daily', 'weekly', 'monthly', 'yearly'].includes(freq)) return null
75
+ const rule: RecurrenceRule = { freq: freq as RecurrenceRule['freq'] }
76
+ if (map.INTERVAL) rule.interval = Number(map.INTERVAL)
77
+ if (map.BYDAY) {
78
+ const parts = map.BYDAY.split(',')
79
+ const m = parts[0]!.match(/^(-?\d+)?([A-Z]{2})$/)
80
+ if (parts.length === 1 && m && m[1]) {
81
+ rule.weekOfMonth = Number(m[1])
82
+ rule.weekdays = [WD.indexOf(m[2] as (typeof WD)[number])]
83
+ } else {
84
+ rule.weekdays = parts.map((p) => WD.indexOf(p.replace(/^-?\d+/, '') as (typeof WD)[number])).filter((n) => n >= 0)
85
+ }
86
+ }
87
+ if (map.BYMONTHDAY) rule.day = Number(map.BYMONTHDAY)
88
+ if (map.BYMONTH) rule.month = Number(map.BYMONTH) - 1
89
+ if (map.COUNT) rule.count = Number(map.COUNT)
90
+ if (map.UNTIL) rule.until = parseICalDate(map.UNTIL).date.toISOString()
91
+ return rule
92
+ }
93
+
94
+ // --- export -----------------------------------------------------------------
95
+
96
+ /** Serialize events to an iCalendar string. `stamp` sets DTSTAMP (default epoch). */
97
+ export function toICS(events: ReadonlyArray<ICalEvent>, opts: { prodId?: string; stamp?: Date } = {}): string {
98
+ const stamp = utcStamp(opts.stamp ?? new Date(0))
99
+ const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:${opts.prodId ?? '-//SvGrid//Scheduler//EN'}`]
100
+ events.forEach((e, i) => {
101
+ lines.push('BEGIN:VEVENT')
102
+ lines.push(`UID:${e.uid ?? `svgrid-${i}-${e.start.getTime()}`}`)
103
+ lines.push(`DTSTAMP:${stamp}`)
104
+ if (e.allDay) {
105
+ lines.push(`DTSTART;VALUE=DATE:${dateOnly(e.start)}`)
106
+ lines.push(`DTEND;VALUE=DATE:${dateOnly(e.end)}`)
107
+ } else {
108
+ lines.push(`DTSTART:${utcStamp(e.start)}`)
109
+ lines.push(`DTEND:${utcStamp(e.end)}`)
110
+ }
111
+ lines.push(`SUMMARY:${escText(e.title)}`)
112
+ if (e.description) lines.push(`DESCRIPTION:${escText(e.description)}`)
113
+ if (e.location) lines.push(`LOCATION:${escText(e.location)}`)
114
+ if (e.status) lines.push(`STATUS:${e.status.toUpperCase()}`)
115
+ if (e.rrule) lines.push(`RRULE:${ruleToRRule(e.rrule)}`)
116
+ lines.push('END:VEVENT')
117
+ })
118
+ lines.push('END:VCALENDAR')
119
+ return lines.map(fold).join('\r\n')
120
+ }
121
+
122
+ // --- import -----------------------------------------------------------------
123
+
124
+ function parseICalDate(value: string): { date: Date; allDay: boolean } {
125
+ // 20260706 (date) | 20260706T093000Z (utc) | 20260706T093000 (local/floating)
126
+ const m = value.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/)
127
+ if (!m) return { date: new Date(NaN), allDay: false }
128
+ const [, y, mo, d, hh, mm, ss, z] = m
129
+ if (hh == null) return { date: new Date(Number(y), Number(mo) - 1, Number(d)), allDay: true }
130
+ if (z) return { date: new Date(Date.UTC(+y!, +mo! - 1, +d!, +hh, +mm!, +ss!)), allDay: false }
131
+ return { date: new Date(+y!, +mo! - 1, +d!, +hh, +mm!, +ss!), allDay: false }
132
+ }
133
+
134
+ /** Parse an iCalendar string into events (unfolds lines, reads VEVENT blocks). */
135
+ export function fromICS(text: string): ICalEvent[] {
136
+ // Unfold: a line starting with space/tab continues the previous one.
137
+ const raw = text.replace(/\r\n/g, '\n').split('\n')
138
+ const unfolded: string[] = []
139
+ for (const line of raw) {
140
+ if ((line.startsWith(' ') || line.startsWith('\t')) && unfolded.length) {
141
+ unfolded[unfolded.length - 1] += line.slice(1)
142
+ } else unfolded.push(line)
143
+ }
144
+ const out: ICalEvent[] = []
145
+ let cur: Partial<ICalEvent> & { _allDayStart?: boolean } = {}
146
+ let inEvent = false
147
+ for (const line of unfolded) {
148
+ if (line === 'BEGIN:VEVENT') {
149
+ inEvent = true
150
+ cur = {}
151
+ continue
152
+ }
153
+ if (line === 'END:VEVENT') {
154
+ if (cur.title && cur.start && cur.end) out.push({ title: cur.title, start: cur.start, end: cur.end, allDay: cur.allDay, uid: cur.uid, description: cur.description, location: cur.location, status: cur.status, rrule: cur.rrule ?? null })
155
+ inEvent = false
156
+ continue
157
+ }
158
+ if (!inEvent) continue
159
+ const ci = line.indexOf(':')
160
+ if (ci < 0) continue
161
+ const nameAndParams = line.slice(0, ci)
162
+ const value = line.slice(ci + 1)
163
+ const name = nameAndParams.split(';')[0]!.toUpperCase()
164
+ if (name === 'UID') cur.uid = value
165
+ else if (name === 'SUMMARY') cur.title = unescText(value)
166
+ else if (name === 'DESCRIPTION') cur.description = unescText(value)
167
+ else if (name === 'LOCATION') cur.location = unescText(value)
168
+ else if (name === 'STATUS') cur.status = value.toLowerCase() as ICalEvent['status']
169
+ else if (name === 'DTSTART') {
170
+ const { date, allDay } = parseICalDate(value)
171
+ cur.start = date
172
+ cur.allDay = allDay
173
+ } else if (name === 'DTEND') {
174
+ cur.end = parseICalDate(value).date
175
+ } else if (name === 'RRULE') {
176
+ cur.rrule = rruleToRule(value) ?? undefined
177
+ }
178
+ }
179
+ return out
180
+ }