@svgrid/grid 1.2.22 → 1.2.24

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 (263) hide show
  1. package/dist/GridFooter.svelte +34 -20
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/SvAutoComplete.svelte +89 -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 +409 -0
  8. package/dist/SvCalendar.svelte.d.ts +52 -0
  9. package/dist/SvCheckBox.svelte +82 -0
  10. package/dist/SvCheckBox.svelte.d.ts +19 -0
  11. package/dist/SvColorInput.svelte +160 -0
  12. package/dist/SvColorInput.svelte.d.ts +15 -0
  13. package/dist/SvComboBox.svelte +108 -0
  14. package/dist/SvComboBox.svelte.d.ts +15 -0
  15. package/dist/SvCountryInput.svelte +125 -0
  16. package/dist/SvCountryInput.svelte.d.ts +14 -0
  17. package/dist/SvDateTimePicker.svelte +265 -0
  18. package/dist/SvDateTimePicker.svelte.d.ts +38 -0
  19. package/dist/SvDropDownList.svelte +120 -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 +97 -0
  24. package/dist/SvGauge.svelte.d.ts +26 -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 +89 -0
  35. package/dist/SvListBox.svelte.d.ts +22 -0
  36. package/dist/SvMaskedInput.svelte +66 -0
  37. package/dist/SvMaskedInput.svelte.d.ts +15 -0
  38. package/dist/SvNumberInput.svelte +116 -0
  39. package/dist/SvNumberInput.svelte.d.ts +18 -0
  40. package/dist/SvPasswordInput.svelte +100 -0
  41. package/dist/SvPasswordInput.svelte.d.ts +18 -0
  42. package/dist/SvPhoneInput.svelte +87 -0
  43. package/dist/SvPhoneInput.svelte.d.ts +19 -0
  44. package/dist/SvRadioGroup.svelte +85 -0
  45. package/dist/SvRadioGroup.svelte.d.ts +20 -0
  46. package/dist/SvRating.svelte +91 -0
  47. package/dist/SvRating.svelte.d.ts +18 -0
  48. package/dist/SvRepeatButton.svelte +89 -0
  49. package/dist/SvRepeatButton.svelte.d.ts +21 -0
  50. package/dist/SvSlider.svelte +155 -0
  51. package/dist/SvSlider.svelte.d.ts +20 -0
  52. package/dist/SvSwitchButton.svelte +87 -0
  53. package/dist/SvSwitchButton.svelte.d.ts +14 -0
  54. package/dist/SvTabs.svelte +109 -0
  55. package/dist/SvTabs.svelte.d.ts +26 -0
  56. package/dist/SvTagsInput.svelte +70 -0
  57. package/dist/SvTagsInput.svelte.d.ts +14 -0
  58. package/dist/SvTimePicker.svelte +201 -0
  59. package/dist/SvTimePicker.svelte.d.ts +30 -0
  60. package/dist/SvToggleButton.svelte +57 -0
  61. package/dist/SvToggleButton.svelte.d.ts +16 -0
  62. package/dist/SvTree.svelte +131 -0
  63. package/dist/SvTree.svelte.d.ts +27 -0
  64. package/dist/build-api.js +78 -1
  65. package/dist/cdn/svgrid.js +11674 -5907
  66. package/dist/cdn/svgrid.svelte-external.js +10436 -4671
  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/createAutocomplete.svelte.d.ts +77 -0
  76. package/dist/createAutocomplete.svelte.js +100 -0
  77. package/dist/createCalendar.svelte.d.ts +192 -0
  78. package/dist/createCalendar.svelte.js +402 -0
  79. package/dist/createCheckbox.svelte.d.ts +45 -0
  80. package/dist/createCheckbox.svelte.js +26 -0
  81. package/dist/createColorInput.svelte.d.ts +67 -0
  82. package/dist/createColorInput.svelte.js +100 -0
  83. package/dist/createCombobox.svelte.d.ts +102 -0
  84. package/dist/createCombobox.svelte.js +171 -0
  85. package/dist/createCountryInput.svelte.d.ts +86 -0
  86. package/dist/createCountryInput.svelte.js +126 -0
  87. package/dist/createDateTimePicker.svelte.d.ts +83 -0
  88. package/dist/createDateTimePicker.svelte.js +202 -0
  89. package/dist/createDropdownList.svelte.d.ts +78 -0
  90. package/dist/createDropdownList.svelte.js +117 -0
  91. package/dist/createGauge.svelte.d.ts +56 -0
  92. package/dist/createGauge.svelte.js +49 -0
  93. package/dist/createListbox.svelte.d.ts +79 -0
  94. package/dist/createListbox.svelte.js +117 -0
  95. package/dist/createMaskedInput.svelte.d.ts +30 -0
  96. package/dist/createMaskedInput.svelte.js +62 -0
  97. package/dist/createNumberInput.svelte.d.ts +72 -0
  98. package/dist/createNumberInput.svelte.js +167 -0
  99. package/dist/createPasswordInput.svelte.d.ts +60 -0
  100. package/dist/createPasswordInput.svelte.js +72 -0
  101. package/dist/createPhoneInput.svelte.d.ts +46 -0
  102. package/dist/createPhoneInput.svelte.js +105 -0
  103. package/dist/createRadioGroup.svelte.d.ts +64 -0
  104. package/dist/createRadioGroup.svelte.js +73 -0
  105. package/dist/createRating.svelte.d.ts +70 -0
  106. package/dist/createRating.svelte.js +85 -0
  107. package/dist/createSlider.svelte.d.ts +71 -0
  108. package/dist/createSlider.svelte.js +123 -0
  109. package/dist/createSwitch.svelte.d.ts +43 -0
  110. package/dist/createSwitch.svelte.js +41 -0
  111. package/dist/createTabs.svelte.d.ts +78 -0
  112. package/dist/createTabs.svelte.js +92 -0
  113. package/dist/createTagsInput.svelte.d.ts +65 -0
  114. package/dist/createTagsInput.svelte.js +93 -0
  115. package/dist/createTimePicker.svelte.d.ts +106 -0
  116. package/dist/createTimePicker.svelte.js +248 -0
  117. package/dist/createToggle.svelte.d.ts +43 -0
  118. package/dist/createToggle.svelte.js +39 -0
  119. package/dist/createTree.svelte.d.ts +96 -0
  120. package/dist/createTree.svelte.js +194 -0
  121. package/dist/datetime/date-core.d.ts +88 -0
  122. package/dist/datetime/date-core.js +165 -0
  123. package/dist/datetime/date-format.d.ts +39 -0
  124. package/dist/datetime/date-format.js +371 -0
  125. package/dist/datetime/date-restrict.d.ts +23 -0
  126. package/dist/datetime/date-restrict.js +50 -0
  127. package/dist/datetime/date-selection.d.ts +25 -0
  128. package/dist/datetime/date-selection.js +94 -0
  129. package/dist/datetime/mask.d.ts +21 -0
  130. package/dist/datetime/mask.js +90 -0
  131. package/dist/editing.d.ts +1 -1
  132. package/dist/editing.js +7 -0
  133. package/dist/editor-contract.d.ts +48 -0
  134. package/dist/editor-contract.js +26 -0
  135. package/dist/editors/cell-editors.d.ts +2 -2
  136. package/dist/editors/cell-editors.js +3 -1
  137. package/dist/export-format.d.ts +178 -0
  138. package/dist/export-format.js +419 -0
  139. package/dist/hyperformula-adapter.d.ts +4 -0
  140. package/dist/hyperformula-adapter.js +15 -6
  141. package/dist/index.d.ts +60 -0
  142. package/dist/index.js +72 -0
  143. package/dist/list-option.d.ts +10 -0
  144. package/dist/list-option.js +7 -0
  145. package/dist/menus.js +18 -2
  146. package/dist/popover.d.ts +48 -0
  147. package/dist/popover.js +65 -0
  148. package/dist/row-resize.d.ts +4 -4
  149. package/dist/row-resize.js +8 -5
  150. package/dist/selection.d.ts +6 -0
  151. package/dist/selection.js +31 -5
  152. package/dist/server-data-source.d.ts +43 -0
  153. package/dist/server-data-source.js +100 -0
  154. package/dist/sparkline.js +8 -4
  155. package/dist/svgrid-wrapper.types.d.ts +26 -1
  156. package/package.json +6 -1
  157. package/src/GridFooter.svelte +34 -20
  158. package/src/SvAutoComplete.svelte +89 -0
  159. package/src/SvButton.svelte +97 -0
  160. package/src/SvCalendar.svelte +409 -0
  161. package/src/SvCalendar.test.ts +211 -0
  162. package/src/SvCheckBox.svelte +82 -0
  163. package/src/SvColorInput.svelte +160 -0
  164. package/src/SvComboBox.svelte +108 -0
  165. package/src/SvCountryInput.svelte +125 -0
  166. package/src/SvDateTimePicker.svelte +265 -0
  167. package/src/SvDateTimePicker.test.ts +136 -0
  168. package/src/SvDropDownList.svelte +120 -0
  169. package/src/SvForm.svelte +147 -0
  170. package/src/SvGauge.svelte +97 -0
  171. package/src/SvGrid.controller.svelte.ts +74 -8
  172. package/src/SvGrid.css +84 -31
  173. package/src/SvGrid.helpers.ts +41 -12
  174. package/src/SvGrid.svelte +84 -5
  175. package/src/SvGrid.types.ts +33 -0
  176. package/src/SvGridDropdown.svelte +16 -30
  177. package/src/SvListBox.svelte +89 -0
  178. package/src/SvMaskedInput.svelte +66 -0
  179. package/src/SvNumberInput.svelte +116 -0
  180. package/src/SvPasswordInput.svelte +100 -0
  181. package/src/SvPhoneInput.svelte +87 -0
  182. package/src/SvRadioGroup.svelte +85 -0
  183. package/src/SvRating.svelte +91 -0
  184. package/src/SvRepeatButton.svelte +89 -0
  185. package/src/SvSlider.svelte +155 -0
  186. package/src/SvSwitchButton.svelte +87 -0
  187. package/src/SvTabs.svelte +109 -0
  188. package/src/SvTagsInput.svelte +70 -0
  189. package/src/SvTimePicker.svelte +201 -0
  190. package/src/SvTimePicker.test.ts +98 -0
  191. package/src/SvToggleButton.svelte +57 -0
  192. package/src/SvTree.svelte +131 -0
  193. package/src/build-api.ts +115 -0
  194. package/src/cell-render.test.ts +38 -0
  195. package/src/cell-render.ts +43 -0
  196. package/src/clipboard.test.ts +18 -0
  197. package/src/clipboard.ts +24 -1
  198. package/src/collaboration.test.ts +30 -0
  199. package/src/collaboration.ts +6 -0
  200. package/src/core.ts +27 -3
  201. package/src/countries.ts +71 -0
  202. package/src/createAutocomplete.svelte.ts +104 -0
  203. package/src/createCalendar.svelte.ts +488 -0
  204. package/src/createCheckbox.svelte.ts +74 -0
  205. package/src/createColorInput.svelte.ts +115 -0
  206. package/src/createCombobox.svelte.ts +158 -0
  207. package/src/createCountryInput.svelte.ts +126 -0
  208. package/src/createDateTimePicker.svelte.ts +216 -0
  209. package/src/createDropdownList.svelte.ts +125 -0
  210. package/src/createGauge.svelte.ts +86 -0
  211. package/src/createListbox.svelte.ts +186 -0
  212. package/src/createMaskedInput.svelte.ts +81 -0
  213. package/src/createNumberInput.svelte.ts +193 -0
  214. package/src/createPasswordInput.svelte.ts +87 -0
  215. package/src/createPhoneInput.svelte.ts +119 -0
  216. package/src/createRadioGroup.svelte.ts +132 -0
  217. package/src/createRating.svelte.ts +145 -0
  218. package/src/createSlider.svelte.ts +160 -0
  219. package/src/createSwitch.svelte.ts +78 -0
  220. package/src/createTabs.svelte.ts +129 -0
  221. package/src/createTagsInput.svelte.ts +98 -0
  222. package/src/createTimePicker.svelte.ts +289 -0
  223. package/src/createToggle.svelte.ts +69 -0
  224. package/src/createTree.svelte.ts +216 -0
  225. package/src/datetime/date-core.test.ts +217 -0
  226. package/src/datetime/date-core.ts +204 -0
  227. package/src/datetime/date-format.test.ts +121 -0
  228. package/src/datetime/date-format.ts +317 -0
  229. package/src/datetime/date-restrict.ts +60 -0
  230. package/src/datetime/date-selection.test.ts +129 -0
  231. package/src/datetime/date-selection.ts +137 -0
  232. package/src/datetime/mask.test.ts +36 -0
  233. package/src/datetime/mask.ts +84 -0
  234. package/src/editing.test.ts +22 -0
  235. package/src/editing.ts +7 -1
  236. package/src/editor-contract.test.ts +32 -0
  237. package/src/editor-contract.ts +60 -0
  238. package/src/editors/cell-editors.ts +7 -1
  239. package/src/export-data-api.test.ts +126 -0
  240. package/src/export-format.test.ts +107 -0
  241. package/src/export-format.ts +598 -0
  242. package/src/hyperformula-adapter.test.ts +35 -1
  243. package/src/hyperformula-adapter.ts +18 -6
  244. package/src/index.ts +110 -0
  245. package/src/list-option.ts +15 -0
  246. package/src/menus.ts +21 -2
  247. package/src/popover.ts +79 -0
  248. package/src/row-resize.test.ts +25 -0
  249. package/src/row-resize.ts +8 -5
  250. package/src/selection.test.ts +36 -4
  251. package/src/selection.ts +30 -9
  252. package/src/server-data-source.test.ts +201 -0
  253. package/src/server-data-source.ts +148 -0
  254. package/src/sparkline.test.ts +9 -0
  255. package/src/sparkline.ts +9 -4
  256. package/src/svgrid-wrapper.types.ts +28 -1
  257. package/src/svgrid.behavior.test.ts +27 -0
  258. package/src/svgrid.new-features.wrapper.test.ts +27 -1
  259. package/src/ui-buttons.test.ts +105 -0
  260. package/src/ui-composite.test.ts +89 -0
  261. package/src/ui-inputs.test.ts +118 -0
  262. package/src/ui-range.test.ts +61 -0
  263. package/src/ui-selection.test.ts +119 -0
