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