@blotch/dolan 22.2.3 → 22.2.5

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 (227) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +105 -2
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -0,0 +1,901 @@
1
+ import {
2
+ type ElementRef,
3
+ Component,
4
+ Injector,
5
+ ViewEncapsulation,
6
+ booleanAttribute,
7
+ computed,
8
+ inject,
9
+ input,
10
+ model,
11
+ output,
12
+ signal,
13
+ viewChild,
14
+ } from '@angular/core';
15
+ import { type FormValueControl } from '@angular/forms/signals';
16
+ import {
17
+ LYNX_ELEMENTS,
18
+ LynxAccessibilitySettings,
19
+ LynxSystemInfo,
20
+ MainThreadElement,
21
+ } from '@blotch/angular-lynx';
22
+
23
+ import { DURATION, EASING } from '../../utils/animate';
24
+ import { cn } from '../../utils/cn';
25
+ import { UiGlass } from '../glass/glass';
26
+ import {
27
+ type GlassDesign,
28
+ injectGlassDesign,
29
+ injectSystemDrawsGlass,
30
+ } from '../glass/glass-design';
31
+ import { injectColorScheme } from '../icon/icon-theme';
32
+
33
+ /**
34
+ * `'default'` is a flat slider in the theme's colors on every platform.
35
+ * `'glass'` takes each platform's own: iOS 26's, whose thumb turns into a
36
+ * Liquid Glass lens while it's dragged; iOS 15 to 25's; and Material 3
37
+ * Expressive's on Android (see GlassDesign).
38
+ */
39
+ export type SliderVariant = 'default' | 'glass';
40
+
41
+ /** What the slider draws: the default look, or a platform's design. */
42
+ type SliderLook = 'default' | GlassDesign;
43
+
44
+ /** A slider's sizes in one look, in px. */
45
+ type SliderGeometry = {
46
+ /** The control's height, which is also where it takes touches. */
47
+ readonly height: number;
48
+ /** The track's thickness. */
49
+ readonly track: number;
50
+ /** The thumb's size at rest. */
51
+ readonly thumbWidth: number;
52
+ readonly thumbHeight: number;
53
+ /** Its size while it's held: the glass lens, or Material's thin handle. */
54
+ readonly pressedWidth: number;
55
+ readonly pressedHeight: number;
56
+ };
57
+
58
+ /**
59
+ * The sizes of each look:
60
+ *
61
+ * - `default`: a 20px round thumb on an 8px track.
62
+ * - `glass`: iOS 26's UISlider, measured on the iOS 27 Simulator: 34pt
63
+ * tall, a 6pt track and a 37 by 24pt capsule thumb, which grows to a 59 by
64
+ * 38pt glass lens (1.6 times) while it's held.
65
+ * - `frosted`: UISlider from iOS 13 to 25: 31pt tall, a 4pt track and a 28pt
66
+ * round thumb that doesn't change when it's held.
67
+ * - `material`: Material 3 Expressive's slider: a 16dp track and a 4 by 44dp
68
+ * bar for a handle, which narrows to 2dp while it's held.
69
+ *
70
+ * Every size is in px, not rem: Lynx's rem is 14px, so the thumb's offset,
71
+ * which is in px, would drift from a rem-sized track (see the switch).
72
+ */
73
+ const SLIDER_GEOMETRY: Readonly<Record<SliderLook, SliderGeometry>> = {
74
+ default: {
75
+ height: 20,
76
+ track: 8,
77
+ thumbWidth: 20,
78
+ thumbHeight: 20,
79
+ pressedWidth: 20,
80
+ pressedHeight: 20,
81
+ },
82
+ glass: {
83
+ height: 34,
84
+ track: 6,
85
+ thumbWidth: 37,
86
+ thumbHeight: 24,
87
+ pressedWidth: 59,
88
+ pressedHeight: 38,
89
+ },
90
+ frosted: {
91
+ height: 31,
92
+ track: 4,
93
+ thumbWidth: 28,
94
+ thumbHeight: 28,
95
+ pressedWidth: 28,
96
+ pressedHeight: 28,
97
+ },
98
+ material: {
99
+ height: 44,
100
+ track: 16,
101
+ thumbWidth: 4,
102
+ thumbHeight: 44,
103
+ pressedWidth: 2,
104
+ pressedHeight: 44,
105
+ },
106
+ };
107
+
108
+ /** How much smaller than the thumb the hidden lens is, on each side. */
109
+ const LENS_INSET = 2;
110
+
111
+ /** The gap Material 3 Expressive leaves between its handle and the track. */
112
+ const MATERIAL_GAP = 6;
113
+
114
+ /** Material's stop indicator: a dot at the end of the inactive track. */
115
+ const MATERIAL_STOP = 4;
116
+
117
+ /**
118
+ * iOS's colors, in the app's color scheme as UIKit draws them in the
119
+ * phone's. The filled part is the system tint UISlider takes by default,
120
+ * the blue measured on the iOS 27 Simulator for iOS 26 and the older system
121
+ * blue before it. The rest of the track is translucent, so it sits on any
122
+ * background.
123
+ */
124
+ const IOS_COLORS = {
125
+ glass: {
126
+ light: { fill: '#0088ff', track: 'rgba(0, 0, 0, 0.1)' },
127
+ dark: { fill: '#0091ff', track: 'rgba(255, 255, 255, 0.1)' },
128
+ },
129
+ frosted: {
130
+ light: { fill: '#007aff', track: 'rgba(120, 120, 128, 0.2)' },
131
+ dark: { fill: '#0a84ff', track: 'rgba(120, 120, 128, 0.36)' },
132
+ },
133
+ } as const;
134
+
135
+ /**
136
+ * The shadows under iOS's white thumbs. Inline styles, because Tailwind's
137
+ * shadow classes draw nothing on Lynx.
138
+ */
139
+ const IOS_THUMB_SHADOW = {
140
+ glass: '0 4px 12px rgba(0, 0, 0, 0.12), 0 0.5px 2px rgba(0, 0, 0, 0.12)',
141
+ frosted: '0 6px 13px rgba(0, 0, 0, 0.12), 0 0.5px 4px rgba(0, 0, 0, 0.12)',
142
+ } as const;
143
+
144
+ /**
145
+ * How far a finger moves before a press becomes a drag, in px. Below it, a
146
+ * press is a tap or the start of a scroll, and the value stays put.
147
+ */
148
+ const DRAG_SLOP = 6;
149
+
150
+ /**
151
+ * The swipe angles, in degrees from the right, that the slider keeps from
152
+ * the scroll views around it: within 45 degrees of sideways. On iOS a
153
+ * scroll view's pan runs alongside Lynx's touch events, so without this a
154
+ * slightly diagonal drag also scrolled the page (`consume-slide-event` fails
155
+ * those pans). A swipe up or down still scrolls.
156
+ */
157
+ const SIDEWAYS: [number, number][] = [
158
+ [-45, 45],
159
+ [135, 180],
160
+ [-180, -135],
161
+ ];
162
+
163
+ /** How far outside the thumb a press still picks it up, in px. */
164
+ const GRAB_SLOP = 10;
165
+
166
+ /**
167
+ * A CSS transition of each property, for an inline `transition`.
168
+ */
169
+ const transitionOf = (
170
+ properties: readonly string[],
171
+ duration: number,
172
+ easing: string,
173
+ ): string =>
174
+ properties
175
+ .map((property) => `${property} ${duration}ms ${easing}`)
176
+ .join(', ');
177
+
178
+ /**
179
+ * The thumb glides to a value set by a tap or an accessibility action, and
180
+ * the track's parts glide with it. A drag moves them with the finger at
181
+ * once, and a new layout or a value from a binding puts them in place.
182
+ */
183
+ const THUMB_GLIDE = transitionOf(
184
+ ['transform'],
185
+ DURATION.normal,
186
+ EASING.decelerate,
187
+ );
188
+ const FILL_GLIDE = transitionOf(['width'], DURATION.normal, EASING.decelerate);
189
+ const TRACK_GLIDE = transitionOf(
190
+ ['left', 'width'],
191
+ DURATION.normal,
192
+ EASING.decelerate,
193
+ );
194
+
195
+ /**
196
+ * The thumb's change of size and opacity as it's picked up and let go.
197
+ * Lynx can't animate a corner radius, but a capsule's is half its height
198
+ * at any size (`rounded-full`), so it stays a capsule as it grows.
199
+ */
200
+ const MORPH = transitionOf(
201
+ ['left', 'top', 'width', 'height', 'opacity'],
202
+ DURATION.fast,
203
+ EASING.standard,
204
+ );
205
+
206
+ /** The names of the accessibility actions, which screen readers say. */
207
+ const INCREASE = 'Increase';
208
+ const DECREASE = 'Decrease';
209
+
210
+ /** Where a touch or the mouse is, in what Lynx sends with its events. */
211
+ type PointerEvent = {
212
+ readonly touches?: readonly { readonly x?: number; readonly y?: number }[];
213
+ readonly x?: number;
214
+ readonly y?: number;
215
+ };
216
+
217
+ /** What Lynx's `layoutchange` sends, as far as the slider reads it. */
218
+ type LayoutEvent = {
219
+ readonly detail?: { readonly width?: number };
220
+ readonly params?: { readonly width?: number };
221
+ };
222
+
223
+ /**
224
+ * The position of a touch, or of the mouse on the web. A desktop browser
225
+ * sends no touch events for a mouse, so the slider listens for both.
226
+ */
227
+ const pointOf = (event: PointerEvent): { x: number; y: number } | null => {
228
+ const source = event.touches?.[0] ?? event;
229
+ return typeof source.x === 'number' && typeof source.y === 'number'
230
+ ? { x: source.x, y: source.y }
231
+ : null;
232
+ };
233
+
234
+ /**
235
+ * The platform, as SystemInfo names it, or undefined where there's no
236
+ * SystemInfo to read, such as in unit tests, where LynxSystemInfo throws.
237
+ */
238
+ const platformOf = (injector: Injector): string | undefined => {
239
+ try {
240
+ return injector.get(LynxSystemInfo).platform;
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ };
245
+
246
+ /**
247
+ * The decimals of a step, to round away floating-point drift.
248
+ */
249
+ const decimalsOf = (step: number): number => {
250
+ const [, decimals = ''] = String(step).split('.');
251
+ return decimals.length;
252
+ };
253
+
254
+ /**
255
+ * A length in px, for a style object.
256
+ */
257
+ const px = (value: number): string => `${value}px`;
258
+
259
+ /**
260
+ * A press in progress: where it started, and whether it has become a drag.
261
+ */
262
+ type Press = {
263
+ readonly startX: number;
264
+ readonly startY: number;
265
+ lastX: number;
266
+ /** It moved sideways past DRAG_SLOP, so the thumb follows it. */
267
+ dragging: boolean;
268
+ /** It moved up or down first: a scroll, which the slider leaves alone. */
269
+ scrolling: boolean;
270
+ /**
271
+ * Where the finger is on the thumb, from its center, so the thumb doesn't
272
+ * jump when it's picked up. Null until the press's place in the slider is
273
+ * known, and 0 for a press beside the thumb, which pulls it under the
274
+ * finger.
275
+ */
276
+ grab: number | null;
277
+ /** It landed on the thumb (or within GRAB_SLOP of it). */
278
+ onThumb: boolean;
279
+ };
280
+
281
+ /**
282
+ * A slider for picking a number in a range, such as a volume or a
283
+ * brightness:
284
+ *
285
+ * ```html
286
+ * <ui-slider [(value)]="volume" accessibility-label="Volume" />
287
+ * <ui-slider variant="glass" [min]="0" [max]="1" [step]="0.05" [(value)]="brightness" />
288
+ * ```
289
+ *
290
+ * Drag the thumb, or tap the track to move it there. A drag only starts
291
+ * once the finger moves sideways, so a scroll that starts on the slider
292
+ * scrolls and leaves the value alone.
293
+ *
294
+ * `value` is a `model()`, so it works with `[(value)]`, with Signal Forms'
295
+ * `[formField]` (it implements FormValueControl) and, since Angular 22, with
296
+ * reactive forms' `[formControl]` and `formControlName` and with
297
+ * `[(ngModel)]`.
298
+ *
299
+ * Screen readers find it as an adjustable element with its label and value,
300
+ * and its Increase and Decrease actions move it by a tenth of its range, or
301
+ * one step when there are fewer.
302
+ */
303
+ @Component({
304
+ selector: 'ui-slider',
305
+ imports: [LYNX_ELEMENTS, UiGlass],
306
+ encapsulation: ViewEncapsulation.None,
307
+ // Only the root takes touches: everything inside it ignores them, so a
308
+ // touch's `x` on iOS and Android is relative to the slider, wherever the
309
+ // finger lands (Lynx gives `x` relative to the element touched).
310
+ //
311
+ // Mouse events are bound beside the touch events because a desktop
312
+ // browser never sends touches for a mouse (see the bottom sheet). There's
313
+ // no mouseleave: Chrome doesn't deliver it to Lynx for Web, so a move with
314
+ // no button held ends the drag instead.
315
+ //
316
+ // The lens (iOS 26's glass) sits under the solid thumb, the same size,
317
+ // so it's hidden until the thumb is picked up: then the thumb fades as
318
+ // the lens grows. The lens is always there rather than added on press,
319
+ // since iOS's glass pops in at once when it's added.
320
+ template: `
321
+ <view
322
+ #root
323
+ [class]="rootClass()"
324
+ [style]="rootStyle()"
325
+ hit-slop="8px"
326
+ [consume-slide-event]="sideways"
327
+ [accessibility-element]="true"
328
+ [accessibility-traits]="disabled() ? 'adjustable,disabled' : 'adjustable'"
329
+ accessibility-role-description="slider"
330
+ [accessibility-label]="accessibilityLabel()"
331
+ [accessibility-value]="accessibilityValue()"
332
+ [accessibility-actions]="accessibilityActions"
333
+ (bindaccessibilityaction)="onAccessibilityAction($event)"
334
+ (bindlayoutchange)="onLayout($event)"
335
+ (bindtouchstart)="onPressStart($event)"
336
+ (bindtouchmove)="onPressMove($event)"
337
+ (bindtouchend)="onPressEnd()"
338
+ (bindtouchcancel)="onPressCancel()"
339
+ (bindmousedown)="onPressStart($event)"
340
+ (bindmousemove)="onMouseMove($event)"
341
+ (bindmouseup)="onPressEnd()"
342
+ (bindtap)="onTap()"
343
+ >
344
+ <view
345
+ [user-interaction-enabled]="false"
346
+ [class]="trackClass()"
347
+ [style]="frames().track"
348
+ ></view>
349
+ <view
350
+ [user-interaction-enabled]="false"
351
+ [class]="fillClass()"
352
+ [style]="frames().fill"
353
+ ></view>
354
+ @if (look() === 'material') {
355
+ <view
356
+ [user-interaction-enabled]="false"
357
+ class="rounded-full bg-primary absolute"
358
+ [style]="frames().stop"
359
+ ></view>
360
+ }
361
+ @if (look() === 'glass') {
362
+ <blur-view
363
+ ui-glass
364
+ variant="clear"
365
+ class="absolute"
366
+ [user-interaction-enabled]="false"
367
+ [style]="frames().lens"
368
+ ></blur-view>
369
+ }
370
+ <view
371
+ [user-interaction-enabled]="false"
372
+ [class]="thumbClass()"
373
+ [style]="frames().thumb"
374
+ ></view>
375
+ </view>
376
+ `,
377
+ })
378
+ export class UiSlider implements FormValueControl<number> {
379
+ /** The value, between `min` and `max`. Supports `[(value)]`. */
380
+ readonly value = model(0);
381
+ /** The lowest value, at the start of the track. */
382
+ readonly min = input<number | undefined>(0);
383
+ /** The highest value, at the end of the track. */
384
+ readonly max = input<number | undefined>(100);
385
+ /** The values the slider stops at: `min`, `min + step` and so on. */
386
+ readonly step = input(1);
387
+ readonly disabled = input(false, { transform: booleanAttribute });
388
+ /** `'default'` or `'glass'`, each platform's own design; see SliderVariant. */
389
+ readonly variant = input<SliderVariant>('default');
390
+ /** What a screen reader calls the slider, such as "Volume". */
391
+ readonly accessibilityLabel = input<string | undefined>(undefined, {
392
+ alias: 'accessibility-label',
393
+ });
394
+ /**
395
+ * What a screen reader says for a value. A percentage of the range
396
+ * without it, as iOS and Android read their own sliders.
397
+ */
398
+ readonly valueText = input<((value: number) => string) | undefined>(
399
+ undefined,
400
+ );
401
+ readonly userClass = input<string>('', { alias: 'class' });
402
+
403
+ /**
404
+ * The value the user settled on: when a drag ends, after a tap on the
405
+ * track, and after an accessibility action. `valueChange` follows every
406
+ * step of a drag.
407
+ */
408
+ readonly changed = output<number>();
409
+ /**
410
+ * The user finished changing the value, for forms, which mark the field
411
+ * as touched (FormUiControl's `touch`).
412
+ */
413
+ readonly touch = output<void>();
414
+
415
+ readonly rootRef = viewChild.required<ElementRef>('root');
416
+
417
+ readonly #settings = inject(LynxAccessibilitySettings);
418
+ readonly #design = injectGlassDesign();
419
+ readonly #scheme = injectColorScheme();
420
+ readonly #systemDrawsGlass = injectSystemDrawsGlass();
421
+ readonly #web = platformOf(inject(Injector)) === 'web';
422
+
423
+ protected readonly accessibilityActions = [INCREASE, DECREASE];
424
+ protected readonly sideways = SIDEWAYS;
425
+
426
+ /** The slider's width, from its layout; 0 until it's laid out. */
427
+ readonly #width = signal(0);
428
+ /** The thumb is held, by a press on it or a drag. */
429
+ readonly #holding = signal(false);
430
+ /**
431
+ * The last change was a tap or an accessibility action, which the thumb
432
+ * glides to. Otherwise it moves at once: with a dragging finger, and when
433
+ * the first layout arrives, which would glide it in from the start.
434
+ */
435
+ readonly #glide = signal(false);
436
+ #press: Press | null = null;
437
+ /**
438
+ * Where the slider's left edge is in the `<lynx-view>`, on the web, where
439
+ * Lynx for Web gives `x` from the view rather than from the element
440
+ * touched. Measured when a press starts; null until the measurement
441
+ * arrives.
442
+ */
443
+ #origin: number | null = null;
444
+ /**
445
+ * The press that ended, in the slider, for the tap that follows it. Null
446
+ * for a tap with no press before it: a screen reader's double tap.
447
+ */
448
+ #tapX: number | null = null;
449
+ /** The press was a drag, so the tap that may follow it isn't one. */
450
+ #suppressTap = false;
451
+
452
+ readonly look = computed<SliderLook>(() =>
453
+ this.variant() === 'glass' ? this.#design() : 'default',
454
+ );
455
+
456
+ readonly #geometry = computed(() => SLIDER_GEOMETRY[this.look()]);
457
+
458
+ readonly #range = computed(() => {
459
+ const min = this.min() ?? 0;
460
+ const max = this.max() ?? 100;
461
+ return { min, max: Math.max(min, max) };
462
+ });
463
+
464
+ /**
465
+ * The value as the slider shows it: within the range and on a step, as
466
+ * the bound value may not be.
467
+ */
468
+ readonly #current = computed(() => this.#snap(this.value()));
469
+
470
+ readonly #fraction = computed(() => {
471
+ const { min, max } = this.#range();
472
+ return max > min ? (this.#current() - min) / (max - min) : 0;
473
+ });
474
+
475
+ /** The thumb's center, from the slider's left edge. */
476
+ readonly #center = computed(() => {
477
+ const { thumbWidth } = this.#geometry();
478
+ const travel = Math.max(0, this.#width() - thumbWidth);
479
+ return thumbWidth / 2 + this.#fraction() * travel;
480
+ });
481
+
482
+ readonly #animate = computed(() => !this.#settings.reduceMotion());
483
+
484
+ /**
485
+ * Where the runtime draws the glass (Android and the web), Reduce
486
+ * Transparency turns it into an opaque surface, which as a lens would hide
487
+ * the track. The solid thumb grows instead. iOS adapts its own glass.
488
+ */
489
+ readonly #lensIsGlass = computed(
490
+ () => this.#systemDrawsGlass || !this.#settings.reduceTransparency(),
491
+ );
492
+
493
+ protected readonly accessibilityValue = computed(() => {
494
+ const format = this.valueText();
495
+ return format
496
+ ? format(this.#current())
497
+ : `${Math.round(this.#fraction() * 100)}%`;
498
+ });
499
+
500
+ protected readonly rootClass = computed(() =>
501
+ cn('w-full relative', this.userClass()),
502
+ );
503
+
504
+ protected readonly rootStyle = computed(() => ({
505
+ height: px(this.#geometry().height),
506
+ opacity: this.disabled()
507
+ ? this.look() === 'material'
508
+ ? '0.38'
509
+ : '0.5'
510
+ : null,
511
+ }));
512
+
513
+ protected readonly trackClass = computed(() => {
514
+ switch (this.look()) {
515
+ case 'default':
516
+ return 'rounded-full bg-secondary absolute left-0 right-0';
517
+ case 'material':
518
+ return 'rounded-l-[2px] rounded-r-full bg-secondary absolute';
519
+ default:
520
+ return 'rounded-full absolute left-0 right-0';
521
+ }
522
+ });
523
+
524
+ protected readonly fillClass = computed(() => {
525
+ switch (this.look()) {
526
+ case 'default':
527
+ return 'rounded-full bg-primary absolute left-0';
528
+ case 'material':
529
+ return 'rounded-l-full rounded-r-[2px] bg-primary absolute left-0';
530
+ default:
531
+ return 'rounded-full absolute left-0';
532
+ }
533
+ });
534
+
535
+ protected readonly thumbClass = computed(() => {
536
+ switch (this.look()) {
537
+ case 'default':
538
+ return 'rounded-full border-2 border-primary bg-background absolute';
539
+ case 'material':
540
+ return 'rounded-[2px] bg-primary absolute';
541
+ default:
542
+ return 'rounded-full bg-white absolute';
543
+ }
544
+ });
545
+
546
+ /**
547
+ * Each part's frame: the track and its filled part, Material's stop
548
+ * indicator, the lens and the thumb. The thumb and lens sit at the
549
+ * slider's left edge, centered on it, and a transform moves them to the
550
+ * value, so a change of size (picking the thumb up) keeps them centered
551
+ * while a drag moves them.
552
+ */
553
+ protected readonly frames = computed(() => {
554
+ const look = this.look();
555
+ const geometry = this.#geometry();
556
+ const width = this.#width();
557
+ const center = this.#center();
558
+ const holding = this.#holding();
559
+ const animate = this.#animate();
560
+ const glide = animate && this.#glide();
561
+ // Glides, and the morph, together on the thumb: an element has one
562
+ // `transition`.
563
+ const thumbTransition =
564
+ [animate && MORPH, glide && THUMB_GLIDE].filter(Boolean).join(', ') ||
565
+ 'none';
566
+ const fillTransition = glide ? FILL_GLIDE : 'none';
567
+ const trackTop = (geometry.height - geometry.track) / 2;
568
+ const track = { top: px(trackTop), height: px(geometry.track) };
569
+ const transform = `translateX(${center}px)`;
570
+
571
+ const shape = (shapeWidth: number, shapeHeight: number) => ({
572
+ left: px(-shapeWidth / 2),
573
+ top: px((geometry.height - shapeHeight) / 2),
574
+ width: px(shapeWidth),
575
+ height: px(shapeHeight),
576
+ transform,
577
+ });
578
+ const atRest = shape(geometry.thumbWidth, geometry.thumbHeight);
579
+ const held = shape(geometry.pressedWidth, geometry.pressedHeight);
580
+
581
+ if (look === 'material') {
582
+ // The handle stands in a gap in the track: the filled part ends
583
+ // before it, and the rest starts after it.
584
+ const handle = geometry.thumbWidth / 2 + MATERIAL_GAP;
585
+ const inactiveLeft = Math.min(width, center + handle);
586
+ const inactiveWidth = Math.max(0, width - inactiveLeft);
587
+ return {
588
+ track: {
589
+ ...track,
590
+ left: px(inactiveLeft),
591
+ width: px(inactiveWidth),
592
+ transition: glide ? TRACK_GLIDE : 'none',
593
+ },
594
+ fill: {
595
+ ...track,
596
+ width: px(Math.max(0, center - handle)),
597
+ transition: fillTransition,
598
+ },
599
+ // The stop goes once the handle nears it, as Material's does.
600
+ stop: {
601
+ top: px((geometry.height - MATERIAL_STOP) / 2),
602
+ right: px(MATERIAL_STOP),
603
+ width: px(MATERIAL_STOP),
604
+ height: px(MATERIAL_STOP),
605
+ opacity: inactiveWidth > MATERIAL_STOP * 4 ? '1' : '0',
606
+ },
607
+ lens: null,
608
+ thumb: { ...(holding ? held : atRest), transition: thumbTransition },
609
+ };
610
+ }
611
+
612
+ const colors =
613
+ look === 'glass' || look === 'frosted'
614
+ ? IOS_COLORS[look][this.#scheme()]
615
+ : null;
616
+ const lensShows = look === 'glass' && holding && this.#lensIsGlass();
617
+ // The thumb grows with the lens and fades into it. Without the lens,
618
+ // it grows itself (see #lensIsGlass).
619
+ const thumb =
620
+ look === 'glass' && holding
621
+ ? { ...held, opacity: lensShows ? '0' : '1' }
622
+ : { ...atRest, opacity: '1' };
623
+ return {
624
+ track: { ...track, 'background-color': colors?.track ?? null },
625
+ fill: {
626
+ ...track,
627
+ width: px(center),
628
+ 'background-color': colors?.fill ?? null,
629
+ transition: fillTransition,
630
+ },
631
+ stop: null,
632
+ // Hidden under the thumb at rest: a little smaller, so its rim
633
+ // doesn't show around it.
634
+ lens: {
635
+ ...(lensShows
636
+ ? held
637
+ : shape(
638
+ geometry.thumbWidth - LENS_INSET * 2,
639
+ geometry.thumbHeight - LENS_INSET * 2,
640
+ )),
641
+ transition: thumbTransition,
642
+ },
643
+ thumb: {
644
+ ...thumb,
645
+ 'box-shadow':
646
+ look === 'glass' || look === 'frosted'
647
+ ? IOS_THUMB_SHADOW[look]
648
+ : null,
649
+ transition: thumbTransition,
650
+ },
651
+ };
652
+ });
653
+
654
+ protected onLayout(event: unknown): void {
655
+ const { detail, params } = event as LayoutEvent;
656
+ const width = (detail ?? params)?.width;
657
+ if (typeof width !== 'number') return;
658
+ this.#glide.set(false);
659
+ this.#width.set(width);
660
+ }
661
+
662
+ protected onPressStart(event: PointerEvent): void {
663
+ // A new press starts a new tap, so the last drag's tap can't eat it.
664
+ this.#suppressTap = false;
665
+ this.#tapX = null;
666
+ this.#glide.set(false);
667
+ const point = pointOf(event);
668
+ if (this.disabled() || !point) return;
669
+ const press: Press = {
670
+ startX: point.x,
671
+ startY: point.y,
672
+ lastX: point.x,
673
+ dragging: false,
674
+ scrolling: false,
675
+ grab: null,
676
+ onThumb: false,
677
+ };
678
+ this.#press = press;
679
+ if (this.#web) {
680
+ this.#origin = null;
681
+ void this.#measureOrigin(press);
682
+ }
683
+ this.#locatePress(press);
684
+ }
685
+
686
+ protected onPressMove(event: PointerEvent): void {
687
+ const press = this.#press;
688
+ const point = pointOf(event);
689
+ if (!press || !point || press.scrolling) return;
690
+ press.lastX = point.x;
691
+ if (!press.dragging) {
692
+ const dx = Math.abs(point.x - press.startX);
693
+ const dy = Math.abs(point.y - press.startY);
694
+ if (dx >= DRAG_SLOP && dx > dy) {
695
+ press.dragging = true;
696
+ this.#holding.set(true);
697
+ } else if (dy >= DRAG_SLOP) {
698
+ // A scroll, which the scroll view may or may not take over: Android
699
+ // doesn't always cancel the touch. Either way, the slider sits it
700
+ // out.
701
+ press.scrolling = true;
702
+ this.#holding.set(false);
703
+ return;
704
+ } else {
705
+ return;
706
+ }
707
+ }
708
+ this.#follow(press);
709
+ }
710
+
711
+ /**
712
+ * The mouse's twin of onPressMove, on the web. A move with no button held
713
+ * means the button came up outside the slider, where it couldn't see it.
714
+ */
715
+ protected onMouseMove(event: PointerEvent & { buttons?: number }): void {
716
+ if (event.buttons === 0) {
717
+ this.onPressEnd();
718
+ return;
719
+ }
720
+ this.onPressMove(event);
721
+ }
722
+
723
+ /**
724
+ * The finger lifted or the mouse button came up. A press beside the thumb
725
+ * that didn't move is a tap, which moves the thumb there when Lynx sends
726
+ * it; one on the thumb leaves it where it is.
727
+ */
728
+ protected onPressEnd(): void {
729
+ const press = this.#release();
730
+ if (
731
+ press &&
732
+ !press.dragging &&
733
+ !press.scrolling &&
734
+ press.grab !== null &&
735
+ !press.onThumb
736
+ ) {
737
+ this.#tapX = this.#local(press.startX);
738
+ }
739
+ }
740
+
741
+ /**
742
+ * A scroll view took the touch over. A drag keeps the value it reached,
743
+ * as iOS's slider does, and no tap follows.
744
+ */
745
+ protected onPressCancel(): void {
746
+ this.#release();
747
+ }
748
+
749
+ /**
750
+ * A tap on the track moves the thumb there. A screen reader's double tap
751
+ * has no press before it, so it does nothing: its actions move the value.
752
+ */
753
+ protected onTap(): void {
754
+ const x = this.#tapX;
755
+ this.#tapX = null;
756
+ if (this.#suppressTap) {
757
+ this.#suppressTap = false;
758
+ return;
759
+ }
760
+ if (this.disabled() || x === null) return;
761
+ this.#glide.set(true);
762
+ this.#set(this.#valueAt(x));
763
+ this.#finish();
764
+ }
765
+
766
+ protected onAccessibilityAction(event: unknown): void {
767
+ const name = (event as { detail?: { name?: string } }).detail?.name;
768
+ if (this.disabled() || (name !== INCREASE && name !== DECREASE)) return;
769
+ const step = this.#accessibilityStep();
770
+ this.#glide.set(true);
771
+ this.#set(this.#current() + (name === INCREASE ? step : -step));
772
+ this.#finish();
773
+ }
774
+
775
+ /**
776
+ * A tenth of the range, as iOS's own slider moves for VoiceOver, rounded
777
+ * to whole steps, and never less than one.
778
+ */
779
+ readonly #accessibilityStep = computed(() => {
780
+ const { min, max } = this.#range();
781
+ const step = this.#stepSize();
782
+ return step * Math.max(1, Math.round((max - min) / 10 / step));
783
+ });
784
+
785
+ readonly #stepSize = computed(() => {
786
+ const step = this.step();
787
+ return step > 0 ? step : 1;
788
+ });
789
+
790
+ /**
791
+ * Ends the press, finishing a drag, and returns it.
792
+ */
793
+ #release(): Press | null {
794
+ const press = this.#press;
795
+ this.#press = null;
796
+ this.#holding.set(false);
797
+ if (press?.dragging) {
798
+ // Lynx may still send a tap for a short drag, and the browser always
799
+ // clicks after a mouse drag: that tap isn't one.
800
+ this.#suppressTap = true;
801
+ this.#finish();
802
+ }
803
+ return press;
804
+ }
805
+
806
+ /**
807
+ * Once the press's place in the slider is known (at once on iOS and
808
+ * Android, after the measurement on the web), a press on the thumb picks
809
+ * it up, keeping where the finger is on it, and one beside it will pull
810
+ * it under the finger if it drags.
811
+ */
812
+ #locatePress(press: Press): void {
813
+ const x = this.#local(press.startX);
814
+ if (x === null) return;
815
+ const offset = x - this.#center();
816
+ const reach = this.#geometry().thumbWidth / 2 + GRAB_SLOP;
817
+ press.onThumb = Math.abs(offset) <= reach;
818
+ press.grab = press.onThumb ? offset : 0;
819
+ if (press.onThumb) this.#holding.set(true);
820
+ if (press.dragging) this.#follow(press);
821
+ }
822
+
823
+ /**
824
+ * Moves the value to where the dragging finger has the thumb.
825
+ */
826
+ #follow(press: Press): void {
827
+ const x = this.#local(press.lastX);
828
+ if (x === null || press.grab === null) return;
829
+ this.#set(this.#valueAt(x - press.grab));
830
+ }
831
+
832
+ /**
833
+ * Measures where the slider is in the `<lynx-view>`, for the web. In the
834
+ * docs' scaled previews the measurement is in screen pixels while events
835
+ * and layout are in the app's, so the layout width gives the scale.
836
+ */
837
+ async #measureOrigin(press: Press): Promise<void> {
838
+ let origin = 0;
839
+ try {
840
+ const rect = await new MainThreadElement(
841
+ this.rootRef().nativeElement.element as ConstructorParameters<
842
+ typeof MainThreadElement
843
+ >[0],
844
+ ).getBoundingClientRect();
845
+ const width = this.#width();
846
+ const scale = width > 0 && rect.width > 0 ? rect.width / width : 1;
847
+ origin = rect.left / scale;
848
+ } catch {
849
+ // Measured from the view's edge, the drag still moves the thumb, if
850
+ // not under the finger.
851
+ }
852
+ if (this.#press !== press) return;
853
+ this.#origin = origin;
854
+ this.#locatePress(press);
855
+ }
856
+
857
+ /**
858
+ * A position from an event, in the slider, or null until it's known.
859
+ */
860
+ #local(x: number): number | null {
861
+ if (!this.#web) return x;
862
+ return this.#origin === null ? null : x - this.#origin;
863
+ }
864
+
865
+ /**
866
+ * The value at a position of the thumb's center in the slider.
867
+ */
868
+ #valueAt(center: number): number {
869
+ const { min, max } = this.#range();
870
+ const { thumbWidth } = this.#geometry();
871
+ const travel = this.#width() - thumbWidth;
872
+ const fraction =
873
+ travel > 0
874
+ ? Math.min(1, Math.max(0, (center - thumbWidth / 2) / travel))
875
+ : 0;
876
+ return min + fraction * (max - min);
877
+ }
878
+
879
+ /**
880
+ * The nearest step to a value, within the range.
881
+ */
882
+ #snap(value: number): number {
883
+ const { min, max } = this.#range();
884
+ const step = this.#stepSize();
885
+ const steps = Math.round(
886
+ (Math.min(max, Math.max(min, value)) - min) / step,
887
+ );
888
+ const snapped = Math.min(max, min + steps * step);
889
+ return Number(snapped.toFixed(decimalsOf(step)));
890
+ }
891
+
892
+ #set(value: number): void {
893
+ const next = this.#snap(value);
894
+ if (next !== this.value()) this.value.set(next);
895
+ }
896
+
897
+ #finish(): void {
898
+ this.changed.emit(this.#current());
899
+ this.touch.emit();
900
+ }
901
+ }