@svgrid/grid 1.2.23 → 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 (141) hide show
  1. package/dist/SvAutoComplete.svelte +23 -34
  2. package/dist/SvCalendar.svelte +74 -334
  3. package/dist/SvCalendar.svelte.d.ts +5 -23
  4. package/dist/SvCheckBox.svelte +11 -10
  5. package/dist/SvColorInput.svelte +31 -58
  6. package/dist/SvComboBox.svelte +32 -54
  7. package/dist/SvCountryInput.svelte +31 -29
  8. package/dist/SvDateTimePicker.svelte +53 -110
  9. package/dist/SvDateTimePicker.svelte.d.ts +4 -3
  10. package/dist/SvDropDownList.svelte +31 -54
  11. package/dist/SvGauge.svelte +21 -23
  12. package/dist/SvGauge.svelte.d.ts +0 -5
  13. package/dist/SvListBox.svelte +25 -58
  14. package/dist/SvListBox.svelte.d.ts +3 -0
  15. package/dist/SvMaskedInput.svelte +0 -0
  16. package/dist/SvNumberInput.svelte +0 -0
  17. package/dist/SvNumberInput.svelte.d.ts +2 -6
  18. package/dist/SvPasswordInput.svelte +18 -28
  19. package/dist/SvPasswordInput.svelte.d.ts +0 -4
  20. package/dist/SvPhoneInput.svelte +0 -0
  21. package/dist/SvRadioGroup.svelte +14 -37
  22. package/dist/SvRadioGroup.svelte.d.ts +6 -9
  23. package/dist/SvRating.svelte +22 -42
  24. package/dist/SvRating.svelte.d.ts +3 -5
  25. package/dist/SvSlider.svelte +30 -57
  26. package/dist/SvSlider.svelte.d.ts +0 -5
  27. package/dist/SvSwitchButton.svelte +10 -17
  28. package/dist/SvSwitchButton.svelte.d.ts +0 -5
  29. package/dist/SvTabs.svelte +26 -39
  30. package/dist/SvTabs.svelte.d.ts +5 -5
  31. package/dist/SvTagsInput.svelte +17 -28
  32. package/dist/SvTagsInput.svelte.d.ts +0 -5
  33. package/dist/SvTimePicker.svelte +52 -210
  34. package/dist/SvTimePicker.svelte.d.ts +11 -8
  35. package/dist/SvToggleButton.svelte +9 -9
  36. package/dist/SvTree.svelte +30 -109
  37. package/dist/SvTree.svelte.d.ts +9 -11
  38. package/dist/cdn/svgrid.js +7488 -5839
  39. package/dist/cdn/svgrid.svelte-external.js +4691 -3028
  40. package/dist/createAutocomplete.svelte.d.ts +77 -0
  41. package/dist/createAutocomplete.svelte.js +100 -0
  42. package/dist/createCalendar.svelte.d.ts +192 -0
  43. package/dist/createCalendar.svelte.js +402 -0
  44. package/dist/createCheckbox.svelte.d.ts +45 -0
  45. package/dist/createCheckbox.svelte.js +26 -0
  46. package/dist/createColorInput.svelte.d.ts +67 -0
  47. package/dist/createColorInput.svelte.js +100 -0
  48. package/dist/createCombobox.svelte.d.ts +102 -0
  49. package/dist/createCombobox.svelte.js +171 -0
  50. package/dist/createCountryInput.svelte.d.ts +86 -0
  51. package/dist/createCountryInput.svelte.js +126 -0
  52. package/dist/createDateTimePicker.svelte.d.ts +83 -0
  53. package/dist/createDateTimePicker.svelte.js +202 -0
  54. package/dist/createDropdownList.svelte.d.ts +78 -0
  55. package/dist/createDropdownList.svelte.js +117 -0
  56. package/dist/createGauge.svelte.d.ts +56 -0
  57. package/dist/createGauge.svelte.js +49 -0
  58. package/dist/createListbox.svelte.d.ts +79 -0
  59. package/dist/createListbox.svelte.js +117 -0
  60. package/dist/createMaskedInput.svelte.d.ts +30 -0
  61. package/dist/createMaskedInput.svelte.js +62 -0
  62. package/dist/createNumberInput.svelte.d.ts +72 -0
  63. package/dist/createNumberInput.svelte.js +167 -0
  64. package/dist/createPasswordInput.svelte.d.ts +60 -0
  65. package/dist/createPasswordInput.svelte.js +72 -0
  66. package/dist/createPhoneInput.svelte.d.ts +46 -0
  67. package/dist/createPhoneInput.svelte.js +105 -0
  68. package/dist/createRadioGroup.svelte.d.ts +64 -0
  69. package/dist/createRadioGroup.svelte.js +73 -0
  70. package/dist/createRating.svelte.d.ts +70 -0
  71. package/dist/createRating.svelte.js +85 -0
  72. package/dist/createSlider.svelte.d.ts +71 -0
  73. package/dist/createSlider.svelte.js +123 -0
  74. package/dist/createSwitch.svelte.d.ts +43 -0
  75. package/dist/createSwitch.svelte.js +41 -0
  76. package/dist/createTabs.svelte.d.ts +78 -0
  77. package/dist/createTabs.svelte.js +92 -0
  78. package/dist/createTagsInput.svelte.d.ts +65 -0
  79. package/dist/createTagsInput.svelte.js +93 -0
  80. package/dist/createTimePicker.svelte.d.ts +106 -0
  81. package/dist/createTimePicker.svelte.js +248 -0
  82. package/dist/createToggle.svelte.d.ts +43 -0
  83. package/dist/createToggle.svelte.js +39 -0
  84. package/dist/createTree.svelte.d.ts +96 -0
  85. package/dist/createTree.svelte.js +194 -0
  86. package/dist/editor-contract.d.ts +48 -0
  87. package/dist/editor-contract.js +26 -0
  88. package/dist/index.d.ts +24 -0
  89. package/dist/index.js +31 -0
  90. package/package.json +1 -1
  91. package/src/SvAutoComplete.svelte +23 -34
  92. package/src/SvCalendar.svelte +74 -334
  93. package/src/SvCheckBox.svelte +11 -10
  94. package/src/SvColorInput.svelte +31 -58
  95. package/src/SvComboBox.svelte +32 -54
  96. package/src/SvCountryInput.svelte +31 -29
  97. package/src/SvDateTimePicker.svelte +53 -110
  98. package/src/SvDropDownList.svelte +31 -54
  99. package/src/SvGauge.svelte +21 -23
  100. package/src/SvListBox.svelte +25 -58
  101. package/src/SvMaskedInput.svelte +0 -0
  102. package/src/SvNumberInput.svelte +0 -0
  103. package/src/SvPasswordInput.svelte +18 -28
  104. package/src/SvPhoneInput.svelte +0 -0
  105. package/src/SvRadioGroup.svelte +14 -37
  106. package/src/SvRating.svelte +22 -42
  107. package/src/SvSlider.svelte +30 -57
  108. package/src/SvSwitchButton.svelte +10 -17
  109. package/src/SvTabs.svelte +26 -39
  110. package/src/SvTagsInput.svelte +17 -28
  111. package/src/SvTimePicker.svelte +52 -210
  112. package/src/SvToggleButton.svelte +9 -9
  113. package/src/SvTree.svelte +30 -109
  114. package/src/createAutocomplete.svelte.ts +104 -0
  115. package/src/createCalendar.svelte.ts +488 -0
  116. package/src/createCheckbox.svelte.ts +74 -0
  117. package/src/createColorInput.svelte.ts +115 -0
  118. package/src/createCombobox.svelte.ts +158 -0
  119. package/src/createCountryInput.svelte.ts +126 -0
  120. package/src/createDateTimePicker.svelte.ts +216 -0
  121. package/src/createDropdownList.svelte.ts +125 -0
  122. package/src/createGauge.svelte.ts +86 -0
  123. package/src/createListbox.svelte.ts +186 -0
  124. package/src/createMaskedInput.svelte.ts +81 -0
  125. package/src/createNumberInput.svelte.ts +193 -0
  126. package/src/createPasswordInput.svelte.ts +87 -0
  127. package/src/createPhoneInput.svelte.ts +119 -0
  128. package/src/createRadioGroup.svelte.ts +132 -0
  129. package/src/createRating.svelte.ts +145 -0
  130. package/src/createSlider.svelte.ts +160 -0
  131. package/src/createSwitch.svelte.ts +78 -0
  132. package/src/createTabs.svelte.ts +129 -0
  133. package/src/createTagsInput.svelte.ts +98 -0
  134. package/src/createTimePicker.svelte.ts +289 -0
  135. package/src/createToggle.svelte.ts +69 -0
  136. package/src/createTree.svelte.ts +216 -0
  137. package/src/editor-contract.test.ts +32 -0
  138. package/src/editor-contract.ts +60 -0
  139. package/src/index.ts +39 -0
  140. package/src/ui-inputs.test.ts +26 -0
  141. package/src/ui-selection.test.ts +13 -0
