|
28 | 28 | import { cubicOut } from 'svelte/easing'; |
29 | 29 | import { Tween } from 'svelte/motion'; |
30 | 30 |
|
31 | | - // Unique gauge IDs |
| 31 | + // Unique IDs |
32 | 32 | const id = $props.id(); |
33 | 33 | const inputId = id + '-input'; |
34 | 34 | const labelId = id + '-label'; |
35 | | - const gaugeId = id + '-gauge'; |
36 | 35 |
|
37 | 36 | interface Props { |
38 | 37 | name: string; |
|
51 | 50 | let lastFraction: number | null = null; |
52 | 51 |
|
53 | 52 | // --- helpers --- |
| 53 | + function snapValue(raw: number): number { |
| 54 | + return clamp(Math.round((raw - min) / step) * step + min, min, max); |
| 55 | + } |
| 56 | +
|
54 | 57 | function fractionFromEvent(event: PointerEvent) { |
55 | 58 | const rect = element.getBoundingClientRect(); |
56 | 59 | const cx = rect.left + rect.width / 2; |
|
95 | 98 | isTracking = false; |
96 | 99 | window.removeEventListener('pointermove', handlePointerMoveDrag); |
97 | 100 | window.removeEventListener('pointerup', stopTracking); |
| 101 | + window.removeEventListener('pointercancel', stopTracking); |
98 | 102 | lastFraction = null; // reset for next interaction |
99 | 103 | } |
100 | 104 |
|
|
104 | 108 | // Always jump to exact pointer position on start (wrap protection OFF) |
105 | 109 | updateValueFromFraction(fractionFromEvent(event)); |
106 | 110 |
|
107 | | - element.setPointerCapture(event.pointerId); |
108 | | -
|
109 | 111 | if (!isTracking) { |
110 | 112 | isTracking = true; |
111 | 113 | window.addEventListener('pointermove', handlePointerMoveDrag); |
112 | 114 | window.addEventListener('pointerup', stopTracking); |
| 115 | + window.addEventListener('pointercancel', stopTracking); |
113 | 116 | } |
114 | 117 | } |
115 | 118 |
|
|
121 | 124 | easing: cubicOut, |
122 | 125 | }); |
123 | 126 |
|
124 | | - // Update animated value based on value, step, min, and max |
| 127 | + // Snap value to step grid and animate |
125 | 128 | $effect(() => { |
126 | | - const stepped = Math.round((value - min) / step) * step + min; |
127 | | - const rounded = Math.round((stepped + Number.EPSILON) * 100) / 100; |
128 | | - value = clamp(rounded, min, max); |
129 | | - tween.set(value); |
| 129 | + const snapped = snapValue(value); |
| 130 | + if (snapped !== value) { |
| 131 | + value = snapped; |
| 132 | + } |
| 133 | + tween.set(snapped); |
130 | 134 | }); |
131 | 135 |
|
132 | 136 | // Update visual progress |
|
146 | 150 |
|
147 | 151 | <!-- foreground arc --> |
148 | 152 | <path |
149 | | - id={gaugeId} |
150 | 153 | d={calcSvgPathData(degrees)} |
151 | 154 | class="cursor-pointer fill-none stroke-blue-500 stroke-[10] dark:stroke-blue-400" |
152 | 155 | stroke-linecap="round" |
|
166 | 169 | aria-valuenow={value} |
167 | 170 | aria-valuemax={max} |
168 | 171 | aria-labelledby={labelId} |
169 | | - aria-controls={gaugeId} |
170 | 172 | class="cursor-move fill-white stroke-neutral-400 drop-shadow-md outline-none focus:ring-2 focus:ring-blue-500/60 dark:fill-neutral-900 dark:stroke-neutral-700" |
171 | 173 | /> |
172 | 174 | </svg> |
|
188 | 190 | <label |
189 | 191 | id={labelId} |
190 | 192 | for={inputId} |
191 | | - aria-label="Name" |
192 | 193 | class="absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/10 text-center text-neutral-600 dark:text-neutral-300" |
193 | 194 | > |
194 | 195 | {name} |
|
0 commit comments