@blotch/dolan 22.2.2 → 22.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/add.d.ts +6 -2
- package/dist/commands/add.js +41 -11
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/add.test.js +162 -9
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/diff.d.ts +2 -1
- package/dist/commands/diff.js +38 -25
- package/dist/commands/diff.js.map +1 -1
- package/dist/commands/diff.test.js +72 -11
- package/dist/commands/diff.test.js.map +1 -1
- package/dist/commands/doctor.js +228 -34
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +367 -10
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/eject.js +7 -0
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +39 -0
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/info.js +3 -2
- package/dist/commands/info.js.map +1 -1
- package/dist/commands/init.d.ts +37 -1
- package/dist/commands/init.js +169 -40
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +392 -27
- package/dist/commands/init.test.js.map +1 -1
- package/dist/commands/install-packages.d.ts +6 -0
- package/dist/commands/install-packages.js +20 -0
- package/dist/commands/install-packages.js.map +1 -0
- package/dist/commands/install-packages.test.d.ts +1 -0
- package/dist/commands/install-packages.test.js +55 -0
- package/dist/commands/install-packages.test.js.map +1 -0
- package/dist/commands/list.js +21 -14
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/list.test.js +62 -5
- package/dist/commands/list.test.js.map +1 -1
- package/dist/commands/outdated.js +11 -10
- package/dist/commands/outdated.js.map +1 -1
- package/dist/commands/outdated.test.js +20 -5
- package/dist/commands/outdated.test.js.map +1 -1
- package/dist/commands/remove.d.ts +4 -0
- package/dist/commands/remove.js +17 -4
- package/dist/commands/remove.js.map +1 -1
- package/dist/commands/remove.test.js +53 -2
- package/dist/commands/remove.test.js.map +1 -1
- package/dist/commands/theme.js +1 -1
- package/dist/commands/theme.js.map +1 -1
- package/dist/commands/update.d.ts +11 -0
- package/dist/commands/update.js +174 -58
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +587 -33
- package/dist/commands/update.test.js.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/lockfile.d.ts +7 -0
- package/dist/lockfile.js +36 -0
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.d.ts +1 -0
- package/dist/lockfile.test.js +25 -0
- package/dist/lockfile.test.js.map +1 -0
- package/dist/registry.d.ts +14 -0
- package/dist/registry.js +78 -24
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +119 -38
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +5 -0
- package/dist/test-utils.js +6 -1
- package/dist/test-utils.js.map +1 -1
- package/dist/testing/fire-lynx-event.d.ts +14 -0
- package/dist/testing/fire-lynx-event.js +19 -0
- package/dist/testing/fire-lynx-event.js.map +1 -0
- package/dist/testing/fire-lynx-event.test.d.ts +1 -0
- package/dist/testing/fire-lynx-event.test.js +44 -0
- package/dist/testing/fire-lynx-event.test.js.map +1 -0
- package/dist/testing/lynx-main-thread.d.ts +22 -0
- package/dist/testing/lynx-main-thread.js +11 -0
- package/dist/testing/lynx-main-thread.js.map +1 -0
- package/dist/testing/lynx-main-thread.test.d.ts +1 -0
- package/dist/testing/lynx-main-thread.test.js +24 -0
- package/dist/testing/lynx-main-thread.test.js.map +1 -0
- package/dist/testing/set-input-signal.d.ts +14 -0
- package/dist/testing/set-input-signal.js +21 -0
- package/dist/testing/set-input-signal.js.map +1 -0
- package/dist/testing/set-input-signal.test.d.ts +1 -0
- package/dist/testing/set-input-signal.test.js +20 -0
- package/dist/testing/set-input-signal.test.js.map +1 -0
- package/dist/ui/components/accordion/accordion.test.ts +158 -144
- package/dist/ui/components/accordion/accordion.ts +19 -14
- package/dist/ui/components/action-sheet/action-sheet.test.ts +283 -151
- package/dist/ui/components/action-sheet/action-sheet.ts +77 -43
- package/dist/ui/components/alert/alert.test.ts +67 -19
- package/dist/ui/components/alert/alert.ts +14 -16
- package/dist/ui/components/alert-dialog/alert-dialog.test.ts +208 -94
- package/dist/ui/components/alert-dialog/alert-dialog.ts +75 -38
- package/dist/ui/components/area-chart/area-chart.test.ts +134 -119
- package/dist/ui/components/area-chart/area-chart.ts +100 -315
- package/dist/ui/components/area-chart/index.ts +2 -3
- package/dist/ui/components/avatar/avatar.test.ts +89 -45
- package/dist/ui/components/badge/badge.test.ts +57 -23
- package/dist/ui/components/badge/badge.ts +12 -14
- package/dist/ui/components/bar-chart/bar-chart.test.ts +172 -45
- package/dist/ui/components/bar-chart/bar-chart.ts +140 -294
- package/dist/ui/components/bar-chart/index.ts +3 -3
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +386 -128
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +170 -53
- package/dist/ui/components/button/button.test.ts +611 -2
- package/dist/ui/components/button/button.ts +434 -49
- package/dist/ui/components/button/index.ts +2 -0
- package/dist/ui/components/button-group/button-group.test.ts +484 -2
- package/dist/ui/components/button-group/button-group.ts +380 -68
- package/dist/ui/components/button-group/index.ts +3 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +183 -58
- package/dist/ui/components/candlestick-chart/candlestick-chart.ts +150 -323
- package/dist/ui/components/candlestick-chart/index.ts +3 -4
- package/dist/ui/components/card/card.test.ts +82 -62
- package/dist/ui/components/chart/chart-focus.test.ts +132 -0
- package/dist/ui/components/chart/chart-focus.ts +115 -0
- package/dist/ui/components/chart/chart-presets.test.ts +335 -0
- package/dist/ui/components/chart/chart-presets.ts +278 -0
- package/dist/ui/components/chart/chart-scene.test.ts +589 -0
- package/dist/ui/components/chart/chart-scene.ts +483 -0
- package/dist/ui/components/chart/chart-theme.test.ts +85 -0
- package/dist/ui/components/chart/chart-theme.ts +210 -0
- package/dist/ui/components/chart/chart-zoom.test.ts +159 -0
- package/dist/ui/components/chart/chart-zoom.ts +162 -0
- package/dist/ui/components/chart/chart.test.ts +1017 -0
- package/dist/ui/components/chart/chart.ts +934 -0
- package/dist/ui/components/chart/index.ts +24 -0
- package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
- package/dist/ui/components/checkbox/checkbox.ts +23 -23
- package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
- package/dist/ui/components/collapsible/collapsible.ts +19 -14
- package/dist/ui/components/context-menu/context-menu.test.ts +224 -0
- package/dist/ui/components/context-menu/context-menu.ts +110 -0
- package/dist/ui/components/context-menu/index.ts +1 -0
- package/dist/ui/components/dialog/dialog.test.ts +213 -108
- package/dist/ui/components/dialog/dialog.ts +72 -35
- package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
- package/dist/ui/components/dock/dock-scroll.ts +133 -0
- package/dist/ui/components/dock/dock.test.ts +749 -0
- package/dist/ui/components/dock/dock.ts +960 -0
- package/dist/ui/components/dock/index.ts +2 -0
- package/dist/ui/components/empty-state/empty-state.test.ts +28 -39
- package/dist/ui/components/glass/concentric-radius.test.ts +13 -0
- package/dist/ui/components/glass/concentric-radius.ts +17 -0
- package/dist/ui/components/glass/glass-design.test.ts +78 -0
- package/dist/ui/components/glass/glass-design.ts +119 -0
- package/dist/ui/components/glass/glass.test.ts +338 -0
- package/dist/ui/components/glass/glass.ts +360 -0
- package/dist/ui/components/glass/index.ts +18 -0
- package/dist/ui/components/icon/icon-theme.test.ts +169 -0
- package/dist/ui/components/icon/icon-theme.ts +227 -0
- package/dist/ui/components/icon/icon.test.ts +85 -25
- package/dist/ui/components/icon/icon.ts +38 -21
- package/dist/ui/components/input/input.test.ts +178 -101
- package/dist/ui/components/input/input.ts +15 -12
- package/dist/ui/components/label/label.test.ts +11 -24
- package/dist/ui/components/line-chart/index.ts +2 -3
- package/dist/ui/components/line-chart/line-chart.test.ts +149 -56
- package/dist/ui/components/line-chart/line-chart.ts +83 -241
- package/dist/ui/components/list/list.test.ts +119 -21
- package/dist/ui/components/list/list.ts +21 -15
- package/dist/ui/components/menu/index.ts +25 -0
- package/dist/ui/components/menu/menu-placement.test.ts +153 -0
- package/dist/ui/components/menu/menu-placement.ts +159 -0
- package/dist/ui/components/menu/menu-popup.test.ts +632 -0
- package/dist/ui/components/menu/menu-popup.ts +544 -0
- package/dist/ui/components/menu/menu.test.ts +429 -0
- package/dist/ui/components/menu/menu.ts +415 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +435 -191
- package/dist/ui/components/nav-drawer/nav-drawer.ts +127 -56
- package/dist/ui/components/pagination/pagination.test.ts +177 -65
- package/dist/ui/components/pagination/pagination.ts +55 -8
- package/dist/ui/components/progress/progress.test.ts +102 -46
- package/dist/ui/components/progress/progress.ts +52 -54
- package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
- package/dist/ui/components/radio-group/radio-group.ts +24 -24
- package/dist/ui/components/scatter-chart/index.ts +2 -3
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +131 -26
- package/dist/ui/components/scatter-chart/scatter-chart.ts +104 -250
- package/dist/ui/components/scroll-area/scroll-area.test.ts +95 -24
- package/dist/ui/components/scroll-area/scroll-area.ts +8 -8
- package/dist/ui/components/select/select.test.ts +138 -78
- package/dist/ui/components/select/select.ts +17 -11
- package/dist/ui/components/separator/separator.test.ts +18 -17
- package/dist/ui/components/sheet/sheet.test.ts +64 -11
- package/dist/ui/components/sheet/sheet.ts +9 -1
- package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
- package/dist/ui/components/skeleton/skeleton.ts +10 -16
- package/dist/ui/components/spinner/spinner.test.ts +67 -22
- package/dist/ui/components/spinner/spinner.ts +48 -41
- package/dist/ui/components/switch/switch.test.ts +116 -46
- package/dist/ui/components/switch/switch.ts +8 -13
- package/dist/ui/components/tab-bar/index.ts +1 -0
- package/dist/ui/components/tab-bar/tab-bar.test.ts +280 -0
- package/dist/ui/components/tab-bar/tab-bar.ts +230 -0
- package/dist/ui/components/tabs/tabs.test.ts +215 -157
- package/dist/ui/components/tabs/tabs.ts +110 -118
- package/dist/ui/components/textarea/textarea.test.ts +206 -100
- package/dist/ui/components/textarea/textarea.ts +14 -12
- package/dist/ui/components/toast/toast.test.ts +436 -315
- package/dist/ui/components/toast/toast.ts +62 -55
- package/dist/ui/components/toggle/toggle.test.ts +247 -140
- package/dist/ui/theme/chart-colors.test.ts +35 -0
- package/dist/ui/theme/dark.css +46 -2
- package/dist/ui/theme/default.css +63 -7
- package/dist/ui/theme/icon-colors.test.ts +35 -0
- package/dist/ui/theme/rgb-channels.test.ts +84 -0
- package/dist/ui/theme/tailwind-build.test.ts +114 -0
- package/dist/ui/theme/tailwind-css.test.ts +83 -0
- package/dist/ui/theme/tailwind-plugin.test.ts +42 -2
- package/dist/ui/theme/tailwind-plugin.ts +98 -25
- package/dist/ui/theme/tailwind.css +113 -0
- package/dist/ui/utils/animate.test.ts +13 -1
- package/dist/utils/analyze.d.ts +14 -0
- package/dist/utils/analyze.js +15 -0
- package/dist/utils/analyze.js.map +1 -1
- package/dist/utils/analyze.test.js +14 -1
- package/dist/utils/analyze.test.js.map +1 -1
- package/dist/utils/installed.d.ts +29 -0
- package/dist/utils/installed.js +43 -0
- package/dist/utils/installed.js.map +1 -0
- package/dist/utils/installed.test.d.ts +1 -0
- package/dist/utils/installed.test.js +57 -0
- package/dist/utils/installed.test.js.map +1 -0
- package/dist/utils/interactive.d.ts +9 -0
- package/dist/utils/interactive.js +10 -0
- package/dist/utils/interactive.js.map +1 -0
- package/dist/utils/interactive.test.d.ts +1 -0
- package/dist/utils/interactive.test.js +24 -0
- package/dist/utils/interactive.test.js.map +1 -0
- package/dist/utils/packages.d.ts +36 -0
- package/dist/utils/packages.js +70 -0
- package/dist/utils/packages.js.map +1 -0
- package/dist/utils/packages.test.d.ts +1 -0
- package/dist/utils/packages.test.js +89 -0
- package/dist/utils/packages.test.js.map +1 -0
- package/dist/utils/resolve-paths.d.ts +17 -1
- package/dist/utils/resolve-paths.js +21 -3
- package/dist/utils/resolve-paths.js.map +1 -1
- package/dist/utils/resolve-paths.test.js +38 -2
- package/dist/utils/resolve-paths.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +25 -0
- package/dist/utils/tailwind.js +51 -0
- package/dist/utils/tailwind.js.map +1 -0
- package/dist/utils/tailwind.test.d.ts +1 -0
- package/dist/utils/tailwind.test.js +71 -0
- package/dist/utils/tailwind.test.js.map +1 -0
- package/package.json +13 -1
- package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -121
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -174
- package/dist/ui/components/button/button-render.test.ts +0 -113
- package/dist/ui/components/button-group/button-group-render.test.ts +0 -146
- package/dist/ui/components/candlestick-chart/candlestick-chart-render.test.ts +0 -174
- package/dist/ui/components/cartesian-chart/cartesian-chart-render.test.ts +0 -188
- package/dist/ui/components/cartesian-chart/cartesian-chart.test.ts +0 -525
- package/dist/ui/components/cartesian-chart/cartesian-chart.ts +0 -1140
- package/dist/ui/components/cartesian-chart/index.ts +0 -18
- package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -121
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -157
|
@@ -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
|
+
}
|