@@ -1,20 +1,24 @@
1
1
  <script lang="ts" module>
2
- export type TimeValue = Date | string | number | null
2
+ export type { TimeValue } from './createTimePicker.svelte'
3
3
  </script>
4
4
 
5
5
  <script lang="ts">
6
6
  /**
7
7
  * SvTimePicker - an analog clock-dial time picker (parity: Smart
8
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.
9
+ * pointer-drag + keyboard. It is a styled renderer over the headless
10
+ * `createTimePicker` core: the core owns the value math, dial geometry, keyboard
11
+ * and ARIA; this component keeps the DOM-bound pointer capture + hit-testing.
10
12
  * All visuals come from `--sg-*` tokens so every theme applies for free.
11
13
  */
14
+ import { createTimePicker, type TimeValue, type TimeFormat, type TimeSelection } from './createTimePicker.svelte'
15
+
12
16
  type Props = {
13
17
  /** Date, "HH:MM[:SS]" string, or epoch ms. */
14
18
  value?: TimeValue
15
19
  /** Fires with a Date (today's date carrying the picked time). */
16
20
  onChange?: (value: Date) => void
17
- format?: '12-hour' | '24-hour'
21
+ format?: TimeFormat
18
22
  minuteInterval?: number
19
23
  /** After picking an hour, jump the dial to minutes. Default true. */
20
24
  autoSwitchToMinutes?: boolean
@@ -24,7 +28,7 @@
24
28
  readonly?: boolean
25
29
  name?: string
26
30
  /** Which dial opens first. */
27
- selection?: 'hour' | 'minute'
31
+ selection?: TimeSelection
28
32
  }
