@blotch/dolan 22.2.4 → 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 (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. package/package.json +1 -1
@@ -0,0 +1,603 @@
1
+ import {
2
+ type OnDestroy,
3
+ Component,
4
+ ElementRef,
5
+ ViewEncapsulation,
6
+ booleanAttribute,
7
+ computed,
8
+ effect,
9
+ inject,
10
+ input,
11
+ output,
12
+ signal,
13
+ untracked,
14
+ viewChild,
15
+ } from '@angular/core';
16
+ import { LYNX_ELEMENTS, LynxAccessibilitySettings } from '@blotch/angular-lynx';
17
+
18
+ import { DURATION, EASING } from '../../utils/animate';
19
+ import { UiGlass } from '../glass/glass';
20
+ import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
21
+ import {
22
+ type MenuPlacement,
23
+ type MenuPlacementMode,
24
+ type MenuRect,
25
+ menuStartFrame,
26
+ placeMenu,
27
+ } from '../menu/menu-placement';
28
+ import {
29
+ type MenuElement,
30
+ type MenuPhase,
31
+ measureMenuElement,
32
+ } from '../menu/menu-popup';
33
+
34
+ /**
35
+ * An element a popover opens from: AngularLynx's element, which an
36
+ * `ElementRef` or a template reference to a Lynx element holds.
37
+ */
38
+ export type PopoverAnchor = MenuElement;
39
+
40
+ /**
41
+ * Where a popover is in its open and close sequence; see MenuPhase, whose
42
+ * steps it shares.
43
+ */
44
+ export type PopoverPhase = MenuPhase;
45
+
46
+ /** How each design draws and places the popover. */
47
+ type PopoverMetrics = {
48
+ /** Its corner radius, in px. */
49
+ readonly radius: number;
50
+ /** Over its anchor (`'grow'`), or beside it; see MenuPlacementMode. */
51
+ readonly mode: MenuPlacementMode;
52
+ /** The space between the popover and an anchor it opens beside. */
53
+ readonly gap: number;
54
+ /** Where it grows from; see menuStartFrame. */
55
+ readonly from: 'anchor' | 'corner' | 'edge';
56
+ };
57
+
58
+ /** The arrow of a popover before iOS 26: its base and its height, in px. */
59
+ export const ARROW_WIDTH = 26;
60
+ export const ARROW_HEIGHT = 13;
61
+ /**
62
+ * The side of the square turned 45° whose corner pokes out as the arrow: its
63
+ * diagonal is the arrow's base.
64
+ */
65
+ const ARROW_SIDE = ARROW_WIDTH / Math.SQRT2;
66
+
67
+ const METRICS: Readonly<Record<GlassDesign, PopoverMetrics>> = {
68
+ // iOS 26 grows a popover out of the control that presents it, over it,
69
+ // and the control hides until it closes (UIPopoverPresentationController's
70
+ // sourceItem): the pull-down menu's growth.
71
+ glass: { radius: 24, mode: 'grow', gap: 0, from: 'anchor' },
72
+ // iOS 15 to 25 put a popover beside its control, with an arrow pointing at
73
+ // it, a little way off.
74
+ frosted: {
75
+ radius: 13,
76
+ mode: 'beside',
77
+ gap: ARROW_HEIGHT + 2,
78
+ from: 'corner',
79
+ },
80
+ // Material 3's menu surface unfolds from the edge nearest its anchor.
81
+ material: { radius: 16, mode: 'beside', gap: 8, from: 'edge' },
82
+ };
83
+
84
+ /** The space the popover keeps from the screen's edges, in px. */
85
+ const MARGIN = 8;
86
+
87
+ /**
88
+ * How long the panel gets to lay out before it's measured: a frame or two.
89
+ */
90
+ const LAYOUT_DELAY = 32;
91
+
92
+ /**
93
+ * How long the panel waits at its start frame, so the native side draws it
94
+ * there before the transition to its frame starts.
95
+ */
96
+ const FRAME_DELAY = 16;
97
+
98
+ const ZERO_RECT: MenuRect = { left: 0, top: 0, width: 0, height: 0 };
99
+
100
+ /** A point, in px. */
101
+ type Offset = { readonly left: number; readonly top: number };
102
+
103
+ /**
104
+ * Where a popover's arrow goes before iOS 26, in the same coordinates as
105
+ * `placement`: on the edge of the popover that faces its anchor, pointing at
106
+ * the anchor's middle, but no nearer the corners than their `radius`.
107
+ *
108
+ * `left` and `top` are the corner of the arrow's `ARROW_WIDTH` ×
109
+ * `ARROW_HEIGHT` box, and `up` says it points up, at an anchor above the
110
+ * popover. Null when the popover couldn't open beside its anchor and covers
111
+ * it instead (see placeMenu), with nothing to point at.
112
+ */
113
+ export const popoverArrow = (
114
+ placement: MenuPlacement,
115
+ radius: number,
116
+ ): { left: number; top: number; up: boolean } | null => {
117
+ const { frame, anchor, vertical } = placement;
118
+ const up = vertical === 'top';
119
+ const beside = up
120
+ ? frame.top >= anchor.top + anchor.height
121
+ : frame.top + frame.height <= anchor.top;
122
+ if (!beside) return null;
123
+ const middle = anchor.left + anchor.width / 2;
124
+ const left = Math.min(
125
+ Math.max(middle - ARROW_WIDTH / 2, frame.left + radius),
126
+ frame.left + frame.width - radius - ARROW_WIDTH,
127
+ );
128
+ return {
129
+ left,
130
+ top: up ? frame.top - ARROW_HEIGHT : frame.top + frame.height,
131
+ up,
132
+ };
133
+ };
134
+
135
+ /**
136
+ * The panel of a popover and of an action sheet opened from a control:
137
+ * content that opens from the element that presents it and leaves the rest
138
+ * of the screen as it was. Nothing dims, and a touch anywhere else closes it
139
+ * and still reaches what it touched, so one tap can close a popover and
140
+ * press the button under the finger. `ui-popover` and an anchored
141
+ * `ui-action-sheet` hold one, and project their content into it.
142
+ *
143
+ * Unlike the menus' overlay, which is a window over the whole screen that
144
+ * takes every touch, the panel is a `position: fixed` view in the page: Lynx
145
+ * lifts it to the page root, above the content, and hit-tests it like any
146
+ * other view. It hears touches elsewhere through `global-bind` listeners,
147
+ * which Lynx runs for a touch on any element, and only while it's shown.
148
+ * It tells touches on itself apart with ordinary bubbling listeners: on
149
+ * iOS, Lynx 4.1 ran no `capture-bind` listener, on a view or a blur view.
150
+ *
151
+ * Opening measures the anchor, the screen's safe area (two marker views of
152
+ * no size, which cover nothing) and the content at its natural size off
153
+ * screen; places the panel with the pull-down menu's placeMenu; and grows it
154
+ * from its start frame (see menuStartFrame) with CSS transitions:
155
+ *
156
+ * - **iOS 26 and later** (`glass`): Liquid Glass that grows out of the
157
+ * anchor, over it, the way UIKit's popovers and action sheets grow out of
158
+ * their source item. The anchor hides meanwhile (see coversAnchor).
159
+ * - **iOS 15 to 25** (`frosted`): the frosted material beside the anchor,
160
+ * with an arrow pointing at it.
161
+ * - **Android** (`material`): Material 3's menu surface, which unfolds from
162
+ * the edge nearest the anchor on a solid surface with a shadow. Liquid
163
+ * Glass, as on iOS 26, with `provideGlass({ android: 'glass' })`.
164
+ *
165
+ * Under Reduce Motion it appears in place and only its content fades.
166
+ */
167
+ @Component({
168
+ selector: 'ui-popover-panel',
169
+ imports: [LYNX_ELEMENTS, UiGlass],
170
+ encapsulation: ViewEncapsulation.None,
171
+ template: `
172
+ <!-- The safe area's top edge, as wide as it and no taller, and an edge
173
+ from the top of the page to the bottom of the safe area, no wider:
174
+ where the panel has to stay, measured without covering anything. -->
175
+ <view
176
+ #area
177
+ style="position: fixed; top: env(safe-area-inset-top); left: env(safe-area-inset-left); right: env(safe-area-inset-right); height: 0;"
178
+ (bindlayoutchange)="onAreaLayout($event)"
179
+ ></view>
180
+ <view
181
+ #page
182
+ style="position: fixed; top: 0; left: 0; bottom: env(safe-area-inset-bottom); width: 0;"
183
+ ></view>
184
+ @if (visible()) {
185
+ @if (arrow(); as arrow) {
186
+ <!-- The arrow: a frosted square turned 45°, clipped to the half that
187
+ pokes out of the panel. -->
188
+ <view
189
+ class="overflow-hidden"
190
+ style="position: fixed; z-index: 999;"
191
+ [style.left.px]="arrow.left"
192
+ [style.top.px]="arrow.top"
193
+ [style.width.px]="arrowWidth"
194
+ [style.height.px]="arrowHeight"
195
+ (bindtouchstart)="touchedInside()"
196
+ (bindmousedown)="touchedInside()"
197
+ >
198
+ <blur-view
199
+ ui-glass
200
+ shape="rounded"
201
+ [radius]="3"
202
+ class="absolute"
203
+ style="transform: rotate(45deg);"
204
+ [style.left.px]="arrowSquare.left"
205
+ [style.top.px]="arrow.up ? arrowSquare.top : -arrowSquare.top"
206
+ [style.width.px]="arrowSquare.side"
207
+ [style.height.px]="arrowSquare.side"
208
+ ></blur-view>
209
+ </view>
210
+ }
211
+ <!-- Fixed, so Lynx lifts it to the page root, and stacked above the
212
+ root's other children. -->
213
+ <blur-view
214
+ #panel
215
+ ui-glass
216
+ shape="rounded"
217
+ [radius]="metrics().radius"
218
+ class="flex-col flex overflow-hidden"
219
+ style="position: fixed; z-index: 999;"
220
+ [style.left.px]="panelPosition().left"
221
+ [style.top.px]="panelPosition().top"
222
+ [style.width.px]="frame()?.width"
223
+ [style.height.px]="frame()?.height"
224
+ [glassId]="glassId()"
225
+ [style.opacity]="panelOpacity()"
226
+ [style.visibility]="panelVisibility()"
227
+ [style.transition]="panelTransition()"
228
+ [style.align-items]="
229
+ placement()?.horizontal === 'right' ? 'flex-end' : 'flex-start'
230
+ "
231
+ [style.justify-content]="
232
+ placement()?.vertical === 'bottom' ? 'flex-end' : 'flex-start'
233
+ "
234
+ (bindtouchstart)="touchedInside()"
235
+ (bindmousedown)="touchedInside()"
236
+ (global-bindtouchstart)="touchedAnywhere()"
237
+ (global-bindmousedown)="touchedAnywhere()"
238
+ >
239
+ <!-- Fixed at the panel's size once it's measured, and pinned to the
240
+ edge nearest the anchor, so the content stays put while the
241
+ glass grows around it instead of reflowing. -->
242
+ <view
243
+ #content
244
+ class="shrink-0 flex-col flex"
245
+ [style.width.px]="placement()?.frame.width ?? width()"
246
+ [style.height.px]="placement()?.frame.height"
247
+ [style.max-width.px]="maxWidth()"
248
+ [style.opacity]="phase() === 'open' ? 1 : 0"
249
+ [style.transition]="contentTransition()"
250
+ >
251
+ <ng-content />
252
+ </view>
253
+ </blur-view>
254
+ }
255
+ `,
256
+ })
257
+ export class UiPopoverPanel implements OnDestroy {
258
+ /** Whether the panel is open. */
259
+ readonly open = input(false, { transform: booleanAttribute });
260
+ /** The element it opens from. */
261
+ readonly anchor = input.required<PopoverAnchor>();
262
+ /**
263
+ * The panel's width, in px. By default, its content's natural width, as
264
+ * wide as the screen at most.
265
+ */
266
+ readonly width = input<number | undefined>(undefined);
267
+ /**
268
+ * The id the panel's glass shares with its anchor's (see UiGlass's
269
+ * `glassId`), so that on iOS the panel takes over a glass anchor's glass.
270
+ */
271
+ readonly glassId = input<string | undefined>(undefined);
272
+
273
+ /** A touch landed outside the panel (and outside its anchor). */
274
+ readonly dismissed = output<void>();
275
+ /** The panel has closed and its close animation has finished. */
276
+ readonly closed = output<void>();
277
+
278
+ readonly #settings = inject(LynxAccessibilitySettings);
279
+
280
+ readonly design = injectGlassDesign();
281
+ readonly phase = signal<PopoverPhase>('closed');
282
+ readonly placement = signal<MenuPlacement | null>(null);
283
+ readonly metrics = computed(() => METRICS[this.design()]);
284
+
285
+ // Required: the markers are always in the template, and the panel and
286
+ // its content while it's shown, the only time they're read.
287
+ readonly area = viewChild.required<ElementRef<PopoverAnchor>>('area');
288
+ readonly page = viewChild.required<ElementRef<PopoverAnchor>>('page');
289
+ readonly panel = viewChild.required('panel', { read: ElementRef });
290
+ readonly content = viewChild.required<ElementRef<PopoverAnchor>>('content');
291
+
292
+ protected readonly arrowWidth = ARROW_WIDTH;
293
+ protected readonly arrowHeight = ARROW_HEIGHT;
294
+ /**
295
+ * The turned square in the arrow's box: centered on the panel's edge, so
296
+ * half of it shows. `top` is for an arrow pointing up; one pointing down
297
+ * hangs it from the box's top instead.
298
+ */
299
+ protected readonly arrowSquare = {
300
+ side: ARROW_SIDE,
301
+ left: (ARROW_WIDTH - ARROW_SIDE) / 2,
302
+ top: ARROW_HEIGHT - ARROW_SIDE / 2,
303
+ };
304
+
305
+ readonly #reduceMotion = computed(() => this.#settings.reduceMotion());
306
+ readonly #material = computed(() => this.design() === 'material');
307
+
308
+ protected readonly visible = computed(() => this.phase() !== 'closed');
309
+
310
+ /**
311
+ * Whether the panel covers its anchor now: Liquid Glass grows out of it
312
+ * and shrinks back into it, so the anchor hides meanwhile, as on iOS 26.
313
+ * The other designs open beside it.
314
+ *
315
+ * The anchor hides with `visibility`, in the same update that shows the
316
+ * panel at its place (see panelVisibility), and shows in the one that
317
+ * takes the panel away. On iOS 26 and later, the shell pairs a glass
318
+ * anchor with the panel across those changes (see glassId): the panel
319
+ * takes over the anchor's corners, and the anchor's content fades out in
320
+ * it, and back in as the panel closes.
321
+ */
322
+ readonly coversAnchor = computed(() => {
323
+ const phase = this.phase();
324
+ return (
325
+ this.design() === 'glass' && phase !== 'closed' && phase !== 'measuring'
326
+ );
327
+ });
328
+
329
+ /**
330
+ * Where the safe area's top-left corner is in the page, whose corner the
331
+ * fixed panel is placed from.
332
+ */
333
+ readonly #offset = signal<Offset>({ left: 0, top: 0 });
334
+
335
+ /** The panel's box in the safe area: off screen while it's measured. */
336
+ protected readonly frame = computed<MenuRect | null>(() => {
337
+ const placement = this.placement();
338
+ const phase = this.phase();
339
+ if (!placement || phase === 'measuring') return null;
340
+ if (phase === 'open' || this.#reduceMotion()) return placement.frame;
341
+ return menuStartFrame(placement, this.metrics().from);
342
+ });
343
+
344
+ protected readonly panelPosition = computed<Offset>(() => {
345
+ const frame = this.frame();
346
+ const offset = this.#offset();
347
+ return frame
348
+ ? { left: offset.left + frame.left, top: offset.top + frame.top }
349
+ : { left: -10000, top: 0 };
350
+ });
351
+
352
+ /**
353
+ * The arrow before iOS 26, once the panel is open, in the page. It doesn't
354
+ * grow, so it shows only while the panel is at its frame.
355
+ */
356
+ protected readonly arrow = computed(() => {
357
+ const placement = this.placement();
358
+ if (this.design() !== 'frosted' || !placement) return null;
359
+ if (this.phase() !== 'open') return null;
360
+ const arrow = popoverArrow(placement, this.metrics().radius);
361
+ const offset = this.#offset();
362
+ return (
363
+ arrow && {
364
+ ...arrow,
365
+ left: offset.left + arrow.left,
366
+ top: offset.top + arrow.top,
367
+ }
368
+ );
369
+ });
370
+
371
+ /**
372
+ * The content's widest, until it's placed: the safe area, inside the
373
+ * margins, so long text wraps rather than running off the screen.
374
+ */
375
+ protected readonly maxWidth = computed(() =>
376
+ this.#areaWidth() > 0 ? this.#areaWidth() - 2 * MARGIN : null,
377
+ );
378
+
379
+ /**
380
+ * Material's solid surface fades as it unfolds. Glass doesn't fade: iOS
381
+ * draws a blur view with less than full opacity wrongly, so it only grows
382
+ * and shrinks, and its content fades.
383
+ */
384
+ protected readonly panelOpacity = computed(() =>
385
+ this.#material() && this.phase() !== 'open' ? 0 : 1,
386
+ );
387
+ protected readonly panelTransition = computed(() =>
388
+ this.#transition(['left', 'top', 'width', 'height', 'opacity']),
389
+ );
390
+
391
+ /**
392
+ * The panel is hidden while it's measured, so it shows in the same update
393
+ * that hides its anchor (see coversAnchor).
394
+ */
395
+ protected readonly panelVisibility = computed(() =>
396
+ this.phase() === 'measuring' ? 'hidden' : null,
397
+ );
398
+ protected readonly contentTransition = computed(() =>
399
+ this.#transition(['opacity']),
400
+ );
401
+
402
+ /**
403
+ * A transition of `properties` for the phase: springy as the panel opens,
404
+ * quicker as it closes, none while it's placed. Under Reduce Motion
405
+ * nothing moves or grows, and only the fades play.
406
+ */
407
+ #transition(properties: readonly string[]): string {
408
+ const phase = this.phase();
409
+ if (phase !== 'open' && phase !== 'closing') return 'none';
410
+ const [duration, easing] =
411
+ phase === 'open'
412
+ ? [DURATION.slow, EASING.springSubtle]
413
+ : [DURATION.normal, EASING.standard];
414
+ const animated = this.#reduceMotion()
415
+ ? properties.filter((property) => property === 'opacity')
416
+ : properties;
417
+ return animated
418
+ .map((property) => `${property} ${duration}ms ${easing}`)
419
+ .join(', ');
420
+ }
421
+
422
+ /**
423
+ * Increases with every open or close request; a sequence that sees it
424
+ * change while it waits stops, so a quick close can't be overtaken by the
425
+ * open it interrupted.
426
+ */
427
+ #run = 0;
428
+ readonly #timers = new Set<ReturnType<typeof setTimeout>>();
429
+
430
+ /**
431
+ * Opens or closes as `open` changes. A field rather than a constructor:
432
+ * Angular's test build keeps a function for a constructor that nothing
433
+ * calls, which an app's coverage report would show as uncovered.
434
+ */
435
+ protected readonly openOrClose = effect(() => {
436
+ const open = this.open();
437
+ untracked(() => {
438
+ if (open) void this.#show();
439
+ else this.#hide();
440
+ });
441
+ });
442
+
443
+ /**
444
+ * The safe area's width as laid out, in the app's own pixels, from its
445
+ * `layoutchange` event; 0 until that arrives. `boundingClientRect`
446
+ * measures in the screen's pixels, which the docs' web previews scale
447
+ * down, so the two give the scale to undo (see UiMenuPopup, which does
448
+ * the same).
449
+ */
450
+ readonly #areaWidth = signal(0);
451
+
452
+ protected onAreaLayout(event: unknown): void {
453
+ this.#areaWidth.set(
454
+ (event as { detail?: { width?: number } }).detail?.width ?? 0,
455
+ );
456
+ }
457
+
458
+ /**
459
+ * Whether the touch going on now began on the panel or its anchor; see
460
+ * touchedAnywhere.
461
+ */
462
+ #touchedInside = false;
463
+
464
+ /**
465
+ * A touch began on the panel (or its arrow).
466
+ */
467
+ protected touchedInside(): void {
468
+ this.#touchedInside = true;
469
+ }
470
+
471
+ /**
472
+ * A touch began on the anchor, which the element that presents the panel
473
+ * reports: touching it toggles the panel, so it doesn't count as outside.
474
+ */
475
+ touchedAnchor(): void {
476
+ if (this.visible()) this.#touchedInside = true;
477
+ }
478
+
479
+ /**
480
+ * A touch began anywhere: closes the panel unless it began on the panel
481
+ * or the anchor. Lynx runs a `global-bind` listener in the same dispatch
482
+ * as the touched element's own listeners, before them on iOS, so the
483
+ * check waits for the next timer, which runs after them all. Mouse
484
+ * presses count too, for the web preview, where touch events come only
485
+ * from a touch screen.
486
+ */
487
+ protected touchedAnywhere(): void {
488
+ void this.#wait(0).then(() => {
489
+ const inside = this.#touchedInside;
490
+ this.#touchedInside = false;
491
+ if (!inside) this.dismissed.emit();
492
+ });
493
+ }
494
+
495
+ ngOnDestroy(): void {
496
+ this.#run++;
497
+ for (const timer of this.#timers) clearTimeout(timer);
498
+ }
499
+
500
+ /**
501
+ * Waits `ms`, with a timer ngOnDestroy can stop.
502
+ */
503
+ #wait(ms: number): Promise<void> {
504
+ return new Promise((resolve) => {
505
+ const timer = setTimeout(() => {
506
+ this.#timers.delete(timer);
507
+ resolve();
508
+ }, ms);
509
+ this.#timers.add(timer);
510
+ });
511
+ }
512
+
513
+ async #show(): Promise<void> {
514
+ const run = ++this.#run;
515
+ // Opened again while closing: finish closing at once.
516
+ if (this.phase() !== 'closed') this.#finishClose(false);
517
+ this.#touchedInside = false;
518
+
519
+ const anchor = await measureMenuElement(this.anchor());
520
+ if (run !== this.#run) return;
521
+ this.phase.set('measuring');
522
+
523
+ await this.#wait(LAYOUT_DELAY);
524
+ if (run !== this.#run) return;
525
+ const [area, page, content] = await Promise.all([
526
+ measureMenuElement(this.area().nativeElement),
527
+ measureMenuElement(this.page().nativeElement),
528
+ measureMenuElement(this.content().nativeElement),
529
+ ]);
530
+ if (run !== this.#run) return;
531
+
532
+ // Measured boxes in the app's own pixels: see #areaWidth.
533
+ const areaWidth = this.#areaWidth();
534
+ const scale = area && areaWidth > 0 ? area.width / areaWidth : 1;
535
+ const inPixels = (box: MenuRect | null): MenuRect => ({
536
+ left: (box ?? ZERO_RECT).left / scale,
537
+ top: (box ?? ZERO_RECT).top / scale,
538
+ width: (box ?? ZERO_RECT).width / scale,
539
+ height: (box ?? ZERO_RECT).height / scale,
540
+ });
541
+ const areaBox = inPixels(area);
542
+ const pageBox = inPixels(page);
543
+ const anchorBox = inPixels(anchor);
544
+ const metrics = this.metrics();
545
+ this.#offset.set({
546
+ left: areaBox.left - pageBox.left,
547
+ top: areaBox.top - pageBox.top,
548
+ });
549
+ this.placement.set(
550
+ placeMenu({
551
+ anchor: {
552
+ ...anchorBox,
553
+ left: anchorBox.left - areaBox.left,
554
+ top: anchorBox.top - areaBox.top,
555
+ },
556
+ area: {
557
+ width: areaBox.width,
558
+ height: pageBox.top + pageBox.height - areaBox.top,
559
+ },
560
+ menu: {
561
+ width: this.width() ?? inPixels(content).width,
562
+ height: inPixels(content).height,
563
+ },
564
+ mode: metrics.mode,
565
+ gap: metrics.gap,
566
+ margin: MARGIN,
567
+ }),
568
+ );
569
+ this.phase.set('from');
570
+
571
+ await this.#wait(FRAME_DELAY);
572
+ if (run !== this.#run) return;
573
+ this.phase.set('open');
574
+ // Move a screen reader into the panel: it would otherwise stay on what
575
+ // opened it, which the panel may now cover. The rest of the screen
576
+ // stays reachable, as the panel doesn't take over the screen.
577
+ this.panel().nativeElement.invoke('requestAccessibilityFocus');
578
+ }
579
+
580
+ #hide(): void {
581
+ const run = ++this.#run;
582
+ const phase = this.phase();
583
+ // Only a panel that's open or opening closes: `open` turning false again
584
+ // reaches here only after it turned true, which ends any closing.
585
+ if (phase === 'closed') return;
586
+ if (phase === 'measuring') {
587
+ this.#finishClose(true);
588
+ return;
589
+ }
590
+ this.phase.set('closing');
591
+ // +20ms: Lynx's timers can fire a little early, and the panel would
592
+ // vanish before its transition ends.
593
+ void this.#wait(DURATION.normal + 20).then(() => {
594
+ if (run === this.#run) this.#finishClose(true);
595
+ });
596
+ }
597
+
598
+ #finishClose(emit: boolean): void {
599
+ this.placement.set(null);
600
+ this.phase.set('closed');
601
+ if (emit) this.closed.emit();
602
+ }
603
+ }