@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
@@ -1,63 +1,549 @@
1
- import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
2
- import { describe, expect, it, vi } from 'vitest';
3
- import { setInputSignal } from '../../../test-utils/set-input-signal';
4
- import { UiSwitch } from './switch';
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 {
11
+ type MockInstance,
12
+ afterEach,
13
+ beforeEach,
14
+ describe,
15
+ expect,
16
+ it,
17
+ vi,
18
+ } from 'vitest';
19
+ import {
20
+ type LynxMainThread,
21
+ lynxMainThread,
22
+ } from '../../../test-utils/lynx-main-thread';
23
+ import { provideGlass } from '../glass/glass-design';
24
+ import { provideIconTheme } from '../icon/icon-theme';
25
+ import { UiSwitch, type SwitchVariant } from './switch';
26
+
27
+ @Component({
28
+ imports: [LYNX_ELEMENTS, UiSwitch],
29
+ template: `
30
+ <view>
31
+ <ui-switch
32
+ [(checked)]="checked"
33
+ [disabled]="disabled()"
34
+ [variant]="variant()"
35
+ accessibility-label="Wi-Fi"
36
+ class="ml-2"
37
+ (changed)="changes.push($event)"
38
+ />
39
+ </view>
40
+ `,
41
+ })
42
+ class SwitchHost {
43
+ readonly checked = signal(false);
44
+ readonly disabled = signal(false);
45
+ readonly variant = signal<SwitchVariant>('default');
46
+ readonly changes: boolean[] = [];
47
+ }
48
+
49
+ /** Accessibility settings a test can change, as the host app would. */
50
+ const settings = {
51
+ reduceMotion: signal(false),
52
+ reduceTransparency: signal(false),
53
+ increaseContrast: signal(false),
54
+ };
55
+
56
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
57
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
58
+ const ANDROID = { platform: 'Android', osVersion: '15' };
59
+ const WEB = { platform: 'web', osVersion: '' };
60
+
61
+ /** `__ElementAnimate`'s first argument for starting an animation. */
62
+ const START = 0;
63
+ /** `__ElementAnimate`'s first argument for canceling one. */
64
+ const CANCEL = 3;
65
+
66
+ const style = (element: Element, name: string) =>
67
+ (element as HTMLElement).style.getPropertyValue(name);
68
+
69
+ /**
70
+ * Renders the switch on a platform (none for no SystemInfo).
71
+ */
72
+ const renderSwitch = async (
73
+ platform?: { platform: string; osVersion: string },
74
+ providers: Provider[] = [],
75
+ ) => {
76
+ vi.stubGlobal('SystemInfo', platform);
77
+ const result = await render(SwitchHost, {
78
+ providers: [
79
+ { provide: LynxAccessibilitySettings, useValue: settings },
80
+ ...providers,
81
+ ],
82
+ });
83
+ const host = result.componentRef.instance as SwitchHost;
84
+ const track = () => result.getByRole('button', { name: 'Wi-Fi' });
85
+ /**
86
+ * What paints the track, under the thumb.
87
+ */
88
+ const fill = () => track().firstElementChild!;
89
+ const thumb = () => track().lastElementChild!;
90
+ const update = async (change: () => void) => {
91
+ change();
92
+ await waitForUpdate();
93
+ };
94
+ const touch = async (kind: 'touchstart' | 'touchmove', x: number, y = 10) => {
95
+ fireEvent[kind](track(), { touches: [{ x, y }] });
96
+ await waitForUpdate();
97
+ };
98
+ const lift = async (kind: 'touchend' | 'touchcancel' = 'touchend') => {
99
+ fireEvent[kind](track());
100
+ await waitForUpdate();
101
+ };
102
+ const tap = async () => {
103
+ fireEvent.tap(track());
104
+ await waitForUpdate();
105
+ };
106
+ return { ...result, host, track, fill, thumb, update, touch, lift, tap };
107
+ };
5
108
 