29
33
 
30
34
  let {
@@ -40,224 +44,68 @@
40
44
  selection = 'hour',
41
45
  }: Props = $props()
42
46
 
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
47
+ // The headless core owns all state, geometry, keyboard + ARIA. Reactive inputs
48
+ // are getters; callbacks are closures.
49
+ const tp = createTimePicker({
50
+ value: () => value,
51
+ onChange: (d) => onChange?.(d),
52
+ format: () => format,
53
+ minuteInterval: () => minuteInterval,
54
+ autoSwitchToMinutes: () => autoSwitchToMinutes,
55
+ disabled: () => disabled,
56
+ readonly: () => readonly,
57
+ selection: () => selection,
111
58
  })
112
59
 
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 ---
60
+ // --- Pointer drag on the dial (DOM-bound; stays in the component) -----------
61
+ // The core exposes SIZE/C + a pure `pointerSelect(angle, dist)`; here we only
62
+ // turn a pointer event into that angle via the live element rect.
169
63
  let dialEl: SVGSVGElement | null = null
170
64
  let dragging = false
171
65
 
172
66
  function angleFromEvent(e: PointerEvent): { angle: number; dist: number } {
173
67
  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
68
+ const scale = tp.SIZE / rect.width
69
+ const x = (e.clientX - rect.left) * scale - tp.C
70
+ const y = (e.clientY - rect.top) * scale - tp.C
177
71
  let deg = (Math.atan2(y, x) * 180) / Math.PI + 90
178
72
  if (deg < 0) deg += 360
179
73
  return { angle: deg, dist: Math.hypot(x, y) }
180
74
  }
