@delightstack/components 0.1.0

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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,912 @@
1
+ <script lang="ts">
2
+ import { tooltip } from '@delightstack/utilities';
3
+ import { getContext } from 'svelte';
4
+ import type { FormContext } from './Form.svelte';
5
+
6
+ const propId = $props.id();
7
+ let {
8
+ /** Current value: number for single, [number, number] for range mode */
9
+ value = $bindable(0) as number | [number, number],
10
+
11
+ /** Minimum value */
12
+ min = 0,
13
+
14
+ /** Maximum value */
15
+ max = 100,
16
+
17
+ /** Step increment */
18
+ step = 1,
19
+
20
+ /** Whether to show two thumbs for range selection */
21
+ range = false,
22
+
23
+ /** Whether the slider is disabled */
24
+ disabled = false,
25
+
26
+ /** Size preset: 0=small, 1=default, 2=medium, 3=large */
27
+ size = '1' as '0' | '1' | '2' | '3',
28
+
29
+ /** Whether to show the current value near the thumb */
30
+ show_value = false,
31
+
32
+ /** Whether to display stop indicator dots at each step */
33
+ show_ticks = false,
34
+
35
+ /** Custom labels for tick positions */
36
+ tick_labels = undefined as string[] | undefined,
37
+
38
+ /** Custom formatter for displayed values */
39
+ format_value = undefined as ((n: number) => string) | undefined,
40
+
41
+ /** Label text displayed above the slider */
42
+ label = undefined as string | undefined,
43
+
44
+ /** Tooltip message shown on hover */
45
+ tooltip: tooltip_message = undefined as string | undefined,
46
+
47
+ /** Whether the slider uses dense spacing */
48
+ dense = false,
49
+
50
+ /** Whether the slider uses comfortable spacing */
51
+ comfortable = false,
52
+
53
+ /** Whether to display the slider vertically */
54
+ vertical = false,
55
+
56
+ /** The id of the slider element */
57
+ id = propId,
58
+
59
+ /** Name attribute for hidden input(s) */
60
+ name = undefined as string | undefined,
61
+
62
+ /** Error message shown below the slider */
63
+ error = '',
64
+
65
+ /** Parses & validates the value (e.g. a database table form field's
66
+ * `parse`). Inside a Form it is registered with the form, which runs it
67
+ * on the form's validation timing. */
68
+ parse = undefined as ((value: unknown) => unknown) | undefined,
69
+
70
+ /** Accessible label for the slider thumb(s) when no visible `label` is
71
+ * shown (e.g. an icon-only slider). Takes precedence over `label`. */
72
+ aria_label = undefined as string | undefined,
73
+
74
+ /** Custom class name */
75
+ class: class_name = '',
76
+
77
+ /** Called when value changes (on pointerup / change) */
78
+ onchange = undefined as
79
+ | ((detail: { value: number | [number, number] }) => void)
80
+ | undefined,
81
+
82
+ /** Called during dragging */
83
+ oninput = undefined as
84
+ | ((detail: { value: number | [number, number] }) => void)
85
+ | undefined,
86
+ } = $props();
87
+
88
+ let lower_hovering = $state(false);
89
+ let upper_hovering = $state(false);
90
+ let lower_dragging = $state(false);
91
+ let upper_dragging = $state(false);
92
+ let drag_wrapper: HTMLElement | null = null;
93
+ let active_thumb = $state<'lower' | 'upper' | null>(null);
94
+ let overshoot_px = $state(0);
95
+
96
+ /* ------------------------------------------------------------------ */
97
+ /* Form context integration */
98
+ /* ------------------------------------------------------------------ */
99
+
100
+ const form_ctx = getContext<FormContext | undefined>('form');
101
+ let lower_input = $state<HTMLInputElement | undefined>(undefined);
102
+
103
+ /** Inside a Form with a name, the slider value flows through the form data */
104
+ const context_driven = !!(form_ctx && name);
105
+
106
+ /** Disabled merges the parent form's disabled/submitting state */
107
+ const effectively_disabled = $derived(disabled || (form_ctx?.disabled ?? false));
108
+
109
+ /** Error from the local prop or the parent form context */
110
+ const resolved_error = $derived.by(() => {
111
+ if (error) return error;
112
+ if (form_ctx && name && form_ctx.errors[name]) return form_ctx.errors[name];
113
+ return '';
114
+ });
115
+
116
+ // Register with a parent Form (focus-on-error target + field validator).
117
+ $effect(() => {
118
+ if (!form_ctx || !name) return;
119
+ if (lower_input) form_ctx.register(name, lower_input, parse);
120
+ return () => form_ctx.unregister(name);
121
+ });
122
+
123
+ // Pull the value from the form data. Declared before the push effect so it
124
+ // runs first on mount — the form's value wins over the prop default.
125
+ $effect(() => {
126
+ if (!context_driven || !form_ctx || !name) return;
127
+ const ctx_value = form_ctx.getValue(name);
128
+ if (range) {
129
+ if (
130
+ Array.isArray(ctx_value) &&
131
+ (!Array.isArray(value) || ctx_value[0] !== value[0] || ctx_value[1] !== value[1])
132
+ ) {
133
+ value = [Number(ctx_value[0]), Number(ctx_value[1])];
134
+ }
135
+ } else if (ctx_value != null) {
136
+ const next = Number(ctx_value);
137
+ if (!Number.isNaN(next) && next !== value) value = next;
138
+ }
139
+ });
140
+
141
+ // Push the current value into the form data. Called synchronously from the
142
+ // value-change handlers (not a $effect) so the form data is fresh before the
143
+ // pull effect above re-runs — otherwise it would read the stale value and
144
+ // clobber the change.
145
+ function pushValue() {
146
+ if (context_driven && form_ctx && name) form_ctx.setValue(name, emitValue());
147
+ }
148
+
149
+ const lower_value = $derived(
150
+ range && Array.isArray(value) ? value[0] : (value as number),
151
+ );
152
+ const upper_value = $derived(range && Array.isArray(value) ? value[1] : max);
153
+
154
+ const lower_pct = $derived(((lower_value - min) / (max - min)) * 100);
155
+ const upper_pct = $derived(((upper_value - min) / (max - min)) * 100);
156
+
157
+ // Native range inputs offset the thumb center from raw percentage by
158
+ // handleWidth * (0.5 - ratio). Compute this so track segments align with the thumb.
159
+ const lower_thumb_offset = $derived(0.5 - lower_pct / 100);
160
+ const upper_thumb_offset = $derived(0.5 - upper_pct / 100);
161
+
162
+ const is_dragging = $derived(lower_dragging || upper_dragging);
163
+
164
+ // Visual pixel offsets for track segments to follow the handle during magnetic drag
165
+ const lower_visual_offset = $derived(active_thumb === 'lower' ? overshoot_px : 0);
166
+ const upper_visual_offset = $derived(active_thumb === 'upper' ? overshoot_px : 0);
167
+
168
+ const tick_count = $derived(Math.floor((max - min) / step));
169
+
170
+ function formatDisplay(n: number): string {
171
+ if (format_value) return format_value(n);
172
+ return String(n);
173
+ }
174
+
175
+ function emitValue() {
176
+ return range ? ([lower_value, upper_value] as [number, number]) : lower_value;
177
+ }
178
+
179
+ function onLowerInput(e: Event) {
180
+ const input = e.currentTarget as HTMLInputElement;
181
+ const v = Number(input.value);
182
+ if (range) {
183
+ value = [v, Math.max(v, upper_value)] as [number, number];
184
+ } else {
185
+ value = v;
186
+ }
187
+ pushValue();
188
+ oninput?.({ value: emitValue() });
189
+ // Recompute overshoot now that value has snapped, so the DOM update
190
+ // includes an overshoot consistent with the new thumb position
191
+ if (is_dragging) updateOvershoot();
192
+ }
193
+
194
+ function onUpperInput(e: Event) {
195
+ const input = e.currentTarget as HTMLInputElement;
196
+ const v = Number(input.value);
197
+ value = [Math.min(v, lower_value), v] as [number, number];
198
+ pushValue();
199
+ oninput?.({ value: emitValue() });
200
+ if (is_dragging) updateOvershoot();
201
+ }
202
+
203
+ function onLowerChange() {
204
+ if (form_ctx && name) form_ctx.setTouched(name);
205
+ onchange?.({ value: emitValue() });
206
+ }
207
+
208
+ function onUpperChange() {
209
+ if (form_ctx && name) form_ctx.setTouched(name);
210
+ onchange?.({ value: emitValue() });
211
+ }
212
+
213
+ function rawPctFromPointer(e: PointerEvent): number {
214
+ if (!drag_wrapper) return 0;
215
+ const rect = drag_wrapper.getBoundingClientRect();
216
+ return vertical
217
+ ? 1 - (e.clientY - rect.top) / rect.height
218
+ : (e.clientX - rect.left) / rect.width;
219
+ }
220
+
221
+ function valueFromPointer(e: PointerEvent): number {
222
+ const pct = Math.max(0, Math.min(1, rawPctFromPointer(e)));
223
+ const raw = min + pct * (max - min);
224
+ const snapped = Math.round(raw / step) * step;
225
+ return Math.max(min, Math.min(max, snapped));
226
+ }
227
+
228
+ function setValueForThumb(v: number) {
229
+ if (range && Array.isArray(value)) {
230
+ if (active_thumb === 'lower') {
231
+ value = [v, Math.max(v, upper_value)] as [number, number];
232
+ } else {
233
+ value = [Math.min(v, lower_value), v] as [number, number];
234
+ }
235
+ } else {
236
+ value = v;
237
+ }
238
+ pushValue();
239
+ }
240
+
241
+ // --- Track click-and-drag (custom pointer capture on wrapper) ---
242
+
243
+ function onTrackPointerDown(e: PointerEvent) {
244
+ if (effectively_disabled) return;
245
+ if (e.target instanceof HTMLInputElement) return;
246
+ e.preventDefault();
247
+
248
+ drag_wrapper = e.currentTarget as HTMLElement;
249
+ const v = valueFromPointer(e);
250
+
251
+ let thumb: 'lower' | 'upper' = 'lower';
252
+ if (range && Array.isArray(value)) {
253
+ const lower_dist = Math.abs(v - lower_value);
254
+ const upper_dist = Math.abs(v - upper_value);
255
+ thumb = lower_dist <= upper_dist ? 'lower' : 'upper';
256
+ }
257
+
258
+ active_thumb = thumb;
259
+ drag_wrapper.setPointerCapture(e.pointerId);
260
+ if (thumb === 'lower') lower_dragging = true;
261
+ else upper_dragging = true;
262
+
263
+ setValueForThumb(v);
264
+ oninput?.({ value: emitValue() });
265
+ }
266
+
267
+ function onTrackPointerMove(e: PointerEvent) {
268
+ if (!active_thumb) return;
269
+ // Only update value for track-initiated drags (wrapper has capture, so
270
+ // target is the wrapper). For native thumb drags the event bubbles from
271
+ // the input and the native oninput handler manages the value instead.
272
+ if (!(e.target instanceof HTMLInputElement)) {
273
+ setValueForThumb(valueFromPointer(e));
274
+ oninput?.({ value: emitValue() });
275
+ }
276
+ }
277
+
278
+ function onTrackPointerUp() {
279
+ if (!active_thumb) return;
280
+ if (active_thumb === 'lower') lower_dragging = false;
281
+ else upper_dragging = false;
282
+ active_thumb = null;
283
+ overshoot_px = 0;
284
+ drag_wrapper = null;
285
+ if (form_ctx && name) form_ctx.setTouched(name);
286
+ onchange?.({ value: emitValue() });
287
+ }
288
+
289
+ // --- Native thumb drag detection via pointer capture events ---
290
+ // The browser internally sets pointer capture on the input when dragging
291
+ // the thumb. gotpointercapture/lostpointercapture fire regardless of
292
+ // the CSS pointer-events property.
293
+
294
+ function onThumbCaptureStart(thumb: 'lower' | 'upper', e: Event) {
295
+ if (active_thumb) return; // Already in a custom track drag
296
+ active_thumb = thumb;
297
+ drag_wrapper = (e.currentTarget as HTMLElement).parentElement as HTMLElement;
298
+ if (thumb === 'lower') lower_dragging = true;
299
+ else upper_dragging = true;
300
+ }
301
+
302
+ function onThumbCaptureEnd(thumb: 'lower' | 'upper') {
303
+ if (active_thumb !== thumb) return; // Wasn't our drag
304
+ if (thumb === 'lower') lower_dragging = false;
305
+ else upper_dragging = false;
306
+ active_thumb = null;
307
+ overshoot_px = 0;
308
+ drag_wrapper = null;
309
+ }
310
+
311
+ // --- Overshoot computation (shared by pointermove and oninput) ---
312
+
313
+ let last_pointer_coord = 0;
314
+
315
+ function updateOvershoot() {
316
+ if (!drag_wrapper) return;
317
+ const rect = drag_wrapper.getBoundingClientRect();
318
+ const raw_pct = vertical
319
+ ? 1 - (last_pointer_coord - rect.top) / rect.height
320
+ : (last_pointer_coord - rect.left) / rect.width;
321
+
322
+ if (raw_pct < 0 || raw_pct > 1) {
323
+ // Edge rubber band
324
+ const overflow_px = (raw_pct < 0 ? raw_pct : raw_pct - 1) * rect.width;
325
+ const max_shift = 24;
326
+ overshoot_px = max_shift * Math.tanh(overflow_px / 100);
327
+ } else {
328
+ // Magnetic tick gravity — uses the ACTUAL current value (not our
329
+ // own snap) so the overshoot is always consistent with the thumb
330
+ // position. Uses a smooth easing that reaches full-follow at the
331
+ // midpoint between ticks, guaranteeing visual continuity at snaps.
332
+ const current_val = active_thumb === 'lower' ? lower_value : upper_value;
333
+ const snapped_pct = (current_val - min) / (max - min);
334
+ const pull_px = (raw_pct - snapped_pct) * rect.width;
335
+ const step_px = (step / (max - min)) * rect.width;
336
+ const half_step_px = step_px / 2;
337
+
338
+ if (half_step_px < 1) {
339
+ overshoot_px = 0;
340
+ } else {
341
+ // Smooth ease: starts at gravity rate near tick, reaches
342
+ // full follow at midpoint (continuity across snaps)
343
+ const t = Math.min(1, Math.abs(pull_px) / half_step_px);
344
+ const gravity = 0.15;
345
+ const eased = gravity * t + (1 - gravity) * t * t;
346
+ overshoot_px = Math.sign(pull_px) * eased * half_step_px;
347
+ }
348
+ }
349
+ }
350
+
351
+ // --- Window-level pointermove for overshoot (works for both drag sources) ---
352
+
353
+ $effect(() => {
354
+ if (!is_dragging) return;
355
+
356
+ function onMove(e: PointerEvent) {
357
+ last_pointer_coord = vertical ? e.clientY : e.clientX;
358
+ updateOvershoot();
359
+ }
360
+
361
+ window.addEventListener('pointermove', onMove);
362
+ return () => window.removeEventListener('pointermove', onMove);
363
+ });
364
+ </script>
365
+
366
+ <div
367
+ class={['range-container', `size-${size}`, class_name].filter(Boolean).join(' ')}
368
+ class:disabled={effectively_disabled}
369
+ class:dense
370
+ class:comfortable
371
+ class:vertical
372
+ class:has-error={!!resolved_error}
373
+ class:has-tick-labels={show_ticks && !!tick_labels?.length}
374
+ class:dragging={is_dragging}
375
+ {@attach tooltip_message ? tooltip(tooltip_message) : () => {}}>
376
+ {#if label}
377
+ <label for={id}>{label}</label>
378
+ {/if}
379
+
380
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
381
+ <div
382
+ class="range-wrapper"
383
+ onpointerdown={onTrackPointerDown}
384
+ onpointermove={onTrackPointerMove}
385
+ onpointerup={onTrackPointerUp}>
386
+ {#if show_value}
387
+ <span
388
+ class="value-tooltip"
389
+ class:visible={lower_hovering || lower_dragging}
390
+ style:left="calc({lower_pct}% + var(--handle-width) * {lower_thumb_offset})">
391
+ {formatDisplay(lower_value)}
392
+ </span>
393
+ {#if range}
394
+ <span
395
+ class="value-tooltip"
396
+ class:visible={upper_hovering || upper_dragging}
397
+ style:left="calc({upper_pct}% + var(--handle-width) * {upper_thumb_offset})">
398
+ {formatDisplay(upper_value)}
399
+ </span>
400
+ {/if}
401
+ {/if}
402
+
403
+ <!-- Track segments follow the handle's visual position (including magnetic overshoot) -->
404
+ {#if range}
405
+ <div
406
+ class="track-segment inactive"
407
+ style:left="0"
408
+ style:width="calc({lower_pct}% + var(--handle-width) * {lower_thumb_offset} - var(--gap)
409
+ + {lower_visual_offset}px)">
410
+ </div>
411
+ <div
412
+ class="track-segment active"
413
+ style:left="calc({lower_pct}% + var(--handle-width) * {lower_thumb_offset} + var(--gap)
414
+ + {lower_visual_offset}px)"
415
+ style:width="calc({upper_pct - lower_pct}% + var(--handle-width) * {upper_thumb_offset -
416
+ lower_thumb_offset} - var(--gap) * 2 + {upper_visual_offset -
417
+ lower_visual_offset}px)">
418
+ </div>
419
+ <div
420
+ class="track-segment inactive"
421
+ style:left="calc({upper_pct}% + var(--handle-width) * {upper_thumb_offset} + var(--gap)
422
+ + {upper_visual_offset}px)"
423
+ style:right="0">
424
+ </div>
425
+ {:else}
426
+ <div
427
+ class="track-segment active"
428
+ style:left="0"
429
+ style:width="calc({lower_pct}% + var(--handle-width) * {lower_thumb_offset} - var(--gap)
430
+ + {lower_visual_offset}px)">
431
+ </div>
432
+ <div
433
+ class="track-segment inactive"
434
+ style:left="calc({lower_pct}% + var(--handle-width) * {lower_thumb_offset} + var(--gap)
435
+ + {lower_visual_offset}px)"
436
+ style:right="0">
437
+ </div>
438
+ {/if}
439
+
440
+ <input
441
+ type="range"
442
+ bind:this={lower_input}
443
+ {id}
444
+ {name}
445
+ {min}
446
+ {max}
447
+ {step}
448
+ disabled={effectively_disabled}
449
+ value={lower_value}
450
+ class="lower"
451
+ aria-valuenow={lower_value}
452
+ aria-valuemin={min}
453
+ aria-valuemax={range ? upper_value : max}
454
+ aria-label={aria_label || label || 'Range value'}
455
+ oninput={onLowerInput}
456
+ onchange={onLowerChange}
457
+ onpointerenter={() => (lower_hovering = true)}
458
+ onpointerleave={() => (lower_hovering = false)}
459
+ ongotpointercapture={(e) => onThumbCaptureStart('lower', e)}
460
+ onlostpointercapture={() => onThumbCaptureEnd('lower')} />
461
+
462
+ {#if range}
463
+ <input
464
+ type="range"
465
+ {min}
466
+ {max}
467
+ {step}
468
+ disabled={effectively_disabled}
469
+ value={upper_value}
470
+ class="upper"
471
+ aria-valuenow={upper_value}
472
+ aria-valuemin={lower_value}
473
+ aria-valuemax={max}
474
+ aria-label={aria_label
475
+ ? `${aria_label} upper`
476
+ : label
477
+ ? `${label} upper`
478
+ : 'Range upper value'}
479
+ oninput={onUpperInput}
480
+ onchange={onUpperChange}
481
+ onpointerenter={() => (upper_hovering = true)}
482
+ onpointerleave={() => (upper_hovering = false)}
483
+ ongotpointercapture={(e) => onThumbCaptureStart('upper', e)}
484
+ onlostpointercapture={() => onThumbCaptureEnd('upper')} />
485
+ {/if}
486
+
487
+ <!-- Visual handle(s). Positioned from the raw value (like the track fill)
488
+ so they glide smoothly on programmatic value changes and never
489
+ step-snap the way a native range thumb does. The native input above
490
+ stays transparent but interactive (drag / keyboard / focus). -->
491
+ <div
492
+ class="handle lower"
493
+ class:hovering={lower_hovering}
494
+ class:dragging={lower_dragging}
495
+ style:left="calc({lower_pct}% + var(--handle-width) * {lower_thumb_offset}
496
+ + {lower_visual_offset}px)">
497
+ </div>
498
+ {#if range}
499
+ <div
500
+ class="handle upper"
501
+ class:hovering={upper_hovering}
502
+ class:dragging={upper_dragging}
503
+ style:left="calc({upper_pct}% + var(--handle-width) * {upper_thumb_offset}
504
+ + {upper_visual_offset}px)">
505
+ </div>
506
+ {/if}
507
+
508
+ {#if show_ticks && tick_count <= 50}
509
+ <div class="ticks" aria-hidden="true">
510
+ {#each { length: tick_count + 1 } as _, i}
511
+ {@const tick_value = min + i * step}
512
+ {@const tick_pct = ((tick_value - min) / (max - min)) * 100}
513
+ {@const tick_offset = 0.5 - tick_pct / 100}
514
+ <span
515
+ class="tick"
516
+ class:active={tick_value >= (range ? lower_value : min) &&
517
+ tick_value <= (range ? upper_value : lower_value)}
518
+ style:left="calc({tick_pct}% + var(--handle-width) * {tick_offset})">
519
+ {#if tick_labels && tick_labels[i] !== undefined}
520
+ <span class="tick-label">{tick_labels[i]}</span>
521
+ {/if}
522
+ </span>
523
+ {/each}
524
+ </div>
525
+ {/if}
526
+ </div>
527
+
528
+ {#if show_value && !show_ticks}
529
+ <div class="value-display">
530
+ <span>{formatDisplay(lower_value)}</span>
531
+ {#if range}
532
+ <span>{formatDisplay(upper_value)}</span>
533
+ {/if}
534
+ </div>
535
+ {/if}
536
+
537
+ {#if resolved_error}
538
+ <span class="error-text">{resolved_error}</span>
539
+ {/if}
540
+ </div>
541
+
542
+ <style>
543
+ .range-container {
544
+ --handle-width: 8px;
545
+ --handle-height: 24px;
546
+ --active-height: 6px;
547
+ --inactive-height: 4px;
548
+ --gap: 7px;
549
+ --fill-color: var(--color-action, hsl(220 70% 55%));
550
+ --track-bg: var(--color-bg-muted, hsl(0 0% 80%));
551
+
552
+ display: flex;
553
+ flex-direction: column;
554
+ gap: 0.5em;
555
+ width: 100%;
556
+ font-size: var(--control-font-1, 1rem);
557
+
558
+ &.dense {
559
+ gap: 0.25em;
560
+ }
561
+ &.comfortable {
562
+ gap: 0.75em;
563
+ }
564
+
565
+ .error-text {
566
+ font-size: 0.8em;
567
+ color: var(--color-error, #d32f2f);
568
+ }
569
+
570
+ /* Sizes */
571
+ &.size-0 {
572
+ --handle-width: 6px;
573
+ --handle-height: 20px;
574
+ --active-height: 4px;
575
+ --inactive-height: 4px;
576
+ --gap: 6px;
577
+ font-size: var(--control-font-0, 0.875rem);
578
+ }
579
+ &.size-1 {
580
+ --handle-width: 8px;
581
+ --handle-height: 24px;
582
+ --active-height: 6px;
583
+ --inactive-height: 4px;
584
+ --gap: 7px;
585
+ font-size: var(--control-font-1, 1rem);
586
+ }
587
+ &.size-2 {
588
+ --handle-width: 10px;
589
+ --handle-height: 28px;
590
+ --active-height: 7px;
591
+ --inactive-height: 4px;
592
+ --gap: 9px;
593
+ font-size: var(--control-font-2, 1.125rem);
594
+ }
595
+ &.size-3 {
596
+ --handle-width: 12px;
597
+ --handle-height: 32px;
598
+ --active-height: 8px;
599
+ --inactive-height: 5px;
600
+ --gap: 10px;
601
+ font-size: var(--control-font-3, 1.25rem);
602
+ }
603
+ }
604
+
605
+ label {
606
+ color: var(--color-text, inherit);
607
+ font-weight: 500;
608
+ line-height: 1.4;
609
+ }
610
+
611
+ .range-wrapper {
612
+ position: relative;
613
+ height: var(--handle-height);
614
+ display: flex;
615
+ align-items: center;
616
+ cursor: pointer;
617
+ }
618
+
619
+ /* Track segments */
620
+ .track-segment {
621
+ position: absolute;
622
+ top: 50%;
623
+ transform: translateY(-50%);
624
+ border-radius: 999px;
625
+ pointer-events: none;
626
+ }
627
+
628
+ .track-segment.active {
629
+ height: var(--active-height);
630
+ background: var(--fill-color);
631
+ transition:
632
+ left 100ms ease,
633
+ width 100ms ease,
634
+ height 200ms ease,
635
+ box-shadow 200ms ease;
636
+ }
637
+
638
+ .track-segment.inactive {
639
+ height: var(--inactive-height);
640
+ background: var(--track-bg);
641
+ transition:
642
+ left 100ms ease,
643
+ width 100ms ease,
644
+ right 100ms ease,
645
+ height 200ms ease;
646
+ }
647
+
648
+ /* During drag: no position transition (track follows handle via reactive offset) */
649
+ .dragging .track-segment.active {
650
+ transition:
651
+ height 200ms ease,
652
+ box-shadow 200ms ease;
653
+ box-shadow: 0 0 8px rgb(from var(--fill-color) r g b / 0.35);
654
+ }
655
+ .dragging .track-segment.inactive {
656
+ transition: height 200ms ease;
657
+ }
658
+
659
+ /* Track grows on hover */
660
+ .range-container:not(.disabled):hover .track-segment.active {
661
+ height: calc(var(--active-height) + 2px);
662
+ }
663
+ .range-container:not(.disabled):hover .track-segment.inactive {
664
+ height: calc(var(--inactive-height) + 2px);
665
+ }
666
+
667
+ /* Native range inputs. Kept transparent and interactive for drag, keyboard
668
+ and focus — the visible handle is the `.handle` element below, positioned
669
+ from the raw value so it never step-snaps. */
670
+ input {
671
+ position: absolute;
672
+ width: 100%;
673
+ height: var(--handle-height);
674
+ margin: 0;
675
+ padding: 0;
676
+ background: transparent;
677
+ appearance: none;
678
+ -webkit-appearance: none;
679
+ pointer-events: none;
680
+ outline: none;
681
+ z-index: 2;
682
+ }
683
+
684
+ input::-webkit-slider-runnable-track {
685
+ height: var(--active-height);
686
+ background: transparent;
687
+ border: none;
688
+ }
689
+ input::-moz-range-track {
690
+ height: var(--active-height);
691
+ background: transparent;
692
+ border: none;
693
+ }
694
+
695
+ /* Invisible interaction thumb — the visible bar is `.handle`. Kept sized so
696
+ the drag/keyboard hit target and the focus ring match the handle. */
697
+ input::-webkit-slider-thumb {
698
+ -webkit-appearance: none;
699
+ appearance: none;
700
+ width: var(--handle-width);
701
+ height: var(--handle-height);
702
+ background: transparent;
703
+ border: none;
704
+ cursor: pointer;
705
+ pointer-events: auto;
706
+ margin-top: calc((var(--active-height) - var(--handle-height)) / 2);
707
+ }
708
+ input::-moz-range-thumb {
709
+ width: var(--handle-width);
710
+ height: var(--handle-height);
711
+ background: transparent;
712
+ border: none;
713
+ cursor: pointer;
714
+ pointer-events: auto;
715
+ }
716
+
717
+ /* Focus ring — drawn on the (transparent) thumb so it traces the handle box */
718
+ input:focus-visible::-webkit-slider-thumb {
719
+ outline: 2px solid var(--color-border-active, currentColor);
720
+ outline-offset: 2px;
721
+ border-radius: calc(var(--handle-width) / 2);
722
+ }
723
+ input:focus-visible::-moz-range-thumb {
724
+ outline: 2px solid var(--color-border-active, currentColor);
725
+ outline-offset: 2px;
726
+ border-radius: calc(var(--handle-width) / 2);
727
+ }
728
+
729
+ input:disabled {
730
+ cursor: not-allowed;
731
+ }
732
+ input:disabled::-webkit-slider-thumb {
733
+ cursor: not-allowed;
734
+ }
735
+ input:disabled::-moz-range-thumb {
736
+ cursor: not-allowed;
737
+ }
738
+
739
+ /* M3-style vertical bar handle (the visible thumb). */
740
+ .handle {
741
+ position: absolute;
742
+ top: 50%;
743
+ width: var(--handle-width);
744
+ height: var(--handle-height);
745
+ border-radius: calc(var(--handle-width) / 2);
746
+ background: var(--fill-color);
747
+ transform: translate(-50%, -50%);
748
+ pointer-events: none;
749
+ z-index: 2;
750
+ /* Glide to the new position on programmatic value changes (matches the
751
+ track segments). The transform/box-shadow ease the hover grow + halo. */
752
+ transition:
753
+ left 100ms ease,
754
+ transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1),
755
+ box-shadow 150ms ease;
756
+ }
757
+
758
+ /* Handle hover: widen + grow taller + halo */
759
+ .range-container:not(.disabled) .handle.hovering {
760
+ transform: translate(-50%, -50%) scale(1.5, 1.3);
761
+ box-shadow: 0 0 0 8px rgb(from var(--fill-color) r g b / 0.12);
762
+ }
763
+
764
+ /* During drag: bigger scale + larger halo, and instant position tracking
765
+ (no `left`/`transform` easing) so it stays pinned under the pointer. */
766
+ .range-container:not(.disabled) .handle.dragging {
767
+ transform: translate(-50%, -50%) scale(1.5, 1.15);
768
+ box-shadow: 0 0 0 12px rgb(from var(--fill-color) r g b / 0.18);
769
+ }
770
+ .handle.dragging {
771
+ transition: box-shadow 150ms ease;
772
+ }
773
+
774
+ .disabled .handle {
775
+ background: var(--color-action-disabled, hsl(0 0% 70%));
776
+ }
777
+
778
+ .disabled {
779
+ opacity: 0.5;
780
+ }
781
+
782
+ /* Ticks — M3 stop indicator dots on the track */
783
+ .ticks {
784
+ position: absolute;
785
+ left: 0;
786
+ right: 0;
787
+ top: 0;
788
+ bottom: 0;
789
+ pointer-events: none;
790
+ z-index: 3;
791
+ }
792
+
793
+ .tick {
794
+ position: absolute;
795
+ top: 50%;
796
+ width: 4px;
797
+ height: 4px;
798
+ border-radius: 50%;
799
+ background: var(--fill-color);
800
+ opacity: 0.6;
801
+ transform: translate(-50%, -50%);
802
+ }
803
+ .tick.active {
804
+ background: var(--color-action-text, white);
805
+ opacity: 0.6;
806
+ }
807
+
808
+ .tick-label {
809
+ position: absolute;
810
+ top: calc(var(--handle-height) / 2 + 6px);
811
+ left: 50%;
812
+ transform: translateX(-50%);
813
+ font-size: 0.75em;
814
+ color: var(--color-text-muted, inherit);
815
+ white-space: nowrap;
816
+ }
817
+
818
+ /* Value tooltip */
819
+ .value-tooltip {
820
+ position: absolute;
821
+ bottom: calc(100% + 8px);
822
+ transform: translateX(-50%) translateY(4px);
823
+ background: var(--color-action-active, hsl(220 70% 50%));
824
+ color: var(--color-action-text, white);
825
+ padding: 2px 8px;
826
+ border-radius: 4px;
827
+ font-size: 0.8em;
828
+ font-weight: 600;
829
+ white-space: nowrap;
830
+ pointer-events: none;
831
+ z-index: 3;
832
+ opacity: 0;
833
+ visibility: hidden;
834
+ transition:
835
+ opacity 150ms ease,
836
+ transform 150ms ease,
837
+ visibility 150ms ease;
838
+ }
839
+
840
+ .value-tooltip.visible {
841
+ opacity: 1;
842
+ visibility: visible;
843
+ transform: translateX(-50%) translateY(0);
844
+ }
845
+
846
+ /* Tooltip arrow */
847
+ .value-tooltip::after {
848
+ content: '';
849
+ position: absolute;
850
+ top: 100%;
851
+ left: 50%;
852
+ transform: translateX(-50%);
853
+ border: 4px solid transparent;
854
+ border-top-color: var(--color-action-active, hsl(220 70% 50%));
855
+ }
856
+
857
+ /* Value display below */
858
+ .value-display {
859
+ display: flex;
860
+ justify-content: space-between;
861
+ color: var(--color-text-muted, inherit);
862
+ font-size: 0.85em;
863
+ font-variant-numeric: tabular-nums;
864
+ }
865
+
866
+ .has-tick-labels .range-wrapper {
867
+ margin-bottom: 1.5em;
868
+ }
869
+
870
+ /* ========== Vertical mode ========== */
871
+ .range-container.vertical {
872
+ width: fit-content;
873
+ height: var(--range-height, 200px);
874
+ align-items: center;
875
+ overflow: visible;
876
+
877
+ .range-wrapper {
878
+ /* Rotate the horizontal slider to render vertically.
879
+ The wrapper's CSS "width" becomes the visual height. */
880
+ width: var(--range-height, 200px);
881
+ transform-origin: 0 0;
882
+ transform: rotate(-90deg) translateX(-100%);
883
+ }
884
+
885
+ /* Vertical tooltip: reposition to the right of the handle, counter-rotate text */
886
+ .value-tooltip {
887
+ /* In rotated space, "bottom: 100%" places the tooltip to the visual-left.
888
+ Switch to "top: 100%" so it appears to the visual-right instead. */
889
+ bottom: auto;
890
+ top: calc(100% + 8px);
891
+ transform: translateX(-50%) translateY(-4px) rotate(90deg) translateX(18px);
892
+
893
+ &.visible {
894
+ transform: translateX(-50%) translateY(0) rotate(90deg) translateX(10px);
895
+ }
896
+
897
+ /* Arrow points left instead of down */
898
+ &::after {
899
+ top: 50%;
900
+ left: auto;
901
+ right: 100%;
902
+ transform: translateY(-50%);
903
+ border-top-color: transparent;
904
+ border-right-color: var(--color-action-active, hsl(220 70% 50%));
905
+ }
906
+ }
907
+
908
+ .tick-label {
909
+ transform: translateX(-50%) rotate(90deg);
910
+ }
911
+ }
912
+ </style>