@svgrid/grid 2.1.22 → 2.1.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +9 -2
  2. package/dist/DockNodeView.svelte +306 -0
  3. package/dist/DockNodeView.svelte.d.ts +7 -0
  4. package/dist/DockPopoutHost.svelte +17 -0
  5. package/dist/DockPopoutHost.svelte.d.ts +14 -0
  6. package/dist/GridMenus.svelte +83 -3
  7. package/dist/SvAutoComplete.svelte +5 -3
  8. package/dist/SvCarousel.svelte +20 -42
  9. package/dist/SvColorInput.svelte +4 -3
  10. package/dist/SvColorInput.svelte.d.ts +1 -1
  11. package/dist/SvComboBox.svelte +7 -3
  12. package/dist/SvCommand.svelte +28 -85
  13. package/dist/SvCountryInput.svelte +1 -1
  14. package/dist/SvDateRangeInput.svelte +1 -1
  15. package/dist/SvDateTimePicker.svelte +9 -3
  16. package/dist/SvDateTimePicker.svelte.d.ts +3 -0
  17. package/dist/SvDockLayout.svelte +244 -0
  18. package/dist/SvDockLayout.svelte.d.ts +77 -0
  19. package/dist/SvDockManager.svelte +922 -0
  20. package/dist/SvDockManager.svelte.d.ts +106 -0
  21. package/dist/SvDrawer.svelte +69 -47
  22. package/dist/SvDrawer.svelte.d.ts +4 -0
  23. package/dist/SvDropDownList.svelte +85 -13
  24. package/dist/SvDropDownList.svelte.d.ts +3 -0
  25. package/dist/SvField.svelte +21 -4
  26. package/dist/SvField.svelte.d.ts +1 -0
  27. package/dist/SvForm.svelte +201 -91
  28. package/dist/SvForm.svelte.d.ts +15 -22
  29. package/dist/SvGrid.controller.svelte.d.ts +32 -1
  30. package/dist/SvGrid.controller.svelte.js +156 -27
  31. package/dist/SvGrid.css +193 -5
  32. package/dist/SvGrid.svelte +357 -12
  33. package/dist/SvGrid.types.d.ts +452 -1
  34. package/dist/SvGridDropdown.svelte +8 -1
  35. package/dist/SvGridSelect.svelte +1 -0
  36. package/dist/SvListBox.svelte +36 -14
  37. package/dist/SvMaskedInput.svelte +4 -3
  38. package/dist/SvMaskedInput.svelte.d.ts +1 -1
  39. package/dist/SvMenu.svelte +1 -1
  40. package/dist/SvMenuList.svelte +23 -71
  41. package/dist/SvMenuList.svelte.d.ts +2 -13
  42. package/dist/SvModal.svelte +12 -29
  43. package/dist/SvMultiSelect.svelte +1 -0
  44. package/dist/SvNumberInput.svelte +4 -3
  45. package/dist/SvNumberInput.svelte.d.ts +1 -1
  46. package/dist/SvPagination.svelte +18 -18
  47. package/dist/SvPasswordInput.svelte +3 -2
  48. package/dist/SvPasswordInput.svelte.d.ts +1 -1
  49. package/dist/SvPhoneInput.svelte +3 -2
  50. package/dist/SvPhoneInput.svelte.d.ts +1 -1
  51. package/dist/SvPopover.svelte +1 -1
  52. package/dist/SvStepper.svelte +6 -9
  53. package/dist/SvTagsInput.svelte +3 -2
  54. package/dist/SvTagsInput.svelte.d.ts +1 -1
  55. package/dist/SvTooltip.svelte +12 -23
  56. package/dist/SvTour.svelte +32 -4
  57. package/dist/SvTreeSelect.svelte +1 -0
  58. package/dist/build-api.js +76 -54
  59. package/dist/cdn/svgrid.js +10907 -6444
  60. package/dist/cdn/svgrid.svelte-external.js +9622 -5475
  61. package/dist/columns.js +6 -0
  62. package/dist/conditional-formatting.d.ts +67 -3
  63. package/dist/conditional-formatting.js +124 -17
  64. package/dist/createCarousel.svelte.d.ts +72 -0
  65. package/dist/createCarousel.svelte.js +80 -0
  66. package/dist/createCommand.svelte.d.ts +62 -0
  67. package/dist/createCommand.svelte.js +109 -0
  68. package/dist/createForm.svelte.d.ts +62 -0
  69. package/dist/createForm.svelte.js +374 -0
  70. package/dist/createMenu.svelte.d.ts +88 -0
  71. package/dist/createMenu.svelte.js +153 -0
  72. package/dist/createOverlay.svelte.d.ts +38 -0
  73. package/dist/createOverlay.svelte.js +68 -0
  74. package/dist/createPagination.d.ts +74 -0
  75. package/dist/createPagination.js +71 -0
  76. package/dist/createPopoverSelect.svelte.js +6 -12
  77. package/dist/createStepper.d.ts +44 -0
  78. package/dist/createStepper.js +26 -0
  79. package/dist/createTooltip.svelte.d.ts +32 -0
  80. package/dist/createTooltip.svelte.js +61 -0
  81. package/dist/datetime/timezone.d.ts +36 -0
  82. package/dist/datetime/timezone.js +110 -0
  83. package/dist/dock-context.d.ts +52 -0
  84. package/dist/dock-context.js +1 -0
  85. package/dist/dock-manager-model.d.ts +136 -0
  86. package/dist/dock-manager-model.js +514 -0
  87. package/dist/dock-model.d.ts +105 -0
  88. package/dist/dock-model.js +288 -0
  89. package/dist/dock-popout.d.ts +30 -0
  90. package/dist/dock-popout.js +81 -0
  91. package/dist/editor-contract.d.ts +2 -0
  92. package/dist/filter-operators.d.ts +6 -0
  93. package/dist/filter-operators.js +35 -2
  94. package/dist/filtering/excel-filters.d.ts +18 -1
  95. package/dist/filtering/excel-filters.js +56 -0
  96. package/dist/form-field.d.ts +73 -0
  97. package/dist/form-field.js +15 -0
  98. package/dist/index.d.ts +25 -5
  99. package/dist/index.js +40 -4
  100. package/dist/list-nav.d.ts +18 -0
  101. package/dist/list-nav.js +30 -0
  102. package/dist/list-option.d.ts +2 -0
  103. package/dist/menu-item.d.ts +20 -0
  104. package/dist/menu-item.js +1 -0
  105. package/dist/menus.d.ts +5 -0
  106. package/dist/menus.js +68 -16
  107. package/dist/panel-resize.d.ts +25 -0
  108. package/dist/panel-resize.js +40 -0
  109. package/dist/popover.d.ts +29 -3
  110. package/dist/popover.js +22 -6
  111. package/dist/recurrence.d.ts +33 -7
  112. package/dist/recurrence.js +151 -13
  113. package/dist/row-drag.d.ts +8 -0
  114. package/dist/row-drag.js +11 -0
  115. package/dist/scheduler-ical.d.ts +30 -0
  116. package/dist/scheduler-ical.js +180 -0
  117. package/dist/scheduler-model.d.ts +269 -0
  118. package/dist/scheduler-model.js +602 -0
  119. package/dist/scheduler-view.svelte.d.ts +22 -0
  120. package/dist/scheduler-view.svelte.js +13 -0
  121. package/dist/spreadsheet.js +20 -12
  122. package/dist/ui-app.types.d.ts +3 -0
  123. package/package.json +5 -1
  124. package/src/DockNodeView.svelte +306 -0
  125. package/src/DockPopoutHost.svelte +17 -0
  126. package/src/GridMenus.svelte +83 -3
  127. package/src/SvAutoComplete.svelte +5 -3
  128. package/src/SvCarousel.svelte +20 -42
  129. package/src/SvCarousel.test.ts +68 -0
  130. package/src/SvColorInput.svelte +4 -3
  131. package/src/SvComboBox.svelte +7 -3
  132. package/src/SvCommand.svelte +28 -85
  133. package/src/SvCommand.test.ts +78 -0
  134. package/src/SvCountryInput.svelte +1 -1
  135. package/src/SvDateRangeInput.svelte +1 -1
  136. package/src/SvDateTimePicker.svelte +9 -3
  137. package/src/SvDockLayout.svelte +244 -0
  138. package/src/SvDockManager.svelte +922 -0
  139. package/src/SvDrawer.svelte +69 -47
  140. package/src/SvDropDownList.svelte +85 -13
  141. package/src/SvField.svelte +21 -4
  142. package/src/SvForm.svelte +201 -91
  143. package/src/SvForm.test.ts +299 -0
  144. package/src/SvGrid.controller.svelte.ts +152 -28
  145. package/src/SvGrid.css +193 -5
  146. package/src/SvGrid.svelte +357 -12
  147. package/src/SvGrid.types.ts +463 -1
  148. package/src/SvGridDropdown.svelte +8 -1
  149. package/src/SvGridSelect.svelte +1 -0
  150. package/src/SvListBox.svelte +36 -14
  151. package/src/SvMaskedInput.svelte +4 -3
  152. package/src/SvMenu.svelte +1 -1
  153. package/src/SvMenu.test.ts +24 -0
  154. package/src/SvMenuList.svelte +23 -71
  155. package/src/SvModal.svelte +12 -29
  156. package/src/SvModal.test.ts +82 -0
  157. package/src/SvMultiSelect.svelte +1 -0
  158. package/src/SvNumberInput.svelte +4 -3
  159. package/src/SvPagination.svelte +18 -18
  160. package/src/SvPasswordInput.svelte +3 -2
  161. package/src/SvPhoneInput.svelte +3 -2
  162. package/src/SvPopover.svelte +1 -1
  163. package/src/SvStepper.svelte +6 -9
  164. package/src/SvTagsInput.svelte +3 -2
  165. package/src/SvTooltip.svelte +12 -23
  166. package/src/SvTooltip.test.ts +73 -0
  167. package/src/SvTour.svelte +32 -4
  168. package/src/SvTreeSelect.svelte +1 -0
  169. package/src/build-api.ts +74 -51
  170. package/src/columns.ts +5 -0
  171. package/src/conditional-formatting.test.ts +104 -0
  172. package/src/conditional-formatting.ts +176 -19
  173. package/src/createCarousel.svelte.ts +104 -0
  174. package/src/createCommand.svelte.ts +125 -0
  175. package/src/createForm.svelte.ts +333 -0
  176. package/src/createMenu.svelte.ts +202 -0
  177. package/src/createOverlay.svelte.ts +101 -0
  178. package/src/createPagination.test.ts +65 -0
  179. package/src/createPagination.ts +84 -0
  180. package/src/createPopoverSelect.svelte.ts +5 -9
  181. package/src/createStepper.test.ts +62 -0
  182. package/src/createStepper.ts +61 -0
  183. package/src/createTooltip.svelte.ts +86 -0
  184. package/src/datetime/timezone.test.ts +69 -0
  185. package/src/datetime/timezone.ts +134 -0
  186. package/src/dock-context.ts +47 -0
  187. package/src/dock-manager-model.test.ts +275 -0
  188. package/src/dock-manager-model.ts +593 -0
  189. package/src/dock-model.test.ts +163 -0
  190. package/src/dock-model.ts +368 -0
  191. package/src/dock-popout.ts +94 -0
  192. package/src/editor-contract.ts +2 -0
  193. package/src/filter-operators.test.ts +59 -0
  194. package/src/filter-operators.ts +35 -2
  195. package/src/filtering/excel-filters.test.ts +86 -1
  196. package/src/filtering/excel-filters.ts +65 -0
  197. package/src/form-field.ts +87 -0
  198. package/src/index.ts +158 -4
  199. package/src/list-nav.test.ts +49 -0
  200. package/src/list-nav.ts +29 -0
  201. package/src/list-option.ts +2 -0
  202. package/src/menu-item.ts +21 -0
  203. package/src/menus.test.ts +48 -0
  204. package/src/menus.ts +73 -16
  205. package/src/panel-resize.ts +52 -0
  206. package/src/popover.bounds.test.ts +93 -0
  207. package/src/popover.ts +51 -7
  208. package/src/recurrence.test.ts +88 -1
  209. package/src/recurrence.ts +166 -15
  210. package/src/row-drag.ts +10 -0
  211. package/src/scheduler-ical.test.ts +68 -0
  212. package/src/scheduler-ical.ts +180 -0
  213. package/src/scheduler-model.test.ts +546 -0
  214. package/src/scheduler-model.ts +853 -0
  215. package/src/scheduler-view.svelte.ts +33 -0
  216. package/src/spreadsheet.ts +20 -12
  217. package/src/svgrid.effect-driven-filter.svelte.test.ts +116 -0
  218. package/src/ui-app.types.ts +3 -0
  219. package/src/virtual.test.ts +6 -4
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createPagination } from './createPagination'
3
+
4
+ function make(over: Partial<{ page: number; pageCount: number; disabled: boolean }> = {}) {
5
+ let page = over.page ?? 5
6
+ const onChange = vi.fn((p: number) => (page = p))
7
+ const pg = createPagination({
8
+ page: () => page,
9
+ pageCount: () => over.pageCount ?? 20,
10
+ onChange,
11
+ disabled: () => over.disabled ?? false,
12
+ })
13
+ return { pg, onChange, getPage: () => page }
14
+ }
15
+
16
+ describe('createPagination', () => {
17
+ it('builds the page/ellipsis sequence around the current page', () => {
18
+ const { pg } = make({ page: 10, pageCount: 20 })
19
+ expect(pg.items[0]).toBe(1)
20
+ expect(pg.items).toContain(10)
21
+ expect(pg.items.some((i) => i === 'ellipsis-left')).toBe(true)
22
+ expect(pg.items.some((i) => i === 'ellipsis-right')).toBe(true)
23
+ expect(pg.items.at(-1)).toBe(20)
24
+ })
25
+
26
+ it('clamps navigation to [1, pageCount] and only fires on a real change', () => {
27
+ const { pg, onChange } = make({ page: 1, pageCount: 20 })
28
+ pg.prev() // already at 1 -> clamped, no change
29
+ expect(onChange).not.toHaveBeenCalled()
30
+ pg.next()
31
+ expect(onChange).toHaveBeenLastCalledWith(2)
32
+ pg.last()
33
+ expect(onChange).toHaveBeenLastCalledWith(20)
34
+ })
35
+
36
+ it('go() clamps out-of-range targets', () => {
37
+ const { pg, onChange } = make({ page: 5, pageCount: 20 })
38
+ pg.go(999)
39
+ expect(onChange).toHaveBeenLastCalledWith(20)
40
+ pg.go(-3)
41
+ expect(onChange).toHaveBeenLastCalledWith(1)
42
+ })
43
+
44
+ it('canPrev/canNext reflect the edges', () => {
45
+ expect(make({ page: 1, pageCount: 20 }).pg.canPrev()).toBe(false)
46
+ expect(make({ page: 1, pageCount: 20 }).pg.canNext()).toBe(true)
47
+ expect(make({ page: 20, pageCount: 20 }).pg.canNext()).toBe(false)
48
+ })
49
+
50
+ it('is inert while disabled', () => {
51
+ const { pg, onChange } = make({ page: 5, pageCount: 20, disabled: true })
52
+ pg.next()
53
+ expect(onChange).not.toHaveBeenCalled()
54
+ expect(pg.canPrev()).toBe(false)
55
+ expect(pg.canNext()).toBe(false)
56
+ expect(pg.pageButtonProps(3).disabled).toBe(true)
57
+ })
58
+
59
+ it('marks the active page in prop-getters', () => {
60
+ const { pg } = make({ page: 5, pageCount: 20 })
61
+ expect(pg.isActive(5)).toBe(true)
62
+ expect(pg.pageButtonProps(5)['aria-current']).toBe('page')
63
+ expect(pg.pageButtonProps(6)['aria-current']).toBeUndefined()
64
+ })
65
+ })
@@ -0,0 +1,84 @@
1
+ /**
2
+ * createPagination - the HEADLESS core behind <SvPagination>: the page/ellipsis
3
+ * sequence (via the pure `paginationRange`) plus navigation (first/prev/next/last
4
+ * with clamping) and prop-getters you spread onto your own markup. Pure getters,
5
+ * no runes - so it is directly unit-testable and framework-free.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createPagination } from '@svgrid/grid'
10
+ * const pg = createPagination({ page: () => page, pageCount: () => 20, onChange: (p) => (page = p) })
11
+ * </script>
12
+ * <nav {...pg.navProps()}>
13
+ * <button {...pg.prevProps()}>‹</button>
14
+ * {#each pg.items as it}
15
+ * {#if typeof it === 'number'}<button {...pg.pageButtonProps(it)}>{it}</button>{:else}…{/if}
16
+ * {/each}
17
+ * <button {...pg.nextProps()}>›</button>
18
+ * </nav>
19
+ * ```
20
+ */
21
+ import { paginationRange, type PaginationItem } from './paginate'
22
+
23
+ export type PaginationConfig = {
24
+ /** 1-based current page. */
25
+ page: () => number
26
+ pageCount: () => number
27
+ onChange?: (page: number) => void
28
+ siblingCount?: () => number
29
+ boundaryCount?: () => number
30
+ disabled?: () => boolean
31
+ }
32
+
33
+ export function createPagination(config: PaginationConfig) {
34
+ const page = () => config.page()
35
+ const pageCount = () => config.pageCount()
36
+ const disabled = () => config.disabled?.() ?? false
37
+
38
+ function go(p: number) {
39
+ if (disabled()) return
40
+ const next = Math.min(Math.max(p, 1), pageCount())
41
+ if (next !== page()) config.onChange?.(next)
42
+ }
43
+ const first = () => go(1)
44
+ const prev = () => go(page() - 1)
45
+ const next = () => go(page() + 1)
46
+ const last = () => go(pageCount())
47
+ const canPrev = () => !disabled() && page() > 1
48
+ const canNext = () => !disabled() && page() < pageCount()
49
+
50
+ return {
51
+ /** The page/ellipsis sequence to render, recomputed from the live inputs. */
52
+ get items(): PaginationItem[] {
53
+ return paginationRange({
54
+ page: page(),
55
+ pageCount: pageCount(),
56
+ siblingCount: config.siblingCount?.() ?? 1,
57
+ boundaryCount: config.boundaryCount?.() ?? 1,
58
+ })
59
+ },
60
+ go,
61
+ first,
62
+ prev,
63
+ next,
64
+ last,
65
+ canPrev,
66
+ canNext,
67
+ isActive: (p: number) => p === page(),
68
+ /** Spread onto the `<nav>` wrapper. */
69
+ navProps: (ariaLabel = 'Pagination') => ({ 'aria-label': ariaLabel }),
70
+ /** Spread onto a numbered page button. */
71
+ pageButtonProps: (p: number) => ({
72
+ 'aria-current': (p === page() ? 'page' : undefined) as 'page' | undefined,
73
+ 'aria-label': `Page ${p}`,
74
+ disabled: disabled(),
75
+ onclick: () => go(p),
76
+ }),
77
+ firstProps: () => ({ 'aria-label': 'First page', disabled: !canPrev(), onclick: first }),
78
+ prevProps: () => ({ 'aria-label': 'Previous page', disabled: !canPrev(), onclick: prev }),
79
+ nextProps: () => ({ 'aria-label': 'Next page', disabled: !canNext(), onclick: next }),
80
+ lastProps: () => ({ 'aria-label': 'Last page', disabled: !canNext(), onclick: last }),
81
+ }
82
+ }
83
+
84
+ export type Pagination = ReturnType<typeof createPagination>
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { anchoredRect, type AnchoredRect } from './popover'
16
16
  import { createDismissableLayer } from './a11y/dismissable'
