@blotch/dolan 22.2.4 → 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.
- 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
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
import { Component, type Provider, signal, viewChild } from '@angular/core';
|
|
2
|
+
import {
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
waitForUpdate,
|
|
6
|
+
} from '@blotch/angular-lynx-testing-library';
|
|
7
|
+
import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
|
|
10
|
+
import { ICON_LIGHT_COLORS } from '../icon/icon-theme';
|
|
11
|
+
import {
|
|
12
|
+
type SearchFieldContainer,
|
|
13
|
+
UI_SEARCH_FIELD_CONTAINER,
|
|
14
|
+
UiSearchField,
|
|
15
|
+
} from './search-field';
|
|
16
|
+
|
|
17
|
+
@Component({
|
|
18
|
+
imports: [LYNX_ELEMENTS, UiSearchField],
|
|
19
|
+
template: `
|
|
20
|
+
<ui-search-field
|
|
21
|
+
class="mx-4"
|
|
22
|
+
[placeholder]="placeholder()"
|
|
23
|
+
[disabled]="disabled()"
|
|
24
|
+
[cancelable]="cancelable()"
|
|
25
|
+
[value]="query()"
|
|
26
|
+
(valueChange)="query.set($event)"
|
|
27
|
+
(submitted)="submitted.push($event)"
|
|
28
|
+
(canceled)="events.push('canceled')"
|
|
29
|
+
(focused)="events.push('focused')"
|
|
30
|
+
(blurred)="events.push('blurred')"
|
|
31
|
+
/>
|
|
32
|
+
`,
|
|
33
|
+
})
|
|
34
|
+
class FieldHost {
|
|
35
|
+
readonly placeholder = signal('Songs and artists');
|
|
36
|
+
readonly disabled = signal(false);
|
|
37
|
+
readonly cancelable = signal(false);
|
|
38
|
+
readonly query = signal('');
|
|
39
|
+
readonly submitted: string[] = [];
|
|
40
|
+
readonly events: string[] = [];
|
|
41
|
+
readonly field = viewChild.required(UiSearchField);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** A bar that holds the field in a shape of its own, as the dock does. */
|
|
45
|
+
const container = {
|
|
46
|
+
searching: signal(false),
|
|
47
|
+
searchCanceled: vi.fn<() => void>(),
|
|
48
|
+
} satisfies SearchFieldContainer;
|
|
49
|
+
|
|
50
|
+
@Component({
|
|
51
|
+
imports: [LYNX_ELEMENTS, UiSearchField],
|
|
52
|
+
providers: [{ provide: UI_SEARCH_FIELD_CONTAINER, useValue: container }],
|
|
53
|
+
template: `<ui-search-field cancelable />`,
|
|
54
|
+
})
|
|
55
|
+
class ContainedHost {
|
|
56
|
+
readonly field = viewChild.required(UiSearchField);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The Lynx function that runs an element's UI methods: focusing an input,
|
|
61
|
+
* or measuring an element (`boundingClientRect`).
|
|
62
|
+
*/
|
|
63
|
+
type InvokeUIMethod = {
|
|
64
|
+
__InvokeUIMethod: (
|
|
65
|
+
element: Element,
|
|
66
|
+
method: string,
|
|
67
|
+
params: Record<string, unknown>,
|
|
68
|
+
callback: (result: { code: number; data?: unknown }) => void,
|
|
69
|
+
) => void;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Accessibility settings a test can change, as the host app would. */
|
|
73
|
+
const settings = {
|
|
74
|
+
reduceMotion: signal(false),
|
|
75
|
+
reduceTransparency: signal(false),
|
|
76
|
+
increaseContrast: signal(false),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** A phone's screen, as SystemInfo reports it: 402×874pt at 3x. */
|
|
80
|
+
const IOS_26 = {
|
|
81
|
+
platform: 'iOS',
|
|
82
|
+
osVersion: '26.0',
|
|
83
|
+
pixelWidth: 1206,
|
|
84
|
+
pixelHeight: 2622,
|
|
85
|
+
pixelRatio: 3,
|
|
86
|
+
};
|
|
87
|
+
const IOS_18 = { ...IOS_26, osVersion: '18.5' };
|
|
88
|
+
const ANDROID = {
|
|
89
|
+
platform: 'Android',
|
|
90
|
+
osVersion: '15',
|
|
91
|
+
pixelWidth: 1080,
|
|
92
|
+
pixelHeight: 2400,
|
|
93
|
+
pixelRatio: 2.5,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The theme's light colors, as the field resolves them for an SVG.
|
|
98
|
+
*/
|
|
99
|
+
const color = (token: string) => ICON_LIGHT_COLORS[token]!;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Where elements are on screen, as `boundingClientRect` reports them: the
|
|
103
|
+
* page fills a 874pt screen, and the field's host sits `fieldBottom` down.
|
|
104
|
+
*/
|
|
105
|
+
const screen = { fieldBottom: 820, pageBottom: 874, fails: false };
|
|
106
|
+
|
|
107
|
+
const renderOn = async (
|
|
108
|
+
platform: Record<string, unknown> | undefined,
|
|
109
|
+
hostType: typeof FieldHost | typeof ContainedHost = FieldHost,
|
|
110
|
+
providers: Provider[] = [],
|
|
111
|
+
) => {
|
|
112
|
+
vi.stubGlobal('SystemInfo', platform);
|
|
113
|
+
const invoke = vi
|
|
114
|
+
.spyOn(globalThis as unknown as InvokeUIMethod, '__InvokeUIMethod')
|
|
115
|
+
.mockImplementation((element, method, _params, callback) => {
|
|
116
|
+
if (method !== 'boundingClientRect') {
|
|
117
|
+
callback({ code: 0 });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (screen.fails) {
|
|
121
|
+
callback({ code: 1 });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const page = element.tagName.toLowerCase() === 'page';
|
|
125
|
+
const bottom = page ? screen.pageBottom : screen.fieldBottom;
|
|
126
|
+
callback({ code: 0, data: { left: 0, top: bottom - 48, bottom } });
|
|
127
|
+
});
|
|
128
|
+
const result = await render(hostType, {
|
|
129
|
+
providers: [
|
|
130
|
+
{ provide: LynxAccessibilitySettings, useValue: settings },
|
|
131
|
+
...providers,
|
|
132
|
+
],
|
|
133
|
+
});
|
|
134
|
+
const { container: root } = result;
|
|
135
|
+
const host = result.componentRef.instance as FieldHost;
|
|
136
|
+
/**
|
|
137
|
+
* The field itself, for what it says about its state.
|
|
138
|
+
*/
|
|
139
|
+
const component = () => host.field();
|
|
140
|
+
const field = () => root.querySelector<HTMLElement>('input')!;
|
|
141
|
+
/**
|
|
142
|
+
* The field's host, the outermost view of the component.
|
|
143
|
+
*/
|
|
144
|
+
const fieldHost = () =>
|
|
145
|
+
root.querySelector<HTMLElement>('.flex-row.items-center')!;
|
|
146
|
+
/**
|
|
147
|
+
* The view inside the host that rises above the keyboard. Not the host's
|
|
148
|
+
* first element: Angular's anchors are invisible views on Lynx.
|
|
149
|
+
*/
|
|
150
|
+
const frame = () =>
|
|
151
|
+
fieldHost().querySelector<HTMLElement>(':scope > .flex-1.flex-row')!;
|
|
152
|
+
const glass = () => root.querySelector<HTMLElement>('[ui-glass]');
|
|
153
|
+
const icons = () =>
|
|
154
|
+
Array.from(root.querySelectorAll('svg')).map(
|
|
155
|
+
(svg) => svg.getAttribute('content') ?? '',
|
|
156
|
+
);
|
|
157
|
+
const update = async (change: () => void) => {
|
|
158
|
+
change();
|
|
159
|
+
await waitForUpdate();
|
|
160
|
+
};
|
|
161
|
+
const type = async (text: string) => {
|
|
162
|
+
fireEvent.input(field(), { detail: { value: text } });
|
|
163
|
+
await waitForUpdate();
|
|
164
|
+
};
|
|
165
|
+
const keyboard = async (height: number) => {
|
|
166
|
+
fireLynxEvent(field(), 'keyboardheightchange', { detail: { height } });
|
|
167
|
+
await vi.waitFor(() => expect(invoke).toHaveBeenCalled());
|
|
168
|
+
await waitForUpdate();
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
...result,
|
|
172
|
+
host,
|
|
173
|
+
component,
|
|
174
|
+
invoke,
|
|
175
|
+
field,
|
|
176
|
+
fieldHost,
|
|
177
|
+
frame,
|
|
178
|
+
glass,
|
|
179
|
+
icons,
|
|
180
|
+
update,
|
|
181
|
+
type,
|
|
182
|
+
keyboard,
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const style = (element: HTMLElement, name: string) =>
|
|
187
|
+
element.style.getPropertyValue(name);
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The UI methods the field called on its input, by name.
|
|
191
|
+
*/
|
|
192
|
+
const methodsCalled = (rendered: {
|
|
193
|
+
invoke: { mock: { calls: unknown[][] } };
|
|
194
|
+
}) => rendered.invoke.mock.calls.map(([, method]) => method);
|
|
195
|
+
|
|
196
|
+
afterEach(() => {
|
|
197
|
+
vi.restoreAllMocks();
|
|
198
|
+
vi.unstubAllGlobals();
|
|
199
|
+
settings.reduceMotion.set(false);
|
|
200
|
+
settings.increaseContrast.set(false);
|
|
201
|
+
container.searching.set(false);
|
|
202
|
+
container.searchCanceled.mockClear();
|
|
203
|
+
Object.assign(screen, { fieldBottom: 820, pageBottom: 874, fails: false });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('UiSearchField on iOS 26 and later', () => {
|
|
207
|
+
it('is a glass capsule with a magnifier and a search box that searches on return', async () => {
|
|
208
|
+
const { getByRole, field, fieldHost, glass, icons } =
|
|
209
|
+
await renderOn(IOS_26);
|
|
210
|
+
|
|
211
|
+
expect(fieldHost().classList).toContain('mx-4');
|
|
212
|
+
expect(glass()?.parentElement?.hasAttribute('ui-glass-group')).toBe(true);
|
|
213
|
+
expect(glass()?.getAttribute('blur-effect')).toBe('glass');
|
|
214
|
+
expect(glass()?.getAttribute('glass-interactive')).toBe('true');
|
|
215
|
+
expect(glass()?.classList).toContain('rounded-full');
|
|
216
|
+
expect(style(glass()!, 'height')).toBe('48px');
|
|
217
|
+
expect(style(glass()!, 'column-gap')).toBe('8px');
|
|
218
|
+
|
|
219
|
+
expect(getByRole('searchbox', { name: 'Songs and artists' })).toBe(field());
|
|
220
|
+
expect(field().getAttribute('confirm-type')).toBe('search');
|
|
221
|
+
expect(field().getAttribute('placeholder')).toBe('Songs and artists');
|
|
222
|
+
expect(field().classList).toContain('text-glass-foreground');
|
|
223
|
+
expect(style(field(), 'font-size')).toBe('17px');
|
|
224
|
+
// The placeholder and the magnifier take the glass's muted foreground,
|
|
225
|
+
// resolved, as an attribute and an SVG can't read a CSS variable.
|
|
226
|
+
expect(field().getAttribute('placeholder-color')).toBe(
|
|
227
|
+
color('glass-muted-foreground'),
|
|
228
|
+
);
|
|
229
|
+
expect(icons()).toEqual([
|
|
230
|
+
expect.stringContaining(color('glass-muted-foreground')),
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('follows typing, drops the placeholder while there is text, and clears it', async () => {
|
|
235
|
+
const { host, getByRole, queryByRole, field, type, icons } =
|
|
236
|
+
await renderOn(IOS_26);
|
|
237
|
+
expect(queryByRole('button', { name: 'Clear text' })).toBeNull();
|
|
238
|
+
|
|
239
|
+
await type('night');
|
|
240
|
+
expect(host.query()).toBe('night');
|
|
241
|
+
expect(field().getAttribute('placeholder')).toBeNull();
|
|
242
|
+
// UIKit's clear button: an x cut out of a gray circle.
|
|
243
|
+
const clear = getByRole('button', { name: 'Clear text' });
|
|
244
|
+
expect(clear.querySelector('.rounded-full')?.classList).toContain(
|
|
245
|
+
'bg-glass-muted-foreground',
|
|
246
|
+
);
|
|
247
|
+
expect(icons()[1]).toContain(color('background'));
|
|
248
|
+
|
|
249
|
+
fireEvent.tap(clear);
|
|
250
|
+
await waitForUpdate();
|
|
251
|
+
expect(host.query()).toBe('');
|
|
252
|
+
expect(field().getAttribute('placeholder')).toBe('Songs and artists');
|
|
253
|
+
expect(queryByRole('button', { name: 'Clear text' })).toBeNull();
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('shows text the app sets', async () => {
|
|
257
|
+
const { host, update, field } = await renderOn(IOS_26);
|
|
258
|
+
await update(() => host.query.set('jazz'));
|
|
259
|
+
expect(field().getAttribute('placeholder')).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('emits the text when the keyboard’s Search key is pressed', async () => {
|
|
263
|
+
const { host, field } = await renderOn(IOS_26);
|
|
264
|
+
fireLynxEvent(field(), 'confirm', { detail: { value: 'night swim' } });
|
|
265
|
+
expect(host.submitted).toEqual(['night swim']);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('says when it has the keyboard’s focus', async () => {
|
|
269
|
+
const { host, field, component } = await renderOn(IOS_26);
|
|
270
|
+
|
|
271
|
+
fireEvent.focus(field());
|
|
272
|
+
expect(component().hasFocus()).toBe(true);
|
|
273
|
+
fireEvent.blur(field());
|
|
274
|
+
expect(component().hasFocus()).toBe(false);
|
|
275
|
+
expect(host.events).toEqual(['focused', 'blurred']);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('grows a glass cancel button out of its end while it’s in use, which clears it and closes the keyboard', async () => {
|
|
279
|
+
const rendered = await renderOn(IOS_26);
|
|
280
|
+
const { host, update, getByRole, queryByRole, field } = rendered;
|
|
281
|
+
await update(() => host.cancelable.set(true));
|
|
282
|
+
const cancel = () => getByRole('button', { name: 'Cancel', hidden: true });
|
|
283
|
+
|
|
284
|
+
// At rest, it's a circle with no width, folded into the field's end.
|
|
285
|
+
expect(cancel().getAttribute('blur-effect')).toBe('glass');
|
|
286
|
+
expect(cancel().parentElement?.hasAttribute('ui-glass-group')).toBe(true);
|
|
287
|
+
expect(style(cancel(), 'width')).toBe('0px');
|
|
288
|
+
expect(style(cancel(), 'margin-left')).toBe('0px');
|
|
289
|
+
expect(style(cancel(), 'transition')).toContain('width');
|
|
290
|
+
expect(queryByRole('button', { name: 'Cancel' })).toBeNull();
|
|
291
|
+
|
|
292
|
+
fireEvent.focus(field());
|
|
293
|
+
await waitForUpdate();
|
|
294
|
+
expect(style(cancel(), 'width')).toBe('48px');
|
|
295
|
+
expect(style(cancel(), 'margin-left')).toBe('10px');
|
|
296
|
+
expect(
|
|
297
|
+
style(cancel().querySelector('svg') as unknown as HTMLElement, 'opacity'),
|
|
298
|
+
).toBe('1');
|
|
299
|
+
|
|
300
|
+
await rendered.type('moons');
|
|
301
|
+
fireEvent.tap(cancel());
|
|
302
|
+
await waitForUpdate();
|
|
303
|
+
expect(host.query()).toBe('');
|
|
304
|
+
expect(host.events).toEqual(['focused', 'canceled']);
|
|
305
|
+
expect(methodsCalled(rendered)).toContain('blur');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('shows Cancel while it holds text, focused or not', async () => {
|
|
309
|
+
const { host, update, getByRole } = await renderOn(IOS_26);
|
|
310
|
+
await update(() => {
|
|
311
|
+
host.cancelable.set(true);
|
|
312
|
+
host.query.set('ada');
|
|
313
|
+
});
|
|
314
|
+
expect(style(getByRole('button', { name: 'Cancel' }), 'width')).toBe(
|
|
315
|
+
'48px',
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('dims and ignores typing while it’s disabled, with no clear button', async () => {
|
|
320
|
+
const { host, update, queryByRole, field, type } = await renderOn(IOS_26);
|
|
321
|
+
await update(() => {
|
|
322
|
+
host.query.set('ada');
|
|
323
|
+
host.disabled.set(true);
|
|
324
|
+
});
|
|
325
|
+
expect(field().classList).toContain('opacity-50');
|
|
326
|
+
expect(queryByRole('button', { name: 'Clear text' })).toBeNull();
|
|
327
|
+
|
|
328
|
+
await type('blocked');
|
|
329
|
+
expect(host.query()).toBe('ada');
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('turns its muted icons full strength under Increase Contrast', async () => {
|
|
333
|
+
const { icons } = await renderOn(IOS_26);
|
|
334
|
+
settings.increaseContrast.set(true);
|
|
335
|
+
await waitForUpdate();
|
|
336
|
+
expect(icons()[0]).toContain(color('glass-foreground'));
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('grows and fades nothing under Reduce Motion', async () => {
|
|
340
|
+
settings.reduceMotion.set(true);
|
|
341
|
+
const { host, update, getByRole, frame } = await renderOn(IOS_26);
|
|
342
|
+
await update(() => host.cancelable.set(true));
|
|
343
|
+
const cancel = getByRole('button', { name: 'Cancel', hidden: true });
|
|
344
|
+
expect(style(cancel, 'transition')).toBe('none');
|
|
345
|
+
expect(
|
|
346
|
+
style(
|
|
347
|
+
cancel.querySelector('svg') as unknown as HTMLElement,
|
|
348
|
+
'transition',
|
|
349
|
+
),
|
|
350
|
+
).toBe('none');
|
|
351
|
+
expect(style(frame(), 'transition')).toBe('none');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('gives its input the keyboard’s focus, and takes it back', async () => {
|
|
355
|
+
const rendered = await renderOn(IOS_26);
|
|
356
|
+
|
|
357
|
+
rendered.component().focus();
|
|
358
|
+
rendered.component().blur();
|
|
359
|
+
expect(methodsCalled(rendered)).toEqual(
|
|
360
|
+
expect.arrayContaining(['focus', 'blur']),
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('UiSearchField and the keyboard', () => {
|
|
366
|
+
it('rises on iOS to clear a keyboard that would cover it, and settles as it closes', async () => {
|
|
367
|
+
// The keyboard is 336pt, so its top is at 874 - 336 = 538. The field's
|
|
368
|
+
// bottom is at 820, so it rises 820 + 8 - 538 = 290.
|
|
369
|
+
const { frame, keyboard } = await renderOn(IOS_26);
|
|
370
|
+
expect(style(frame(), 'transition')).toContain('transform');
|
|
371
|
+
|
|
372
|
+
await keyboard(336);
|
|
373
|
+
expect(style(frame(), 'transform')).toBe('translateY(-290px)');
|
|
374
|
+
|
|
375
|
+
fireLynxEvent(frame().querySelector('input')!, 'keyboardheightchange', {
|
|
376
|
+
detail: { height: 0 },
|
|
377
|
+
});
|
|
378
|
+
await waitForUpdate();
|
|
379
|
+
expect(style(frame(), 'transform')).toBe('');
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('stays where it is when the keyboard doesn’t reach it', async () => {
|
|
383
|
+
screen.fieldBottom = 200;
|
|
384
|
+
const { frame, keyboard } = await renderOn(IOS_26);
|
|
385
|
+
await keyboard(336);
|
|
386
|
+
expect(style(frame(), 'transform')).toBe('');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('counts Android’s keyboard in physical pixels from the bottom of the Lynx view', async () => {
|
|
390
|
+
// 800 physical pixels at 2.5x is 320dp, measured up from the page's
|
|
391
|
+
// bottom at 860: its top is at 540, and the field rises 820 + 8 - 540.
|
|
392
|
+
screen.pageBottom = 860;
|
|
393
|
+
const { frame, keyboard } = await renderOn(ANDROID);
|
|
394
|
+
await keyboard(800);
|
|
395
|
+
expect(style(frame(), 'transform')).toBe('translateY(-288px)');
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('settles when it loses focus, past a measure still to answer', async () => {
|
|
399
|
+
const { frame, field, invoke } = await renderOn(IOS_26);
|
|
400
|
+
let answer: (() => void) | undefined;
|
|
401
|
+
invoke.mockImplementation((_element, _method, _params, callback) => {
|
|
402
|
+
answer = () => callback({ code: 0, data: { bottom: 820 } });
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
fireLynxEvent(field(), 'keyboardheightchange', { detail: { height: 336 } });
|
|
406
|
+
fireEvent.blur(field());
|
|
407
|
+
answer?.();
|
|
408
|
+
await waitForUpdate();
|
|
409
|
+
expect(style(frame(), 'transform')).toBe('');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('stays put where its box can’t be measured', async () => {
|
|
413
|
+
screen.fails = true;
|
|
414
|
+
const { frame, keyboard } = await renderOn(IOS_26);
|
|
415
|
+
await keyboard(336);
|
|
416
|
+
expect(style(frame(), 'transform')).toBe('');
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('ignores the keyboard where there’s no SystemInfo to place it', async () => {
|
|
420
|
+
const rendered = await renderOn(undefined);
|
|
421
|
+
const { frame, field } = rendered;
|
|
422
|
+
fireLynxEvent(field(), 'keyboardheightchange', { detail: { height: 336 } });
|
|
423
|
+
await waitForUpdate();
|
|
424
|
+
expect(methodsCalled(rendered)).not.toContain('boundingClientRect');
|
|
425
|
+
expect(style(frame(), 'transform')).toBe('');
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
describe('UiSearchField before iOS 26', () => {
|
|
430
|
+
it('is UISearchBar’s field: a rounded rectangle on a gray fill', async () => {
|
|
431
|
+
const { glass, fieldHost, field, icons } = await renderOn(IOS_18);
|
|
432
|
+
expect(glass()).toBeNull();
|
|
433
|
+
const fill = fieldHost().querySelector<HTMLElement>('.bg-muted')!;
|
|
434
|
+
expect(style(fill, 'border-radius')).toBe('10px');
|
|
435
|
+
expect(style(fill, 'height')).toBe('36px');
|
|
436
|
+
expect(field().classList).toContain('text-foreground');
|
|
437
|
+
expect(field().getAttribute('placeholder-color')).toBe(
|
|
438
|
+
color('muted-foreground'),
|
|
439
|
+
);
|
|
440
|
+
expect(icons()[0]).toContain(color('muted-foreground'));
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('slides a Cancel button in beside the field while it’s in use', async () => {
|
|
444
|
+
const { host, update, getByRole, getByText, field } =
|
|
445
|
+
await renderOn(IOS_18);
|
|
446
|
+
await update(() => host.cancelable.set(true));
|
|
447
|
+
const cancel = () => getByRole('button', { name: 'Cancel', hidden: true });
|
|
448
|
+
expect(getByText('Cancel').classList).toContain('text-primary');
|
|
449
|
+
expect(style(cancel(), 'max-width')).toBe('0px');
|
|
450
|
+
|
|
451
|
+
fireEvent.focus(field());
|
|
452
|
+
await waitForUpdate();
|
|
453
|
+
expect(style(cancel(), 'max-width')).toBe('120px');
|
|
454
|
+
expect(style(cancel(), 'margin-left')).toBe('8px');
|
|
455
|
+
|
|
456
|
+
fireEvent.tap(cancel());
|
|
457
|
+
await waitForUpdate();
|
|
458
|
+
expect(host.events).toEqual(['focused', 'canceled']);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it('clears with a gray circle, and slides Cancel in at once under Reduce Motion', async () => {
|
|
462
|
+
settings.reduceMotion.set(true);
|
|
463
|
+
const { host, update, getByRole } = await renderOn(IOS_18);
|
|
464
|
+
await update(() => {
|
|
465
|
+
host.cancelable.set(true);
|
|
466
|
+
host.query.set('ada');
|
|
467
|
+
});
|
|
468
|
+
const clear = getByRole('button', { name: 'Clear text' });
|
|
469
|
+
expect(clear.querySelector('.rounded-full')?.classList).toContain(
|
|
470
|
+
'bg-muted-foreground',
|
|
471
|
+
);
|
|
472
|
+
expect(style(getByRole('button', { name: 'Cancel' }), 'transition')).toBe(
|
|
473
|
+
'none',
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
describe('UiSearchField on Android', () => {
|
|
479
|
+
it('is Material 3’s search bar: a 56dp capsule on a tonal surface', async () => {
|
|
480
|
+
const { host, update, getByRole, glass, fieldHost, field, icons } =
|
|
481
|
+
await renderOn(ANDROID);
|
|
482
|
+
expect(glass()).toBeNull();
|
|
483
|
+
const bar = fieldHost().querySelector<HTMLElement>('.bg-secondary')!;
|
|
484
|
+
expect(bar.classList).toContain('rounded-full');
|
|
485
|
+
expect(style(bar, 'height')).toBe('56px');
|
|
486
|
+
expect(style(bar, 'column-gap')).toBe('16px');
|
|
487
|
+
expect(style(field(), 'font-size')).toBe('16px');
|
|
488
|
+
|
|
489
|
+
// Its clear button is a plain x.
|
|
490
|
+
await update(() => host.query.set('ada'));
|
|
491
|
+
const clear = getByRole('button', { name: 'Clear text' });
|
|
492
|
+
expect(clear.querySelector('.rounded-full')).toBeNull();
|
|
493
|
+
expect(icons()[1]).toContain(color('muted-foreground'));
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('turns its search icon into a back arrow that cancels while it’s in use', async () => {
|
|
497
|
+
const { host, update, getByRole, queryByRole, field } =
|
|
498
|
+
await renderOn(ANDROID);
|
|
499
|
+
await update(() => host.cancelable.set(true));
|
|
500
|
+
expect(queryByRole('button', { name: 'Cancel' })).toBeNull();
|
|
501
|
+
|
|
502
|
+
fireEvent.focus(field());
|
|
503
|
+
await waitForUpdate();
|
|
504
|
+
fireEvent.tap(getByRole('button', { name: 'Cancel' }));
|
|
505
|
+
// Cancel takes the focus, which Lynx reports with a blur.
|
|
506
|
+
fireEvent.blur(field());
|
|
507
|
+
await waitForUpdate();
|
|
508
|
+
expect(host.events).toEqual(['focused', 'canceled', 'blurred']);
|
|
509
|
+
expect(queryByRole('button', { name: 'Cancel' })).toBeNull();
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
describe('UiSearchField in a bar that holds it', () => {
|
|
514
|
+
it('draws only its content on glass, padded as a field of its own', async () => {
|
|
515
|
+
const { glass, frame, keyboard, component } = await renderOn(
|
|
516
|
+
IOS_26,
|
|
517
|
+
ContainedHost,
|
|
518
|
+
);
|
|
519
|
+
expect(glass()).toBeNull();
|
|
520
|
+
expect(style(frame(), 'padding-left')).toBe('14px');
|
|
521
|
+
expect(style(frame(), 'column-gap')).toBe('8px');
|
|
522
|
+
|
|
523
|
+
// The bar moves itself above the keyboard, by the height the field
|
|
524
|
+
// reports.
|
|
525
|
+
await keyboard(336);
|
|
526
|
+
expect(style(frame(), 'transform')).toBe('');
|
|
527
|
+
expect(component().keyboardHeight()).toBe(336);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('keeps its own fill before iOS 26, which had no glass to hold it', async () => {
|
|
531
|
+
const { fieldHost } = await renderOn(IOS_18, ContainedHost);
|
|
532
|
+
expect(fieldHost().classList).toContain('flex-1');
|
|
533
|
+
expect(fieldHost().querySelector('.bg-muted')).toBeTruthy();
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('shows Material’s back arrow while the bar searches, and tells the bar when it’s pressed', async () => {
|
|
537
|
+
const { getByRole, queryByRole, fieldHost } = await renderOn(
|
|
538
|
+
ANDROID,
|
|
539
|
+
ContainedHost,
|
|
540
|
+
);
|
|
541
|
+
expect(fieldHost().querySelector('.bg-secondary')).toBeNull();
|
|
542
|
+
expect(queryByRole('button', { name: 'Cancel' })).toBeNull();
|
|
543
|
+
|
|
544
|
+
container.searching.set(true);
|
|
545
|
+
await waitForUpdate();
|
|
546
|
+
fireEvent.tap(getByRole('button', { name: 'Cancel' }));
|
|
547
|
+
expect(container.searchCanceled).toHaveBeenCalledOnce();
|
|
548
|
+
});
|
|
549
|
+
});
|