@@ -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
+ })
@@ -0,0 +1,317 @@
1
+ /**
2
+ * date-format - a small .NET/Smart-style date format-token engine used for
3
+ * `formatString` display and for parsing the masked DateTimePicker input. It is
4
+ * locale-aware for names (month/weekday, AM/PM) via `Intl`, and framework-free.
5
+ *
6
+ * Supported tokens (case-sensitive, matching Smart's `formatString`):
7
+ * d dd day of month (5, 05)
8
+ * ddd dddd weekday name (Mon, Monday) [locale]
9
+ * M MM month number (6, 06)
10
+ * MMM MMMM month name (Jun, June) [locale]
11
+ * yy yyyy year (26, 2026)
12
+ * H HH hour 24 (7, 07)
13
+ * h hh hour 12 (7, 07)
14
+ * m mm minute
15
+ * s ss second
16
+ * f ff fff fractional seconds (tenths..millis)
17
+ * t tt AM/PM designator (A, AM) [locale]
18
+ *
19
+ * Anything else is a literal. Wrap literal letters in single quotes ('T') or
20
+ * escape with a backslash (\T) to keep them out of token matching.
21
+ */
22
+
23
+ /** Ordered longest-first so the scanner is greedy and unambiguous. */
24
+ const TOKENS = [
25
+ 'dddd', 'ddd', 'dd', 'd',
26
+ 'MMMM', 'MMM', 'MM', 'M',
27
+ 'yyyy', 'yy',
28
+ 'HH', 'H',
29
+ 'hh', 'h',
30
+ 'mm', 'm',
31
+ 'ss', 's',
32
+ 'fff', 'ff', 'f',
33
+ 'tt', 't',
34
+ ] as const
35
+
36
+ type Part = { token: string } | { literal: string }
37
+
38
+ /** Split a mask into an ordered list of token / literal parts. */
39
+ export function tokenizeMask(mask: string): Part[] {
40
+ const parts: Part[] = []
41
+ let i = 0
42
+ while (i < mask.length) {
43
+ const ch = mask[i]!
44
+ // Backslash escape: next char is a literal.
45
+ if (ch === '\\' && i + 1 < mask.length) {
46
+ parts.push({ literal: mask[i + 1]! })
47
+ i += 2
48
+ continue
49
+ }
50
+ // Single-quoted literal run.
51
+ if (ch === "'") {
52
+ let j = i + 1
53
+ let lit = ''
54
+ while (j < mask.length && mask[j] !== "'") {
55
+ lit += mask[j]
56
+ j++
57
+ }
58
+ parts.push({ literal: lit })
59
+ i = j + 1 // skip closing quote (or run to end)
60
+ continue
61
+ }
62
+ // Greedy longest-token match.
63
+ let matched: string | null = null
64
+ for (const t of TOKENS) {
65
+ if (mask.startsWith(t, i)) {
66
+ matched = t
67
+ break
68
+ }
69
+ }
70
+ if (matched) {
71
+ parts.push({ token: matched })
72
+ i += matched.length
73
+ } else {
74
+ parts.push({ literal: ch })
75
+ i += 1
76
+ }
77
+ }
78
+ return parts
79
+ }
80
+
81
+ const pad = (n: number, len: number) => String(Math.abs(n)).padStart(len, '0')
82
+
83
+ // Intl name caches keyed by locale.
84
+ const monthCache = new Map<string, { long: string[]; short: string[] }>()
85
+ const weekdayCache = new Map<string, { long: string[]; short: string[] }>()
86
+ const dayperiodCache = new Map<string, { am: string; pm: string }>()
87
+
88
+ function monthNames(locale: string) {
89
+ let c = monthCache.get(locale)
90
+ if (!c) {
91
+ const long = new Intl.DateTimeFormat(locale, { month: 'long' })
92
+ const short = new Intl.DateTimeFormat(locale, { month: 'short' })
93
+ c = {
94
+ long: Array.from({ length: 12 }, (_, m) => long.format(new Date(2021, m, 1))),
95
+ short: Array.from({ length: 12 }, (_, m) => short.format(new Date(2021, m, 1))),
96
+ }
97
+ monthCache.set(locale, c)
98
+ }
99
+ return c
100
+ }
101
+
102
+ function weekdayNames(locale: string) {
103
+ let c = weekdayCache.get(locale)
104
+ if (!c) {
105
+ const long = new Intl.DateTimeFormat(locale, { weekday: 'long' })
106
+ const short = new Intl.DateTimeFormat(locale, { weekday: 'short' })
107
+ // 2021-08-01 is a Sunday.
108
+ c = {
109
+ long: Array.from({ length: 7 }, (_, d) => long.format(new Date(2021, 7, 1 + d))),
110
+ short: Array.from({ length: 7 }, (_, d) => short.format(new Date(2021, 7, 1 + d))),
111
+ }
112
+ weekdayCache.set(locale, c)
113
+ }
114
+ return c
115
+ }
116
+
117
+ function dayPeriods(locale: string) {
118
+ let c = dayperiodCache.get(locale)
119
+ if (!c) {
120
+ const f = new Intl.DateTimeFormat(locale, { hour: 'numeric', hour12: true })
121
+ const am = f.formatToParts(new Date(2021, 0, 1, 9)).find((p) => p.type === 'dayPeriod')?.value ?? 'AM'
122
+ const pm = f.formatToParts(new Date(2021, 0, 1, 21)).find((p) => p.type === 'dayPeriod')?.value ?? 'PM'
123
+ c = { am, pm }
124
+ dayperiodCache.set(locale, c)
125
+ }
126
+ return c
127
+ }
128
+
129
+ /** Render `date` per `mask` in `locale`. */
130
+ export function formatDate(date: Date, mask: string, locale = 'en-US'): string {
131
+ const parts = tokenizeMask(mask)
132
+ const h24 = date.getHours()
133
+ const h12 = h24 % 12 === 0 ? 12 : h24 % 12
134
+ const ms = date.getMilliseconds()
135
+ let out = ''
136
+ for (const p of parts) {
137
+ if ('literal' in p) {
138
+ out += p.literal
139
+ continue
140
+ }
141
+ switch (p.token) {
142
+ case 'd': out += String(date.getDate()); break
143
+ case 'dd': out += pad(date.getDate(), 2); break
144
+ case 'ddd': out += weekdayNames(locale).short[date.getDay()]; break
145
+ case 'dddd': out += weekdayNames(locale).long[date.getDay()]; break
146
+ case 'M': out += String(date.getMonth() + 1); break
147
+ case 'MM': out += pad(date.getMonth() + 1, 2); break
148
+ case 'MMM': out += monthNames(locale).short[date.getMonth()]; break
149
+ case 'MMMM': out += monthNames(locale).long[date.getMonth()]; break
150
+ case 'yy': out += pad(date.getFullYear() % 100, 2); break
151
+ case 'yyyy': out += pad(date.getFullYear(), 4); break
152
+ case 'H': out += String(h24); break
153
+ case 'HH': out += pad(h24, 2); break
154
+ case 'h': out += String(h12); break
155
+ case 'hh': out += pad(h12, 2); break
156
+ case 'm': out += String(date.getMinutes()); break
157
+ case 'mm': out += pad(date.getMinutes(), 2); break
158
+ case 's': out += String(date.getSeconds()); break
159
+ case 'ss': out += pad(date.getSeconds(), 2); break
160
+ case 'f': out += pad(ms, 3).slice(0, 1); break
161
+ case 'ff': out += pad(ms, 3).slice(0, 2); break
162
+ case 'fff': out += pad(ms, 3); break
163
+ case 't': out += (h24 < 12 ? dayPeriods(locale).am : dayPeriods(locale).pm).slice(0, 1); break
164
+ case 'tt': out += h24 < 12 ? dayPeriods(locale).am : dayPeriods(locale).pm; break
165
+ default: out += p.token
166
+ }
167
+ }
168
+ return out
169
+ }
170
+
171
+ type Acc = {
172
+ year?: number
173
+ month?: number // 0-11
174
+ day?: number
175
+ hour?: number // 24h accumulator
176
+ hour12?: number
177
+ minute?: number
178
+ second?: number
179
+ ms?: number
180
+ pm?: boolean
181
+ isPm?: boolean
182
+ }
183
+
184
+ const MAX_DIGITS: Record<string, number> = {
185
+ d: 2, dd: 2, M: 2, MM: 2, yy: 2, yyyy: 4,
186
+ H: 2, HH: 2, h: 2, hh: 2, m: 2, mm: 2, s: 2, ss: 2, f: 1, ff: 2, fff: 3,
187
+ }
188
+
189
+ function readDigits(str: string, at: number, max: number): { value: number; len: number } | null {
190
+ let len = 0
191
+ while (len < max && at + len < str.length && str[at + len]! >= '0' && str[at + len]! <= '9') len++
192
+ if (len === 0) return null
193
+ return { value: parseInt(str.slice(at, at + len), 10), len }
194
+ }
195
+
196
+ function matchName(str: string, at: number, names: string[]): { index: number; len: number } | null {
197
+ // Longest-first so 'June' wins over a hypothetical 'Jun' prefix collision.
198
+ const ordered = names
199
+ .map((name, index) => ({ name, index }))
200
+ .sort((a, b) => b.name.length - a.name.length)
201
+ const lower = str.slice(at).toLowerCase()
202
+ for (const { name, index } of ordered) {
203
+ if (name && lower.startsWith(name.toLowerCase())) return { index, len: name.length }
204
+ }
205
+ return null
206
+ }
207
+
208
+ /**
209
+ * Parse `str` against `mask` in `locale`. Returns a Date or null if the string
210
+ * does not satisfy the mask. `yearCutoff` disambiguates 2-digit years: values
211
+ * <= (yearCutoff % 100) map to 2000s, above to 1900s (Smart's default 1926 ->
212
+ * a 26 pivot). `base` supplies fields the mask omits (defaults to epoch-zero
213
+ * local midnight so a time-only mask yields a today-agnostic time).
214
+ */
215
+ export function parseDate(
216
+ str: string,
217
+ mask: string,
218
+ locale = 'en-US',
219
+ yearCutoff = 2029,
220
+ base?: Date,
221
+ ): Date | null {
222
+ const parts = tokenizeMask(mask)
223
+ const acc: Acc = {}
224
+ let i = 0
225
+ const s = str
226
+ for (const p of parts) {
227
+ if ('literal' in p) {
228
+ // Literals must match (whitespace-insensitive at the boundary).
229
+ for (const ch of p.literal) {
230
+ if (ch === ' ') {
231
+ while (s[i] === ' ') i++
232
+ continue
233
+ }
234
+ if (s[i] !== ch) return null
235
+ i++
236
+ }
237
+ continue
238
+ }
239
+ const tok = p.token
240
+ switch (tok) {
241
+ case 'ddd':
242
+ case 'dddd': {
243
+ const m = matchName(s, i, weekdayNames(locale)[tok === 'ddd' ? 'short' : 'long'])
244
+ if (!m) return null
245
+ i += m.len // weekday is informational; day-of-month drives the date
246
+ break
247
+ }
248
+ case 'MMM':
249
+ case 'MMMM': {
250
+ const m = matchName(s, i, monthNames(locale)[tok === 'MMM' ? 'short' : 'long'])
251
+ if (!m) return null
252
+ acc.month = m.index
253
+ i += m.len
254
+ break
255
+ }
256
+ case 't':
257
+ case 'tt': {
258
+ const dp = dayPeriods(locale)
259
+ const lower = s.slice(i).toLowerCase()
260
+ if (lower.startsWith(dp.pm.toLowerCase())) { acc.isPm = true; i += dp.pm.length }
261
+ else if (lower.startsWith(dp.am.toLowerCase())) { acc.isPm = false; i += dp.am.length }
262
+ else if (lower.startsWith(dp.pm.slice(0, 1).toLowerCase())) { acc.isPm = true; i += 1 }
263
+ else if (lower.startsWith(dp.am.slice(0, 1).toLowerCase())) { acc.isPm = false; i += 1 }
264
+ else return null
265
+ break
266
+ }
267
+ default: {
268
+ const r = readDigits(s, i, MAX_DIGITS[tok] ?? 2)
269
+ if (!r) return null
270
+ i += r.len
271
+ const v = r.value
272
+ switch (tok) {
273
+ case 'd': case 'dd': acc.day = v; break
274
+ case 'M': case 'MM': acc.month = v - 1; break
275
+ case 'yy': acc.year = v <= yearCutoff % 100 ? 2000 + v : 1900 + v; break
276
+ case 'yyyy': acc.year = v; break
277
+ case 'H': case 'HH': acc.hour = v; break
278
+ case 'h': case 'hh': acc.hour12 = v; break
279
+ case 'm': case 'mm': acc.minute = v; break
280
+ case 's': case 'ss': acc.second = v; break
281
+ case 'f': acc.ms = v * 100; break
282
+ case 'ff': acc.ms = v * 10; break
283
+ case 'fff': acc.ms = v; break
284
+ }
285
+ }
286
+ }
287
+ }
288
+ // Trailing input that the mask did not consume is a mismatch.
289
+ if (i !== s.length && s.slice(i).trim() !== '') return null
290
+
291
+ const b = base ?? new Date(1970, 0, 1)
292
+ let hour = acc.hour
293
+ if (hour == null && acc.hour12 != null) {
294
+ hour = acc.hour12 % 12
295
+ if (acc.isPm) hour += 12
296
+ }
297
+ const year = acc.year ?? b.getFullYear()
298
+ const month = acc.month ?? b.getMonth()
299
+ const day = acc.day ?? b.getDate()
300
+ const result = new Date(
301
+ year,
302
+ month,
303
+ day,
304
+ hour ?? b.getHours(),
305
+ acc.minute ?? b.getMinutes(),
306
+ acc.second ?? b.getSeconds(),
307
+ acc.ms ?? b.getMilliseconds(),
308
+ )
309
+ // Reject impossible dates the constructor silently rolls over (e.g. Feb 31).
310
+ if (
311
+ (acc.day != null && result.getDate() !== day) ||
312
+ (acc.month != null && result.getMonth() !== month)
313
+ ) {
314
+ return null
315
+ }
316
+ return isNaN(result.getTime()) ? null : result
317
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * date-restrict - predicates for calendar constraints: min/max bounds,
3
+ * `restrictedDates` (non-selectable), and `importantDates` (highlighted but
4
+ * still selectable). Mirrors Smart's calendar constraint model. Framework-free.
5
+ */
6
+ import { compareDay, isSameDay, toDate, type DateLike } from './date-core'
7
+
8
+ export type RestrictOptions = {
9
+ min?: DateLike | null
10
+ max?: DateLike | null
11
+ /** Individual dates (or predicate) that cannot be selected. */
12
+ restrictedDates?: ReadonlyArray<DateLike> | ((d: Date) => boolean) | null
13
+ }
14
+
15
+ function normalizeList(list: ReadonlyArray<DateLike> | null | undefined): Date[] {
16
+ if (!list) return []
17
+ const out: Date[] = []
18
+ for (const item of list) {
19
+ const d = toDate(item)
20
+ if (d) out.push(d)
21
+ }
22
+ return out
23
+ }
24
+
25
+ /** True when `d` is outside the inclusive [min, max] day range. */
26
+ export function isOutOfRange(d: Date, min?: DateLike | null, max?: DateLike | null): boolean {
27
+ const lo = toDate(min ?? null)
28
+ const hi = toDate(max ?? null)
29
+ if (lo && compareDay(d, lo) < 0) return true
30
+ if (hi && compareDay(d, hi) > 0) return true
31
+ return false
32
+ }
33
+
34
+ /** True when `d` is explicitly restricted (by list membership or predicate). */
35
+ export function isRestricted(
36
+ d: Date,
37
+ restrictedDates?: ReadonlyArray<DateLike> | ((day: Date) => boolean) | null,
38
+ ): boolean {
39
+ if (!restrictedDates) return false
40
+ if (typeof restrictedDates === 'function') return restrictedDates(d)
41
+ return normalizeList(restrictedDates).some((r) => isSameDay(r, d))
42
+ }
43
+
44
+ /**
45
+ * True when `d` cannot be selected: out of [min, max] OR restricted. This is
46
+ * the single predicate the calendar/pickers use to disable a day.
47
+ */
48
+ export function isDisabledDay(d: Date, opts: RestrictOptions): boolean {
49
+ return isOutOfRange(d, opts.min, opts.max) || isRestricted(d, opts.restrictedDates)
50
+ }
51
+
52
+ /** True when `d` is flagged important (highlighted). Never affects selectability. */
53
+ export function isImportant(
54
+ d: Date,
55
+ importantDates?: ReadonlyArray<DateLike> | ((day: Date) => boolean) | null,
56
+ ): boolean {
57
+ if (!importantDates) return false
58
+ if (typeof importantDates === 'function') return importantDates(d)
59
+ return normalizeList(importantDates).some((r) => isSameDay(r, d))
60
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ emptySelection,
4
+ isSelected,
5
+ rangeDays,
6
+ selectDate,
7
+ isMultiSelectMode,
8
+ type SelectionState,
9
+ } from './date-selection'
10
+ import { isDisabledDay, isImportant, isOutOfRange, isRestricted } from './date-restrict'
11
+
12
+ const day = (y: number, m: number, d: number) => new Date(y, m, d)
13
+ const state = (dates: Date[] = [], anchor: Date | null = null, rangeStart: Date | null = null): SelectionState => ({
14
+ dates,
15
+ anchor,
16
+ rangeStart,
17
+ })
18
+
19
+ describe('rangeDays', () => {
20
+ it('is inclusive and order-independent', () => {
21
+ const fwd = rangeDays(day(2026, 5, 1), day(2026, 5, 5))
22
+ expect(fwd.map((d) => d.getDate())).toEqual([1, 2, 3, 4, 5])
23
+ const rev = rangeDays(day(2026, 5, 5), day(2026, 5, 1))
24
+ expect(rev.map((d) => d.getDate())).toEqual([1, 2, 3, 4, 5])
25
+ expect(rangeDays(day(2026, 5, 3), day(2026, 5, 3))).toHaveLength(1)
26
+ })
27
+ })
28
+
29
+ describe('selectDate modes', () => {
30
+ it('none does nothing', () => {
31
+ const r = selectDate(state([day(2026, 5, 1)]), day(2026, 5, 9), 'none')
32
+ expect(r.dates.map((d) => d.getDate())).toEqual([1])
33
+ })
34
+ it('one/default replaces', () => {
35
+ const r = selectDate(state([day(2026, 5, 1)]), day(2026, 5, 9), 'one')
36
+ expect(r.dates.map((d) => d.getDate())).toEqual([9])
37
+ expect(isSelected(r.dates, day(2026, 5, 1))).toBe(false)
38
+ })
39
+ it('zeroOrOne toggles the sole day off', () => {
40
+ const first = selectDate(emptySelection(), day(2026, 5, 9), 'zeroOrOne')
41
+ expect(first.dates).toHaveLength(1)
42
+ const off = selectDate(first, day(2026, 5, 9), 'zeroOrOne')
43
+ expect(off.dates).toHaveLength(0)
44
+ })
45
+ it('many toggles add/remove and keeps sorted', () => {
46
+ let r = selectDate(emptySelection(), day(2026, 5, 9), 'many')
47
+ r = selectDate(r, day(2026, 5, 1), 'many')
48
+ r = selectDate(r, day(2026, 5, 5), 'many')
49
+ expect(r.dates.map((d) => d.getDate())).toEqual([1, 5, 9]) // sorted
50
+ r = selectDate(r, day(2026, 5, 5), 'many') // remove
51
+ expect(r.dates.map((d) => d.getDate())).toEqual([1, 9])
52
+ })
53
+ it('oneOrMany refuses to drop below one', () => {
54
+ const one = selectDate(emptySelection(), day(2026, 5, 9), 'oneOrMany')
55
+ const stillOne = selectDate(one, day(2026, 5, 9), 'oneOrMany') // toggling last stays
56
+ expect(stillOne.dates).toHaveLength(1)
57
+ })
58
+ it('oneExtended: plain click single, ctrl toggles, shift extends from anchor', () => {
59
+ const base = selectDate(emptySelection(), day(2026, 5, 10), 'oneExtended')
60
+ expect(base.dates.map((d) => d.getDate())).toEqual([10])
61
+ const ctrl = selectDate(base, day(2026, 5, 12), 'oneExtended', { ctrl: true })
62
+ expect(ctrl.dates.map((d) => d.getDate())).toEqual([10, 12])
63
+ const shift = selectDate(base, day(2026, 5, 13), 'oneExtended', { shift: true })
64
+ expect(shift.dates.map((d) => d.getDate())).toEqual([10, 11, 12, 13]) // contiguous
65
+ })
66
+ it('week selects seven consecutive days from the week start', () => {
67
+ const r = selectDate(emptySelection(), day(2026, 5, 10), 'week', {}, 1) // Monday-first
68
+ expect(r.dates).toHaveLength(7)
69
+ expect(r.dates[0]!.getDay()).toBe(1)
70
+ // consecutive
71
+ for (let i = 1; i < 7; i++) {
72
+ expect(r.dates[i]!.getTime() - r.dates[i - 1]!.getTime()).toBe(24 * 3600 * 1000)
73
+ }
74
+ })
75
+ it('range: first click starts, second completes inclusive', () => {
76
+ const start = selectDate(emptySelection(), day(2026, 5, 3), 'range')
77
+ expect(start.rangeStart?.getDate()).toBe(3)
78
+ expect(start.dates.map((d) => d.getDate())).toEqual([3])
79
+ const done = selectDate(start, day(2026, 5, 6), 'range')
80
+ expect(done.rangeStart).toBeNull()
81
+ expect(done.dates.map((d) => d.getDate())).toEqual([3, 4, 5, 6])
82
+ // A third click starts a new range.
83
+ const restart = selectDate(done, day(2026, 5, 20), 'range')
84
+ expect(restart.rangeStart?.getDate()).toBe(20)
85
+ expect(restart.dates.map((d) => d.getDate())).toEqual([20])
86
+ })
87
+ it('does not mutate the input state', () => {
88
+ const input = state([day(2026, 5, 1)], day(2026, 5, 1))
89
+ const snapshot = input.dates.slice()
90
+ selectDate(input, day(2026, 5, 9), 'one')
91
+ expect(input.dates).toEqual(snapshot)
92
+ })
93
+ })
94
+
95
+ describe('isMultiSelectMode', () => {
96
+ it('classifies modes', () => {
97
+ expect(isMultiSelectMode('one')).toBe(false)
98
+ expect(isMultiSelectMode('zeroOrOne')).toBe(false)
99
+ expect(isMultiSelectMode('many')).toBe(true)
100
+ expect(isMultiSelectMode('range')).toBe(true)
101
+ expect(isMultiSelectMode('week')).toBe(true)
102
+ })
103
+ })
104
+
105
+ describe('date-restrict', () => {
106
+ const min = day(2026, 5, 5)
107
+ const max = day(2026, 5, 25)
108
+ it('isOutOfRange respects inclusive bounds', () => {
109
+ expect(isOutOfRange(day(2026, 5, 4), min, max)).toBe(true)
110
+ expect(isOutOfRange(day(2026, 5, 5), min, max)).toBe(false) // inclusive
111
+ expect(isOutOfRange(day(2026, 5, 25), min, max)).toBe(false) // inclusive
112
+ expect(isOutOfRange(day(2026, 5, 26), min, max)).toBe(true)
113
+ expect(isOutOfRange(day(2026, 5, 26), null, null)).toBe(false) // open
114
+ })
115
+ it('isRestricted supports list and predicate', () => {
116
+ expect(isRestricted(day(2026, 5, 10), [day(2026, 5, 10)])).toBe(true)
117
+ expect(isRestricted(day(2026, 5, 11), [day(2026, 5, 10)])).toBe(false)
118
+ expect(isRestricted(day(2026, 5, 13), (d) => d.getDay() === 6)).toBe(true) // Saturday
119
+ })
120
+ it('isDisabledDay combines range + restriction', () => {
121
+ expect(isDisabledDay(day(2026, 5, 1), { min, max })).toBe(true) // below min
122
+ expect(isDisabledDay(day(2026, 5, 10), { min, max, restrictedDates: [day(2026, 5, 10)] })).toBe(true)
123
+ expect(isDisabledDay(day(2026, 5, 10), { min, max })).toBe(false)
124
+ })
125
+ it('isImportant never affects selectability', () => {
126
+ expect(isImportant(day(2026, 5, 10), [day(2026, 5, 10)])).toBe(true)
127
+ expect(isImportant(day(2026, 5, 11), [day(2026, 5, 10)])).toBe(false)
128
+ })
129
+ })