@svgrid/grid 2.1.22 → 2.2.0

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 (295) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/FlexRender.svelte +96 -96
  7. package/dist/GridFooter.svelte +178 -178
  8. package/dist/GridMenus.svelte +83 -3
  9. package/dist/SvAutoComplete.svelte +5 -3
  10. package/dist/SvCarousel.svelte +141 -163
  11. package/dist/SvColorInput.svelte +187 -186
  12. package/dist/SvColorInput.svelte.d.ts +1 -1
  13. package/dist/SvComboBox.svelte +7 -3
  14. package/dist/SvCommand.svelte +28 -85
  15. package/dist/SvContextMenu.svelte +116 -116
  16. package/dist/SvCountryInput.svelte +1 -1
  17. package/dist/SvDateRangeInput.svelte +1 -1
  18. package/dist/SvDateTimePicker.svelte +9 -3
  19. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  20. package/dist/SvDockLayout.svelte +244 -0
  21. package/dist/SvDockLayout.svelte.d.ts +77 -0
  22. package/dist/SvDockManager.svelte +922 -0
  23. package/dist/SvDockManager.svelte.d.ts +106 -0
  24. package/dist/SvDrawer.svelte +250 -228
  25. package/dist/SvDrawer.svelte.d.ts +4 -0
  26. package/dist/SvDropDownList.svelte +85 -13
  27. package/dist/SvDropDownList.svelte.d.ts +3 -0
  28. package/dist/SvField.svelte +21 -4
  29. package/dist/SvField.svelte.d.ts +1 -0
  30. package/dist/SvForm.svelte +201 -91
  31. package/dist/SvForm.svelte.d.ts +15 -22
  32. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  33. package/dist/SvGrid.controller.svelte.js +156 -27
  34. package/dist/SvGrid.css +193 -5
  35. package/dist/SvGrid.svelte +3131 -2786
  36. package/dist/SvGrid.types.d.ts +452 -1
  37. package/dist/SvGridBoard.svelte +2339 -2339
  38. package/dist/SvGridChart.svelte +1724 -1724
  39. package/dist/SvGridChartPanel.svelte +485 -485
  40. package/dist/SvGridDropdown.svelte +689 -682
  41. package/dist/SvGridSelect.svelte +1 -0
  42. package/dist/SvGroupCell.svelte +109 -109
  43. package/dist/SvListBox.svelte +36 -14
  44. package/dist/SvMaskedInput.svelte +4 -3
  45. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  46. package/dist/SvMenu.svelte +124 -124
  47. package/dist/SvMenuList.svelte +117 -165
  48. package/dist/SvMenuList.svelte.d.ts +2 -13
  49. package/dist/SvModal.svelte +12 -29
  50. package/dist/SvMultiSelect.svelte +1 -0
  51. package/dist/SvNumberInput.svelte +131 -130
  52. package/dist/SvNumberInput.svelte.d.ts +1 -1
  53. package/dist/SvOtpInput.svelte +158 -158
  54. package/dist/SvPagination.svelte +18 -18
  55. package/dist/SvPasswordInput.svelte +3 -2
  56. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  57. package/dist/SvPhoneInput.svelte +3 -2
  58. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  59. package/dist/SvPopover.svelte +139 -139
  60. package/dist/SvRowGroupPanel.svelte +170 -170
  61. package/dist/SvScrollArea.svelte +61 -61
  62. package/dist/SvStepper.svelte +6 -9
  63. package/dist/SvTagsInput.svelte +3 -2
  64. package/dist/SvTagsInput.svelte.d.ts +1 -1
  65. package/dist/SvTooltip.svelte +127 -138
  66. package/dist/SvTour.svelte +204 -176
  67. package/dist/SvTree.svelte +377 -377
  68. package/dist/SvTreeSelect.svelte +230 -229
  69. package/dist/build-api.js +76 -54
  70. package/dist/cdn/svgrid.js +10913 -6450
  71. package/dist/cdn/svgrid.svelte-external.js +9628 -5481
  72. package/dist/chart-export.js +8 -8
  73. package/dist/columns.js +6 -0
  74. package/dist/conditional-formatting.d.ts +67 -3
  75. package/dist/conditional-formatting.js +124 -17
  76. package/dist/createCarousel.svelte.d.ts +72 -0
  77. package/dist/createCarousel.svelte.js +80 -0
  78. package/dist/createCommand.svelte.d.ts +62 -0
  79. package/dist/createCommand.svelte.js +109 -0
  80. package/dist/createForm.svelte.d.ts +62 -0
  81. package/dist/createForm.svelte.js +374 -0
  82. package/dist/createMenu.svelte.d.ts +88 -0
  83. package/dist/createMenu.svelte.js +153 -0
  84. package/dist/createOverlay.svelte.d.ts +38 -0
  85. package/dist/createOverlay.svelte.js +68 -0
  86. package/dist/createPagination.d.ts +74 -0
  87. package/dist/createPagination.js +71 -0
  88. package/dist/createPopoverSelect.svelte.js +6 -12
  89. package/dist/createStepper.d.ts +44 -0
  90. package/dist/createStepper.js +26 -0
  91. package/dist/createTooltip.svelte.d.ts +32 -0
  92. package/dist/createTooltip.svelte.js +61 -0
  93. package/dist/datetime/timezone.d.ts +36 -0
  94. package/dist/datetime/timezone.js +110 -0
  95. package/dist/dock-context.d.ts +52 -0
  96. package/dist/dock-context.js +1 -0
  97. package/dist/dock-manager-model.d.ts +136 -0
  98. package/dist/dock-manager-model.js +514 -0
  99. package/dist/dock-model.d.ts +105 -0
  100. package/dist/dock-model.js +288 -0
  101. package/dist/dock-popout.d.ts +30 -0
  102. package/dist/dock-popout.js +81 -0
  103. package/dist/editor-contract.d.ts +2 -0
  104. package/dist/filter-operators.d.ts +6 -0
  105. package/dist/filter-operators.js +35 -2
  106. package/dist/filtering/excel-filters.d.ts +18 -1
  107. package/dist/filtering/excel-filters.js +56 -0
  108. package/dist/form-field.d.ts +73 -0
  109. package/dist/form-field.js +15 -0
  110. package/dist/index.d.ts +25 -5
  111. package/dist/index.js +40 -4
  112. package/dist/list-nav.d.ts +18 -0
  113. package/dist/list-nav.js +30 -0
  114. package/dist/list-option.d.ts +2 -0
  115. package/dist/menu-item.d.ts +20 -0
  116. package/dist/menu-item.js +1 -0
  117. package/dist/menus.d.ts +5 -0
  118. package/dist/menus.js +68 -16
  119. package/dist/panel-resize.d.ts +25 -0
  120. package/dist/panel-resize.js +40 -0
  121. package/dist/popover.d.ts +29 -3
  122. package/dist/popover.js +22 -6
  123. package/dist/recurrence.d.ts +33 -7
  124. package/dist/recurrence.js +151 -13
  125. package/dist/row-drag.d.ts +8 -0
  126. package/dist/row-drag.js +11 -0
  127. package/dist/scheduler-ical.d.ts +30 -0
  128. package/dist/scheduler-ical.js +180 -0
  129. package/dist/scheduler-model.d.ts +269 -0
  130. package/dist/scheduler-model.js +602 -0
  131. package/dist/scheduler-view.svelte.d.ts +22 -0
  132. package/dist/scheduler-view.svelte.js +13 -0
  133. package/dist/spreadsheet.js +20 -12
  134. package/dist/ui-app.types.d.ts +3 -0
  135. package/package.json +14 -10
  136. package/src/DockNodeView.svelte +306 -0
  137. package/src/DockPopoutHost.svelte +17 -0
  138. package/src/FlexRender.svelte +96 -96
  139. package/src/GridFooter.svelte +178 -178
  140. package/src/GridMenus.svelte +83 -3
  141. package/src/SvAutoComplete.svelte +5 -3
  142. package/src/SvCarousel.svelte +141 -163
  143. package/src/SvCarousel.test.ts +68 -0
  144. package/src/SvColorInput.svelte +187 -186
  145. package/src/SvComboBox.svelte +7 -3
  146. package/src/SvCommand.svelte +28 -85
  147. package/src/SvCommand.test.ts +78 -0
  148. package/src/SvContextMenu.svelte +116 -116
  149. package/src/SvCountryInput.svelte +1 -1
  150. package/src/SvDateRangeInput.svelte +1 -1
  151. package/src/SvDateTimePicker.svelte +9 -3
  152. package/src/SvDockLayout.svelte +244 -0
  153. package/src/SvDockManager.svelte +922 -0
  154. package/src/SvDrawer.svelte +250 -228
  155. package/src/SvDropDownList.svelte +85 -13
  156. package/src/SvField.svelte +21 -4
  157. package/src/SvForm.svelte +201 -91
  158. package/src/SvForm.test.ts +299 -0
  159. package/src/SvGrid.controller.svelte.ts +3158 -3034
  160. package/src/SvGrid.css +193 -5
  161. package/src/SvGrid.svelte +3131 -2786
  162. package/src/SvGrid.types.ts +1489 -1027
  163. package/src/SvGridBoard.svelte +2339 -2339
  164. package/src/SvGridChart.svelte +1724 -1724
  165. package/src/SvGridChartPanel.svelte +485 -485
  166. package/src/SvGridDropdown.svelte +689 -682
  167. package/src/SvGridSelect.svelte +1 -0
  168. package/src/SvGroupCell.svelte +109 -109
  169. package/src/SvListBox.svelte +36 -14
  170. package/src/SvMaskedInput.svelte +4 -3
  171. package/src/SvMenu.svelte +124 -124
  172. package/src/SvMenu.test.ts +97 -73
  173. package/src/SvMenuList.svelte +117 -165
  174. package/src/SvModal.svelte +12 -29
  175. package/src/SvModal.test.ts +82 -0
  176. package/src/SvMultiSelect.svelte +1 -0
  177. package/src/SvNumberInput.svelte +131 -130
  178. package/src/SvOtpInput.svelte +158 -158
  179. package/src/SvPagination.svelte +18 -18
  180. package/src/SvPasswordInput.svelte +3 -2
  181. package/src/SvPhoneInput.svelte +3 -2
  182. package/src/SvPopover.svelte +139 -139
  183. package/src/SvRowGroupPanel.svelte +170 -170
  184. package/src/SvScrollArea.svelte +61 -61
  185. package/src/SvStepper.svelte +6 -9
  186. package/src/SvTagsInput.svelte +3 -2
  187. package/src/SvTooltip.svelte +127 -138
  188. package/src/SvTooltip.test.ts +73 -0
  189. package/src/SvTour.svelte +204 -176
  190. package/src/SvTree.svelte +377 -377
  191. package/src/SvTreeSelect.svelte +230 -229
  192. package/src/a11y.contract.test.ts +49 -49
  193. package/src/a11y.test.ts +59 -59
  194. package/src/a11y.ts +61 -61
  195. package/src/build-api.ts +870 -847
  196. package/src/cell-formatting.ts +169 -169
  197. package/src/cell-render.ts +469 -469
  198. package/src/chart-export.ts +201 -201
  199. package/src/chart.ts +2296 -2296
  200. package/src/collaboration.test.ts +104 -104
  201. package/src/collaboration.ts +167 -167
  202. package/src/columns.ts +5 -0
  203. package/src/conditional-formatting.test.ts +104 -0
  204. package/src/conditional-formatting.ts +176 -19
  205. package/src/core.performance.test.ts +30 -30
  206. package/src/core.ts +1111 -1111
  207. package/src/createAutocomplete.svelte.ts +132 -132
  208. package/src/createCarousel.svelte.ts +104 -0
  209. package/src/createCombobox.svelte.ts +179 -179
  210. package/src/createCommand.svelte.ts +125 -0
  211. package/src/createCountryInput.svelte.ts +157 -157
  212. package/src/createDropdownList.svelte.ts +162 -162
  213. package/src/createForm.svelte.ts +333 -0
  214. package/src/createGrid.svelte.ts +42 -42
  215. package/src/createGrid.test.ts +10 -10
  216. package/src/createGridState.svelte.ts +17 -17
  217. package/src/createMenu.svelte.ts +202 -0
  218. package/src/createOverlay.svelte.ts +101 -0
  219. package/src/createPagination.test.ts +65 -0
  220. package/src/createPagination.ts +84 -0
  221. package/src/createPopoverSelect.svelte.ts +206 -210
  222. package/src/createStepper.test.ts +62 -0
  223. package/src/createStepper.ts +61 -0
  224. package/src/createTooltip.svelte.ts +86 -0
  225. package/src/createTree.svelte.ts +319 -319
  226. package/src/datetime/timezone.test.ts +69 -0
  227. package/src/datetime/timezone.ts +134 -0
  228. package/src/dock-context.ts +47 -0
  229. package/src/dock-manager-model.test.ts +275 -0
  230. package/src/dock-manager-model.ts +593 -0
  231. package/src/dock-model.test.ts +163 -0
  232. package/src/dock-model.ts +368 -0
  233. package/src/dock-popout.ts +94 -0
  234. package/src/editing.test.ts +859 -859
  235. package/src/editing.ts +675 -675
  236. package/src/editor-contract.ts +2 -0
  237. package/src/export-data-api.test.ts +126 -126
  238. package/src/export-format.test.ts +107 -107
  239. package/src/export-format.ts +598 -598
  240. package/src/filter-operators.test.ts +59 -0
  241. package/src/filter-operators.ts +35 -2
  242. package/src/filtering/excel-filters.test.ts +86 -1
  243. package/src/filtering/excel-filters.ts +65 -0
  244. package/src/flex-render.ts +3 -3
  245. package/src/form-field.ts +87 -0
  246. package/src/index.ts +623 -469
  247. package/src/keyboard.test.ts +59 -59
  248. package/src/keyboard.ts +97 -97
  249. package/src/list-nav.test.ts +49 -0
  250. package/src/list-nav.ts +29 -0
  251. package/src/list-option.ts +2 -0
  252. package/src/menu-item.ts +21 -0
  253. package/src/menus.test.ts +48 -0
  254. package/src/menus.ts +639 -582
  255. package/src/merge-objects.ts +48 -48
  256. package/src/overlays.test.ts +90 -90
  257. package/src/panel-resize.ts +52 -0
  258. package/src/popover.bounds.test.ts +93 -0
  259. package/src/popover.ts +51 -7
  260. package/src/recurrence.test.ts +88 -1
  261. package/src/recurrence.ts +166 -15
  262. package/src/render-component.ts +28 -28
  263. package/src/row-drag.ts +10 -0
  264. package/src/scheduler-ical.test.ts +68 -0
  265. package/src/scheduler-ical.ts +180 -0
  266. package/src/scheduler-model.test.ts +546 -0
  267. package/src/scheduler-model.ts +853 -0
  268. package/src/scheduler-view.svelte.ts +33 -0
  269. package/src/selection.test.ts +754 -754
  270. package/src/selection.ts +600 -600
  271. package/src/server-data-source.test.ts +289 -289
  272. package/src/server-data-source.ts +413 -413
  273. package/src/sparkline.test.ts +68 -68
  274. package/src/sparkline.ts +169 -169
  275. package/src/spreadsheet.test.ts +489 -489
  276. package/src/spreadsheet.ts +312 -304
  277. package/src/static-functions.ts +11 -11
  278. package/src/subscribe.ts +38 -38
  279. package/src/svgrid-wrapper.types.ts +501 -501
  280. package/src/svgrid.behavior.test.ts +706 -706
  281. package/src/svgrid.charting.test.ts +527 -527
  282. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  283. package/src/svgrid.features.test.ts +157 -157
  284. package/src/svgrid.new-features.wrapper.test.ts +251 -251
  285. package/src/svgrid.wrapper.test.ts +40 -40
  286. package/src/test-setup.ts +62 -62
  287. package/src/themes/index.ts +159 -159
  288. package/src/ui-app.types.ts +3 -0
  289. package/src/virtual.test.ts +6 -4
  290. package/src/virtualization/column-virtualizer.test.ts +27 -27
  291. package/src/virtualization/column-virtualizer.ts +30 -30
  292. package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
  293. package/src/virtualization/types.ts +30 -30
  294. package/src/virtualization/virtualizer.test.ts +47 -47
  295. package/src/virtualization/virtualizer.ts +296 -296
