@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
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
+ }
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
+ }