181
75
 
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
76
  function onDialPointerDown(e: PointerEvent) {
204
- if (!isInteractive) return
77
+ if (!tp.isInteractive) return
205
78
  dragging = true
206
79
  dialEl?.setPointerCapture(e.pointerId)
207
- applyPointer(e)
80
+ const { angle, dist } = angleFromEvent(e)
81
+ tp.pointerSelect(angle, dist)
208
82
  }
209
83
  function onDialPointerMove(e: PointerEvent) {
210
- if (dragging) applyPointer(e)
84
+ if (!dragging) return
85
+ const { angle, dist } = angleFromEvent(e)
86
+ tp.pointerSelect(angle, dist)
211
87
  }
212
88
  function onDialPointerUp(e: PointerEvent) {
213
89
  if (!dragging) return
214
90
  dragging = false
215
91
  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
92
+ tp.endPointer()
245
93
  }
246
94
  </script>
247
95
 
248
96
  <div class="sv-tp" class:sv-tp--disabled={disabled} role="group" aria-label="Time picker">
249
97
  <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')}
98
+ <button class="sv-tp__seg" class:is-active={tp.selection === 'hour'} {...tp.segProps('hour')}>
99
+ {String(tp.displayHour).padStart(2, '0')}
252
100
  </button>
253
101
  <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}
102
+ <button class="sv-tp__seg" class:is-active={tp.selection === 'minute'} {...tp.segProps('minute')}>
103
+ {tp.mm}
256
104
  </button>
