@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,216 @@
1
+ /**
2
+ * createTree - the HEADLESS core behind <SvTree>, in the same spirit as
3
+ * `createSvGrid` for the grid: a runes-based state machine (expand/collapse,
4
+ * single-select highlight, cascading tri-state checkboxes, full keyboard) with
5
+ * **prop-getters** you spread onto YOUR OWN markup. The visible tree is
6
+ * flattened into `rows` so roving-focus keyboard nav stays a flat index walk.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createTree } from '@svgrid/grid'
11
+ * let selected = $state<string | null>(null)
12
+ * const tree = createTree({ nodes: () => nodes, selected: () => selected, onSelect: (id) => (selected = id) })
13
+ * </script>
14
+ * <div {...tree.treeProps()}>
15
+ * {#each tree.rows as row (row.node.id)}
16
+ * <div {...tree.itemProps(row)}>{row.node.label}</div>
17
+ * {/each}
18
+ * </div>
19
+ * ```
20
+ *
21
+ * The styled <SvTree> is just one renderer over this core. The cascade math
22
+ * (`treeDescendantIds`, `treeCheckState`) is exported as pure functions.
23
+ */
24
+ export type TreeNode = {
25
+ id: string
26
+ label: string
27
+ children?: TreeNode[]
28
+ disabled?: boolean
29
+ }
30
+
31
+ export type CheckState = 'checked' | 'indeterminate' | 'unchecked'
32
+
33
+ /** A single visible (flattened) tree row. */
34
+ export type TreeRow = {
35
+ node: TreeNode
36
+ depth: number
37
+ hasChildren: boolean
38
+ open: boolean
39
+ parentId: string | null
40
+ index: number
41
+ }
42
+
43
+ /** All descendant ids of `node` (pure, depth-first). */
44
+ export function treeDescendantIds(node: TreeNode): string[] {
45
+ const ids: string[] = []
46
+ const walk = (n: TreeNode) => { for (const c of n.children ?? []) { ids.push(c.id); walk(c) } }
47
+ walk(node)
48
+ return ids
49
+ }
50
+
51
+ /** Tri-state check status of `node` given the set of checked ids (pure). */
52
+ export function treeCheckState(node: TreeNode, checked: ReadonlySet<string>): CheckState {
53
+ if (!node.children?.length) return checked.has(node.id) ? 'checked' : 'unchecked'
54
+ const desc = treeDescendantIds(node)
55
+ const on = desc.filter((id) => checked.has(id)).length
56
+ if (on === 0 && !checked.has(node.id)) return 'unchecked'
57
+ if (on === desc.length) return 'checked'
58
+ return 'indeterminate'
59
+ }
60
+
61
+ function findNode(list: ReadonlyArray<TreeNode>, id: string): TreeNode | null {
62
+ for (const n of list) {
63
+ if (n.id === id) return n
64
+ if (n.children) { const f = findNode(n.children, id); if (f) return f }
65
+ }
66
+ return null
67
+ }
68
+
69
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
70
+ export type TreeConfig = {
71
+ nodes: () => ReadonlyArray<TreeNode>
72
+ selected?: () => string | null
73
+ onSelect?: (id: string) => void
74
+ /** Seed the internally-managed expanded set (read once). */
75
+ expandedIds?: () => string[] | undefined
76
+ onToggle?: (id: string, expanded: boolean) => void
77
+ checkable?: () => boolean
78
+ checked?: () => string[]
79
+ onCheck?: (ids: string[]) => void
80
+ ariaLabel?: () => string | undefined
81
+ }
82
+
83
+ export function createTree(config: TreeConfig) {
84
+ const nodes = () => config.nodes()
85
+ const checkable = () => config.checkable?.() ?? false
86
+ const selected = () => config.selected?.() ?? null
87
+
88
+ // Internal expanded set, seeded from the prop once (then self-managed).
89
+ let expanded = $state<Set<string>>(new Set())
90
+ let seeded = false
91
+ $effect(() => {
92
+ if (!seeded) { seeded = true; const ids = config.expandedIds?.(); if (ids) expanded = new Set(ids) }
93
+ })
94
+
95
+ const rows = $derived.by<TreeRow[]>(() => {
96
+ const out: TreeRow[] = []
97
+ const walk = (list: ReadonlyArray<TreeNode>, depth: number, parentId: string | null) => {
98
+ for (const node of list) {
99
+ const hasChildren = !!node.children?.length
100
+ const open = expanded.has(node.id)
101
+ out.push({ node, depth, hasChildren, open, parentId, index: out.length })
102
+ if (hasChildren && open) walk(node.children!, depth + 1, node.id)
103
+ }
104
+ }
105
+ walk(nodes(), 0, null)
106
+ return out
107
+ })
108
+
109
+ let active = $state(0)
110
+ $effect(() => { if (active >= rows.length) active = Math.max(0, rows.length - 1) })
111
+
112
+ const checkedSet = $derived(new Set(config.checked?.() ?? []))
113
+
114
+ // Roving focus target; `focusTick` bumps only on keyboard moves.
115
+ let focusTick = $state(0)
116
+ function setActive(i: number, focus = false) { active = i; if (focus) focusTick++ }
117
+
118
+ const checkStateOf = (node: TreeNode): CheckState => treeCheckState(node, checkedSet)
119
+ const isExpanded = (id: string) => expanded.has(id)
120
+ const isSelected = (id: string) => id === selected()
121
+
122
+ function toggleExpand(node: TreeNode) {
123
+ if (!node.children?.length) return
124
+ const next = new Set(expanded)
125
+ const willOpen = !next.has(node.id)
126
+ willOpen ? next.add(node.id) : next.delete(node.id)
127
+ expanded = next
128
+ config.onToggle?.(node.id, willOpen)
129
+ }
130
+ const toggle = (id: string) => { const n = findNode(nodes(), id); if (n) toggleExpand(n) }
131
+
132
+ function select(node: TreeNode) {
133
+ if (node.disabled) return
134
+ config.onSelect?.(node.id)
135
+ }
136
+ const selectId = (id: string) => { const n = findNode(nodes(), id); if (n) select(n) }
137
+
138
+ function toggleCheck(node: TreeNode) {
139
+ if (node.disabled) return
140
+ const next = new Set(checkedSet)
141
+ const ids = [node.id, ...treeDescendantIds(node)]
142
+ const currentlyOn = checkStateOf(node) === 'checked'
143
+ for (const id of ids) currentlyOn ? next.delete(id) : next.add(id)
144
+ config.onCheck?.([...next])
145
+ }
146
+
147
+ function onKeydown(e: KeyboardEvent) {
148
+ const item = rows[active]
149
+ if (!item) return
150
+ switch (e.key) {
151
+ case 'ArrowDown': e.preventDefault(); setActive(Math.min(active + 1, rows.length - 1), true); break
152
+ case 'ArrowUp': e.preventDefault(); setActive(Math.max(active - 1, 0), true); break
153
+ case 'ArrowRight':
154
+ e.preventDefault()
155
+ if (item.hasChildren && !item.open) toggleExpand(item.node)
156
+ else if (item.hasChildren && item.open) setActive(Math.min(active + 1, rows.length - 1), true)
157
+ break
158
+ case 'ArrowLeft':
159
+ e.preventDefault()
160
+ if (item.hasChildren && item.open) toggleExpand(item.node)
161
+ else if (item.parentId) { const pi = rows.findIndex((r) => r.node.id === item.parentId); if (pi >= 0) setActive(pi, true) }
162
+ break
163
+ case 'Enter': e.preventDefault(); select(item.node); break
164
+ case ' ': e.preventDefault(); checkable() ? toggleCheck(item.node) : select(item.node); break
165
+ case 'Home': e.preventDefault(); setActive(0, true); break
166
+ case 'End': e.preventDefault(); setActive(rows.length - 1, true); break
167
+ }
168
+ }
169
+
170
+ return {
171
+ /** Internally-managed set of expanded node ids. */
172
+ get expanded() { return expanded },
173
+ /** Selected node id (single-select highlight). */
174
+ get selected() { return selected() },
175
+ /** Flattened list of visible rows. */
176
+ get rows() { return rows },
177
+ /** Roving-focus row index. */
178
+ get activeIndex() { return active },
179
+ /** Monotonic counter; changes only on keyboard navigation. */
180
+ get focusTick() { return focusTick },
181
+ isExpanded,
182
+ isSelected,
183
+ checkStateOf,
184
+ toggle,
185
+ toggleExpand,
186
+ select: selectId,
187
+ toggleCheck,
188
+ setActive,
189
+ onKeydown,
190
+ /** Spread onto the tree container element. */
191
+ treeProps: () => ({
192
+ role: 'tree' as const,
193
+ 'aria-label': config.ariaLabel?.(),
194
+ 'aria-multiselectable': checkable(),
195
+ }),
196
+ /** Spread onto the treeitem element for a flattened `row`. */
197
+ itemProps: (row: TreeRow) => {
198
+ const cs = checkable() ? checkStateOf(row.node) : 'unchecked'
199
+ const ariaChecked: 'true' | 'mixed' | 'false' | undefined =
200
+ checkable() ? (cs === 'checked' ? 'true' : cs === 'indeterminate' ? 'mixed' : 'false') : undefined
201
+ return {
202
+ role: 'treeitem' as const,
203
+ 'aria-level': row.depth + 1,
204
+ 'aria-selected': row.node.id === selected(),
205
+ 'aria-expanded': row.hasChildren ? row.open : undefined,
206
+ 'aria-checked': ariaChecked,
207
+ 'data-row': row.index,
208
+ tabindex: row.index === active ? 0 : -1,
209
+ onclick: () => { setActive(row.index); select(row.node) },
210
+ onkeydown: onKeydown,
211
+ }
212
+ },
213
+ }
214
+ }
215
+
216
+ export type Tree = ReturnType<typeof createTree>
@@ -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
+ }