@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,125 @@
1
+ /**
2
+ * createDropdownList - the HEADLESS core behind <SvDropDownList>: a single-select
3
+ * dropdown state machine (trigger open/close, roving active index over enabled
4
+ * options, full keyboard) exposed as **prop-getters** you spread onto YOUR OWN
5
+ * markup. No styles, no DOM, no portal/measurement - those stay in the styled
6
+ * component.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createDropdownList } from '@svgrid/grid'
11
+ * let value = $state<string | null>(null)
12
+ * const dd = createDropdownList({ options: () => options, value: () => value, onChange: (v) => (value = v) })
13
+ * </script>
14
+ * <button {...dd.triggerProps()}>{dd.selected?.label ?? 'Select'}</button>
15
+ * {#if dd.open}
16
+ * <ul {...dd.listboxProps()}>
17
+ * {#each options as opt, i (opt.value)}<li {...dd.optionProps(i)}>{opt.label}</li>{/each}
18
+ * </ul>
19
+ * {/if}
20
+ * ```
21
+ */
22
+ import type { ListOption } from './list-option'
23
+
24
+ export type DropdownValue = string | number | null
25
+
26
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
27
+ export type DropdownListConfig = {
28
+ options: () => ReadonlyArray<ListOption>
29
+ value: () => DropdownValue
30
+ onChange?: (value: string | number) => void
31
+ disabled?: () => boolean
32
+ ariaLabel?: () => string | undefined
33
+ /** DOM focus hook provided by the renderer; the core never touches the DOM. */
34
+ focusTrigger?: () => void
35
+ }
36
+
37
+ export function createDropdownList(config: DropdownListConfig) {
38
+ const opts = () => config.options()
39
+ const disabled = () => config.disabled?.() ?? false
40
+
41
+ let open = $state(false)
42
+ let active = $state(-1)
43
+
44
+ const selected = $derived(opts().find((o) => o.value === config.value()) ?? null)
45
+ const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
46
+
47
+ function openPanel() {
48
+ if (disabled() || open) return
49
+ open = true
50
+ active = Math.max(0, opts().findIndex((o) => o.value === config.value()))
51
+ }
52
+ function close() { open = false }
53
+ function toggle() { if (open) { close() } else { openPanel() } }
54
+ function pick(i: number) {
55
+ const o = opts()[i]
56
+ if (!o || o.disabled) return
57
+ config.onChange?.(o.value); close(); config.focusTrigger?.()
58
+ }
59
+ function setActive(i: number) { const o = opts()[i]; if (o && !o.disabled) active = i }
60
+ function move(d: number) {
61
+ const pos = enabledIdx.indexOf(active)
62
+ active = enabledIdx[(pos + d + enabledIdx.length) % enabledIdx.length] ?? active
63
+ }
64
+ function onKeydown(e: KeyboardEvent) {
65
+ if (disabled()) return
66
+ if (!open && (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); openPanel(); return }
67
+ if (!open) return
68
+ if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
69
+ else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
70
+ else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(active) }
71
+ else if (e.key === 'Escape') { e.preventDefault(); close(); config.focusTrigger?.() }
72
+ else if (e.key === 'Home') { active = enabledIdx[0] ?? 0 }
73
+ else if (e.key === 'End') { active = enabledIdx.at(-1) ?? 0 }
74
+ }
75
+
76
+ return {
77
+ /** Panel open state. */
78
+ get open() { return open },
79
+ /** Highlighted option index into `options`. */
80
+ get activeIndex() { return active },
81
+ /** The option matching the controlled value, if any. */
82
+ get selected() { return selected },
83
+ /** Controlled value. */
84
+ get value() { return config.value() },
85
+ isActive: (i: number) => i === active,
86
+ isSelected: (o: ListOption) => o.value === config.value(),
87
+ setActive,
88
+ openPanel,
89
+ close,
90
+ toggle,
91
+ pick,
92
+ move,
93
+ onKeydown,
94
+ /** Spread onto the trigger <button>. */
95
+ triggerProps: () => ({
96
+ type: 'button' as const,
97
+ 'aria-haspopup': 'listbox' as const,
98
+ 'aria-expanded': open,
99
+ 'aria-label': config.ariaLabel?.(),
100
+ disabled: disabled(),
101
+ onclick: toggle,
102
+ onkeydown: onKeydown,
103
+ }),
104
+ /** Spread onto the listbox container. */
105
+ listboxProps: () => ({
106
+ role: 'listbox' as const,
107
+ tabindex: -1,
108
+ }),
109
+ /** Spread onto the option at `index` (index into `options`). */
110
+ optionProps: (index: number) => {
111
+ const o = opts()[index]
112
+ return {
113
+ role: 'option' as const,
114
+ tabindex: -1,
115
+ 'aria-selected': o ? o.value === config.value() : false,
116
+ 'aria-disabled': o?.disabled,
117
+ 'data-idx': index,
118
+ onclick: () => pick(index),
119
+ onpointermove: () => { if (o && !o.disabled) active = index },
120
+ }
121
+ },
122
+ }
123
+ }
124
+
125
+ export type DropdownList = ReturnType<typeof createDropdownList>
@@ -0,0 +1,86 @@
1
+ /**
2
+ * createGauge - the HEADLESS core behind <SvGauge>. A gauge is a display control,
3
+ * so this core is light: value in, computed arc/needle geometry out, plus a
4
+ * `rootProps()` with `role="meter"` + `aria-valuenow/min/max`. The styled
5
+ * <SvGauge> (or your own SVG) reads the geometry and draws it.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createGauge } from '@svgrid/grid'
10
+ * const g = createGauge({ value: () => value })
11
+ * </script>
12
+ * <div {...g.rootProps()}>
13
+ * <svg viewBox={`0 0 ${g.cx * 2} ${g.cy * 2}`}>
14
+ * <path d={g.arcPath(0, g.clamped, g.r)} />
15
+ * </svg>
16
+ * </div>
17
+ * ```
18
+ */
19
+ export type GaugePoint = { x: number; y: number }
20
+
21
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
22
+ export type GaugeConfig = {
23
+ value: () => number
24
+ min?: () => number
25
+ max?: () => number
26
+ /** Sweep angle in degrees (default 270, a classic dashboard gauge). */
27
+ sweep?: () => number
28
+ size?: () => number
29
+ thickness?: () => number
30
+ ariaLabel?: () => string | undefined
31
+ }
32
+
33
+ export function createGauge(config: GaugeConfig) {
34
+ const min = () => config.min?.() ?? 0
35
+ const max = () => config.max?.() ?? 100
36
+ const sweep = () => config.sweep?.() ?? 270
37
+ const size = () => config.size?.() ?? 180
38
+ const thickness = () => config.thickness?.() ?? 14
39
+
40
+ const clamped = $derived(Math.min(max(), Math.max(min(), config.value())))
41
+ const startAngle = $derived(90 + (360 - sweep()) / 2) // symmetric around the bottom
42
+ const cx = $derived(size() / 2)
43
+ const cy = $derived(size() / 2)
44
+ const r = $derived(size() / 2 - thickness() / 2 - 2)
45
+
46
+ const frac = (v: number) => (v - min()) / (max() - min())
47
+ const angleOf = (v: number) => startAngle + frac(v) * sweep()
48
+ function polar(angleDeg: number, radius: number): GaugePoint {
49
+ const a = (angleDeg * Math.PI) / 180
50
+ return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a) }
51
+ }
52
+ function arcPath(v0: number, v1: number, radius: number): string {
53
+ const a0 = angleOf(v0)
54
+ const a1 = angleOf(v1)
55
+ const p0 = polar(a0, radius)
56
+ const p1 = polar(a1, radius)
57
+ const large = a1 - a0 > 180 ? 1 : 0
58
+ return `M ${p0.x} ${p0.y} A ${radius} ${radius} 0 ${large} 1 ${p1.x} ${p1.y}`
59
+ }
60
+ const needleEnd = $derived(polar(angleOf(clamped), r - thickness() / 2))
61
+
62
+ return {
63
+ /** Value clamped into [min, max]. */
64
+ get clamped() { return clamped },
65
+ get startAngle() { return startAngle },
66
+ get cx() { return cx },
67
+ get cy() { return cy },
68
+ get r() { return r },
69
+ /** Needle tip point (for `<line x2 y2>`). */
70
+ get needleEnd() { return needleEnd },
71
+ frac,
72
+ angleOf,
73
+ polar,
74
+ arcPath,
75
+ /** Spread onto the gauge root element. */
76
+ rootProps: () => ({
77
+ role: 'meter' as const,
78
+ 'aria-valuenow': clamped,
79
+ 'aria-valuemin': min(),
80
+ 'aria-valuemax': max(),
81
+ 'aria-label': config.ariaLabel?.() ?? 'Gauge',
82
+ }),
83
+ }
84
+ }
85
+
86
+ export type Gauge = ReturnType<typeof createGauge>
@@ -0,0 +1,186 @@
1
+ /**
2
+ * createListbox - the HEADLESS core behind <SvListBox>, in the same spirit as
3
+ * `createSvGrid` for the grid: a runes-based state machine (roving active index,
4
+ * single/multi selection, full keyboard) with **prop-getters** you spread onto
5
+ * YOUR OWN markup. No styles, no DOM assumptions - render it however you like.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createListbox } from '@svgrid/grid'
10
+ * let value = $state<string | null>(null)
11
+ * const lb = createListbox({
12
+ * options: () => options, value: () => value,
13
+ * onChange: (v) => (value = v),
14
+ * })
15
+ * </script>
16
+ * <ul {...lb.rootProps()}>
17
+ * {#each options as opt, i}
18
+ * <li {...lb.optionProps(i)}>{opt.label}</li>
19
+ * {/each}
20
+ * </ul>
21
+ * ```
22
+ *
23
+ * The styled <SvListBox> is just one renderer over this core.
24
+ */
25
+ import type { ListOption } from './list-option'
26
+
27
+ export type ListboxValue = string | number | Array<string | number> | null
28
+
29
+ /** Reactive inputs are passed as getters so the core tracks live prop changes
30
+ * (the same controlled pattern Svelte headless libraries use). */
31
+ export type ListboxConfig = {
32
+ options: () => ReadonlyArray<ListOption>
33
+ value: () => ListboxValue
34
+ onChange?: (value: string | number | Array<string | number>) => void
35
+ multiple?: () => boolean
36
+ disabled?: () => boolean
37
+ ariaLabel?: () => string | undefined
38
+ }
39
+
40
+ export type OptionProps = {
41
+ role: 'option'
42
+ id: string
43
+ 'aria-selected': boolean
44
+ 'aria-disabled': boolean | undefined
45
+ 'data-idx': number
46
+ 'data-active': '' | undefined
47
+ 'data-selected': '' | undefined
48
+ onclick: () => void
49
+ onpointermove: () => void
50
+ }
51
+
52
+ export type ListboxRootProps = {
53
+ role: 'listbox'
54
+ 'aria-multiselectable': boolean
55
+ 'aria-label': string | undefined
56
+ 'aria-disabled': boolean
57
+ 'aria-activedescendant': string | undefined
58
+ tabindex: number
59
+ onkeydown: (e: KeyboardEvent) => void
60
+ }
61
+
62
+ export type Listbox = {
63
+ /** Currently highlighted option index (roving focus). */
64
+ readonly activeIndex: number
65
+ /** Selected values as an array (single mode -> 0 or 1 entry). */
66
+ readonly selectedValues: Array<string | number>
67
+ isSelected: (value: string | number) => boolean
68
+ isActive: (index: number) => boolean
69
+ setActive: (index: number) => void
70
+ /** Toggle (multi) / set (single) the option at `index`. */
71
+ pick: (index: number) => void
72
+ /** Move the active highlight by `delta`, wrapping over enabled options. */
73
+ move: (delta: number) => void
74
+ first: () => void
75
+ last: () => void
76
+ onKeydown: (e: KeyboardEvent) => void
77
+ /** Spread onto the list container element. */
78
+ rootProps: () => ListboxRootProps
79
+ /** Spread onto the option element at `index`. */
80
+ optionProps: (index: number) => OptionProps
81
+ }
82
+
83
+ let uid = 0
84
+
85
+ /** Normalize a listbox value to an array (pure). */
86
+ export function toSelectedArray(value: ListboxValue, multiple: boolean): Array<string | number> {
87
+ if (multiple) return Array.isArray(value) ? [...value] : value == null ? [] : [value]
88
+ return value == null ? [] : [value as string | number]
89
+ }
90
+
91
+ export function createListbox(config: ListboxConfig): Listbox {
92
+ const id = `sv-lb-${uid++}`
93
+ const opts = () => config.options()
94
+ const multiple = () => config.multiple?.() ?? false
95
+ const disabled = () => config.disabled?.() ?? false
96
+
97
+ let active = $state(0)
98
+ const selected = $derived(toSelectedArray(config.value(), multiple()))
99
+ const enabledIdx = $derived(opts().map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0))
100
+
101
+ // Keep the active index on an enabled option as options change.
102
+ $effect(() => {
103
+ const o = opts()[active]
104
+ if (!o || o.disabled) active = enabledIdx[0] ?? 0
105
+ })
106
+
107
+ const isSelected = (value: string | number) => selected.includes(value)
108
+ const isActive = (index: number) => index === active
109
+ const optionId = (index: number) => `${id}-opt-${index}`
110
+
111
+ function setActive(index: number) {
112
+ const o = opts()[index]
113
+ if (o && !o.disabled) active = index
114
+ }
115
+
116
+ function pick(index: number) {
117
+ const o = opts()[index]
118
+ if (!o || o.disabled || disabled()) return
119
+ active = index
120
+ if (multiple()) {
121
+ const set = new Set(selected)
122
+ set.has(o.value) ? set.delete(o.value) : set.add(o.value)
123
+ config.onChange?.([...set])
124
+ } else {
125
+ config.onChange?.(o.value)
126
+ }
127
+ }
128
+
129
+ function move(delta: number) {
130
+ const list = enabledIdx
131
+ if (!list.length) return
132
+ const pos = list.indexOf(active)
133
+ const next = list[(pos + delta + list.length) % list.length]
134
+ if (next != null) active = next
135
+ }
136
+ const first = () => { active = enabledIdx[0] ?? 0 }
137
+ const last = () => { active = enabledIdx.at(-1) ?? 0 }
138
+
139
+ function onKeydown(e: KeyboardEvent) {
140
+ if (disabled()) return
141
+ switch (e.key) {
142
+ case 'ArrowDown': e.preventDefault(); move(1); break
143
+ case 'ArrowUp': e.preventDefault(); move(-1); break
144
+ case 'Home': e.preventDefault(); first(); break
145
+ case 'End': e.preventDefault(); last(); break
146
+ case ' ':
147
+ case 'Enter': e.preventDefault(); pick(active); break
148
+ }
149
+ }
150
+
151
+ return {
152
+ get activeIndex() { return active },
153
+ get selectedValues() { return selected },
154
+ isSelected,
155
+ isActive,
156
+ setActive,
157
+ pick,
158
+ move,
159
+ first,
160
+ last,
161
+ onKeydown,
162
+ rootProps: () => ({
163
+ role: 'listbox',
164
+ 'aria-multiselectable': multiple(),
165
+ 'aria-label': config.ariaLabel?.(),
166
+ 'aria-disabled': disabled(),
167
+ 'aria-activedescendant': opts()[active] ? optionId(active) : undefined,
168
+ tabindex: disabled() ? -1 : 0,
169
+ onkeydown: onKeydown,
170
+ }),
171
+ optionProps: (index: number) => {
172
+ const o = opts()[index]
173
+ return {
174
+ role: 'option',
175
+ id: optionId(index),
176
+ 'aria-selected': o ? isSelected(o.value) : false,
177
+ 'aria-disabled': o?.disabled,
178
+ 'data-idx': index,
179
+ 'data-active': isActive(index) ? '' : undefined,
180
+ 'data-selected': o && isSelected(o.value) ? '' : undefined,
181
+ onclick: () => pick(index),
182
+ onpointermove: () => setActive(index),
183
+ }
184
+ },
185
+ }
186
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * createMaskedInput - the HEADLESS core behind <SvMaskedInput>: a runes-based
3
+ * state machine that owns the pattern-mask formatting (#=digit, A=letter,
4
+ * *=alnum; other chars are literals) via the pure `./datetime/mask` engine,
5
+ * exposed as **prop-getters** you spread onto YOUR OWN markup. No styles, no DOM.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createMaskedInput } from '@svgrid/grid'
10
+ * let value = $state('')
11
+ * const mi = createMaskedInput({ value: () => value, mask: () => '(###) ###-####', onChange: (m) => (value = m) })
12
+ * </script>
13
+ * <input {...mi.inputProps()} />
14
+ * ```
15
+ *
16
+ * The styled <SvMaskedInput> is just one renderer over this core.
17
+ */
18
+ import { applyMask, unmask, isMaskComplete } from './datetime/mask'
19
+
20
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
21
+ export type MaskedInputConfig = {
22
+ value: () => string
23
+ /** Called with (masked, raw, complete). */
24
+ onChange?: (masked: string, raw: string, complete: boolean) => void
25
+ mask?: () => string
26
+ placeholder?: () => string | undefined
27
+ disabled?: () => boolean
28
+ readonly?: () => boolean
29
+ ariaLabel?: () => string | undefined
30
+ }
31
+
32
+ export function createMaskedInput(config: MaskedInputConfig) {
33
+ const mask = () => config.mask?.() ?? ''
34
+ const disabled = () => config.disabled?.() ?? false
35
+ const readonly = () => config.readonly?.() ?? false
36
+
37
+ let text = $state('')
38
+ let lastRaw = ''
39
+
40
+ // Reflect controlled `value` into the masked display text.
41
+ $effect(() => {
42
+ const m = mask()
43
+ const raw = m ? unmask(config.value(), m) : config.value()
44
+ if (raw !== lastRaw) {
45
+ lastRaw = raw
46
+ text = m ? applyMask(raw, m).formatted : config.value()
47
+ }
48
+ })
49
+
50
+ function onInput(e: Event) {
51
+ const el = e.currentTarget as HTMLInputElement
52
+ const m = mask()
53
+ const raw = m ? unmask(el.value, m) : el.value
54
+ const masked = m ? applyMask(raw, m).formatted : el.value
55
+ lastRaw = raw
56
+ text = masked
57
+ el.value = masked
58
+ config.onChange?.(masked, raw, m ? isMaskComplete(masked, m) : true)
59
+ }
60
+
61
+ return {
62
+ /** The current masked display text. */
63
+ get masked() { return text },
64
+ /** The raw (unmasked) data characters. */
65
+ get raw() { return mask() ? unmask(text, mask()) : text },
66
+ /** Whether every token position in the mask is filled. */
67
+ get complete() { return mask() ? isMaskComplete(text, mask()) : true },
68
+ /** Spread onto the text input element. */
69
+ inputProps: () => ({
70
+ value: text,
71
+ type: 'text' as const,
72
+ placeholder: config.placeholder?.() ?? mask(),
73
+ disabled: disabled(),
74
+ readonly: readonly(),
75
+ 'aria-label': config.ariaLabel?.(),
76
+ oninput: onInput,
77
+ }),
78
+ }
79
+ }
80
+
81
+ export type MaskedInputCore = ReturnType<typeof createMaskedInput>
@@ -0,0 +1,193 @@
1
+ /**
2
+ * createNumberInput - the HEADLESS core behind <SvNumberInput>, in the same
3
+ * spirit as `createListbox` / `createSvGrid`: a runes-based state machine that
4
+ * owns parse / format / clamp / spinner state + keyboard, exposed as
5
+ * **prop-getters** you spread onto YOUR OWN markup. No styles, no DOM.
6
+ *
7
+ * ```svelte
8
+ * <script lang="ts">
9
+ * import { createNumberInput } from '@svgrid/grid'
10
+ * let value = $state<number | null>(0)
11
+ * const num = createNumberInput({ value: () => value, onChange: (v) => (value = v) })
12
+ * </script>
13
+ * <input {...num.inputProps()} />
14
+ * <button {...num.incrementProps()}>+</button>
15
+ * <button {...num.decrementProps()}>-</button>
16
+ * ```
17
+ *
18
+ * The styled <SvNumberInput> is just one renderer over this core.
19
+ */
20
+ import { editorAria, type EditorAriaState } from './editor-contract'
21
+
22
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
23
+ export type NumberInputConfig = {
24
+ value: () => number | null
25
+ onChange?: (value: number | null) => void
26
+ min?: () => number
27
+ max?: () => number
28
+ step?: () => number
29
+ precision?: () => number | undefined
30
+ /** Group thousands (1,234) on display. */
31
+ grouping?: () => boolean
32
+ prefix?: () => string
33
+ suffix?: () => string
34
+ placeholder?: () => string | undefined
35
+ disabled?: () => boolean
36
+ readonly?: () => boolean
37
+ // Editor contract (ARIA + validation) - folded into inputProps().
38
+ id?: () => string | undefined
39
+ invalid?: () => boolean
40
+ required?: () => boolean
41
+ error?: () => string | undefined
42
+ ariaLabel?: () => string | undefined
43
+ }
44
+
45
+ export function createNumberInput(config: NumberInputConfig) {
46
+ const min = () => config.min?.() ?? -Infinity
47
+ const max = () => config.max?.() ?? Infinity
48
+ const step = () => config.step?.() ?? 1
49
+ const precision = () => config.precision?.()
50
+ const grouping = () => config.grouping?.() ?? false
51
+ const prefix = () => config.prefix?.() ?? ''
52
+ const suffix = () => config.suffix?.() ?? ''
53
+ const disabled = () => config.disabled?.() ?? false
54
+ const readonly = () => config.readonly?.() ?? false
55
+
56
+ const isInteractive = $derived(!disabled() && !readonly())
57
+ let text = $state('')
58
+ let focused = $state(false)
59
+ let lastKey = ''
60
+
61
+ const fmt = $derived(
62
+ new Intl.NumberFormat(undefined, {
63
+ useGrouping: grouping(),
64
+ minimumFractionDigits: precision() ?? 0,
65
+ maximumFractionDigits: precision() ?? 20,
66
+ }),
67
+ )
68
+
69
+ function display(v: number | null): string {
70
+ if (v == null) return ''
71
+ return `${prefix()}${fmt.format(v)}${suffix()}`
72
+ }
73
+
74
+ // Reflect controlled `value` into the display text unless the user is editing.
75
+ $effect(() => {
76
+ const v = config.value()
77
+ const key = v == null ? 'null' : String(v)
78
+ if (key !== lastKey) {
79
+ lastKey = key
80
+ if (!focused) text = display(v)
81
+ }
82
+ })
83
+
84
+ function clamp(n: number): number {
85
+ let r = Math.min(max(), Math.max(min(), n))
86
+ const p = precision()
87
+ if (p != null) r = Math.round(r * 10 ** p) / 10 ** p
88
+ return r
89
+ }
90
+
91
+ function parse(s: string): number | null {
92
+ const cleaned = s.replace(prefix(), '').replace(suffix(), '').replace(/[,\s]/g, '').trim()
93
+ if (cleaned === '' || cleaned === '-') return null
94
+ const n = Number(cleaned)
95
+ return isNaN(n) ? null : n
96
+ }
97
+
98
+ function commit() {
99
+ const parsed = parse(text)
100
+ const next = parsed == null ? null : clamp(parsed)
101
+ lastKey = next == null ? 'null' : String(next)
102
+ config.onChange?.(next)
103
+ text = display(next)
104
+ }
105
+
106
+ function bump(dir: 1 | -1) {
107
+ if (!isInteractive) return
108
+ const base = parse(text) ?? 0
109
+ const next = clamp(base + dir * step())
110
+ lastKey = String(next)
111
+ config.onChange?.(next)
112
+ text = focused ? String(next) : display(next)
113
+ }
114
+ const increment = () => bump(1)
115
+ const decrement = () => bump(-1)
116
+
117
+ function onFocus() {
118
+ focused = true
119
+ // Show the bare number for easy editing.
120
+ const v = config.value()
121
+ text = v == null ? '' : String(v)
122
+ }
123
+ function onBlur() {
124
+ focused = false
125
+ commit()
126
+ }
127
+ function onInput(e: Event) {
128
+ text = (e.currentTarget as HTMLInputElement).value
129
+ }
130
+ function onKeydown(e: KeyboardEvent) {
131
+ if (e.key === 'ArrowUp') { e.preventDefault(); bump(1) }
132
+ else if (e.key === 'ArrowDown') { e.preventDefault(); bump(-1) }
133
+ else if (e.key === 'Enter') { commit() }
134
+ }
135
+
136
+ const ariaState = (): EditorAriaState => ({
137
+ id: config.id?.(),
138
+ invalid: config.invalid?.(),
139
+ required: config.required?.(),
140
+ error: config.error?.(),
141
+ ariaLabel: config.ariaLabel?.(),
142
+ })
143
+
144
+ return {
145
+ /** The current display text (bind this to your input's value). */
146
+ get text() { return text },
147
+ /** Whether the field is being edited. */
148
+ get focused() { return focused },
149
+ /** Whether interaction is allowed (not disabled and not readonly). */
150
+ get isInteractive() { return isInteractive },
151
+ commit,
152
+ bump,
153
+ increment,
154
+ decrement,
155
+ /** Spread onto the text input element. */
156
+ inputProps: () => ({
157
+ id: config.id?.(),
158
+ value: text,
159
+ type: 'text' as const,
160
+ inputmode: 'decimal' as const,
161
+ role: 'spinbutton' as const,
162
+ placeholder: config.placeholder?.(),
163
+ disabled: disabled(),
164
+ readonly: readonly(),
165
+ 'aria-valuenow': config.value() ?? undefined,
166
+ 'aria-valuemin': min() === -Infinity ? undefined : min(),
167
+ 'aria-valuemax': max() === Infinity ? undefined : max(),
168
+ ...editorAria(ariaState()),
169
+ oninput: onInput,
170
+ onfocus: onFocus,
171
+ onblur: onBlur,
172
+ onkeydown: onKeydown,
173
+ }),
174
+ /** Spread onto a native increment button. */
175
+ incrementProps: () => ({
176
+ type: 'button' as const,
177
+ 'aria-label': 'Increment',
178
+ disabled: !isInteractive,
179
+ tabindex: -1,
180
+ onclick: increment,
181
+ }),
182
+ /** Spread onto a native decrement button. */
183
+ decrementProps: () => ({
184
+ type: 'button' as const,
185
+ 'aria-label': 'Decrement',
186
+ disabled: !isInteractive,
187
+ tabindex: -1,
188
+ onclick: decrement,
189
+ }),
190
+ }
191
+ }
192
+
193
+ export type NumberInputCore = ReturnType<typeof createNumberInput>