@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.
Files changed (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. package/package.json +1 -1
@@ -0,0 +1,735 @@
1
+ import {
2
+ ApplicationRef,
3
+ Component,
4
+ type ElementRef,
5
+ signal,
6
+ viewChild,
7
+ } from '@angular/core';
8
+ import { render, waitForUpdate } from '@blotch/angular-lynx-testing-library';
9
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
12
+ import { type MenuPlacement, type MenuRect } from '../menu/menu-placement';
13
+ import {
14
+ ARROW_HEIGHT,
15
+ ARROW_WIDTH,
16
+ type PopoverAnchor,
17
+ UiPopoverPanel,
18
+ popoverArrow,
19
+ } from './popover-panel';
20
+
21
+ @Component({
22
+ imports: [LYNX_ELEMENTS, UiPopoverPanel],
23
+ template: `
24
+ <view #anchor class="anchor"><text>Tip</text></view>
25
+ <ui-popover-panel
26
+ #panel
27
+ [open]="open()"
28
+ [anchor]="anchorView().nativeElement"
29
+ [width]="width()"
30
+ (dismissed)="dismissed()"
31
+ (closed)="closed()"
32
+ >
33
+ <view class="body"><text>Liked trips show up first.</text></view>
34
+ </ui-popover-panel>
35
+ `,
36
+ })
37
+ class PanelHost {
38
+ readonly open = signal(false);
39
+ readonly width = signal<number | undefined>(undefined);
40
+ readonly anchorView = viewChild.required<ElementRef<PopoverAnchor>>('anchor');
41
+ readonly panel = viewChild.required<UiPopoverPanel>('panel');
42
+ readonly dismissed = vi.fn();
43
+ readonly closed = vi.fn();
44
+ }
45
+
46
+ /**
47
+ * The Lynx function that runs an element's UI methods, such as measuring it
48
+ * (`boundingClientRect`). The testing library defines it on the global
49
+ * object, and answers without a box.
50
+ */
51
+ type InvokeUIMethod = {
52
+ __InvokeUIMethod: (
53
+ element: Element,
54
+ method: string,
55
+ params: Record<string, unknown>,
56
+ callback: (result: { code: number; data?: unknown }) => void,
57
+ ) => void;
58
+ };
59
+
60
+ /** A phone's screen inside its safe area: 62pt down from the top. */
61
+ const AREA: MenuRect = { left: 0, top: 62, width: 402, height: 778 };
62
+ /** The marker from the top of the page to the bottom of the safe area. */
63
+ const PAGE: MenuRect = { left: 0, top: 0, width: 0, height: 840 };
64
+ /** The popover's content at its natural size. */
65
+ const CONTENT = { left: 0, top: 0, width: 240, height: 80 };
66
+
67
+ /** Accessibility settings a test can change, as the host app would. */
68
+ const settings = {
69
+ reduceMotion: signal(false),
70
+ reduceTransparency: signal(false),
71
+ increaseContrast: signal(false),
72
+ };
73
+
74
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
75
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
76
+
77
+ /**
78
+ * Renders the host on a platform, with the elements answering
79
+ * `boundingClientRect` as on a phone: the anchor at `anchorRect`, the
80
+ * markers at AREA and PAGE, and the content CONTENT. `held` keeps the
81
+ * answers back for the anchor or the rest until `release()`, or makes every
82
+ * measurement fail.
83
+ */
84
+ const renderPanel = async ({
85
+ platform = IOS_26,
86
+ anchorRect = { left: 16, top: 200, width: 80, height: 44 },
87
+ area = AREA,
88
+ page = PAGE,
89
+ }: {
90
+ /** `SystemInfo`, or null for none, as in a test, where design is Material. */
91
+ platform?: { platform: string; osVersion: string } | null;
92
+ anchorRect?: MenuRect;
93
+ area?: MenuRect;
94
+ page?: MenuRect;
95
+ } = {}) => {
96
+ vi.stubGlobal('SystemInfo', platform ?? undefined);
97
+ const pending: (() => void)[] = [];
98
+ const held = { anchor: false, rest: false, measure: true };
99
+ const invoke = vi
100
+ .spyOn(globalThis as unknown as InvokeUIMethod, '__InvokeUIMethod')
101
+ .mockImplementation((element, method, _params, callback) => {
102
+ if (method !== 'boundingClientRect') {
103
+ callback({ code: 0 });
104
+ return;
105
+ }
106
+ const isAnchor = element.classList.contains('anchor');
107
+ const style = element.getAttribute('style') ?? '';
108
+ const box = !held.measure
109
+ ? undefined
110
+ : isAnchor
111
+ ? anchorRect
112
+ : style.includes('safe-area-inset-right')
113
+ ? area
114
+ : style.includes('safe-area-inset-bottom')
115
+ ? page
116
+ : CONTENT;
117
+ const answer = () => callback({ code: 0, data: box });
118
+ if (isAnchor ? held.anchor : held.rest) pending.push(answer);
119
+ else answer();
120
+ });
121
+ const result = await render(PanelHost, {
122
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
123
+ });
124
+ const host = result.componentRef.instance as PanelHost;
125
+ const appRef = result.componentRef.injector.get(ApplicationRef);
126
+ const query = (selector: string) =>
127
+ result.container.querySelector(selector) as HTMLElement | null;
128
+ return {
129
+ ...result,
130
+ host,
131
+ invoke,
132
+ held,
133
+ /** How many measurements `held` is keeping back. */
134
+ pendingCount: () => pending.length,
135
+ /** Answers the measurements `held` kept back. */
136
+ release: () => {
137
+ for (const answer of pending.splice(0)) answer();
138
+ },
139
+ /** Runs change detection and effects now. */
140
+ tick: () => appRef.tick(),
141
+ /** The panel, the glass with the content in it. */
142
+ panel: () => query('blur-view[ui-glass]:not(.absolute)'),
143
+ content: () => query('.shrink-0')!,
144
+ /** The arrow's box, before iOS 26. */
145
+ arrow: () => query('view.overflow-hidden'),
146
+ area: () =>
147
+ [...result.container.querySelectorAll('view')].find((view) =>
148
+ view.getAttribute('style')?.includes('safe-area-inset-right'),
149
+ )!,
150
+ };
151
+ };
152
+
153
+ type Rendered = Awaited<ReturnType<typeof renderPanel>>;
154
+
155
+ const style = (element: Element, name: string) =>
156
+ (element as HTMLElement).style.getPropertyValue(name);
157
+
158
+ /**
159
+ * The box an element's inline style puts it in.
160
+ */
161
+ const box = (element: Element) => ({
162
+ left: parseFloat(style(element, 'left')),
163
+ top: parseFloat(style(element, 'top')),
164
+ width: parseFloat(style(element, 'width')),
165
+ height: parseFloat(style(element, 'height')),
166
+ });
167
+
168
+ /**
169
+ * Opens the panel and stops at its start frame, which it leaves after a
170
+ * frame: the measurements are held back, then answered at once.
171
+ */
172
+ const toStartFrame = async (rendered: Rendered) => {
173
+ rendered.held.rest = true;
174
+ rendered.host.open.set(true);
175
+ await waitForHeld(rendered);
176
+ rendered.held.rest = false;
177
+ rendered.release();
178
+ await settle();
179
+ expect(rendered.host.panel().phase()).toBe('from');
180
+ rendered.tick();
181
+ };
182
+
183
+ /**
184
+ * Opens the panel and stops while it's measured, before it lays out: the
185
+ * anchor's measurement is held back, then answered.
186
+ */
187
+ const toMeasuring = async (rendered: Rendered) => {
188
+ rendered.held.anchor = true;
189
+ rendered.host.open.set(true);
190
+ await waitForUpdate();
191
+ rendered.held.anchor = false;
192
+ rendered.release();
193
+ await settle();
194
+ expect(rendered.host.panel().phase()).toBe('measuring');
195
+ };
196
+
197
+ /**
198
+ * Opens the panel and waits until it has grown to its frame.
199
+ */
200
+ const openPanel = async (rendered: Rendered) => {
201
+ rendered.host.open.set(true);
202
+ await vi.waitFor(() => expect(rendered.host.panel().phase()).toBe('open'));
203
+ await waitForUpdate();
204
+ };
205
+
206
+ /**
207
+ * Closes the panel and waits for its close animation to finish.
208
+ */
209
+ const closePanel = async (rendered: Rendered) => {
210
+ rendered.host.open.set(false);
211
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
212
+ await waitForUpdate();
213
+ };
214
+
215
+ /**
216
+ * Lets every pending promise callback run.
217
+ */
218
+ const settle = async () => {
219
+ for (let i = 0; i < 10; i++) await Promise.resolve();
220
+ };
221
+
222
+ /**
223
+ * Waits until the panel has asked for the measurements `held` keeps back, so
224
+ * that `release()` has them to answer. Waiting for the panel to appear isn't
225
+ * enough: it appears first, and measures itself only after LAYOUT_DELAY, a
226
+ * real timer, which a busy CI runner can fire well after that.
227
+ */
228
+ const waitForHeld = (rendered: Rendered) =>
229
+ vi.waitFor(() => expect(rendered.pendingCount()).toBeGreaterThan(0));
230
+
231
+ /**
232
+ * Lets Lynx's timers run: the touch check waits for the next one.
233
+ */
234
+ const nextTimer = () => new Promise((resolve) => setTimeout(resolve, 5));
235
+
236
+ // The renderer warns that <ui-popover-panel> isn't a Lynx element.
237
+ beforeEach(() => {
238
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
239
+ });
240
+
241
+ afterEach(() => {
242
+ vi.restoreAllMocks();
243
+ vi.unstubAllGlobals();
244
+ settings.reduceMotion.set(false);
245
+ });
246
+
247
+ describe('UiPopoverPanel', () => {
248
+ it('is hidden until opened, with only its markers in the page', async () => {
249
+ const { panel, invoke, host } = await renderPanel();
250
+
251
+ expect(panel()).toBeNull();
252
+ expect(host.panel().phase()).toBe('closed');
253
+ expect(invoke).not.toHaveBeenCalled();
254
+ });
255
+
256
+ it('grows Liquid Glass out of its anchor, over it, on iOS 26', async () => {
257
+ const rendered = await renderPanel();
258
+ const { host, panel, content } = rendered;
259
+
260
+ rendered.held.rest = true;
261
+ host.open.set(true);
262
+ // Measured off screen at its natural size first.
263
+ await waitForHeld(rendered);
264
+ expect(style(panel()!, 'left')).toBe('-10000px');
265
+ expect(style(panel()!, 'width')).toBe('');
266
+ expect(style(panel()!, 'transition')).toBe('none');
267
+ expect(host.panel().coversAnchor()).toBe(false);
268
+ rendered.held.rest = false;
269
+ rendered.release();
270
+ await settle();
271
+ rendered.tick();
272
+
273
+ // Then it sits on the anchor, where it is on the page.
274
+ expect(host.panel().phase()).toBe('from');
275
+ expect(box(panel()!)).toEqual({
276
+ left: 16,
277
+ top: 200,
278
+ width: 80,
279
+ height: 44,
280
+ });
281
+ expect(host.panel().coversAnchor()).toBe(true);
282
+
283
+ // ...and grows to its frame, from the anchor's top edge, its content
284
+ // fading in.
285
+ await openPanel(rendered);
286
+ expect(box(panel()!)).toEqual({
287
+ left: 16,
288
+ top: 200,
289
+ width: 240,
290
+ height: 80,
291
+ });
292
+ expect(style(panel()!, 'transition')).toContain('width 350ms');
293
+ expect(style(panel()!, 'opacity')).toBe('1');
294
+ expect(style(panel()!, 'position')).toBe('fixed');
295
+ expect(style(panel()!, 'border-radius')).toBe('24px');
296
+ expect(style(panel()!, 'justify-content')).toBe('flex-start');
297
+ expect(style(panel()!, 'align-items')).toBe('flex-start');
298
+ expect(panel()!.getAttribute('blur-effect')).toBe('glass');
299
+ expect(style(content(), 'opacity')).toBe('1');
300
+ expect(style(content(), 'width')).toBe('240px');
301
+ expect(style(content(), 'height')).toBe('80px');
302
+ // Glass has no arrow.
303
+ expect(rendered.arrow()).toBeNull();
304
+
305
+ // Closing shrinks it back into the anchor, quicker.
306
+ host.open.set(false);
307
+ await waitForUpdate();
308
+ expect(box(panel()!).width).toBe(80);
309
+ expect(style(panel()!, 'transition')).toContain('width 250ms');
310
+ await closePanel(rendered);
311
+ expect(panel()).toBeNull();
312
+ expect(host.panel().coversAnchor()).toBe(false);
313
+ expect(host.closed).toHaveBeenCalledTimes(1);
314
+ });
315
+
316
+ it('moves a screen reader into the panel once it opens', async () => {
317
+ const rendered = await renderPanel();
318
+ await openPanel(rendered);
319
+
320
+ expect(rendered.invoke).toHaveBeenCalledWith(
321
+ rendered.panel(),
322
+ 'requestAccessibilityFocus',
323
+ expect.anything(),
324
+ expect.any(Function),
325
+ );
326
+ });
327
+
328
+ it('opens upward from its anchor near the bottom of the screen', async () => {
329
+ const rendered = await renderPanel({
330
+ anchorRect: { left: 300, top: 780, width: 90, height: 44 },
331
+ });
332
+ await openPanel(rendered);
333
+
334
+ // Lined up with the anchor's bottom and right edges, and pinned there.
335
+ expect(box(rendered.panel()!)).toEqual({
336
+ left: 150,
337
+ top: 824 - 80,
338
+ width: 240,
339
+ height: 80,
340
+ });
341
+ expect(style(rendered.panel()!, 'justify-content')).toBe('flex-end');
342
+ expect(style(rendered.panel()!, 'align-items')).toBe('flex-end');
343
+ });
344
+
345
+ it('takes the width it is given', async () => {
346
+ const rendered = await renderPanel();
347
+ rendered.host.width.set(270);
348
+ await waitForUpdate();
349
+ rendered.held.rest = true;
350
+ rendered.host.open.set(true);
351
+ await waitForHeld(rendered);
352
+ expect(style(rendered.content(), 'width')).toBe('270px');
353
+ rendered.release();
354
+
355
+ await openPanel(rendered);
356
+ expect(box(rendered.panel()!).width).toBe(270);
357
+ });
358
+
359
+ it('keeps the content inside the safe area once it knows its width', async () => {
360
+ const rendered = await renderPanel();
361
+ rendered.held.rest = true;
362
+ rendered.host.open.set(true);
363
+ await vi.waitFor(() => expect(rendered.panel()).not.toBeNull());
364
+ expect(style(rendered.content(), 'max-width')).toBe('');
365
+
366
+ fireLynxEvent(rendered.area(), 'layoutchange', {
367
+ detail: { width: 402 },
368
+ });
369
+ await waitForUpdate();
370
+ expect(style(rendered.content(), 'max-width')).toBe('386px');
371
+ rendered.release();
372
+ });
373
+
374
+ it('undoes the scale of a web preview drawn smaller than the app', async () => {
375
+ /**
376
+ * The docs draw the app at 0.5: boundingClientRect measures half of
377
+ * everything, and layoutchange reports the app's own width.
378
+ */
379
+ const half = (rect: MenuRect): MenuRect => ({
380
+ left: rect.left / 2,
381
+ top: rect.top / 2,
382
+ width: rect.width / 2,
383
+ height: rect.height / 2,
384
+ });
385
+ const rendered = await renderPanel({
386
+ anchorRect: half({ left: 16, top: 200, width: 80, height: 44 }),
387
+ area: half(AREA),
388
+ page: half(PAGE),
389
+ });
390
+ fireLynxEvent(rendered.area(), 'layoutchange', {
391
+ detail: { width: 402 },
392
+ });
393
+ await openPanel(rendered);
394
+
395
+ // The content measured 240 wide at half scale: 480, as wide as the
396
+ // safe area allows.
397
+ expect(box(rendered.panel()!)).toEqual({
398
+ left: 8,
399
+ top: 200,
400
+ width: 386,
401
+ height: 160,
402
+ });
403
+ });
404
+
405
+ it('measures nothing where the elements give no box', async () => {
406
+ const rendered = await renderPanel();
407
+ rendered.held.measure = false;
408
+ // A layout event with no size, as a hidden element sends.
409
+ fireLynxEvent(rendered.area(), 'layoutchange', {});
410
+ fireLynxEvent(rendered.area(), 'layoutchange', { detail: {} });
411
+ await openPanel(rendered);
412
+
413
+ // Kept its margin from the edges of a safe area of no size.
414
+ expect(box(rendered.panel()!)).toEqual({
415
+ left: 8,
416
+ top: 8,
417
+ width: 0,
418
+ height: 0,
419
+ });
420
+ });
421
+
422
+ it('opens beside its anchor, with an arrow, before iOS 26', async () => {
423
+ const rendered = await renderPanel({ platform: IOS_18 });
424
+ const { host, panel } = rendered;
425
+
426
+ // It grows from a corner, with no arrow yet.
427
+ await toStartFrame(rendered);
428
+ expect(box(panel()!).width).toBeCloseTo(72);
429
+ expect(rendered.arrow()).toBeNull();
430
+
431
+ await openPanel(rendered);
432
+ // Below the anchor, an arrow's height and a little away.
433
+ expect(box(panel()!)).toEqual({
434
+ left: 16,
435
+ top: 244 + ARROW_HEIGHT + 2,
436
+ width: 240,
437
+ height: 80,
438
+ });
439
+ expect(style(panel()!, 'border-radius')).toBe('13px');
440
+ expect(panel()!.getAttribute('blur-effect')).toBe('extra-light');
441
+ expect(host.panel().coversAnchor()).toBe(false);
442
+ // The arrow points up at the anchor's middle.
443
+ const arrow = rendered.arrow()!;
444
+ expect(box(arrow)).toEqual({
445
+ left: 56 - ARROW_WIDTH / 2,
446
+ top: 244 + 2,
447
+ width: ARROW_WIDTH,
448
+ height: ARROW_HEIGHT,
449
+ });
450
+ const square = arrow.querySelector('blur-view')!;
451
+ expect(style(square, 'transform')).toBe('rotate(45deg)');
452
+ expect(parseFloat(style(square, 'top'))).toBeGreaterThan(0);
453
+
454
+ // It doesn't shrink with the panel, so it goes as the panel closes.
455
+ host.open.set(false);
456
+ await waitForUpdate();
457
+ expect(rendered.arrow()).toBeNull();
458
+ });
459
+
460
+ it('points its arrow down at an anchor below it', async () => {
461
+ const rendered = await renderPanel({
462
+ platform: IOS_18,
463
+ anchorRect: { left: 160, top: 780, width: 80, height: 44 },
464
+ });
465
+ await openPanel(rendered);
466
+
467
+ const arrow = rendered.arrow()!;
468
+ expect(box(arrow).top).toBe(780 - ARROW_HEIGHT - 2);
469
+ expect(box(arrow).left).toBe(200 - ARROW_WIDTH / 2);
470
+ const square = arrow.querySelector('blur-view')!;
471
+ expect(parseFloat(style(square, 'top'))).toBeLessThan(0);
472
+ });
473
+
474
+ it('has no arrow when it can only cover its anchor', async () => {
475
+ // As tall as the screen: there's room neither below nor above.
476
+ const rendered = await renderPanel({
477
+ platform: IOS_18,
478
+ anchorRect: { left: 16, top: 92, width: 80, height: 44 },
479
+ area: { ...AREA, height: 100 },
480
+ page: { ...PAGE, height: 162 },
481
+ });
482
+ await openPanel(rendered);
483
+
484
+ expect(rendered.arrow()).toBeNull();
485
+ });
486
+
487
+ it('unfolds a Material surface from its edge on Android, fading it in', async () => {
488
+ const rendered = await renderPanel({ platform: null });
489
+ const { host, panel } = rendered;
490
+
491
+ // A sliver along the edge nearest the anchor, transparent.
492
+ await toStartFrame(rendered);
493
+ expect(box(panel()!).height).toBe(0);
494
+ expect(style(panel()!, 'opacity')).toBe('0');
495
+
496
+ await openPanel(rendered);
497
+ expect(box(panel()!)).toEqual({
498
+ left: 16,
499
+ top: 244 + 8,
500
+ width: 240,
501
+ height: 80,
502
+ });
503
+ expect(style(panel()!, 'opacity')).toBe('1');
504
+ expect(style(panel()!, 'border-radius')).toBe('16px');
505
+ expect(panel()!.classList).toContain('bg-secondary');
506
+ expect(rendered.arrow()).toBeNull();
507
+ expect(host.panel().coversAnchor()).toBe(false);
508
+ });
509
+
510
+ it('appears in place under Reduce Motion, only fading its content', async () => {
511
+ settings.reduceMotion.set(true);
512
+ const rendered = await renderPanel();
513
+ const { panel } = rendered;
514
+
515
+ // Already at its frame, rather than at the anchor.
516
+ await toStartFrame(rendered);
517
+ expect(box(panel()!).width).toBe(240);
518
+
519
+ await openPanel(rendered);
520
+ expect(style(panel()!, 'transition')).toMatch(
521
+ /^opacity 350ms cubic-bezier\([^)]*\)$/,
522
+ );
523
+ expect(style(rendered.content(), 'transition')).toContain('opacity');
524
+ });
525
+
526
+ describe('closes on a touch outside it', () => {
527
+ it('closes on a touch anywhere else, which still reaches what it touched', async () => {
528
+ const rendered = await renderPanel();
529
+ await openPanel(rendered);
530
+
531
+ // Lynx runs the panel's global listener for a touch on any element.
532
+ fireLynxEvent(rendered.panel()!, 'touchstart', {}, 'global-bindEvent');
533
+ expect(rendered.host.dismissed).not.toHaveBeenCalled();
534
+ await nextTimer();
535
+ expect(rendered.host.dismissed).toHaveBeenCalledTimes(1);
536
+ });
537
+
538
+ it('closes on a mouse press anywhere else, in the web preview', async () => {
539
+ const rendered = await renderPanel();
540
+ await openPanel(rendered);
541
+
542
+ fireLynxEvent(rendered.panel()!, 'mousedown', {}, 'global-bindEvent');
543
+ await nextTimer();
544
+ expect(rendered.host.dismissed).toHaveBeenCalledTimes(1);
545
+ });
546
+
547
+ it('stays open for a touch or a mouse press on itself', async () => {
548
+ const rendered = await renderPanel();
549
+ await openPanel(rendered);
550
+ const panel = rendered.panel()!;
551
+
552
+ fireLynxEvent(panel, 'touchstart', {}, 'global-bindEvent');
553
+ fireLynxEvent(panel, 'touchstart', {}, 'bindEvent');
554
+ await nextTimer();
555
+ fireLynxEvent(panel, 'mousedown', {}, 'bindEvent');
556
+ fireLynxEvent(panel, 'mousedown', {}, 'global-bindEvent');
557
+ await nextTimer();
558
+ expect(rendered.host.dismissed).not.toHaveBeenCalled();
559
+
560
+ // The next touch elsewhere still closes it.
561
+ fireLynxEvent(panel, 'touchstart', {}, 'global-bindEvent');
562
+ await nextTimer();
563
+ expect(rendered.host.dismissed).toHaveBeenCalledTimes(1);
564
+ });
565
+
566
+ it('stays open for a touch on its arrow', async () => {
567
+ const rendered = await renderPanel({ platform: IOS_18 });
568
+ await openPanel(rendered);
569
+ const arrow = rendered.arrow()!;
570
+
571
+ fireLynxEvent(arrow, 'touchstart', {}, 'bindEvent');
572
+ fireLynxEvent(arrow, 'mousedown', {}, 'bindEvent');
573
+ fireLynxEvent(rendered.panel()!, 'touchstart', {}, 'global-bindEvent');
574
+ await nextTimer();
575
+ expect(rendered.host.dismissed).not.toHaveBeenCalled();
576
+ });
577
+
578
+ it('stays open for a touch on its anchor, which toggles it', async () => {
579
+ const rendered = await renderPanel();
580
+ await openPanel(rendered);
581
+
582
+ rendered.host.panel().touchedAnchor();
583
+ fireLynxEvent(rendered.panel()!, 'touchstart', {}, 'global-bindEvent');
584
+ await nextTimer();
585
+ expect(rendered.host.dismissed).not.toHaveBeenCalled();
586
+ });
587
+
588
+ it("doesn't keep a touch on its anchor from before it opened", async () => {
589
+ const rendered = await renderPanel();
590
+ // The touch that opens it, which no global listener heard.
591
+ rendered.host.panel().touchedAnchor();
592
+ await openPanel(rendered);
593
+
594
+ fireLynxEvent(rendered.panel()!, 'touchstart', {}, 'global-bindEvent');
595
+ await nextTimer();
596
+ expect(rendered.host.dismissed).toHaveBeenCalledTimes(1);
597
+ });
598
+ });
599
+
600
+ describe('when opened and closed quickly', () => {
601
+ it('closes at once while it is still being measured', async () => {
602
+ const rendered = await renderPanel();
603
+ rendered.held.rest = true;
604
+ rendered.host.open.set(true);
605
+ await waitForHeld(rendered);
606
+
607
+ rendered.host.open.set(false);
608
+ await waitForUpdate();
609
+ expect(rendered.panel()).toBeNull();
610
+ expect(rendered.host.closed).toHaveBeenCalledTimes(1);
611
+ // The measurements it was waiting for change nothing.
612
+ rendered.release();
613
+ await settle();
614
+ expect(rendered.host.panel().phase()).toBe('closed');
615
+ });
616
+
617
+ it('stops when closed before its anchor is measured', async () => {
618
+ const rendered = await renderPanel();
619
+ rendered.held.anchor = true;
620
+ rendered.host.open.set(true);
621
+ await waitForUpdate();
622
+ rendered.host.open.set(false);
623
+ await waitForUpdate();
624
+
625
+ rendered.release();
626
+ await settle();
627
+ expect(rendered.panel()).toBeNull();
628
+ expect(rendered.host.closed).not.toHaveBeenCalled();
629
+ });
630
+
631
+ it('stops when closed while it waits to be laid out', async () => {
632
+ const rendered = await renderPanel();
633
+ await toMeasuring(rendered);
634
+ rendered.host.open.set(false);
635
+ rendered.tick();
636
+ // Reopened at once: the first open's wait ends, and gives way.
637
+ rendered.host.open.set(true);
638
+ await openPanel(rendered);
639
+ expect(box(rendered.panel()!).width).toBe(240);
640
+ });
641
+
642
+ it('stops when closed while it sits at its start frame', async () => {
643
+ const rendered = await renderPanel();
644
+ await toStartFrame(rendered);
645
+
646
+ rendered.host.open.set(false);
647
+ rendered.tick();
648
+ await closePanel(rendered);
649
+ expect(rendered.host.panel().phase()).toBe('closed');
650
+ });
651
+
652
+ it('finishes closing at once when reopened while closing', async () => {
653
+ const rendered = await renderPanel();
654
+ await openPanel(rendered);
655
+ rendered.host.open.set(false);
656
+ await waitForUpdate();
657
+ expect(rendered.host.panel().phase()).toBe('closing');
658
+
659
+ rendered.host.open.set(true);
660
+ await openPanel(rendered);
661
+ // The interrupted close never reported, and its timer gave way.
662
+ await new Promise((resolve) => setTimeout(resolve, 300));
663
+ expect(rendered.host.closed).not.toHaveBeenCalled();
664
+ expect(rendered.host.panel().phase()).toBe('open');
665
+ });
666
+
667
+ it('stops its timers when destroyed while opening', async () => {
668
+ const rendered = await renderPanel();
669
+ await toMeasuring(rendered);
670
+ rendered.unmount();
671
+ await new Promise((resolve) => setTimeout(resolve, 100));
672
+ expect(rendered.host.closed).not.toHaveBeenCalled();
673
+ });
674
+ });
675
+ });
676
+
677
+ describe('popoverArrow', () => {
678
+ const placement = (
679
+ overrides: Partial<MenuPlacement> = {},
680
+ ): MenuPlacement => ({
681
+ frame: { left: 100, top: 300, width: 200, height: 100 },
682
+ anchor: { left: 180, top: 250, width: 40, height: 40 },
683
+ vertical: 'top',
684
+ horizontal: 'left',
685
+ ...overrides,
686
+ });
687
+
688
+ it("points up at an anchor above, at the anchor's middle", () => {
689
+ expect(popoverArrow(placement(), 13)).toEqual({
690
+ left: 200 - ARROW_WIDTH / 2,
691
+ top: 300 - ARROW_HEIGHT,
692
+ up: true,
693
+ });
694
+ });
695
+
696
+ it('points down at an anchor below', () => {
697
+ expect(
698
+ popoverArrow(
699
+ placement({
700
+ anchor: { left: 180, top: 420, width: 40, height: 40 },
701
+ vertical: 'bottom',
702
+ }),
703
+ 13,
704
+ ),
705
+ ).toEqual({ left: 200 - ARROW_WIDTH / 2, top: 400, up: false });
706
+ });
707
+
708
+ it('keeps clear of the corners', () => {
709
+ const near = (left: number) =>
710
+ popoverArrow(
711
+ placement({ anchor: { left, top: 250, width: 10, height: 40 } }),
712
+ 13,
713
+ )?.left;
714
+ expect(near(0)).toBe(113);
715
+ expect(near(400)).toBe(300 - 13 - ARROW_WIDTH);
716
+ });
717
+
718
+ it('is null when the popover covers its anchor', () => {
719
+ expect(
720
+ popoverArrow(
721
+ placement({ anchor: { left: 180, top: 320, width: 40, height: 40 } }),
722
+ 13,
723
+ ),
724
+ ).toBeNull();
725
+ expect(
726
+ popoverArrow(
727
+ placement({
728
+ anchor: { left: 180, top: 320, width: 40, height: 40 },
729
+ vertical: 'bottom',
730
+ }),
731
+ 13,
732
+ ),
733
+ ).toBeNull();
734
+ });
735
+ });