@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,224 @@
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 { UiMenuItem, UiMenuSeparator } from '../menu/menu';
11
+ import { UiContextMenu } from './context-menu';
12
+
13
+ @Component({
14
+ imports: [LYNX_ELEMENTS, UiContextMenu, UiMenuItem, UiMenuSeparator],
15
+ template: `
16
+ <ui-context-menu
17
+ [open]="open()"
18
+ (openChange)="open.set($event)"
19
+ [disabled]="disabled()"
20
+ (closed)="closed()"
21
+ >
22
+ <view class="card" (bindtap)="opened()">
23
+ <text>Kyoto</text>
24
+ </view>
25
+ <ui-menu-item label="Share" icon="upload" (pressed)="shared()" />
26
+ <ui-menu-separator />
27
+ <ui-menu-item
28
+ label="Delete"
29
+ icon="trash"
30
+ variant="destructive"
31
+ (pressed)="deleted()"
32
+ />
33
+ </ui-context-menu>
34
+ `,
35
+ })
36
+ class ContextMenuHost {
37
+ readonly open = signal(false);
38
+ readonly disabled = signal(false);
39
+ readonly closed = vi.fn();
40
+ readonly opened = vi.fn();
41
+ readonly shared = vi.fn();
42
+ readonly deleted = vi.fn();
43
+ }
44
+
45
+ /**
46
+ * The Lynx function that runs an element's UI methods, such as measuring it
47
+ * (`boundingClientRect`). The testing library defines it on the global
48
+ * object.
49
+ */
50
+ type InvokeUIMethod = {
51
+ __InvokeUIMethod: (
52
+ element: Element,
53
+ method: string,
54
+ params: Record<string, unknown>,
55
+ callback: (result: { code: number; data?: unknown }) => void,
56
+ ) => void;
57
+ };
58
+
59
+ /** Accessibility settings a test can change, as the host app would. */
60
+ const settings = {
61
+ reduceMotion: signal(false),
62
+ reduceTransparency: signal(false),
63
+ increaseContrast: signal(false),
64
+ };
65
+
66
+ const IOS_26 = { platform: 'iOS', osVersion: '26.0' };
67
+
68
+ /**
69
+ * Renders the context menu on a platform (none for Material), measuring
70
+ * elements as `boundingClientRect` would on a phone: the safe area (the
71
+ * element that holds the menu) a phone's screen, the menu's content 120pt
72
+ * tall, and anything else, the pressed element, a card near the top.
73
+ */
74
+ const renderContextMenu = async (
75
+ platform: { platform: string; osVersion: string } | null = IOS_26,
76
+ ) => {
77
+ vi.stubGlobal('SystemInfo', platform ?? undefined);
78
+ vi.spyOn(
79
+ globalThis as unknown as InvokeUIMethod,
80
+ '__InvokeUIMethod',
81
+ ).mockImplementation((element, method, _params, callback) => {
82
+ if (method !== 'boundingClientRect') {
83
+ callback({ code: 0 });
84
+ return;
85
+ }
86
+ const data = element.querySelector(':scope > blur-view[ui-glass]')
87
+ ? { left: 0, top: 62, width: 402, height: 778 }
88
+ : element.parentElement?.tagName.toLowerCase() === 'scroll-view'
89
+ ? { left: 0, top: 0, width: 250, height: 120 }
90
+ : { left: 16, top: 162, width: 370, height: 96 };
91
+ callback({ code: 0, data });
92
+ });
93
+ const result = await render(ContextMenuHost, {
94
+ providers: [{ provide: LynxAccessibilitySettings, useValue: settings }],
95
+ });
96
+ const host = result.componentRef.instance as ContextMenuHost;
97
+ const overlay = result.container.querySelector('overlay')!;
98
+ return {
99
+ ...result,
100
+ host,
101
+ overlay,
102
+ menu: within(overlay as HTMLElement),
103
+ card: () => result.getByText('Kyoto').parentElement!,
104
+ /** The element the card is in: its home, or the overlay's slot. */
105
+ holder: () =>
106
+ result.getByText('Kyoto').parentElement!.parentElement!.parentElement!,
107
+ panel: () => overlay.querySelector('blur-view[ui-glass]')!,
108
+ };
109
+ };
110
+
111
+ type Rendered = Awaited<ReturnType<typeof renderContextMenu>>;
112
+
113
+ /**
114
+ * Waits until the menu has opened.
115
+ */
116
+ const waitForOpen = (rendered: Rendered) =>
117
+ vi.waitFor(() =>
118
+ expect(rendered.panel().getAttribute('accessibility-exclusive-focus')).toBe(
119
+ 'true',
120
+ ),
121
+ );
122
+
123
+ /**
124
+ * Waits until the menu has closed and its animation has finished.
125
+ */
126
+ const waitForClose = async (rendered: Rendered) => {
127
+ await vi.waitFor(() => expect(rendered.host.closed).toHaveBeenCalled());
128
+ await waitForUpdate();
129
+ };
130
+
131
+ /**
132
+ * Long-presses an element. The press bubbles, as Lynx's touch events do, to
133
+ * the context menu around the element.
134
+ */
135
+ const longPress = (element: Element) =>
136
+ fireEvent.longpress(element, { bubbles: true });
137
+
138
+ const style = (element: Element, name: string) =>
139
+ (element as HTMLElement).style.getPropertyValue(name);
140
+
141
+ // The renderer warns that the components' selectors aren't Lynx elements.
142
+ beforeEach(() => {
143
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
144
+ });
145
+
146
+ afterEach(() => {
147
+ vi.restoreAllMocks();
148
+ vi.unstubAllGlobals();
149
+ });
150
+
151
+ describe('UiContextMenu', () => {
152
+ it('opens on a long press, lifting the element above the dimmed screen', async () => {
153
+ const rendered = await renderContextMenu();
154
+ const home = rendered.holder();
155
+
156
+ longPress(rendered.card());
157
+ await waitForOpen(rendered);
158
+
159
+ expect(rendered.host.open()).toBe(true);
160
+ // The element itself is in the menu's overlay now, lifted, and the spot
161
+ // it left keeps its size.
162
+ expect(rendered.overlay.contains(rendered.card())).toBe(true);
163
+ expect(style(rendered.holder(), 'transform')).toBe('scale(1.03)');
164
+ expect(style(home, 'width')).toBe('370px');
165
+ expect(style(home, 'height')).toBe('96px');
166
+ expect(rendered.overlay.querySelector('.bg-glass-dim')).not.toBeNull();
167
+ // The menu opens below it.
168
+ expect(style(rendered.panel(), 'top')).toBe(`${100 + 96 + 8}px`);
169
+ });
170
+
171
+ it('runs the chosen item and closes, putting the element back', async () => {
172
+ const rendered = await renderContextMenu();
173
+ longPress(rendered.card());
174
+ await waitForOpen(rendered);
175
+
176
+ fireEvent.tap(
177
+ rendered.menu.getByRole('button', { name: 'Delete, destructive' }),
178
+ );
179
+
180
+ expect(rendered.host.deleted).toHaveBeenCalledTimes(1);
181
+ expect(rendered.host.open()).toBe(false);
182
+ await waitForClose(rendered);
183
+ expect(rendered.overlay.contains(rendered.card())).toBe(false);
184
+ expect(style(rendered.holder(), 'height')).toBe('');
185
+ // A tap on the element still reaches it.
186
+ fireEvent.tap(rendered.card());
187
+ expect(rendered.host.opened).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it('closes on a tap outside the menu', async () => {
191
+ const rendered = await renderContextMenu();
192
+ rendered.host.open.set(true);
193
+ await waitForOpen(rendered);
194
+
195
+ fireEvent.tap(rendered.overlay.firstElementChild!);
196
+
197
+ expect(rendered.host.open()).toBe(false);
198
+ await waitForClose(rendered);
199
+ });
200
+
201
+ it("doesn't open on a long press when disabled", async () => {
202
+ const rendered = await renderContextMenu();
203
+ rendered.host.disabled.set(true);
204
+ await waitForUpdate();
205
+
206
+ longPress(rendered.card());
207
+ await waitForUpdate();
208
+
209
+ expect(rendered.host.open()).toBe(false);
210
+ });
211
+
212
+ it('opens a Material menu beside the element on Android, leaving it in place', async () => {
213
+ const rendered = await renderContextMenu(null);
214
+ const home = rendered.holder();
215
+
216
+ longPress(rendered.card());
217
+ await waitForOpen(rendered);
218
+
219
+ expect(home.contains(rendered.card())).toBe(true);
220
+ expect(style(home, 'height')).toBe('');
221
+ expect(rendered.overlay.querySelector('.bg-glass-dim')).toBeNull();
222
+ expect(rendered.menu.getByRole('button', { name: 'Share' })).toBeTruthy();
223
+ });
224
+ });
@@ -0,0 +1,110 @@
1
+ import {
2
+ Component,
3
+ type ElementRef,
4
+ ViewEncapsulation,
5
+ booleanAttribute,
6
+ inject,
7
+ input,
8
+ model,
9
+ output,
10
+ viewChild,
11
+ } from '@angular/core';
12
+ import { LYNX_ELEMENTS } from '@blotch/angular-lynx';
13
+
14
+ import { type MenuElement, UiMenuPopup } from '../menu/menu-popup';
15
+ import { UI_MENU, type UiMenuHost } from '../menu/menu';
16
+
17
+ /**
18
+ * The `ui-context-menu` an item is in, for `UI_MENU`. A named function rather
19
+ * than an inline factory: the component's metadata keeps its own copy of an
20
+ * inline one, which nothing calls, so an app's coverage report would show it
21
+ * as uncovered.
22
+ */
23
+ const contextMenu = (): UiMenuHost => inject(UiContextMenu);
24
+
25
+ /**
26
+ * A context menu: a long press on an element lifts it and opens a menu of
27
+ * actions beside it, while the rest of the screen dims.
28
+ *
29
+ * ```html
30
+ * <ui-context-menu>
31
+ * <view class="rounded-xl bg-card p-4">
32
+ * <text class="text-foreground">Trip to Kyoto</text>
33
+ * </view>
34
+ * <ui-menu-item label="Share" icon="upload" (pressed)="share()" />
35
+ * <ui-menu-item label="Favorite" icon="heart" (pressed)="favorite()" />
36
+ * <ui-menu-separator />
37
+ * <ui-menu-item label="Delete" icon="trash" variant="destructive" (pressed)="remove()" />
38
+ * </ui-context-menu>
39
+ * ```
40
+ *
41
+ * The items (`ui-menu-item`, `ui-menu-section`, `ui-menu-separator`) are the
42
+ * menu, and everything else is the element it opens from. Taps on the
43
+ * element still reach it: Lynx tells a tap from a long press. A tap outside
44
+ * the menu, choosing an item, or Android's back button closes it, and
45
+ * `[(open)]` opens and closes it from code.
46
+ *
47
+ * On iOS the element lifts above a dimming layer (a blur before iOS 26) and
48
+ * the menu, Liquid Glass from iOS 26, opens below it, or above it near the
49
+ * bottom of the screen; when it fits on neither side, the element moves up
50
+ * to make room. The element itself moves into the menu's overlay, so it
51
+ * stays live and looks exactly as it did. On Android it's a Material 3 menu
52
+ * beside the element, without the lift or the dimming, unless the app opts
53
+ * into glass with `provideGlass({ android: 'glass' })`.
54
+ *
55
+ * Apple asks for a context menu's first actions to match the swipe actions
56
+ * of the row it's on, when there are any, so the two ways to reach an
57
+ * action agree.
58
+ */
59
+ @Component({
60
+ selector: 'ui-context-menu',
61
+ imports: [LYNX_ELEMENTS, UiMenuPopup],
62
+ encapsulation: ViewEncapsulation.None,
63
+ providers: [{ provide: UI_MENU, useFactory: contextMenu }],
64
+ template: `
65
+ <!-- Keeps the element's size while it's lifted into the overlay, so the
66
+ page doesn't move under the menu. -->
67
+ <view
68
+ #home
69
+ class="flex-col flex"
70
+ [style.width.px]="popup.placeholder()?.width"
71
+ [style.height.px]="popup.placeholder()?.height"
72
+ >
73
+ <view #element class="flex-col flex" (bindlongpress)="onLongPress()">
74
+ <ng-content />
75
+ </view>
76
+ </view>
77
+ <ui-menu-popup
78
+ #popup
79
+ kind="context"
80
+ [open]="open()"
81
+ [anchor]="elementView().nativeElement"
82
+ [anchorHome]="homeView().nativeElement"
83
+ (dismissed)="close()"
84
+ (closed)="closed.emit()"
85
+ >
86
+ <ng-content select="ui-menu-item, ui-menu-section, ui-menu-separator" />
87
+ </ui-menu-popup>
88
+ `,
89
+ })
90
+ export class UiContextMenu implements UiMenuHost {
91
+ /** Whether the menu is open. */
92
+ readonly open = model(false);
93
+ /** Turns the long press off. */
94
+ readonly disabled = input(false, { transform: booleanAttribute });
95
+
96
+ /** The menu has closed and its close animation has finished. */
97
+ readonly closed = output<void>();
98
+
99
+ // Required: both are always in the template.
100
+ readonly elementView = viewChild.required<ElementRef<MenuElement>>('element');
101
+ readonly homeView = viewChild.required<ElementRef<MenuElement>>('home');
102
+
103
+ protected onLongPress(): void {
104
+ if (!this.disabled()) this.open.set(true);
105
+ }
106
+
107
+ close(): void {
108
+ this.open.set(false);
109
+ }
110
+ }
@@ -0,0 +1 @@
1
+ export { UiContextMenu } from './context-menu';