@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,695 @@
1
+ import { Component, type Provider, signal } from '@angular/core';
2
+ import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms';
3
+ import { FormField, form } from '@angular/forms/signals';
4
+ import {
5
+ fireEvent,
6
+ render,
7
+ waitForUpdate,
8
+ } from '@blotch/angular-lynx-testing-library';
9
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
10
+ import { afterEach, describe, expect, it, vi } from 'vitest';
11
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
12
+ import { provideGlass } from '../glass/glass-design';
13
+ import { provideIconTheme } from '../icon/icon-theme';
14
+ import { UiSlider, type SliderVariant } from './slider';
15
+
16
+ @Component({
17
+ imports: [LYNX_ELEMENTS, UiSlider],
18
+ template: `
19
+ <view>
20
+ <ui-slider
21
+ [(value)]="value"
22
+ [min]="min()"
23
+ [max]="max()"
24
+ [step]="step()"
25
+ [disabled]="disabled()"
26
+ [variant]="variant()"
27
+ [valueText]="valueText()"
28
+ accessibility-label="Volume"
29
+ class="mt-2"
30
+ (changed)="changes.push($event)"
31
+ (touch)="touches = touches + 1"
32
+ />
33
+ </view>
34
+ `,
35
+ })
36
+ class SliderHost {
37
+ readonly value = signal(50);
38
+ readonly min = signal<number | undefined>(0);
39
+ readonly max = signal<number | undefined>(100);
40
+ readonly step = signal(1);
41
+ readonly disabled = signal(false);
42
+ readonly variant = signal<SliderVariant>('default');
43
+ readonly valueText = signal<((value: number) => string) | undefined>(
44
+ undefined,
45
+ );
46
+ readonly changes: number[] = [];
47
+ touches = 0;
48
+ }
49
+
50
+ /** Accessibility settings a test can change, as the host app would. */
51
+ const settings = {
52
+ reduceMotion: signal(false),
53
+ reduceTransparency: signal(false),
54
+ increaseContrast: signal(false),
55
+ };
56
+
57
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
58
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
59
+ const ANDROID = { platform: 'Android', osVersion: '15' };
60
+ const WEB = { platform: 'web', osVersion: '' };
61
+
62
+ /** The width the slider is laid out at. */
63
+ const WIDTH = 300;
64
+
65
+ /**
66
+ * The Lynx function that runs an element's UI methods, such as measuring it
67
+ * (`boundingClientRect`). The testing library defines it on the global
68
+ * object.
69
+ */
70
+ type InvokeUIMethod = {
71
+ __InvokeUIMethod: (
72
+ element: Element,
73
+ method: string,
74
+ params: Record<string, unknown>,
75
+ callback: (result: { code: number; data?: unknown }) => void,
76
+ ) => void;
77
+ };
78
+
79
+ const style = (element: Element, name: string) =>
80
+ (element as HTMLElement).style.getPropertyValue(name);
81
+
82
+ /**
83
+ * Renders the slider on a platform (none for no SystemInfo) and lays it out
84
+ * at WIDTH, as Lynx reports its size after the first frame.
85
+ */
86
+ const renderSlider = async (
87
+ platform?: { platform: string; osVersion: string },
88
+ providers: Provider[] = [],
89
+ ) => {
90
+ vi.stubGlobal('SystemInfo', platform);
91
+ const result = await render(SliderHost, {
92
+ providers: [
93
+ { provide: LynxAccessibilitySettings, useValue: settings },
94
+ ...providers,
95
+ ],
96
+ });
97
+ const host = result.componentRef.instance as SliderHost;
98
+ const slider = result.getByRole('slider', { name: 'Volume' });
99
+ const [track, fill] = Array.from(slider.children);
100
+ const thumb = slider.lastElementChild!;
101
+ fireEvent.layoutchange(slider, { detail: { width: WIDTH, height: 20 } });
102
+ await waitForUpdate();
103
+ const update = async (change: () => void) => {
104
+ change();
105
+ await waitForUpdate();
106
+ };
107
+ /**
108
+ * Touches the slider at x (from its left edge, as Lynx gives it).
109
+ */
110
+ const touch = async (kind: 'touchstart' | 'touchmove', x: number, y = 10) => {
111
+ fireEvent[kind](slider, { touches: [{ x, y }] });
112
+ await waitForUpdate();
113
+ };
114
+ const lift = async (kind: 'touchend' | 'touchcancel' = 'touchend') => {
115
+ fireEvent[kind](slider);
116
+ await waitForUpdate();
117
+ };
118
+ const tap = async () => {
119
+ fireEvent.tap(slider);
120
+ await waitForUpdate();
121
+ };
122
+ return {
123
+ ...result,
124
+ host,
125
+ slider,
126
+ track: track!,
127
+ fill: fill!,
128
+ thumb,
129
+ update,
130
+ touch,
131
+ lift,
132
+ tap,
133
+ };
134
+ };
135
+
136
+ describe('UiSlider', () => {
137
+ afterEach(() => {
138
+ vi.restoreAllMocks();
139
+ vi.unstubAllGlobals();
140
+ settings.reduceMotion.set(false);
141
+ settings.reduceTransparency.set(false);
142
+ });
143
+
144
+ it('shows its value on a track in the theme colors', async () => {
145
+ const { slider, track, fill, thumb } = await renderSlider();
146
+
147
+ expect(slider.classList).toContain('mt-2');
148
+ expect(style(slider, 'height')).toBe('20px');
149
+ expect(track.classList).toContain('bg-secondary');
150
+ expect(fill.classList).toContain('bg-primary');
151
+ expect(thumb.classList).toContain('border-primary');
152
+ // The thumb's 20px travel inset: 10 + 0.5 × (300 − 20).
153
+ expect(style(thumb, 'transform')).toBe('translateX(150px)');
154
+ expect(style(fill, 'width')).toBe('150px');
155
+ });
156
+
157
+ it('reads as an adjustable element with its value and actions', async () => {
158
+ const { slider } = await renderSlider();
159
+
160
+ expect(slider.getAttribute('accessibility-traits')).toBe('adjustable');
161
+ expect(slider.getAttribute('accessibility-role-description')).toBe(
162
+ 'slider',
163
+ );
164
+ expect(slider.getAttribute('accessibility-value')).toBe('50%');
165
+ expect(slider.getAttribute('accessibility-actions')).toBe(
166
+ JSON.stringify(['Increase', 'Decrease']),
167
+ );
168
+ // Sideways swipes are the slider's, not the scroll view's.
169
+ expect(slider.getAttribute('consume-slide-event')).toBe(
170
+ JSON.stringify([
171
+ [-45, 45],
172
+ [135, 180],
173
+ [-180, -135],
174
+ ]),
175
+ );
176
+ });
177
+
178
+ it('follows a drag of the thumb, keeping where the finger holds it', async () => {
179
+ const { host, thumb, touch, lift, tap } = await renderSlider();
180
+
181
+ // Picked up 4px right of its center, at 150.
182
+ await touch('touchstart', 154);
183
+ // Not yet a drag: the finger has to move past the slop.
184
+ await touch('touchmove', 157);
185
+ expect(host.value()).toBe(50);
186
+
187
+ await touch('touchmove', 200);
188
+ await touch('touchmove', 182);
189
+ // The center goes to 178: (178 − 10) / 280 = 60%.
190
+ expect(host.value()).toBe(60);
191
+ expect(style(thumb, 'transform')).toBe('translateX(178px)');
192
+ expect(style(thumb, 'transition')).not.toContain('transform');
193
+ expect(host.changes).toEqual([]);
194
+
195
+ await lift();
196
+ expect(host.changes).toEqual([60]);
197
+ expect(host.touches).toBe(1);
198
+
199
+ // Lynx may send a tap after a short drag: it doesn't move the thumb.
200
+ await tap();
201
+ expect(host.changes).toEqual([60]);
202
+ });
203
+
204
+ it('pulls the thumb under a finger that drags beside it', async () => {
205
+ const { host, touch, lift } = await renderSlider();
206
+
207
+ await touch('touchstart', 220);
208
+ await touch('touchmove', 234);
209
+ expect(host.value()).toBe(80);
210
+ await lift();
211
+ expect(host.changes).toEqual([80]);
212
+ });
213
+
214
+ it('moves to a tap on the track, but not to one on the thumb', async () => {
215
+ const { host, slider, thumb, touch, lift, tap } = await renderSlider();
216
+
217
+ await touch('touchstart', 150);
218
+ await lift();
219
+ await tap();
220
+ expect(host.changes).toEqual([]);
221
+
222
+ await touch('touchstart', 38);
223
+ await lift();
224
+ await tap();
225
+ expect(host.value()).toBe(10);
226
+ // It glides there, where a drag or a new layout moves it at once.
227
+ expect(style(thumb, 'transition')).toContain('transform');
228
+ fireEvent.layoutchange(slider, { detail: { width: WIDTH } });
229
+ await waitForUpdate();
230
+ expect(style(thumb, 'transition')).not.toContain('transform');
231
+ expect(host.changes).toEqual([10]);
232
+ expect(host.touches).toBe(1);
233
+ });
234
+
235
+ it('leaves a press that moves up or down to the scroll view', async () => {
236
+ const { host, touch, lift, tap } = await renderSlider();
237
+
238
+ await touch('touchstart', 220, 10);
239
+ await touch('touchmove', 222, 30);
240
+ await touch('touchmove', 260, 30);
241
+ expect(host.value()).toBe(50);
242
+ await lift();
243
+ await tap();
244
+ expect(host.value()).toBe(50);
245
+ expect(host.changes).toEqual([]);
246
+ });
247
+
248
+ it('keeps the value a drag reached when a scroll takes the touch', async () => {
249
+ const { host, touch, lift, tap } = await renderSlider();
250
+
251
+ await touch('touchstart', 150);
252
+ await touch('touchmove', 94);
253
+ expect(host.value()).toBe(30);
254
+ await lift('touchcancel');
255
+ expect(host.changes).toEqual([30]);
256
+
257
+ // A canceled press is no tap, even beside the thumb.
258
+ await touch('touchstart', 250);
259
+ await lift('touchcancel');
260
+ await tap();
261
+ expect(host.value()).toBe(30);
262
+ });
263
+
264
+ it('ignores events without a position, and moves with no press', async () => {
265
+ const { host, slider, touch } = await renderSlider();
266
+
267
+ fireEvent.touchstart(slider, { touches: [] });
268
+ await touch('touchmove', 250);
269
+ fireEvent.touchend(slider);
270
+ fireEvent.mousemove(slider, { buttons: 0 });
271
+ await waitForUpdate();
272
+ expect(host.value()).toBe(50);
273
+
274
+ await touch('touchstart', 150);
275
+ fireEvent.touchmove(slider, { touches: [{}] });
276
+ await waitForUpdate();
277
+ expect(host.value()).toBe(50);
278
+ });
279
+
280
+ it('steps by a tenth of its range for a screen reader', async () => {
281
+ const { host, slider, tap } = await renderSlider();
282
+
283
+ fireLynxEvent(slider, 'accessibilityaction', {
284
+ detail: { name: 'Increase' },
285
+ });
286
+ await waitForUpdate();
287
+ expect(host.value()).toBe(60);
288
+ expect(slider.getAttribute('accessibility-value')).toBe('60%');
289
+
290
+ fireLynxEvent(slider, 'accessibilityaction', {
291
+ detail: { name: 'Decrease' },
292
+ });
293
+ fireLynxEvent(slider, 'accessibilityaction', { detail: {} });
294
+ await waitForUpdate();
295
+ expect(host.value()).toBe(50);
296
+ expect(host.changes).toEqual([60, 50]);
297
+
298
+ // At the end of the range, it stays there.
299
+ host.value.set(0);
300
+ await waitForUpdate();
301
+ fireLynxEvent(slider, 'accessibilityaction', {
302
+ detail: { name: 'Decrease' },
303
+ });
304
+ await waitForUpdate();
305
+ expect(host.value()).toBe(0);
306
+ host.value.set(50);
307
+ await waitForUpdate();
308
+
309
+ // A screen reader's double tap has no press before it.
310
+ await tap();
311
+ expect(host.value()).toBe(50);
312
+ });
313
+
314
+ it('keeps to its range and steps', async () => {
315
+ const { host, slider, thumb, update } = await renderSlider();
316
+
317
+ await update(() => host.value.set(140));
318
+ expect(style(thumb, 'transform')).toBe('translateX(290px)');
319
+ expect(slider.getAttribute('accessibility-value')).toBe('100%');
320
+
321
+ await update(() => {
322
+ host.min.set(0);
323
+ host.max.set(1);
324
+ host.step.set(0.05);
325
+ host.value.set(0.33);
326
+ });
327
+ expect(slider.getAttribute('accessibility-value')).toBe('35%');
328
+ fireLynxEvent(slider, 'accessibilityaction', {
329
+ detail: { name: 'Increase' },
330
+ });
331
+ await waitForUpdate();
332
+ expect(host.value()).toBe(0.45);
333
+
334
+ // A range of four steps moves one at a time.
335
+ await update(() => {
336
+ host.max.set(4);
337
+ host.step.set(1);
338
+ host.value.set(2);
339
+ });
340
+ fireLynxEvent(slider, 'accessibilityaction', {
341
+ detail: { name: 'Increase' },
342
+ });
343
+ await waitForUpdate();
344
+ expect(host.value()).toBe(3);
345
+
346
+ // No range, no step, or no min and max: all still draw.
347
+ await update(() => {
348
+ host.min.set(undefined);
349
+ host.max.set(undefined);
350
+ host.step.set(0);
351
+ host.value.set(42);
352
+ });
353
+ expect(slider.getAttribute('accessibility-value')).toBe('42%');
354
+ await update(() => {
355
+ host.min.set(10);
356
+ host.max.set(5);
357
+ });
358
+ expect(style(thumb, 'transform')).toBe('translateX(10px)');
359
+ expect(slider.getAttribute('accessibility-value')).toBe('0%');
360
+ });
361
+
362
+ it('says a value the way it is given', async () => {
363
+ const { host, slider, update } = await renderSlider();
364
+
365
+ await update(() => host.valueText.set((value) => `${value} dB`));
366
+ expect(slider.getAttribute('accessibility-value')).toBe('50 dB');
367
+ });
368
+
369
+ it('dims and ignores the user while disabled', async () => {
370
+ const { host, slider, touch, lift, tap, update } = await renderSlider();
371
+
372
+ await update(() => host.disabled.set(true));
373
+ expect(style(slider, 'opacity')).toBe('0.5');
374
+ expect(slider.getAttribute('accessibility-traits')).toBe(
375
+ 'adjustable,disabled',
376
+ );
377
+
378
+ await touch('touchstart', 250);
379
+ await touch('touchmove', 280);
380
+ await lift();
381
+ await tap();
382
+ fireLynxEvent(slider, 'accessibilityaction', {
383
+ detail: { name: 'Increase' },
384
+ });
385
+ await waitForUpdate();
386
+ expect(host.value()).toBe(50);
387
+ expect(host.changes).toEqual([]);
388
+ });
389
+
390
+ it('reads a layout from older Android, and waits for one with a width', async () => {
391
+ const { slider, thumb } = await renderSlider();
392
+
393
+ fireEvent.layoutchange(slider, { params: { width: 200 } });
394
+ await waitForUpdate();
395
+ expect(style(thumb, 'transform')).toBe('translateX(100px)');
396
+ fireEvent.layoutchange(slider, { detail: {} });
397
+ await waitForUpdate();
398
+ expect(style(thumb, 'transform')).toBe('translateX(100px)');
399
+ });
400
+
401
+ it('goes to its start when it is narrower than its thumb', async () => {
402
+ const { host, slider, thumb, touch, lift, tap } = await renderSlider();
403
+
404
+ fireEvent.layoutchange(slider, { detail: { width: 12 } });
405
+ await waitForUpdate();
406
+ expect(style(thumb, 'transform')).toBe('translateX(10px)');
407
+ await touch('touchstart', 40);
408
+ await lift();
409
+ await tap();
410
+ expect(host.value()).toBe(0);
411
+ });
412
+
413
+ it('drags with the mouse on the web, measuring where the slider is', async () => {
414
+ let left = 16;
415
+ const invoke = vi
416
+ .spyOn(globalThis as unknown as InvokeUIMethod, '__InvokeUIMethod')
417
+ .mockImplementation((_element, method, _params, callback) => {
418
+ callback({
419
+ code: 0,
420
+ data: method === 'boundingClientRect' ? { left, width: WIDTH } : {},
421
+ });
422
+ });
423
+ const { host, slider, tap } = await renderSlider(WEB);
424
+ const mouse = async (
425
+ kind: 'mousedown' | 'mousemove' | 'mouseup',
426
+ x: number,
427
+ buttons = 1,
428
+ ) => {
429
+ fireEvent[kind](slider, { x, y: 10, buttons });
430
+ await waitForUpdate();
431
+ };
432
+
433
+ // Lynx for Web gives x from the <lynx-view>, where the slider starts 16px
434
+ // in: the thumb's center, 150 in the slider, is at 166.
435
+ await mouse('mousedown', 166);
436
+ expect(invoke).toHaveBeenCalledWith(
437
+ slider,
438
+ 'boundingClientRect',
439
+ {},
440
+ expect.any(Function),
441
+ );
442
+ await mouse('mousemove', 194);
443
+ expect(host.value()).toBe(60);
444
+ // A move with no button held: the button came up outside the slider.
445
+ await mouse('mousemove', 250, 0);
446
+ expect(host.changes).toEqual([60]);
447
+ await mouse('mousemove', 250);
448
+ expect(host.value()).toBe(60);
449
+
450
+ // The browser's click after a mouse press beside the thumb moves it.
451
+ await mouse('mousedown', 54);
452
+ await mouse('mouseup', 54);
453
+ await tap();
454
+ expect(host.value()).toBe(10);
455
+
456
+ // In a scaled preview, the measurement is in screen pixels.
457
+ left = 8;
458
+ invoke.mockImplementation((_element, _method, _params, callback) => {
459
+ callback({ code: 0, data: { left, width: WIDTH / 2 } });
460
+ });
461
+ await mouse('mousedown', 16 + 150);
462
+ await mouse('mouseup', 16 + 150);
463
+ await tap();
464
+ expect(host.value()).toBe(50);
465
+ });
466
+
467
+ it('waits for the measurement on the web before it follows a drag', async () => {
468
+ let answer: (() => void) | undefined;
469
+ vi.spyOn(
470
+ globalThis as unknown as InvokeUIMethod,
471
+ '__InvokeUIMethod',
472
+ ).mockImplementation((_element, _method, _params, callback) => {
473
+ answer = () => callback({ code: 0, data: { left: 0, width: 0 } });
474
+ });
475
+ const { host, touch, lift } = await renderSlider(WEB);
476
+
477
+ await touch('touchstart', 150);
478
+ await touch('touchmove', 178);
479
+ expect(host.value()).toBe(50);
480
+ answer!();
481
+ await waitForUpdate();
482
+ expect(host.value()).toBe(60);
483
+ await lift();
484
+
485
+ // A measurement that comes after its press has ended is dropped.
486
+ await touch('touchstart', 220);
487
+ await lift();
488
+ answer!();
489
+ await waitForUpdate();
490
+ expect(host.value()).toBe(60);
491
+ });
492
+
493
+ describe('glass', () => {
494
+ const renderGlass = async (
495
+ platform: { platform: string; osVersion: string },
496
+ providers: Provider[] = [],
497
+ ) => {
498
+ const rendered = await renderSlider(platform, providers);
499
+ await rendered.update(() => rendered.host.variant.set('glass'));
500
+ const lens = rendered.slider.querySelector('blur-view');
501
+ return { ...rendered, lens };
502
+ };
503
+
504
+ it("draws iOS 26's slider, whose thumb turns to glass while it's held", async () => {
505
+ const { slider, track, fill, thumb, lens, touch, lift } =
506
+ await renderGlass(IOS_26);
507
+
508
+ expect(style(slider, 'height')).toBe('34px');
509
+ expect(style(track, 'height')).toBe('6px');
510
+ expect(style(fill, 'background-color')).toBe('rgb(0, 136, 255)');
511
+ expect(style(thumb, 'width')).toBe('37px');
512
+ expect(style(thumb, 'box-shadow')).not.toBe('');
513
+ // The lens hides under the thumb, a little smaller.
514
+ expect(lens?.getAttribute('blur-effect')).toBe('glass');
515
+ expect(lens?.getAttribute('glass-style')).toBe('clear');
516
+ expect(style(lens!, 'width')).toBe('33px');
517
+
518
+ await touch('touchstart', 150);
519
+ expect(style(lens!, 'width')).toBe('59px');
520
+ expect(style(lens!, 'height')).toBe('38px');
521
+ expect(style(lens!, 'left')).toBe('-29.5px');
522
+ expect(style(thumb, 'opacity')).toBe('0');
523
+
524
+ await lift();
525
+ expect(style(thumb, 'width')).toBe('37px');
526
+ expect(style(thumb, 'opacity')).toBe('1');
527
+ });
528
+
529
+ it("takes iOS's dark colors in a dark app", async () => {
530
+ const { track, fill } = await renderGlass(IOS_26, [
531
+ provideIconTheme({ colorScheme: 'dark' }),
532
+ ]);
533
+
534
+ expect(style(fill, 'background-color')).toBe('rgb(0, 145, 255)');
535
+ expect(style(track, 'background-color')).toBe('rgba(255, 255, 255, 0.1)');
536
+ });
537
+
538
+ it('grows a solid thumb instead under Reduce Transparency on the web', async () => {
539
+ settings.reduceTransparency.set(true);
540
+ const { thumb, lens, touch } = await renderGlass(WEB);
541
+
542
+ await touch('touchstart', 150);
543
+ expect(style(thumb, 'width')).toBe('59px');
544
+ expect(style(thumb, 'opacity')).toBe('1');
545
+ expect(style(lens!, 'width')).toBe('33px');
546
+ });
547
+
548
+ it("draws iOS 15 to 25's slider before iOS 26", async () => {
549
+ const { slider, track, fill, thumb, lens, touch } =
550
+ await renderGlass(IOS_18);
551
+
552
+ expect(lens).toBeNull();
553
+ expect(style(slider, 'height')).toBe('31px');
554
+ expect(style(track, 'height')).toBe('4px');
555
+ expect(style(fill, 'background-color')).toBe('rgb(0, 122, 255)');
556
+ expect(style(thumb, 'width')).toBe('28px');
557
+ await touch('touchstart', 150);
558
+ expect(style(thumb, 'width')).toBe('28px');
559
+ });
560
+
561
+ it("draws Material 3 Expressive's slider on Android", async () => {
562
+ const { host, slider, track, fill, thumb, touch, lift, update } =
563
+ await renderGlass(ANDROID);
564
+
565
+ expect(slider.querySelector('blur-view')).toBeNull();
566
+ expect(style(slider, 'height')).toBe('44px');
567
+ expect(fill.classList).toContain('bg-primary');
568
+ expect(track.classList).toContain('bg-secondary');
569
+ // The handle at 150, with 2 + 6px clear each side of it.
570
+ expect(style(thumb, 'width')).toBe('4px');
571
+ expect(style(fill, 'width')).toBe('142px');
572
+ expect(style(track, 'left')).toBe('158px');
573
+ expect(style(track, 'width')).toBe('142px');
574
+ const stop = slider.children[2]!;
575
+ expect(style(stop, 'opacity')).toBe('1');
576
+
577
+ // Held, the handle narrows, and the track follows a drag at once.
578
+ await touch('touchstart', 150);
579
+ expect(style(thumb, 'width')).toBe('2px');
580
+ await touch('touchmove', 164);
581
+ expect(style(track, 'transition')).toBe('none');
582
+ await lift();
583
+ fireLynxEvent(slider, 'accessibilityaction', {
584
+ detail: { name: 'Decrease' },
585
+ });
586
+ await waitForUpdate();
587
+ expect(style(track, 'transition')).toContain('left');
588
+
589
+ // Near the end, the stop goes under the handle's gap.
590
+ await update(() => host.value.set(98));
591
+ expect(style(stop, 'opacity')).toBe('0');
592
+ await update(() => host.disabled.set(true));
593
+ expect(style(slider, 'opacity')).toBe('0.38');
594
+ });
595
+
596
+ it('draws glass on Android when the app opts in', async () => {
597
+ const { lens } = await renderGlass(ANDROID, [
598
+ provideGlass({ android: 'glass' }),
599
+ ]);
600
+
601
+ expect(lens).not.toBeNull();
602
+ });
603
+
604
+ it('moves without animating under Reduce Motion', async () => {
605
+ settings.reduceMotion.set(true);
606
+ const { thumb, fill, touch } = await renderGlass(IOS_26);
607
+
608
+ expect(style(thumb, 'transition')).toBe('none');
609
+ expect(style(fill, 'transition')).toBe('none');
610
+ await touch('touchstart', 150);
611
+ expect(style(thumb, 'transition')).toBe('none');
612
+ });
613
+ });
614
+
615
+ describe('forms', () => {
616
+ it('binds to a reactive form control', async () => {
617
+ @Component({
618
+ imports: [ReactiveFormsModule, UiSlider],
619
+ template: `<ui-slider
620
+ [formControl]="volume"
621
+ accessibility-label="Volume"
622
+ />`,
623
+ })
624
+ class ReactiveHost {
625
+ readonly volume = new FormControl(20, { nonNullable: true });
626
+ }
627
+ const { componentRef, getByRole } = await render(ReactiveHost);
628
+ const host = componentRef.instance as ReactiveHost;
629
+ const slider = getByRole('slider', { name: 'Volume' });
630
+
631
+ expect(slider.getAttribute('accessibility-value')).toBe('20%');
632
+ fireLynxEvent(slider, 'accessibilityaction', {
633
+ detail: { name: 'Increase' },
634
+ });
635
+ await waitForUpdate();
636
+ expect(host.volume.value).toBe(30);
637
+ expect(host.volume.touched).toBe(true);
638
+
639
+ host.volume.disable();
640
+ await waitForUpdate();
641
+ expect(slider.getAttribute('accessibility-traits')).toBe(
642
+ 'adjustable,disabled',
643
+ );
644
+ });
645
+
646
+ it('binds to ngModel', async () => {
647
+ @Component({
648
+ imports: [FormsModule, UiSlider],
649
+ template: `<ui-slider
650
+ [(ngModel)]="volume"
651
+ accessibility-label="Volume"
652
+ />`,
653
+ })
654
+ class ModelHost {
655
+ volume = 40;
656
+ }
657
+ const { componentRef, getByRole } = await render(ModelHost);
658
+ const host = componentRef.instance as ModelHost;
659
+
660
+ fireLynxEvent(
661
+ getByRole('slider', { name: 'Volume' }),
662
+ 'accessibilityaction',
663
+ {
664
+ detail: { name: 'Increase' },
665
+ },
666
+ );
667
+ await waitForUpdate();
668
+ expect(host.volume).toBe(50);
669
+ });
670
+
671
+ it('binds to a Signal Forms field', async () => {
672
+ @Component({
673
+ imports: [FormField, UiSlider],
674
+ template: `<ui-slider
675
+ [formField]="settings.volume"
676
+ accessibility-label="Volume"
677
+ />`,
678
+ })
679
+ class SignalHost {
680
+ readonly model = signal({ volume: 70 });
681
+ readonly settings = form(this.model);
682
+ }
683
+ const { componentRef, getByRole } = await render(SignalHost);
684
+ const host = componentRef.instance as SignalHost;
685
+ const slider = getByRole('slider', { name: 'Volume' });
686
+
687
+ expect(slider.getAttribute('accessibility-value')).toBe('70%');
688
+ fireLynxEvent(slider, 'accessibilityaction', {
689
+ detail: { name: 'Decrease' },
690
+ });
691
+ await waitForUpdate();
692
+ expect(host.model().volume).toBe(60);
693
+ });
694
+ });
695
+ });