@blotch/dolan 22.2.4 → 22.2.6

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 (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. package/package.json +1 -1
@@ -1 +1 @@
1
- export { UiSwitch } from './switch';
1
+ export { UiSwitch, type SwitchVariant } from './switch';
@@ -1,9 +1,12 @@
1
- import { Component, signal } from '@angular/core';
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';
2
4
  import {
3
5
  fireEvent,
4
6
  render,
5
7
  waitForUpdate,
6
8
  } from '@blotch/angular-lynx-testing-library';
9
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
7
10
  import {
8
11
  type MockInstance,
9
12
  afterEach,
@@ -17,117 +20,530 @@ import {
17
20
  type LynxMainThread,
18
21
  lynxMainThread,
19
22
  } from '../../../test-utils/lynx-main-thread';
20
- import { UiSwitch } from './switch';
23
+ import { provideGlass } from '../glass/glass-design';
24
+ import { provideIconTheme } from '../icon/icon-theme';
25
+ import { UiSwitch, type SwitchVariant } from './switch';
21
26
 
22
27
  @Component({
23
- imports: [UiSwitch],
28
+ imports: [LYNX_ELEMENTS, UiSwitch],
24
29
  template: `
25
- <ui-switch
26
- [(checked)]="checked"
27
- [disabled]="disabled()"
28
- class="ml-2"
29
- (changed)="changes.push($event)"
30
- />
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>
31
40
  `,
32
41
  })
33
42
  class SwitchHost {
34
43
  readonly checked = signal(false);
35
44
  readonly disabled = signal(false);
45
+ readonly variant = signal<SwitchVariant>('default');
36
46
  readonly changes: boolean[] = [];
37
47
  }
38
48
 
39
- const renderSwitch = async () => {
40
- const result = await render(SwitchHost);
41
- return {
42
- ...result,
43
- host: result.componentRef.instance as SwitchHost,
44
- track: () => result.container.querySelector('.flex-row')!,
45
- thumb: () => result.container.querySelector('.bg-background')!,
46
- };
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),
47
54
  };
48
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
+
49
61
  /** `__ElementAnimate`'s first argument for starting an animation. */
50
62
  const START = 0;
51
63
  /** `__ElementAnimate`'s first argument for canceling one. */
52
64
  const CANCEL = 3;
53
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
+ };
108
+
54
109
  describe('UiSwitch', () => {
55
110
  // Watched per test: the testing library resets the main thread after each.
56
111
  let animate: MockInstance<LynxMainThread['__ElementAnimate']>;
57
112
  beforeEach(() => {
58
113
  animate = vi.spyOn(lynxMainThread(), '__ElementAnimate');
59
114
  });
60
- afterEach(() => animate.mockRestore());
115
+ afterEach(() => {
116
+ vi.restoreAllMocks();
117
+ vi.unstubAllGlobals();
118
+ settings.reduceMotion.set(false);
119
+ settings.reduceTransparency.set(false);
120
+ });
61
121
 
62
122
  it('renders off, with the thumb at the left and the given class', async () => {
63
- const { track, thumb } = await renderSwitch();
64
- expect(track().classList).toContain('bg-input');
123
+ const { track, fill, thumb } = await renderSwitch();
124
+ expect(fill().classList).toContain('bg-input');
65
125
  expect(track().classList).toContain('ml-2');
66
- expect(thumb().getAttribute('style')).toContain('translateX(2px)');
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');
67
143
  });
68
144
 
69
145
  it('turns on and off on tap, emitting `changed`', async () => {
70
- const { host, track, thumb } = await renderSwitch();
146
+ const { host, fill, thumb, tap } = await renderSwitch();
71
147
 
72
- fireEvent.tap(track());
73
- await waitForUpdate();
148
+ await tap();
74
149
  expect(host.checked()).toBe(true);
75
- expect(track().classList).toContain('bg-primary');
76
- expect(thumb().getAttribute('style')).toContain('translateX(22px)');
150
+ expect(fill().classList).toContain('bg-primary');
151
+ expect(style(thumb(), 'transform')).toBe('translateX(32px)');
77
152
 
78
- fireEvent.tap(track());
79
- await waitForUpdate();
153
+ await tap();
80
154
  expect(host.checked()).toBe(false);
81
155
  expect(host.changes).toEqual([true, false]);
82
156
  });
83
157
 
84
158
  it('springs the thumb across the track, squishing it halfway', async () => {
85
- const { track, thumb } = await renderSwitch();
159
+ const { thumb, tap } = await renderSwitch();
86
160
 
87
- fireEvent.tap(track());
161
+ await tap();
88
162
  expect(animate).toHaveBeenCalledWith(thumb(), [
89
163
  START,
90
164
  expect.any(String),
91
165
  [
92
- { transform: 'translateX(2px) scale(1)' },
93
- { transform: 'translateX(12px) scale(1.1)' },
94
- { transform: 'translateX(22px) scale(1)' },
166
+ { transform: 'translateX(12px) scale(1)' },
167
+ { transform: 'translateX(22px) scale(1.1)' },
168
+ { transform: 'translateX(32px) scale(1)' },
95
169
  ],
96
- expect.objectContaining({ fill: 'forwards' }),
170
+ expect.objectContaining({ fill: 'none' }),
97
171
  ]);
98
172
 
99
173
  // Turning it back off cuts the first spring short and springs it back.
100
- fireEvent.tap(track());
174
+ await tap();
101
175
  expect(animate).toHaveBeenCalledWith(thumb(), [CANCEL, expect.any(String)]);
102
176
  expect(animate).toHaveBeenLastCalledWith(thumb(), [
103
177
  START,
104
178
  expect.any(String),
105
179
  [
106
- { transform: 'translateX(22px) scale(1)' },
107
- { transform: 'translateX(12px) scale(1.1)' },
108
- { transform: 'translateX(2px) scale(1)' },
180
+ { transform: 'translateX(32px) scale(1)' },
181
+ { transform: 'translateX(22px) scale(1.1)' },
182
+ { transform: 'translateX(12px) scale(1)' },
109
183
  ],
110
- expect.objectContaining({ fill: 'forwards' }),
184
+ expect.objectContaining({ fill: 'none' }),
111
185
  ]);
112
186
  });
113
187
 
114
188
  it('follows its checked binding', async () => {
115
- const { host, thumb } = await renderSwitch();
116
- host.checked.set(true);
117
- await waitForUpdate();
118
- expect(thumb().getAttribute('style')).toContain('translateX(22px)');
189
+ const { host, thumb, update } = await renderSwitch();
190
+ await update(() => host.checked.set(true));
191
+ expect(style(thumb(), 'transform')).toBe('translateX(32px)');
119
192
  });
120
193
 
121
- it('dims and ignores taps while disabled', async () => {
122
- const { host, track } = await renderSwitch();
123
- host.disabled.set(true);
194
+ it('toggles when its thumb is dragged across, as on iOS', async () => {
195
+ const { host, fill, thumb, touch, lift, tap } = await renderSwitch();
196
+
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 });
124
264
  await waitForUpdate();
125
- expect(track().classList).toContain('opacity-50');
265
+ expect(host.checked()).toBe(false);
126
266
 
127
- fireEvent.tap(track());
267
+ await touch('touchstart', 12);
268
+ fireEvent.touchmove(track(), { touches: [{}] });
128
269
  await waitForUpdate();
129
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);
130
313
  expect(host.changes).toEqual([]);
131
314
  expect(animate).not.toHaveBeenCalled();
132
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
+ });
548
+ });
133
549
  });