@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.
- package/dist/commands/add.test.js +2 -0
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +78 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +167 -26
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.test.js +4 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/update.d.ts +3 -1
- package/dist/commands/update.js +78 -15
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +257 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +10 -0
- package/dist/lockfile.js +20 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.js +43 -3
- package/dist/lockfile.test.js.map +1 -1
- package/dist/registry.js +73 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +16 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +24 -2
- package/dist/test-utils.js +41 -2
- package/dist/test-utils.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
- package/dist/ui/components/action-sheet/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
- package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- package/dist/ui/components/badge/badge.ts +2 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +36 -1
- package/dist/ui/components/button/button.ts +17 -7
- package/dist/ui/components/button-group/button-group.test.ts +53 -9
- package/dist/ui/components/button-group/button-group.ts +67 -21
- package/dist/ui/components/card/card.test.ts +16 -0
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.ts +6 -6
- package/dist/ui/components/chart/chart.test.ts +14 -0
- package/dist/ui/components/chart/chart.ts +10 -3
- package/dist/ui/components/checkbox/checkbox.ts +2 -1
- package/dist/ui/components/collapsible/collapsible.ts +2 -1
- package/dist/ui/components/dialog/dialog.ts +6 -6
- package/dist/ui/components/dock/dock-scroll.ts +9 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +4 -1
- package/dist/ui/components/dock/dock.ts +529 -51
- package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
- package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
- package/dist/ui/components/dynamic-color/index.ts +5 -0
- package/dist/ui/components/glass/glass.test.ts +39 -1
- package/dist/ui/components/glass/glass.ts +87 -17
- package/dist/ui/components/glass/index.ts +1 -0
- package/dist/ui/components/icon/icon-theme.test.ts +28 -0
- package/dist/ui/components/icon/icon-theme.ts +38 -7
- package/dist/ui/components/input/input.ts +2 -1
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.ts +5 -4
- package/dist/ui/components/menu/menu-popup.test.ts +18 -14
- package/dist/ui/components/menu/menu-popup.ts +24 -3
- package/dist/ui/components/menu/menu.test.ts +99 -5
- package/dist/ui/components/menu/menu.ts +74 -34
- package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
- package/dist/ui/components/navigation-bar/index.ts +1 -0
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
- package/dist/ui/components/popover/index.ts +9 -0
- package/dist/ui/components/popover/popover-panel.test.ts +735 -0
- package/dist/ui/components/popover/popover-panel.ts +603 -0
- package/dist/ui/components/popover/popover.test.ts +286 -0
- package/dist/ui/components/popover/popover.ts +207 -0
- package/dist/ui/components/progress/progress.ts +2 -1
- package/dist/ui/components/radio-group/radio-group.ts +2 -1
- package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
- package/dist/ui/components/search-field/index.ts +15 -0
- package/dist/ui/components/search-field/keyboard.test.ts +147 -0
- package/dist/ui/components/search-field/keyboard.ts +123 -0
- package/dist/ui/components/search-field/search-field.test.ts +549 -0
- package/dist/ui/components/search-field/search-field.ts +628 -0
- package/dist/ui/components/segmented-control/index.ts +8 -0
- package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
- package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
- package/dist/ui/components/select/select.ts +2 -1
- package/dist/ui/components/sheet/sheet.test.ts +90 -3
- package/dist/ui/components/sheet/sheet.ts +59 -4
- package/dist/ui/components/slider/index.ts +1 -0
- package/dist/ui/components/slider/slider.test.ts +695 -0
- package/dist/ui/components/slider/slider.ts +901 -0
- package/dist/ui/components/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +464 -48
- package/dist/ui/components/switch/switch.ts +613 -43
- package/dist/ui/components/tabs/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +278 -2
- package/dist/ui/components/tabs/tabs.ts +233 -30
- package/dist/ui/components/textarea/textarea.ts +2 -1
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +310 -4
- package/dist/ui/components/toggle/toggle.ts +241 -26
- package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
- package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
- package/dist/ui/components/toolbar/index.ts +12 -0
- package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
- package/dist/ui/components/toolbar/toolbar.ts +535 -0
- package/dist/utils/analyze.d.ts +10 -5
- package/dist/utils/analyze.js +3 -2
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/installed.d.ts +13 -1
- package/dist/utils/installed.js +17 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +39 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -5
- package/dist/utils/tailwind.js +53 -14
- package/dist/utils/tailwind.js.map +1 -1
- package/dist/utils/tailwind.test.js +85 -22
- package/dist/utils/tailwind.test.js.map +1 -1
- 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 {
|
|
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
|
-
<
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(() =>
|
|
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(
|
|
123
|
+
const { track, fill, thumb } = await renderSwitch();
|
|
124
|
+
expect(fill().classList).toContain('bg-input');
|
|
65
125
|
expect(track().classList).toContain('ml-2');
|
|
66
|
-
expect(
|
|
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,
|
|
146
|
+
const { host, fill, thumb, tap } = await renderSwitch();
|
|
71
147
|
|
|
72
|
-
|
|
73
|
-
await waitForUpdate();
|
|
148
|
+
await tap();
|
|
74
149
|
expect(host.checked()).toBe(true);
|
|
75
|
-
expect(
|
|
76
|
-
expect(thumb()
|
|
150
|
+
expect(fill().classList).toContain('bg-primary');
|
|
151
|
+
expect(style(thumb(), 'transform')).toBe('translateX(32px)');
|
|
77
152
|
|
|
78
|
-
|
|
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 {
|
|
159
|
+
const { thumb, tap } = await renderSwitch();
|
|
86
160
|
|
|
87
|
-
|
|
161
|
+
await tap();
|
|
88
162
|
expect(animate).toHaveBeenCalledWith(thumb(), [
|
|
89
163
|
START,
|
|
90
164
|
expect.any(String),
|
|
91
165
|
[
|
|
92
|
-
{ transform: 'translateX(
|
|
93
|
-
{ transform: 'translateX(
|
|
94
|
-
{ transform: 'translateX(
|
|
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: '
|
|
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
|
-
|
|
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(
|
|
107
|
-
{ transform: 'translateX(
|
|
108
|
-
{ transform: 'translateX(
|
|
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: '
|
|
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
|
-
|
|
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('
|
|
122
|
-
const { host,
|
|
123
|
-
|
|
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(
|
|
265
|
+
expect(host.checked()).toBe(false);
|
|
126
266
|
|
|
127
|
-
|
|
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
|
});
|