@svgrid/grid 1.2.21 → 1.2.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 (190) hide show
  1. package/dist/GridFooter.svelte +34 -20
  2. package/dist/GridFooter.svelte.d.ts +8 -0
  3. package/dist/SvAutoComplete.svelte +100 -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 +669 -0
  8. package/dist/SvCalendar.svelte.d.ts +70 -0
  9. package/dist/SvCheckBox.svelte +81 -0
  10. package/dist/SvCheckBox.svelte.d.ts +19 -0
  11. package/dist/SvColorInput.svelte +187 -0
  12. package/dist/SvColorInput.svelte.d.ts +15 -0
  13. package/dist/SvComboBox.svelte +130 -0
  14. package/dist/SvComboBox.svelte.d.ts +15 -0
  15. package/dist/SvCountryInput.svelte +123 -0
  16. package/dist/SvCountryInput.svelte.d.ts +14 -0
  17. package/dist/SvDateTimePicker.svelte +322 -0
  18. package/dist/SvDateTimePicker.svelte.d.ts +37 -0
  19. package/dist/SvDropDownList.svelte +143 -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 +99 -0
  24. package/dist/SvGauge.svelte.d.ts +31 -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 +122 -0
  35. package/dist/SvListBox.svelte.d.ts +19 -0
  36. package/dist/SvMaskedInput.svelte +0 -0
  37. package/dist/SvMaskedInput.svelte.d.ts +15 -0
  38. package/dist/SvNumberInput.svelte +0 -0
  39. package/dist/SvNumberInput.svelte.d.ts +22 -0
  40. package/dist/SvPasswordInput.svelte +110 -0
  41. package/dist/SvPasswordInput.svelte.d.ts +22 -0
  42. package/dist/SvPhoneInput.svelte +0 -0
  43. package/dist/SvPhoneInput.svelte.d.ts +19 -0
  44. package/dist/SvRadioGroup.svelte +108 -0
  45. package/dist/SvRadioGroup.svelte.d.ts +23 -0
  46. package/dist/SvRating.svelte +111 -0
  47. package/dist/SvRating.svelte.d.ts +20 -0
  48. package/dist/SvRepeatButton.svelte +89 -0
  49. package/dist/SvRepeatButton.svelte.d.ts +21 -0
  50. package/dist/SvSlider.svelte +182 -0
  51. package/dist/SvSlider.svelte.d.ts +25 -0
  52. package/dist/SvSwitchButton.svelte +94 -0
  53. package/dist/SvSwitchButton.svelte.d.ts +19 -0
  54. package/dist/SvTabs.svelte +122 -0
  55. package/dist/SvTabs.svelte.d.ts +26 -0
  56. package/dist/SvTagsInput.svelte +81 -0
  57. package/dist/SvTagsInput.svelte.d.ts +19 -0
  58. package/dist/SvTimePicker.svelte +359 -0
  59. package/dist/SvTimePicker.svelte.d.ts +27 -0
  60. package/dist/SvToggleButton.svelte +57 -0
  61. package/dist/SvToggleButton.svelte.d.ts +16 -0
  62. package/dist/SvTree.svelte +210 -0
  63. package/dist/SvTree.svelte.d.ts +29 -0
  64. package/dist/build-api.js +78 -1
  65. package/dist/cdn/svgrid.js +10022 -5904
  66. package/dist/cdn/svgrid.svelte-external.js +8531 -4429
  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/datetime/date-core.d.ts +88 -0
  76. package/dist/datetime/date-core.js +165 -0
  77. package/dist/datetime/date-format.d.ts +39 -0
  78. package/dist/datetime/date-format.js +371 -0
  79. package/dist/datetime/date-restrict.d.ts +23 -0
  80. package/dist/datetime/date-restrict.js +50 -0
  81. package/dist/datetime/date-selection.d.ts +25 -0
  82. package/dist/datetime/date-selection.js +94 -0
  83. package/dist/datetime/mask.d.ts +21 -0
  84. package/dist/datetime/mask.js +90 -0
  85. package/dist/editing.d.ts +1 -1
  86. package/dist/editing.js +7 -0
  87. package/dist/editors/cell-editors.d.ts +2 -2
  88. package/dist/editors/cell-editors.js +3 -1
  89. package/dist/export-format.d.ts +178 -0
  90. package/dist/export-format.js +419 -0
  91. package/dist/hyperformula-adapter.d.ts +4 -0
  92. package/dist/hyperformula-adapter.js +15 -6
  93. package/dist/index.d.ts +36 -0
  94. package/dist/index.js +41 -0
  95. package/dist/list-option.d.ts +10 -0
  96. package/dist/list-option.js +7 -0
  97. package/dist/menus.js +18 -2
  98. package/dist/popover.d.ts +48 -0
  99. package/dist/popover.js +65 -0
  100. package/dist/row-resize.d.ts +4 -4
  101. package/dist/row-resize.js +8 -5
  102. package/dist/selection.d.ts +6 -0
  103. package/dist/selection.js +31 -5
  104. package/dist/server-data-source.d.ts +43 -0
  105. package/dist/server-data-source.js +100 -0
  106. package/dist/sparkline.js +8 -4
  107. package/dist/svgrid-wrapper.types.d.ts +26 -1
  108. package/package.json +6 -1
  109. package/src/GridFooter.svelte +34 -20
  110. package/src/SvAutoComplete.svelte +100 -0
  111. package/src/SvButton.svelte +97 -0
  112. package/src/SvCalendar.svelte +669 -0
  113. package/src/SvCalendar.test.ts +211 -0
  114. package/src/SvCheckBox.svelte +81 -0
  115. package/src/SvColorInput.svelte +187 -0
  116. package/src/SvComboBox.svelte +130 -0
  117. package/src/SvCountryInput.svelte +123 -0
  118. package/src/SvDateTimePicker.svelte +322 -0
  119. package/src/SvDateTimePicker.test.ts +136 -0
  120. package/src/SvDropDownList.svelte +143 -0
  121. package/src/SvForm.svelte +147 -0
  122. package/src/SvGauge.svelte +99 -0
  123. package/src/SvGrid.controller.svelte.ts +74 -8
  124. package/src/SvGrid.css +84 -31
  125. package/src/SvGrid.helpers.ts +41 -12
  126. package/src/SvGrid.svelte +84 -5
  127. package/src/SvGrid.types.ts +33 -0
  128. package/src/SvGridDropdown.svelte +16 -30
  129. package/src/SvListBox.svelte +122 -0
  130. package/src/SvMaskedInput.svelte +0 -0
  131. package/src/SvNumberInput.svelte +0 -0
  132. package/src/SvPasswordInput.svelte +110 -0
  133. package/src/SvPhoneInput.svelte +0 -0
  134. package/src/SvRadioGroup.svelte +108 -0
  135. package/src/SvRating.svelte +111 -0
  136. package/src/SvRepeatButton.svelte +89 -0
  137. package/src/SvSlider.svelte +182 -0
  138. package/src/SvSwitchButton.svelte +94 -0
  139. package/src/SvTabs.svelte +122 -0
  140. package/src/SvTagsInput.svelte +81 -0
  141. package/src/SvTimePicker.svelte +359 -0
  142. package/src/SvTimePicker.test.ts +98 -0
  143. package/src/SvToggleButton.svelte +57 -0
  144. package/src/SvTree.svelte +210 -0
  145. package/src/build-api.ts +115 -0
  146. package/src/cell-render.test.ts +38 -0
  147. package/src/cell-render.ts +43 -0
  148. package/src/clipboard.test.ts +18 -0
  149. package/src/clipboard.ts +24 -1
  150. package/src/collaboration.test.ts +30 -0
  151. package/src/collaboration.ts +6 -0
  152. package/src/core.ts +27 -3
  153. package/src/countries.ts +71 -0
  154. package/src/datetime/date-core.test.ts +217 -0
  155. package/src/datetime/date-core.ts +204 -0
  156. package/src/datetime/date-format.test.ts +121 -0
  157. package/src/datetime/date-format.ts +317 -0
  158. package/src/datetime/date-restrict.ts +60 -0
  159. package/src/datetime/date-selection.test.ts +129 -0
  160. package/src/datetime/date-selection.ts +137 -0
  161. package/src/datetime/mask.test.ts +36 -0
  162. package/src/datetime/mask.ts +84 -0
  163. package/src/editing.test.ts +22 -0
  164. package/src/editing.ts +7 -1
  165. package/src/editors/cell-editors.ts +7 -1
  166. package/src/export-data-api.test.ts +126 -0
  167. package/src/export-format.test.ts +107 -0
  168. package/src/export-format.ts +598 -0
  169. package/src/hyperformula-adapter.test.ts +35 -1
  170. package/src/hyperformula-adapter.ts +18 -6
  171. package/src/index.ts +71 -0
  172. package/src/list-option.ts +15 -0
  173. package/src/menus.ts +21 -2
  174. package/src/popover.ts +79 -0
  175. package/src/row-resize.test.ts +25 -0
  176. package/src/row-resize.ts +8 -5
  177. package/src/selection.test.ts +36 -4
  178. package/src/selection.ts +30 -9
  179. package/src/server-data-source.test.ts +201 -0
  180. package/src/server-data-source.ts +148 -0
  181. package/src/sparkline.test.ts +9 -0
  182. package/src/sparkline.ts +9 -4
  183. package/src/svgrid-wrapper.types.ts +28 -1
  184. package/src/svgrid.behavior.test.ts +27 -0
  185. package/src/svgrid.new-features.wrapper.test.ts +27 -1
  186. package/src/ui-buttons.test.ts +105 -0
  187. package/src/ui-composite.test.ts +89 -0
  188. package/src/ui-inputs.test.ts +92 -0
  189. package/src/ui-range.test.ts +61 -0
  190. package/src/ui-selection.test.ts +106 -0
