@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,104 @@
1
+ /**
2
+ * createAutocomplete - the HEADLESS core behind <SvAutoComplete>: a free-text
3
+ * field with a live-filtered suggestion list (accepts ANY typed value; the
4
+ * suggestions are shortcuts). Roving active index + keyboard, exposed as
5
+ * **prop-getters** you spread onto YOUR OWN markup. No styles/DOM/portal - those
6
+ * stay in the styled component.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createAutocomplete } from '@svgrid/grid'
11
+ * let value = $state('')
12
+ * const ac = createAutocomplete({ value: () => value, suggestions: () => list, onChange: (v) => (value = v) })
13
+ * </script>
14
+ * <input {...ac.inputProps()} />
15
+ * {#if ac.open}
16
+ * <ul {...ac.listboxProps()}>
17
+ * {#each ac.filtered as opt, i (opt.value)}<li {...ac.optionProps(i)}>{opt.label}</li>{/each}
18
+ * </ul>
19
+ * {/if}
20
+ * ```
21
+ */
22
+ import { filterOptions, type ListOption } from './list-option'
23
+
24
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
25
+ export type AutocompleteConfig = {
26
+ value: () => string
27
+ onChange?: (value: string) => void
28
+ /** Suggestions - strings or {value,label} (label shown, value inserted). */
29
+ suggestions: () => ReadonlyArray<string | ListOption>
30
+ minChars?: () => number
31
+ disabled?: () => boolean
32
+ ariaLabel?: () => string | undefined
33
+ /** DOM focus hook provided by the renderer; the core never touches the DOM. */
34
+ focusInput?: () => void
35
+ }
36
+
37
+ export function createAutocomplete(config: AutocompleteConfig) {
38
+ const minChars = () => config.minChars?.() ?? 1
39
+
40
+ let open = $state(false)
41
+ let active = $state(0)
42
+
43
+ const asOptions = $derived<ListOption[]>(
44
+ config.suggestions().map((s) => (typeof s === 'string' ? { value: s, label: s } : s)),
45
+ )
46
+ const filtered = $derived(config.value().length >= minChars() ? filterOptions(asOptions, config.value()).slice(0, 10) : [])
47
+
48
+ function maybeOpen() { open = filtered.length > 0; if (open) active = 0 }
49
+ function pick(o: ListOption | undefined) { if (!o) return; config.onChange?.(String(o.value)); open = false; config.focusInput?.() }
50
+ function close() { open = false }
51
+ function onInput(e: Event) { config.onChange?.((e.currentTarget as HTMLInputElement).value); queueMicrotask(maybeOpen) }
52
+ function onKeydown(e: KeyboardEvent) {
53
+ if (!open) { if (e.key === 'ArrowDown') { queueMicrotask(maybeOpen) } return }
54
+ if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
55
+ else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
56
+ else if (e.key === 'Enter') { e.preventDefault(); pick(filtered[active]) }
57
+ else if (e.key === 'Escape') { close() }
58
+ }
59
+
60
+ return {
61
+ /** Panel open state. */
62
+ get open() { return open },
63
+ /** Highlighted suggestion index into `filtered`. */
64
+ get activeIndex() { return active },
65
+ /** Suggestions after the live substring filter (capped at 10). */
66
+ get filtered() { return filtered },
67
+ /** Controlled text value. */
68
+ get value() { return config.value() },
69
+ isActive: (i: number) => i === active,
70
+ setActive: (i: number) => { active = i },
71
+ maybeOpen,
72
+ close,
73
+ pick,
74
+ onInput,
75
+ onKeydown,
76
+ /** Spread onto the text <input>. */
77
+ inputProps: () => ({
78
+ role: 'combobox' as const,
79
+ 'aria-expanded': open,
80
+ 'aria-autocomplete': 'list' as const,
81
+ 'aria-label': config.ariaLabel?.(),
82
+ value: config.value(),
83
+ disabled: config.disabled?.() ?? false,
84
+ oninput: onInput,
85
+ onfocus: maybeOpen,
86
+ onkeydown: onKeydown,
87
+ }),
88
+ /** Spread onto the listbox container. */
89
+ listboxProps: () => ({
90
+ role: 'listbox' as const,
91
+ }),
92
+ /** Spread onto the suggestion at `index` (index into `filtered`). */
93
+ optionProps: (index: number) => ({
94
+ role: 'option' as const,
95
+ tabindex: -1,
96
+ 'aria-selected': index === active,
97
+ 'data-idx': index,
98
+ onclick: () => pick(filtered[index]),
99
+ onpointermove: () => { active = index },
100
+ }),
101
+ }
102
+ }
103
+
104
+ export type Autocomplete = ReturnType<typeof createAutocomplete>
@@ -0,0 +1,488 @@
1
+ /**
2
+ * createCalendar - the HEADLESS core behind <SvCalendar>, in the same spirit as
3
+ * `createListbox` / `createSvGrid`: a runes-based state machine (view page +
4
+ * drill level, roving focus, every Smart `selectionMode`, range hover preview,
5
+ * full keyboard) with **prop-getters** you spread onto YOUR OWN markup. No
6
+ * styles, no DOM assumptions. It reuses the framework-free date engine in
7
+ * ./datetime for all the actual date math / selection / restriction, so this
8
+ * file only owns the reactive component-level state + interaction wiring.
9
+ *
10
+ * ```svelte
11
+ * <script lang="ts">
12
+ * import { createCalendar } from '@svgrid/grid'
13
+ * let value = $state<Date | null>(new Date())
14
+ * const cal = createCalendar({ value: () => value, onChange: (d) => (value = d[0] ?? null) })
15
+ * </script>
16
+ * <div {...cal.gridProps()}>
17
+ * {#each cal.panels[0].matrix as week}
18
+ * {#each week as cell}
19
+ * <button {...cal.dayProps(cell, cal.panels[0].month)}>{cell.date.getDate()}</button>
20
+ * {/each}
21
+ * {/each}
22
+ * </div>
23
+ * ```
24
+ *
25
+ * The styled <SvCalendar> is one renderer over this core; it keeps the render-only
26
+ * concerns (WAAPI view-change animation, mouse-wheel navigation, DOM refs) itself.
27
+ */
28
+ import {
29
+ addDays,
30
+ addMonths,
31
+ addYears,
32
+ monthMatrix,
33
+ weekdayOrder,
34
+ startOfDay,
35
+ isSameDay,
36
+ isSameMonth,
37
+ toDate,
38
+ decadeRange,
39
+ type DateLike,
40
+ type MonthMatrixCell,
41
+ } from './datetime/date-core'
42
+ import {
43
+ selectDate,
44
+ isSelected,
45
+ rangeDays,
46
+ emptySelection,
47
+ type SelectionMode,
48
+ type SelectionState,
49
+ } from './datetime/date-selection'
50
+ import { isDisabledDay, isImportant, type RestrictOptions } from './datetime/date-restrict'
51
+
52
+ export type DisplayMode = 'month' | 'year' | 'decade'
53
+ export type CalendarNameFormat = 'narrow' | 'short' | 'long'
54
+
55
+ /** Selected value(s). Single Date for one/zeroOrOne, array for multi modes. */
56
+ export type CalendarValue = Date | number | string | ReadonlyArray<Date | number | string> | null
57
+
58
+ /** A one-click shortcut shown in the presets rail. `value` is a single date
59
+ * (or an [start, end] range for range mode); pass a function for "today"-
60
+ * relative shortcuts that resolve at click time. */
61
+ export type CalendarPreset = {
62
+ label: string
63
+ value:
64
+ | Date | number | string
65
+ | readonly [Date | number | string, Date | number | string]
66
+ | (() => Date | number | string | readonly [Date | number | string, Date | number | string])
67
+ }
68
+
69
+ /** How the calendar animates a navigation / drill (a render concern the styled
70
+ * component acts on; the core only reports the direction). */
71
+ export type CalendarAnimation = 'slide' | 'fade' | 'none'
72
+
73
+ /** Why the visible view last changed - drives the styled component's animation. */
74
+ export type CalendarNavDir = 'next' | 'prev' | 'drillDown' | 'drillUp' | 'fade'
75
+
76
+ /** Per-day interaction/visual state (booleans the renderer maps to classes). */
77
+ export type CalendarDayState = {
78
+ disabled: boolean
79
+ selected: boolean
80
+ important: boolean
81
+ today: boolean
82
+ outside: boolean
83
+ focused: boolean
84
+ preview: boolean
85
+ }
86
+
87
+ /** Reactive inputs are passed as getters so the core tracks live prop changes;
88
+ * callbacks are plain closures. */
89
+ export type CalendarConfig = {
90
+ value: () => CalendarValue
91
+ onChange?: (dates: Date[]) => void
92
+ onNavigate?: (viewDate: Date, displayMode: DisplayMode) => void
93
+ selectionMode?: () => SelectionMode
94
+ min?: () => DateLike | null
95
+ max?: () => DateLike | null
96
+ restrictedDates?: () => RestrictOptions['restrictedDates']
97
+ importantDates?: () => ReadonlyArray<DateLike> | ((d: Date) => boolean) | null
98
+ firstDayOfWeek?: () => number
99
+ weeks?: () => number
100
+ months?: () => number
101
+ displayMode?: () => DisplayMode
102
+ disabled?: () => boolean
103
+ readonly?: () => boolean
104
+ locale?: () => string
105
+ dayNameFormat?: () => CalendarNameFormat
106
+ monthNameFormat?: () => CalendarNameFormat
107
+ dateTooltip?: () => ((date: Date) => string | null | undefined) | undefined
108
+ }
109
+
110
+ const defaultLocale = () => (typeof navigator !== 'undefined' ? navigator.language : 'en-US')
111
+
112
+ /** Normalize a calendar value to an ordered day-midnight list (pure). */
113
+ export function normalizeCalendarValue(v: CalendarValue): Date[] {
114
+ if (v == null) return []
115
+ const list = Array.isArray(v) ? v : [v]
116
+ const out: Date[] = []
117
+ for (const item of list) {
118
+ const d = toDate(item as DateLike)
119
+ if (d) out.push(startOfDay(d))
120
+ }
121
+ return out
122
+ }
123
+
124
+ export function createCalendar(config: CalendarConfig) {
125
+ // --- Config accessors (with the same defaults as the styled component) ------
126
+ const selectionMode = () => config.selectionMode?.() ?? 'one'
127
+ const firstDayOfWeek = () => config.firstDayOfWeek?.() ?? 0
128
+ const weeksCount = () => config.weeks?.() ?? 6
129
+ const monthsCount = () => Math.max(1, config.months?.() ?? 1)
130
+ const displayMode = () => config.displayMode?.() ?? 'month'
131
+ const isDisabled = () => config.disabled?.() ?? false
132
+ const isReadonly = () => config.readonly?.() ?? false
133
+ const locale = () => config.locale?.() ?? defaultLocale()
134
+ const dayNameFormat = () => config.dayNameFormat?.() ?? 'short'
135
+ const monthNameFormat = () => config.monthNameFormat?.() ?? 'long'
136
+
137
+ // --- Internal reactive state ------------------------------------------------
138
+ // Seeded from `value`; range mode also tracks a pending rangeStart between the
139
+ // two clicks, which `value` alone cannot hold.
140
+ let sel = $state<SelectionState>(emptySelection())
141
+ // The month currently shown (first panel) + the drill level.
142
+ let view = $state<Date>(startOfDay(new Date()))
143
+ let mode = $state<DisplayMode>('month')
144
+ // Roving focus + range hover preview.
145
+ let focusDate = $state<Date>(startOfDay(new Date()))
146
+ let hoverDate = $state<Date | null>(null)
147
+ // View-change animation is a render concern that lives in the styled
148
+ // component; the core only records why the view changed and bumps a token so
149
+ // the component can replay a keyframe on the fresh DOM.
150
+ let navDir: CalendarNavDir = 'fade'
151
+ let navToken = $state(0)
152
+
153
+ const restrict = $derived<RestrictOptions>({
154
+ min: config.min?.() ?? null,
155
+ max: config.max?.() ?? null,
156
+ restrictedDates: config.restrictedDates?.() ?? null,
157
+ })
158
+ const isInteractive = $derived(!isDisabled() && !isReadonly())
159
+
160
+ // --- Value sync + one-time seed of the visible page ------------------------
161
+ let lastSyncedKey = '￿' // sentinel so the first sync always fires (incl. null value)
162
+ let seeded = false
163
+ $effect(() => {
164
+ const dates = normalizeCalendarValue(config.value())
165
+ const key = dates.map((d) => d.getTime()).join(',')
166
+ if (key !== lastSyncedKey) {
167
+ lastSyncedKey = key
168
+ sel = { dates, anchor: dates[dates.length - 1] ?? null, rangeStart: null }
169
+ if (!seeded) {
170
+ seeded = true
171
+ const first = dates[0] ?? startOfDay(new Date())
172
+ view = startOfDay(first)
173
+ focusDate = startOfDay(first)
174
+ mode = displayMode()
175
+ }
176
+ }
177
+ })
178
+
179
+ // --- Intl-driven labels (cached by the browser) ----------------------------
180
+ const weekdayFmt = $derived(new Intl.DateTimeFormat(locale(), { weekday: dayNameFormat() }))
181
+ const monthTitleFmt = $derived(new Intl.DateTimeFormat(locale(), { month: monthNameFormat(), year: 'numeric' }))
182
+ const monthShortFmt = $derived(new Intl.DateTimeFormat(locale(), { month: 'short' }))
183
+ const fullDateFmt = $derived(new Intl.DateTimeFormat(locale(), { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }))
184
+
185
+ const weekdayHeaders = $derived(
186
+ weekdayOrder(firstDayOfWeek()).map((wd) => {
187
+ // 2021-08-01 is a Sunday; offset to the requested weekday.
188
+ const d = new Date(2021, 7, 1 + wd)
189
+ return { label: weekdayFmt.format(d), weekday: wd }
190
+ }),
191
+ )
192
+
193
+ /** The month grids to render (1..months panels), each with its own title. */
194
+ const panels = $derived(
195
+ Array.from({ length: monthsCount() }, (_, i) => {
196
+ const panelMonth = addMonths(view, i)
197
+ return { month: panelMonth, title: monthTitleFmt.format(panelMonth), matrix: monthMatrix(panelMonth, firstDayOfWeek(), weeksCount()) }
198
+ }),
199
+ )
200
+
201
+ // Range preview: while a range is pending (or being hovered), show the band.
202
+ const previewRange = $derived.by(() => {
203
+ if (selectionMode() !== 'range') return null
204
+ const start = sel.rangeStart
205
+ if (!start || !hoverDate) return null
206
+ return rangeDays(start, hoverDate)
207
+ })
208
+ const inPreview = (d: Date): boolean => !!previewRange && previewRange.some((p) => isSameDay(p, d))
209
+
210
+ function dayState(d: Date, panelMonth: Date): CalendarDayState {
211
+ return {
212
+ disabled: isDisabledDay(d, restrict),
213
+ selected: isSelected(sel.dates, d),
214
+ important: isImportant(d, config.importantDates?.() ?? null),
215
+ today: isSameDay(d, new Date()),
216
+ outside: !isSameMonth(d, panelMonth),
217
+ focused: isSameDay(d, focusDate),
218
+ preview: inPreview(d),
219
+ }
220
+ }
221
+
222
+ const title = $derived(
223
+ mode === 'month'
224
+ ? monthTitleFmt.format(view)
225
+ : mode === 'year'
226
+ ? String(view.getFullYear())
227
+ : (() => {
228
+ const { start, end } = decadeRange(view.getFullYear())
229
+ return `${start} - ${end}`
230
+ })(),
231
+ )
232
+
233
+ // Year-mode cells (12 months) and decade-mode cells (10 years + 2 padding).
234
+ const yearCells = $derived(
235
+ Array.from({ length: 12 }, (_, m) => ({
236
+ index: m,
237
+ label: monthShortFmt.format(new Date(view.getFullYear(), m, 1)),
238
+ current: m === new Date().getMonth() && view.getFullYear() === new Date().getFullYear(),
239
+ selected: sel.dates.some((d) => d.getMonth() === m && d.getFullYear() === view.getFullYear()),
240
+ })),
241
+ )
242
+ const decadeCells = $derived.by(() => {
243
+ const { start } = decadeRange(view.getFullYear())
244
+ return Array.from({ length: 12 }, (_, i) => {
245
+ const year = start - 1 + i
246
+ return {
247
+ year,
248
+ outside: i === 0 || i === 11,
249
+ current: year === new Date().getFullYear(),
250
+ selected: sel.dates.some((d) => d.getFullYear() === year),
251
+ }
252
+ })
253
+ })
254
+
255
+ const canClear = $derived(
256
+ selectionMode() === 'zeroOrOne' || selectionMode() === 'zeroOrMany' || selectionMode() === 'none',
257
+ )
258
+
259
+ // --- Actions ---------------------------------------------------------------
260
+ function commit(next: SelectionState) {
261
+ sel = next
262
+ lastSyncedKey = next.dates.map((x) => x.getTime()).join(',')
263
+ config.onChange?.(next.dates.map((x) => new Date(x.getTime())))
264
+ }
265
+
266
+ function pickDay(d: Date, e?: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }) {
267
+ if (!isInteractive || isDisabledDay(d, restrict)) return
268
+ focusDate = startOfDay(d)
269
+ commit(selectDate(sel, d, selectionMode(), { ctrl: e?.ctrlKey || e?.metaKey, shift: e?.shiftKey }, firstDayOfWeek()))
270
+ }
271
+
272
+ function setView(next: Date, nextMode: DisplayMode = mode) {
273
+ view = startOfDay(next)
274
+ mode = nextMode
275
+ navToken++ // signal the styled component to replay the view-change animation
276
+ config.onNavigate?.(view, mode)
277
+ }
278
+
279
+ function drillUp() {
280
+ navDir = 'drillUp'
281
+ if (mode === 'month') setView(view, 'year')
282
+ else if (mode === 'year') setView(view, 'decade')
283
+ }
284
+
285
+ function step(dir: -1 | 1) {
286
+ navDir = dir === 1 ? 'next' : 'prev'
287
+ if (mode === 'month') setView(addMonths(view, dir))
288
+ else if (mode === 'year') setView(addYears(view, dir))
289
+ else setView(addYears(view, dir * 10))
290
+ }
291
+
292
+ function pickMonth(monthIndex: number) {
293
+ navDir = 'drillDown'
294
+ setView(new Date(view.getFullYear(), monthIndex, 1), 'month')
295
+ focusDate = new Date(view.getFullYear(), monthIndex, Math.min(focusDate.getDate(), 28))
296
+ }
297
+
298
+ function pickYear(year: number) {
299
+ navDir = 'drillDown'
300
+ setView(new Date(year, view.getMonth(), 1), 'year')
301
+ }
302
+
303
+ function goToday() {
304
+ navDir = 'fade'
305
+ const t = new Date()
306
+ setView(t, 'month')
307
+ focusDate = startOfDay(t)
308
+ if (isInteractive) pickDay(t)
309
+ }
310
+
311
+ const startOfMonthSafe = (d: Date) => new Date(d.getFullYear(), d.getMonth(), 1)
312
+ function applyPreset(p: CalendarPreset) {
313
+ if (!isInteractive) return
314
+ const resolved = typeof p.value === 'function' ? p.value() : p.value
315
+ const isRange = Array.isArray(resolved)
316
+ const pair = (isRange ? resolved : [resolved, resolved]) as ReadonlyArray<DateLike>
317
+ const start = toDate(pair[0] as DateLike)
318
+ const end = toDate(pair[1] as DateLike)
319
+ if (!start) return
320
+ focusDate = startOfDay(start)
321
+ navDir = 'fade'
322
+ view = startOfMonthSafe(start)
323
+ navToken++
324
+ if (isRange && end) {
325
+ commit({ dates: rangeDays(start, end), anchor: startOfDay(end), rangeStart: null })
326
+ } else {
327
+ commit({ dates: [startOfDay(start)], anchor: startOfDay(start), rangeStart: null })
328
+ }
329
+ }
330
+
331
+ function clearSelection() {
332
+ if (!isInteractive) return
333
+ commit(emptySelection())
334
+ }
335
+
336
+ const setHover = (d: Date | null) => (hoverDate = d)
337
+
338
+ // --- Keyboard --------------------------------------------------------------
339
+ function onNonMonthKeydown(e: KeyboardEvent) {
340
+ if (e.key === 'Enter' || e.key === ' ') {
341
+ e.preventDefault()
342
+ if (mode === 'year') pickMonth(focusDate.getMonth())
343
+ else pickYear(focusDate.getFullYear())
344
+ }
345
+ }
346
+
347
+ function onKeydown(e: KeyboardEvent) {
348
+ if (mode !== 'month') return onNonMonthKeydown(e)
349
+ let next: Date | null = null
350
+ switch (e.key) {
351
+ case 'ArrowLeft': next = addDays(focusDate, -1); break
352
+ case 'ArrowRight': next = addDays(focusDate, 1); break
353
+ case 'ArrowUp': next = addDays(focusDate, -7); break
354
+ case 'ArrowDown': next = addDays(focusDate, 7); break
355
+ case 'Home': next = addDays(focusDate, -((focusDate.getDay() - firstDayOfWeek() + 7) % 7)); break
356
+ case 'End': next = addDays(focusDate, 6 - ((focusDate.getDay() - firstDayOfWeek() + 7) % 7)); break
357
+ case 'PageUp': next = e.shiftKey ? addYears(focusDate, -1) : addMonths(focusDate, -1); break
358
+ case 'PageDown': next = e.shiftKey ? addYears(focusDate, 1) : addMonths(focusDate, 1); break
359
+ case 'Enter':
360
+ case ' ':
361
+ e.preventDefault()
362
+ pickDay(focusDate, e)
363
+ return
364
+ default:
365
+ return
366
+ }
367
+ if (next) {
368
+ e.preventDefault()
369
+ focusDate = startOfDay(next)
370
+ if (!isSameMonth(focusDate, view)) setView(focusDate, 'month')
371
+ }
372
+ }
373
+
374
+ return {
375
+ // --- Reactive state -------------------------------------------------------
376
+ get view() { return view },
377
+ get mode() { return mode },
378
+ get title() { return title },
379
+ get panels() { return panels },
380
+ get weekdayHeaders() { return weekdayHeaders },
381
+ get yearCells() { return yearCells },
382
+ get decadeCells() { return decadeCells },
383
+ get selectedDates() { return sel.dates },
384
+ get previewRange() { return previewRange },
385
+ get focusDate() { return focusDate },
386
+ get hoverDate() { return hoverDate },
387
+ get canClear() { return canClear },
388
+ get isInteractive() { return isInteractive },
389
+ /** Increments on every view/drill change; the styled component watches it to
390
+ * replay its WAAPI animation on the updated DOM. */
391
+ get navToken() { return navToken },
392
+ /** Why the view last changed (read untracked from the animation effect). */
393
+ get navDir() { return navDir },
394
+
395
+ // --- Derived helpers ------------------------------------------------------
396
+ dayState,
397
+ inPreview,
398
+ formatFullDate: (d: Date) => fullDateFmt.format(d),
399
+ formatMonthTitle: (d: Date) => monthTitleFmt.format(d),
400
+
401
+ // --- Actions --------------------------------------------------------------
402
+ pickDay,
403
+ step,
404
+ drillUp,
405
+ pickMonth,
406
+ pickYear,
407
+ goToday,
408
+ applyPreset,
409
+ clearSelection,
410
+ setHover,
411
+ onKeydown,
412
+
413
+ // --- Prop-getters (spread onto YOUR markup) -------------------------------
414
+ /** Previous / Next navigation button. */
415
+ navProps(dir: -1 | 1) {
416
+ return {
417
+ type: 'button' as const,
418
+ disabled: isDisabled(),
419
+ 'aria-label': dir === 1 ? 'Next' : 'Previous',
420
+ onclick: () => step(dir),
421
+ }
422
+ },
423
+ /** The centered title button that drills up (month -> year -> decade). */
424
+ titleProps() {
425
+ return {
426
+ type: 'button' as const,
427
+ disabled: isDisabled() || mode === 'decade',
428
+ 'aria-live': 'polite' as const,
429
+ onclick: drillUp,
430
+ }
431
+ },
432
+ /** The grid container: keyboard + ARIA. Works for month, year and decade
433
+ * modes (the handler dispatches on the current mode). */
434
+ gridProps() {
435
+ return {
436
+ role: 'grid' as const,
437
+ 'aria-label': title,
438
+ tabindex: -1,
439
+ onkeydown: onKeydown,
440
+ }
441
+ },
442
+ /** A single day button in the month grid. Combine with `dayState` for the
443
+ * visual class flags. */
444
+ dayProps(cell: MonthMatrixCell, panelMonth: Date) {
445
+ const st = dayState(cell.date, panelMonth)
446
+ return {
447
+ type: 'button' as const,
448
+ role: 'gridcell' as const,
449
+ 'aria-selected': st.selected,
450
+ 'aria-disabled': st.disabled,
451
+ 'aria-current': st.today ? ('date' as const) : undefined,
452
+ 'aria-label': fullDateFmt.format(cell.date),
453
+ title: config.dateTooltip?.()?.(cell.date) ?? undefined,
454
+ tabindex: st.focused ? 0 : -1,
455
+ disabled: st.disabled || isDisabled(),
456
+ onclick: (e: MouseEvent) => pickDay(cell.date, e),
457
+ onpointerenter: () => setHover(cell.date),
458
+ onpointerleave: () => setHover(null),
459
+ }
460
+ },
461
+ /** A month cell in year mode. */
462
+ monthCellProps(monthIndex: number) {
463
+ return {
464
+ type: 'button' as const,
465
+ disabled: isDisabled(),
466
+ onclick: () => pickMonth(monthIndex),
467
+ }
468
+ },
469
+ /** A year cell in decade mode. */
470
+ yearCellProps(year: number) {
471
+ return {
472
+ type: 'button' as const,
473
+ disabled: isDisabled(),
474
+ onclick: () => pickYear(year),
475
+ }
476
+ },
477
+ /** A preset shortcut button. */
478
+ presetProps(preset: CalendarPreset) {
479
+ return {
480
+ type: 'button' as const,
481
+ disabled: !isInteractive,
482
+ onclick: () => applyPreset(preset),
483
+ }
484
+ },
485
+ }
486
+ }
487
+
488
+ export type Calendar = ReturnType<typeof createCalendar>
@@ -0,0 +1,74 @@
1
+ /**
2
+ * createCheckbox - the HEADLESS core behind <SvCheckBox>: a controlled tri-state
3
+ * checkbox (checked / unchecked / indeterminate, ARIA `checkbox` with
4
+ * `aria-checked="mixed"`), exposed as **prop-getters** you spread onto YOUR OWN
5
+ * markup. A native <button> supplies Space/Enter activation.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createCheckbox } from '@svgrid/grid'
10
+ * let checked = $state(false)
11
+ * const cb = createCheckbox({ checked: () => checked, onChange: (v) => (checked = v) })
12
+ * </script>
13
+ * <button {...cb.boxProps()}>{cb.checked ? '✓' : ''}</button>
14
+ * ```
15
+ *
16
+ * The styled <SvCheckBox> is just one renderer over this core.
17
+ */
18
+ export type CheckboxConfig = {
19
+ checked: () => boolean
20
+ indeterminate?: () => boolean
21
+ onChange?: (checked: boolean) => void
22
+ disabled?: () => boolean
23
+ ariaLabel?: () => string | undefined
24
+ }
25
+
26
+ export type CheckboxBoxProps = {
27
+ type: 'button'
28
+ role: 'checkbox'
29
+ 'aria-checked': boolean | 'mixed'
30
+ 'aria-label': string | undefined
31
+ disabled: boolean
32
+ 'data-checked': '' | undefined
33
+ 'data-indeterminate': '' | undefined
34
+ onclick: () => void
35
+ }
36
+
37
+ export type Checkbox = {
38
+ /** True only when fully checked (never in the indeterminate state). */
39
+ readonly checked: boolean
40
+ readonly indeterminate: boolean
41
+ readonly disabled: boolean
42
+ /** Indeterminate resolves to checked; otherwise flips checked. */
43
+ toggle: () => void
44
+ /** Spread onto the checkbox element. */
45
+ boxProps: () => CheckboxBoxProps
46
+ }
47
+
48
+ export function createCheckbox(config: CheckboxConfig): Checkbox {
49
+ const checked = () => config.checked()
50
+ const indeterminate = () => config.indeterminate?.() ?? false
51
+ const disabled = () => config.disabled?.() ?? false
52
+
53
+ function toggle() {
54
+ if (disabled()) return
55
+ config.onChange?.(indeterminate() ? true : !checked())
56
+ }
57
+
58
+ return {
59
+ get checked() { return checked() },
60
+ get indeterminate() { return indeterminate() },
61
+ get disabled() { return disabled() },
62
+ toggle,
63
+ boxProps: () => ({
64
+ type: 'button',
65
+ role: 'checkbox',
66
+ 'aria-checked': indeterminate() ? 'mixed' : checked(),
67
+ 'aria-label': config.ariaLabel?.(),
68
+ disabled: disabled(),
69
+ 'data-checked': checked() && !indeterminate() ? '' : undefined,
70
+ 'data-indeterminate': indeterminate() ? '' : undefined,
71
+ onclick: toggle,
72
+ }),
73
+ }
74
+ }