257
- {#if is12}
105
+ {#if tp.is12}
258
106
  <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>
107
+ <button class:is-active={!tp.isPm} {...tp.ampmProps(false)}>AM</button>
108
+ <button class:is-active={tp.isPm} {...tp.ampmProps(true)}>PM</button>
261
109
  </div>
262
110
  {/if}
263
111
  </div>
@@ -265,43 +113,37 @@
265
113
  <svg
266
114
  bind:this={dialEl}
267
115
  class="sv-tp__dial"
268
- viewBox={`0 0 ${SIZE} ${SIZE}`}
116
+ viewBox={`0 0 ${tp.SIZE} ${tp.SIZE}`}
269
117
  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}
118
+ {...tp.dialProps()}
276
119
  onpointerdown={onDialPointerDown}
277
120
  onpointermove={onDialPointerMove}
278
121
  onpointerup={onDialPointerUp}
279
122
  onpointercancel={onDialPointerUp}
280
- onkeydown={onKeydown}
281
123
  >
282
- <circle class="sv-tp__face" cx={C} cy={C} r={C - 4} />
124
+ <circle class="sv-tp__face" cx={tp.C} cy={tp.C} r={tp.C - 4} />
283
125
  <!-- 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>
126
+ <line class="sv-tp__hand" x1={tp.C} y1={tp.C} x2={tp.handEnd.x} y2={tp.handEnd.y} />
127
+ <circle class="sv-tp__hub" cx={tp.C} cy={tp.C} r="3" />
128
+ <circle class="sv-tp__knob" cx={tp.handEnd.x} cy={tp.handEnd.y} r="15" />
129
+ {#if tp.selection === 'hour'}
130
+ {#each tp.hourTicks as t (t.ring + '-' + t.value)}
131
+ <text class="sv-tp__num" class:is-active={tp.isActiveHour(t)} class:is-inner={t.ring === 'inner'} x={t.x} y={t.y} dominant-baseline="central" text-anchor="middle">{t.label}</text>
290
132
  {/each}
291
133
  {: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>
134
+ {#each tp.minuteTicks as t (t.value)}
135
+ <text class="sv-tp__num" class:is-active={t.value === tp.minutes} x={t.x} y={t.y} dominant-baseline="central" text-anchor="middle">{t.label}</text>
294
136
  {/each}
295
137
  {/if}
296
138
  </svg>
297
139
 
298
140
  {#if footer}
299
141
  <div class="sv-tp__footer">
300
- <button type="button" class="sv-tp__now" onclick={now} disabled={disabled}>Now</button>
142
+ <button class="sv-tp__now" {...tp.nowProps()}>Now</button>
301
143
  </div>
302
144
  {/if}
303
145
 
304
- {#if name}<input type="hidden" {name} value={`${String(hours).padStart(2, '0')}:${mm}`} />{/if}
146
+ {#if name}<input type="hidden" {name} value={`${String(tp.hours).padStart(2, '0')}:${tp.mm}`} />{/if}
305
147
  </div>
306
148
 
307
149
  <style>
@@ -4,6 +4,7 @@
4
4
  * Parity: Smart `smart-toggle-button`. Controlled via `pressed` + `onChange`.
5
5
  */
6
6
  import type { Snippet } from 'svelte'
7
+ import { createToggle } from './createToggle.svelte'
7
8
 
8
9
  type Props = {
9
10
  pressed?: boolean
@@ -16,20 +17,19 @@
16
17
 
17
18
  let { pressed = false, onChange, disabled = false, size = 'md', ariaLabel, children }: Props = $props()
18
19
 
19
- function toggle() {
20
- if (disabled) return
21
- onChange?.(!pressed)
22
- }
20
+ // The styled toggle is just a renderer over the headless core.
21
+ const t = createToggle({
22
+ pressed: () => pressed,
23
+ onChange: (v) => onChange?.(v),
24
+ disabled: () => disabled,
25
+ ariaLabel: () => ariaLabel,
26
+ })
23
27
  </script>
24
28
 
25
29
  <button
26
- type="button"
27
30
  class="sv-toggle sv-toggle--{size}"
28
31
  class:is-pressed={pressed}
29
- aria-pressed={pressed}
30
- aria-label={ariaLabel}
31
- {disabled}
32
- onclick={toggle}
32
+ {...t.buttonProps()}
33
33
  >
34
34
  {#if children}{@render children()}{/if}
35
35
  </button>
package/src/SvTree.svelte CHANGED
@@ -1,10 +1,5 @@
1
1
  <script lang="ts" module>
2
- export type TreeNode = {
3
- id: string
4
- label: string
5
- children?: TreeNode[]
6
- disabled?: boolean
7
- }
2
+ export type { TreeNode } from './createTree.svelte'
8
3
  </script>
9
4
 
10
5
  <script lang="ts">
@@ -12,8 +7,12 @@
12
7
  * SvTree - a WAI-ARIA tree view. Expand/collapse, single-select highlight, and
13
8
  * optional cascading tri-state checkboxes. Parity: Smart `smart-tree`. Keyboard:
14
9
  * 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.
10
+ *
11
+ * The behavior (flattening, cascade math, keyboard, ARIA) lives in the headless
12
+ * `createTree` core; this component is just one styled renderer over it.
16
13
  */
14
+ import { createTree, type TreeNode } from './createTree.svelte'
15
+
17
16
  type Props = {
18
17
  nodes: ReadonlyArray<TreeNode>
19
18
  /** Selected node id (single-select highlight). */
@@ -41,117 +40,39 @@
41
40
  ariaLabel,
42
41
  }: Props = $props()
43
42
 
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) }
43
+ const tree = createTree({
44
+ nodes: () => nodes,
45
+ selected: () => selected,
46
+ onSelect: (id) => onSelect?.(id),
47
+ expandedIds: () => expandedIds,
48
+ onToggle: (id, exp) => onToggle?.(id, exp),
49
+ checkable: () => checkable,
50
+ checked: () => checked,
51
+ onCheck: (ids) => onCheck?.(ids),
52
+ ariaLabel: () => ariaLabel,
50
53
  })
51
54
 
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
- }
55
+ // DOM focus movement is a render concern: follow the core's roving focus row.
56
+ let treeEl: HTMLDivElement | null = null
57
+ let lastFocusTick = 0
58
+ $effect(() => {
59
+ if (tree.focusTick !== lastFocusTick) {
60
+ lastFocusTick = tree.focusTick
61
+ const i = tree.activeIndex
62
+ queueMicrotask(() => treeEl?.querySelector<HTMLElement>(`[data-row="${i}"]`)?.focus())
63
63
  }
64
- walk(nodes, 0, null)
65
- return out
66
64
  })
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
65
  </script>
137
66
 
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'}
67
+ <div bind:this={treeEl} class="sv-tree" {...tree.treeProps()}>
68
+ {#each tree.rows as item (item.node.id)}
69
+ {@const cs = checkable ? tree.checkStateOf(item.node) : 'unchecked'}
141
70
  <div
142
71
  class="sv-tree__row"
143
72
  class:is-selected={item.node.id === selected}
144
73
  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
74
  style:padding-left={`${item.depth * 18 + 6}px`}
153
- onclick={() => { active = i; select(item.node) }}
154
- onkeydown={onKeydown}
75
+ {...tree.itemProps(item)}
155
76
  >
156
77
  <button
157
78
  type="button"
@@ -160,14 +81,14 @@
160
81
  class:is-leaf={!item.hasChildren}
161
82
  tabindex="-1"
162
83
  aria-hidden={!item.hasChildren}
163
- onclick={(e) => { e.stopPropagation(); toggleExpand(item.node) }}
84
+ onclick={(e) => { e.stopPropagation(); tree.toggleExpand(item.node) }}
164
85
  >
165
86
  {#if item.hasChildren}
166
87
  <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
88
  {/if}
168
89
  </button>
169
90
  {#if checkable}
170
- <button type="button" class="sv-tree__check is-{cs}" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); toggleCheck(item.node) }}>
91
+ <button type="button" class="sv-tree__check is-{cs}" tabindex="-1" aria-hidden="true" onclick={(e) => { e.stopPropagation(); tree.toggleCheck(item.node) }}>
171
92
  {#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
93
  {: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
94
  </button>
@@ -0,0 +1,104 @@
1
+ /**
2
+ * createAutocomplete - the HEADLESS core behind <SvAutoComplete>: a free-text
3
+ * field with a live-filtered suggestion list (accepts ANY typed value; the
4
+ * suggestions are shortcuts). Roving active index + keyboard, exposed as
5
+ * **prop-getters** you spread onto YOUR OWN markup. No styles/DOM/portal - those
6
+ * stay in the styled component.
7
+ *
8
+ * ```svelte
9
+ * <script lang="ts">
10
+ * import { createAutocomplete } from '@svgrid/grid'
11
+ * let value = $state('')
12
+ * const ac = createAutocomplete({ value: () => value, suggestions: () => list, onChange: (v) => (value = v) })
13
+ * </script>
14
+ * <input {...ac.inputProps()} />
15
+ * {#if ac.open}
16
+ * <ul {...ac.listboxProps()}>
17
+ * {#each ac.filtered as opt, i (opt.value)}<li {...ac.optionProps(i)}>{opt.label}</li>{/each}
18
+ * </ul>
19
+ * {/if}
20
+ * ```
21
+ */
22
+ import { filterOptions, type ListOption } from './list-option'
23
+
24
+ /** Reactive inputs are passed as getters so the core tracks live prop changes. */
25
+ export type AutocompleteConfig = {
26
+ value: () => string
27
+ onChange?: (value: string) => void
28
+ /** Suggestions - strings or {value,label} (label shown, value inserted). */
29
+ suggestions: () => ReadonlyArray<string | ListOption>
30
+ minChars?: () => number
31
+ disabled?: () => boolean
32
+ ariaLabel?: () => string | undefined
33
+ /** DOM focus hook provided by the renderer; the core never touches the DOM. */
34
+ focusInput?: () => void
35
+ }
36
+
37
+ export function createAutocomplete(config: AutocompleteConfig) {
38
+ const minChars = () => config.minChars?.() ?? 1
39
+
40
+ let open = $state(false)
41
+ let active = $state(0)
42
+
43
+ const asOptions = $derived<ListOption[]>(
44
+ config.suggestions().map((s) => (typeof s === 'string' ? { value: s, label: s } : s)),
45
+ )
46
+ const filtered = $derived(config.value().length >= minChars() ? filterOptions(asOptions, config.value()).slice(0, 10) : [])
47
+
48
+ function maybeOpen() { open = filtered.length > 0; if (open) active = 0 }
49
+ function pick(o: ListOption | undefined) { if (!o) return; config.onChange?.(String(o.value)); open = false; config.focusInput?.() }
50
+ function close() { open = false }
51
+ function onInput(e: Event) { config.onChange?.((e.currentTarget as HTMLInputElement).value); queueMicrotask(maybeOpen) }
52
+ function onKeydown(e: KeyboardEvent) {
53
+ if (!open) { if (e.key === 'ArrowDown') { queueMicrotask(maybeOpen) } return }
54
+ if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(active + 1, filtered.length - 1) }
55
+ else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(active - 1, 0) }
56
+ else if (e.key === 'Enter') { e.preventDefault(); pick(filtered[active]) }
57
+ else if (e.key === 'Escape') { close() }
58
+ }
59
+
60
+ return {
61
+ /** Panel open state. */
62
+ get open() { return open },
63
+ /** Highlighted suggestion index into `filtered`. */
64
+ get activeIndex() { return active },
65
+ /** Suggestions after the live substring filter (capped at 10). */
66
+ get filtered() { return filtered },
67
+ /** Controlled text value. */
68
+ get value() { return config.value() },
69
+ isActive: (i: number) => i === active,
70
+ setActive: (i: number) => { active = i },
71
+ maybeOpen,
72
+ close,
73
+ pick,
74
+ onInput,
75
+ onKeydown,
76
+ /** Spread onto the text <input>. */
77
+ inputProps: () => ({
78
+ role: 'combobox' as const,
79
+ 'aria-expanded': open,
80
+ 'aria-autocomplete': 'list' as const,
81
+ 'aria-label': config.ariaLabel?.(),
82
+ value: config.value(),
83
+ disabled: config.disabled?.() ?? false,
84
+ oninput: onInput,
85
+ onfocus: maybeOpen,
86
+ onkeydown: onKeydown,
87
+ }),
88
+ /** Spread onto the listbox container. */
89
+ listboxProps: () => ({
90
+ role: 'listbox' as const,
91
+ }),
92
+ /** Spread onto the suggestion at `index` (index into `filtered`). */
93
+ optionProps: (index: number) => ({
94
+ role: 'option' as const,
95
+ tabindex: -1,
96
+ 'aria-selected': index === active,
97
+ 'data-idx': index,
98
+ onclick: () => pick(filtered[index]),
99
+ onpointermove: () => { active = index },
100
+ }),
101
+ }
102
+ }
103
+
104
+ export type Autocomplete = ReturnType<typeof createAutocomplete>