@@ -0,0 +1,359 @@
1
+ <script lang="ts" module>
2
+ export type TimeValue = Date | string | number | null
3
+ </script>
4
+
5
+ <script lang="ts">
6
+ /**
7
+ * SvTimePicker - an analog clock-dial time picker (parity: Smart
8
+ * `smart-time-picker`). 12/24-hour, minute snapping, hour->minute auto-switch,
9
+ * pointer-drag + keyboard. Works standalone and as the SvGrid `time` editor.
10
+ * All visuals come from `--sg-*` tokens so every theme applies for free.
11
+ */
12
+ type Props = {
13
+ /** Date, "HH:MM[:SS]" string, or epoch ms. */
14
+ value?: TimeValue
15
+ /** Fires with a Date (today's date carrying the picked time). */
16
+ onChange?: (value: Date) => void
17
+ format?: '12-hour' | '24-hour'
18
+ minuteInterval?: number
19
+ /** After picking an hour, jump the dial to minutes. Default true. */
20
+ autoSwitchToMinutes?: boolean
21
+ /** Show the Now footer button. */
22
+ footer?: boolean
23
+ disabled?: boolean
24
+ readonly?: boolean
25
+ name?: string
26
+ /** Which dial opens first. */
27
+ selection?: 'hour' | 'minute'
28
+ }
29
+
30
+ let {
31
+ value = null,
32
+ onChange,
33
+ format = '24-hour',
34
+ minuteInterval = 1,
35
+ autoSwitchToMinutes = true,
36
+ footer = false,
37
+ disabled = false,
38
+ readonly = false,
39
+ name,
40
+ selection = 'hour',
41
+ }: Props = $props()
42
+
43
+ const is12 = $derived(format === '12-hour')
44
+ const isInteractive = $derived(!disabled && !readonly)
45
+
46
+ function parseValue(v: TimeValue): { h: number; m: number } {
47
+ if (v == null) { const n = new Date(); return { h: n.getHours(), m: n.getMinutes() } }
48
+ if (typeof v === 'string') {
49
+ const mt = v.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?/)
50
+ if (mt) return { h: Math.min(23, +mt[1]!), m: Math.min(59, +mt[2]!) }
51
+ const d = new Date(v)
52
+ if (!isNaN(d.getTime())) return { h: d.getHours(), m: d.getMinutes() }
53
+ const n = new Date(); return { h: n.getHours(), m: n.getMinutes() }
54
+ }
55
+ const d = new Date(v)
56
+ return { h: d.getHours(), m: d.getMinutes() }
57
+ }
58
+
59
+ let hours = $state(0)
60
+ let minutes = $state(0)
61
+ let sel = $state<'hour' | 'minute'>('hour')
62
+ let seeded = false
63
+ let lastKey = '￿'
64
+ $effect(() => {
65
+ const { h, m } = parseValue(value)
66
+ const key = `${h}:${m}`
67
+ if (key !== lastKey) {
68
+ lastKey = key
69
+ hours = h
70
+ minutes = m
71
+ if (!seeded) { seeded = true; sel = selection }
72
+ }
73
+ })
74
+
75
+ const displayHour = $derived(is12 ? (hours % 12 === 0 ? 12 : hours % 12) : hours)
76
+ const isPm = $derived(hours >= 12)
77
+ const mm = $derived(String(minutes).padStart(2, '0'))
78
+
79
+ // --- Dial geometry ---
80
+ const SIZE = 240
81
+ const C = SIZE / 2
82
+ const OUTER = C - 22
83
+ const INNER = C - 58
84
+
85
+ function pointAt(angleDeg: number, r: number) {
86
+ const rad = ((angleDeg - 90) * Math.PI) / 180
87
+ return { x: C + r * Math.cos(rad), y: C + r * Math.sin(rad) }
88
+ }
89
+
90
+ type Tick = { label: string; x: number; y: number; value: number; ring: 'outer' | 'inner' }
91
+
92
+ const hourTicks = $derived.by<Tick[]>(() => {
93
+ const ticks: Tick[] = []
94
+ if (is12) {
95
+ for (let i = 0; i < 12; i++) {
96
+ const h = i === 0 ? 12 : i
97
+ const p = pointAt(i * 30, OUTER)
98
+ ticks.push({ label: String(h), x: p.x, y: p.y, value: h, ring: 'outer' })
99
+ }
100
+ } else {
101
+ for (let i = 0; i < 12; i++) {
102
+ const outer = pointAt(i * 30, OUTER) // 12..23
103
+ const inner = pointAt(i * 30, INNER) // 00..11
104
+ const ho = i + 12
105
+ const hi = i
106
+ ticks.push({ label: String(ho).padStart(2, '0'), x: outer.x, y: outer.y, value: ho, ring: 'outer' })
107
+ ticks.push({ label: hi === 0 ? '00' : String(hi).padStart(2, '0'), x: inner.x, y: inner.y, value: hi, ring: 'inner' })
108
+ }
109
+ }
110
+ return ticks
111
+ })
112
+
113
+ const minuteTicks = $derived.by<Tick[]>(() =>
114
+ Array.from({ length: 12 }, (_, i) => {
115
+ const v = i * 5
116
+ const p = pointAt(i * 30, OUTER)
117
+ return { label: String(v).padStart(2, '0'), x: p.x, y: p.y, value: v, ring: 'outer' as const }
118
+ }),
119
+ )
120
+
121
+ // Hand endpoint for the current selection.
122
+ const handAngle = $derived(
123
+ sel === 'hour'
124
+ ? (is12 ? (displayHour % 12) * 30 : (hours % 12) * 30)
125
+ : minutes * 6,
126
+ )
127
+ const handRadius = $derived(sel === 'hour' && !is12 && hours % 12 === hours && hours < 12 ? INNER : OUTER)
128
+ const handEnd = $derived(pointAt(handAngle, handRadius))
129
+
130
+ // --- Emit ---
131
+ function emit() {
132
+ const d = new Date()
133
+ d.setHours(hours, minutes, 0, 0)
134
+ onChange?.(d)
135
+ }
136
+
137
+ function setHourFromDisplay(h12OrH: number, ring: 'outer' | 'inner') {
138
+ if (is12) {
139
+ // Preserve AM/PM.
140
+ const base = h12OrH % 12
141
+ hours = isPm ? base + 12 : base
142
+ } else {
143
+ hours = ring === 'inner' ? h12OrH : h12OrH
144
+ }
145
+ lastKey = `${hours}:${minutes}`
146
+ emit()
147
+ if (autoSwitchToMinutes) sel = 'minute'
148
+ }
149
+
150
+ function snap(m: number) {
151
+ const step = Math.max(1, Math.floor(minuteInterval))
152
+ return (Math.round(m / step) * step) % 60
153
+ }
154
+
155
+ function setMinute(m: number) {
156
+ minutes = snap(m)
157
+ lastKey = `${hours}:${minutes}`
158
+ emit()
159
+ }
160
+
161
+ function setAmPm(pm: boolean) {
162
+ if (pm === isPm) return
163
+ hours = pm ? (hours % 12) + 12 : hours % 12
164
+ lastKey = `${hours}:${minutes}`
165
+ emit()
166
+ }
167
+
168
+ // --- Pointer drag on the dial ---
169
+ let dialEl: SVGSVGElement | null = null
170
+ let dragging = false
171
+
172
+ function angleFromEvent(e: PointerEvent): { angle: number; dist: number } {
173
+ const rect = dialEl!.getBoundingClientRect()
174
+ const scale = SIZE / rect.width
175
+ const x = (e.clientX - rect.left) * scale - C
176
+ const y = (e.clientY - rect.top) * scale - C
177
+ let deg = (Math.atan2(y, x) * 180) / Math.PI + 90
178
+ if (deg < 0) deg += 360
179
+ return { angle: deg, dist: Math.hypot(x, y) }
180
+ }
181
+
182
+ function applyPointer(e: PointerEvent) {
183
+ if (!isInteractive) return
184
+ const { angle, dist } = angleFromEvent(e)
185
+ if (sel === 'hour') {
186
+ const idx = Math.round(angle / 30) % 12
187
+ if (is12) {
188
+ const h = idx === 0 ? 12 : idx
189
+ const base = h % 12
190
+ hours = isPm ? base + 12 : base
191
+ } else {
192
+ const inner = dist < (OUTER + INNER) / 2
193
+ hours = inner ? idx : idx + 12
194
+ }
195
+ lastKey = `${hours}:${minutes}`
196
+ emit()
197
+ } else {
198
+ const m = Math.round(angle / 6) % 60
199
+ setMinute(m)
200
+ }
201
+ }
202
+
203
+ function onDialPointerDown(e: PointerEvent) {
204
+ if (!isInteractive) return
205
+ dragging = true
206
+ dialEl?.setPointerCapture(e.pointerId)
207
+ applyPointer(e)
208
+ }
209
+ function onDialPointerMove(e: PointerEvent) {
210
+ if (dragging) applyPointer(e)
211
+ }
212
+ function onDialPointerUp(e: PointerEvent) {
213
+ if (!dragging) return
214
+ dragging = false
215
+ dialEl?.releasePointerCapture(e.pointerId)
216
+ if (sel === 'hour' && autoSwitchToMinutes) sel = 'minute'
217
+ }
218
+
219
+ function onKeydown(e: KeyboardEvent) {
220
+ if (!isInteractive) return
221
+ const step = sel === 'minute' ? Math.max(1, Math.floor(minuteInterval)) : 1
222
+ if (e.key === 'ArrowUp' || e.key === 'ArrowRight') {
223
+ e.preventDefault()
224
+ if (sel === 'hour') { hours = (hours + 1) % 24 } else { minutes = (minutes + step) % 60 }
225
+ lastKey = `${hours}:${minutes}`; emit()
226
+ } else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') {
227
+ e.preventDefault()
228
+ if (sel === 'hour') { hours = (hours + 23) % 24 } else { minutes = (minutes - step + 60) % 60 }
229
+ lastKey = `${hours}:${minutes}`; emit()
230
+ } else if (e.key === 'Tab' && !e.shiftKey && sel === 'hour') {
231
+ sel = 'minute'
232
+ }
233
+ }
234
+
235
+ function now() {
236
+ const n = new Date()
237
+ hours = n.getHours(); minutes = n.getMinutes()
238
+ lastKey = `${hours}:${minutes}`
239
+ emit()
240
+ }
241
+
242
+ function isActiveHour(t: Tick): boolean {
243
+ if (is12) return t.value === displayHour
244
+ return t.value === hours
245
+ }
246
+ </script>
247
+
248
+ <div class="sv-tp" class:sv-tp--disabled={disabled} role="group" aria-label="Time picker">
249
+ <div class="sv-tp__head">
250
+ <button type="button" class="sv-tp__seg" class:is-active={sel === 'hour'} onclick={() => (sel = 'hour')} disabled={disabled}>
251
+ {String(displayHour).padStart(2, '0')}
252
+ </button>
253
+ <span class="sv-tp__colon">:</span>
254
+ <button type="button" class="sv-tp__seg" class:is-active={sel === 'minute'} onclick={() => (sel = 'minute')} disabled={disabled}>
255
+ {mm}
256
+ </button>
257
+ {#if is12}
258
+ <div class="sv-tp__ampm">
259
+ <button type="button" class:is-active={!isPm} onclick={() => setAmPm(false)} disabled={disabled}>AM</button>
260
+ <button type="button" class:is-active={isPm} onclick={() => setAmPm(true)} disabled={disabled}>PM</button>
261
+ </div>
262
+ {/if}
263
+ </div>
264
+
265
+ <svg
266
+ bind:this={dialEl}
267
+ class="sv-tp__dial"
268
+ viewBox={`0 0 ${SIZE} ${SIZE}`}
269
+ width="100%"
270
+ role="slider"
271
+ tabindex={disabled ? -1 : 0}
272
+ aria-label={sel === 'hour' ? 'Hour' : 'Minute'}
273
+ aria-valuenow={sel === 'hour' ? hours : minutes}
274
+ aria-valuemin="0"
275
+ aria-valuemax={sel === 'hour' ? 23 : 59}
276
+ onpointerdown={onDialPointerDown}
277
+ onpointermove={onDialPointerMove}
278
+ onpointerup={onDialPointerUp}
279
+ onpointercancel={onDialPointerUp}
280
+ onkeydown={onKeydown}
281
+ >
282
+ <circle class="sv-tp__face" cx={C} cy={C} r={C - 4} />
283
+ <!-- hand -->
284
+ <line class="sv-tp__hand" x1={C} y1={C} x2={handEnd.x} y2={handEnd.y} />
285
+ <circle class="sv-tp__hub" cx={C} cy={C} r="3" />
286
+ <circle class="sv-tp__knob" cx={handEnd.x} cy={handEnd.y} r="15" />
287
+ {#if sel === 'hour'}
288
+ {#each hourTicks as t (t.ring + '-' + t.value)}
289
+ <text class="sv-tp__num" class:is-active={isActiveHour(t)} class:is-inner={t.ring === 'inner'} x={t.x} y={t.y} dominant-baseline="central" text-anchor="middle">{t.label}</text>
290
+ {/each}
291
+ {:else}
292
+ {#each minuteTicks as t (t.value)}
293
+ <text class="sv-tp__num" class:is-active={t.value === minutes} x={t.x} y={t.y} dominant-baseline="central" text-anchor="middle">{t.label}</text>
294
+ {/each}
295
+ {/if}
296
+ </svg>
297
+
298
+ {#if footer}
299
+ <div class="sv-tp__footer">
300
+ <button type="button" class="sv-tp__now" onclick={now} disabled={disabled}>Now</button>
301
+ </div>
302
+ {/if}
303
+
304
+ {#if name}<input type="hidden" {name} value={`${String(hours).padStart(2, '0')}:${mm}`} />{/if}
305
+ </div>
306
+
307
+ <style>
308
+ .sv-tp {
309
+ --_accent: var(--sg-accent, #2563eb);
310
+ --_bg: var(--sg-bg, #fff);
311
+ --_fg: var(--sg-fg, #0f172a);
312
+ --_muted: var(--sg-muted, #64748b);
313
+ --_border: var(--sg-border, #e2e8f0);
314
+ --_face: var(--sg-header-bg, #f1f5f9);
315
+ --_radius: var(--sg-radius, 8px);
316
+ display: inline-flex; flex-direction: column; gap: 10px; align-items: center;
317
+ width: 260px; padding: 12px;
318
+ background: var(--_bg); color: var(--_fg);
319
+ border: 1px solid var(--_border); border-radius: calc(var(--_radius) + 4px);
320
+ user-select: none;
321
+ }
322
+ .sv-tp--disabled { opacity: 0.55; pointer-events: none; }
323
+
324
+ .sv-tp__head { display: flex; align-items: center; gap: 2px; font-variant-numeric: tabular-nums; }
325
+ .sv-tp__seg {
326
+ font-size: 30px; font-weight: 700; line-height: 1;
327
+ background: none; border: 0; color: var(--_muted); cursor: pointer;
328
+ padding: 2px 6px; border-radius: var(--_radius);
329
+ }
330
+ .sv-tp__seg.is-active { color: var(--_accent); background: color-mix(in srgb, var(--_accent) 12%, transparent); }
331
+ .sv-tp__colon { font-size: 28px; font-weight: 700; color: var(--_muted); }
332
+ .sv-tp__ampm { display: flex; flex-direction: column; margin-left: 8px; gap: 2px; }
333
+ .sv-tp__ampm button {
334
+ font-size: 11px; font-weight: 700; padding: 3px 8px;
335
+ background: none; border: 1px solid var(--_border); border-radius: 6px; color: var(--_muted); cursor: pointer;
336
+ }
337
+ .sv-tp__ampm button.is-active { background: var(--_accent); color: var(--sg-on-accent, #fff); border-color: var(--_accent); }
338
+
339
+ .sv-tp__dial { touch-action: none; cursor: pointer; outline: none; display: block; width: 100%; height: auto; aspect-ratio: 1; }
340
+ .sv-tp__dial:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; border-radius: 50%; }
341
+ .sv-tp__face { fill: var(--_face); }
342
+ .sv-tp__hand { stroke: var(--_accent); stroke-width: 2; }
343
+ .sv-tp__hub { fill: var(--_accent); }
344
+ .sv-tp__knob { fill: var(--_accent); opacity: 0.9; }
345
+ .sv-tp__num {
346
+ font-size: 13px; fill: var(--_fg); pointer-events: none;
347
+ font-family: inherit;
348
+ }
349
+ .sv-tp__num.is-inner { font-size: 11px; fill: var(--_muted); }
350
+ .sv-tp__num.is-active { fill: var(--sg-on-accent, #fff); font-weight: 700; }
351
+
352
+ .sv-tp__footer { width: 100%; display: flex; justify-content: center; border-top: 1px solid var(--_border); padding-top: 8px; }
353
+ .sv-tp__now {
354
+ padding: 5px 14px; font: inherit; font-size: 12px; font-weight: 600;
355
+ background: none; border: 1px solid var(--_border); border-radius: var(--_radius);
356
+ color: var(--_accent); cursor: pointer;
357
+ }
358
+ .sv-tp__now:hover { background: color-mix(in srgb, var(--_accent) 10%, transparent); }
359
+ </style>
@@ -0,0 +1,27 @@
1
+ export type TimeValue = Date | string | number | null;
2
+ /**
3
+ * SvTimePicker - an analog clock-dial time picker (parity: Smart
4
+ * `smart-time-picker`). 12/24-hour, minute snapping, hour->minute auto-switch,
5
+ * pointer-drag + keyboard. Works standalone and as the SvGrid `time` editor.
6
+ * All visuals come from `--sg-*` tokens so every theme applies for free.
7
+ */
8
+ type Props = {
9
+ /** Date, "HH:MM[:SS]" string, or epoch ms. */
10
+ value?: TimeValue;
11
+ /** Fires with a Date (today's date carrying the picked time). */
12
+ onChange?: (value: Date) => void;
13
+ format?: '12-hour' | '24-hour';
14
+ minuteInterval?: number;
15
+ /** After picking an hour, jump the dial to minutes. Default true. */
16
+ autoSwitchToMinutes?: boolean;
17
+ /** Show the Now footer button. */
18
+ footer?: boolean;
19
+ disabled?: boolean;
20
+ readonly?: boolean;
21
+ name?: string;
22
+ /** Which dial opens first. */
23
+ selection?: 'hour' | 'minute';
24
+ };
25
+ declare const SvTimePicker: import("svelte").Component<Props, {}, "">;
26
+ type SvTimePicker = ReturnType<typeof SvTimePicker>;
27
+ export default SvTimePicker;
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ /**
3
+ * SvToggleButton - a button with a pressed on/off state (aria-pressed).
4
+ * Parity: Smart `smart-toggle-button`. Controlled via `pressed` + `onChange`.
5
+ */
6
+ import type { Snippet } from 'svelte'
7
+
8
+ type Props = {
9
+ pressed?: boolean
10
+ onChange?: (pressed: boolean) => void
11
+ disabled?: boolean
12
+ size?: 'sm' | 'md' | 'lg'
13
+ ariaLabel?: string
14
+ children?: Snippet
15
+ }
16
+
17
+ let { pressed = false, onChange, disabled = false, size = 'md', ariaLabel, children }: Props = $props()
18
+
19
+ function toggle() {
20
+ if (disabled) return
21
+ onChange?.(!pressed)
22
+ }
23
+ </script>
24
+
25
+ <button
26
+ type="button"
27
+ class="sv-toggle sv-toggle--{size}"
28
+ class:is-pressed={pressed}
29
+ aria-pressed={pressed}
30
+ aria-label={ariaLabel}
31
+ {disabled}
32
+ onclick={toggle}
33
+ >
34
+ {#if children}{@render children()}{/if}
35
+ </button>
36
+
37
+ <style>
38
+ .sv-toggle {
39
+ --_accent: var(--sg-accent, #2563eb);
40
+ --_border: var(--sg-border, #cbd5e1);
41
+ --_radius: var(--sg-radius, 8px);
42
+ display: inline-flex; align-items: center; justify-content: center; gap: 6px;
43
+ font: inherit; font-weight: 600; line-height: 1;
44
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
45
+ border: 1px solid var(--_border); border-radius: var(--_radius); cursor: pointer; user-select: none;
46
+ transition: background 0.12s, border-color 0.12s, color 0.12s;
47
+ }
48
+ .sv-toggle--sm { padding: 5px 10px; font-size: 12px; }
49
+ .sv-toggle--md { padding: 8px 13px; font-size: 13px; }
50
+ .sv-toggle--lg { padding: 11px 17px; font-size: 15px; }
51
+ .sv-toggle:hover:not([disabled]):not(.is-pressed) { background: var(--sg-row-hover-bg, #f1f5f9); }
52
+ .sv-toggle.is-pressed {
53
+ background: var(--_accent); color: var(--sg-on-accent, #fff); border-color: var(--_accent);
54
+ }
55
+ .sv-toggle[disabled] { opacity: 0.55; cursor: not-allowed; }
56
+ .sv-toggle:focus-visible { outline: 2px solid var(--sg-focus-ring, var(--_accent)); outline-offset: 2px; }
57
+ </style>
@@ -0,0 +1,16 @@
1
+ /**
2
+ * SvToggleButton - a button with a pressed on/off state (aria-pressed).
3
+ * Parity: Smart `smart-toggle-button`. Controlled via `pressed` + `onChange`.
4
+ */
5
+ import type { Snippet } from 'svelte';
6
+ type Props = {
7
+ pressed?: boolean;
8
+ onChange?: (pressed: boolean) => void;
9
+ disabled?: boolean;
10
+ size?: 'sm' | 'md' | 'lg';
11
+ ariaLabel?: string;
12
+ children?: Snippet;
13
+ };
14
+ declare const SvToggleButton: import("svelte").Component<Props, {}, "">;
15
+ type SvToggleButton = ReturnType<typeof SvToggleButton>;
16
+ export default SvToggleButton;
@@ -0,0 +1,210 @@
1
+ <script lang="ts" module>
2
+ export type TreeNode = {
3
+ id: string
4
+ label: string
5
+ children?: TreeNode[]
6
+ disabled?: boolean
7
+ }
8
+ </script>
9
+
10
+ <script lang="ts">
11
+ /**
12
+ * SvTree - a WAI-ARIA tree view. Expand/collapse, single-select highlight, and
13
+ * optional cascading tri-state checkboxes. Parity: Smart `smart-tree`. Keyboard:
14
+ * up/down move, left collapse/parent, right expand/child, Enter/Space select.
15
+ * The visible tree is flattened so roving-focus keyboard nav stays simple.
16
+ */
17
+ type Props = {
18
+ nodes: ReadonlyArray<TreeNode>
19
+ /** Selected node id (single-select highlight). */
20
+ selected?: string | null
21
+ onSelect?: (id: string) => void
22
+ /** Initially/controlled expanded ids. */
23
+ expandedIds?: string[]
24
+ onToggle?: (id: string, expanded: boolean) => void
25
+ /** Show cascading checkboxes; `checked` is the set of checked ids. */
26
+ checkable?: boolean
27
+ checked?: string[]
28
+ onCheck?: (ids: string[]) => void
29
+ ariaLabel?: string
30
+ }
31
+
32
+ let {
33
+ nodes,
34
+ selected = null,
35
+ onSelect,
36
+ expandedIds,
37
+ onToggle,
38
+ checkable = false,
39
+ checked = [],
40
+ onCheck,
41
+ ariaLabel,
42
+ }: Props = $props()
43
+
44
+ // Internal expanded set, seeded from the prop once (then self-managed unless
45
+ // the prop identity changes).
46
+ let expanded = $state<Set<string>>(new Set())
47
+ let seededExp = false
48
+ $effect(() => {
49
+ if (!seededExp) { seededExp = true; if (expandedIds) expanded = new Set(expandedIds) }
50
+ })
51
+
52
+ type Flat = { node: TreeNode; depth: number; hasChildren: boolean; open: boolean; parentId: string | null }
53
+
54
+ const flat = $derived.by<Flat[]>(() => {
55
+ const out: Flat[] = []
56
+ const walk = (list: ReadonlyArray<TreeNode>, depth: number, parentId: string | null) => {
57
+ for (const node of list) {
58
+ const hasChildren = !!node.children?.length
59
+ const open = expanded.has(node.id)
60
+ out.push({ node, depth, hasChildren, open, parentId })
61
+ if (hasChildren && open) walk(node.children!, depth + 1, node.id)
62
+ }
63
+ }
64
+ walk(nodes, 0, null)
65
+ return out
66
+ })
67
+
68
+ let active = $state(0)
69
+ $effect(() => { if (active >= flat.length) active = Math.max(0, flat.length - 1) })
70
+
71
+ const checkedSet = $derived(new Set(checked))
72
+
73
+ function descendantIds(node: TreeNode): string[] {
74
+ const ids: string[] = []
75
+ const walk = (n: TreeNode) => { for (const c of n.children ?? []) { ids.push(c.id); walk(c) } }
76
+ walk(node)
77
+ return ids
78
+ }
79
+ function checkState(node: TreeNode): 'checked' | 'indeterminate' | 'unchecked' {
80
+ if (!node.children?.length) return checkedSet.has(node.id) ? 'checked' : 'unchecked'
81
+ const desc = descendantIds(node)
82
+ const on = desc.filter((id) => checkedSet.has(id)).length
83
+ if (on === 0 && !checkedSet.has(node.id)) return 'unchecked'
84
+ if (on === desc.length) return 'checked'
85
+ return 'indeterminate'
86
+ }
87
+
88
+ function toggleExpand(node: TreeNode) {
89
+ if (!node.children?.length) return
90
+ const next = new Set(expanded)
91
+ const willOpen = !next.has(node.id)
92
+ willOpen ? next.add(node.id) : next.delete(node.id)
93
+ expanded = next
94
+ onToggle?.(node.id, willOpen)
95
+ }
96
+ function select(node: TreeNode) {
97
+ if (node.disabled) return
98
+ onSelect?.(node.id)
99
+ }
100
+ function toggleCheck(node: TreeNode) {
101
+ if (node.disabled) return
102
+ const next = new Set(checkedSet)
103
+ const ids = [node.id, ...descendantIds(node)]
104
+ const currentlyOn = checkState(node) === 'checked'
105
+ for (const id of ids) currentlyOn ? next.delete(id) : next.add(id)
106
+ onCheck?.([...next])
107
+ }
108
+
109
+ function onKeydown(e: KeyboardEvent) {
110
+ const item = flat[active]
111
+ if (!item) return
112
+ switch (e.key) {
113
+ case 'ArrowDown': e.preventDefault(); active = Math.min(active + 1, flat.length - 1); focusActive(); break
114
+ case 'ArrowUp': e.preventDefault(); active = Math.max(active - 1, 0); focusActive(); break
115
+ case 'ArrowRight':
116
+ e.preventDefault()
117
+ if (item.hasChildren && !item.open) toggleExpand(item.node)
118
+ else if (item.hasChildren && item.open) { active = Math.min(active + 1, flat.length - 1); focusActive() }
119
+ break
120
+ case 'ArrowLeft':
121
+ e.preventDefault()
122
+ if (item.hasChildren && item.open) toggleExpand(item.node)
123
+ else if (item.parentId) { const pi = flat.findIndex((f) => f.node.id === item.parentId); if (pi >= 0) { active = pi; focusActive() } }
124
+ break
125
+ case 'Enter': e.preventDefault(); select(item.node); break
126
+ case ' ': e.preventDefault(); checkable ? toggleCheck(item.node) : select(item.node); break
127
+ case 'Home': e.preventDefault(); active = 0; focusActive(); break
128
+ case 'End': e.preventDefault(); active = flat.length - 1; focusActive(); break
129
+ }
130
+ }
131
+
132
+ let treeEl: HTMLDivElement | null = null
133
+ function focusActive() {
134
+ queueMicrotask(() => treeEl?.querySelector<HTMLElement>(`[data-row="${active}"]`)?.focus())
135
+ }
136
+ </script>
137
+
138
+ <div bind:this={treeEl} class="sv-tree" role="tree" aria-label={ariaLabel} aria-multiselectable={checkable}>
139
+ {#each flat as item, i (item.node.id)}
140
+ {@const cs = checkable ? checkState(item.node) : 'unchecked'}
141
+ <div
142
+ class="sv-tree__row"
143
+ class:is-selected={item.node.id === selected}
144
+ class:is-disabled={item.node.disabled}
145
+ role="treeitem"
146
+ aria-level={item.depth + 1}
147
+ aria-selected={item.node.id === selected}
148
+ aria-expanded={item.hasChildren ? item.open : undefined}
149
+ aria-checked={checkable ? cs === 'checked' ? 'true' : cs === 'indeterminate' ? 'mixed' : 'false' : undefined}
150
+ data-row={i}
151
+ tabindex={i === active ? 0 : -1}
152
+ style:padding-left={`${item.depth * 18 + 6}px`}
153
+ onclick={() => { active = i; select(item.node) }}
154
+ onkeydown={onKeydown}
155
+ >
156
+ <button
157
+ type="button"
158
+ class="sv-tree__twist"
159
+ class:is-open={item.open}
160
+ class:is-leaf={!item.hasChildren}
161
+ tabindex="-1"
162
+ aria-hidden={!item.hasChildren}
163
+ onclick={(e) => { e.stopPropagation(); toggleExpand(item.node) }}
164
+ >
165
+ {#if item.hasChildren}
166
+ <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6" /></svg>
167
+ {/if}
168
+ </button>
169
+ {#if checkable}
170
+ <button type="button" class="sv-tree__check is-{cs}" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); toggleCheck(item.node) }}>
171
+ {#if cs === 'checked'}<svg viewBox="0 0 16 16"><path d="M3.5 8.5l3 3 6-6.5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" /></svg>
172
+ {:else if cs === 'indeterminate'}<svg viewBox="0 0 16 16"><path d="M4 8h8" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" /></svg>{/if}
173
+ </button>
174
+ {/if}
175
+ <span class="sv-tree__label">{item.node.label}</span>
176
+ </div>
177
+ {/each}
178
+ </div>
179
+
180
+ <style>
181
+ .sv-tree {
182
+ --_accent: var(--sg-accent, #2563eb);
183
+ width: 260px; padding: 4px; user-select: none;
184
+ background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
185
+ border: 1px solid var(--sg-border, #e2e8f0); border-radius: var(--sg-radius, 8px);
186
+ font-size: 13px;
187
+ }
188
+ .sv-tree__row {
189
+ display: flex; align-items: center; gap: 4px; height: 30px; padding-right: 8px;
190
+ border-radius: 6px; cursor: pointer; outline: none;
191
+ }
192
+ .sv-tree__row:hover:not(.is-disabled) { background: var(--sg-row-hover-bg, #f1f5f9); }
193
+ .sv-tree__row.is-selected { background: color-mix(in srgb, var(--_accent) 15%, transparent); color: var(--_accent); font-weight: 600; }
194
+ .sv-tree__row.is-disabled { opacity: 0.45; cursor: not-allowed; }
195
+ .sv-tree__row:focus-visible { box-shadow: inset 0 0 0 2px var(--sg-focus-ring, var(--_accent)); }
196
+ .sv-tree__twist {
197
+ display: grid; place-items: center; width: 18px; height: 18px; flex: none;
198
+ background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; transition: transform 0.12s;
199
+ }
200
+ .sv-tree__twist.is-open { transform: rotate(90deg); }
201
+ .sv-tree__twist.is-leaf { visibility: hidden; }
202
+ .sv-tree__check {
203
+ display: grid; place-items: center; width: 16px; height: 16px; flex: none; padding: 0;
204
+ border: 1.5px solid var(--sg-border, #cbd5e1); border-radius: 4px; background: var(--sg-input-bg, #fff);
205
+ color: var(--sg-on-accent, #fff); cursor: pointer;
206
+ }
207
+ .sv-tree__check.is-checked, .sv-tree__check.is-indeterminate { background: var(--_accent); border-color: var(--_accent); }
208
+ .sv-tree__check svg { width: 100%; height: 100%; }
209
+ .sv-tree__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
210
+ </style>