@blotch/dolan 22.2.3 → 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 (227) hide show
  1. package/dist/commands/add.js +9 -1
  2. package/dist/commands/add.js.map +1 -1
  3. package/dist/commands/add.test.js +38 -3
  4. package/dist/commands/add.test.js.map +1 -1
  5. package/dist/commands/doctor.js +258 -21
  6. package/dist/commands/doctor.js.map +1 -1
  7. package/dist/commands/doctor.test.js +425 -11
  8. package/dist/commands/doctor.test.js.map +1 -1
  9. package/dist/commands/init.d.ts +2 -2
  10. package/dist/commands/init.js +38 -18
  11. package/dist/commands/init.js.map +1 -1
  12. package/dist/commands/init.test.js +105 -2
  13. package/dist/commands/init.test.js.map +1 -1
  14. package/dist/commands/theme.js +1 -1
  15. package/dist/commands/theme.js.map +1 -1
  16. package/dist/commands/update.d.ts +7 -0
  17. package/dist/commands/update.js +194 -50
  18. package/dist/commands/update.js.map +1 -1
  19. package/dist/commands/update.test.js +570 -3
  20. package/dist/commands/update.test.js.map +1 -1
  21. package/dist/lockfile.d.ts +17 -0
  22. package/dist/lockfile.js +56 -1
  23. package/dist/lockfile.js.map +1 -1
  24. package/dist/lockfile.test.d.ts +1 -0
  25. package/dist/lockfile.test.js +65 -0
  26. package/dist/lockfile.test.js.map +1 -0
  27. package/dist/registry.d.ts +6 -0
  28. package/dist/registry.js +119 -11
  29. package/dist/registry.js.map +1 -1
  30. package/dist/registry.test.js +61 -6
  31. package/dist/registry.test.js.map +1 -1
  32. package/dist/test-utils.d.ts +27 -0
  33. package/dist/test-utils.js +45 -1
  34. package/dist/test-utils.js.map +1 -1
  35. package/dist/testing/fire-lynx-event.d.ts +14 -0
  36. package/dist/testing/fire-lynx-event.js +19 -0
  37. package/dist/testing/fire-lynx-event.js.map +1 -0
  38. package/dist/testing/fire-lynx-event.test.d.ts +1 -0
  39. package/dist/testing/fire-lynx-event.test.js +44 -0
  40. package/dist/testing/fire-lynx-event.test.js.map +1 -0
  41. package/dist/testing/lynx-main-thread.d.ts +22 -0
  42. package/dist/testing/lynx-main-thread.js +11 -0
  43. package/dist/testing/lynx-main-thread.js.map +1 -0
  44. package/dist/testing/lynx-main-thread.test.d.ts +1 -0
  45. package/dist/testing/lynx-main-thread.test.js +24 -0
  46. package/dist/testing/lynx-main-thread.test.js.map +1 -0
  47. package/dist/testing/set-input-signal.d.ts +5 -4
  48. package/dist/testing/set-input-signal.js +5 -4
  49. package/dist/testing/set-input-signal.js.map +1 -1
  50. package/dist/ui/boolean-inputs.test.ts +76 -0
  51. package/dist/ui/components/accordion/accordion.test.ts +158 -144
  52. package/dist/ui/components/accordion/accordion.ts +19 -14
  53. package/dist/ui/components/action-sheet/action-sheet.test.ts +637 -183
  54. package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
  55. package/dist/ui/components/action-sheet/index.ts +1 -0
  56. package/dist/ui/components/alert/alert.test.ts +67 -19
  57. package/dist/ui/components/alert/alert.ts +14 -16
  58. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +740 -129
  59. package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
  60. package/dist/ui/components/alert-dialog/index.ts +5 -0
  61. package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
  62. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  63. package/dist/ui/components/avatar/avatar.test.ts +89 -45
  64. package/dist/ui/components/badge/badge.test.ts +57 -23
  65. package/dist/ui/components/badge/badge.ts +14 -15
  66. package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
  67. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
  68. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
  69. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  70. package/dist/ui/components/button/button.test.ts +646 -2
  71. package/dist/ui/components/button/button.ts +443 -64
  72. package/dist/ui/components/button/index.ts +2 -0
  73. package/dist/ui/components/button-group/button-group.test.ts +528 -2
  74. package/dist/ui/components/button-group/button-group.ts +428 -70
  75. package/dist/ui/components/button-group/index.ts +3 -0
  76. package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
  77. package/dist/ui/components/card/card.test.ts +97 -61
  78. package/dist/ui/components/card/card.ts +2 -1
  79. package/dist/ui/components/chart/chart-presets.test.ts +197 -1
  80. package/dist/ui/components/chart/chart-presets.ts +14 -7
  81. package/dist/ui/components/chart/chart-theme.ts +12 -0
  82. package/dist/ui/components/chart/chart.test.ts +1031 -0
  83. package/dist/ui/components/chart/chart.ts +66 -46
  84. package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
  85. package/dist/ui/components/checkbox/checkbox.ts +25 -24
  86. package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
  87. package/dist/ui/components/collapsible/collapsible.ts +21 -15
  88. package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
  89. package/dist/ui/components/context-menu/context-menu.ts +110 -0
  90. package/dist/ui/components/context-menu/index.ts +1 -0
  91. package/dist/ui/components/dialog/dialog.test.ts +210 -152
  92. package/dist/ui/components/dialog/dialog.ts +35 -29
  93. package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
  94. package/dist/ui/components/dock/dock-scroll.ts +142 -0
  95. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  96. package/dist/ui/components/dock/dock.test.ts +752 -0
  97. package/dist/ui/components/dock/dock.ts +1438 -0
  98. package/dist/ui/components/dock/index.ts +2 -0
  99. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  100. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  101. package/dist/ui/components/dynamic-color/index.ts +5 -0
  102. package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
  103. package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
  104. package/dist/ui/components/glass/concentric-radius.ts +17 -0
  105. package/dist/ui/components/glass/glass-design.test.ts +78 -0
  106. package/dist/ui/components/glass/glass-design.ts +119 -0
  107. package/dist/ui/components/glass/glass.test.ts +376 -0
  108. package/dist/ui/components/glass/glass.ts +430 -0
  109. package/dist/ui/components/glass/index.ts +19 -0
  110. package/dist/ui/components/icon/icon-theme.test.ts +64 -0
  111. package/dist/ui/components/icon/icon-theme.ts +70 -16
  112. package/dist/ui/components/icon/icon.test.ts +52 -41
  113. package/dist/ui/components/input/input.test.ts +178 -101
  114. package/dist/ui/components/input/input.ts +17 -13
  115. package/dist/ui/components/label/label.test.ts +11 -24
  116. package/dist/ui/components/label/label.ts +8 -2
  117. package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
  118. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  119. package/dist/ui/components/list/list.test.ts +80 -49
  120. package/dist/ui/components/list/list.ts +5 -4
  121. package/dist/ui/components/menu/index.ts +25 -0
  122. package/dist/ui/components/menu/menu-placement.test.ts +153 -0
  123. package/dist/ui/components/menu/menu-placement.ts +159 -0
  124. package/dist/ui/components/menu/menu-popup.test.ts +636 -0
  125. package/dist/ui/components/menu/menu-popup.ts +565 -0
  126. package/dist/ui/components/menu/menu.test.ts +523 -0
  127. package/dist/ui/components/menu/menu.ts +455 -0
  128. package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
  129. package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
  130. package/dist/ui/components/navigation-bar/index.ts +1 -0
  131. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  132. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  133. package/dist/ui/components/pagination/pagination.test.ts +136 -86
  134. package/dist/ui/components/popover/index.ts +9 -0
  135. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  136. package/dist/ui/components/popover/popover-panel.ts +603 -0
  137. package/dist/ui/components/popover/popover.test.ts +286 -0
  138. package/dist/ui/components/popover/popover.ts +207 -0
  139. package/dist/ui/components/progress/progress.test.ts +102 -46
  140. package/dist/ui/components/progress/progress.ts +54 -55
  141. package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
  142. package/dist/ui/components/radio-group/radio-group.ts +26 -25
  143. package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
  144. package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
  145. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  146. package/dist/ui/components/search-field/index.ts +15 -0
  147. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  148. package/dist/ui/components/search-field/keyboard.ts +123 -0
  149. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  150. package/dist/ui/components/search-field/search-field.ts +628 -0
  151. package/dist/ui/components/segmented-control/index.ts +8 -0
  152. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  153. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  154. package/dist/ui/components/select/select.test.ts +138 -78
  155. package/dist/ui/components/select/select.ts +19 -12
  156. package/dist/ui/components/separator/separator.test.ts +18 -17
  157. package/dist/ui/components/sheet/sheet.test.ts +151 -11
  158. package/dist/ui/components/sheet/sheet.ts +68 -5
  159. package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
  160. package/dist/ui/components/skeleton/skeleton.ts +10 -16
  161. package/dist/ui/components/slider/index.ts +1 -0
  162. package/dist/ui/components/slider/slider.test.ts +695 -0
  163. package/dist/ui/components/slider/slider.ts +901 -0
  164. package/dist/ui/components/spinner/spinner.test.ts +64 -46
  165. package/dist/ui/components/spinner/spinner.ts +16 -19
  166. package/dist/ui/components/switch/index.ts +1 -1
  167. package/dist/ui/components/switch/switch.test.ts +534 -48
  168. package/dist/ui/components/switch/switch.ts +613 -48
  169. package/dist/ui/components/tab-bar/index.ts +1 -0
  170. package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
  171. package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
  172. package/dist/ui/components/tabs/index.ts +7 -1
  173. package/dist/ui/components/tabs/tabs.test.ts +484 -150
  174. package/dist/ui/components/tabs/tabs.ts +344 -149
  175. package/dist/ui/components/textarea/textarea.test.ts +206 -100
  176. package/dist/ui/components/textarea/textarea.ts +16 -13
  177. package/dist/ui/components/toast/toast.test.ts +434 -350
  178. package/dist/ui/components/toast/toast.ts +42 -51
  179. package/dist/ui/components/toggle/index.ts +1 -0
  180. package/dist/ui/components/toggle/toggle.test.ts +559 -146
  181. package/dist/ui/components/toggle/toggle.ts +241 -26
  182. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  183. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  184. package/dist/ui/components/toolbar/index.ts +12 -0
  185. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  186. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  187. package/dist/ui/theme/chart-colors.test.ts +8 -3
  188. package/dist/ui/theme/dark.css +38 -2
  189. package/dist/ui/theme/default.css +55 -7
  190. package/dist/ui/theme/icon-colors.test.ts +8 -3
  191. package/dist/ui/theme/rgb-channels.test.ts +84 -0
  192. package/dist/ui/theme/tailwind-build.test.ts +114 -0
  193. package/dist/ui/theme/tailwind-css.test.ts +83 -0
  194. package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
  195. package/dist/ui/theme/tailwind-plugin.ts +93 -29
  196. package/dist/ui/theme/tailwind.css +113 -0
  197. package/dist/ui/utils/animate.test.ts +13 -1
  198. package/dist/utils/analyze.d.ts +19 -0
  199. package/dist/utils/analyze.js +16 -0
  200. package/dist/utils/analyze.js.map +1 -1
  201. package/dist/utils/analyze.test.js +14 -1
  202. package/dist/utils/analyze.test.js.map +1 -1
  203. package/dist/utils/installed.d.ts +25 -1
  204. package/dist/utils/installed.js +33 -0
  205. package/dist/utils/installed.js.map +1 -1
  206. package/dist/utils/installed.test.js +55 -1
  207. package/dist/utils/installed.test.js.map +1 -1
  208. package/dist/utils/resolve-paths.d.ts +4 -4
  209. package/dist/utils/resolve-paths.js +5 -7
  210. package/dist/utils/resolve-paths.js.map +1 -1
  211. package/dist/utils/resolve-paths.test.js +9 -5
  212. package/dist/utils/resolve-paths.test.js.map +1 -1
  213. package/dist/utils/tailwind.d.ts +45 -0
  214. package/dist/utils/tailwind.js +90 -0
  215. package/dist/utils/tailwind.js.map +1 -0
  216. package/dist/utils/tailwind.test.d.ts +1 -0
  217. package/dist/utils/tailwind.test.js +134 -0
  218. package/dist/utils/tailwind.test.js.map +1 -0
  219. package/package.json +9 -1
  220. package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
  221. package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
  222. package/dist/ui/components/button/button-render.test.ts +0 -132
  223. package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
  224. package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -59
  225. package/dist/ui/components/chart/chart-render.test.ts +0 -730
  226. package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
  227. package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