6
109
  describe('UiSwitch', () => {
7
- it('renders in the off position by default', async () => {
8
- const { container } = await render(UiSwitch);
9
- expect(container).toBeTruthy();
110
+ // Watched per test: the testing library resets the main thread after each.
111
+ let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
112
+ beforeEach(() => {
113
+ animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
114
+ });
115
+ afterEach(() => {
116
+ vi.restoreAllMocks();
117
+ vi.unstubAllGlobals();
118
+ settings.reduceMotion.set(false);
119
+ settings.reduceTransparency.set(false);
120
+ });
121
+
122
+ it('renders off, with the thumb at the left and the given class', async () => {
123
+ const { track, fill, thumb } = await renderSwitch();
124
+ expect(fill().classList).toContain('bg-input');
125
+ expect(track().classList).toContain('ml-2');
126
+ expect(style(track(), 'width')).toBe('44px');
127
+ expect(thumb().classList).toContain('bg-background');
128
+ // Centered 12px in: 2px from the edge for a 20px thumb.
129
+ expect(style(thumb(), 'transform')).toBe('translateX(12px)');
130
+ expect(style(thumb(), 'left')).toBe('-10px');
131
+ });
132
+
133
+ it('reads as a switch with its label and state', async () => {
134
+ const { host, track, update } = await renderSwitch();
135
+ expect(track().getAttribute('accessibility-value')).toBe('Off');
136
+ expect(track().getAttribute('accessibility-role-description')).toBe(
137
+ 'switch',
138
+ );
139
+ // Sideways swipes are the switch's, not the scroll view's.
140
+ expect(track().getAttribute('consume-slide-event')).toContain('[-45,45]');
141
+ await update(() => host.checked.set(true));
142
+ expect(track().getAttribute('accessibility-value')).toBe('On');
10
143
  });
11
144
 
12
- it('toggle() flips checked and emits `changed`', async () => {
13
- const { componentRef } = await render(UiSwitch);
14
- const inst = componentRef.instance as UiSwitch;
15
- const emitted: boolean[] = [];
16
- inst.changed.subscribe((v) => emitted.push(v));
145
+ it('turns on and off on tap, emitting `changed`', async () => {
146
+ const { host, fill, thumb, tap } = await renderSwitch();
147
+
148
+ await tap();
149
+ expect(host.checked()).toBe(true);
150
+ expect(fill().classList).toContain('bg-primary');
151
+ expect(style(thumb(), 'transform')).toBe('translateX(32px)');
17
152
 
18
- inst.toggle();
19
- expect(inst.checked()).toBe(true);
20
- inst.toggle();
21
- expect(inst.checked()).toBe(false);
22
- expect(emitted).toEqual([true, false]);
153
+ await tap();
154
+ expect(host.checked()).toBe(false);
155
+ expect(host.changes).toEqual([true, false]);
23
156
  });
24
157
 
25
- it('toggle() is a no-op when disabled', async () => {
26
- const { componentRef } = await render(UiSwitch);
27
- const inst = componentRef.instance as UiSwitch;
28
- setInputSignal(inst.disabled, true);
29
- const listener = vi.fn();
30
- inst.changed.subscribe(listener);
158
+ it('springs the thumb across the track, squishing it halfway', async () => {
159
+ const { thumb, tap } = await renderSwitch();
160
+
161
+ await tap();
162
+ expect(animate).toHaveBeenCalledWith(thumb(), [
163
+ START,
164
+ expect.any(String),
165
+ [
166
+ { transform: 'translateX(12px) scale(1)' },
167
+ { transform: 'translateX(22px) scale(1.1)' },
168
+ { transform: 'translateX(32px) scale(1)' },
169
+ ],
170
+ expect.objectContaining({ fill: 'none' }),
171
+ ]);
172
+
173
+ // Turning it back off cuts the first spring short and springs it back.
174
+ await tap();
175
+ expect(animate).toHaveBeenCalledWith(thumb(), [CANCEL, expect.any(String)]);
176
+ expect(animate).toHaveBeenLastCalledWith(thumb(), [
177
+ START,
178
+ expect.any(String),
179
+ [
180
+ { transform: 'translateX(32px) scale(1)' },
181
+ { transform: 'translateX(22px) scale(1.1)' },
182
+ { transform: 'translateX(12px) scale(1)' },
183
+ ],
184
+ expect.objectContaining({ fill: 'none' }),
185
+ ]);
186
+ });
31
187
 
32
- inst.toggle();
33
- expect(inst.checked()).toBe(false);
34
- expect(listener).not.toHaveBeenCalled();
188
+ it('follows its checked binding', async () => {
189
+ const { host, thumb, update } = await renderSwitch();
190
+ await update(() => host.checked.set(true));
191
+ expect(style(thumb(), 'transform')).toBe('translateX(32px)');
35
192
  });
36
193
 
37
- it('positions the thumb differently in ON vs OFF state', async () => {
38
- const { componentRef, container } = await render(UiSwitch);
39
- const inst = componentRef.instance as UiSwitch;
40
- let thumb = container.querySelectorAll('view')[1];
41
- expect(thumb?.getAttribute('style')).toContain('translateX(2px)');
194
+ it('toggles when its thumb is dragged across, as on iOS', async () => {
195
+ const { host, fill, thumb, touch, lift, tap } = await renderSwitch();
42
196
 
43
- inst.checked.set(true);
197
+ await touch('touchstart', 12);
198
+ // Not yet a drag: the finger has to move past the slop.
199
+ await touch('touchmove', 16);
200
+ expect(style(thumb(), 'transform')).toBe('translateX(12px)');
201
+
202
+ await touch('touchmove', 25);
203
+ expect(style(thumb(), 'transform')).toBe('translateX(25px)');
204
+ // Past the middle, the track shows it on before it's let go.
205
+ expect(fill().classList).toContain('bg-primary');
206
+ expect(host.checked()).toBe(false);
207
+
208
+ // Dragged past the end, the thumb stays on the track.
209
+ await touch('touchmove', 80);
210
+ expect(style(thumb(), 'transform')).toBe('translateX(32px)');
211
+
212
+ await lift();
213
+ expect(host.checked()).toBe(true);
214
+ expect(host.changes).toEqual([true]);
215
+ expect(animate).toHaveBeenLastCalledWith(thumb(), [
216
+ START,
217
+ expect.any(String),
218
+ [
219
+ { transform: 'translateX(32px) scale(1)' },
220
+ { transform: 'translateX(32px) scale(1.1)' },
221
+ { transform: 'translateX(32px) scale(1)' },
222
+ ],
223
+ expect.objectContaining({ fill: 'none' }),
224
+ ]);
225
+
226
+ // Lynx may send a tap after a short drag: it doesn't toggle it back.
227
+ await tap();
228
+ expect(host.checked()).toBe(true);
229
+ });
230
+
231
+ it('springs back when a drag is let go on the side it started', async () => {
232
+ const { host, touch, lift } = await renderSwitch();
233
+
234
+ await touch('touchstart', 12);
235
+ await touch('touchmove', 20);
236
+ await lift('touchcancel');
237
+ expect(host.checked()).toBe(false);
238
+ expect(host.changes).toEqual([]);
239
+ });
240
+
241
+ it('leaves a press that moves up or down to the scroll view', async () => {
242
+ const { host, touch, lift, tap } = await renderSwitch();
243
+
244
+ await touch('touchstart', 12, 10);
245
+ await touch('touchmove', 14, 30);
246
+ await touch('touchmove', 40, 30);
247
+ await lift('touchcancel');
248
+ expect(host.checked()).toBe(false);
249
+
250
+ // A tap after it is a tap again.
251
+ await touch('touchstart', 12);
252
+ await lift();
253
+ await tap();
254
+ expect(host.checked()).toBe(true);
255
+ });
256
+
257
+ it('ignores events without a position, and moves with no press', async () => {
258
+ const { host, track, touch } = await renderSwitch();
259
+
260
+ fireEvent.touchstart(track(), { touches: [] });
261
+ await touch('touchmove', 40);
262
+ fireEvent.touchend(track());
263
+ fireEvent.mousemove(track(), { buttons: 0 });
44
264
  await waitForUpdate();
45
- thumb = container.querySelectorAll('view')[1];
46
- expect(thumb?.getAttribute('style')).toContain('translateX(22px)');
47
- });
48
-
49
- it('applies opacity-50 when disabled and includes a user class', async () => {
50
- // Covers the disabled=true branch of trackClass() (line 62) and the
51
- // userClass() concatenation. Without a render + detectChanges cycle the
52
- // computed never runs with disabled=true and the branch stays uncovered.
53
- const { componentRef, container } = await render(UiSwitch);
54
- const inst = componentRef.instance as UiSwitch;
55
- setInputSignal(inst.disabled, true);
56
- setInputSignal(inst.userClass, 'custom-cls');
265
+ expect(host.checked()).toBe(false);
266
+
267
+ await touch('touchstart', 12);
268
+ fireEvent.touchmove(track(), { touches: [{}] });
57
269
  await waitForUpdate();
58
- const track = container.querySelectorAll('view')[0];
59
- const cls = track?.getAttribute('class') ?? '';
60
- expect(cls).toContain('opacity-50');
61
- expect(cls).toContain('custom-cls');
270
+ expect(host.checked()).toBe(false);
271
+ });
272
+
273
+ it('drags with the mouse on the web', async () => {
274
+ const { host, track, tap } = await renderSwitch(WEB);
275
+ const mouse = (
276
+ kind: 'mousedown' | 'mousemove' | 'mouseup',
277
+ x: number,
278
+ buttons = 1,
279
+ ) => {
280
+ fireEvent[kind](track(), { x, y: 10, buttons });
281
+ return waitForUpdate();
282
+ };
283
+
284
+ // Lynx for Web gives x from the <lynx-view>: only the distance counts.
285
+ await mouse('mousedown', 120);
286
+ await mouse('mousemove', 150);
287
+ // A move with no button held: the button came up outside the switch.
288
+ await mouse('mousemove', 150, 0);
289
+ expect(host.checked()).toBe(true);
290
+ // The browser's click after the drag isn't a tap.
291
+ await tap();
292
+ expect(host.checked()).toBe(true);
293
+
294
+ await mouse('mousedown', 150);
295
+ await mouse('mouseup', 150);
296
+ await tap();
297
+ expect(host.checked()).toBe(false);
298
+ });
299
+
300
+ it('dims and ignores taps and drags while disabled', async () => {
301
+ const { host, track, update, touch, lift, tap } = await renderSwitch();
302
+ await update(() => host.disabled.set(true));
303
+ expect(track().classList).toContain('opacity-50');
304
+ expect(track().getAttribute('accessibility-traits')).toBe(
305
+ 'button,disabled',
306
+ );
307
+
308
+ await touch('touchstart', 12);
309
+ await touch('touchmove', 40);
310
+ await lift();
311
+ await tap();
312
+ expect(host.checked()).toBe(false);
313
+ expect(host.changes).toEqual([]);
314
+ expect(animate).not.toHaveBeenCalled();
315
+ });
316
+
317
+ it('jumps instead of springing under Reduce Motion', async () => {
318
+ settings.reduceMotion.set(true);
319
+ const { host, thumb, fill, tap } = await renderSwitch();
320
+
321
+ await tap();
322
+ expect(host.checked()).toBe(true);
323
+ expect(animate).not.toHaveBeenCalled();
324
+ expect(style(thumb(), 'transition')).toBe('none');
325
+ expect(style(fill(), 'transition')).toBe('none');
326
+ });
327
+
328
+ describe('glass', () => {
329
+ const renderGlass = async (
330
+ platform: { platform: string; osVersion: string },
331
+ providers: Provider[] = [],
332
+ ) => {
333
+ const rendered = await renderSwitch(platform, providers);
334
+ await rendered.update(() => rendered.host.variant.set('glass'));
335
+ const lens = () => rendered.track().querySelector('blur-view');
336
+ return { ...rendered, lens };
337
+ };
338
+
339
+ it("draws iOS 26's switch, whose thumb turns to glass while it's touched", async () => {
340
+ const { host, track, fill, thumb, lens, touch, lift, tap } =
341
+ await renderGlass(IOS_26);
342
+
343
+ expect(style(track(), 'width')).toBe('63px');
344
+ expect(style(track(), 'height')).toBe('28px');
345
+ expect(style(fill(), 'background-color')).toBe(
346
+ 'rgba(120, 120, 128, 0.43)',
347
+ );
348
+ expect(style(thumb(), 'width')).toBe('37px');
349
+ expect(style(thumb(), 'transform')).toBe('translateX(20.5px)');
350
+ // The lens hides under the thumb, a little smaller.
351
+ expect(lens()?.getAttribute('blur-effect')).toBe('glass');
352
+ expect(lens()?.getAttribute('glass-style')).toBe('clear');
353
+ expect(style(lens()!, 'width')).toBe('33px');
354
+
355
+ await touch('touchstart', 20);
356
+ expect(style(lens()!, 'width')).toBe('59px');
357
+ expect(style(lens()!, 'height')).toBe('38px');
358
+ expect(style(lens()!, 'top')).toBe('-5px');
359
+ expect(style(thumb(), 'opacity')).toBe('0');
360
+
361
+ await lift();
362
+ await tap();
363
+ expect(host.checked()).toBe(true);
364
+ expect(style(fill(), 'background-color')).toBe('rgb(52, 199, 89)');
365
+ expect(style(thumb(), 'transform')).toBe('translateX(42.5px)');
366
+ expect(style(thumb(), 'opacity')).toBe('1');
367
+ expect(style(thumb(), 'transition')).toContain('transform');
368
+ // No spring: the CSS transition slides it.
369
+ expect(animate).not.toHaveBeenCalled();
370
+ });
371
+
372
+ it('slides the lens with a dragging finger', async () => {
373
+ const { host, thumb, lens, touch, lift } = await renderGlass(IOS_26);
374
+
375
+ await touch('touchstart', 20);
376
+ await touch('touchmove', 30);
377
+ expect(style(lens()!, 'transform')).toBe('translateX(30.5px)');
378
+ expect(style(thumb(), 'transition')).not.toContain('transform');
379
+ await touch('touchmove', 45);
380
+ await lift();
381
+ expect(host.checked()).toBe(true);
382
+ });
383
+
384
+ it("takes iOS's dark colors in a dark app", async () => {
385
+ const { host, fill, update } = await renderGlass(IOS_26, [
386
+ provideIconTheme({ colorScheme: 'dark' }),
387
+ ]);
388
+
389
+ expect(style(fill(), 'background-color')).toBe(
390
+ 'rgba(120, 120, 128, 0.58)',
391
+ );
392
+ await update(() => host.checked.set(true));
393
+ expect(style(fill(), 'background-color')).toBe('rgb(48, 209, 88)');
394
+ });
395
+
396
+ it('grows a solid thumb instead under Reduce Transparency on the web', async () => {
397
+ settings.reduceTransparency.set(true);
398
+ const { thumb, lens, touch } = await renderGlass(WEB);
399
+
400
+ await touch('touchstart', 20);
401
+ expect(style(thumb(), 'width')).toBe('59px');
402
+ expect(style(thumb(), 'opacity')).toBe('1');
403
+ expect(style(lens()!, 'width')).toBe('33px');
404
+ });
405
+
406
+ it("draws iOS 15 to 25's switch before iOS 26, stretching from its end", async () => {
407
+ const { host, track, fill, thumb, lens, touch, lift, update } =
408
+ await renderGlass(IOS_18);
409
+
410
+ expect(lens()).toBeNull();
411
+ expect(style(track(), 'width')).toBe('51px');
412
+ expect(style(track(), 'height')).toBe('31px');
413
+ expect(style(thumb(), 'width')).toBe('27px');
414
+ expect(style(thumb(), 'box-shadow')).not.toBe('');
415
+
416
+ // Off, it stretches right from its 2px inset.
417
+ await touch('touchstart', 20);
418
+ expect(style(thumb(), 'width')).toBe('33px');
419
+ expect(style(thumb(), 'left')).toBe('-13.5px');
420
+ await lift();
421
+
422
+ // On, left from the other end.
423
+ await update(() => host.checked.set(true));
424
+ expect(style(fill(), 'background-color')).toBe('rgb(52, 199, 89)');
425
+ await touch('touchstart', 30);
426
+ expect(style(thumb(), 'left')).toBe('-19.5px');
427
+ });
428
+
429
+ it("draws Material 3's switch on Android", async () => {
430
+ const { host, track, fill, thumb, touch, lift, update } =
431
+ await renderGlass(ANDROID);
432
+ const outline = () => track().querySelector('.border-muted-foreground');
433
+ /**
434
+ * The state layer, 40dp across.
435
+ */
436
+ const halo = () =>
437
+ Array.from(track().children).find(
438
+ (child) => style(child, 'width') === '40px',
439
+ )!;
440
+
441
+ expect(track().querySelector('blur-view')).toBeNull();
442
+ expect(style(track(), 'width')).toBe('52px');
443
+ expect(style(track(), 'height')).toBe('32px');
444
+ expect(fill().classList).toContain('bg-secondary');
445
+ expect(outline()).not.toBeNull();
446
+ // Off, a 16dp handle centered 16dp in.
447
+ expect(thumb().classList).toContain('bg-muted-foreground');
448
+ expect(style(thumb(), 'width')).toBe('16px');
449
+ expect(style(thumb(), 'transform')).toBe('translateX(16px)');
450
+ expect(style(halo(), 'opacity')).toBe('0');
451
+
452
+ // Pressed, it grows to 28dp with its state layer.
453
+ await touch('touchstart', 16);
454
+ expect(style(thumb(), 'width')).toBe('28px');
455
+ expect(style(halo(), 'opacity')).toBe('0.1');
456
+ await lift();
457
+
458
+ // On, a 24dp handle in the primary track, with no outline.
459
+ await update(() => host.checked.set(true));
460
+ expect(fill().classList).toContain('bg-primary');
461
+ expect(outline()).toBeNull();
462
+ expect(thumb().classList).toContain('bg-primary-foreground');
463
+ expect(style(thumb(), 'width')).toBe('24px');
464
+ expect(halo().classList).toContain('bg-primary');
465
+
466
+ await update(() => host.disabled.set(true));
467
+ expect(track().classList).toContain('opacity-[0.38]');
468
+ });
469
+
470
+ it('draws glass on Android when the app opts in', async () => {
471
+ const { lens } = await renderGlass(ANDROID, [
472
+ provideGlass({ android: 'glass' }),
473
+ ]);
474
+
475
+ expect(lens()).not.toBeNull();
476
+ });
477
+
478
+ it('morphs without animating under Reduce Motion', async () => {
479
+ settings.reduceMotion.set(true);
480
+ const { thumb, lens, touch } = await renderGlass(IOS_26);
481
+
482
+ expect(style(thumb(), 'transition')).toBe('none');
483
+ await touch('touchstart', 20);
484
+ expect(style(lens()!, 'transition')).toBe('none');
485
+ });
486
+ });
487
+
488
+ describe('forms', () => {
489
+ it('binds to a reactive form control', async () => {
490
+ @Component({
491
+ imports: [ReactiveFormsModule, UiSwitch],
492
+ template: `<ui-switch
493
+ [formControl]="wifi"
494
+ accessibility-label="Wi-Fi"
495
+ />`,
496
+ })
497
+ class ReactiveHost {
498
+ readonly wifi = new FormControl(true, { nonNullable: true });
499
+ }
500
+ const { componentRef, getByRole } = await render(ReactiveHost);
501
+ const host = componentRef.instance as ReactiveHost;
502
+ const track = getByRole('button', { name: 'Wi-Fi' });
503
+
504
+ expect(track.getAttribute('accessibility-value')).toBe('On');
505
+ fireEvent.tap(track);
506
+ await waitForUpdate();
507
+ expect(host.wifi.value).toBe(false);
508
+ });
509
+
510
+ it('binds to ngModel', async () => {
511
+ @Component({
512
+ imports: [FormsModule, UiSwitch],
513
+ template: `<ui-switch
514
+ [(ngModel)]="wifi"
515
+ accessibility-label="Wi-Fi"
516
+ />`,
517
+ })
518
+ class ModelHost {
519
+ wifi = false;
520
+ }
521
+ const { componentRef, getByRole } = await render(ModelHost);
522
+ const host = componentRef.instance as ModelHost;
523
+
524
+ fireEvent.tap(getByRole('button', { name: 'Wi-Fi' }));
525
+ await waitForUpdate();
526
+ expect(host.wifi).toBe(true);
527
+ });
528
+
529
+ it('binds to a Signal Forms field', async () => {
530
+ @Component({
531
+ imports: [FormField, UiSwitch],
532
+ template: `<ui-switch
533
+ [formField]="settings.wifi"
534
+ accessibility-label="Wi-Fi"
535
+ />`,
536
+ })
537
+ class SignalHost {
538
+ readonly model = signal({ wifi: false });
539
+ readonly settings = form(this.model);
540
+ }
541
+ const { componentRef, getByRole } = await render(SignalHost);
542
+ const host = componentRef.instance as SignalHost;
543
+
544
+ fireEvent.tap(getByRole('button', { name: 'Wi-Fi' }));
545
+ await waitForUpdate();
546
+ expect(host.model().wifi).toBe(true);
547
+ });
62
548
  });
63
549
  });