@@ -0,0 +1,69 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { zoneParts, zoneOffsetMs, toZonedLocal, fromZonedLocal, instantFromWallClock, normalizeTimeZone } from './timezone'
3
+
4
+ const H = 3_600_000
5
+
6
+ describe('timezone utils', () => {
7
+ // 2026-07-01 12:00 UTC (summer): NY = EDT (-4), Kolkata = +5:30, London = BST (+1)
8
+ const summer = new Date('2026-07-01T12:00:00Z')
9
+ // 2026-01-01 12:00 UTC (winter): NY = EST (-5), London = GMT (0)
10
+ const winter = new Date('2026-01-01T12:00:00Z')
11
+
12
+ it('zoneParts gives wall-clock in the target zone', () => {
13
+ expect(zoneParts(summer, 'America/New_York')).toMatchObject({ hour: 8, minute: 0 })
14
+ expect(zoneParts(summer, 'Asia/Kolkata')).toMatchObject({ hour: 17, minute: 30 })
15
+ expect(zoneParts(summer, 'Europe/London')).toMatchObject({ hour: 13, minute: 0 })
16
+ expect(zoneParts(winter, 'America/New_York')).toMatchObject({ hour: 7 })
17
+ })
18
+
19
+ it('zoneOffsetMs is DST-aware', () => {
20
+ expect(zoneOffsetMs(summer, 'America/New_York')).toBe(-4 * H)
21
+ expect(zoneOffsetMs(winter, 'America/New_York')).toBe(-5 * H)
22
+ expect(zoneOffsetMs(summer, 'Asia/Kolkata')).toBe(5.5 * H)
23
+ expect(zoneOffsetMs(winter, 'Europe/London')).toBe(0)
24
+ })
25
+
26
+ it('toZonedLocal exposes the zone wall-clock via local getters', () => {
27
+ const z = toZonedLocal(summer, 'Asia/Kolkata')
28
+ expect(z.getHours()).toBe(17)
29
+ expect(z.getMinutes()).toBe(30)
30
+ })
31
+
32
+ it('fromZonedLocal round-trips back to the same instant', () => {
33
+ for (const tz of ['America/New_York', 'Asia/Kolkata', 'Europe/London', 'Australia/Sydney']) {
34
+ for (const inst of [summer, winter]) {
35
+ const back = fromZonedLocal(toZonedLocal(inst, tz), tz)
36
+ expect(back.getTime()).toBe(Math.floor(inst.getTime() / 1000) * 1000)
37
+ }
38
+ }
39
+ })
40
+
41
+ it('instantFromWallClock maps a zone wall-clock to the right instant', () => {
42
+ // 09:00 in New York on 2026-07-01 (EDT -4) === 13:00 UTC.
43
+ const inst = instantFromWallClock(2026, 7, 1, 9, 0, 0, 'America/New_York')
44
+ expect(inst.toISOString()).toBe('2026-07-01T13:00:00.000Z')
45
+ // 09:00 in Kolkata (+5:30) === 03:30 UTC.
46
+ expect(instantFromWallClock(2026, 7, 1, 9, 0, 0, 'Asia/Kolkata').toISOString()).toBe('2026-07-01T03:30:00.000Z')
47
+ })
48
+
49
+ it('handles the DST spring-forward edge without drifting', () => {
50
+ // US spring-forward 2026-03-08: 02:00 EST -> 03:00 EDT. 09:00 that day is EDT (-4).
51
+ const inst = instantFromWallClock(2026, 3, 8, 9, 0, 0, 'America/New_York')
52
+ expect(inst.toISOString()).toBe('2026-03-08T13:00:00.000Z')
53
+ // The round trip is stable for a post-transition instant.
54
+ const back = fromZonedLocal(toZonedLocal(inst, 'America/New_York'), 'America/New_York')
55
+ expect(back.getTime()).toBe(inst.getTime())
56
+ })
57
+
58
+ it('undefined zone is the identity (browser local)', () => {
59
+ expect(toZonedLocal(summer, undefined)).toBe(summer)
60
+ expect(fromZonedLocal(summer, undefined)).toBe(summer)
61
+ expect(zoneOffsetMs(summer, undefined)).toBe(-summer.getTimezoneOffset() * 60_000)
62
+ })
63
+
64
+ it('normalizeTimeZone rejects bad ids', () => {
65
+ expect(normalizeTimeZone('America/New_York')).toBe('America/New_York')
66
+ expect(normalizeTimeZone('Not/AZone')).toBeUndefined()
67
+ expect(normalizeTimeZone(undefined)).toBeUndefined()
68
+ })
69
+ })
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Pure, framework-free IANA-timezone helpers, built on `Intl` (no date library).
3
+ *
4
+ * The scheduler renders in a chosen `timeZone` via a "pseudo-local" trick:
5
+ * {@link toZonedLocal} turns an absolute instant into a Date whose *local*
6
+ * wall-clock equals the wall-clock in `timeZone`, so all the existing
7
+ * local-based date math (startOfDay, minuteOfDay, layout) runs unchanged; then
8
+ * {@link fromZonedLocal} converts a user edit back to a real instant. When
9
+ * `timeZone` is undefined every function is the identity on the browser's zone.
10
+ *
11
+ * These functions are DST-aware and take no ambient "now" (safe for the model /
12
+ * headless contexts that forbid `Date.now()`).
13
+ */
14
+
15
+ export type ZoneParts = {
16
+ year: number
17
+ month: number // 1-12
18
+ day: number
19
+ hour: number // 0-23
20
+ minute: number
21
+ second: number
22
+ }
23
+
24
+ const fmtCache = new Map<string, Intl.DateTimeFormat>()
25
+ function partsFormat(timeZone: string): Intl.DateTimeFormat {
26
+ let f = fmtCache.get(timeZone)
27
+ if (!f) {
28
+ f = new Intl.DateTimeFormat('en-US', {
29
+ timeZone,
30
+ hourCycle: 'h23',
31
+ year: 'numeric',
32
+ month: '2-digit',
33
+ day: '2-digit',
34
+ hour: '2-digit',
35
+ minute: '2-digit',
36
+ second: '2-digit',
37
+ })
38
+ fmtCache.set(timeZone, f)
39
+ }
40
+ return f
41
+ }
42
+
43
+ /** The wall-clock parts of `instant` in `timeZone` (browser-local if undefined). */
44
+ export function zoneParts(instant: Date, timeZone?: string): ZoneParts {
45
+ if (!timeZone) {
46
+ return {
47
+ year: instant.getFullYear(),
48
+ month: instant.getMonth() + 1,
49
+ day: instant.getDate(),
50
+ hour: instant.getHours(),
51
+ minute: instant.getMinutes(),
52
+ second: instant.getSeconds(),
53
+ }
54
+ }
55
+ const map: Record<string, number> = {}
56
+ for (const p of partsFormat(timeZone).formatToParts(instant)) {
57
+ if (p.type !== 'literal') map[p.type] = Number(p.value)
58
+ }
59
+ let hour = map.hour ?? 0
60
+ if (hour === 24) hour = 0 // some engines emit '24' for midnight under h23
61
+ return { year: map.year!, month: map.month!, day: map.day!, hour, minute: map.minute ?? 0, second: map.second ?? 0 }
62
+ }
63
+
64
+ /** Milliseconds `timeZone` is ahead of UTC at `instant` (DST-aware; negative west of UTC). */
65
+ export function zoneOffsetMs(instant: Date, timeZone?: string): number {
66
+ if (!timeZone) return -instant.getTimezoneOffset() * 60_000
67
+ const p = zoneParts(instant, timeZone)
68
+ const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second)
69
+ // `asUtc` reads the wall-clock as if it were UTC; its distance from the real
70
+ // instant (truncated to whole seconds, matching the parts' resolution) is the offset.
71
+ return asUtc - Math.floor(instant.getTime() / 1000) * 1000
72
+ }
73
+
74
+ /** An instant -> a Date whose LOCAL fields equal its wall-clock in `timeZone`. */
75
+ export function toZonedLocal(instant: Date, timeZone?: string): Date {
76
+ if (!timeZone) return instant
77
+ const p = zoneParts(instant, timeZone)
78
+ return new Date(p.year, p.month - 1, p.day, p.hour, p.minute, p.second, instant.getMilliseconds())
79
+ }
80
+
81
+ /** The instant whose wall-clock in `timeZone` is the given Y-M-D H:M:S. */
82
+ export function instantFromWallClock(
83
+ year: number,
84
+ month: number, // 1-12
85
+ day: number,
86
+ hour: number,
87
+ minute: number,
88
+ second = 0,
89
+ timeZone?: string,
90
+ ms = 0,
91
+ ): Date {
92
+ if (!timeZone) return new Date(year, month - 1, day, hour, minute, second, ms)
93
+ const wallUtc = Date.UTC(year, month - 1, day, hour, minute, second, ms)
94
+ // Assume the wall-clock is UTC, subtract the offset there, then correct once
95
+ // in case the candidate instant lands on the other side of a DST transition.
96
+ const off1 = zoneOffsetMs(new Date(wallUtc), timeZone)
97
+ let inst = new Date(wallUtc - off1)
98
+ const off2 = zoneOffsetMs(inst, timeZone)
99
+ if (off2 !== off1) inst = new Date(wallUtc - off2)
100
+ return inst
101
+ }
102
+
103
+ /** Inverse of {@link toZonedLocal}: a pseudo-local Date -> the real instant. */
104
+ export function fromZonedLocal(pseudo: Date, timeZone?: string): Date {
105
+ if (!timeZone) return pseudo
106
+ return instantFromWallClock(
107
+ pseudo.getFullYear(),
108
+ pseudo.getMonth() + 1,
109
+ pseudo.getDate(),
110
+ pseudo.getHours(),
111
+ pseudo.getMinutes(),
112
+ pseudo.getSeconds(),
113
+ timeZone,
114
+ pseudo.getMilliseconds(),
115
+ )
116
+ }
117
+
118
+ /** Short zone name at `instant`, e.g. "EDT" / "GMT+5:30" - for a ruler header. */
119
+ export function zoneAbbr(instant: Date, timeZone?: string): string {
120
+ if (!timeZone) return ''
121
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone, hour: '2-digit', timeZoneName: 'short' }).formatToParts(instant)
122
+ return parts.find((p) => p.type === 'timeZoneName')?.value ?? ''
123
+ }
124
+
125
+ /** Validate an IANA zone id; returns it if usable, else undefined (falls back to local). */
126
+ export function normalizeTimeZone(timeZone?: string): string | undefined {
127
+ if (!timeZone) return undefined
128
+ try {
129
+ new Intl.DateTimeFormat('en-US', { timeZone })
130
+ return timeZone
131
+ } catch {
132
+ return undefined
133
+ }
134
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared context for <SvDockLayout> and its recursive <DockNodeView>. Carries
3
+ * the pane-content snippet and the handful of callbacks the tree view needs, so
4
+ * the recursive renderer reaches them without prop-drilling through every level.
5
+ */
6
+ import type { Snippet } from 'svelte'
7
+ import type { DockPane, DockTabs, DockZone } from './dock-model'
8
+
9
+ export type DockContext = {
10
+ /** Renders a pane's content. Called as `{@render pane(dockPane)}`. */
11
+ pane: Snippet<[DockPane]>
12
+ /**
13
+ * Optional stack-header controls for a leaf (maximize / auto-hide / pop-out /
14
+ * float), rendered once per leaf at the right of its tab strip.
15
+ */
16
+ leafActions?: Snippet<[DockTabs]>
17
+ /** Make tab `index` active in the given leaf. */
18
+ activate: (tabsId: string, index: number) => void
19
+ /** Close (remove) a pane. */
20
+ close: (paneId: string) => void
21
+ /** Start dragging a pane's tab to re-dock it. */
22
+ beginDrag: (event: PointerEvent, paneId: string, tabsId: string) => void
23
+ /** Commit new size weights for a group after a splitter drag. */
24
+ resize: (groupId: string, sizes: number[]) => void
25
+ /** Minimum pane size (px) along the split axis. */
26
+ minSize: () => number
27
+ /**
28
+ * The leaf the pointer is over while dragging, for the dock guide. `zone` is
29
+ * the guide chip under the pointer (drop docks there) or `null` (over the leaf
30
+ * but off the guide - the drop will float). `centerOnly` shows just the centre
31
+ * chip (floating windows accept tabs only).
32
+ */
33
+ dropTarget: () => { tabsId: string; zone: DockZone | null; centerOnly?: boolean } | null
34
+ /** The live tab-reorder target, for the insertion-line indicator. */
35
+ reorderTarget?: () => { tabsId: string; index: number } | null
36
+ /** Which side of each leaf the tab strip sits on. Default `'top'`. */
37
+ headerPosition?: () => 'top' | 'bottom' | 'left' | 'right'
38
+ /** The id of the currently focused leaf, for the active-panel highlight. */
39
+ focusedLeaf?: () => string | null
40
+ /** Keep inactive tabs mounted (hidden) so their content state persists. */
41
+ keepAlive?: () => boolean
42
+ /** For a single-pane leaf, suppress the redundant tab button (e.g. an auto-hide fly-out,
43
+ * where the edge strip already names the panel). Leaf actions still render. */
44
+ hideSingleTab?: () => boolean
45
+ }
46
+
47
+ export const DOCK_CONTEXT = Symbol('svgrid-dock')
@@ -0,0 +1,275 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pane, tabs, group, allPaneIds, type DockNode, type DockTabs } from './dock-model'
3
+ import {
4
+ floatPane,
5
+ dockPaneOnto,
6
+ dockWindowOnto,
7
+ reorderTab,
8
+ autoHideLeaf,
9
+ autoHidePaneToSide,
10
+ pinAutoHidden,
11
+ closePane,
12
+ moveWindow,
13
+ resizeWindow,
14
+ bringToFront,
15
+ setWindowMinimized,
16
+ toggleWindowMaximized,
17
+ closeWindow,
18
+ autoHideWindow,
19
+ toggleMaximizeLeaf,
20
+ findLeafById,
21
+ addPaneToMain,
22
+ locatePane,
23
+ surfaceOfTabs,
24
+ allManagerPaneIds,
25
+ allManagerIds,
26
+ dedupeManagerNodeIds,
27
+ setManagerActive,
28
+ type DockManagerState,
29
+ } from './dock-manager-model'
30
+
31
+ function counter() {
32
+ let n = 0
33
+ return () => `m${n++}`
34
+ }
35
+ const rect = { x: 100, y: 100, width: 320, height: 240 }
36
+
37
+ /** main = [A | B], no floats, no auto-hide. Returns the SHARED id generator so
38
+ * follow-up ops mint ids from the same sequence (no collisions - mirrors how
39
+ * SvDockManager uses a single genId). */
40
+ function base(): { state: DockManagerState; A: DockTabs; B: DockTabs; g: () => string } {
41
+ const g = counter()
42
+ const A = tabs(g, [pane('a', 'A')])
43
+ const B = tabs(g, [pane('b', 'B')])
44
+ return { state: { main: group(g, 'row', [A, B]) as DockNode, floating: [], autoHide: [] }, A, B, g }
45
+ }
46
+
47
+ describe('floatPane', () => {
48
+ it('pops a pane into a floating window and collapses its old leaf', () => {
49
+ const { state, g } = base()
50
+ const next = floatPane(state, 'a', rect, g)
51
+ expect(next.floating).toHaveLength(1)
52
+ expect(next.floating[0]!.leaf.panes.map((p) => p.id)).toEqual(['a'])
53
+ // main collapsed to the bare B leaf.
54
+ expect(next.main?.type).toBe('tabs')
55
+ expect(allPaneIds(next.main!)).toEqual(['b'])
56
+ expect(locatePane(next, 'a')).toEqual({ kind: 'floating', windowId: next.floating[0]!.id })
57
+ })
58
+
59
+ it('is a no-op for a pane already alone in its float', () => {
60
+ const { state, g } = base()
61
+ const once = floatPane(state, 'a', rect, g)
62
+ const twice = floatPane(once, 'a', rect, g)
63
+ expect(twice).toBe(once)
64
+ })
65
+ })
66
+
67
+ describe('dockPaneOnto - cross surface', () => {
68
+ it('docks a floating pane back into a main leaf as a tab', () => {
69
+ const { state, B, g } = base()
70
+ const floated = floatPane(state, 'a', rect, g)
71
+ const docked = dockPaneOnto(floated, 'a', B.id, 'center', g)
72
+ expect(docked.floating).toHaveLength(0)
73
+ const leaf = docked.main as DockTabs
74
+ expect(leaf.panes.map((p) => p.id)).toEqual(['b', 'a'])
75
+ })
76
+
77
+ it('edge-docks within main', () => {
78
+ const { state, A, g } = base()
79
+ const floated = floatPane(state, 'a', rect, g) // main is now just B
80
+ // Re-dock A to the LEFT of B (A's old leaf id is gone; target B).
81
+ const bId = surfaceOfTabs(floated, (floated.main as DockTabs).id) === 'main' ? (floated.main as DockTabs).id : ''
82
+ const docked = dockPaneOnto(floated, 'a', bId, 'left', g)
83
+ expect(allPaneIds(docked.main!)).toEqual(['a', 'b'])
84
+ void A
85
+ })
86
+ })
87
+
88
+ describe('reorderTab', () => {
89
+ it('reorders tabs inside a leaf (main)', () => {
90
+ const g = counter()
91
+ const leaf = tabs(g, [pane('a', 'A'), pane('b', 'B'), pane('c', 'C')], 0)
92
+ const state: DockManagerState = { main: leaf, floating: [], autoHide: [] }
93
+ const next = reorderTab(state, leaf.id, 0, 2)
94
+ expect((next.main as DockTabs).panes.map((p) => p.id)).toEqual(['b', 'c', 'a'])
95
+ // active followed pane A.
96
+ expect((next.main as DockTabs).active).toBe(2)
97
+ })
98
+
99
+ it('reorders tabs inside a floating window', () => {
100
+ const { state, g } = base()
101
+ let s = floatPane(state, 'a', rect, g)
102
+ s = dockPaneOnto(s, 'b', s.floating[0]!.leaf.id, 'center', g) // float now [a, b]
103
+ const wid = s.floating[0]!.leaf.id
104
+ const next = reorderTab(s, wid, 0, 1)
105
+ expect(next.floating[0]!.leaf.panes.map((p) => p.id)).toEqual(['b', 'a'])
106
+ })
107
+ })
108
+
109
+ describe('auto-hide + pin', () => {
110
+ it('collapses a main leaf to an edge, then pins it back', () => {
111
+ const { state, B, g } = base()
112
+ const hidden = autoHideLeaf(state, B.id, 'right')
113
+ expect(hidden.autoHide).toHaveLength(1)
114
+ expect(hidden.autoHide[0]!.side).toBe('right')
115
+ expect(allPaneIds(hidden.main!)).toEqual(['a']) // only A left tiled
116
+ expect(allManagerPaneIds(hidden)).toEqual(['a', 'b']) // B still exists, hidden
117
+
118
+ const pinned = pinAutoHidden(hidden, hidden.autoHide[0]!.id, g)
119
+ expect(pinned.autoHide).toHaveLength(0)
120
+ expect(pinned.main?.type).toBe('group')
121
+ expect(allPaneIds(pinned.main!)).toEqual(['a', 'b']) // B re-docked on the right
122
+ })
123
+ })
124
+
125
+ describe('auto-hide to any of the 4 sides', () => {
126
+ it.each(['left', 'right', 'top', 'bottom'] as const)('collapses + pins back on the %s edge', (side) => {
127
+ const { state, B, g } = base()
128
+ const hidden = autoHideLeaf(state, B.id, side)
129
+ expect(hidden.autoHide[0]!.side).toBe(side)
130
+ const pinned = pinAutoHidden(hidden, hidden.autoHide[0]!.id, g)
131
+ const root = pinned.main as Extract<DockNode, { type: 'group' }>
132
+ expect(root.type).toBe('group')
133
+ // Row for left/right, column for top/bottom.
134
+ expect(root.direction).toBe(side === 'left' || side === 'right' ? 'row' : 'column')
135
+ expect(allPaneIds(pinned.main!).sort()).toEqual(['a', 'b'])
136
+ })
137
+
138
+ it('pins back with the requested size fraction (not 50/50)', () => {
139
+ const { state, B, g } = base()
140
+ const hidden = autoHideLeaf(state, B.id, 'right')
141
+ const pinned = pinAutoHidden(hidden, hidden.autoHide[0]!.id, g, 0.2)
142
+ const root = pinned.main as Extract<DockNode, { type: 'group' }>
143
+ // Right side => [content, pinnedLeaf]; the pinned leaf takes ~0.2.
144
+ expect(root.sizes[1]).toBeCloseTo(0.2, 1)
145
+ expect(root.sizes[0]).toBeCloseTo(0.8, 1)
146
+ })
147
+ })
148
+
149
+ describe('autoHidePaneToSide (drag a tab to an edge)', () => {
150
+ it('pulls a single pane out to an edge as its own entry', () => {
151
+ const g = counter()
152
+ const leaf = tabs(g, [pane('a', 'A'), pane('b', 'B')])
153
+ const s: DockManagerState = { main: leaf, floating: [], autoHide: [] }
154
+ const next = autoHidePaneToSide(s, 'a', 'bottom', g)
155
+ expect(next.autoHide).toHaveLength(1)
156
+ expect(next.autoHide[0]!.side).toBe('bottom')
157
+ expect(next.autoHide[0]!.leaf.panes.map((p) => p.id)).toEqual(['a'])
158
+ // 'b' stays in the tiled leaf.
159
+ expect(allPaneIds(next.main!)).toEqual(['b'])
160
+ })
161
+ })
162
+
163
+ describe('dockWindowOnto', () => {
164
+ it('docks a whole window edge-wise into main', () => {
165
+ const { state, A, g } = base()
166
+ const floated = floatPane(state, 'b', rect, g) // main = A, float = [B]
167
+ const wid = floated.floating[0]!.id
168
+ const next = dockWindowOnto(floated, wid, A.id, 'right', g)
169
+ expect(next.floating).toHaveLength(0)
170
+ expect(allPaneIds(next.main!)).toEqual(['a', 'b'])
171
+ })
172
+ })
173
+
174
+ describe('maximize a tiled leaf + addPaneToMain', () => {
175
+ it('toggles maximizedLeaf and finds a leaf by id', () => {
176
+ const { state, A } = base()
177
+ expect(findLeafById(state, A.id)?.id).toBe(A.id)
178
+ const on = toggleMaximizeLeaf(state, A.id)
179
+ expect(on.maximizedLeaf).toBe(A.id)
180
+ expect(toggleMaximizeLeaf(on, A.id).maximizedLeaf).toBeNull()
181
+ })
182
+
183
+ it('adds a pane into the first main leaf (or as main when empty)', () => {
184
+ const { state } = base()
185
+ const next = addPaneToMain(state, pane('z', 'Z'), counter())
186
+ expect(allManagerPaneIds(next)).toContain('z')
187
+ const empty: DockManagerState = { main: null, floating: [], autoHide: [] }
188
+ const filled = addPaneToMain(empty, pane('z', 'Z'), counter())
189
+ expect(filled.main?.type).toBe('tabs')
190
+ expect(allManagerPaneIds(filled)).toEqual(['z'])
191
+ })
192
+ })
193
+
194
+ describe('window controls', () => {
195
+ it('minimizes, maximizes (toggle), closes, and auto-hides a window', () => {
196
+ const { state, g } = base()
197
+ let s = floatPane(state, 'a', rect, g)
198
+ const wid = s.floating[0]!.id
199
+ s = setWindowMinimized(s, wid, true)
200
+ expect(s.floating[0]!.minimized).toBe(true)
201
+ s = toggleWindowMaximized(s, wid)
202
+ expect(s.floating[0]!.maximized).toBe(true)
203
+ expect(s.floating[0]!.minimized).toBe(false) // max clears min
204
+ s = toggleWindowMaximized(s, wid)
205
+ expect(s.floating[0]!.maximized).toBe(false)
206
+ // auto-hide the window to the left edge
207
+ s = autoHideWindow(s, wid, 'left', g)
208
+ expect(s.floating).toHaveLength(0)
209
+ expect(s.autoHide[0]!.side).toBe('left')
210
+ expect(s.autoHide[0]!.leaf.panes.map((p) => p.id)).toEqual(['a'])
211
+ })
212
+
213
+ it('closeWindow drops the window and its panes', () => {
214
+ const { state, g } = base()
215
+ const s = floatPane(state, 'a', rect, g)
216
+ const next = closeWindow(s, s.floating[0]!.id)
217
+ expect(next.floating).toHaveLength(0)
218
+ expect(allManagerPaneIds(next)).toEqual(['b'])
219
+ })
220
+ })
221
+
222
+ describe('window ops + close', () => {
223
+ it('moves, resizes, and restacks windows; closes a pane anywhere', () => {
224
+ const { state, g } = base()
225
+ let s = floatPane(state, 'a', rect, g)
226
+ const wid = s.floating[0]!.id
227
+ s = moveWindow(s, wid, 40, 60)
228
+ s = resizeWindow(s, wid, 500, 400)
229
+ expect(s.floating[0]).toMatchObject({ x: 40, y: 60, width: 500, height: 400 })
230
+ const z0 = s.floating[0]!.z
231
+ s = bringToFront(s, wid)
232
+ expect(s.floating[0]!.z).toBeGreaterThan(z0)
233
+ s = closePane(s, 'a')
234
+ expect(s.floating).toHaveLength(0)
235
+ expect(allManagerPaneIds(s)).toEqual(['b'])
236
+ })
237
+ })
238
+
239
+ describe('setManagerActive', () => {
240
+ it('sets active in a main leaf and in a float', () => {
241
+ const g = counter()
242
+ const leaf = tabs(g, [pane('a', 'A'), pane('b', 'B')], 0)
243
+ const s: DockManagerState = { main: leaf, floating: [], autoHide: [] }
244
+ expect((setManagerActive(s, leaf.id, 1).main as DockTabs).active).toBe(1)
245
+ })
246
+ })
247
+
248
+ describe('dedupeManagerNodeIds', () => {
249
+ it('reassigns duplicate node ids so keyed sibling lists never collide', () => {
250
+ // A restored workspace where two sibling leaves share a generated id (the id
251
+ // counter had reset across reloads) - the shape that threw each_key_duplicate.
252
+ const dup: DockManagerState = {
253
+ main: {
254
+ type: 'group', id: 'dm-1000000', direction: 'row', sizes: [0.5, 0.5],
255
+ children: [
256
+ { type: 'tabs', id: 'dm-1000000', panes: [pane('rail', 'Pages')], active: 0 },
257
+ { type: 'tabs', id: 'dm-1000000', panes: [pane('main', 'Canvas')], active: 0 },
258
+ ],
259
+ } as DockNode,
260
+ floating: [], autoHide: [],
261
+ }
262
+ const fixed = dedupeManagerNodeIds(dup)
263
+ const ids = allManagerIds(fixed)
264
+ // Every node id is now unique...
265
+ const nodeIds = ids.filter((i) => i.startsWith('dm-'))
266
+ expect(new Set(nodeIds).size).toBe(nodeIds.length)
267
+ // ...while pane ids (which key content) are preserved.
268
+ expect(allManagerPaneIds(fixed).sort()).toEqual(['main', 'rail'])
269
+ })
270
+
271
+ it('is value-equal on an already-unique workspace', () => {
272
+ const { state } = base()
273
+ expect(dedupeManagerNodeIds(state)).toBe(state)
274
+ })
275
+ })