@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.
- package/dist/commands/add.test.js +2 -0
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +78 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +167 -26
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.test.js +4 -7
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/update.d.ts +3 -1
- package/dist/commands/update.js +78 -15
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +257 -2
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +10 -0
- package/dist/lockfile.js +20 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.js +43 -3
- package/dist/lockfile.test.js.map +1 -1
- package/dist/registry.js +73 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +16 -3
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +24 -2
- package/dist/test-utils.js +41 -2
- package/dist/test-utils.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -0
- package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
- package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
- package/dist/ui/components/action-sheet/index.ts +1 -0
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
- package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- package/dist/ui/components/badge/badge.ts +2 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +36 -1
- package/dist/ui/components/button/button.ts +17 -7
- package/dist/ui/components/button-group/button-group.test.ts +53 -9
- package/dist/ui/components/button-group/button-group.ts +67 -21
- package/dist/ui/components/card/card.test.ts +16 -0
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.ts +6 -6
- package/dist/ui/components/chart/chart.test.ts +14 -0
- package/dist/ui/components/chart/chart.ts +10 -3
- package/dist/ui/components/checkbox/checkbox.ts +2 -1
- package/dist/ui/components/collapsible/collapsible.ts +2 -1
- package/dist/ui/components/dialog/dialog.ts +6 -6
- package/dist/ui/components/dock/dock-scroll.ts +9 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +4 -1
- package/dist/ui/components/dock/dock.ts +529 -51
- package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
- package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
- package/dist/ui/components/dynamic-color/index.ts +5 -0
- package/dist/ui/components/glass/glass.test.ts +39 -1
- package/dist/ui/components/glass/glass.ts +87 -17
- package/dist/ui/components/glass/index.ts +1 -0
- package/dist/ui/components/icon/icon-theme.test.ts +28 -0
- package/dist/ui/components/icon/icon-theme.ts +38 -7
- package/dist/ui/components/input/input.ts +2 -1
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.ts +5 -4
- package/dist/ui/components/menu/menu-popup.test.ts +18 -14
- package/dist/ui/components/menu/menu-popup.ts +24 -3
- package/dist/ui/components/menu/menu.test.ts +99 -5
- package/dist/ui/components/menu/menu.ts +74 -34
- package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
- package/dist/ui/components/navigation-bar/index.ts +1 -0
- package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
- package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
- package/dist/ui/components/popover/index.ts +9 -0
- package/dist/ui/components/popover/popover-panel.test.ts +735 -0
- package/dist/ui/components/popover/popover-panel.ts +603 -0
- package/dist/ui/components/popover/popover.test.ts +286 -0
- package/dist/ui/components/popover/popover.ts +207 -0
- package/dist/ui/components/progress/progress.ts +2 -1
- package/dist/ui/components/radio-group/radio-group.ts +2 -1
- package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
- package/dist/ui/components/search-field/index.ts +15 -0
- package/dist/ui/components/search-field/keyboard.test.ts +147 -0
- package/dist/ui/components/search-field/keyboard.ts +123 -0
- package/dist/ui/components/search-field/search-field.test.ts +549 -0
- package/dist/ui/components/search-field/search-field.ts +628 -0
- package/dist/ui/components/segmented-control/index.ts +8 -0
- package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
- package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
- package/dist/ui/components/select/select.ts +2 -1
- package/dist/ui/components/sheet/sheet.test.ts +90 -3
- package/dist/ui/components/sheet/sheet.ts +59 -4
- package/dist/ui/components/slider/index.ts +1 -0
- package/dist/ui/components/slider/slider.test.ts +695 -0
- package/dist/ui/components/slider/slider.ts +901 -0
- package/dist/ui/components/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +464 -48
- package/dist/ui/components/switch/switch.ts +613 -43
- package/dist/ui/components/tabs/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +278 -2
- package/dist/ui/components/tabs/tabs.ts +233 -30
- package/dist/ui/components/textarea/textarea.ts +2 -1
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +310 -4
- package/dist/ui/components/toggle/toggle.ts +241 -26
- package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
- package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
- package/dist/ui/components/toolbar/index.ts +12 -0
- package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
- package/dist/ui/components/toolbar/toolbar.ts +535 -0
- package/dist/utils/analyze.d.ts +10 -5
- package/dist/utils/analyze.js +3 -2
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/installed.d.ts +13 -1
- package/dist/utils/installed.js +17 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +39 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -5
- package/dist/utils/tailwind.js +53 -14
- package/dist/utils/tailwind.js.map +1 -1
- package/dist/utils/tailwind.test.js +85 -22
- package/dist/utils/tailwind.test.js.map +1 -1
- 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
|
+
}
|