@@ -0,0 +1,523 @@
1
+ import { Component, signal } from '@angular/core';
2
+ import {
3
+ fireEvent,
4
+ render,
5
+ waitForUpdate,
6
+ within,
7
+ } from '@blotch/angular-lynx-testing-library';
8
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { fireLynxEvent } from '../../../test-utils/fire-lynx-event';
11
+ import { UiGlass } from '../glass/glass';
12
+ import {
13
+ UiMenu,
14
+ UiMenuItem,
15
+ UiMenuSection,
16
+ UiMenuSeparator,
17
+ UiMenuTrigger,
18
+ } from './menu';
19
+
20
+ @Component({
21
+ imports: [
22
+ LYNX_ELEMENTS,
23
+ UiGlass,
24
+ UiMenu,
25
+ UiMenuTrigger,
26
+ UiMenuItem,
27
+ UiMenuSection,
28
+ UiMenuSeparator,
29
+ ],
30
+ template: `
31
+ <ui-menu
32
+ [open]="open()"
33
+ (openChange)="open.set($event)"
34
+ (closed)="closed()"
35
+ >
36
+ @if (glassTrigger()) {
37
+ <blur-view
38
+ ui-glass
39
+ uiMenuTrigger
40
+ class="trigger"
41
+ [accessibility-element]="true"
42
+ accessibility-traits="button"
43
+ accessibility-label="Sort"
44
+ >
45
+ <text>Sort</text>
46
+ </blur-view>
47
+ } @else if (withTrigger()) {
48
+ <view
49
+ uiMenuTrigger
50
+ class="trigger"
51
+ [accessibility-element]="true"
52
+ accessibility-traits="button"
53
+ accessibility-label="Sort"
54
+ >
55
+ <text>Sort</text>
56
+ </view>
57
+ }
58
+ <ui-menu-section title="Sort by">
59
+ <ui-menu-item
60
+ label="Name"
61
+ [checked]="sort() === 'name'"
62
+ (pressed)="sort.set('name')"
63
+ />
64
+ <ui-menu-item
65
+ label="Date"
66
+ [checked]="sort() === 'date'"
67
+ (pressed)="sort.set('date')"
68
+ />
69
+ </ui-menu-section>
70
+ <ui-menu-separator />
71
+ <ui-menu-section>
72
+ <ui-menu-item label="Share" icon="upload" (pressed)="shared()" />
73
+ <ui-menu-item
74
+ label="Delete"
75
+ icon="trash"
76
+ variant="destructive"
77
+ (pressed)="deleted()"
78
+ />
79
+ <ui-menu-item
80
+ label="Archive"
81
+ accessibilityLabel="Archive, unavailable offline"
82
+ disabled
83
+ (pressed)="archived()"
84
+ />
85
+ <ui-menu-item label="Bold" checked />
86
+ <ui-menu-item label="Pin" [checked]="pinned()" />
87
+ </ui-menu-section>
88
+ </ui-menu>
89
+ `,
90
+ })
91
+ class MenuHost {
92
+ readonly open = signal(false);
93
+ readonly withTrigger = signal(true);
94
+ readonly glassTrigger = signal(false);
95
+ readonly sort = signal<'name' | 'date'>('date');
96
+ readonly pinned = signal<boolean | undefined>(undefined);
97
+ readonly closed = vi.fn();
98
+ readonly shared = vi.fn();
99
+ readonly deleted = vi.fn();
100
+ readonly archived = vi.fn();
101
+ }
102
+
103
+ /**
104
+ * The Lynx function that runs an element's UI methods, such as measuring it
105
+ * (`boundingClientRect`). The testing library defines it on the global
106
+ * object.
107
+ */
108
+ type InvokeUIMethod = {
109
+ __InvokeUIMethod: (
110
+ element: Element,
111
+ method: string,
112
+ params: Record<string, unknown>,
113
+ callback: (result: { code: number; data?: unknown }) => void,
114
+ ) => void;
115
+ };
116
+
117
+ /** Accessibility settings a test can change, as the host app would. */
118
+ const settings = {
119
+ reduceMotion: signal(false),
120
+ reduceTransparency: signal(false),
121
+ increaseContrast: signal(false),
122
+ };
123
+
124
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
125
+ const IOS_18 = { platform: 'iOS', osVersion: '18.5' };
126
+
127
+ /**
128
+ * Renders the menu on a platform (none for Material), measuring elements as
129
+ * `boundingClientRect` would on a phone: the safe area (the element that
130
+ * holds the menu) a phone's screen, the menu's content 120pt tall, and
131
+ * anything else, the trigger, a small box near the top.
132
+ */
133
+ const renderMenu = async (
134
+ platform: { platform: string; osVersion: string } | null = IOS_26,
135
+ ) => {
136
+ vi.stubGlobal('SystemInfo', platform ?? undefined);
137
+ const invoke = vi
138
+ .spyOn(globalThis as unknown as InvokeUIMethod, '__InvokeUIMethod')
139
+ .mockImplementation((element, method, _params, callback) => {
140
+ if (method !== 'boundingClientRect') {
141
+ callback({ code: 0 });
142
+ return;
143
+ }
144
+ const data = element.querySelector(':scope > blur-view[ui-glass]')
145
+ ? { left: 0, top: 0, width: 402, height: 800 }
146
+ : element.parentElement?.tagName.toLowerCase() === 'scroll-view'
147
+ ? { left: 0, top: 0, width: 250, height: 120 }
148
+ : { left: 16, top: 80, width: 80, height: 44 };
149
+ callback({ code: 0, data });
150
+ });
151
+ const result = await render(MenuHost, {
152
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
153
+ });
154
+ const host = result.componentRef.instance as MenuHost;
155
+ const overlay = result.container.querySelector('overlay')!;
156
+ return {
157
+ ...result,
158
+ host,
159
+ invoke,
160
+ overlay,
161
+ menu: within(overlay as HTMLElement),
162
+ trigger: () => result.container.querySelector('.trigger') as HTMLElement,
163
+ panel: () => overlay.querySelector('blur-view[ui-glass]')!,
164
+ };
165
+ };
166
+
167
+ type Rendered = Awaited<ReturnType<typeof renderMenu>>;
168
+
169
+ /**
170
+ * Waits until the menu has opened.
171
+ */
172
+ const waitForOpen = (rendered: Rendered) =>
173
+ vi.waitFor(() =>
174
+ expect(rendered.panel().getAttribute('accessibility-exclusive-focus')).toBe(
175
+ 'true',
176
+ ),
177
+ );
178
+
179
+ /**
180
+ * Renders the menu and opens it: a screen reader, and the queries, reach
181
+ * its items only while it shows.
182
+ */
183
+ const renderOpenMenu = async (
184
+ platform: { platform: string; osVersion: string } | null = IOS_26,
185
+ ) => {
186
+ const rendered = await renderMenu(platform);
187
+ rendered.host.open.set(true);
188
+ await waitForOpen(rendered);
189
+ return rendered;
190
+ };
191
+
192
+ /**
193
+ * Whether `first` comes before `second` in the document.
194
+ */
195
+ const precedes = (first: Node, second: Node) =>
196
+ !!(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING);
197
+
198
+ /**
199
+ * Waits until the menu has closed and its animation has finished.
200
+ */
201
+ const waitForClose = async (rendered: Rendered) => {
202
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
203
+ await waitForUpdate();
204
+ };
205
+
206
+ const style = (element: Element, name: string) =>
207
+ (element as HTMLElement).style.getPropertyValue(name);
208
+
209
+ /**
210
+ * An item's row: the element a screen reader reads as a button.
211
+ */
212
+ const row = (rendered: Rendered, name: string | RegExp) =>
213
+ rendered.menu.getByRole('button', { name });
214
+
215
+ // The renderer warns that the components' selectors aren't Lynx elements.
216
+ beforeEach(() => {
217
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
218
+ });
219
+
220
+ afterEach(() => {
221
+ vi.restoreAllMocks();
222
+ vi.unstubAllGlobals();
223
+ });
224
+
225
+ describe('UiMenu', () => {
226
+ it('opens from its trigger and grows out of it, hiding it meanwhile', async () => {
227
+ const rendered = await renderMenu();
228
+ const { host, trigger, overlay, invoke } = rendered;
229
+ expect(overlay.getAttribute('visible')).toBe('false');
230
+
231
+ fireEvent.tap(trigger());
232
+ await waitForOpen(rendered);
233
+
234
+ expect(host.open()).toBe(true);
235
+ expect(overlay.getAttribute('visible')).toBe('true');
236
+ // Measured from the trigger.
237
+ expect(invoke).toHaveBeenCalledWith(
238
+ trigger(),
239
+ 'boundingClientRect',
240
+ { relativeTo: 'screen' },
241
+ expect.any(Function),
242
+ );
243
+ expect(style(trigger(), 'visibility')).toBe('hidden');
244
+
245
+ // A tap on the trigger again closes it, and the trigger comes back.
246
+ fireEvent.tap(trigger());
247
+ await waitForClose(rendered);
248
+ expect(host.open()).toBe(false);
249
+ expect(style(trigger(), 'visibility')).toBe('');
250
+ // A trigger that isn't glass has no glass to hand over.
251
+ expect(trigger().hasAttribute('glass-effect-id')).toBe(false);
252
+ });
253
+
254
+ it("takes a glass trigger's glass over, shown as the trigger hides", async () => {
255
+ const rendered = await renderMenu();
256
+ rendered.host.glassTrigger.set(true);
257
+ await waitForUpdate();
258
+ const { trigger, panel } = rendered;
259
+
260
+ // The trigger and the menu share an id of the menu's own, for the shell
261
+ // to pair the two across the change.
262
+ const id = trigger().getAttribute('glass-effect-id');
263
+ expect(id).toMatch(/^ui-menu-\d+$/);
264
+ expect(panel().getAttribute('glass-effect-id')).toBe(id);
265
+ expect(style(panel(), 'visibility')).toBe('hidden');
266
+
267
+ fireEvent.tap(trigger());
268
+ await waitForOpen(rendered);
269
+ expect(style(panel(), 'visibility')).toBe('');
270
+ expect(style(trigger(), 'visibility')).toBe('hidden');
271
+
272
+ fireEvent.tap(trigger());
273
+ await waitForClose(rendered);
274
+ expect(style(panel(), 'visibility')).toBe('hidden');
275
+ expect(style(trigger(), 'visibility')).toBe('');
276
+
277
+ // A trigger that goes takes its glass with it.
278
+ rendered.host.glassTrigger.set(false);
279
+ await waitForUpdate();
280
+ expect(trigger().tagName.toLowerCase()).toBe('view');
281
+ });
282
+
283
+ it('closes when an item is chosen, after telling it', async () => {
284
+ const rendered = await renderMenu();
285
+ rendered.host.open.set(true);
286
+ await waitForOpen(rendered);
287
+
288
+ fireEvent.tap(row(rendered, 'Share'));
289
+
290
+ expect(rendered.host.shared).toHaveBeenCalledTimes(1);
291
+ expect(rendered.host.open()).toBe(false);
292
+ await waitForClose(rendered);
293
+ expect(rendered.overlay.getAttribute('visible')).toBe('false');
294
+ });
295
+
296
+ it('closes on a tap outside it', async () => {
297
+ const rendered = await renderMenu();
298
+ rendered.host.open.set(true);
299
+ await waitForOpen(rendered);
300
+
301
+ fireEvent.tap(rendered.overlay.firstElementChild!);
302
+
303
+ expect(rendered.host.open()).toBe(false);
304
+ await waitForClose(rendered);
305
+ });
306
+
307
+ it('grows out of the menu itself without a marked trigger', async () => {
308
+ const rendered = await renderMenu();
309
+ rendered.host.withTrigger.set(false);
310
+ await waitForUpdate();
311
+
312
+ rendered.host.open.set(true);
313
+ await waitForOpen(rendered);
314
+
315
+ // The menu's own element holds the overlay's element.
316
+ expect(rendered.invoke).toHaveBeenCalledWith(
317
+ rendered.overlay.parentElement!.parentElement,
318
+ 'boundingClientRect',
319
+ { relativeTo: 'screen' },
320
+ expect.any(Function),
321
+ );
322
+ });
323
+
324
+ it('opens beside its trigger on Material, leaving it in view', async () => {
325
+ const rendered = await renderMenu(null);
326
+
327
+ fireEvent.tap(rendered.trigger());
328
+ await waitForOpen(rendered);
329
+
330
+ expect(style(rendered.trigger(), 'visibility')).toBe('');
331
+ // Below the trigger, a gap away.
332
+ expect(style(rendered.panel(), 'top')).toBe(`${80 + 44 + 8}px`);
333
+ });
334
+ });
335
+
336
+ describe('UiMenuItem', () => {
337
+ it('is a button named by its label, and says when it is destructive', async () => {
338
+ const rendered = await renderOpenMenu();
339
+
340
+ expect(row(rendered, 'Share')).toBeTruthy();
341
+ expect(row(rendered, 'Delete, destructive')).toBeTruthy();
342
+ expect(row(rendered, 'Archive, unavailable offline')).toBeTruthy();
343
+ expect(
344
+ within(row(rendered, 'Delete, destructive')).getByText('Delete')
345
+ .className,
346
+ ).toContain('text-destructive');
347
+ expect(
348
+ within(row(rendered, 'Share')).getByText('Share').className,
349
+ ).toContain('text-glass-foreground');
350
+ });
351
+
352
+ it('shows a checkmark, and is selected to a screen reader, when checked', async () => {
353
+ const rendered = await renderOpenMenu();
354
+ const date = row(rendered, 'Date');
355
+ const name = row(rendered, 'Name');
356
+
357
+ expect(date.getAttribute('accessibility-traits')).toBe('button,selected');
358
+ expect(name.getAttribute('accessibility-traits')).toBe('button');
359
+ // Both keep the checkmark's column, so the labels line up.
360
+ expect(date.querySelector('.h-5.w-5 svg')).not.toBeNull();
361
+ expect(name.querySelector('.h-5.w-5')).not.toBeNull();
362
+ expect(name.querySelector('.h-5.w-5 svg')).toBeNull();
363
+
364
+ fireEvent.tap(name);
365
+ await waitForUpdate();
366
+ expect(rendered.host.sort()).toBe('name');
367
+ expect(name.getAttribute('accessibility-traits')).toBe('button,selected');
368
+ expect(date.getAttribute('accessibility-traits')).toBe('button');
369
+ await waitForClose(rendered);
370
+ });
371
+
372
+ it('is checked by a bare checked attribute, and unset when bound to undefined', async () => {
373
+ const rendered = await renderOpenMenu();
374
+ const bold = row(rendered, 'Bold');
375
+ const pin = row(rendered, 'Pin');
376
+
377
+ expect(bold.getAttribute('accessibility-traits')).toBe('button,selected');
378
+ expect(bold.querySelector('.h-5.w-5 svg')).not.toBeNull();
379
+ // Unset, it's an item that's never checked: no checkmark column.
380
+ expect(pin.querySelector('.h-5.w-5')).toBeNull();
381
+
382
+ rendered.host.pinned.set(false);
383
+ await waitForUpdate();
384
+ expect(pin.querySelector('.h-5.w-5')).not.toBeNull();
385
+ expect(pin.querySelector('.h-5.w-5 svg')).toBeNull();
386
+ });
387
+
388
+ it('highlights while pressed, by touch or mouse', async () => {
389
+ const rendered = await renderOpenMenu();
390
+ const share = row(rendered, 'Share');
391
+ const highlight = share.firstElementChild!;
392
+ expect(style(highlight, 'opacity')).toBe('0');
393
+
394
+ for (const [down, up] of [
395
+ ['touchstart', 'touchend'],
396
+ ['touchstart', 'touchcancel'],
397
+ ['mousedown', 'mouseup'],
398
+ ['mousedown', 'mouseleave'],
399
+ ]) {
400
+ fireLynxEvent(share, down!);
401
+ await waitForUpdate();
402
+ expect(style(highlight, 'opacity')).toBe('0.08');
403
+ fireLynxEvent(share, up!);
404
+ await waitForUpdate();
405
+ expect(style(highlight, 'opacity')).toBe('0');
406
+ }
407
+ });
408
+
409
+ it('highlights a pressed row as its platform does', async () => {
410
+ const pressed = async (
411
+ platform: { platform: string; osVersion: string } | null,
412
+ ) => {
413
+ const rendered = await renderOpenMenu(platform);
414
+ const share = row(rendered, 'Share');
415
+ fireLynxEvent(share, 'touchstart');
416
+ await waitForUpdate();
417
+ const highlight = share.firstElementChild!;
418
+ return {
419
+ classes: highlight.className.split(' '),
420
+ opacity: style(highlight, 'opacity'),
421
+ };
422
+ };
423
+
424
+ // iOS 26's capsule, inset from the menu's sides.
425
+ const glass = await pressed(IOS_26);
426
+ expect(glass.classes).toEqual(
427
+ expect.arrayContaining(['left-[10px]', 'right-[10px]', 'rounded-full']),
428
+ );
429
+ expect(glass.opacity).toBe('0.08');
430
+ // Edge to edge before iOS 26, and as Material's state layer.
431
+ for (const platform of [IOS_18, null]) {
432
+ const { classes, opacity } = await pressed(platform);
433
+ expect(classes).toEqual(expect.arrayContaining(['left-0', 'right-0']));
434
+ expect(classes).not.toContain('rounded-full');
435
+ expect(opacity).toBe('0.1');
436
+ }
437
+ });
438
+
439
+ it("can't be pressed or chosen when disabled", async () => {
440
+ const rendered = await renderOpenMenu();
441
+ const archive = row(rendered, 'Archive, unavailable offline');
442
+
443
+ expect(archive.className).toContain('opacity-40');
444
+ fireEvent.touchstart(archive);
445
+ await waitForUpdate();
446
+ expect(style(archive.firstElementChild!, 'opacity')).toBe('0');
447
+ fireEvent.tap(archive);
448
+
449
+ expect(rendered.host.archived).not.toHaveBeenCalled();
450
+ expect(rendered.host.open()).toBe(true);
451
+ });
452
+
453
+ it('lays out as iOS 26 does: 44pt rows with the icon first', async () => {
454
+ const rendered = await renderOpenMenu();
455
+ const share = row(rendered, 'Share');
456
+ const label = within(share).getByText('Share');
457
+
458
+ expect(share.className).toContain('min-h-[44px]');
459
+ expect(label.className).toContain('text-[17px]');
460
+ expect(precedes(share.querySelector('svg')!, label)).toBe(true);
461
+ });
462
+
463
+ it('puts the icon after the label before iOS 26', async () => {
464
+ const rendered = await renderOpenMenu(IOS_18);
465
+ const share = row(rendered, 'Share');
466
+
467
+ expect(
468
+ precedes(within(share).getByText('Share'), share.querySelector('svg')!),
469
+ ).toBe(true);
470
+ });
471
+
472
+ it('lays out as Material 3 does: 48dp rows with a leading icon', async () => {
473
+ const rendered = await renderOpenMenu(null);
474
+ const share = row(rendered, 'Share');
475
+ const label = within(share).getByText('Share');
476
+
477
+ expect(share.className).toContain('min-h-[48px]');
478
+ expect(label.className).toContain('text-[16px]');
479
+ expect(precedes(share.querySelector('svg')!, label)).toBe(true);
480
+ });
481
+ });
482
+
483
+ describe('UiMenuSection and UiMenuSeparator', () => {
484
+ it('titles a section, as a heading', async () => {
485
+ const rendered = await renderOpenMenu();
486
+
487
+ const title = rendered.menu.getByRole('heading', { name: 'Sort by' });
488
+ expect(title.className).toContain('px-4');
489
+ // The untitled section has no heading.
490
+ expect(rendered.menu.getAllByRole('heading')).toHaveLength(1);
491
+ });
492
+
493
+ it('indents a Material section title as Material does', async () => {
494
+ const rendered = await renderOpenMenu(null);
495
+
496
+ expect(
497
+ rendered.menu.getByRole('heading', { name: 'Sort by' }).className,
498
+ ).toContain('px-3');
499
+ });
500
+
501
+ it('parts sections with a line, or a band before iOS 26', async () => {
502
+ const separatorLine = async (
503
+ platform: { platform: string; osVersion: string } | null,
504
+ ) => {
505
+ const rendered = await renderMenu(platform);
506
+ const line = rendered.overlay.querySelector(
507
+ '[class~="h-px"], [class~="h-2"]',
508
+ )!.className;
509
+ rendered.unmount();
510
+ return line;
511
+ };
512
+
513
+ expect((await separatorLine(IOS_26)).split(' ')).toEqual(
514
+ expect.arrayContaining(['h-px', 'bg-glass-foreground', 'opacity-20']),
515
+ );
516
+ expect((await separatorLine(IOS_18)).split(' ')).toEqual(
517
+ expect.arrayContaining(['h-2', 'bg-glass-foreground', 'opacity-10']),
518
+ );
519
+ expect((await separatorLine(null)).split(' ')).toEqual(
520
+ expect.arrayContaining(['h-px', 'bg-border']),
521
+ );
522
+ });
523
+ });