@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,98 @@
1
+ /**
2
+ * Component tests for SvTimePicker. jsdom lacks real SVG layout, so pointer-drag
3
+ * geometry is exercised lightly; the header/segment/AM-PM/keyboard logic (where
4
+ * the value math lives) is covered directly.
5
+ */
6
+ import { describe, expect, it } from 'vitest'
7
+ import { mount, unmount, flushSync } from 'svelte'
8
+ import SvTimePicker from './SvTimePicker.svelte'
9
+
10
+ function mountTp(props: Record<string, unknown>) {
11
+ const target = document.createElement('div')
12
+ document.body.appendChild(target)
13
+ const app = mount(SvTimePicker, { target, props: props as any })
14
+ flushSync()
15
+ return { target, destroy: () => { unmount(app); target.remove() } }
16
+ }
17
+
18
+ const segs = (t: HTMLElement) => t.querySelectorAll<HTMLButtonElement>('.sv-tp__seg')
19
+
20
+ describe('SvTimePicker render', () => {
21
+ it('shows the seeded time (24h)', () => {
22
+ const { target, destroy } = mountTp({ value: '14:30' })
23
+ try {
24
+ expect(segs(target)[0]!.textContent!.trim()).toBe('14')
25
+ expect(segs(target)[1]!.textContent!.trim()).toBe('30')
26
+ expect(target.querySelector('.sv-tp__ampm')).toBeNull() // no AM/PM in 24h
27
+ } finally { destroy() }
28
+ })
29
+
30
+ it('shows 12-hour display with AM/PM', () => {
31
+ const { target, destroy } = mountTp({ value: '14:30', format: '12-hour' })
32
+ try {
33
+ expect(segs(target)[0]!.textContent!.trim()).toBe('02') // 14 -> 2 PM
34
+ const ampm = target.querySelectorAll<HTMLButtonElement>('.sv-tp__ampm button')
35
+ expect(ampm[1]!.classList.contains('is-active')).toBe(true) // PM active
36
+ } finally { destroy() }
37
+ })
38
+
39
+ it('renders 24 hour ticks (two rings) in 24h and 12 in 12h', () => {
40
+ const a = mountTp({ value: '10:00' })
41
+ try {
42
+ expect(a.target.querySelectorAll('.sv-tp__num').length).toBe(24)
43
+ } finally { a.destroy() }
44
+ const b = mountTp({ value: '10:00', format: '12-hour' })
45
+ try {
46
+ expect(b.target.querySelectorAll('.sv-tp__num').length).toBe(12)
47
+ } finally { b.destroy() }
48
+ })
49
+ })
50
+
51
+ describe('SvTimePicker interaction', () => {
52
+ it('AM/PM toggle shifts the hour by 12 and emits', () => {
53
+ let got: Date | null = null
54
+ const { target, destroy } = mountTp({ value: '02:15', format: '12-hour', onChange: (d: Date) => (got = d) })
55
+ try {
56
+ const pm = target.querySelectorAll<HTMLButtonElement>('.sv-tp__ampm button')[1]!
57
+ pm.click()
58
+ flushSync()
59
+ expect(got).not.toBeNull()
60
+ expect((got as unknown as Date).getHours()).toBe(14) // 2 AM -> 2 PM
61
+ expect((got as unknown as Date).getMinutes()).toBe(15)
62
+ } finally { destroy() }
63
+ })
64
+
65
+ it('keyboard bumps the selected field', () => {
66
+ let got: Date | null = null
67
+ const { target, destroy } = mountTp({ value: '10:00', selection: 'minute', minuteInterval: 15, onChange: (d: Date) => (got = d) })
68
+ try {
69
+ const dial = target.querySelector<SVGSVGElement>('.sv-tp__dial')!
70
+ dial.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }))
71
+ flushSync()
72
+ expect((got as unknown as Date).getMinutes()).toBe(15) // +interval
73
+ } finally { destroy() }
74
+ })
75
+
76
+ it('clicking the minute segment switches the active dial', () => {
77
+ const { target, destroy } = mountTp({ value: '10:00' })
78
+ try {
79
+ segs(target)[1]!.click()
80
+ flushSync()
81
+ expect(segs(target)[1]!.classList.contains('is-active')).toBe(true)
82
+ // minute dial shows 00,05,...,55 (12 labels)
83
+ expect(target.querySelectorAll('.sv-tp__num').length).toBe(12)
84
+ } finally { destroy() }
85
+ })
86
+
87
+ it('Now sets the current time', () => {
88
+ let got: Date | null = null
89
+ const { target, destroy } = mountTp({ value: '00:00', footer: true, onChange: (d: Date) => (got = d) })
90
+ try {
91
+ target.querySelector<HTMLButtonElement>('.sv-tp__now')!.click()
92
+ flushSync()
93
+ expect(got).not.toBeNull()
94
+ const now = new Date()
95
+ expect((got as unknown as Date).getHours()).toBe(now.getHours())
96
+ } finally { destroy() }
97
+ })
98
+ })
@@ -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>