@blotch/dolan 22.2.2 → 22.2.4

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 (259) hide show
  1. package/dist/commands/add.d.ts +6 -2
  2. package/dist/commands/add.js +41 -11
  3. package/dist/commands/add.js.map +1 -1
  4. package/dist/commands/add.test.js +162 -9
  5. package/dist/commands/add.test.js.map +1 -1
  6. package/dist/commands/diff.d.ts +2 -1
  7. package/dist/commands/diff.js +38 -25
  8. package/dist/commands/diff.js.map +1 -1
  9. package/dist/commands/diff.test.js +72 -11
  10. package/dist/commands/diff.test.js.map +1 -1
  11. package/dist/commands/doctor.js +228 -34
  12. package/dist/commands/doctor.js.map +1 -1
  13. package/dist/commands/doctor.test.js +367 -10
  14. package/dist/commands/doctor.test.js.map +1 -1
  15. package/dist/commands/eject.js +7 -0
  16. package/dist/commands/eject.js.map +1 -1
  17. package/dist/commands/eject.test.js +39 -0
  18. package/dist/commands/eject.test.js.map +1 -1
  19. package/dist/commands/info.js +3 -2
  20. package/dist/commands/info.js.map +1 -1
  21. package/dist/commands/init.d.ts +37 -1
  22. package/dist/commands/init.js +169 -40
  23. package/dist/commands/init.js.map +1 -1
  24. package/dist/commands/init.test.js +392 -27
  25. package/dist/commands/init.test.js.map +1 -1
  26. package/dist/commands/install-packages.d.ts +6 -0
  27. package/dist/commands/install-packages.js +20 -0
  28. package/dist/commands/install-packages.js.map +1 -0
  29. package/dist/commands/install-packages.test.d.ts +1 -0
  30. package/dist/commands/install-packages.test.js +55 -0
  31. package/dist/commands/install-packages.test.js.map +1 -0
  32. package/dist/commands/list.js +21 -14
  33. package/dist/commands/list.js.map +1 -1
  34. package/dist/commands/list.test.js +62 -5
  35. package/dist/commands/list.test.js.map +1 -1
  36. package/dist/commands/outdated.js +11 -10
  37. package/dist/commands/outdated.js.map +1 -1
  38. package/dist/commands/outdated.test.js +20 -5
  39. package/dist/commands/outdated.test.js.map +1 -1
  40. package/dist/commands/remove.d.ts +4 -0
  41. package/dist/commands/remove.js +17 -4
  42. package/dist/commands/remove.js.map +1 -1
  43. package/dist/commands/remove.test.js +53 -2
  44. package/dist/commands/remove.test.js.map +1 -1
  45. package/dist/commands/theme.js +1 -1
  46. package/dist/commands/theme.js.map +1 -1
  47. package/dist/commands/update.d.ts +11 -0
  48. package/dist/commands/update.js +174 -58
  49. package/dist/commands/update.js.map +1 -1
  50. package/dist/commands/update.test.js +587 -33
  51. package/dist/commands/update.test.js.map +1 -1
  52. package/dist/index.js +7 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/lockfile.d.ts +7 -0
  55. package/dist/lockfile.js +36 -0
  56. package/dist/lockfile.js.map +1 -1
  57. package/dist/lockfile.test.d.ts +1 -0
  58. package/dist/lockfile.test.js +25 -0
  59. package/dist/lockfile.test.js.map +1 -0
  60. package/dist/registry.d.ts +14 -0
  61. package/dist/registry.js +78 -24
  62. package/dist/registry.js.map +1 -1
  63. package/dist/registry.test.js +119 -38
  64. package/dist/registry.test.js.map +1 -1
  65. package/dist/test-utils.d.ts +5 -0
  66. package/dist/test-utils.js +6 -1
  67. package/dist/test-utils.js.map +1 -1
  68. package/dist/testing/fire-lynx-event.d.ts +14 -0
  69. package/dist/testing/fire-lynx-event.js +19 -0
  70. package/dist/testing/fire-lynx-event.js.map +1 -0
  71. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  72. package/dist/testing/fire-lynx-event.test.js +44 -0
  73. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  74. package/dist/testing/lynx-main-thread.d.ts +22 -0
  75. package/dist/testing/lynx-main-thread.js +11 -0
  76. package/dist/testing/lynx-main-thread.js.map +1 -0
  77. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  78. package/dist/testing/lynx-main-thread.test.js +24 -0
  79. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  80. package/dist/testing/set-input-signal.d.ts +14 -0
  81. package/dist/testing/set-input-signal.js +21 -0
  82. package/dist/testing/set-input-signal.js.map +1 -0
  83. package/dist/testing/set-input-signal.test.d.ts +1 -0
  84. package/dist/testing/set-input-signal.test.js +20 -0
  85. package/dist/testing/set-input-signal.test.js.map +1 -0
  86. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  87. package/dist/ui/components/accordion/accordion.ts +19 -14
  88. package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
  89. package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
  90. package/dist/ui/components/alert/alert.test.ts +67 -19
  91. package/dist/ui/components/alert/alert.ts +14 -16
  92. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
  93. package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
  94. package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
  95. package/dist/ui/components/area-chart/area-chart.ts +100 -315
  96. package/dist/ui/components/area-chart/index.ts +2 -3
  97. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  98. package/dist/ui/components/badge/badge.test.ts +57 -23
  99. package/dist/ui/components/badge/badge.ts +12 -14
  100. package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
  101. package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
  102. package/dist/ui/components/bar-chart/index.ts +3 -3
  103. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
  104. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
  105. package/dist/ui/components/button/button.test.ts +611 -2
  106. package/dist/ui/components/button/button.ts +434 -49
  107. package/dist/ui/components/button/index.ts +2 -0
  108. package/dist/ui/components/button-group/button-group.test.ts +484 -2
  109. package/dist/ui/components/button-group/button-group.ts +380 -68
  110. package/dist/ui/components/button-group/index.ts +3 -0
  111. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
  112. package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
  113. package/dist/ui/components/candlestick-chart/index.ts +3 -4
  114. package/dist/ui/components/card/card.test.ts +82 -62
  115. package/dist/ui/components/chart/chart-focus.test.ts +132 -0
  116. package/dist/ui/components/chart/chart-focus.ts +115 -0
  117. package/dist/ui/components/chart/chart-presets.test.ts +335 -0
  118. package/dist/ui/components/chart/chart-presets.ts +278 -0
  119. package/dist/ui/components/chart/chart-scene.test.ts +589 -0
  120. package/dist/ui/components/chart/chart-scene.ts +483 -0
  121. package/dist/ui/components/chart/chart-theme.test.ts +85 -0
  122. package/dist/ui/components/chart/chart-theme.ts +210 -0
  123. package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
  124. package/dist/ui/components/chart/chart-zoom.ts +162 -0
  125. package/dist/ui/components/chart/chart.test.ts +1017 -0
  126. package/dist/ui/components/chart/chart.ts +934 -0
  127. package/dist/ui/components/chart/index.ts +24 -0
  128. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  129. package/dist/ui/components/checkbox/checkbox.ts +23 -23
  130. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  131. package/dist/ui/components/collapsible/collapsible.ts +19 -14
  132. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  133. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  134. package/dist/ui/components/context-menu/index.ts +1 -0
  135. package/dist/ui/components/dialog/dialog.test.ts +213 -108
  136. package/dist/ui/components/dialog/dialog.ts +72 -35
  137. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  138. package/dist/ui/components/dock/dock-scroll.ts +133 -0
  139. package/dist/ui/components/dock/dock.test.ts +749 -0
  140. package/dist/ui/components/dock/dock.ts +960 -0
  141. package/dist/ui/components/dock/index.ts +2 -0
  142. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  143. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  144. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  145. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  146. package/dist/ui/components/glass/glass-design.ts +119 -0
  147. package/dist/ui/components/glass/glass.test.ts +338 -0
  148. package/dist/ui/components/glass/glass.ts +360 -0
  149. package/dist/ui/components/glass/index.ts +18 -0
  150. package/dist/ui/components/icon/icon-theme.test.ts +169 -0
  151. package/dist/ui/components/icon/icon-theme.ts +227 -0
  152. package/dist/ui/components/icon/icon.test.ts +85 -25
  153. package/dist/ui/components/icon/icon.ts +38 -21
  154. package/dist/ui/components/input/input.test.ts +178 -101
  155. package/dist/ui/components/input/input.ts +15 -12
  156. package/dist/ui/components/label/label.test.ts +11 -24
  157. package/dist/ui/components/line-chart/index.ts +2 -3
  158. package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
  159. package/dist/ui/components/line-chart/line-chart.ts +83 -241
  160. package/dist/ui/components/list/list.test.ts +119 -21
  161. package/dist/ui/components/list/list.ts +21 -15
  162. package/dist/ui/components/menu/index.ts +25 -0
  163. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  164. package/dist/ui/components/menu/menu-placement.ts +159 -0
  165. package/dist/ui/components/menu/menu-popup.test.ts +632 -0
  166. package/dist/ui/components/menu/menu-popup.ts +544 -0
  167. package/dist/ui/components/menu/menu.test.ts +429 -0
  168. package/dist/ui/components/menu/menu.ts +415 -0
  169. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
  170. package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
  171. package/dist/ui/components/pagination/pagination.test.ts +177 -65
  172. package/dist/ui/components/pagination/pagination.ts +55 -8
  173. package/dist/ui/components/progress/progress.test.ts +102 -46
  174. package/dist/ui/components/progress/progress.ts +52 -54
  175. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  176. package/dist/ui/components/radio-group/radio-group.ts +24 -24
  177. package/dist/ui/components/scatter-chart/index.ts +2 -3
  178. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
  179. package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
  180. package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
  181. package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
  182. package/dist/ui/components/select/select.test.ts +138 -78
  183. package/dist/ui/components/select/select.ts +17 -11
  184. package/dist/ui/components/separator/separator.test.ts +18 -17
  185. package/dist/ui/components/sheet/sheet.test.ts +64 -11
  186. package/dist/ui/components/sheet/sheet.ts +9 -1
  187. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  188. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  189. package/dist/ui/components/spinner/spinner.test.ts +67 -22
  190. package/dist/ui/components/spinner/spinner.ts +48 -41
  191. package/dist/ui/components/switch/switch.test.ts +116 -46
  192. package/dist/ui/components/switch/switch.ts +8 -13
  193. package/dist/ui/components/tab-bar/index.ts +1 -0
  194. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  195. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  196. package/dist/ui/components/tabs/tabs.test.ts +215 -157
  197. package/dist/ui/components/tabs/tabs.ts +110 -118
  198. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  199. package/dist/ui/components/textarea/textarea.ts +14 -12
  200. package/dist/ui/components/toast/toast.test.ts +436 -315
  201. package/dist/ui/components/toast/toast.ts +62 -55
  202. package/dist/ui/components/toggle/toggle.test.ts +247 -140
  203. package/dist/ui/theme/chart-colors.test.ts +35 -0
  204. package/dist/ui/theme/dark.css +46 -2
  205. package/dist/ui/theme/default.css +63 -7
  206. package/dist/ui/theme/icon-colors.test.ts +35 -0
  207. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  208. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  209. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  210. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  211. package/dist/ui/theme/tailwind-plugin.ts +98 -25
  212. package/dist/ui/theme/tailwind.css +113 -0
  213. package/dist/ui/utils/animate.test.ts +13 -1
  214. package/dist/utils/analyze.d.ts +14 -0
  215. package/dist/utils/analyze.js +15 -0
  216. package/dist/utils/analyze.js.map +1 -1
  217. package/dist/utils/analyze.test.js +14 -1
  218. package/dist/utils/analyze.test.js.map +1 -1
  219. package/dist/utils/installed.d.ts +29 -0
  220. package/dist/utils/installed.js +43 -0
  221. package/dist/utils/installed.js.map +1 -0
  222. package/dist/utils/installed.test.d.ts +1 -0
  223. package/dist/utils/installed.test.js +57 -0
  224. package/dist/utils/installed.test.js.map +1 -0
  225. package/dist/utils/interactive.d.ts +9 -0
  226. package/dist/utils/interactive.js +10 -0
  227. package/dist/utils/interactive.js.map +1 -0
  228. package/dist/utils/interactive.test.d.ts +1 -0
  229. package/dist/utils/interactive.test.js +24 -0
  230. package/dist/utils/interactive.test.js.map +1 -0
  231. package/dist/utils/packages.d.ts +36 -0
  232. package/dist/utils/packages.js +70 -0
  233. package/dist/utils/packages.js.map +1 -0
  234. package/dist/utils/packages.test.d.ts +1 -0
  235. package/dist/utils/packages.test.js +89 -0
  236. package/dist/utils/packages.test.js.map +1 -0
  237. package/dist/utils/resolve-paths.d.ts +17 -1
  238. package/dist/utils/resolve-paths.js +21 -3
  239. package/dist/utils/resolve-paths.js.map +1 -1
  240. package/dist/utils/resolve-paths.test.js +38 -2
  241. package/dist/utils/resolve-paths.test.js.map +1 -1
  242. package/dist/utils/tailwind.d.ts +25 -0
  243. package/dist/utils/tailwind.js +51 -0
  244. package/dist/utils/tailwind.js.map +1 -0
  245. package/dist/utils/tailwind.test.d.ts +1 -0
  246. package/dist/utils/tailwind.test.js +71 -0
  247. package/dist/utils/tailwind.test.js.map +1 -0
  248. package/package.json +13 -1
  249. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
  250. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
  251. package/dist/ui/components/button/button-render.test.ts +0 -113
  252. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  253. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
  254. package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
  255. package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
  256. package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
  257. package/dist/ui/components/cartesian-chart/index.ts +0 -18
  258. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
  259. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
