@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,40 @@
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
+ * Begin a vertical resize from a grip `pointerdown`. Returns nothing; the drag
13
+ * ends itself on `pointerup`. Safe to call from an `onpointerdown` handler.
14
+ */
15
+ export function startPanelResize(event, opts) {
16
+ // Ignore secondary buttons; only a primary drag resizes.
17
+ if (event.button !== 0)
18
+ return;
19
+ event.preventDefault();
20
+ event.stopPropagation();
21
+ const startY = event.clientY;
22
+ const min = opts.min ?? 80;
23
+ const clamp = (h) => Math.max(min, Math.min(opts.max(), Math.round(h)));
24
+ const onMove = (e) => {
25
+ opts.onHeight(clamp(opts.startHeight + (e.clientY - startY)));
26
+ };
27
+ const onUp = () => {
28
+ window.removeEventListener('pointermove', onMove);
29
+ window.removeEventListener('pointerup', onUp);
30
+ window.removeEventListener('pointercancel', onUp);
31
+ document.body.style.userSelect = '';
32
+ document.body.style.cursor = '';
33
+ };
34
+ // Suppress text selection + keep the resize cursor for the whole drag.
35
+ document.body.style.userSelect = 'none';
36
+ document.body.style.cursor = 'ns-resize';
37
+ window.addEventListener('pointermove', onMove);
38
+ window.addEventListener('pointerup', onUp);
39
+ window.addEventListener('pointercancel', onUp);
40
+ }
package/dist/popover.d.ts CHANGED
@@ -42,6 +42,25 @@ export type AnchoredRect = {
42
42
  left: number;
43
43
  width: number;
44
44
  openUpward: boolean;
45
+ /**
46
+ * Comfortable panel height: the estimate, capped to the room on the chosen
47
+ * side so the panel never leaves the viewport. Apply as the panel's
48
+ * `max-height` and long content scrolls inside instead of overflowing.
49
+ */
50
+ maxHeight: number;
51
+ /**
52
+ * Absolute height the panel may occupy on the chosen side (room minus the
53
+ * viewport margin). The hard ceiling a user resize must clamp to.
54
+ */
55
+ availHeight: number;
56
+ /**
57
+ * For an upward-flipped panel: distance from the viewport bottom to where the
58
+ * panel's bottom edge sits (just above the trigger). Bottom-anchoring an
59
+ * upward panel with `style:bottom` lets it grow to its natural content height
60
+ * instead of being positioned from a (possibly wrong) height estimate.
61
+ * Undefined until `anchoredRect` computes it.
62
+ */
63
+ bottom?: number;
45
64
  };