17
+ import { enabledIndices as enabledIndicesOf, wrapMove } from './list-nav'
17
18
 
18
19
  let uidSeq = 0
19
20
 
@@ -54,16 +55,13 @@ export function createPopoverSelect(config: PopoverSelectConfig) {
54
55
 
55
56
  let open = $state(false)
56
57
  let active = $state(0)
57
- let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false })
58
+ let rect = $state<AnchoredRect>({ top: 0, left: 0, width: 0, openUpward: false, maxHeight: 0, availHeight: 0 })
58
59
 
59
60
  const count = () => config.itemCount()
60
61
  const isDisabled = (i: number) => config.disabled?.(i) ?? false
61
62
 
62
63
  function enabledIndices(): number[] {
63
- const out: number[] = []
64
- const n = count()
65
- for (let i = 0; i < n; i++) if (!isDisabled(i)) out.push(i)
66
- return out
64
+ return enabledIndicesOf(count(), isDisabled)
67
65
  }
68
66
  function clampActive(i: number): number {
69
67
  const n = count()
@@ -88,10 +86,8 @@ export function createPopoverSelect(config: PopoverSelectConfig) {
88
86
  }
89
87
 
90
88
  function move(delta: number) {
91
- const list = enabledIndices()
92
- if (!list.length) return
93
- const pos = list.indexOf(active)
94
- active = list[(pos + delta + list.length) % list.length] ?? list[0]!
89
+ const next = wrapMove(enabledIndices(), active, delta)
90
+ if (next >= 0) active = next
95
91
  }
96
92
  function first() { active = enabledIndices()[0] ?? 0 }
97
93
  function last() { active = enabledIndices().at(-1) ?? 0 }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { createStepper } from './createStepper'
3
+
4
+ function make(over: Partial<{ current: number; count: number; linear: boolean }> = {}) {
5
+ let current = over.current ?? 2
6
+ const onChange = vi.fn((i: number) => (current = i))
7
+ const st = createStepper({
8
+ count: () => over.count ?? 5,
9
+ current: () => current,
10
+ onChange,
11
+ linear: () => over.linear ?? true,
12
+ })
13
+ return { st, onChange }
14
+ }
15
+
16
+ describe('createStepper', () => {
17
+ it('reports per-step status relative to the current step', () => {
18
+ const { st } = make({ current: 2 })
19
+ expect(st.statusOf(0)).toBe('complete')
20
+ expect(st.statusOf(1)).toBe('complete')
21
+ expect(st.statusOf(2)).toBe('active')
22
+ expect(st.statusOf(3)).toBe('upcoming')
23
+ })
24
+
25
+ it('in linear mode only reached steps are clickable', () => {
26
+ const { st } = make({ current: 2, linear: true })
27
+ expect(st.clickable(1)).toBe(true) // complete
28
+ expect(st.clickable(2)).toBe(true) // active
29
+ expect(st.clickable(3)).toBe(false) // upcoming
30
+ })
31
+
32
+ it('in free mode every step is clickable', () => {
33
+ const { st } = make({ current: 2, linear: false })
34
+ expect(st.clickable(4)).toBe(true)
35
+ })
36
+
37
+ it('go() navigates only to clickable, different steps', () => {
38
+ const { st, onChange } = make({ current: 2, linear: true })
39
+ st.go(4) // upcoming, blocked in linear mode
40
+ expect(onChange).not.toHaveBeenCalled()
41
+ st.go(0) // reached
42
+ expect(onChange).toHaveBeenLastCalledWith(0)
43
+ st.go(2) // same as current
44
+ expect(onChange).toHaveBeenCalledTimes(1)
45
+ })
46
+
47
+ it('prev/next step through (relative to current)', () => {
48
+ const fwd = make({ current: 2, linear: false })
49
+ fwd.st.next()
50
+ expect(fwd.onChange).toHaveBeenLastCalledWith(3)
51
+ const back = make({ current: 2, linear: false })
52
+ back.st.prev()
53
+ expect(back.onChange).toHaveBeenLastCalledWith(1)
54
+ })
55
+
56
+ it('stepButtonProps marks the active step and disables unreachable ones', () => {
57
+ const { st } = make({ current: 2, linear: true })
58
+ expect(st.stepButtonProps(2)['aria-current']).toBe('step')
59
+ expect(st.stepButtonProps(0)['aria-current']).toBeUndefined()
60
+ expect(st.stepButtonProps(3).disabled).toBe(true)
61
+ })
62
+ })
@@ -0,0 +1,61 @@
1
+ /**
2
+ * createStepper - the HEADLESS core behind <SvStepper>: per-step status
3
+ * (complete / active / upcoming), the linear-vs-free clickability rule, and
4
+ * navigation, as pure getters + prop-getters. No runes, framework-free, directly
5
+ * unit-testable.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createStepper } from '@svgrid/grid'
10
+ * const st = createStepper({ count: () => steps.length, current: () => i, onChange: (n) => (i = n) })
11
+ * </script>
12
+ * <ol>
13
+ * {#each steps as step, idx}
14
+ * <li class="is-{st.statusOf(idx)}">
15
+ * <button {...st.stepButtonProps(idx)}>{step.label}</button>
16
+ * </li>
17
+ * {/each}
18
+ * </ol>
19
+ * ```
20
+ */
21
+ export type StepStatus = 'complete' | 'active' | 'upcoming'
22
+
23
+ export type StepperConfig = {
24
+ count: () => number
25
+ /** 0-based index of the active step. */
26
+ current: () => number
27
+ onChange?: (index: number) => void
28
+ /** Only allow navigating to already-reached steps. Default true. */
29
+ linear?: () => boolean
30
+ }
31
+
32
+ export function createStepper(config: StepperConfig) {
33
+ const current = () => config.current()
34
+ const linear = () => config.linear?.() ?? true
35
+
36
+ const statusOf = (i: number): StepStatus =>
37
+ i < current() ? 'complete' : i === current() ? 'active' : 'upcoming'
38
+ const clickable = (i: number) => (linear() ? i <= current() : true)
39
+ const go = (i: number) => {
40
+ if (clickable(i) && i !== current()) config.onChange?.(i)
41
+ }
42
+ const next = () => go(current() + 1)
43
+ const prev = () => go(current() - 1)
44
+
45
+ return {
46
+ statusOf,
47
+ clickable,
48
+ go,
49
+ next,
50
+ prev,
51
+ isActive: (i: number) => i === current(),
52
+ /** Spread onto a step's button. */
53
+ stepButtonProps: (i: number) => ({
54
+ 'aria-current': (statusOf(i) === 'active' ? 'step' : undefined) as 'step' | undefined,
55
+ disabled: !clickable(i),
56
+ onclick: () => go(i),
57
+ }),
58
+ }
59
+ }
60
+
61
+ export type Stepper = ReturnType<typeof createStepper>
@@ -0,0 +1,86 @@
1
+ /**
2
+ * createTooltip - the HEADLESS core behind <SvTooltip>: the hover/focus open
3
+ * state machine with a show delay, Escape-to-hide, and the `aria-describedby` /
4
+ * `role="tooltip"` wiring - as prop-getters you spread onto your own anchor and
5
+ * tooltip elements. No styles, no positioning (that is a DOM concern the renderer
6
+ * owns), like `createListbox`.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createTooltip } from '@svgrid/grid'
11
+ * const tip = createTooltip({ text: () => label })
12
+ * </script>
13
+ * <span {...tip.anchorProps()}>{@render children?.()}</span>
14
+ * {#if tip.open}
15
+ * <div {...tip.tooltipProps()}>{label}</div>
16
+ * {/if}
17
+ * ```
18
+ */
19
+ import { nextEditorId } from './editor-contract'
20
+
21
+ export type TooltipConfig = {
22
+ /** The tooltip text; the tooltip never opens when it is empty. */
23
+ text?: () => string | undefined
24
+ /** Suppress the tooltip entirely. */
25
+ disabled?: () => boolean
26
+ /** Show delay in ms after hover/focus. Default 300. */
27
+ delay?: () => number
28
+ }
29
+
30
+ export type TooltipAnchorProps = {
31
+ 'aria-describedby': string | undefined
32
+ onpointerenter: () => void
33
+ onpointerleave: () => void
34
+ onfocusin: () => void
35
+ onfocusout: () => void
36
+ }
37
+
38
+ export type TooltipProps = {
39
+ id: string
40
+ role: 'tooltip'
41
+ }
42
+
43
+ export function createTooltip(config: TooltipConfig = {}) {
44
+ const id = nextEditorId('sv-tip')
45
+ let open = $state(false)
46
+ let showTimer: ReturnType<typeof setTimeout> | undefined
47
+
48
+ function show() {
49
+ if ((config.disabled?.() ?? false) || !(config.text?.() ?? '')) return
50
+ clearTimeout(showTimer)
51
+ showTimer = setTimeout(() => { open = true }, config.delay?.() ?? 300)
52
+ }
53
+ function hide() {
54
+ clearTimeout(showTimer)
55
+ open = false
56
+ }
57
+
58
+ // Escape hides the tooltip while it is open (WAI-ARIA tooltip pattern).
59
+ $effect(() => {
60
+ if (!open) return
61
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') hide() }
62
+ document.addEventListener('keydown', onKey)
63
+ return () => document.removeEventListener('keydown', onKey)
64
+ })
65
+
66
+ return {
67
+ get open() { return open },
68
+ /** Stable id of the tooltip element (for `aria-describedby` + the tip's id). */
69
+ tipId: id,
70
+ show,
71
+ hide,
72
+ /** Spread onto the anchor/trigger wrapper. Also mirror `tipId` onto the real
73
+ * focusable child's `aria-describedby` so AT announces it on focus. */
74
+ anchorProps: (): TooltipAnchorProps => ({
75
+ 'aria-describedby': open ? id : undefined,
76
+ onpointerenter: show,
77
+ onpointerleave: hide,
78
+ onfocusin: show,
79
+ onfocusout: hide,
80
+ }),
81
+ /** Spread onto the tooltip element. */
82
+ tooltipProps: (): TooltipProps => ({ id, role: 'tooltip' }),
83
+ }
84
+ }
85
+
86
+ export type Tooltip = ReturnType<typeof createTooltip>
@@ -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')