@svgrid/grid 1.2.21 → 1.2.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 (190) hide show
  1. package/dist/GridFooter.svelte +34 -20
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/SvAutoComplete.svelte +100 -0
  4. package/dist/SvAutoComplete.svelte.d.ts +16 -0
  5. package/dist/SvButton.svelte +97 -0
  6. package/dist/SvButton.svelte.d.ts +23 -0
  7. package/dist/SvCalendar.svelte +669 -0
  8. package/dist/SvCalendar.svelte.d.ts +70 -0
  9. package/dist/SvCheckBox.svelte +81 -0
  10. package/dist/SvCheckBox.svelte.d.ts +19 -0
  11. package/dist/SvColorInput.svelte +187 -0
  12. package/dist/SvColorInput.svelte.d.ts +15 -0
  13. package/dist/SvComboBox.svelte +130 -0
  14. package/dist/SvComboBox.svelte.d.ts +15 -0
  15. package/dist/SvCountryInput.svelte +123 -0
  16. package/dist/SvCountryInput.svelte.d.ts +14 -0
  17. package/dist/SvDateTimePicker.svelte +322 -0
  18. package/dist/SvDateTimePicker.svelte.d.ts +37 -0
  19. package/dist/SvDropDownList.svelte +143 -0
  20. package/dist/SvDropDownList.svelte.d.ts +15 -0
  21. package/dist/SvForm.svelte +147 -0
  22. package/dist/SvForm.svelte.d.ts +29 -0
  23. package/dist/SvGauge.svelte +99 -0
  24. package/dist/SvGauge.svelte.d.ts +31 -0
  25. package/dist/SvGrid.controller.svelte.d.ts +16 -2
  26. package/dist/SvGrid.controller.svelte.js +63 -8
  27. package/dist/SvGrid.css +84 -31
  28. package/dist/SvGrid.helpers.d.ts +4 -0
  29. package/dist/SvGrid.helpers.js +40 -12
  30. package/dist/SvGrid.svelte +84 -5
  31. package/dist/SvGrid.types.d.ts +36 -0
  32. package/dist/SvGridDropdown.svelte +16 -30
  33. package/dist/SvGridDropdown.svelte.d.ts +4 -0
  34. package/dist/SvListBox.svelte +122 -0
  35. package/dist/SvListBox.svelte.d.ts +19 -0
  36. package/dist/SvMaskedInput.svelte +0 -0
  37. package/dist/SvMaskedInput.svelte.d.ts +15 -0
  38. package/dist/SvNumberInput.svelte +0 -0
  39. package/dist/SvNumberInput.svelte.d.ts +22 -0
  40. package/dist/SvPasswordInput.svelte +110 -0
  41. package/dist/SvPasswordInput.svelte.d.ts +22 -0
  42. package/dist/SvPhoneInput.svelte +0 -0
  43. package/dist/SvPhoneInput.svelte.d.ts +19 -0
  44. package/dist/SvRadioGroup.svelte +108 -0
  45. package/dist/SvRadioGroup.svelte.d.ts +23 -0
  46. package/dist/SvRating.svelte +111 -0
  47. package/dist/SvRating.svelte.d.ts +20 -0
  48. package/dist/SvRepeatButton.svelte +89 -0
  49. package/dist/SvRepeatButton.svelte.d.ts +21 -0
  50. package/dist/SvSlider.svelte +182 -0
  51. package/dist/SvSlider.svelte.d.ts +25 -0
  52. package/dist/SvSwitchButton.svelte +94 -0
  53. package/dist/SvSwitchButton.svelte.d.ts +19 -0
  54. package/dist/SvTabs.svelte +122 -0
  55. package/dist/SvTabs.svelte.d.ts +26 -0
  56. package/dist/SvTagsInput.svelte +81 -0
  57. package/dist/SvTagsInput.svelte.d.ts +19 -0
  58. package/dist/SvTimePicker.svelte +359 -0
  59. package/dist/SvTimePicker.svelte.d.ts +27 -0
  60. package/dist/SvToggleButton.svelte +57 -0
  61. package/dist/SvToggleButton.svelte.d.ts +16 -0
  62. package/dist/SvTree.svelte +210 -0
  63. package/dist/SvTree.svelte.d.ts +29 -0
  64. package/dist/build-api.js +78 -1
  65. package/dist/cdn/svgrid.js +10022 -5904
  66. package/dist/cdn/svgrid.svelte-external.js +8531 -4429
  67. package/dist/cell-render.d.ts +4 -0
  68. package/dist/cell-render.js +35 -1
  69. package/dist/clipboard.js +19 -1
  70. package/dist/collaboration.d.ts +2 -0
  71. package/dist/collaboration.js +4 -0
  72. package/dist/core.d.ts +22 -1
  73. package/dist/countries.d.ts +15 -0
  74. package/dist/countries.js +61 -0
  75. package/dist/datetime/date-core.d.ts +88 -0
  76. package/dist/datetime/date-core.js +165 -0
  77. package/dist/datetime/date-format.d.ts +39 -0
  78. package/dist/datetime/date-format.js +371 -0
  79. package/dist/datetime/date-restrict.d.ts +23 -0
  80. package/dist/datetime/date-restrict.js +50 -0
  81. package/dist/datetime/date-selection.d.ts +25 -0
  82. package/dist/datetime/date-selection.js +94 -0
  83. package/dist/datetime/mask.d.ts +21 -0
  84. package/dist/datetime/mask.js +90 -0
  85. package/dist/editing.d.ts +1 -1
  86. package/dist/editing.js +7 -0
  87. package/dist/editors/cell-editors.d.ts +2 -2
  88. package/dist/editors/cell-editors.js +3 -1
  89. package/dist/export-format.d.ts +178 -0
  90. package/dist/export-format.js +419 -0
  91. package/dist/hyperformula-adapter.d.ts +4 -0
  92. package/dist/hyperformula-adapter.js +15 -6
  93. package/dist/index.d.ts +36 -0
  94. package/dist/index.js +41 -0
  95. package/dist/list-option.d.ts +10 -0
  96. package/dist/list-option.js +7 -0
  97. package/dist/menus.js +18 -2
  98. package/dist/popover.d.ts +48 -0
  99. package/dist/popover.js +65 -0
  100. package/dist/row-resize.d.ts +4 -4
  101. package/dist/row-resize.js +8 -5
  102. package/dist/selection.d.ts +6 -0
  103. package/dist/selection.js +31 -5
  104. package/dist/server-data-source.d.ts +43 -0
  105. package/dist/server-data-source.js +100 -0
  106. package/dist/sparkline.js +8 -4
  107. package/dist/svgrid-wrapper.types.d.ts +26 -1
  108. package/package.json +6 -1
  109. package/src/GridFooter.svelte +34 -20
  110. package/src/SvAutoComplete.svelte +100 -0
  111. package/src/SvButton.svelte +97 -0
  112. package/src/SvCalendar.svelte +669 -0
  113. package/src/SvCalendar.test.ts +211 -0
  114. package/src/SvCheckBox.svelte +81 -0
  115. package/src/SvColorInput.svelte +187 -0
  116. package/src/SvComboBox.svelte +130 -0
  117. package/src/SvCountryInput.svelte +123 -0
  118. package/src/SvDateTimePicker.svelte +322 -0
  119. package/src/SvDateTimePicker.test.ts +136 -0
  120. package/src/SvDropDownList.svelte +143 -0
  121. package/src/SvForm.svelte +147 -0
  122. package/src/SvGauge.svelte +99 -0
  123. package/src/SvGrid.controller.svelte.ts +74 -8
  124. package/src/SvGrid.css +84 -31
  125. package/src/SvGrid.helpers.ts +41 -12
  126. package/src/SvGrid.svelte +84 -5
  127. package/src/SvGrid.types.ts +33 -0
  128. package/src/SvGridDropdown.svelte +16 -30
  129. package/src/SvListBox.svelte +122 -0
  130. package/src/SvMaskedInput.svelte +0 -0
  131. package/src/SvNumberInput.svelte +0 -0
  132. package/src/SvPasswordInput.svelte +110 -0
  133. package/src/SvPhoneInput.svelte +0 -0
  134. package/src/SvRadioGroup.svelte +108 -0
  135. package/src/SvRating.svelte +111 -0
  136. package/src/SvRepeatButton.svelte +89 -0
  137. package/src/SvSlider.svelte +182 -0
  138. package/src/SvSwitchButton.svelte +94 -0
  139. package/src/SvTabs.svelte +122 -0
  140. package/src/SvTagsInput.svelte +81 -0
  141. package/src/SvTimePicker.svelte +359 -0
  142. package/src/SvTimePicker.test.ts +98 -0
  143. package/src/SvToggleButton.svelte +57 -0
  144. package/src/SvTree.svelte +210 -0
  145. package/src/build-api.ts +115 -0
  146. package/src/cell-render.test.ts +38 -0
  147. package/src/cell-render.ts +43 -0
  148. package/src/clipboard.test.ts +18 -0
  149. package/src/clipboard.ts +24 -1
  150. package/src/collaboration.test.ts +30 -0
  151. package/src/collaboration.ts +6 -0
  152. package/src/core.ts +27 -3
  153. package/src/countries.ts +71 -0
  154. package/src/datetime/date-core.test.ts +217 -0
  155. package/src/datetime/date-core.ts +204 -0
  156. package/src/datetime/date-format.test.ts +121 -0
  157. package/src/datetime/date-format.ts +317 -0
  158. package/src/datetime/date-restrict.ts +60 -0
  159. package/src/datetime/date-selection.test.ts +129 -0
  160. package/src/datetime/date-selection.ts +137 -0
  161. package/src/datetime/mask.test.ts +36 -0
  162. package/src/datetime/mask.ts +84 -0
  163. package/src/editing.test.ts +22 -0
  164. package/src/editing.ts +7 -1
  165. package/src/editors/cell-editors.ts +7 -1
  166. package/src/export-data-api.test.ts +126 -0
  167. package/src/export-format.test.ts +107 -0
  168. package/src/export-format.ts +598 -0
  169. package/src/hyperformula-adapter.test.ts +35 -1
  170. package/src/hyperformula-adapter.ts +18 -6
  171. package/src/index.ts +71 -0
  172. package/src/list-option.ts +15 -0
  173. package/src/menus.ts +21 -2
  174. package/src/popover.ts +79 -0
  175. package/src/row-resize.test.ts +25 -0
  176. package/src/row-resize.ts +8 -5
  177. package/src/selection.test.ts +36 -4
  178. package/src/selection.ts +30 -9
  179. package/src/server-data-source.test.ts +201 -0
  180. package/src/server-data-source.ts +148 -0
  181. package/src/sparkline.test.ts +9 -0
  182. package/src/sparkline.ts +9 -4
  183. package/src/svgrid-wrapper.types.ts +28 -1
  184. package/src/svgrid.behavior.test.ts +27 -0
  185. package/src/svgrid.new-features.wrapper.test.ts +27 -1
  186. package/src/ui-buttons.test.ts +105 -0
  187. package/src/ui-composite.test.ts +89 -0
  188. package/src/ui-inputs.test.ts +92 -0
  189. package/src/ui-range.test.ts +61 -0
  190. package/src/ui-selection.test.ts +106 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * countries - a compact country reference for SvPhoneInput / SvCountryInput.