46
65
  export type AnchorOptions = {
47
66
  /** Estimated panel height, used to decide whether to flip upward. */
@@ -52,11 +71,18 @@ export type AnchorOptions = {
52
71
  minWidth?: number;
53
72
  /** Keep the panel within the viewport horizontally. Default true. */
54
73
  clampHorizontal?: boolean;
74
+ /** Viewport edge kept clear top/bottom so a panel never touches it. Default 8. */
75
+ viewportMargin?: number;
76
+ /** Floor for `maxHeight`/`availHeight` so a cramped panel stays usable. Default 96. */
77
+ minHeight?: number;
55
78
  };
56
79
  /**
57
80
  * Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the
58
- * trigger when there isn't room below and there's more room above. Mirrors the
59
- * behavior SvGridDropdown shipped with, generalized with min-width + horizontal
60
- * clamping for wider panels (date/time popovers).
81
+ * trigger when there isn't room below and there's more room above. Generalized
82
+ * with min-width + horizontal clamping for wider panels (date/time popovers),
83
+ * and with vertical bounds detection: `maxHeight` caps the panel to the room on
84
+ * the chosen side (minus a viewport margin) so a long list near a screen edge
85
+ * scrolls internally rather than overflowing, and an upward flip is clamped so
86
+ * its top never leaves the viewport.
61
87
  */
62
88
  export declare function anchoredRect(triggerRect: DOMRect, opts: AnchorOptions): AnchoredRect;
package/dist/popover.js CHANGED
@@ -67,15 +67,22 @@ export function popIn(node, param = {}) {
67
67
  }
68
68
  /**
69
69
  * Compute a `position: fixed` rect anchored to `triggerRect`, flipping above the
70
- * trigger when there isn't room below and there's more room above. Mirrors the
71
- * behavior SvGridDropdown shipped with, generalized with min-width + horizontal
72
- * clamping for wider panels (date/time popovers).
70
+ * trigger when there isn't room below and there's more room above. Generalized
71
+ * with min-width + horizontal clamping for wider panels (date/time popovers),
72
+ * and with vertical bounds detection: `maxHeight` caps the panel to the room on
73
+ * the chosen side (minus a viewport margin) so a long list near a screen edge
74
+ * scrolls internally rather than overflowing, and an upward flip is clamped so
75
+ * its top never leaves the viewport.
73
76
  */
74
77
  export function anchoredRect(triggerRect, opts) {
75
78
  const gap = opts.gap ?? 2;
76
- const spaceBelow = window.innerHeight - triggerRect.bottom;
77
- const spaceAbove = triggerRect.top;
79
+ const margin = opts.viewportMargin ?? 8;
80
+ const minH = opts.minHeight ?? 96;
81
+ const spaceBelow = window.innerHeight - triggerRect.bottom - gap - margin;
82
+ const spaceAbove = triggerRect.top - gap - margin;
78
83
  const openUpward = spaceBelow < opts.estimatedHeight && spaceAbove > spaceBelow;
84
+ const availHeight = Math.max(minH, Math.floor(openUpward ? spaceAbove : spaceBelow));
85
+ const maxHeight = Math.min(opts.estimatedHeight, availHeight);
79
86
  const width = Math.max(triggerRect.width, opts.minWidth ?? 0);
80
87
  let left = triggerRect.left;
81
88
  if (opts.clampHorizontal !== false) {
@@ -83,9 +90,18 @@ export function anchoredRect(triggerRect, opts) {
83
90
  left = Math.max(4, Math.min(left, maxLeft));
84
91
  }
85
92
  return {
86
- top: openUpward ? triggerRect.top - opts.estimatedHeight - gap : triggerRect.bottom + gap,
93
+ // Upward: pin by the comfortable height and clamp to the top margin so the
94
+ // panel can never start off-screen. Downward: sit just below the trigger.
95
+ top: openUpward
96
+ ? Math.max(margin, triggerRect.top - gap - maxHeight)
97
+ : triggerRect.bottom + gap,
87
98
  left,
88
99
  width,
89
100
  openUpward,
101
+ maxHeight,
102
+ availHeight,
103
+ // Bottom-anchor value for upward panels: viewport bottom -> panel bottom
104
+ // edge (which sits `gap` above the trigger top).
105
+ bottom: Math.round(window.innerHeight - triggerRect.top + gap),
90
106
  };
91
107
  }
@@ -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 { type DateLike } from './datetime/date-core';
@@ -18,19 +22,41 @@ export type RecurrenceRule = {
18
22
  freq: RecurrenceFreq;
19
23
  /** Repeat every N units of `freq`. Default 1. Uses `from` as the phase anchor. */
20
24
  interval?: number;
21
- /** weekly: the weekdays it lands on (0 = Sunday .. 6 = Saturday). */
25
+ /** weekly: the weekdays it lands on (0 = Sunday .. 6 = Saturday). Also the
26
+ * weekday for a positional monthly / yearly rule (with `weekOfMonth`). */
22
27
  weekdays?: ReadonlyArray<number>;
23
- /** monthly / yearly: day of the month (1..31). */
28
+ /** monthly / yearly: day of the month. 1..31, or negative to count from the
29
+ * end (-1 = the last day, -2 = the second to last). */
24
30
  day?: number;
31
+ /** monthly / yearly: the ordinal week the `weekdays` land on within the month -
32
+ * 1..4 (first..fourth) or -1 (last). E.g. `{ weekdays:[2], weekOfMonth:-1 }`
33
+ * = the last Tuesday. Takes precedence over `day` when both are set. */
34
+ weekOfMonth?: number;
25
35
  /** yearly: the month (0 = January .. 11 = December). */
26
36
  month?: number;
27
37
  /** First occurrence + the phase for `interval`. */
28
38
  from?: DateLike;
29
39
  /** Last day the pattern applies (inclusive). */
30
40
  until?: DateLike | null;
41
+ /** End after this many occurrences (counted from `from`). Requires `from`. */
42
+ count?: number;
31
43
  };
32
44
  /** Whether `date` matches any of the given recurrence rule(s). */
33
45
  export declare function matchesRecurrence(date: Date, rules: RecurrenceRule | ReadonlyArray<RecurrenceRule> | null | undefined): boolean;
34
46
  /** Every matching date within [start, end] (inclusive), in order - for building
35
47
  * event lists / agendas. Bounded to avoid runaway loops. */
36
48
  export declare function expandRecurrence(rules: RecurrenceRule | ReadonlyArray<RecurrenceRule> | null | undefined, start: Date, end: Date): Date[];
49
+ /** Optional English strings a caller can override for i18n. */
50
+ export type RecurrenceLabels = {
51
+ weekdays?: ReadonlyArray<string>;
52
+ months?: ReadonlyArray<string>;
53
+ ordinals?: Record<number, string>;
54
+ never?: string;
55
+ };
56
+ /**
57
+ * A short, human-readable summary of a recurrence rule (or list) - e.g.
58
+ * "Weekly on weekdays", "Every 2 weeks on Fri, 8 times", "Monthly on the last
59
+ * Friday", "Yearly on the fourth Thursday of Nov". Returns the `never` label
60
+ * (default "") for an empty / falsy rule, so it drops cleanly into a table cell.
61
+ */
62
+ export declare function describeRecurrence(rules: RecurrenceRule | ReadonlyArray<RecurrenceRule> | null | undefined, labels?: RecurrenceLabels): string;
@@ -1,22 +1,38 @@
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 } from './datetime/date-core';
16
20
  const MS_DAY = 86400000;
17
21
  /** Whole-day index, rounded so DST transitions don't shift it. */
18
22
  const dayIndex = (d) => Math.round(startOfDay(d).getTime() / MS_DAY);
19
- function matchesRule(date, r) {
23
+ /** Days in the month that `d` falls in. */
24
+ const daysInMonth = (d) => new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
25
+ /** Whether `d` is the `nth` occurrence of its own weekday within its month -
26
+ * nth 1..4 (first..fourth) or -1 (last). */
27
+ function isNthWeekdayOfMonth(d, nth) {
28
+ if (nth === -1)
29
+ return d.getDate() + 7 > daysInMonth(d); // no same weekday later this month
30
+ return Math.floor((d.getDate() - 1) / 7) + 1 === nth;
31
+ }
32
+ function matchesRule(date, rule) {
33
+ // Fold a `count` end-condition into an equivalent `until` first (pure), so the
34
+ // rest of the matcher stays a stateless per-date test.
35
+ const r = rule.count && rule.count >= 1 && rule.from != null ? resolveCount(rule) : rule;
20
36
  const d = startOfDay(date);
21
37
  const fromD = r.from != null ? startOfDay(toDate(r.from) ?? d) : null;
22
38
  const untilD = r.until != null ? startOfDay(toDate(r.until) ?? d) : null;
@@ -45,26 +61,63 @@ function matchesRule(date, r) {
45
61
  return true;
46
62
  }
47
63
  case 'monthly': {
48
- const dom = r.day ?? fromD?.getDate();
49
- if (dom != null && d.getDate() !== dom)
64
+ if (!matchesMonthDay(d, r))
50
65
  return false;
51
66
  if (!fromD)
52
- return dom != null;
67
+ return true;
53
68
  const months = (d.getFullYear() - fromD.getFullYear()) * 12 + (d.getMonth() - fromD.getMonth());
54
69
  return months % interval === 0;
55
70
  }
56
71
  case 'yearly': {
57
72
  if (r.month != null && d.getMonth() !== r.month)
58
73
  return false;
59
- const dom = r.day ?? fromD?.getDate();
60
- if (dom != null && d.getDate() !== dom)
74
+ if (!matchesMonthDay(d, r))
61
75
  return false;
62
76
  if (!fromD)
63
- return r.month != null || dom != null;
77
+ return r.month != null || r.day != null || r.weekOfMonth != null;
64
78
  return (d.getFullYear() - fromD.getFullYear()) % interval === 0;
65
79
  }
66
80
  }
67
81
  }
82
+ // The day-within-the-month test shared by monthly + yearly: a positional
83
+ // weekday (`weekOfMonth` + `weekdays`), else a day-of-month (`day`, negative =
84
+ // from the end), else - for a `from`-anchored rule - the anchor's day-of-month.
85
+ function matchesMonthDay(d, r) {
86
+ if (r.weekOfMonth != null && r.weekdays && r.weekdays.length) {
87
+ return r.weekdays.includes(d.getDay()) && isNthWeekdayOfMonth(d, r.weekOfMonth);
88
+ }
89
+ const fromDom = r.from != null ? (toDate(r.from) ?? d).getDate() : null;
90
+ const dom = r.day ?? fromDom;
91
+ if (dom == null)
92
+ return false;
93
+ const target = dom < 0 ? daysInMonth(d) + dom + 1 : dom;
94
+ return d.getDate() === target;
95
+ }
96
+ /** Fold a `count` rule into an equivalent `until`-bounded rule by walking
97
+ * forward from `from` to the Nth occurrence. Pure; count is then stripped. */
98
+ function resolveCount(rule) {
99
+ const from = toDate(rule.from);
100
+ if (!from)
101
+ return { ...rule, count: undefined };
102
+ const base = { ...rule, count: undefined };
103
+ let cur = startOfDay(from);
104
+ let n = 0;
105
+ let lastMatch = cur;
106
+ let guard = 0;
107
+ while (guard++ < 4000 * 12) {
108
+ if (matchesRule(cur, base)) {
109
+ n++;
110
+ lastMatch = cur;
111
+ if (n >= rule.count)
112
+ break;
113
+ }
114
+ cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
115
+ }
116
+ const untilFromCount = lastMatch;
117
+ const existingUntil = rule.until != null ? startOfDay(toDate(rule.until) ?? untilFromCount) : null;
118
+ const until = existingUntil && existingUntil < untilFromCount ? existingUntil : untilFromCount;
119
+ return { ...base, until };
120
+ }
68
121
  /** Whether `date` matches any of the given recurrence rule(s). */
69
122
  export function matchesRecurrence(date, rules) {
70
123
  if (!rules)
@@ -77,14 +130,99 @@ export function matchesRecurrence(date, rules) {
77
130
  export function expandRecurrence(rules, start, end) {
78
131
  if (!rules)
79
132
  return [];
133
+ // Pre-resolve any `count` end-condition into a plain `until` once (rather than
134
+ // re-walking to the Nth occurrence for every day in the window).
135
+ const list = (Array.isArray(rules) ? rules : [rules]).map((r) => r.count && r.count >= 1 && r.from != null ? resolveCount(r) : r);
80
136
  const out = [];
81
137
  const last = startOfDay(end);
82
138
  let cur = startOfDay(start);
83
139
  let guard = 0;
84
140
  while (cur <= last && guard++ < 4000) {
85
- if (matchesRecurrence(cur, rules))
141
+ if (matchesRecurrence(cur, list))
86
142
  out.push(cur);
87
143
  cur = new Date(cur.getFullYear(), cur.getMonth(), cur.getDate() + 1);
88
144
  }
89
145
  return out;
90
146
  }
147
+ const WD = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
148
+ const MO = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
149
+ const ORD = { 1: 'first', 2: 'second', 3: 'third', 4: 'fourth', 5: 'fifth', [-1]: 'last' };
150
+ // Where in the month a monthly / yearly rule lands, as a phrase.
151
+ function whereInMonth(r, wd, ord) {
152
+ if (r.weekOfMonth != null && r.weekdays && r.weekdays.length) {
153
+ return `the ${ord[r.weekOfMonth] ?? r.weekOfMonth} ${wd[r.weekdays[0]]}`;
154
+ }
155
+ if (r.day === -1)
156
+ return 'the last day';
157
+ if (r.day != null && r.day < 0)
158
+ return `the ${-r.day} day(s) from the end`;
159
+ if (r.day != null)
160
+ return `day ${r.day}`;
161
+ return '';
162
+ }
163
+ function describeRule(r, L) {
164
+ const n = Math.max(1, Math.floor(r.interval ?? 1));
165
+ let s = '';
166
+ switch (r.freq) {
167
+ case 'daily':
168
+ s = n === 1 ? 'Daily' : `Every ${n} days`;
169
+ break;
170
+ case 'weekly': {
171
+ s = n === 1 ? 'Weekly' : `Every ${n} weeks`;
172
+ const wds = r.weekdays && r.weekdays.length ? [...r.weekdays].sort((a, b) => a - b) : [];
173
+ if (wds.length) {
174
+ const isWeekdays = wds.length === 5 && [1, 2, 3, 4, 5].every((d) => wds.includes(d));
175
+ const isWeekend = wds.length === 2 && wds.includes(0) && wds.includes(6);
176
+ s += isWeekdays ? ' on weekdays' : isWeekend ? ' on weekends' : ` on ${wds.map((d) => L.weekdays[d]).join(', ')}`;
177
+ }
178
+ break;
179
+ }
180
+ case 'monthly': {
181
+ s = n === 1 ? 'Monthly' : `Every ${n} months`;
182
+ const where = whereInMonth(r, L.weekdays, L.ordinals);
183
+ if (where)
184
+ s += ` on ${where}`;
185
+ break;
186
+ }
187
+ case 'yearly': {
188
+ s = n === 1 ? 'Yearly' : `Every ${n} years`;
189
+ const where = whereInMonth(r, L.weekdays, L.ordinals);
190
+ if (r.month != null && where)
191
+ s += ` on ${where} of ${L.months[r.month]}`;
192
+ else if (r.month != null)
193
+ s += ` in ${L.months[r.month]}`;
194
+ else if (where)
195
+ s += ` on ${where}`;
196
+ break;
197
+ }
198
+ }
199
+ // End condition.
200
+ if (r.count && r.count >= 1)
201
+ s += `, ${r.count} time${r.count === 1 ? '' : 's'}`;
202
+ else if (r.until) {
203
+ const u = toDate(r.until);
204
+ if (u)
205
+ s += ` until ${L.months[u.getMonth()]} ${u.getDate()}, ${u.getFullYear()}`;
206
+ }
207
+ return s;
208
+ }
209
+ /**
210
+ * A short, human-readable summary of a recurrence rule (or list) - e.g.
211
+ * "Weekly on weekdays", "Every 2 weeks on Fri, 8 times", "Monthly on the last
212
+ * Friday", "Yearly on the fourth Thursday of Nov". Returns the `never` label
213
+ * (default "") for an empty / falsy rule, so it drops cleanly into a table cell.
214
+ */
215
+ export function describeRecurrence(rules, labels) {
216
+ const L = {
217
+ weekdays: labels?.weekdays ?? WD,
218
+ months: labels?.months ?? MO,
219
+ ordinals: labels?.ordinals ?? ORD,
220
+ never: labels?.never ?? '',
221
+ };
222
+ if (!rules)
223
+ return L.never;
224
+ const list = (Array.isArray(rules) ? rules : [rules]).filter((r) => r && r.freq);
225
+ if (!list.length)
226
+ return L.never;
227
+ return list.map((r) => describeRule(r, L)).join('; ');
228
+ }
@@ -14,6 +14,14 @@ export declare function createRowDrag<TFeatures, TData>(ctx: any): {
14
14
  onRowsContainerDrop: (e: DragEvent) => void;
15
15
  onRowDragEnd: () => void;
16
16
  rowDragGridId: number;
17
+ /**
18
+ * Clear any managed drag this grid currently owns. Call on unmount so an
19
+ * interrupted drag (route change / `{#if}` toggle mid-drag) doesn't leave a
20
+ * stale module-level `bus` that fires `removeFromSource` against a destroyed
21
+ * grid's data on the next grid instance (#68). Guarded by `gridId` so it
22
+ * never disturbs a concurrent drag owned by another grid.
23
+ */
24
+ destroyRowDrag(): void;
17
25
  };
18
26
  /** Options for the {@link rowDropZone} action. */
19
27
  export type RowDropZoneOptions<TData = unknown> = {
package/dist/row-drag.js CHANGED
@@ -162,6 +162,17 @@ export function createRowDrag(ctx) {
162
162
  onRowsContainerDrop,
163
163
  onRowDragEnd,
164
164
  rowDragGridId: gridId,
165
+ /**
166
+ * Clear any managed drag this grid currently owns. Call on unmount so an
167
+ * interrupted drag (route change / `{#if}` toggle mid-drag) doesn't leave a
168
+ * stale module-level `bus` that fires `removeFromSource` against a destroyed
169
+ * grid's data on the next grid instance (#68). Guarded by `gridId` so it
170
+ * never disturbs a concurrent drag owned by another grid.
171
+ */
172
+ destroyRowDrag() {
173
+ if (bus?.gridId === gridId)
174
+ bus = null;
175
+ },
165
176
  };
166
177
  }
167
178
  /**
@@ -0,0 +1,30 @@
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
+ export type ICalEvent = {
10
+ uid?: string;
11
+ title: string;
12
+ description?: string;
13
+ location?: string;
14
+ start: Date;
15
+ end: Date;
16
+ allDay?: boolean;
17
+ status?: 'confirmed' | 'tentative' | 'cancelled';
18
+ rrule?: RecurrenceRule | null;
19
+ };
20
+ /** A {@link RecurrenceRule} -> an iCal `RRULE` value (without the `RRULE:` name). */
21
+ export declare function ruleToRRule(rule: RecurrenceRule): string;
22
+ /** An iCal `RRULE` value -> a {@link RecurrenceRule}. */
23
+ export declare function rruleToRule(value: string): RecurrenceRule | null;
24
+ /** Serialize events to an iCalendar string. `stamp` sets DTSTAMP (default epoch). */
25
+ export declare function toICS(events: ReadonlyArray<ICalEvent>, opts?: {
26
+ prodId?: string;
27
+ stamp?: Date;
28
+ }): string;
29
+ /** Parse an iCalendar string into events (unfolds lines, reads VEVENT blocks). */
30
+ export declare function fromICS(text: string): ICalEvent[];
@@ -0,0 +1,180 @@
1
+ const WD = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'];
2
+ const pad = (n) => String(n).padStart(2, '0');
3
+ const utcStamp = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`;
4
+ const dateOnly = (d) => `${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}`;
5
+ const escText = (s) => s.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n');
6
+ const unescText = (s) => s.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');
7
+ /** Fold a content line at 74 octets (RFC 5545 line folding). */
8
+ function fold(line) {
9
+ if (line.length <= 74)
10
+ return line;
11
+ let out = line.slice(0, 74);
12
+ let rest = line.slice(74);
13
+ while (rest.length > 73) {
14
+ out += '\r\n ' + rest.slice(0, 73);
15
+ rest = rest.slice(73);
16
+ }
17
+ return out + '\r\n ' + rest;
18
+ }
19
+ // --- RRULE serialize / parse ------------------------------------------------
20
+ /** A {@link RecurrenceRule} -> an iCal `RRULE` value (without the `RRULE:` name). */
21
+ export function ruleToRRule(rule) {
22
+ const parts = [`FREQ=${rule.freq.toUpperCase()}`];
23
+ if (rule.interval && rule.interval > 1)
24
+ parts.push(`INTERVAL=${rule.interval}`);
25
+ if (rule.weekdays?.length) {
26
+ if (rule.weekOfMonth != null && rule.weekdays.length === 1) {
27
+ parts.push(`BYDAY=${rule.weekOfMonth}${WD[rule.weekdays[0]]}`);
28
+ }
29
+ else {
30
+ parts.push(`BYDAY=${rule.weekdays.map((w) => WD[w]).join(',')}`);
31
+ }
32
+ }
33
+ if (rule.day != null && rule.weekOfMonth == null)
34
+ parts.push(`BYMONTHDAY=${rule.day}`);
35
+ if (rule.month != null)
36
+ parts.push(`BYMONTH=${rule.month + 1}`);
37
+ if (rule.count != null)
38
+ parts.push(`COUNT=${rule.count}`);
39
+ if (rule.until != null)
40
+ parts.push(`UNTIL=${utcStamp(new Date(rule.until))}`);
41
+ return parts.join(';');
42
+ }
43
+ /** An iCal `RRULE` value -> a {@link RecurrenceRule}. */
44
+ export function rruleToRule(value) {
45
+ const map = {};
46
+ for (const kv of value.split(';')) {
47
+ const [k, v] = kv.split('=');
48
+ if (k && v)
49
+ map[k.toUpperCase()] = v;
50
+ }
51
+ const freq = (map.FREQ ?? '').toLowerCase();
52
+ if (!['daily', 'weekly', 'monthly', 'yearly'].includes(freq))
53
+ return null;
54
+ const rule = { freq: freq };
55
+ if (map.INTERVAL)
56
+ rule.interval = Number(map.INTERVAL);
57
+ if (map.BYDAY) {
58
+ const parts = map.BYDAY.split(',');
59
+ const m = parts[0].match(/^(-?\d+)?([A-Z]{2})$/);
60
+ if (parts.length === 1 && m && m[1]) {
61
+ rule.weekOfMonth = Number(m[1]);
62
+ rule.weekdays = [WD.indexOf(m[2])];
63
+ }
64
+ else {
65
+ rule.weekdays = parts.map((p) => WD.indexOf(p.replace(/^-?\d+/, ''))).filter((n) => n >= 0);
66
+ }
67
+ }
68
+ if (map.BYMONTHDAY)
69
+ rule.day = Number(map.BYMONTHDAY);
70
+ if (map.BYMONTH)
71
+ rule.month = Number(map.BYMONTH) - 1;
72
+ if (map.COUNT)
73
+ rule.count = Number(map.COUNT);
74
+ if (map.UNTIL)
75
+ rule.until = parseICalDate(map.UNTIL).date.toISOString();
76
+ return rule;
77
+ }
78
+ // --- export -----------------------------------------------------------------
79
+ /** Serialize events to an iCalendar string. `stamp` sets DTSTAMP (default epoch). */
80
+ export function toICS(events, opts = {}) {
81
+ const stamp = utcStamp(opts.stamp ?? new Date(0));
82
+ const lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', `PRODID:${opts.prodId ?? '-//SvGrid//Scheduler//EN'}`];
83
+ events.forEach((e, i) => {
84
+ lines.push('BEGIN:VEVENT');
85
+ lines.push(`UID:${e.uid ?? `svgrid-${i}-${e.start.getTime()}`}`);
86
+ lines.push(`DTSTAMP:${stamp}`);
87
+ if (e.allDay) {
88
+ lines.push(`DTSTART;VALUE=DATE:${dateOnly(e.start)}`);
89
+ lines.push(`DTEND;VALUE=DATE:${dateOnly(e.end)}`);
90
+ }
91
+ else {
92
+ lines.push(`DTSTART:${utcStamp(e.start)}`);
93
+ lines.push(`DTEND:${utcStamp(e.end)}`);
94
+ }
95
+ lines.push(`SUMMARY:${escText(e.title)}`);
96
+ if (e.description)
97
+ lines.push(`DESCRIPTION:${escText(e.description)}`);
98
+ if (e.location)
99
+ lines.push(`LOCATION:${escText(e.location)}`);
100
+ if (e.status)
101
+ lines.push(`STATUS:${e.status.toUpperCase()}`);
102
+ if (e.rrule)
103
+ lines.push(`RRULE:${ruleToRRule(e.rrule)}`);
104
+ lines.push('END:VEVENT');
105
+ });
106
+ lines.push('END:VCALENDAR');
107
+ return lines.map(fold).join('\r\n');
108
+ }
109
+ // --- import -----------------------------------------------------------------
110
+ function parseICalDate(value) {
111
+ // 20260706 (date) | 20260706T093000Z (utc) | 20260706T093000 (local/floating)
112
+ const m = value.match(/^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$/);
113
+ if (!m)
114
+ return { date: new Date(NaN), allDay: false };
115
+ const [, y, mo, d, hh, mm, ss, z] = m;
116
+ if (hh == null)
117
+ return { date: new Date(Number(y), Number(mo) - 1, Number(d)), allDay: true };
118
+ if (z)
119
+ return { date: new Date(Date.UTC(+y, +mo - 1, +d, +hh, +mm, +ss)), allDay: false };
120
+ return { date: new Date(+y, +mo - 1, +d, +hh, +mm, +ss), allDay: false };
121
+ }
122
+ /** Parse an iCalendar string into events (unfolds lines, reads VEVENT blocks). */
123
+ export function fromICS(text) {
124
+ // Unfold: a line starting with space/tab continues the previous one.
125
+ const raw = text.replace(/\r\n/g, '\n').split('\n');
126
+ const unfolded = [];
127
+ for (const line of raw) {
128
+ if ((line.startsWith(' ') || line.startsWith('\t')) && unfolded.length) {
129
+ unfolded[unfolded.length - 1] += line.slice(1);
130
+ }
131
+ else
132
+ unfolded.push(line);
133
+ }
134
+ const out = [];
135
+ let cur = {};
136
+ let inEvent = false;
137
+ for (const line of unfolded) {
138
+ if (line === 'BEGIN:VEVENT') {
139
+ inEvent = true;
140
+ cur = {};
141
+ continue;
142
+ }
143
+ if (line === 'END:VEVENT') {
144
+ if (cur.title && cur.start && cur.end)
145
+ 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 });
146
+ inEvent = false;
147
+ continue;
148
+ }
149
+ if (!inEvent)
150
+ continue;
151
+ const ci = line.indexOf(':');
152
+ if (ci < 0)
153
+ continue;
154
+ const nameAndParams = line.slice(0, ci);
155
+ const value = line.slice(ci + 1);
156
+ const name = nameAndParams.split(';')[0].toUpperCase();
157
+ if (name === 'UID')
158
+ cur.uid = value;
159
+ else if (name === 'SUMMARY')
160
+ cur.title = unescText(value);
161
+ else if (name === 'DESCRIPTION')
162
+ cur.description = unescText(value);
163
+ else if (name === 'LOCATION')
164
+ cur.location = unescText(value);
165
+ else if (name === 'STATUS')
166
+ cur.status = value.toLowerCase();
167
+ else if (name === 'DTSTART') {
168
+ const { date, allDay } = parseICalDate(value);
169
+ cur.start = date;
170
+ cur.allDay = allDay;
171
+ }
172
+ else if (name === 'DTEND') {
173
+ cur.end = parseICalDate(value).date;
174
+ }
175
+ else if (name === 'RRULE') {
176
+ cur.rrule = rruleToRule(value) ?? undefined;
177
+ }
178
+ }
179
+ return out;
180
+ }