@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,565 @@
1
+ import {
2
+ type OnDestroy,
3
+ Component,
4
+ ElementRef,
5
+ Renderer2,
6
+ ViewEncapsulation,
7
+ booleanAttribute,
8
+ computed,
9
+ effect,
10
+ inject,
11
+ input,
12
+ output,
13
+ signal,
14
+ untracked,
15
+ viewChild,
16
+ } from '@angular/core';
17
+ import {
18
+ LYNX_ELEMENTS,
19
+ LynxAccessibilitySettings,
20
+ MainThreadElement,
21
+ } from '@blotch/angular-lynx';
22
+
23
+ import { DURATION, EASING } from '../../utils/animate';
24
+ import { cn } from '../../utils/cn';
25
+ import { UiGlass } from '../glass/glass';
26
+ import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
27
+ import { injectColorScheme } from '../icon/icon-theme';
28
+ import {
29
+ type MenuPlacement,
30
+ type MenuRect,
31
+ menuStartFrame,
32
+ placeMenu,
33
+ } from './menu-placement';
34
+
35
+ /**
36
+ * What a menu is: a `'pull-down'` menu opened by a button, or a `'context'`
37
+ * menu opened by a long press on an element.
38
+ */
39
+ export type MenuKind = 'pull-down' | 'context';
40
+
41
+ /**
42
+ * An element the menu measures or moves: AngularLynx's element, which an
43
+ * `ElementRef` or a template reference to a Lynx element holds.
44
+ */
45
+ export type MenuElement = { readonly element: unknown };
46
+
47
+ /**
48
+ * Where the menu is in its open and close sequence:
49
+ *
50
+ * - `'closed'`: the overlay is hidden.
51
+ * - `'measuring'`: the overlay shows with the menu off screen, at its natural
52
+ * size, to measure it.
53
+ * - `'from'`: the menu sits at its start frame, the anchor's box or a corner
54
+ * of the menu, ready to grow.
55
+ * - `'open'`: the menu has grown to its frame, the screen has dimmed, and a
56
+ * context menu's element has lifted.
57
+ * - `'closing'`: everything goes back the way it came.
58
+ */
59
+ export type MenuPhase = 'closed' | 'measuring' | 'from' | 'open' | 'closing';
60
+
61
+ /** How each design draws the menu. */
62
+ type MenuMetrics = {
63
+ /** Its width, in px. */
64
+ readonly width: number;
65
+ /** Its corner radius, in px. */
66
+ readonly radius: number;
67
+ /** Space above and below the items. */
68
+ readonly padding: string;
69
+ };
70
+
71
+ const METRICS: Readonly<Record<GlassDesign, MenuMetrics>> = {
72
+ // iOS 26's menus: rounder, with room around the rows.
73
+ glass: { width: 250, radius: 24, padding: 'py-1.5' },
74
+ // iOS 15 to 25's menus: rows edge to edge.
75
+ frosted: { width: 250, radius: 14, padding: '' },
76
+ // Material 3 Expressive's menu.
77
+ material: { width: 224, radius: 16, padding: 'py-2' },
78
+ };
79
+
80
+ /**
81
+ * How long the overlay gets to lay out before the menu inside it is
82
+ * measured. Android shows an overlay as a dialog window of its own.
83
+ */
84
+ const LAYOUT_DELAY = 32;
85
+
86
+ /**
87
+ * How long the menu waits at its start frame, so the native side draws it
88
+ * there before the transition to its frame starts.
89
+ */
90
+ const FRAME_DELAY = 16;
91
+
92
+ /** How far a context menu's element lifts. */
93
+ const LIFT_SCALE = 1.03;
94
+
95
+ /**
96
+ * Where the overlay sits on the web, where `<overlay>` has no size of its
97
+ * own (see dialog.ts). Native Lynx sizes it to the screen and ignores these.
98
+ */
99
+ const OVERLAY_STYLE =
100
+ 'position: fixed; overflow: visible; width: 100vw; height: 100vh; top: 0; left: 0; z-index: 999;';
101
+
102
+ const ZERO_RECT: MenuRect = { left: 0, top: 0, width: 0, height: 0 };
103
+
104
+ /**
105
+ * An element's box on screen, or null where it can't be measured.
106
+ *
107
+ * Lynx gives a script an element's position only through the
108
+ * `boundingClientRect` UI method, which answers later. Measured from the
109
+ * screen, so that an element in the page and one in the overlay, a window
110
+ * of its own on iOS and Android, compare. Lynx for Web measures from the
111
+ * `<lynx-view>` either way.
112
+ */
113
+ export const measureMenuElement = async (
114
+ element: MenuElement,
115
+ ): Promise<MenuRect | null> => {
116
+ try {
117
+ const { left, top, width, height } = await new MainThreadElement(
118
+ element.element as ConstructorParameters<typeof MainThreadElement>[0],
119
+ ).getBoundingClientRect({ relativeTo: 'screen' });
120
+ return { left, top, width, height };
121
+ } catch {
122
+ return null;
123
+ }
124
+ };
125
+
126
+ /**
127
+ * The overlay of a menu and a context menu: the glass menu itself, placed
128
+ * against the element that opened it and kept on screen, with the screen
129
+ * dimmed and the element lifted for a context menu. `ui-menu` and
130
+ * `ui-context-menu` hold one, and project their items into it.
131
+ *
132
+ * Opening measures the anchor (the button or the pressed element), shows
133
+ * the overlay, measures the menu and the safe area, places the menu (see
134
+ * placeMenu), and then grows it from its start frame (see menuStartFrame)
135
+ * with CSS transitions, which Lynx plays on iOS, Android and the web alike.
136
+ * The menu is one glass shape whose frame grows: iOS's Liquid Glass follows
137
+ * a moving or resizing shape frame by frame. It shows at the trigger's place
138
+ * in the same update that hides the trigger, and hides in the one that shows
139
+ * the trigger again, so the shell can pair a glass trigger with it (see
140
+ * glassId).
141
+ *
142
+ * A context menu's element is lifted by moving the element itself into the
143
+ * overlay, above the dimming layer, at the same place on screen, and back
144
+ * when the menu closes: no copy and no snapshot, so it stays live and looks
145
+ * exactly as it did. The element it came from keeps its size meanwhile (see
146
+ * placeholder), so the page doesn't move under it.
147
+ */
148
+ @Component({
149
+ selector: 'ui-menu-popup',
150
+ imports: [LYNX_ELEMENTS, UiGlass],
151
+ encapsulation: ViewEncapsulation.None,
152
+ template: `
153
+ <overlay
154
+ [attr.visible]="visible()"
155
+ [style]="overlayStyle()"
156
+ (bindrequestclose)="dismissed.emit()"
157
+ >
158
+ <!-- The overlay sizes only its first child, to the screen. A tap
159
+ anywhere outside the menu lands here and dismisses it. -->
160
+ <view class="h-full w-full" (bindtap)="dismissed.emit()">
161
+ @if (blurs()) {
162
+ <!-- Before iOS 26, a context menu blurred the screen. -->
163
+ <blur-view
164
+ class="h-full w-full absolute left-0 top-0"
165
+ [blur-effect]="blurEffect()"
166
+ blur-radius="12px"
167
+ [style.opacity]="dimOpacity()"
168
+ [style.transition]="dimTransition"
169
+ ></blur-view>
170
+ }
171
+ @if (dims()) {
172
+ <view
173
+ class="h-full w-full bg-glass-dim absolute left-0 top-0"
174
+ [style.opacity]="dimOpacity()"
175
+ [style.transition]="dimTransition"
176
+ ></view>
177
+ }
178
+ <!-- The screen inside its safe area, where the menu has to stay. -->
179
+ <view
180
+ #area
181
+ style="position: absolute; top: env(safe-area-inset-top); bottom: env(safe-area-inset-bottom); left: env(safe-area-inset-left); right: env(safe-area-inset-right);"
182
+ (bindlayoutchange)="onAreaLayout($event)"
183
+ >
184
+ <!-- A context menu's element, lifted above the dimming layer. -->
185
+ <view
186
+ #slot
187
+ class="flex-col flex absolute"
188
+ [style.left.px]="liftFrame()?.left"
189
+ [style.top.px]="liftFrame()?.top"
190
+ [style.width.px]="liftFrame()?.width"
191
+ [style.transform]="liftTransform()"
192
+ [style.transition]="liftTransition()"
193
+ ></view>
194
+ <blur-view
195
+ #panel
196
+ ui-glass
197
+ shape="rounded"
198
+ [radius]="metrics().radius"
199
+ class="flex-col flex absolute overflow-hidden"
200
+ [style.left.px]="frame()?.left ?? offScreen"
201
+ [style.top.px]="frame()?.top ?? 0"
202
+ [style.width.px]="frame()?.width"
203
+ [style.height.px]="frame()?.height"
204
+ [glassId]="glassId()"
205
+ [style.opacity]="panelOpacity()"
206
+ [style.visibility]="panelVisibility()"
207
+ [style.transition]="panelTransition()"
208
+ [style.align-items]="
209
+ placement()?.horizontal === 'right' ? 'flex-end' : 'flex-start'
210
+ "
211
+ [style.justify-content]="
212
+ placement()?.vertical === 'bottom' ? 'flex-end' : 'flex-start'
213
+ "
214
+ [accessibility-exclusive-focus]="phase() === 'open'"
215
+ (catchtap)="onPanelTap()"
216
+ >
217
+ <!-- Fixed at the menu's size (its natural size while it's
218
+ measured), and pinned to the edge nearest the anchor, so the
219
+ items stay put while the glass grows around them instead of
220
+ reflowing. A menu taller than the screen scrolls. -->
221
+ <scroll-view
222
+ scroll-orientation="vertical"
223
+ class="shrink-0"
224
+ [style.width.px]="placement()?.frame.width ?? metrics().width"
225
+ [style.height.px]="placement()?.frame.height"
226
+ [style.opacity]="phase() === 'open' ? 1 : 0"
227
+ [style.transition]="contentTransition()"
228
+ >
229
+ <view #content [class]="contentClass()">
230
+ <ng-content />
231
+ </view>
232
+ </scroll-view>
233
+ </blur-view>
234
+ </view>
235
+ </view>
236
+ </overlay>
237
+ `,
238
+ })
239
+ export class UiMenuPopup implements OnDestroy {
240
+ /** Whether the menu is open. */
241
+ readonly open = input(false, { transform: booleanAttribute });
242
+ /** The element the menu opens from: a button, or the pressed element. */
243
+ readonly anchor = input.required<MenuElement>();
244
+ /** A `'pull-down'` or a `'context'` menu; see MenuKind. */
245
+ readonly kind = input<MenuKind>('pull-down');
246
+ /**
247
+ * Where a context menu's lifted element goes back to: the element it was
248
+ * in. Without one, the element isn't lifted.
249
+ */
250
+ readonly anchorHome = input<MenuElement | null>(null);
251
+ /**
252
+ * The id the menu's glass shares with its trigger's (see UiGlass's
253
+ * `glassId`), so that on iOS the menu takes over a glass trigger's glass.
254
+ */
255
+ readonly glassId = input<string | undefined>(undefined);
256
+
257
+ /** The user tapped outside the menu, or pressed Android's back button. */
258
+ readonly dismissed = output<void>();
259
+ /** The menu has closed and its close animation has finished. */
260
+ readonly closed = output<void>();
261
+
262
+ readonly #renderer = inject(Renderer2);
263
+ readonly #settings = inject(LynxAccessibilitySettings);
264
+ readonly #scheme = injectColorScheme();
265
+
266
+ readonly design = injectGlassDesign();
267
+ readonly phase = signal<MenuPhase>('closed');
268
+ readonly placement = signal<MenuPlacement | null>(null);
269
+ /**
270
+ * The size the lifted element's home keeps while the element is in the
271
+ * overlay, so the page doesn't move. Null while nothing is lifted.
272
+ */
273
+ readonly placeholder = signal<MenuRect | null>(null);
274
+
275
+ // Required: all are always in the template, and only read once the overlay
276
+ // shows.
277
+ readonly area = viewChild.required<ElementRef<MenuElement>>('area');
278
+ readonly slot = viewChild.required<ElementRef<MenuElement>>('slot');
279
+ readonly panel = viewChild.required('panel', { read: ElementRef });
280
+ readonly content = viewChild.required<ElementRef<MenuElement>>('content');
281
+
282
+ protected readonly offScreen = -10000;
283
+ protected readonly dimTransition = `opacity ${DURATION.normal}ms ${EASING.standard}`;
284
+
285
+ readonly metrics = computed(() => METRICS[this.design()]);
286
+
287
+ /**
288
+ * Material menus open beside their anchor without dimming the screen or
289
+ * lifting it, on a solid surface. Apple's context menus dim the screen and
290
+ * lift the pressed element.
291
+ */
292
+ readonly #material = computed(() => this.design() === 'material');
293
+ readonly #context = computed(() => this.kind() === 'context');
294
+ protected readonly dims = computed(
295
+ () => this.#context() && !this.#material(),
296
+ );
297
+ protected readonly blurs = computed(
298
+ () => this.dims() && this.design() === 'frosted',
299
+ );
300
+ readonly #lifts = computed(() => this.dims() && !!this.anchorHome());
301
+ readonly #reduceMotion = computed(() => this.#settings.reduceMotion());
302
+
303
+ protected readonly visible = computed(() => this.phase() !== 'closed');
304
+ protected readonly overlayStyle = computed(() =>
305
+ this.visible() ? OVERLAY_STYLE : `${OVERLAY_STYLE} display: none;`,
306
+ );
307
+ protected readonly blurEffect = computed(() =>
308
+ this.#scheme() === 'dark' ? 'dark' : 'light',
309
+ );
310
+ protected readonly dimOpacity = computed(() =>
311
+ this.phase() === 'open' ? 1 : 0,
312
+ );
313
+
314
+ /** Where the anchor was on screen when the menu opened, in the area. */
315
+ readonly #anchorRect = signal<MenuRect>(ZERO_RECT);
316
+ /** Whether the anchor is in the slot. */
317
+ #lifted = false;
318
+
319
+ /**
320
+ * Where the lifted element sits: over the spot it left, and, once open,
321
+ * wherever the placement moved it to make room for the menu.
322
+ */
323
+ protected readonly liftFrame = computed(() => {
324
+ const placement = this.placement();
325
+ if (!placement || !this.#lifts()) return null;
326
+ return this.phase() === 'open' || this.#reduceMotion()
327
+ ? placement.anchor
328
+ : this.#anchorRect();
329
+ });
330
+ protected readonly liftTransform = computed(() =>
331
+ this.phase() === 'open' && !this.#reduceMotion()
332
+ ? `scale(${LIFT_SCALE})`
333
+ : 'scale(1)',
334
+ );
335
+ protected readonly liftTransition = computed(() =>
336
+ this.#transition(['transform', 'top']),
337
+ );
338
+
339
+ /** The menu's box now: off screen while measuring, then start or frame. */
340
+ protected readonly frame = computed<MenuRect | null>(() => {
341
+ const placement = this.placement();
342
+ const phase = this.phase();
343
+ if (!placement || phase === 'measuring') return null;
344
+ if (phase === 'open' || this.#reduceMotion()) return placement.frame;
345
+ return menuStartFrame(
346
+ placement,
347
+ this.#material() ? 'edge' : this.#context() ? 'corner' : 'anchor',
348
+ );
349
+ });
350
+
351
+ /**
352
+ * Material's solid surface fades as it unfolds. Glass doesn't fade: iOS
353
+ * draws a blur view with less than full opacity wrongly, so it only grows
354
+ * and shrinks, and its items fade.
355
+ */
356
+ protected readonly panelOpacity = computed(() =>
357
+ this.#material() && this.phase() !== 'open' ? 0 : 1,
358
+ );
359
+ protected readonly panelTransition = computed(() =>
360
+ this.#transition(['left', 'top', 'width', 'height', 'opacity']),
361
+ );
362
+
363
+ /**
364
+ * The menu's glass is hidden until it's at its start frame, so it shows in
365
+ * the same update that hides the trigger it grows out of, and hides in the
366
+ * one that shows the trigger again: on iOS the shell pairs the two across
367
+ * those changes (see glassId).
368
+ */
369
+ protected readonly panelVisibility = computed(() => {
370
+ const phase = this.phase();
371
+ return phase === 'closed' || phase === 'measuring' ? 'hidden' : null;
372
+ });
373
+ protected readonly contentTransition = computed(() =>
374
+ this.#transition(['opacity']),
375
+ );
376
+
377
+ protected readonly contentClass = computed(() =>
378
+ cn('flex-col flex', this.metrics().padding),
379
+ );
380
+
381
+ /**
382
+ * A transition of `properties` for the phase: springy as the menu opens,
383
+ * quicker as it closes, none while it's placed. Under Reduce Motion
384
+ * nothing moves or grows, and only the fades play.
385
+ */
386
+ #transition(properties: readonly string[]): string {
387
+ const phase = this.phase();
388
+ if (phase !== 'open' && phase !== 'closing') return 'none';
389
+ const [duration, easing] =
390
+ phase === 'open'
391
+ ? [DURATION.slow, EASING.springSubtle]
392
+ : [DURATION.normal, EASING.standard];
393
+ const animated = this.#reduceMotion()
394
+ ? properties.filter((property) => property === 'opacity')
395
+ : properties;
396
+ return animated.length
397
+ ? animated
398
+ .map((property) => `${property} ${duration}ms ${easing}`)
399
+ .join(', ')
400
+ : 'none';
401
+ }
402
+
403
+ /**
404
+ * Increases with every open or close request; a sequence that sees it
405
+ * change while it waits stops, so a quick close can't be overtaken by the
406
+ * open it interrupted.
407
+ */
408
+ #run = 0;
409
+ readonly #timers = new Set<ReturnType<typeof setTimeout>>();
410
+
411
+ /**
412
+ * Opens or closes as `open` changes. A field rather than a constructor:
413
+ * Angular's test build keeps a function for a constructor that nothing
414
+ * calls, which an app's coverage report would show as uncovered.
415
+ */
416
+ protected readonly openOrClose = effect(() => {
417
+ const open = this.open();
418
+ untracked(() => {
419
+ if (open) void this.#show();
420
+ else this.#hide();
421
+ });
422
+ });
423
+
424
+ /**
425
+ * The safe area's width as laid out, in the app's own pixels, from its
426
+ * `layoutchange` event; 0 until that arrives.
427
+ *
428
+ * `boundingClientRect` measures in the screen's pixels, and the docs' web
429
+ * previews draw the app scaled down to fit their pane, so there it gave
430
+ * boxes 0.68 of their size, and the menu opened in the wrong place and
431
+ * too short for its items. The previews hand `layoutchange` to the app in
432
+ * its own pixels, so the two widths give the scale to undo. On a phone
433
+ * they're the same.
434
+ */
435
+ #areaWidth = 0;
436
+
437
+ protected onAreaLayout(event: unknown): void {
438
+ this.#areaWidth =
439
+ (event as { detail?: { width?: number } }).detail?.width ?? 0;
440
+ }
441
+
442
+ /**
443
+ * No-op tap handler for the menu. `catchtap` (vs `bindtap`) stops a tap on
444
+ * the menu from reaching the backdrop behind it, which would dismiss it.
445
+ */
446
+ protected onPanelTap(): void {}
447
+
448
+ ngOnDestroy(): void {
449
+ this.#run++;
450
+ for (const timer of this.#timers) clearTimeout(timer);
451
+ }
452
+
453
+ /**
454
+ * Waits `ms`, with a timer ngOnDestroy can stop.
455
+ */
456
+ #wait(ms: number): Promise<void> {
457
+ return new Promise((resolve) => {
458
+ const timer = setTimeout(() => {
459
+ this.#timers.delete(timer);
460
+ resolve();
461
+ }, ms);
462
+ this.#timers.add(timer);
463
+ });
464
+ }
465
+
466
+ async #show(): Promise<void> {
467
+ const run = ++this.#run;
468
+ // Opened again while closing: finish closing at once.
469
+ if (this.phase() !== 'closed') this.#finishClose(false);
470
+
471
+ const anchor = await measureMenuElement(this.anchor());
472
+ if (run !== this.#run) return;
473
+ this.phase.set('measuring');
474
+
475
+ await this.#wait(LAYOUT_DELAY);
476
+ if (run !== this.#run) return;
477
+ const [area, content] = await Promise.all([
478
+ measureMenuElement(this.area().nativeElement),
479
+ measureMenuElement(this.content().nativeElement),
480
+ ]);
481
+ if (run !== this.#run) return;
482
+
483
+ // Measured boxes in the app's own pixels: see #areaWidth.
484
+ const scale =
485
+ area && this.#areaWidth > 0 ? area.width / this.#areaWidth : 1;
486
+ const inPixels = (box: MenuRect | null): MenuRect => ({
487
+ left: (box?.left ?? 0) / scale,
488
+ top: (box?.top ?? 0) / scale,
489
+ width: (box?.width ?? 0) / scale,
490
+ height: (box?.height ?? 0) / scale,
491
+ });
492
+ const areaRect = inPixels(area);
493
+ const anchorRect = inPixels(anchor);
494
+ const inArea: MenuRect = {
495
+ ...anchorRect,
496
+ left: anchorRect.left - areaRect.left,
497
+ top: anchorRect.top - areaRect.top,
498
+ };
499
+ this.#anchorRect.set(inArea);
500
+ this.placement.set(
501
+ placeMenu({
502
+ anchor: inArea,
503
+ area: areaRect,
504
+ menu: {
505
+ width: this.metrics().width,
506
+ height: inPixels(content).height,
507
+ },
508
+ mode: this.#material() || this.#context() ? 'beside' : 'grow',
509
+ movableAnchor: this.#lifts(),
510
+ }),
511
+ );
512
+ this.#lift();
513
+ this.phase.set('from');
514
+
515
+ await this.#wait(FRAME_DELAY);
516
+ if (run !== this.#run) return;
517
+ this.phase.set('open');
518
+ // Move a screen reader into the menu: it would otherwise stay on what
519
+ // opened it, now behind the overlay. With the menu's
520
+ // accessibility-exclusive-focus, it then stays there until it closes.
521
+ this.panel().nativeElement.invoke('requestAccessibilityFocus');
522
+ }
523
+
524
+ #hide(): void {
525
+ const run = ++this.#run;
526
+ const phase = this.phase();
527
+ // Only a menu that's open or opening closes: `open` turning false again
528
+ // reaches here only after it turned true, which ends any closing.
529
+ if (phase === 'closed') return;
530
+ if (phase === 'measuring') {
531
+ this.#finishClose(true);
532
+ return;
533
+ }
534
+ this.phase.set('closing');
535
+ // +20ms: Lynx's timers can fire a little early, and the menu would
536
+ // vanish before its transition ends.
537
+ void this.#wait(DURATION.normal + 20).then(() => {
538
+ if (run === this.#run) this.#finishClose(true);
539
+ });
540
+ }
541
+
542
+ /**
543
+ * Moves a context menu's element into the overlay, where it was.
544
+ */
545
+ #lift(): void {
546
+ if (!this.#lifts()) return;
547
+ this.placeholder.set(this.#anchorRect());
548
+ this.#renderer.appendChild(
549
+ this.slot().nativeElement,
550
+ this.anchor() as unknown,
551
+ );
552
+ this.#lifted = true;
553
+ }
554
+
555
+ #finishClose(emit: boolean): void {
556
+ if (this.#lifted) {
557
+ this.#renderer.appendChild(this.anchorHome(), this.anchor() as unknown);
558
+ this.#lifted = false;
559
+ }
560
+ this.placeholder.set(null);
561
+ this.placement.set(null);
562
+ this.phase.set('closed');
563
+ if (emit) this.closed.emit();
564
+ }
565
+ }