@@ -0,0 +1,632 @@
1
+ import {
2
+ ApplicationRef,
3
+ Component,
4
+ type ElementRef,
5
+ type Provider,
6
+ signal,
7
+ viewChild,
8
+ } from '@angular/core';
9
+ import {
10
+ fireEvent,
11
+ render,
12
+ waitForUpdate,
13
+ } from '@blotch/angular-lynx-testing-library';
14
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
15
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
16
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
17
+ import { provideIconTheme } from '../icon/icon-theme';
18
+ import { type MenuElement, type MenuKind, UiMenuPopup } from './menu-popup';
19
+ import { type MenuRect } from './menu-placement';
20
+
21
+ @Component({
22
+ imports: [LYNX_ELEMENTS, UiMenuPopup],
23
+ template: `
24
+ <view
25
+ #home
26
+ class="home"
27
+ [style.width.px]="popup.placeholder()?.width"
28
+ [style.height.px]="popup.placeholder()?.height"
29
+ >
30
+ <view #anchor class="anchor"><text>Kyoto</text></view>
31
+ </view>
32
+ <ui-menu-popup
33
+ #popup
34
+ [open]="open()"
35
+ [kind]="kind()"
36
+ [anchor]="anchorView().nativeElement"
37
+ [anchorHome]="lifting() ? homeView().nativeElement : null"
38
+ (dismissed)="dismissed()"
39
+ (closed)="closed()"
40
+ >
41
+ <view class="item"><text>Share</text></view>
42
+ </ui-menu-popup>
43
+ `,
44
+ })
45
+ class PopupHost {
46
+ readonly open = signal(false);
47
+ readonly kind = signal<MenuKind>('pull-down');
48
+ readonly lifting = signal(false);
49
+ readonly anchorView = viewChild.required<ElementRef<MenuElement>>('anchor');
50
+ readonly homeView = viewChild.required<ElementRef<MenuElement>>('home');
51
+ readonly dismissed = vi.fn();
52
+ readonly closed = vi.fn();
53
+ }
54
+
55
+ /**
56
+ * The Lynx function that runs an element's UI methods, such as measuring it
57
+ * (`boundingClientRect`). The testing library defines it on the global
58
+ * object, and answers without a box.
59
+ */
60
+ type InvokeUIMethod = {
61
+ __InvokeUIMethod: (
62
+ element: Element,
63
+ method: string,
64
+ params: Record<string, unknown>,
65
+ callback: (result: { code: number; data?: unknown }) => void,
66
+ ) => void;
67
+ };
68
+
69
+ /** The safe area of a phone, on screen. */
70
+ const AREA: MenuRect = { left: 0, top: 62, width: 402, height: 778 };
71
+ /** The menu's content, at rest. */
72
+ const CONTENT_HEIGHT = 120;
73
+
74
+ /** Accessibility settings a test can change, as the host app would. */
75
+ const settings = {
76
+ reduceMotion: signal(false),
77
+ reduceTransparency: signal(false),
78
+ increaseContrast: signal(false),
79
+ };
80
+
81
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
82
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
83
+
84
+ /**
85
+ * Renders the host on a platform, with the elements answering
86
+ * `boundingClientRect` as on a phone: the anchor at `anchorRect`, the safe
87
+ * area at AREA, and the menu CONTENT_HEIGHT tall. `held` keeps the answers
88
+ * for the elements it picks until `release()`.
89
+ */
90
+ const renderPopup = async ({
91
+ platform = IOS_26,
92
+ anchorRect = { left: 16, top: 162, width: 370, height: 96 },
93
+ area = AREA,
94
+ contentHeight = CONTENT_HEIGHT,
95
+ providers = [],
96
+ }: {
97
+ area?: MenuRect;
98
+ contentHeight?: number;
99
+ /** `SystemInfo`, or null for none, as in a test, where design is Material. */
100
+ platform?: { platform: string; osVersion: string } | null;
101
+ anchorRect?: MenuRect;
102
+ providers?: Provider[];
103
+ } = {}) => {
104
+ vi.stubGlobal('SystemInfo', platform ?? undefined);
105
+ const pending: (() => void)[] = [];
106
+ const held = { anchor: false, menu: false, measure: true };
107
+ const invoke = vi
108
+ .spyOn(globalThis as unknown as InvokeUIMethod, '__InvokeUIMethod')
109
+ .mockImplementation((element, method, _params, callback) => {
110
+ if (method !== 'boundingClientRect') {
111
+ callback({ code: 0 });
112
+ return;
113
+ }
114
+ const isAnchor = element.classList.contains('anchor');
115
+ const box = !held.measure
116
+ ? undefined
117
+ : isAnchor
118
+ ? anchorRect
119
+ : element.parentElement?.tagName.toLowerCase() === 'scroll-view'
120
+ ? { left: 0, top: 0, width: 250, height: contentHeight }
121
+ : area;
122
+ const answer = () => callback({ code: 0, data: box });
123
+ if (isAnchor ? held.anchor : held.menu) pending.push(answer);
124
+ else answer();
125
+ });
126
+ const result = await render(PopupHost, {
127
+ providers: [
128
+ { provide: LynxAccessibilitySettings, useValue: settings },
129
+ ...providers,
130
+ ],
131
+ });
132
+ const host = result.componentRef.instance as PopupHost;
133
+ const appRef = result.componentRef.injector.get(ApplicationRef);
134
+ const query = (selector: string) =>
135
+ result.container.querySelector(selector) as HTMLElement | null;
136
+ return {
137
+ ...result,
138
+ host,
139
+ invoke,
140
+ held,
141
+ /** Answers the measurements `held` kept back. */
142
+ release: () => {
143
+ for (const answer of pending.splice(0)) answer();
144
+ },
145
+ /** Runs change detection and effects now. */
146
+ tick: () => appRef.tick(),
147
+ overlay: () => query('overlay')!,
148
+ backdrop: () => query('overlay > view')!,
149
+ panel: () => query('overlay blur-view[ui-glass]')!,
150
+ scroller: () => query('overlay scroll-view')!,
151
+ slot: () =>
152
+ query('overlay blur-view[ui-glass]')!
153
+ .previousElementSibling as HTMLElement,
154
+ anchor: () => query('.anchor')!,
155
+ home: () => query('.home')!,
156
+ };
157
+ };
158
+
159
+ type Rendered = Awaited<ReturnType<typeof renderPopup>>;
160
+
161
+ const style = (element: Element, name: string) =>
162
+ (element as HTMLElement).style.getPropertyValue(name);
163
+
164
+ /**
165
+ * The box an element's inline style puts it in.
166
+ */
167
+ const box = (element: Element) => ({
168
+ left: parseFloat(style(element, 'left')),
169
+ top: parseFloat(style(element, 'top')),
170
+ width: parseFloat(style(element, 'width')),
171
+ height: parseFloat(style(element, 'height')),
172
+ });
173
+
174
+ /**
175
+ * Opens the menu and waits until it has grown to its frame.
176
+ */
177
+ const openMenu = async (rendered: Rendered) => {
178
+ rendered.host.open.set(true);
179
+ await vi.waitFor(() =>
180
+ expect(rendered.panel().getAttribute('accessibility-exclusive-focus')).toBe(
181
+ 'true',
182
+ ),
183
+ );
184
+ };
185
+
186
+ /**
187
+ * Closes the menu and waits for its close animation to finish.
188
+ */
189
+ const closeMenu = async (rendered: Rendered) => {
190
+ rendered.host.open.set(false);
191
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
192
+ await waitForUpdate();
193
+ };
194
+
195
+ /**
196
+ * Lets every pending promise callback run.
197
+ */
198
+ const settle = async () => {
199
+ for (let i = 0; i < 10; i++) await Promise.resolve();
200
+ };
201
+
202
+ // The renderer warns that <ui-menu-popup> isn't a Lynx element.
203
+ beforeEach(() => {
204
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
205
+ });
206
+
207
+ afterEach(() => {
208
+ vi.restoreAllMocks();
209
+ vi.unstubAllGlobals();
210
+ settings.reduceMotion.set(false);
211
+ });
212
+
213
+ describe('UiMenuPopup', () => {
214
+ it('is hidden until opened', async () => {
215
+ const { overlay, invoke } = await renderPopup();
216
+
217
+ expect(overlay().getAttribute('visible')).toBe('false');
218
+ expect(style(overlay(), 'display')).toBe('none');
219
+ expect(invoke).not.toHaveBeenCalled();
220
+ });
221
+
222
+ it('grows a pull-down menu out of its anchor, as one glass shape', async () => {
223
+ const rendered = await renderPopup();
224
+ const { host, overlay, panel, scroller } = rendered;
225
+
226
+ rendered.held.menu = true;
227
+ host.open.set(true);
228
+ // Measured off screen at its natural size first.
229
+ await vi.waitFor(() =>
230
+ expect(overlay().getAttribute('visible')).toBe('true'),
231
+ );
232
+ expect(style(panel(), 'left')).toBe('-10000px');
233
+ expect(style(panel(), 'width')).toBe('');
234
+ expect(style(panel(), 'transition')).toBe('none');
235
+ rendered.release();
236
+
237
+ // ...and grows to the menu, with a spring, its items fading in.
238
+ await openMenu(rendered);
239
+ expect(box(panel())).toEqual({
240
+ left: 16,
241
+ top: 100,
242
+ width: 250,
243
+ height: 120,
244
+ });
245
+ expect(style(panel(), 'transition')).toContain('width 350ms');
246
+ expect(style(scroller(), 'opacity')).toBe('1');
247
+ expect(style(scroller(), 'height')).toBe('120px');
248
+ expect(style(panel(), 'opacity')).toBe('1');
249
+ expect(style(panel(), 'justify-content')).toBe('flex-start');
250
+ expect(style(panel(), 'align-items')).toBe('flex-start');
251
+ expect(panel().getAttribute('blur-effect')).toBe('glass');
252
+ expect(panel().getAttribute('accessibility-exclusive-focus')).toBe('true');
253
+ // A pull-down menu doesn't dim the screen or move its anchor.
254
+ expect(overlay().querySelector('.bg-glass-dim')).toBeNull();
255
+ expect(rendered.home().contains(rendered.anchor())).toBe(true);
256
+
257
+ await closeMenu(rendered);
258
+ expect(overlay().getAttribute('visible')).toBe('false');
259
+ expect(host.closed).toHaveBeenCalledTimes(1);
260
+ });
261
+
262
+ it('moves a screen reader into the menu once it opens', async () => {
263
+ const rendered = await renderPopup();
264
+
265
+ await openMenu(rendered);
266
+
267
+ expect(rendered.invoke).toHaveBeenCalledWith(
268
+ rendered.panel(),
269
+ 'requestAccessibilityFocus',
270
+ expect.anything(),
271
+ expect.any(Function),
272
+ );
273
+ });
274
+
275
+ it('shrinks back into its anchor as it closes', async () => {
276
+ const rendered = await renderPopup();
277
+ await openMenu(rendered);
278
+
279
+ rendered.host.open.set(false);
280
+ await waitForUpdate();
281
+
282
+ expect(box(rendered.panel())).toEqual({
283
+ left: 16,
284
+ top: 100,
285
+ width: 370,
286
+ height: 96,
287
+ });
288
+ expect(style(rendered.panel(), 'transition')).toContain('width 250ms');
289
+ expect(style(rendered.scroller(), 'opacity')).toBe('0');
290
+ expect(rendered.host.closed).not.toHaveBeenCalled();
291
+ await closeMenu(rendered);
292
+ });
293
+
294
+ it('opens toward the middle of the screen from an anchor in a corner', async () => {
295
+ const rendered = await renderPopup({
296
+ anchorRect: { left: 340, top: 790, width: 44, height: 44 },
297
+ });
298
+
299
+ await openMenu(rendered);
300
+
301
+ // Lined up with the anchor's right edge, and standing on its bottom edge,
302
+ // inside the margin at the bottom of the safe area.
303
+ expect(box(rendered.panel())).toEqual({
304
+ left: 384 - 250,
305
+ top: 778 - 8 - 120,
306
+ width: 250,
307
+ height: 120,
308
+ });
309
+ expect(style(rendered.panel(), 'justify-content')).toBe('flex-end');
310
+ expect(style(rendered.panel(), 'align-items')).toBe('flex-end');
311
+ });
312
+
313
+ it('reports a tap outside the menu, and Android back, as dismissing it', async () => {
314
+ const rendered = await renderPopup();
315
+ await openMenu(rendered);
316
+
317
+ fireEvent.tap(rendered.panel(), { eventType: 'catchEvent' });
318
+ expect(rendered.host.dismissed).not.toHaveBeenCalled();
319
+
320
+ fireEvent.tap(rendered.backdrop());
321
+ fireLynxEvent(rendered.overlay(), 'requestclose');
322
+ expect(rendered.host.dismissed).toHaveBeenCalledTimes(2);
323
+ });
324
+
325
+ it("measures in the app's pixels where the app is drawn scaled", async () => {
326
+ /**
327
+ * A box drawn at half its size, as a docs preview might draw the app:
328
+ * boundingClientRect gives boxes like that, while the safe area's layout
329
+ * gives its real width.
330
+ */
331
+ const half = (box: MenuRect): MenuRect => ({
332
+ left: box.left / 2,
333
+ top: box.top / 2,
334
+ width: box.width / 2,
335
+ height: box.height / 2,
336
+ });
337
+ const rendered = await renderPopup({
338
+ anchorRect: half({ left: 16, top: 162, width: 370, height: 96 }),
339
+ area: half(AREA),
340
+ contentHeight: CONTENT_HEIGHT / 2,
341
+ });
342
+ const area = rendered.panel().parentElement!;
343
+ fireLynxEvent(area, 'layoutchange', { detail: { width: AREA.width } });
344
+
345
+ await openMenu(rendered);
346
+
347
+ expect(box(rendered.panel())).toEqual({
348
+ left: 16,
349
+ top: 100,
350
+ width: 250,
351
+ height: 120,
352
+ });
353
+ });
354
+
355
+ it('takes a layout without a size as unknown', async () => {
356
+ const rendered = await renderPopup();
357
+ fireLynxEvent(rendered.panel().parentElement!, 'layoutchange');
358
+
359
+ await openMenu(rendered);
360
+
361
+ expect(box(rendered.panel()).width).toBe(250);
362
+ });
363
+
364
+ it('opens even where the elements answer without a box', async () => {
365
+ const rendered = await renderPopup();
366
+ rendered.held.measure = false;
367
+
368
+ await openMenu(rendered);
369
+
370
+ // Nothing to place it by: it opens at the area's top-left corner.
371
+ expect(box(rendered.panel())).toEqual({
372
+ left: 8,
373
+ top: 8,
374
+ width: 0,
375
+ height: 0,
376
+ });
377
+ });
378
+
379
+ describe('as a context menu', () => {
380
+ const renderContext = async (
381
+ options: Parameters<typeof renderPopup>[0] = {},
382
+ ) => {
383
+ const rendered = await renderPopup(options);
384
+ rendered.host.kind.set('context');
385
+ rendered.host.lifting.set(true);
386
+ await waitForUpdate();
387
+ return rendered;
388
+ };
389
+
390
+ it('lifts the element above a dimming layer and opens beside it', async () => {
391
+ const rendered = await renderContext();
392
+ const { host, overlay, panel, slot } = rendered;
393
+
394
+ rendered.held.menu = true;
395
+ host.open.set(true);
396
+ await vi.waitFor(() =>
397
+ expect(overlay().getAttribute('visible')).toBe('true'),
398
+ );
399
+ expect(slot().children).toHaveLength(0);
400
+ rendered.release();
401
+ await settle();
402
+ rendered.tick();
403
+ // The element moves into the overlay where it was, and its home keeps
404
+ // its size; the menu starts small, in the corner nearest it.
405
+ expect(slot().contains(rendered.anchor())).toBe(true);
406
+ expect(box(slot())).toEqual({
407
+ left: 16,
408
+ top: 100,
409
+ width: 370,
410
+ height: NaN,
411
+ });
412
+ expect(style(rendered.home(), 'width')).toBe('370px');
413
+ expect(style(rendered.home(), 'height')).toBe('96px');
414
+ expect(box(panel())).toEqual({
415
+ left: 16,
416
+ top: 204,
417
+ width: 75,
418
+ height: 36,
419
+ });
420
+
421
+ await openMenu(rendered);
422
+ expect(box(panel())).toEqual({
423
+ left: 16,
424
+ top: 204,
425
+ width: 250,
426
+ height: 120,
427
+ });
428
+ const dim = overlay().querySelector('.bg-glass-dim')!;
429
+ expect(style(dim, 'opacity')).toBe('1');
430
+ expect(style(dim, 'transition')).toContain('opacity 250ms');
431
+ expect(style(slot(), 'transform')).toBe('scale(1.03)');
432
+ expect(style(slot(), 'transition')).toContain('transform 350ms');
433
+ // iOS 26 dims; it doesn't blur.
434
+ expect(overlay().querySelector('blur-view:not([ui-glass])')).toBeNull();
435
+
436
+ await closeMenu(rendered);
437
+ // The element is back where it was, and its home sizes to it again.
438
+ expect(rendered.home().querySelector('.anchor')?.textContent).toBe(
439
+ 'Kyoto',
440
+ );
441
+ expect(slot().children).toHaveLength(0);
442
+ expect(style(rendered.home(), 'height')).toBe('');
443
+ });
444
+
445
+ it('moves the lifted element up when the menu fits on neither side', async () => {
446
+ const rendered = await renderContext({
447
+ anchorRect: { left: 16, top: 162, width: 370, height: 600 },
448
+ });
449
+
450
+ await openMenu(rendered);
451
+
452
+ // 778 - 8 - 120 - 8 - 600: the element rises to make room below it.
453
+ expect(style(rendered.slot(), 'top')).toBe('42px');
454
+ expect(box(rendered.panel()).top).toBe(42 + 600 + 8);
455
+ });
456
+
457
+ it('blurs the screen behind it before iOS 26, in the app color scheme', async () => {
458
+ const light = await renderContext({ platform: IOS_18 });
459
+ await openMenu(light);
460
+
461
+ const blur = light.overlay().querySelector('blur-view:not([ui-glass])')!;
462
+ expect(blur.getAttribute('blur-effect')).toBe('light');
463
+ expect(style(blur, 'opacity')).toBe('1');
464
+ light.unmount();
465
+
466
+ const dark = await renderContext({
467
+ platform: IOS_18,
468
+ providers: [provideIconTheme({ colorScheme: 'dark' })],
469
+ });
470
+ expect(
471
+ dark
472
+ .overlay()
473
+ .querySelector('blur-view:not([ui-glass])')!
474
+ .getAttribute('blur-effect'),
475
+ ).toBe('dark');
476
+ });
477
+
478
+ it('opens a Material menu beside the element, without lifting or dimming it', async () => {
479
+ const rendered = await renderContext({ platform: null });
480
+ const { host, overlay, panel } = rendered;
481
+
482
+ // It unfolds and fades in...
483
+ await openMenu(rendered);
484
+ expect(box(panel())).toEqual({
485
+ left: 16,
486
+ top: 204,
487
+ width: 224,
488
+ height: 120,
489
+ });
490
+ expect(style(panel(), 'opacity')).toBe('1');
491
+
492
+ // ...and folds back into a sliver along the edge nearest the element,
493
+ // fading out.
494
+ host.open.set(false);
495
+ await waitForUpdate();
496
+ expect(box(panel())).toEqual({
497
+ left: 16,
498
+ top: 204,
499
+ width: 224,
500
+ height: 0,
501
+ });
502
+ expect(style(panel(), 'opacity')).toBe('0');
503
+ expect(overlay().querySelector('.bg-glass-dim')).toBeNull();
504
+ expect(rendered.home().contains(rendered.anchor())).toBe(true);
505
+ expect(style(rendered.home(), 'height')).toBe('');
506
+ });
507
+
508
+ it('follows Reduce Motion: nothing grows, moves or lifts, and only fades play', async () => {
509
+ settings.reduceMotion.set(true);
510
+ const rendered = await renderContext({
511
+ anchorRect: { left: 16, top: 162, width: 370, height: 600 },
512
+ });
513
+ const { host, panel, slot } = rendered;
514
+
515
+ host.open.set(true);
516
+ await vi.waitFor(() => expect(style(panel(), 'width')).toBe('250px'));
517
+ // At its frame from the start, and the element already where it goes.
518
+ expect(box(panel()).height).toBe(120);
519
+ expect(style(slot(), 'top')).toBe('42px');
520
+
521
+ await openMenu(rendered);
522
+ expect(style(slot(), 'transform')).toBe('scale(1)');
523
+ expect(style(slot(), 'transition')).toBe('none');
524
+ expect(style(panel(), 'transition')).toBe(
525
+ 'opacity 350ms cubic-bezier(0.22, 1.2, 0.36, 1)',
526
+ );
527
+ });
528
+ });
529
+
530
+ describe('when it closes before it has opened', () => {
531
+ it('stops before the anchor is measured', async () => {
532
+ const rendered = await renderPopup();
533
+ rendered.held.anchor = true;
534
+
535
+ rendered.host.open.set(true);
536
+ await waitForUpdate();
537
+ rendered.host.open.set(false);
538
+ await waitForUpdate();
539
+ rendered.release();
540
+ await settle();
541
+ await new Promise((resolve) => setTimeout(resolve, 80));
542
+
543
+ expect(rendered.overlay().getAttribute('visible')).toBe('false');
544
+ expect(rendered.host.closed).not.toHaveBeenCalled();
545
+ });
546
+
547
+ it('stops while the overlay lays out, and reports it closed', async () => {
548
+ const rendered = await renderPopup();
549
+ rendered.held.anchor = true;
550
+
551
+ rendered.host.open.set(true);
552
+ await waitForUpdate();
553
+ rendered.release();
554
+ await settle();
555
+ // The overlay shows, to lay the menu out; closing now hides it again.
556
+ rendered.tick();
557
+ expect(rendered.overlay().getAttribute('visible')).toBe('true');
558
+ rendered.host.open.set(false);
559
+ rendered.tick();
560
+ await new Promise((resolve) => setTimeout(resolve, 80));
561
+
562
+ expect(rendered.overlay().getAttribute('visible')).toBe('false');
563
+ expect(rendered.host.closed).toHaveBeenCalledTimes(1);
564
+ });
565
+
566
+ it('stops while the menu is measured', async () => {
567
+ const rendered = await renderPopup();
568
+ rendered.held.menu = true;
569
+
570
+ rendered.host.open.set(true);
571
+ await vi.waitFor(() =>
572
+ expect(rendered.overlay().getAttribute('visible')).toBe('true'),
573
+ );
574
+ await new Promise((resolve) => setTimeout(resolve, 50));
575
+ rendered.host.open.set(false);
576
+ rendered.tick();
577
+ rendered.release();
578
+ await settle();
579
+ await waitForUpdate();
580
+
581
+ expect(rendered.overlay().getAttribute('visible')).toBe('false');
582
+ expect(style(rendered.panel(), 'left')).toBe('-10000px');
583
+ });
584
+
585
+ it('closes from where it started growing', async () => {
586
+ const rendered = await renderPopup();
587
+ rendered.held.menu = true;
588
+
589
+ rendered.host.open.set(true);
590
+ await vi.waitFor(() =>
591
+ expect(rendered.overlay().getAttribute('visible')).toBe('true'),
592
+ );
593
+ await new Promise((resolve) => setTimeout(resolve, 50));
594
+ rendered.release();
595
+ await settle();
596
+ // At its start frame now; closing doesn't let it finish growing.
597
+ rendered.host.open.set(false);
598
+ rendered.tick();
599
+
600
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
601
+ expect(
602
+ rendered.panel().getAttribute('accessibility-exclusive-focus'),
603
+ ).toBe('false');
604
+ });
605
+ });
606
+
607
+ it('opens again at once when reopened while closing', async () => {
608
+ const rendered = await renderPopup();
609
+ await openMenu(rendered);
610
+
611
+ rendered.host.open.set(false);
612
+ await waitForUpdate();
613
+ await openMenu(rendered);
614
+ await new Promise((resolve) => setTimeout(resolve, 300));
615
+
616
+ // The interrupted close never finished, so never reported.
617
+ expect(rendered.host.closed).not.toHaveBeenCalled();
618
+ expect(rendered.overlay().getAttribute('visible')).toBe('true');
619
+ });
620
+
621
+ it('stops its timers when destroyed while closing', async () => {
622
+ const rendered = await renderPopup();
623
+ await openMenu(rendered);
624
+
625
+ rendered.host.open.set(false);
626
+ await waitForUpdate();
627
+ rendered.unmount();
628
+ await new Promise((resolve) => setTimeout(resolve, 300));
629
+
630
+ expect(rendered.host.closed).not.toHaveBeenCalled();
631
+ });
632
+ });