@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
|
@@ -99,12 +99,12 @@ export class UiDialog implements OnDestroy {
|
|
|
99
99
|
);
|
|
100
100
|
|
|
101
101
|
protected readonly backdropClass = computed(() =>
|
|
102
|
-
// bg-black/50 dims the backdrop
|
|
103
|
-
// on
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
),
|
|
102
|
+
// bg-black/50 dims the backdrop. It doesn't blur: a backdrop-blur-sm here
|
|
103
|
+
// blurred nothing on any platform. Lynx has no backdrop-filter, so iOS and
|
|
104
|
+
// Android drop it, the Lynx Tailwind 3 preset doesn't generate the class,
|
|
105
|
+
// and the build drops it from Tailwind 4's CSS for the web preview too
|
|
106
|
+
// (see investigations/lynx-vs-web-differences.md). A <blur-view> blurs.
|
|
107
|
+
cn('h-full w-full items-center flex justify-center', 'bg-black/50'),
|
|
108
108
|
);
|
|
109
109
|
|
|
110
110
|
protected readonly panelClass = computed(() =>
|
|
@@ -32,6 +32,15 @@ export class UiDockState {
|
|
|
32
32
|
* this says.
|
|
33
33
|
*/
|
|
34
34
|
readonly minimized = signal(false);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether the dock's search field is open, for a `<ui-dock-action>` without
|
|
38
|
+
* a route: pressing the action opens it, and the bar's button, Cancel or
|
|
39
|
+
* leaving the search closes it. Set it to open or close the search from
|
|
40
|
+
* code. With a route, the search is open while the route is active, and
|
|
41
|
+
* this isn't needed.
|
|
42
|
+
*/
|
|
43
|
+
readonly searching = signal(false);
|
|
35
44
|
}
|
|
36
45
|
|
|
37
46
|
/**
|
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
import { Component, type Provider, signal, viewChild } from '@angular/core';
|
|
2
|
+
import { RouterOutlet, type Routes } from '@angular/router';
|
|
3
|
+
import {
|
|
4
|
+
fireEvent,
|
|
5
|
+
render,
|
|
6
|
+
waitForUpdate,
|
|
7
|
+
} from '@blotch/angular-lynx-testing-library';
|
|
8
|
+
import {
|
|
9
|
+
LYNX_ELEMENTS,
|
|
10
|
+
LynxAccessibilitySettings,
|
|
11
|
+
LynxSystemInfo,
|
|
12
|
+
provideRouter,
|
|
13
|
+
} from '@blotch/angular-lynx';
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
|
|
16
|
+
import { UiSearchField } from '../search-field/search-field';
|
|
17
|
+
import { UiDock, UiDockAccessory, UiDockAction, UiDockItem } from './dock';
|
|
18
|
+
import { UiDockState } from './dock-scroll';
|
|
19
|
+
|
|
20
|
+
@Component({
|
|
21
|
+
selector: 'home-page',
|
|
22
|
+
imports: [LYNX_ELEMENTS],
|
|
23
|
+
template: `<text>Home page</text>`,
|
|
24
|
+
})
|
|
25
|
+
class HomePage {}
|
|
26
|
+
|
|
27
|
+
@Component({
|
|
28
|
+
selector: 'inbox-page',
|
|
29
|
+
imports: [LYNX_ELEMENTS],
|
|
30
|
+
template: `<text>Inbox page</text>`,
|
|
31
|
+
})
|
|
32
|
+
class InboxPage {}
|
|
33
|
+
|
|
34
|
+
@Component({
|
|
35
|
+
selector: 'search-page',
|
|
36
|
+
imports: [LYNX_ELEMENTS],
|
|
37
|
+
template: `<text>Search page</text>`,
|
|
38
|
+
})
|
|
39
|
+
class SearchPage {}
|
|
40
|
+
|
|
41
|
+
const routes: Routes = [
|
|
42
|
+
{ path: '', component: HomePage },
|
|
43
|
+
{ path: 'inbox', component: InboxPage },
|
|
44
|
+
{ path: 'search', component: SearchPage },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A dock that searches: a search tab, or with `route` null, an action that
|
|
49
|
+
* opens the field over the screen.
|
|
50
|
+
*/
|
|
51
|
+
@Component({
|
|
52
|
+
imports: [
|
|
53
|
+
LYNX_ELEMENTS,
|
|
54
|
+
RouterOutlet,
|
|
55
|
+
UiDock,
|
|
56
|
+
UiDockAccessory,
|
|
57
|
+
UiDockAction,
|
|
58
|
+
UiDockItem,
|
|
59
|
+
UiSearchField,
|
|
60
|
+
],
|
|
61
|
+
template: `
|
|
62
|
+
<router-outlet />
|
|
63
|
+
<ui-dock>
|
|
64
|
+
@if (accessory()) {
|
|
65
|
+
<view ui-dock-accessory><text>Now playing</text></view>
|
|
66
|
+
}
|
|
67
|
+
@if (items()) {
|
|
68
|
+
<ui-dock-item
|
|
69
|
+
route="/"
|
|
70
|
+
icon="home"
|
|
71
|
+
label="Home"
|
|
72
|
+
[routerLinkActiveOptions]="{ exact: true }"
|
|
73
|
+
/>
|
|
74
|
+
<ui-dock-item route="/inbox" icon="inbox" label="Inbox" />
|
|
75
|
+
}
|
|
76
|
+
@if (action()) {
|
|
77
|
+
<ui-dock-action [route]="route()" icon="search" label="Search" />
|
|
78
|
+
}
|
|
79
|
+
@if (field()) {
|
|
80
|
+
<ui-search-field
|
|
81
|
+
placeholder="Search mail"
|
|
82
|
+
[cancelable]="cancelable()"
|
|
83
|
+
[value]="query()"
|
|
84
|
+
(valueChange)="query.set($event)"
|
|
85
|
+
/>
|
|
86
|
+
}
|
|
87
|
+
</ui-dock>
|
|
88
|
+
`,
|
|
89
|
+
})
|
|
90
|
+
class SearchDockHost {
|
|
91
|
+
readonly accessory = signal(true);
|
|
92
|
+
readonly items = signal(true);
|
|
93
|
+
readonly action = signal(true);
|
|
94
|
+
readonly field = signal(true);
|
|
95
|
+
readonly route = signal<string | null>('/search');
|
|
96
|
+
readonly cancelable = signal(true);
|
|
97
|
+
readonly query = signal('');
|
|
98
|
+
readonly searchField = viewChild(UiSearchField);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Accessibility settings a test can change, as the host app would. */
|
|
102
|
+
const settings = {
|
|
103
|
+
reduceMotion: signal(false),
|
|
104
|
+
reduceTransparency: signal(false),
|
|
105
|
+
increaseContrast: signal(false),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** A phone's screen: 402×874pt, as LynxSystemInfo reports it. */
|
|
109
|
+
const SCREEN = { pixelRatio: 3, screenHeight: 874 };
|
|
110
|
+
const IOS_26 = { platform: 'iOS', osVersion: '26.0', ...SCREEN };
|
|
111
|
+
const IOS_18 = { platform: 'iOS', osVersion: '18.5', ...SCREEN };
|
|
112
|
+
const ANDROID = { platform: 'Android', osVersion: '15', ...SCREEN };
|
|
113
|
+
|
|
114
|
+
/** How wide the dock lays out in these tests, in px. */
|
|
115
|
+
const WIDTH = 370;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The Lynx function that runs an element's UI methods: focusing an input,
|
|
119
|
+
* or measuring an element (`boundingClientRect`).
|
|
120
|
+
*/
|
|
121
|
+
type InvokeUIMethod = {
|
|
122
|
+
__InvokeUIMethod: (
|
|
123
|
+
element: Element,
|
|
124
|
+
method: string,
|
|
125
|
+
params: Record<string, unknown>,
|
|
126
|
+
callback: (result: { code: number; data?: unknown }) => void,
|
|
127
|
+
) => void;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Whether `boundingClientRect` fails, as it does where nothing's laid out. */
|
|
131
|
+
let measuringFails = false;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Runs change detection until `done` holds: a navigation takes a few ticks,
|
|
135
|
+
* and RouterLinkActive reports the result in a microtask after it.
|
|
136
|
+
*/
|
|
137
|
+
const until = async (done: () => boolean): Promise<void> => {
|
|
138
|
+
for (let i = 0; i < 20 && !done(); i++) {
|
|
139
|
+
await waitForUpdate();
|
|
140
|
+
}
|
|
141
|
+
expect(done()).toBe(true);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const renderDock = async (
|
|
145
|
+
platform: Record<string, unknown>,
|
|
146
|
+
setUp: (host: SearchDockHost) => void = () => {},
|
|
147
|
+
providers: Provider[] = [],
|
|
148
|
+
) => {
|
|
149
|
+
// The dock and the page sit at the bottom of the screen.
|
|
150
|
+
const invoke = vi
|
|
151
|
+
.spyOn(globalThis as unknown as InvokeUIMethod, '__InvokeUIMethod')
|
|
152
|
+
.mockImplementation((_element, method, _params, callback) => {
|
|
153
|
+
if (method !== 'boundingClientRect') {
|
|
154
|
+
callback({ code: 0 });
|
|
155
|
+
} else if (measuringFails) {
|
|
156
|
+
callback({ code: 1 });
|
|
157
|
+
} else {
|
|
158
|
+
callback({ code: 0, data: { left: 0, top: 0, bottom: 874 } });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
const result = await render(SearchDockHost, {
|
|
162
|
+
providers: [
|
|
163
|
+
provideRouter(routes),
|
|
164
|
+
{ provide: LynxSystemInfo, useValue: platform },
|
|
165
|
+
{ provide: LynxAccessibilitySettings, useValue: settings },
|
|
166
|
+
...providers,
|
|
167
|
+
],
|
|
168
|
+
});
|
|
169
|
+
const { container } = result;
|
|
170
|
+
const host = result.componentRef.instance as SearchDockHost;
|
|
171
|
+
setUp(host);
|
|
172
|
+
await waitForUpdate();
|
|
173
|
+
const state = result.componentRef.injector.get(UiDockState);
|
|
174
|
+
|
|
175
|
+
const stage = () => container.querySelector<HTMLElement>('[ui-glass-group]')!;
|
|
176
|
+
const dock = () => stage().parentElement!;
|
|
177
|
+
const shapes = () =>
|
|
178
|
+
Array.from(stage().children).filter((child) =>
|
|
179
|
+
child.hasAttribute('ui-glass'),
|
|
180
|
+
) as HTMLElement[];
|
|
181
|
+
const input = () => container.querySelector<HTMLElement>('input')!;
|
|
182
|
+
/**
|
|
183
|
+
* The field's shape: the glass the field is in.
|
|
184
|
+
*/
|
|
185
|
+
const search = () =>
|
|
186
|
+
shapes().find((shape) => shape.contains(input())) as HTMLElement;
|
|
187
|
+
const searchContent = () => search().lastElementChild as HTMLElement;
|
|
188
|
+
const button = () =>
|
|
189
|
+
container.querySelector<HTMLElement>('[accessibility-label="Search"]')!;
|
|
190
|
+
const action = () => shapes().find((shape) => shape.contains(button()))!;
|
|
191
|
+
const row = () =>
|
|
192
|
+
container.querySelector<HTMLElement>('[accessibility-traits="tabbar"]')!;
|
|
193
|
+
const bar = () => row().parentElement!;
|
|
194
|
+
const accessory = () =>
|
|
195
|
+
shapes().find((shape) => shape.querySelector('[ui-dock-accessory]'));
|
|
196
|
+
const cancel = () =>
|
|
197
|
+
shapes().find(
|
|
198
|
+
(shape) => shape.getAttribute('accessibility-label') === 'Cancel',
|
|
199
|
+
);
|
|
200
|
+
const restore = () =>
|
|
201
|
+
bar().querySelector<HTMLElement>('[accessibility-label="Show tab bar"]');
|
|
202
|
+
const page = () =>
|
|
203
|
+
Array.from(container.querySelectorAll('text'))
|
|
204
|
+
.map((text) => text.textContent ?? '')
|
|
205
|
+
.find((text) => text.endsWith(' page'));
|
|
206
|
+
const methods = () => invoke.mock.calls.map(([, method]) => method);
|
|
207
|
+
const layOut = async () => {
|
|
208
|
+
fireEvent.layoutchange(dock().firstElementChild!, {
|
|
209
|
+
detail: { width: WIDTH, height: 0 },
|
|
210
|
+
});
|
|
211
|
+
await waitForUpdate();
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* Opens the search by the action, as a person does.
|
|
215
|
+
*/
|
|
216
|
+
const openSearch = async () => {
|
|
217
|
+
fireEvent.tap(button());
|
|
218
|
+
await until(() => searchContent()?.style.opacity === '1');
|
|
219
|
+
};
|
|
220
|
+
const keyboard = async (height: number) => {
|
|
221
|
+
fireLynxEvent(input(), 'keyboardheightchange', { detail: { height } });
|
|
222
|
+
await waitForUpdate();
|
|
223
|
+
await waitForUpdate();
|
|
224
|
+
};
|
|
225
|
+
await until(() => page() === 'Home page');
|
|
226
|
+
await layOut();
|
|
227
|
+
return {
|
|
228
|
+
...result,
|
|
229
|
+
host,
|
|
230
|
+
state,
|
|
231
|
+
invoke,
|
|
232
|
+
stage,
|
|
233
|
+
dock,
|
|
234
|
+
shapes,
|
|
235
|
+
input,
|
|
236
|
+
search,
|
|
237
|
+
searchContent,
|
|
238
|
+
button,
|
|
239
|
+
action,
|
|
240
|
+
row,
|
|
241
|
+
bar,
|
|
242
|
+
accessory,
|
|
243
|
+
cancel,
|
|
244
|
+
restore,
|
|
245
|
+
page,
|
|
246
|
+
methods,
|
|
247
|
+
layOut,
|
|
248
|
+
openSearch,
|
|
249
|
+
keyboard,
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const style = (element: HTMLElement, name: string) =>
|
|
254
|
+
element.style.getPropertyValue(name);
|
|
255
|
+
|
|
256
|
+
// Item, page and field selectors aren't Lynx elements, so the renderer
|
|
257
|
+
// falls back to a <view> for each and warns.
|
|
258
|
+
beforeEach(() => {
|
|
259
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
afterEach(() => {
|
|
263
|
+
vi.restoreAllMocks();
|
|
264
|
+
settings.reduceMotion.set(false);
|
|
265
|
+
measuringFails = false;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
describe('UiDock with a search field, on iOS 26 and later', () => {
|
|
269
|
+
it('keeps the field folded into the action, out of sight, until the search opens', async () => {
|
|
270
|
+
const { shapes, search, searchContent, cancel, input } =
|
|
271
|
+
await renderDock(IOS_26);
|
|
272
|
+
|
|
273
|
+
// The accessory, the bar, the action, the field's glass and its Cancel.
|
|
274
|
+
expect(shapes()).toHaveLength(5);
|
|
275
|
+
// The field draws no glass of its own inside the dock's.
|
|
276
|
+
expect(search().querySelectorAll('[ui-glass]')).toHaveLength(0);
|
|
277
|
+
expect(search().classList).toContain('rounded-full');
|
|
278
|
+
// A point at the action's center: 62px wide, at the dock's right.
|
|
279
|
+
expect(style(search(), 'left')).toBe(`${WIDTH - 31}px`);
|
|
280
|
+
expect(style(search(), 'right')).toBe('31px');
|
|
281
|
+
expect(style(search(), 'bottom')).toBe('31px');
|
|
282
|
+
expect(style(search(), 'height')).toBe('0px');
|
|
283
|
+
expect(style(cancel()!, 'height')).toBe('0px');
|
|
284
|
+
expect(style(searchContent(), 'opacity')).toBe('0');
|
|
285
|
+
expect(searchContent().getAttribute('accessibility-elements-hidden')).toBe(
|
|
286
|
+
'true',
|
|
287
|
+
);
|
|
288
|
+
expect(input().getAttribute('placeholder')).toBe('Search mail');
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it('grows the field out of the search tab across the bottom, and shrinks the bar to a button back', async () => {
|
|
292
|
+
const rendered = await renderDock(IOS_26);
|
|
293
|
+
const { stage, bar, row, action, search, searchContent, accessory } =
|
|
294
|
+
rendered;
|
|
295
|
+
await rendered.openSearch();
|
|
296
|
+
expect(rendered.page()).toBe('Search page');
|
|
297
|
+
|
|
298
|
+
// The bar is a 48px circle at the left, showing the item it goes back to.
|
|
299
|
+
expect(style(bar(), 'right')).toBe(`${WIDTH - 48}px`);
|
|
300
|
+
expect(style(bar(), 'height')).toBe('48px');
|
|
301
|
+
expect(row().getAttribute('accessibility-elements-hidden')).toBe('true');
|
|
302
|
+
expect(rendered.restore()?.getAttribute('accessibility-value')).toBe(
|
|
303
|
+
'Home',
|
|
304
|
+
);
|
|
305
|
+
// The field fills the rest, as tall as the button.
|
|
306
|
+
expect(style(search(), 'left')).toBe('58px');
|
|
307
|
+
expect(style(search(), 'right')).toBe('0px');
|
|
308
|
+
expect(style(search(), 'bottom')).toBe('0px');
|
|
309
|
+
expect(style(search(), 'height')).toBe('48px');
|
|
310
|
+
expect(style(searchContent(), 'transition')).toContain('opacity');
|
|
311
|
+
expect(searchContent().hasAttribute('accessibility-elements-hidden')).toBe(
|
|
312
|
+
false,
|
|
313
|
+
);
|
|
314
|
+
// The action shrinks into the field's end, out of a screen reader's way.
|
|
315
|
+
expect(style(action(), 'width')).toBe('0px');
|
|
316
|
+
expect(style(action(), 'right')).toBe('24px');
|
|
317
|
+
expect(action().getAttribute('accessibility-elements-hidden')).toBe('true');
|
|
318
|
+
// Its icon fades, so it doesn't show through the field.
|
|
319
|
+
expect(style(rendered.button(), 'opacity')).toBe('0');
|
|
320
|
+
expect(style(rendered.button(), 'transition')).toContain('opacity');
|
|
321
|
+
// The accessory stays above, across the dock.
|
|
322
|
+
expect(style(accessory()!, 'bottom')).toBe('58px');
|
|
323
|
+
expect(style(accessory()!, 'left')).toBe('0px');
|
|
324
|
+
expect(style(stage(), 'height')).toBe('106px');
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('doesn’t minimize while it searches', async () => {
|
|
328
|
+
const rendered = await renderDock(IOS_26);
|
|
329
|
+
await rendered.openSearch();
|
|
330
|
+
rendered.state.minimized.set(true);
|
|
331
|
+
await waitForUpdate();
|
|
332
|
+
expect(style(rendered.search(), 'height')).toBe('48px');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('grows Cancel out of the field while it’s in use, which clears it and keeps the search tab open', async () => {
|
|
336
|
+
const rendered = await renderDock(IOS_26);
|
|
337
|
+
const { host, search, cancel, input } = rendered;
|
|
338
|
+
await rendered.openSearch();
|
|
339
|
+
expect(style(cancel()!, 'bottom')).toBe('24px');
|
|
340
|
+
|
|
341
|
+
fireEvent.focus(input());
|
|
342
|
+
await waitForUpdate();
|
|
343
|
+
expect(style(cancel()!, 'left')).toBe(`${WIDTH - 48}px`);
|
|
344
|
+
expect(style(cancel()!, 'height')).toBe('48px');
|
|
345
|
+
expect(style(search(), 'right')).toBe('58px');
|
|
346
|
+
expect(
|
|
347
|
+
style(
|
|
348
|
+
cancel()!.querySelector('svg') as unknown as HTMLElement,
|
|
349
|
+
'opacity',
|
|
350
|
+
),
|
|
351
|
+
).toBe('1');
|
|
352
|
+
|
|
353
|
+
fireEvent.input(input(), { detail: { value: 'invoices' } });
|
|
354
|
+
fireEvent.tap(cancel()!);
|
|
355
|
+
await waitForUpdate();
|
|
356
|
+
expect(host.query()).toBe('');
|
|
357
|
+
expect(rendered.methods()).toContain('blur');
|
|
358
|
+
expect(rendered.page()).toBe('Search page');
|
|
359
|
+
expect(style(search(), 'height')).toBe('48px');
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it('leaves Cancel out for a field that isn’t cancelable', async () => {
|
|
363
|
+
const { shapes } = await renderDock(IOS_26, (host) =>
|
|
364
|
+
host.cancelable.set(false),
|
|
365
|
+
);
|
|
366
|
+
expect(shapes()).toHaveLength(4);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it('goes back to the last item from the bar’s button, and closes the keyboard', async () => {
|
|
370
|
+
const rendered = await renderDock(IOS_26);
|
|
371
|
+
await rendered.openSearch();
|
|
372
|
+
rendered.invoke.mockClear();
|
|
373
|
+
|
|
374
|
+
fireEvent.tap(rendered.restore()!);
|
|
375
|
+
await until(() => rendered.page() === 'Home page');
|
|
376
|
+
await until(() => style(rendered.search(), 'height') === '0px');
|
|
377
|
+
expect(rendered.methods()).toContain('blur');
|
|
378
|
+
expect(rendered.restore()).toBeNull();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('opens the field over the screen from an action without a route, and gives it the keyboard', async () => {
|
|
382
|
+
const rendered = await renderDock(IOS_26, (host) => host.route.set(null));
|
|
383
|
+
const { state, search, cancel, input } = rendered;
|
|
384
|
+
await rendered.openSearch();
|
|
385
|
+
expect(state.searching()).toBe(true);
|
|
386
|
+
expect(rendered.page()).toBe('Home page');
|
|
387
|
+
expect(rendered.methods()).toContain('focus');
|
|
388
|
+
// The bar's button shows the item that's selected.
|
|
389
|
+
expect(rendered.restore()?.getAttribute('accessibility-value')).toBe(
|
|
390
|
+
'Home',
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
// Its Cancel closes the search.
|
|
394
|
+
fireEvent.focus(input());
|
|
395
|
+
await waitForUpdate();
|
|
396
|
+
fireEvent.tap(cancel()!);
|
|
397
|
+
await until(() => style(search(), 'height') === '0px');
|
|
398
|
+
expect(state.searching()).toBe(false);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it('closes a search the bar’s button ends, without going anywhere', async () => {
|
|
402
|
+
const rendered = await renderDock(IOS_26, (host) => host.route.set(null));
|
|
403
|
+
await rendered.openSearch();
|
|
404
|
+
fireEvent.tap(rendered.restore()!);
|
|
405
|
+
await waitForUpdate();
|
|
406
|
+
expect(rendered.state.searching()).toBe(false);
|
|
407
|
+
expect(rendered.page()).toBe('Home page');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('goes back to the first item from a search no item came before', async () => {
|
|
411
|
+
const rendered = await renderDock(IOS_26, (host) => host.items.set(false));
|
|
412
|
+
await rendered.openSearch();
|
|
413
|
+
rendered.host.items.set(true);
|
|
414
|
+
await waitForUpdate();
|
|
415
|
+
expect(rendered.restore()?.hasAttribute('accessibility-value')).toBe(false);
|
|
416
|
+
|
|
417
|
+
fireEvent.tap(rendered.restore()!);
|
|
418
|
+
await until(() => rendered.page() === 'Home page');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('closes the search when its field goes', async () => {
|
|
422
|
+
const rendered = await renderDock(IOS_26, (host) => host.route.set(null));
|
|
423
|
+
await rendered.openSearch();
|
|
424
|
+
rendered.host.field.set(false);
|
|
425
|
+
await waitForUpdate();
|
|
426
|
+
expect(rendered.shapes()).toHaveLength(3);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('searches only with an action to open the field', async () => {
|
|
430
|
+
const rendered = await renderDock(IOS_26, (host) => host.action.set(false));
|
|
431
|
+
rendered.state.searching.set(true);
|
|
432
|
+
await waitForUpdate();
|
|
433
|
+
expect(rendered.input()).toBeNull();
|
|
434
|
+
// Canceled from code, the field has no search to close.
|
|
435
|
+
rendered.host.searchField()?.cancel();
|
|
436
|
+
await waitForUpdate();
|
|
437
|
+
expect(rendered.state.searching()).toBe(false);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('rises with its field above the keyboard, and settles as it closes', async () => {
|
|
441
|
+
const rendered = await renderDock(IOS_26);
|
|
442
|
+
const { dock } = rendered;
|
|
443
|
+
await rendered.openSearch();
|
|
444
|
+
expect(style(dock(), 'transition')).toContain('padding-bottom');
|
|
445
|
+
|
|
446
|
+
// The dock ends at the screen's bottom: a 336pt keyboard covers 336pt of
|
|
447
|
+
// it, and the dock floats 8pt above.
|
|
448
|
+
await rendered.keyboard(336);
|
|
449
|
+
await until(() => style(dock(), 'padding-bottom') === '344px');
|
|
450
|
+
|
|
451
|
+
await rendered.keyboard(0);
|
|
452
|
+
expect(style(dock(), 'padding-bottom')).toBe('');
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('drops a keyboard measure that a newer change has overtaken', async () => {
|
|
456
|
+
const rendered = await renderDock(IOS_26);
|
|
457
|
+
await rendered.openSearch();
|
|
458
|
+
let answer: (() => void) | undefined;
|
|
459
|
+
rendered.invoke.mockImplementation(
|
|
460
|
+
(_element, _method, _params, callback) => {
|
|
461
|
+
answer = () => callback({ code: 0, data: { bottom: 874 } });
|
|
462
|
+
},
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
fireLynxEvent(rendered.input(), 'keyboardheightchange', {
|
|
466
|
+
detail: { height: 336 },
|
|
467
|
+
});
|
|
468
|
+
await waitForUpdate();
|
|
469
|
+
await rendered.keyboard(0);
|
|
470
|
+
answer?.();
|
|
471
|
+
await waitForUpdate();
|
|
472
|
+
expect(style(rendered.dock(), 'padding-bottom')).toBe('');
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('stays down where the dock can’t be measured', async () => {
|
|
476
|
+
const rendered = await renderDock(IOS_26);
|
|
477
|
+
await rendered.openSearch();
|
|
478
|
+
measuringFails = true;
|
|
479
|
+
await rendered.keyboard(336);
|
|
480
|
+
expect(style(rendered.dock(), 'padding-bottom')).toBe('');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('grows, shrinks and rises without moving under Reduce Motion', async () => {
|
|
484
|
+
settings.reduceMotion.set(true);
|
|
485
|
+
const rendered = await renderDock(IOS_26);
|
|
486
|
+
await rendered.openSearch();
|
|
487
|
+
expect(style(rendered.search(), 'transition')).toBe('none');
|
|
488
|
+
expect(style(rendered.searchContent(), 'transition')).toBe('none');
|
|
489
|
+
expect(style(rendered.dock(), 'transition')).toBe('');
|
|
490
|
+
expect(style(rendered.button(), 'opacity')).toBe('0');
|
|
491
|
+
expect(style(rendered.button(), 'transition')).toBe('');
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('folds the field into the minimized action, with Cancel', async () => {
|
|
495
|
+
const rendered = await renderDock(IOS_26);
|
|
496
|
+
rendered.state.minimized.set(true);
|
|
497
|
+
await waitForUpdate();
|
|
498
|
+
expect(style(rendered.search(), 'left')).toBe(`${WIDTH - 24}px`);
|
|
499
|
+
expect(style(rendered.cancel()!, 'bottom')).toBe('24px');
|
|
500
|
+
});
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
describe('UiDock with a search field, on iOS 15 to 25', () => {
|
|
504
|
+
it('opens UISearchBar’s field in a bar above the tab bar, which stays', async () => {
|
|
505
|
+
const rendered = await renderDock(IOS_18);
|
|
506
|
+
const { stage, search, searchContent, accessory, row } = rendered;
|
|
507
|
+
expect(style(search(), 'height')).toBe('0px');
|
|
508
|
+
expect(style(search(), 'transition')).toContain('height');
|
|
509
|
+
|
|
510
|
+
await rendered.openSearch();
|
|
511
|
+
expect(search().getAttribute('blur-material')).toBe('chrome');
|
|
512
|
+
expect(search().querySelector('.h-\\[0\\.5px\\]')).toBeTruthy();
|
|
513
|
+
expect(search().classList).not.toContain('rounded-full');
|
|
514
|
+
expect(style(search(), 'height')).toBe('52px');
|
|
515
|
+
expect(style(search(), 'bottom')).toBe(
|
|
516
|
+
'calc(49px + env(safe-area-inset-bottom))',
|
|
517
|
+
);
|
|
518
|
+
expect(searchContent().classList).toContain('px-[8px]');
|
|
519
|
+
// The field keeps its own fill on the bar.
|
|
520
|
+
expect(searchContent().querySelector('.bg-muted')).toBeTruthy();
|
|
521
|
+
// The accessory moves up above it; the tab bar's items stay.
|
|
522
|
+
expect(style(accessory()!, 'bottom')).toBe(
|
|
523
|
+
'calc(109px + env(safe-area-inset-bottom))',
|
|
524
|
+
);
|
|
525
|
+
expect(style(stage(), 'height')).toBe(
|
|
526
|
+
'calc(157px + env(safe-area-inset-bottom))',
|
|
527
|
+
);
|
|
528
|
+
expect(row().hasAttribute('accessibility-elements-hidden')).toBe(false);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
it('puts the field’s bar on the keyboard, with the tab bar under it', async () => {
|
|
532
|
+
const rendered = await renderDock(IOS_18);
|
|
533
|
+
await rendered.openSearch();
|
|
534
|
+
await rendered.keyboard(336);
|
|
535
|
+
await until(
|
|
536
|
+
() =>
|
|
537
|
+
style(rendered.dock(), 'padding-bottom') ===
|
|
538
|
+
'calc(287px - env(safe-area-inset-bottom))',
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it('keeps the search tab open on Cancel', async () => {
|
|
543
|
+
const rendered = await renderDock(IOS_18);
|
|
544
|
+
await rendered.openSearch();
|
|
545
|
+
fireEvent.focus(rendered.input());
|
|
546
|
+
await waitForUpdate();
|
|
547
|
+
fireEvent.tap(rendered.getByRole('button', { name: 'Cancel' }));
|
|
548
|
+
await waitForUpdate();
|
|
549
|
+
expect(rendered.page()).toBe('Search page');
|
|
550
|
+
expect(style(rendered.search(), 'height')).toBe('52px');
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
describe('UiDock with a search field, on Android', () => {
|
|
555
|
+
it('grows the floating action button into a search bar, and slides the toolbar away', async () => {
|
|
556
|
+
const rendered = await renderDock(ANDROID);
|
|
557
|
+
const { bar, action, search } = rendered;
|
|
558
|
+
// At rest, a point at the button's center.
|
|
559
|
+
expect(style(search(), 'right')).toBe('28px');
|
|
560
|
+
expect(style(search(), 'bottom')).toBe('32px');
|
|
561
|
+
expect(rendered.cancel()).toBeUndefined();
|
|
562
|
+
|
|
563
|
+
await rendered.openSearch();
|
|
564
|
+
expect(style(bar(), 'transform')).toBe('translateY(128px)');
|
|
565
|
+
expect(style(action(), 'width')).toBe('0px');
|
|
566
|
+
expect(style(action(), 'bottom')).toBe('32px');
|
|
567
|
+
expect(style(search(), 'left')).toBe('0px');
|
|
568
|
+
expect(style(search(), 'right')).toBe('0px');
|
|
569
|
+
expect(style(search(), 'bottom')).toBe('4px');
|
|
570
|
+
expect(style(search(), 'height')).toBe('56px');
|
|
571
|
+
expect(rendered.row().getAttribute('accessibility-elements-hidden')).toBe(
|
|
572
|
+
'true',
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it('goes back from the search bar’s back arrow', async () => {
|
|
577
|
+
const rendered = await renderDock(ANDROID, (host) =>
|
|
578
|
+
host.cancelable.set(false),
|
|
579
|
+
);
|
|
580
|
+
await rendered.openSearch();
|
|
581
|
+
fireEvent.tap(rendered.getByRole('button', { name: 'Cancel' }));
|
|
582
|
+
await until(() => rendered.page() === 'Home page');
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
describe('UiDock with a search field and no items', () => {
|
|
587
|
+
it('stays on the search tab, with nowhere to go back to', async () => {
|
|
588
|
+
const rendered = await renderDock(IOS_26, (host) => host.items.set(false));
|
|
589
|
+
await rendered.openSearch();
|
|
590
|
+
fireEvent.tap(rendered.restore()!);
|
|
591
|
+
await waitForUpdate();
|
|
592
|
+
expect(rendered.page()).toBe('Search page');
|
|
593
|
+
});
|
|
594
|
+
});
|
|
@@ -505,13 +505,16 @@ describe('UiDock on iOS 26 and later', () => {
|
|
|
505
505
|
});
|
|
506
506
|
|
|
507
507
|
describe('UiDock on iOS 15 to 25', () => {
|
|
508
|
-
it(
|
|
508
|
+
it("runs edge to edge on the bars' chrome material, over the home indicator", async () => {
|
|
509
509
|
const { dock, stage, bar, row, action, accessory, capsule } =
|
|
510
510
|
await renderDock(IOS_18);
|
|
511
511
|
|
|
512
512
|
expect(dock().classList).not.toContain('px-4');
|
|
513
513
|
expect(stage().getAttribute('blur-effect')).toBe('light');
|
|
514
514
|
expect(bar().getAttribute('blur-effect')).toBe('extra-light');
|
|
515
|
+
for (const shape of [bar(), action()!, accessory()!]) {
|
|
516
|
+
expect(shape.getAttribute('blur-material')).toBe('chrome');
|
|
517
|
+
}
|
|
515
518
|
expect(bar().classList).toContain('pb-safe');
|
|
516
519
|
expect(action()?.classList).toContain('pb-safe');
|
|
517
520
|
expect(row().classList).toContain('h-[49px]');
|