3
+ * ISO 3166-1 alpha-2 code, display name, E.164 dial code, and a flag emoji
4
+ * (derived from the code so no image assets are needed). This is a curated
5
+ * common-set; extend `COUNTRIES` for full coverage.
6
+ */
7
+ export type Country = { code: string; name: string; dial: string }
8
+
9
+ /** Regional-indicator flag emoji from an ISO alpha-2 code. */
10
+ export function flagEmoji(code: string): string {
11
+ return code
12
+ .toUpperCase()
13
+ .replace(/./g, (c) => String.fromCodePoint(127397 + c.charCodeAt(0)))
14
+ }
15
+
16
+ export const COUNTRIES: Country[] = [
17
+ { code: 'US', name: 'United States', dial: '+1' },
18
+ { code: 'CA', name: 'Canada', dial: '+1' },
19
+ { code: 'GB', name: 'United Kingdom', dial: '+44' },
20
+ { code: 'IE', name: 'Ireland', dial: '+353' },
21
+ { code: 'FR', name: 'France', dial: '+33' },
22
+ { code: 'DE', name: 'Germany', dial: '+49' },
23
+ { code: 'ES', name: 'Spain', dial: '+34' },
24
+ { code: 'PT', name: 'Portugal', dial: '+351' },
25
+ { code: 'IT', name: 'Italy', dial: '+39' },
26
+ { code: 'NL', name: 'Netherlands', dial: '+31' },
27
+ { code: 'BE', name: 'Belgium', dial: '+32' },
28
+ { code: 'CH', name: 'Switzerland', dial: '+41' },
29
+ { code: 'AT', name: 'Austria', dial: '+43' },
30
+ { code: 'SE', name: 'Sweden', dial: '+46' },
31
+ { code: 'NO', name: 'Norway', dial: '+47' },
32
+ { code: 'DK', name: 'Denmark', dial: '+45' },
33
+ { code: 'FI', name: 'Finland', dial: '+358' },
34
+ { code: 'PL', name: 'Poland', dial: '+48' },
35
+ { code: 'CZ', name: 'Czechia', dial: '+420' },
36
+ { code: 'GR', name: 'Greece', dial: '+30' },
37
+ { code: 'BG', name: 'Bulgaria', dial: '+359' },
38
+ { code: 'RO', name: 'Romania', dial: '+40' },
39
+ { code: 'UA', name: 'Ukraine', dial: '+380' },
40
+ { code: 'RU', name: 'Russia', dial: '+7' },
41
+ { code: 'TR', name: 'Turkey', dial: '+90' },
42
+ { code: 'IL', name: 'Israel', dial: '+972' },
43
+ { code: 'AE', name: 'United Arab Emirates', dial: '+971' },
44
+ { code: 'SA', name: 'Saudi Arabia', dial: '+966' },
45
+ { code: 'IN', name: 'India', dial: '+91' },
46
+ { code: 'PK', name: 'Pakistan', dial: '+92' },
47
+ { code: 'CN', name: 'China', dial: '+86' },
48
+ { code: 'HK', name: 'Hong Kong', dial: '+852' },
49
+ { code: 'JP', name: 'Japan', dial: '+81' },
50
+ { code: 'KR', name: 'South Korea', dial: '+82' },
51
+ { code: 'SG', name: 'Singapore', dial: '+65' },
52
+ { code: 'MY', name: 'Malaysia', dial: '+60' },
53
+ { code: 'TH', name: 'Thailand', dial: '+66' },
54
+ { code: 'ID', name: 'Indonesia', dial: '+62' },
55
+ { code: 'PH', name: 'Philippines', dial: '+63' },
56
+ { code: 'VN', name: 'Vietnam', dial: '+84' },
57
+ { code: 'AU', name: 'Australia', dial: '+61' },
58
+ { code: 'NZ', name: 'New Zealand', dial: '+64' },
59
+ { code: 'ZA', name: 'South Africa', dial: '+27' },
60
+ { code: 'NG', name: 'Nigeria', dial: '+234' },
61
+ { code: 'EG', name: 'Egypt', dial: '+20' },
62
+ { code: 'KE', name: 'Kenya', dial: '+254' },
63
+ { code: 'MA', name: 'Morocco', dial: '+212' },
64
+ { code: 'BR', name: 'Brazil', dial: '+55' },
65
+ { code: 'AR', name: 'Argentina', dial: '+54' },
66
+ { code: 'CL', name: 'Chile', dial: '+56' },
67
+ { code: 'CO', name: 'Colombia', dial: '+57' },
68
+ { code: 'MX', name: 'Mexico', dial: '+52' },
69
+ ]
70
+
71
+ export const COUNTRY_BY_CODE = new Map(COUNTRIES.map((c) => [c.code, c]))
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Unit tests for date-core math. These are written to be *discriminating*: each
3
+ * asserts a value that a plausible mutation (off-by-one, wrong operator, dropped
4
+ * clamp) would break - not just "does not throw".
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import {
8
+ toDate,
9
+ startOfDay,
10
+ startOfMonth,
11
+ endOfMonth,
12
+ daysInMonth,
13
+ startOfWeek,
14
+ isSameDay,
15
+ isSameMonth,
16
+ compareDay,
17
+ addDays,
18
+ addMonths,
19
+ addYears,
20
+ clampDate,
21
+ isoWeek,
22
+ decadeRange,
23
+ centuryRange,
24
+ monthMatrix,
25
+ weekdayOrder,
26
+ withTime,
27
+ snapMinute,
28
+ } from './date-core'
29
+
30
+ describe('toDate', () => {
31
+ it('passes through valid Date/number/string and clones Dates', () => {
32
+ const d = new Date(2026, 5, 15)
33
+ const out = toDate(d)!
34
+ expect(out.getTime()).toBe(d.getTime())
35
+ expect(out).not.toBe(d) // clone, not same ref
36
+ expect(toDate(d.getTime())!.getTime()).toBe(d.getTime())
37
+ expect(toDate('2026-06-15T00:00:00')!.getFullYear()).toBe(2026)
38
+ })
39
+ it('returns null for null/undefined/invalid', () => {
40
+ expect(toDate(null)).toBeNull()
41
+ expect(toDate(undefined)).toBeNull()
42
+ expect(toDate('not-a-date')).toBeNull()
43
+ expect(toDate(NaN)).toBeNull()
44
+ })
45
+ })
46
+
47
+ describe('startOfDay / month boundaries', () => {
48
+ it('strips time to local midnight', () => {
49
+ const d = startOfDay(new Date(2026, 2, 9, 13, 45, 30, 500))
50
+ expect([d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()]).toEqual([0, 0, 0, 0])
51
+ expect(d.getDate()).toBe(9)
52
+ })
53
+ it('startOfMonth / endOfMonth / daysInMonth', () => {
54
+ const mid = new Date(2026, 1, 14) // Feb 2026 (28 days)
55
+ expect(startOfMonth(mid).getDate()).toBe(1)
56
+ expect(endOfMonth(mid).getDate()).toBe(28)
57
+ expect(daysInMonth(mid)).toBe(28)
58
+ expect(daysInMonth(new Date(2024, 1, 1))).toBe(29) // leap year
59
+ expect(daysInMonth(new Date(2026, 0, 1))).toBe(31)
60
+ })
61
+ })
62
+
63
+ describe('startOfWeek', () => {
64
+ it('honors firstDayOfWeek=0 (Sunday)', () => {
65
+ // 2026-06-15 is a Monday.
66
+ const s = startOfWeek(new Date(2026, 5, 15), 0)
67
+ expect(s.getDay()).toBe(0)
68
+ expect(s.getDate()).toBe(14) // the Sunday before
69
+ })
70
+ it('honors firstDayOfWeek=1 (Monday)', () => {
71
+ const s = startOfWeek(new Date(2026, 5, 15), 1)
72
+ expect(s.getDay()).toBe(1)
73
+ expect(s.getDate()).toBe(15) // Monday itself
74
+ })
75
+ it('wraps correctly when day < firstDayOfWeek', () => {
76
+ // Sunday 2026-06-14 with week starting Monday -> previous Monday (Jun 8)
77
+ const s = startOfWeek(new Date(2026, 5, 14), 1)
78
+ expect(s.getDate()).toBe(8)
79
+ })
80
+ })
81
+
82
+ describe('sameness + comparison', () => {
83
+ it('isSameDay ignores time, isSameMonth ignores day', () => {
84
+ expect(isSameDay(new Date(2026, 5, 15, 9), new Date(2026, 5, 15, 23))).toBe(true)
85
+ expect(isSameDay(new Date(2026, 5, 15), new Date(2026, 5, 16))).toBe(false)
86
+ expect(isSameDay(null, new Date())).toBe(false)
87
+ expect(isSameMonth(new Date(2026, 5, 1), new Date(2026, 5, 30))).toBe(true)
88
+ expect(isSameMonth(new Date(2026, 5, 30), new Date(2026, 6, 1))).toBe(false)
89
+ })
90
+ it('compareDay returns -1/0/1 by day', () => {
91
+ expect(compareDay(new Date(2026, 5, 15, 23), new Date(2026, 5, 15, 1))).toBe(0)
92
+ expect(compareDay(new Date(2026, 5, 14), new Date(2026, 5, 15))).toBe(-1)
93
+ expect(compareDay(new Date(2026, 5, 16), new Date(2026, 5, 15))).toBe(1)
94
+ })
95
+ })
96
+
97
+ describe('addDays / addMonths / addYears clamping', () => {
98
+ it('addDays crosses month boundary', () => {
99
+ expect(addDays(new Date(2026, 0, 31), 1).getMonth()).toBe(1) // -> Feb 1
100
+ expect(addDays(new Date(2026, 5, 15), -20).getMonth()).toBe(4) // -> May
101
+ })
102
+ it('addMonths clamps day-of-month (Jan 31 + 1mo = Feb 28)', () => {
103
+ const r = addMonths(new Date(2026, 0, 31), 1)
104
+ expect(r.getMonth()).toBe(1)
105
+ expect(r.getDate()).toBe(28) // NOT March 3
106
+ })
107
+ it('addMonths preserves time-of-day', () => {
108
+ const r = addMonths(new Date(2026, 0, 15, 8, 30), 2)
109
+ expect([r.getHours(), r.getMinutes()]).toEqual([8, 30])
110
+ expect(r.getMonth()).toBe(2)
111
+ })
112
+ it('addYears clamps Feb 29 -> Feb 28 on non-leap target', () => {
113
+ const r = addYears(new Date(2024, 1, 29), 1)
114
+ expect(r.getFullYear()).toBe(2025)
115
+ expect(r.getMonth()).toBe(1)
116
+ expect(r.getDate()).toBe(28)
117
+ })
118
+ })
119
+
120
+ describe('clampDate', () => {
121
+ const min = new Date(2026, 0, 1)
122
+ const max = new Date(2026, 11, 31)
123
+ it('clamps below min and above max, passes through inside', () => {
124
+ expect(clampDate(new Date(2025, 5, 1), min, max).getTime()).toBe(min.getTime())
125
+ expect(clampDate(new Date(2027, 5, 1), min, max).getTime()).toBe(max.getTime())
126
+ const inside = new Date(2026, 5, 1)
127
+ expect(clampDate(inside, min, max).getTime()).toBe(inside.getTime())
128
+ })
129
+ it('treats null bounds as open', () => {
130
+ const d = new Date(1000, 0, 1)
131
+ expect(clampDate(d, null, null).getTime()).toBe(d.getTime())
132
+ })
133
+ })
134
+
135
+ describe('isoWeek', () => {
136
+ it('matches known ISO week numbers', () => {
137
+ // 2026-01-01 is a Thursday -> ISO week 1.
138
+ expect(isoWeek(new Date(2026, 0, 1))).toBe(1)
139
+ // 2026-06-15 (Mon) -> ISO week 25.
140
+ expect(isoWeek(new Date(2026, 5, 15))).toBe(25)
141
+ // 2027-01-01 is a Friday -> belongs to ISO week 53 of 2026.
142
+ expect(isoWeek(new Date(2027, 0, 1))).toBe(53)
143
+ // 2024-12-30 (Mon) -> ISO week 1 of 2025.
144
+ expect(isoWeek(new Date(2024, 11, 30))).toBe(1)
145
+ })
146
+ })
147
+
148
+ describe('decadeRange / centuryRange', () => {
149
+ it('decade block anchors to the base-10 start', () => {
150
+ expect(decadeRange(2026)).toEqual({ start: 2020, end: 2029 })
151
+ expect(decadeRange(2020)).toEqual({ start: 2020, end: 2029 })
152
+ expect(decadeRange(2019)).toEqual({ start: 2010, end: 2019 })
153
+ })
154
+ it('century block anchors to the base-100 start', () => {
155
+ expect(centuryRange(2026)).toEqual({ start: 2000, end: 2099 })
156
+ expect(centuryRange(1999)).toEqual({ start: 1900, end: 1999 })
157
+ })
158
+ })
159
+
160
+ describe('monthMatrix', () => {
161
+ it('produces weeks x 7 cells starting on firstDayOfWeek', () => {
162
+ const m = monthMatrix(new Date(2026, 5, 15), 0, 6) // June 2026, Sunday-first
163
+ expect(m.length).toBe(6)
164
+ expect(m.every((row) => row.length === 7)).toBe(true)
165
+ // Every first cell of a row is the configured first weekday.
166
+ expect(m.every((row) => row[0]!.date.getDay() === 0)).toBe(true)
167
+ // June 1 2026 is a Monday, so the grid starts on Sunday May 31.
168
+ expect(m[0]![0]!.date.getMonth()).toBe(4)
169
+ expect(m[0]![0]!.date.getDate()).toBe(31)
170
+ expect(m[0]![0]!.inMonth).toBe(false)
171
+ expect(m[0]![1]!.inMonth).toBe(true) // June 1
172
+ expect(m[0]![1]!.date.getDate()).toBe(1)
173
+ })
174
+ it('honors firstDayOfWeek=1 (Monday-first grid)', () => {
175
+ const m = monthMatrix(new Date(2026, 5, 15), 1, 6)
176
+ expect(m.every((row) => row[0]!.date.getDay() === 1)).toBe(true)
177
+ // June 1 2026 is Monday, so the first cell IS June 1.
178
+ expect(m[0]![0]!.date.getDate()).toBe(1)
179
+ expect(m[0]![0]!.inMonth).toBe(true)
180
+ })
181
+ it('tags cells with an ISO week number', () => {
182
+ const m = monthMatrix(new Date(2026, 0, 1), 1, 6)
183
+ expect(m[0]![0]!.week).toBe(isoWeek(m[0]![0]!.date))
184
+ })
185
+ })
186
+
187
+ describe('weekdayOrder', () => {
188
+ it('rotates the 0-6 sequence by firstDayOfWeek', () => {
189
+ expect(weekdayOrder(0)).toEqual([0, 1, 2, 3, 4, 5, 6])
190
+ expect(weekdayOrder(1)).toEqual([1, 2, 3, 4, 5, 6, 0])
191
+ expect(weekdayOrder(6)).toEqual([6, 0, 1, 2, 3, 4, 5])
192
+ })
193
+ })
194
+
195
+ describe('withTime', () => {
196
+ it('grafts time-of-day onto a calendar day', () => {
197
+ const day = new Date(2026, 5, 15)
198
+ const time = new Date(2000, 0, 1, 14, 25, 9, 123)
199
+ const r = withTime(day, time)
200
+ expect([r.getFullYear(), r.getMonth(), r.getDate()]).toEqual([2026, 5, 15])
201
+ expect([r.getHours(), r.getMinutes(), r.getSeconds(), r.getMilliseconds()]).toEqual([14, 25, 9, 123])
202
+ })
203
+ })
204
+
205
+ describe('snapMinute', () => {
206
+ it('snaps down to the nearest interval multiple', () => {
207
+ expect(snapMinute(0, 15)).toBe(0)
208
+ expect(snapMinute(14, 15)).toBe(0)
209
+ expect(snapMinute(15, 15)).toBe(15)
210
+ expect(snapMinute(59, 15)).toBe(45)
211
+ expect(snapMinute(37, 5)).toBe(35)
212
+ })
213
+ it('interval < 1 behaves as 1 (no snapping)', () => {
214
+ expect(snapMinute(37, 0)).toBe(37)
215
+ expect(snapMinute(37, 1)).toBe(37)
216
+ })
217
+ })
@@ -0,0 +1,204 @@
1
+ /**
2
+ * date-core - framework-free date math shared by SvCalendar, SvTimePicker and
3
+ * SvDateTimePicker. No Svelte, no DOM: pure functions over the native `Date`
4
+ * so it is trivially unit- and mutation-testable and reusable standalone.
5
+ *
6
+ * Conventions:
7
+ * - A "day" is compared by local calendar date (year/month/date), ignoring the
8
+ * time-of-day, unless a helper says otherwise.
9
+ * - `firstDayOfWeek` is 0-6 (0 = Sunday), matching `Date.getDay()` and Smart's
10
+ * `firstDayOfWeek`.
11
+ */
12
+
13
+ export type DateLike = Date | number | string
14
+
15
+ /** Coerce a Date | epoch-ms | parseable string to a Date, or null if invalid. */
16
+ export function toDate(value: DateLike | null | undefined): Date | null {
17
+ if (value == null) return null
18
+ const d = value instanceof Date ? new Date(value.getTime()) : new Date(value)
19
+ return isNaN(d.getTime()) ? null : d
20
+ }
21
+
22
+ /** A new Date at local midnight of the same calendar day (time stripped). */
23
+ export function startOfDay(d: Date): Date {
24
+ return new Date(d.getFullYear(), d.getMonth(), d.getDate())
25
+ }
26
+
27
+ /** First day (midnight) of the month `d` falls in. */
28
+ export function startOfMonth(d: Date): Date {
29
+ return new Date(d.getFullYear(), d.getMonth(), 1)
30
+ }
31
+
32
+ /** Last day (midnight) of the month `d` falls in. */
33
+ export function endOfMonth(d: Date): Date {
34
+ return new Date(d.getFullYear(), d.getMonth() + 1, 0)
35
+ }
36
+
37
+ /** Number of days in the month `d` falls in. */
38
+ export function daysInMonth(d: Date): number {
39
+ return endOfMonth(d).getDate()
40
+ }
41
+
42
+ /**
43
+ * Start of the week containing `d`, honoring `firstDayOfWeek` (0-6). Returns a
44
+ * local-midnight Date on or before `d`.
45
+ */
46
+ export function startOfWeek(d: Date, firstDayOfWeek = 0): Date {
47
+ const start = startOfDay(d)
48
+ const diff = (start.getDay() - firstDayOfWeek + 7) % 7
49
+ start.setDate(start.getDate() - diff)
50
+ return start
51
+ }
52
+
53
+ /** True when both dates are the same local calendar day. */
54
+ export function isSameDay(a: Date | null, b: Date | null): boolean {
55
+ if (!a || !b) return false
56
+ return (
57
+ a.getFullYear() === b.getFullYear() &&
58
+ a.getMonth() === b.getMonth() &&
59
+ a.getDate() === b.getDate()
60
+ )
61
+ }
62
+
63
+ /** True when both dates are in the same year+month. */
64
+ export function isSameMonth(a: Date | null, b: Date | null): boolean {
65
+ if (!a || !b) return false
66
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth()
67
+ }
68
+
69
+ /** -1 / 0 / 1 comparison by calendar day (time-of-day ignored). */
70
+ export function compareDay(a: Date, b: Date): number {
71
+ const av = startOfDay(a).getTime()
72
+ const bv = startOfDay(b).getTime()
73
+ return av < bv ? -1 : av > bv ? 1 : 0
74
+ }
75
+
76
+ /** Add `n` days (can be negative), returning a new Date. */
77
+ export function addDays(d: Date, n: number): Date {
78
+ const r = new Date(d.getTime())
79
+ r.setDate(r.getDate() + n)
80
+ return r
81
+ }
82
+
83
+ /**
84
+ * Add `n` calendar months, clamping the day-of-month so 31 Jan + 1 month = 28/29
85
+ * Feb rather than spilling into March (matches how calendars paginate).
86
+ */
87
+ export function addMonths(d: Date, n: number): Date {
88
+ const day = d.getDate()
89
+ const r = new Date(d.getFullYear(), d.getMonth() + n, 1, d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds())
90
+ r.setDate(Math.min(day, daysInMonth(r)))
91
+ return r
92
+ }
93
+
94
+ /** Add `n` years, clamping Feb-29 to Feb-28 on non-leap targets. */
95
+ export function addYears(d: Date, n: number): Date {
96
+ return addMonths(d, n * 12)
97
+ }
98
+
99
+ /** Clamp `d` to the inclusive [min, max] range (either bound may be null). */
100
+ export function clampDate(d: Date, min: Date | null, max: Date | null): Date {
101
+ if (min && d.getTime() < min.getTime()) return new Date(min.getTime())
102
+ if (max && d.getTime() > max.getTime()) return new Date(max.getTime())
103
+ return d
104
+ }
105
+
106
+ /**
107
+ * ISO 8601 week number (1-53) for `d`. Week 1 is the week containing the first
108
+ * Thursday of the year; weeks start Monday. Independent of `firstDayOfWeek`,
109
+ * matching Smart's `weekNumbers` column which is always ISO.
110
+ */
111
+ export function isoWeek(d: Date): number {
112
+ // Shift to the Thursday of this week, then count weeks from Jan 1.
113
+ const date = startOfDay(d)
114
+ const day = (date.getDay() + 6) % 7 // Mon=0..Sun=6
115
+ date.setDate(date.getDate() - day + 3) // Thursday of this week
116
+ const firstThursday = new Date(date.getFullYear(), 0, 4)
117
+ const firstDay = (firstThursday.getDay() + 6) % 7
118
+ firstThursday.setDate(firstThursday.getDate() - firstDay + 3)
119
+ const diffMs = date.getTime() - firstThursday.getTime()
120
+ return 1 + Math.round(diffMs / (7 * 24 * 3600 * 1000))
121
+ }
122
+
123
+ /**
124
+ * The [start, end] years of the decade block that `year` belongs to, as Smart
125
+ * renders it: a 12-cell page running from the decade start minus 1 to plus 10
126
+ * (one leading + one trailing year for context). We expose the canonical
127
+ * decade [Y0, Y0+9] and let the view pad.
128
+ */
129
+ export function decadeRange(year: number): { start: number; end: number } {
130
+ const start = Math.floor(year / 10) * 10
131
+ return { start, end: start + 9 }
132
+ }
133
+
134
+ /** The [start, end] years of the century block (100-year) `year` belongs to. */
135
+ export function centuryRange(year: number): { start: number; end: number } {
136
+ const start = Math.floor(year / 100) * 100
137
+ return { start, end: start + 99 }
138
+ }
139
+
140
+ export type MonthMatrixCell = {
141
+ date: Date
142
+ /** In the displayed month (vs. leading/trailing days of adjacent months). */
143
+ inMonth: boolean
144
+ /** ISO week number of the row this cell sits in. */
145
+ week: number
146
+ }
147
+
148
+ /**
149
+ * Build a month grid of `weeks` rows x 7 columns starting at `firstDayOfWeek`,
150
+ * padded with the trailing days of the previous month and leading days of the
151
+ * next so every row is full. `weeks` defaults to 6 (the max any month needs),
152
+ * matching Smart's fixed-height calendar.
153
+ */
154
+ export function monthMatrix(
155
+ monthDate: Date,
156
+ firstDayOfWeek = 0,
157
+ weeks = 6,
158
+ ): MonthMatrixCell[][] {
159
+ const first = startOfMonth(monthDate)
160
+ const gridStart = startOfWeek(first, firstDayOfWeek)
161
+ const rows: MonthMatrixCell[][] = []
162
+ let cursor = gridStart
163
+ for (let r = 0; r < weeks; r++) {
164
+ const row: MonthMatrixCell[] = []
165
+ const weekNo = isoWeek(cursor)
166
+ for (let c = 0; c < 7; c++) {
167
+ row.push({
168
+ date: cursor,
169
+ inMonth: cursor.getMonth() === first.getMonth() && cursor.getFullYear() === first.getFullYear(),
170
+ week: weekNo,
171
+ })
172
+ cursor = addDays(cursor, 1)
173
+ }
174
+ rows.push(row)
175
+ }
176
+ return rows
177
+ }
178
+
179
+ /**
180
+ * Ordered weekday indexes (0-6) for a header row starting at `firstDayOfWeek`.
181
+ * e.g. firstDayOfWeek=1 -> [1,2,3,4,5,6,0].
182
+ */
183
+ export function weekdayOrder(firstDayOfWeek = 0): number[] {
184
+ return Array.from({ length: 7 }, (_, i) => (firstDayOfWeek + i) % 7)
185
+ }
186
+
187
+ /** Merge a calendar day with a time-of-day taken from `time`, returning a new Date. */
188
+ export function withTime(day: Date, time: Date): Date {
189
+ return new Date(
190
+ day.getFullYear(),
191
+ day.getMonth(),
192
+ day.getDate(),
193
+ time.getHours(),
194
+ time.getMinutes(),
195
+ time.getSeconds(),
196
+ time.getMilliseconds(),
197
+ )
198
+ }
199
+
200
+ /** Snap a minute value to the nearest lower multiple of `interval` (>=1). */
201
+ export function snapMinute(minute: number, interval: number): number {
202
+ const step = Math.max(1, Math.floor(interval))
203
+ return Math.floor(minute / step) * step
204
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Unit tests for the date-format token engine. Emphasis on:
3
+ * - each token formatting exactly (a mutated case label would break these)
4
+ * - format -> parse -> format round-trips across masks and locales
5
+ * - parse rejecting malformed / impossible input (not silently rolling over)
6
+ * - yearCutoff pivot for 2-digit years
7
+ */
8
+ import { describe, expect, it } from 'vitest'
9
+ import { formatDate, parseDate, tokenizeMask } from './date-format'
10
+
11
+ const D = new Date(2026, 5, 7, 9, 4, 3, 50) // 2026-06-07 09:04:03.050 (a Sunday)
12
+
13
+ describe('tokenizeMask', () => {
14
+ it('splits tokens from literals and honors quotes + escapes', () => {
15
+ expect(tokenizeMask('yyyy-MM-dd')).toEqual([
16
+ { token: 'yyyy' }, { literal: '-' }, { token: 'MM' }, { literal: '-' }, { token: 'dd' },
17
+ ])
18
+ expect(tokenizeMask("yyyy'T'HH")).toEqual([{ token: 'yyyy' }, { literal: 'T' }, { token: 'HH' }])
19
+ expect(tokenizeMask('HH\\h mm')).toEqual([
20
+ { token: 'HH' }, { literal: 'h' }, { literal: ' ' }, { token: 'mm' },
21
+ ])
22
+ })
23
+ })
24
+
25
+ describe('formatDate - individual tokens', () => {
26
+ it('day / month / year widths', () => {
27
+ expect(formatDate(D, 'd')).toBe('7')
28
+ expect(formatDate(D, 'dd')).toBe('07')
29
+ expect(formatDate(D, 'M')).toBe('6')
30
+ expect(formatDate(D, 'MM')).toBe('06')
31
+ expect(formatDate(D, 'yy')).toBe('26')
32
+ expect(formatDate(D, 'yyyy')).toBe('2026')
33
+ })
34
+ it('24h vs 12h hours', () => {
35
+ expect(formatDate(D, 'H')).toBe('9')
36
+ expect(formatDate(D, 'HH')).toBe('09')
37
+ expect(formatDate(D, 'h')).toBe('9')
38
+ expect(formatDate(new Date(2026, 0, 1, 13), 'h')).toBe('1')
39
+ expect(formatDate(new Date(2026, 0, 1, 0), 'hh')).toBe('12') // midnight -> 12
40
+ })
41
+ it('minute / second / fractional seconds', () => {
42
+ expect(formatDate(D, 'mm')).toBe('04')
43
+ expect(formatDate(D, 'ss')).toBe('03')
44
+ expect(formatDate(D, 'fff')).toBe('050')
45
+ expect(formatDate(D, 'ff')).toBe('05')
46
+ expect(formatDate(D, 'f')).toBe('0')
47
+ })
48
+ it('AM/PM designator (en-US)', () => {
49
+ expect(formatDate(new Date(2026, 0, 1, 9), 'tt', 'en-US')).toBe('AM')
50
+ expect(formatDate(new Date(2026, 0, 1, 21), 'tt', 'en-US')).toBe('PM')
51
+ expect(formatDate(new Date(2026, 0, 1, 21), 't', 'en-US')).toBe('P')
52
+ })
53
+ it('localized month + weekday names', () => {
54
+ expect(formatDate(D, 'MMM', 'en-US')).toBe('Jun')
55
+ expect(formatDate(D, 'MMMM', 'en-US')).toBe('June')
56
+ expect(formatDate(D, 'dddd', 'en-US')).toBe('Sunday')
57
+ // German month name (partial to survive ICU differences).
58
+ expect(formatDate(D, 'MMMM', 'de-DE').toLowerCase()).toContain('juni')
59
+ })
60
+ })
61
+
62
+ describe('formatDate - full masks', () => {
63
+ it('Smart default-style masks', () => {
64
+ expect(formatDate(D, 'dd-MMM-yy HH:mm:ss.fff', 'en-US')).toBe('07-Jun-26 09:04:03.050')
65
+ expect(formatDate(D, 'yyyy-MM-dd')).toBe('2026-06-07')
66
+ expect(formatDate(new Date(2026, 0, 1, 13, 5), 'hh:mm tt', 'en-US')).toBe('01:05 PM')
67
+ })
68
+ })
69
+
70
+ describe('parseDate', () => {
71
+ it('parses a full datetime mask', () => {
72
+ const d = parseDate('07-Jun-26 09:04:03.050', 'dd-MMM-yy HH:mm:ss.fff', 'en-US')!
73
+ expect(d.getFullYear()).toBe(2026)
74
+ expect(d.getMonth()).toBe(5)
75
+ expect(d.getDate()).toBe(7)
76
+ expect([d.getHours(), d.getMinutes(), d.getSeconds(), d.getMilliseconds()]).toEqual([9, 4, 3, 50])
77
+ })
78
+ it('resolves 12h + PM into 24h', () => {
79
+ const d = parseDate('01:05 PM', 'hh:mm tt', 'en-US')!
80
+ expect(d.getHours()).toBe(13)
81
+ expect(d.getMinutes()).toBe(5)
82
+ const am = parseDate('12:30 AM', 'hh:mm tt', 'en-US')!
83
+ expect(am.getHours()).toBe(0) // 12 AM -> 00
84
+ })
85
+ it('applies yearCutoff pivot for 2-digit years', () => {
86
+ // cutoff 2029 -> pivot 29: '29' -> 2029, '30' -> 1930
87
+ expect(parseDate('29', 'yy', 'en-US', 2029)!.getFullYear()).toBe(2029)
88
+ expect(parseDate('30', 'yy', 'en-US', 2029)!.getFullYear()).toBe(1930)
89
+ })
90
+ it('rejects malformed and impossible input', () => {
91
+ expect(parseDate('2026/06/07', 'yyyy-MM-dd')).toBeNull() // wrong separators
92
+ expect(parseDate('2026-13-01', 'yyyy-MM-dd')).toBeNull() // month 13
93
+ expect(parseDate('2026-02-31', 'yyyy-MM-dd')).toBeNull() // Feb 31 rollover
94
+ expect(parseDate('', 'yyyy-MM-dd')).toBeNull()
95
+ expect(parseDate('2026-06-07 extra', 'yyyy-MM-dd')).toBeNull() // trailing junk
96
+ })
97
+ it('accepts trailing whitespace and flexible spaces', () => {
98
+ expect(parseDate('2026-06-07 ', 'yyyy-MM-dd')).not.toBeNull()
99
+ })
100
+ })
101
+
102
+ describe('round-trip: format -> parse -> format', () => {
103
+ const masks = ['yyyy-MM-dd', 'dd-MMM-yy HH:mm:ss.fff', 'dd/MM/yyyy HH:mm', 'hh:mm:ss tt', 'MMMM d, yyyy']
104
+ const samples = [
105
+ new Date(2026, 5, 7, 9, 4, 3, 50),
106
+ new Date(2000, 0, 1, 0, 0, 0, 0),
107
+ new Date(1999, 11, 31, 23, 59, 59, 999),
108
+ new Date(2024, 1, 29, 13, 30, 0, 0), // leap day, PM
109
+ ]
110
+ for (const mask of masks) {
111
+ for (const sample of samples) {
112
+ it(`stable for "${mask}" @ ${sample.toISOString()}`, () => {
113
+ const s1 = formatDate(sample, mask, 'en-US')
114
+ const parsed = parseDate(s1, mask, 'en-US', 2029, sample)
115
+ expect(parsed).not.toBeNull()
116
+ const s2 = formatDate(parsed!, mask, 'en-US')
117
+ expect(s2).toBe(s1)
118
+ })
119
+ }
120
+ }
121
+ })