@blotch/dolan 22.2.3 → 22.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/add.js +9 -1
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/add.test.js +38 -3
- package/dist/commands/add.test.js.map +1 -1
- package/dist/commands/doctor.js +258 -21
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +425 -11
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/init.d.ts +2 -2
- package/dist/commands/init.js +38 -18
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/init.test.js +105 -2
- package/dist/commands/init.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 +7 -0
- package/dist/commands/update.js +194 -50
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/update.test.js +570 -3
- package/dist/commands/update.test.js.map +1 -1
- package/dist/lockfile.d.ts +17 -0
- package/dist/lockfile.js +56 -1
- package/dist/lockfile.js.map +1 -1
- package/dist/lockfile.test.d.ts +1 -0
- package/dist/lockfile.test.js +65 -0
- package/dist/lockfile.test.js.map +1 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.js +119 -11
- package/dist/registry.js.map +1 -1
- package/dist/registry.test.js +61 -6
- package/dist/registry.test.js.map +1 -1
- package/dist/test-utils.d.ts +27 -0
- package/dist/test-utils.js +45 -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 +5 -4
- package/dist/testing/set-input-signal.js +5 -4
- package/dist/testing/set-input-signal.js.map +1 -1
- package/dist/ui/boolean-inputs.test.ts +76 -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 +637 -183
- package/dist/ui/components/action-sheet/action-sheet.ts +339 -55
- package/dist/ui/components/action-sheet/index.ts +1 -0
- 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 +740 -129
- package/dist/ui/components/alert-dialog/alert-dialog.ts +672 -58
- package/dist/ui/components/alert-dialog/index.ts +5 -0
- package/dist/ui/components/area-chart/area-chart.test.ts +81 -1
- package/dist/ui/components/area-chart/area-chart.ts +3 -2
- 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 +14 -15
- package/dist/ui/components/bar-chart/bar-chart.test.ts +82 -1
- package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +1314 -273
- package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1046 -119
- package/dist/ui/components/bottom-sheet/index.ts +7 -1
- package/dist/ui/components/button/button.test.ts +646 -2
- package/dist/ui/components/button/button.ts +443 -64
- package/dist/ui/components/button/index.ts +2 -0
- package/dist/ui/components/button-group/button-group.test.ts +528 -2
- package/dist/ui/components/button-group/button-group.ts +428 -70
- package/dist/ui/components/button-group/index.ts +3 -0
- package/dist/ui/components/candlestick-chart/candlestick-chart.test.ts +82 -1
- package/dist/ui/components/card/card.test.ts +97 -61
- package/dist/ui/components/card/card.ts +2 -1
- package/dist/ui/components/chart/chart-presets.test.ts +197 -1
- package/dist/ui/components/chart/chart-presets.ts +14 -7
- package/dist/ui/components/chart/chart-theme.ts +12 -0
- package/dist/ui/components/chart/chart.test.ts +1031 -0
- package/dist/ui/components/chart/chart.ts +66 -46
- package/dist/ui/components/checkbox/checkbox.test.ts +124 -42
- package/dist/ui/components/checkbox/checkbox.ts +25 -24
- package/dist/ui/components/collapsible/collapsible.test.ts +158 -81
- package/dist/ui/components/collapsible/collapsible.ts +21 -15
- 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 +210 -152
- package/dist/ui/components/dialog/dialog.ts +35 -29
- package/dist/ui/components/dock/dock-scroll.test.ts +130 -0
- package/dist/ui/components/dock/dock-scroll.ts +142 -0
- package/dist/ui/components/dock/dock-search.test.ts +594 -0
- package/dist/ui/components/dock/dock.test.ts +752 -0
- package/dist/ui/components/dock/dock.ts +1438 -0
- package/dist/ui/components/dock/index.ts +2 -0
- 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/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 +376 -0
- package/dist/ui/components/glass/glass.ts +430 -0
- package/dist/ui/components/glass/index.ts +19 -0
- package/dist/ui/components/icon/icon-theme.test.ts +64 -0
- package/dist/ui/components/icon/icon-theme.ts +70 -16
- package/dist/ui/components/icon/icon.test.ts +52 -41
- package/dist/ui/components/input/input.test.ts +178 -101
- package/dist/ui/components/input/input.ts +17 -13
- package/dist/ui/components/label/label.test.ts +11 -24
- package/dist/ui/components/label/label.ts +8 -2
- package/dist/ui/components/line-chart/line-chart.test.ts +76 -1
- package/dist/ui/components/line-chart/line-chart.ts +3 -2
- package/dist/ui/components/list/list.test.ts +80 -49
- package/dist/ui/components/list/list.ts +5 -4
- 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 +636 -0
- package/dist/ui/components/menu/menu-popup.ts +565 -0
- package/dist/ui/components/menu/menu.test.ts +523 -0
- package/dist/ui/components/menu/menu.ts +455 -0
- package/dist/ui/components/nav-drawer/nav-drawer.test.ts +425 -260
- package/dist/ui/components/nav-drawer/nav-drawer.ts +75 -58
- 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/pagination/pagination.test.ts +136 -86
- 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.test.ts +102 -46
- package/dist/ui/components/progress/progress.ts +54 -55
- package/dist/ui/components/radio-group/radio-group.test.ts +128 -83
- package/dist/ui/components/radio-group/radio-group.ts +26 -25
- package/dist/ui/components/scatter-chart/scatter-chart.test.ts +74 -1
- package/dist/ui/components/scroll-area/scroll-area.test.ts +72 -36
- 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.test.ts +138 -78
- package/dist/ui/components/select/select.ts +19 -12
- package/dist/ui/components/separator/separator.test.ts +18 -17
- package/dist/ui/components/sheet/sheet.test.ts +151 -11
- package/dist/ui/components/sheet/sheet.ts +68 -5
- package/dist/ui/components/skeleton/skeleton.test.ts +24 -7
- package/dist/ui/components/skeleton/skeleton.ts +10 -16
- 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/spinner/spinner.test.ts +64 -46
- package/dist/ui/components/spinner/spinner.ts +16 -19
- package/dist/ui/components/switch/index.ts +1 -1
- package/dist/ui/components/switch/switch.test.ts +534 -48
- package/dist/ui/components/switch/switch.ts +613 -48
- 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/index.ts +7 -1
- package/dist/ui/components/tabs/tabs.test.ts +484 -150
- package/dist/ui/components/tabs/tabs.ts +344 -149
- package/dist/ui/components/textarea/textarea.test.ts +206 -100
- package/dist/ui/components/textarea/textarea.ts +16 -13
- package/dist/ui/components/toast/toast.test.ts +434 -350
- package/dist/ui/components/toast/toast.ts +42 -51
- package/dist/ui/components/toggle/index.ts +1 -0
- package/dist/ui/components/toggle/toggle.test.ts +559 -146
- 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/ui/theme/chart-colors.test.ts +8 -3
- package/dist/ui/theme/dark.css +38 -2
- package/dist/ui/theme/default.css +55 -7
- package/dist/ui/theme/icon-colors.test.ts +8 -3
- 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 +93 -29
- package/dist/ui/theme/tailwind.css +113 -0
- package/dist/ui/utils/animate.test.ts +13 -1
- package/dist/utils/analyze.d.ts +19 -0
- package/dist/utils/analyze.js +16 -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 +25 -1
- package/dist/utils/installed.js +33 -0
- package/dist/utils/installed.js.map +1 -1
- package/dist/utils/installed.test.js +55 -1
- package/dist/utils/installed.test.js.map +1 -1
- package/dist/utils/resolve-paths.d.ts +4 -4
- package/dist/utils/resolve-paths.js +5 -7
- package/dist/utils/resolve-paths.js.map +1 -1
- package/dist/utils/resolve-paths.test.js +9 -5
- package/dist/utils/resolve-paths.test.js.map +1 -1
- package/dist/utils/tailwind.d.ts +45 -0
- package/dist/utils/tailwind.js +90 -0
- package/dist/utils/tailwind.js.map +1 -0
- package/dist/utils/tailwind.test.d.ts +1 -0
- package/dist/utils/tailwind.test.js +134 -0
- package/dist/utils/tailwind.test.js.map +1 -0
- package/package.json +9 -1
- package/dist/ui/components/area-chart/area-chart-render.test.ts +0 -63
- package/dist/ui/components/bar-chart/bar-chart-render.test.ts +0 -58
- package/dist/ui/components/button/button-render.test.ts +0 -132
- 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 -59
- package/dist/ui/components/chart/chart-render.test.ts +0 -730
- package/dist/ui/components/line-chart/line-chart-render.test.ts +0 -83
- package/dist/ui/components/scatter-chart/scatter-chart-render.test.ts +0 -64
|
@@ -1,18 +1,25 @@
|
|
|
1
1
|
// cspell:words WAAPI tweens
|
|
2
2
|
import {
|
|
3
|
-
type ElementRef,
|
|
4
3
|
type OnDestroy,
|
|
5
4
|
Component,
|
|
5
|
+
Directive,
|
|
6
|
+
ElementRef,
|
|
6
7
|
ViewEncapsulation,
|
|
7
8
|
computed,
|
|
8
9
|
effect,
|
|
10
|
+
inject,
|
|
9
11
|
input,
|
|
10
12
|
model,
|
|
11
13
|
output,
|
|
12
14
|
signal,
|
|
15
|
+
untracked,
|
|
13
16
|
viewChild,
|
|
14
17
|
} from '@angular/core';
|
|
15
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
LYNX_ELEMENTS,
|
|
20
|
+
LynxAccessibilitySettings,
|
|
21
|
+
MainThreadElement,
|
|
22
|
+
} from '@blotch/angular-lynx';
|
|
16
23
|
|
|
17
24
|
import {
|
|
18
25
|
type AnimationHandle,
|
|
@@ -24,6 +31,81 @@ import {
|
|
|
24
31
|
slideOut,
|
|
25
32
|
} from '../../utils/animate';
|
|
26
33
|
import { cn } from '../../utils/cn';
|
|
34
|
+
import { concentricRadius } from '../glass/concentric-radius';
|
|
35
|
+
import { UiGlass } from '../glass/glass';
|
|
36
|
+
import { type GlassDesign, injectGlassDesign } from '../glass/glass-design';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `'default'` is the sheet's own look, the same on every platform. `'glass'`
|
|
40
|
+
* is the platform's sheet: Liquid Glass on iOS 26 and later (and in the web
|
|
41
|
+
* preview, and on Android with `provideGlass({ android: 'glass' })`), the
|
|
42
|
+
* opaque sheet of iOS 15 to 25, and Material 3's bottom sheet on Android.
|
|
43
|
+
*/
|
|
44
|
+
export type BottomSheetVariant = 'default' | 'glass';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A height the sheet rests at, as iOS calls them:
|
|
48
|
+
*
|
|
49
|
+
* - `'fit'`: its content's height (the default).
|
|
50
|
+
* - `'medium'`: half the screen.
|
|
51
|
+
* - `'large'`: the whole screen below the status bar.
|
|
52
|
+
*/
|
|
53
|
+
export type SheetDetent = 'fit' | 'medium' | 'large';
|
|
54
|
+
|
|
55
|
+
/** What the sheet draws: its own default look, or a glass design's. */
|
|
56
|
+
type SheetLook = 'default' | GlassDesign;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Where the sheet is going: nowhere, after the finger, to a detent, out of
|
|
60
|
+
* the control that presents it, or back into it (see UiSheetSource).
|
|
61
|
+
*/
|
|
62
|
+
type SheetMotion = 'rest' | 'drag' | 'settle' | 'grow' | 'shrink';
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* AngularLynx's element, which an `ElementRef` to a Lynx element holds: the
|
|
66
|
+
* control a sheet grows out of.
|
|
67
|
+
*/
|
|
68
|
+
type SourceElement = { readonly element: unknown };
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The panel's place on the screen, as its style sets it: how far it is from
|
|
72
|
+
* the left, right and bottom edges, and its height, in px.
|
|
73
|
+
*/
|
|
74
|
+
type PanelFrame = {
|
|
75
|
+
readonly left: number;
|
|
76
|
+
readonly right: number;
|
|
77
|
+
readonly bottom: number;
|
|
78
|
+
readonly height: number;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A sheet a control presents (see UiSheetSource): `ui-bottom-sheet`, or
|
|
83
|
+
* `ui-sheet`, which hands the control on to its own.
|
|
84
|
+
*/
|
|
85
|
+
export type SheetSourceTarget = {
|
|
86
|
+
/** Whether the sheet covers the control now, which hides meanwhile. */
|
|
87
|
+
coversSource(): boolean;
|
|
88
|
+
/** @internal Called by the `uiSheetSource` directive. */
|
|
89
|
+
setSource(source: SourceElement | null, glass: UiGlass | null): void;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** How many sheets there have been, for their glass ids. */
|
|
93
|
+
let sheetCount = 0;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* An element's box on the screen, or null where it can't be measured. Lynx
|
|
97
|
+
* gives a script an element's position only through `boundingClientRect`,
|
|
98
|
+
* which answers later.
|
|
99
|
+
*/
|
|
100
|
+
const measure = async (element: SourceElement) => {
|
|
101
|
+
try {
|
|
102
|
+
return await new MainThreadElement(
|
|
103
|
+
element.element as ConstructorParameters<typeof MainThreadElement>[0],
|
|
104
|
+
).getBoundingClientRect({ relativeTo: 'screen' });
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
};
|
|
27
109
|
|
|
28
110
|
// Drag-to-dismiss tuning for the handle strip at the top of the sheet.
|
|
29
111
|
//
|
|
@@ -34,13 +116,15 @@ const DISMISS_THRESHOLD = 100;
|
|
|
34
116
|
// Off-screen translateY target (px) for the dismiss slide. Deliberately larger
|
|
35
117
|
// than any realistic sheet height so the panel fully clears the viewport. Kept
|
|
36
118
|
// in px — not % — so the exit interpolates continuously from the px-based drag
|
|
37
|
-
// offset; WAAPI won't tween between px and % transforms.
|
|
119
|
+
// offset; WAAPI won't tween between px and % transforms. Once the screen's
|
|
120
|
+
// height is known, a taller screen uses that instead.
|
|
38
121
|
const DISMISS_TRANSLATE = 600;
|
|
39
122
|
|
|
40
|
-
// Backdrop dim tuning. The scrim sits at
|
|
41
|
-
// is fully open and lightens toward transparent as the handle is
|
|
42
|
-
// so the dimming reads as coupled to how much of the sheet is
|
|
43
|
-
|
|
123
|
+
// Backdrop dim tuning. The scrim sits at the look's dim (see BACKDROP_DIM)
|
|
124
|
+
// when the sheet is fully open and lightens toward transparent as the handle is
|
|
125
|
+
// dragged down, so the dimming reads as coupled to how much of the sheet is
|
|
126
|
+
// still on screen.
|
|
127
|
+
//
|
|
44
128
|
// Drag distance (px) over which the backdrop fades from full dim to fully clear.
|
|
45
129
|
// Ideally this would be the panel's own height, but measuring it on the drag's
|
|
46
130
|
// background thread isn't cheap (getBoundingClientRect is main-thread/async), so
|
|
@@ -48,22 +132,145 @@ const BACKDROP_MAX_DIM = 0.5;
|
|
|
48
132
|
// DISMISS_TRANSLATE off-screen distance. The dim reduces linearly across it.
|
|
49
133
|
const BACKDROP_CLEAR_DISTANCE = 500;
|
|
50
134
|
|
|
135
|
+
/**
|
|
136
|
+
* How dark the backdrop gets behind each look: the default's own, iOS's
|
|
137
|
+
* dimming view (black at 20%, measured on the iOS 27 Simulator, which iOS 15
|
|
138
|
+
* to 25 share) and Material 3's scrim (black at 32%).
|
|
139
|
+
*/
|
|
140
|
+
const BACKDROP_DIM: Readonly<Record<SheetLook, number>> = {
|
|
141
|
+
default: 0.5,
|
|
142
|
+
glass: 0.2,
|
|
143
|
+
frosted: 0.2,
|
|
144
|
+
material: 0.32,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* How far a drag between detents has to go before the sheet moves on to the
|
|
149
|
+
* next detent rather than settling back. Lynx reports no velocity, so a short
|
|
150
|
+
* flick can't be told from a nudge: the distance decides.
|
|
151
|
+
*/
|
|
152
|
+
const DETENT_THRESHOLD = 50;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* How far iOS 26 insets a partial-height sheet from the sides and bottom of
|
|
156
|
+
* the screen (measured on the iOS 27 Simulator: 8pt), so content shows around
|
|
157
|
+
* it.
|
|
158
|
+
*/
|
|
159
|
+
const GLASS_INSET = 8;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The corner radius of the display iOS 26 sheets nest in: the iPhone 16 Pro's
|
|
163
|
+
* and later (62pt on the iOS 27 Simulator's iPhone 18 Pro). Lynx can't read a
|
|
164
|
+
* phone's own, and earlier iPhones round less, so their inset sheets sit a
|
|
165
|
+
* little rounder than their screens.
|
|
166
|
+
*/
|
|
167
|
+
const DISPLAY_RADIUS = 62;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The glass sheet's corner radius: concentric with the display's corners, so
|
|
171
|
+
* the gap around the inset sheet stays 8px wide round them. One radius for
|
|
172
|
+
* all four corners, because Lynx's iOS glass ignores per-corner radii (it
|
|
173
|
+
* draws a square shape with them) and can't animate a radius, so it never
|
|
174
|
+
* changes either: at full height the bottom corners sit under the display's
|
|
175
|
+
* own, which round more.
|
|
176
|
+
*/
|
|
177
|
+
const GLASS_RADIUS = concentricRadius(DISPLAY_RADIUS, GLASS_INSET);
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Material 3's level 1 elevation, as an inline style: Tailwind's shadow
|
|
181
|
+
* classes draw nothing on Lynx.
|
|
182
|
+
*/
|
|
183
|
+
const MATERIAL_ELEVATION =
|
|
184
|
+
'0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15)';
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* How many frames the sheet waits for the screen to be laid out (see
|
|
188
|
+
* #present) before it opens anyway, at its content's height.
|
|
189
|
+
*/
|
|
190
|
+
const LAYOUT_TRIES = 10;
|
|
191
|
+
|
|
192
|
+
/** One frame, for waiting on layout. */
|
|
193
|
+
const FRAME = 16;
|
|
194
|
+
|
|
195
|
+
/** The panel's own classes in each look. */
|
|
196
|
+
const PANEL_CLASS: Readonly<Record<SheetLook, string>> = {
|
|
197
|
+
default: 'w-full rounded-t-2xl border-t border-border bg-background',
|
|
198
|
+
// The glass, drawn by the layers in the template, is the panel's surface.
|
|
199
|
+
glass: '',
|
|
200
|
+
// iOS 15 to 25: the system background, with 10pt corners on top.
|
|
201
|
+
frosted: 'w-full rounded-t-[10px] bg-background',
|
|
202
|
+
// Material 3: the surface container, with 28dp corners on top.
|
|
203
|
+
material: 'w-full rounded-t-[28px] bg-secondary',
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** The handle strip's padding, which places the grabber, in each look. */
|
|
207
|
+
const HANDLE_CLASS: Readonly<Record<SheetLook, string>> = {
|
|
208
|
+
default: 'pb-3 pt-2',
|
|
209
|
+
glass: 'pb-[14px] pt-[6px]',
|
|
210
|
+
frosted: 'pb-[14px] pt-[5px]',
|
|
211
|
+
// Material's drag handle sits in a 48dp touch target.
|
|
212
|
+
material: 'py-[22px]',
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The grabber in each look: iOS 26's is 60×4, iOS 15 to 25's 36×5, both a
|
|
217
|
+
* faint gray that reads on glass and on the opaque sheet alike, and
|
|
218
|
+
* Material's drag handle 32×4 in on-surface-variant at 40%.
|
|
219
|
+
*/
|
|
220
|
+
const GRABBER_CLASS: Readonly<Record<SheetLook, string>> = {
|
|
221
|
+
default: 'h-1 w-10 bg-muted',
|
|
222
|
+
glass: 'h-[4px] w-[60px] bg-foreground opacity-25',
|
|
223
|
+
frosted: 'h-[5px] w-[36px] bg-foreground opacity-25',
|
|
224
|
+
material: 'h-[4px] w-[32px] bg-muted-foreground opacity-40',
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* The height a `layoutchange` event reports. Its template type is the DOM's
|
|
229
|
+
* `Event`, which has no `detail`.
|
|
230
|
+
*/
|
|
231
|
+
const layoutHeight = (event: unknown): number =>
|
|
232
|
+
(event as { readonly detail: { readonly height: number } }).detail.height;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The width a `layoutchange` event reports; see layoutHeight.
|
|
236
|
+
*/
|
|
237
|
+
const layoutWidth = (event: unknown): number =>
|
|
238
|
+
(event as { readonly detail: { readonly width: number } }).detail.width;
|
|
239
|
+
|
|
51
240
|
/**
|
|
52
241
|
* Low-level bottom-sheet primitive: a full-screen `<overlay>` with a dimmed
|
|
53
242
|
* backdrop and a panel docked to the bottom that slides up on open, slides
|
|
54
|
-
* down on close, and can be dragged
|
|
243
|
+
* down on close, and can be dragged by its handle between its detents or
|
|
244
|
+
* down to dismiss.
|
|
55
245
|
*
|
|
56
246
|
* It owns none of the content — consumers project whatever they want via
|
|
57
247
|
* `<ng-content>` (a scrollable item list for `select`, arbitrary content for
|
|
58
248
|
* `sheet`). This is the shared engine both those components build on, so the
|
|
59
249
|
* overlay/animation/drag behavior lives in exactly one place.
|
|
250
|
+
*
|
|
251
|
+
* `variant="glass"` draws the platform's sheet. On iOS 26 and later a sheet
|
|
252
|
+
* below full height is Liquid Glass, inset from the screen's sides and bottom
|
|
253
|
+
* with corners concentric to the display's. As it's dragged up to full height
|
|
254
|
+
* it meets the edges and turns opaque. Lynx can't animate `border-radius`, so
|
|
255
|
+
* that transition never changes one: it animates the glass's frame, which
|
|
256
|
+
* iOS's glass follows frame by frame, and fades an opaque layer in over it.
|
|
257
|
+
*
|
|
258
|
+
* Mark the control that presents it with `uiSheetSource`, and the Liquid
|
|
259
|
+
* Glass sheet grows out of that control rather than sliding up, and shrinks
|
|
260
|
+
* back into it as it closes, as iOS 26 grows a sheet out of its button.
|
|
60
261
|
*/
|
|
61
262
|
@Component({
|
|
62
263
|
selector: 'ui-bottom-sheet',
|
|
63
|
-
imports: [LYNX_ELEMENTS],
|
|
264
|
+
imports: [LYNX_ELEMENTS, UiGlass],
|
|
64
265
|
encapsulation: ViewEncapsulation.None,
|
|
65
266
|
template: `
|
|
66
|
-
|
|
267
|
+
<!-- Android's back button closes the sheet: while an overlay shows, it
|
|
268
|
+
takes the press and fires requestclose. -->
|
|
269
|
+
<overlay
|
|
270
|
+
[attr.visible]="overlayVisible()"
|
|
271
|
+
[style]="overlayStyle()"
|
|
272
|
+
(bindrequestclose)="open.set(false)"
|
|
273
|
+
>
|
|
67
274
|
<!--
|
|
68
275
|
The mouse drag's move/up listeners sit on the full-screen backdrop,
|
|
69
276
|
not the handle: a touch keeps targeting the element it started on, but
|
|
@@ -83,25 +290,56 @@ const BACKDROP_CLEAR_DISTANCE = 500;
|
|
|
83
290
|
(bindmousedown)="onBackdropMouseDown()"
|
|
84
291
|
(bindmousemove)="onMouseDragMove($event)"
|
|
85
292
|
(bindmouseup)="onDragEnd()"
|
|
293
|
+
(bindlayoutchange)="onScreenLayout($event)"
|
|
86
294
|
>
|
|
295
|
+
<!-- The screen below the status bar, which the large detent fills. -->
|
|
296
|
+
<view
|
|
297
|
+
class="w-0 absolute bottom-0 left-0"
|
|
298
|
+
style="top: env(safe-area-inset-top);"
|
|
299
|
+
(bindlayoutchange)="onLargeLayout($event)"
|
|
300
|
+
></view>
|
|
87
301
|
<view
|
|
88
302
|
#panel
|
|
89
303
|
[class]="panelClass()"
|
|
90
|
-
[style]="
|
|
304
|
+
[style]="panelStyle()"
|
|
91
305
|
[accessibility-exclusive-focus]="overlayVisible()"
|
|
92
306
|
(catchtap)="onPanelTap()"
|
|
307
|
+
(bindlayoutchange)="onPanelLayout($event)"
|
|
93
308
|
>
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
309
|
+
@if (look() === 'glass') {
|
|
310
|
+
<!-- Behind the handle and the content: Lynx paints in source
|
|
311
|
+
order. The opaque layer overlaps the glass by 1px all round,
|
|
312
|
+
so at full height the glass's rim doesn't show at its
|
|
313
|
+
edge. -->
|
|
314
|
+
<blur-view
|
|
315
|
+
ui-glass
|
|
316
|
+
shape="rounded"
|
|
317
|
+
[radius]="glassRadius"
|
|
318
|
+
[glassId]="glassId"
|
|
319
|
+
class="h-full w-full absolute left-0 top-0"
|
|
320
|
+
[style.visibility]="glassVisibility()"
|
|
321
|
+
></blur-view>
|
|
322
|
+
<view class="bg-background absolute" [style]="opaqueStyle()"></view>
|
|
323
|
+
}
|
|
324
|
+
<!-- The handle and the content, which fade in as the sheet grows
|
|
325
|
+
out of the control that presents it. -->
|
|
326
|
+
<view class="grow flex-col flex" [style]="contentStyle()">
|
|
327
|
+
<view
|
|
328
|
+
[class]="handleClass()"
|
|
329
|
+
[accessibility-element]="resizable()"
|
|
330
|
+
[attr.accessibility-traits]="resizable() ? 'button' : null"
|
|
331
|
+
[attr.accessibility-label]="resizable() ? 'Sheet grabber' : null"
|
|
332
|
+
(catchtouchstart)="onHandleTouchStart($event)"
|
|
333
|
+
(catchtouchmove)="onHandleTouchMove($event)"
|
|
334
|
+
(catchtouchend)="onDragEnd()"
|
|
335
|
+
(catchtouchcancel)="onDragEnd()"
|
|
336
|
+
(catchmousedown)="onHandleMouseDown($event)"
|
|
337
|
+
(catchtap)="onHandleTap()"
|
|
338
|
+
>
|
|
339
|
+
<view [class]="grabberClass()" />
|
|
340
|
+
</view>
|
|
341
|
+
<ng-content />
|
|
103
342
|
</view>
|
|
104
|
-
<ng-content />
|
|
105
343
|
</view>
|
|
106
344
|
</view>
|
|
107
345
|
</overlay>
|
|
@@ -110,9 +348,40 @@ const BACKDROP_CLEAR_DISTANCE = 500;
|
|
|
110
348
|
export class UiBottomSheet implements OnDestroy {
|
|
111
349
|
readonly open = model(false);
|
|
112
350
|
readonly userClass = input<string>('', { alias: 'class' });
|
|
351
|
+
/** `'default'` or `'glass'`; see BottomSheetVariant. */
|
|
352
|
+
readonly variant = input<BottomSheetVariant>('default');
|
|
353
|
+
/**
|
|
354
|
+
* The heights the sheet rests at (see SheetDetent). It opens at the first,
|
|
355
|
+
* and drags between them. With more than one, a tap on the grabber moves
|
|
356
|
+
* to the next.
|
|
357
|
+
*/
|
|
358
|
+
readonly detents = input<readonly SheetDetent[]>(['fit']);
|
|
359
|
+
/**
|
|
360
|
+
* The detent the sheet rests at while it's open, and undefined while it's
|
|
361
|
+
* closed. Set it before opening to open at another detent than the first,
|
|
362
|
+
* or while open to move the sheet there.
|
|
363
|
+
*/
|
|
364
|
+
readonly detent = model<SheetDetent>();
|
|
113
365
|
|
|
114
366
|
readonly closed = output<void>();
|
|
115
367
|
|
|
368
|
+
readonly #settings = inject(LynxAccessibilitySettings);
|
|
369
|
+
readonly #design = injectGlassDesign();
|
|
370
|
+
|
|
371
|
+
/** The look on this platform: the default, or the glass variant's design. */
|
|
372
|
+
protected readonly look = computed<SheetLook>(() =>
|
|
373
|
+
this.variant() === 'glass' ? this.#design() : 'default',
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
protected readonly glassRadius = GLASS_RADIUS;
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The id the sheet's glass and a glass control that presents it share
|
|
380
|
+
* (see UiGlass's `glassId`), unique to the sheet, so the sheet takes over
|
|
381
|
+
* the control's glass as it grows out of it.
|
|
382
|
+
*/
|
|
383
|
+
readonly glassId = `ui-sheet-${++sheetCount}`;
|
|
384
|
+
|
|
116
385
|
protected readonly overlayVisible = signal(false);
|
|
117
386
|
/**
|
|
118
387
|
* `width`/`height`/`top`/`left` are web-only necessities: on web
|
|
@@ -126,8 +395,10 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
126
395
|
: 'position: fixed; overflow: visible; width: 100vw; height: 100vh; top: 0; left: 0; z-index: 999; display: none;',
|
|
127
396
|
);
|
|
128
397
|
|
|
129
|
-
|
|
130
|
-
|
|
398
|
+
// Required: both are always in the template (the overlay is hidden, never
|
|
399
|
+
// removed), and nothing reads them before the first render.
|
|
400
|
+
readonly backdropRef = viewChild.required<ElementRef>('backdrop');
|
|
401
|
+
readonly panelRef = viewChild.required<ElementRef>('panel');
|
|
131
402
|
#backdropAnim?: AnimationHandle;
|
|
132
403
|
#panelAnim?: AnimationHandle;
|
|
133
404
|
#hasBeenOpen = false;
|
|
@@ -135,46 +406,199 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
135
406
|
// them.
|
|
136
407
|
readonly #timers = new Set<ReturnType<typeof setTimeout>>();
|
|
137
408
|
|
|
409
|
+
// --- Layout, from `layoutchange` ---
|
|
410
|
+
// The screen's height, the large detent's (the screen below the status
|
|
411
|
+
// bar), and the panel's at its content's height. 0 until laid out.
|
|
412
|
+
readonly #screenHeight = signal(0);
|
|
413
|
+
readonly #screenWidth = signal(0);
|
|
414
|
+
readonly #largeHeight = signal(0);
|
|
415
|
+
readonly #fitHeight = signal(0);
|
|
416
|
+
|
|
417
|
+
// --- Height ---
|
|
418
|
+
// The sheet's height, from the bottom of the screen to its top, in px, or
|
|
419
|
+
// null to fit its content. A glass sheet's inset comes off it (see
|
|
420
|
+
// panelStyle), so a detent puts the top in the same place in every look.
|
|
421
|
+
readonly #height = signal<number | null>(null);
|
|
422
|
+
readonly #motion = signal<SheetMotion>('rest');
|
|
423
|
+
// Whether the open sequence has started showing the sheet. Until then it's
|
|
424
|
+
// laid out below the screen over a clear backdrop, so it doesn't flash at
|
|
425
|
+
// its resting place while the screen is measured. Not `visibility: hidden`:
|
|
426
|
+
// on iOS, glass that an ancestor hides as it's set up draws nothing once
|
|
427
|
+
// shown.
|
|
428
|
+
readonly #presented = signal(false);
|
|
429
|
+
|
|
430
|
+
// --- The control that presents it (see UiSheetSource) ---
|
|
431
|
+
readonly #source = signal<SourceElement | null>(null);
|
|
432
|
+
// The control's own glass, which shares the sheet's glass id.
|
|
433
|
+
#sourceGlass: UiGlass | null = null;
|
|
434
|
+
// The control's frame while the sheet grows out of it or shrinks back
|
|
435
|
+
// into it, which the panel takes instead of its own; null otherwise.
|
|
436
|
+
readonly #sourceFrame = signal<PanelFrame | null>(null);
|
|
437
|
+
// Increases with every open and close, so a step of one that the other
|
|
438
|
+
// interrupts doesn't run.
|
|
439
|
+
#sequence = 0;
|
|
440
|
+
// The detent the sheet last came to rest at, to tell an app's change of
|
|
441
|
+
// `detent` from the sheet's own.
|
|
442
|
+
#restingAt?: SheetDetent;
|
|
443
|
+
// Increases with every settle and drag, so a settle that a drag interrupts
|
|
444
|
+
// doesn't end the drag when its timer fires.
|
|
445
|
+
#settleRun = 0;
|
|
446
|
+
|
|
138
447
|
// --- Drag-to-dismiss state ---
|
|
139
|
-
// Live downward drag distance (px)
|
|
140
|
-
// `
|
|
448
|
+
// Live downward drag distance (px) past the smallest detent, applied to the
|
|
449
|
+
// panel as a translateY via `panelStyle()` so the sheet tracks the finger
|
|
450
|
+
// in real time.
|
|
141
451
|
protected readonly dragOffset = signal(0);
|
|
142
|
-
// Screen Y where the current drag began; the delta from here drives
|
|
452
|
+
// Screen Y where the current drag began; the delta from here drives the
|
|
453
|
+
// drag.
|
|
143
454
|
#dragStartY = 0;
|
|
455
|
+
// The sheet's height when the current drag began.
|
|
456
|
+
#dragFrom = 0;
|
|
144
457
|
// True only between touchstart and touchend on the handle, so unrelated move
|
|
145
458
|
// events can't shift the sheet.
|
|
146
459
|
#isDragging = false;
|
|
147
460
|
// Set when a mouse drag that actually moved is released. The browser follows
|
|
148
461
|
// that mouseup with a click on the nearest element under both the mousedown
|
|
149
|
-
// and the mouseup
|
|
150
|
-
//
|
|
151
|
-
// drag released above the panel (where
|
|
152
|
-
|
|
462
|
+
// and the mouseup: the backdrop whenever the cursor ended up off the panel,
|
|
463
|
+
// or the handle when it ended up back on it. Lynx web maps that click to
|
|
464
|
+
// that element's tap. Without this, a drag released above the panel (where
|
|
465
|
+
// the sheet snaps back) would close it, and one released on the handle
|
|
466
|
+
// would move it on to the next detent.
|
|
467
|
+
#ignoreNextTap = false;
|
|
153
468
|
// Whether the current drag is a mouse drag that has moved: only those are
|
|
154
469
|
// followed by a click. A moved touch produces no tap.
|
|
155
470
|
#mouseDragMoved = false;
|
|
156
471
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
472
|
+
/**
|
|
473
|
+
* Opens or closes the sheet as `open` changes. A field rather than a
|
|
474
|
+
* constructor: Angular's test build keeps a function for a constructor that
|
|
475
|
+
* nothing calls, which an app's coverage report would show as uncovered.
|
|
476
|
+
*
|
|
477
|
+
* `#hasBeenOpen` prevents the close animation from running on the initial
|
|
478
|
+
* effect evaluation when `open` starts as false. Without this guard, the
|
|
479
|
+
* first run would call #doClose() and emit `closed` before the sheet
|
|
480
|
+
* has ever been opened.
|
|
481
|
+
*/
|
|
482
|
+
protected readonly openOrClose = effect(() => {
|
|
483
|
+
const isOpen = this.open();
|
|
484
|
+
if (isOpen) {
|
|
485
|
+
this.#hasBeenOpen = true;
|
|
486
|
+
this.#doOpen();
|
|
487
|
+
} else if (this.#hasBeenOpen) {
|
|
488
|
+
this.#doClose();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Moves an open sheet to the detent an app sets. The sheet's own moves set
|
|
494
|
+
* `detent` too, to where it already is.
|
|
495
|
+
*/
|
|
496
|
+
protected readonly followDetent = effect(() => {
|
|
497
|
+
const detent = this.detent();
|
|
498
|
+
untracked(() => {
|
|
499
|
+
if (
|
|
500
|
+
detent === undefined ||
|
|
501
|
+
detent === this.#restingAt ||
|
|
502
|
+
!this.#presented() ||
|
|
503
|
+
this.#isDragging ||
|
|
504
|
+
!this.#detentList().includes(detent)
|
|
505
|
+
) {
|
|
506
|
+
return;
|
|
169
507
|
}
|
|
508
|
+
this.#settle(detent);
|
|
170
509
|
});
|
|
171
|
-
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
/** The detents, or `fit` alone for an empty list. */
|
|
513
|
+
readonly #detentList = computed<readonly SheetDetent[]>(() => {
|
|
514
|
+
const detents = this.detents();
|
|
515
|
+
return detents.length > 0 ? detents : ['fit'];
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
/** Whether the sheet has more than one height to rest at. */
|
|
519
|
+
protected readonly resizable = computed(() => this.#detentList().length > 1);
|
|
520
|
+
|
|
521
|
+
/** The detents from shortest to tallest. */
|
|
522
|
+
readonly #ordered = computed(() =>
|
|
523
|
+
[...this.#detentList()].sort(
|
|
524
|
+
(a, b) => this.#detentHeight(a) - this.#detentHeight(b),
|
|
525
|
+
),
|
|
526
|
+
);
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* How far a glass sheet is from partial height (0) to full height (1):
|
|
530
|
+
* from the tallest detent below the large one to the large one. It sets
|
|
531
|
+
* the glass sheet's inset and how opaque it is, so both follow a drag.
|
|
532
|
+
*/
|
|
533
|
+
protected readonly progress = computed(() => {
|
|
534
|
+
const large = this.#largeHeight();
|
|
535
|
+
if (large <= 0) return 0;
|
|
536
|
+
const height = this.#currentHeight();
|
|
537
|
+
if (height >= large) return 1;
|
|
538
|
+
const partial = Math.max(
|
|
539
|
+
0,
|
|
540
|
+
...this.#detentList()
|
|
541
|
+
.filter((detent) => detent !== 'large')
|
|
542
|
+
.map((detent) => this.#detentHeight(detent)),
|
|
543
|
+
);
|
|
544
|
+
return Math.max(0, (height - partial) / (large - partial));
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
/** A glass sheet's inset from the screen's sides and bottom. */
|
|
548
|
+
readonly #inset = computed(() =>
|
|
549
|
+
this.look() === 'glass' ? GLASS_INSET * (1 - this.progress()) : 0,
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
readonly #reduceMotion = computed(() => this.#settings.reduceMotion());
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Whether the glass variant's own motion (its slide in and out, and the
|
|
556
|
+
* snap back after a drag) gives way to Reduce Motion. The default variant
|
|
557
|
+
* keeps the motion it always had.
|
|
558
|
+
*/
|
|
559
|
+
readonly #calm = computed(
|
|
560
|
+
() => this.look() !== 'default' && this.#reduceMotion(),
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* Whether the sheet grows out of the control that presents it, and back
|
|
565
|
+
* into it, as iOS 26 grows a sheet out of its button: the Liquid Glass
|
|
566
|
+
* sheet, with a control marked `uiSheetSource`, unless Reduce Motion is on.
|
|
567
|
+
*/
|
|
568
|
+
readonly #grows = computed(
|
|
569
|
+
() => this.look() === 'glass' && !this.#calm() && this.#source() !== null,
|
|
570
|
+
);
|
|
571
|
+
|
|
572
|
+
/** Whether the sheet covers its control now, which hides meanwhile. */
|
|
573
|
+
readonly coversSource = computed(() => this.#grows() && this.#presented());
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* A sheet that grows out of its control shows its glass in the same update
|
|
577
|
+
* that hides the control, and hides it in the one that shows the control
|
|
578
|
+
* again. On iOS 26 and later the shell pairs the two across those changes
|
|
579
|
+
* (see glassId): the sheet takes over a glass control's corners, and the
|
|
580
|
+
* control's content fades out in it, and back in as the sheet closes.
|
|
581
|
+
*/
|
|
582
|
+
protected readonly glassVisibility = computed(() =>
|
|
583
|
+
this.#grows() && !this.#presented() ? 'hidden' : null,
|
|
584
|
+
);
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The handle and content fade in once the sheet growing out of its
|
|
588
|
+
* control has room for them, and out as it shrinks back.
|
|
589
|
+
*/
|
|
590
|
+
protected readonly contentStyle = computed(() => {
|
|
591
|
+
if (!this.#grows()) return null;
|
|
592
|
+
return this.#presented() && this.#sourceFrame() === null
|
|
593
|
+
? `opacity: 1; transition: opacity ${DURATION.normal}ms ${EASING.decelerate} ${DURATION.fast}ms;`
|
|
594
|
+
: `opacity: 0; transition: opacity ${DURATION.fast}ms ${EASING.accelerate};`;
|
|
595
|
+
});
|
|
172
596
|
|
|
173
597
|
protected readonly panelClass = computed(() =>
|
|
174
598
|
cn(
|
|
175
|
-
'flex-col
|
|
176
|
-
|
|
177
|
-
// The panel docks at bottom: 0 (see
|
|
599
|
+
'flex-col flex',
|
|
600
|
+
PANEL_CLASS[this.look()],
|
|
601
|
+
// The panel docks at bottom: 0 (see panelStyle), so its lower edge
|
|
178
602
|
// sits behind the home indicator. `pb-safe` pads the bottom by
|
|
179
603
|
// env(safe-area-inset-bottom), lifting the projected content clear of it
|
|
180
604
|
// while the panel background still fills to the screen edge. Degrades to no
|
|
@@ -185,35 +609,149 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
185
609
|
),
|
|
186
610
|
);
|
|
187
611
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
// animations run via element.animate() and override this transform while they
|
|
191
|
-
// play (see #animateIn/#animateOut), then hand back to it once canceled.
|
|
192
|
-
protected readonly panelPositionStyle = computed(
|
|
193
|
-
() =>
|
|
194
|
-
`position: absolute; bottom: 0; left: 0; right: 0; transform: translateY(${this.dragOffset()}px);`,
|
|
612
|
+
protected readonly handleClass = computed(() =>
|
|
613
|
+
cn('items-center flex justify-center', HANDLE_CLASS[this.look()]),
|
|
195
614
|
);
|
|
196
615
|
|
|
616
|
+
protected readonly grabberClass = computed(() =>
|
|
617
|
+
cn('rounded-full', GRABBER_CLASS[this.look()]),
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
// Docked at the bottom, full width (a glass sheet inset from both); its
|
|
621
|
+
// height, when it's at a detent other than its content's or being
|
|
622
|
+
// dragged; and `translateY(dragOffset)` layers the live dismiss drag on top
|
|
623
|
+
// so the sheet follows the finger. The open/close slide animations run via
|
|
624
|
+
// element.animate() and override this transform while they play (see
|
|
625
|
+
// #animateIn/#animateOut), then hand back to it once canceled. Settling at
|
|
626
|
+
// a detent transitions the frame, which iOS's glass follows.
|
|
627
|
+
protected readonly panelStyle = computed(() => {
|
|
628
|
+
const inset = this.#inset();
|
|
629
|
+
const height = this.#height();
|
|
630
|
+
const offset = this.#presented() ? `${this.dragOffset()}px` : '110%';
|
|
631
|
+
// Growing out of the control that presents it, or shrinking back into
|
|
632
|
+
// it, the sheet starts from the control's frame, or ends at it.
|
|
633
|
+
const source = this.#sourceFrame();
|
|
634
|
+
const styles = source
|
|
635
|
+
? [
|
|
636
|
+
`position: absolute; bottom: ${source.bottom}px; left: ${source.left}px; right: ${source.right}px;`,
|
|
637
|
+
`transform: translateY(0px); height: ${source.height}px;`,
|
|
638
|
+
]
|
|
639
|
+
: [
|
|
640
|
+
`position: absolute; bottom: ${inset}px; left: ${inset}px; right: ${inset}px;`,
|
|
641
|
+
`transform: translateY(${offset});`,
|
|
642
|
+
];
|
|
643
|
+
if (!source && height !== null) styles.push(`height: ${height - inset}px;`);
|
|
644
|
+
if (this.look() === 'material') {
|
|
645
|
+
styles.push(`box-shadow: ${MATERIAL_ELEVATION};`);
|
|
646
|
+
}
|
|
647
|
+
const motion = this.#motion();
|
|
648
|
+
if (motion === 'settle' || motion === 'grow' || motion === 'shrink') {
|
|
649
|
+
// Out of the control on the sheet's curve, a little longer for the
|
|
650
|
+
// way it travels (a spring that starts as quickly as the menus' covers
|
|
651
|
+
// most of it in the first frame), and back into it quicker.
|
|
652
|
+
const timing =
|
|
653
|
+
motion === 'settle'
|
|
654
|
+
? `${DURATION.slow}ms ${EASING.sheet}`
|
|
655
|
+
: motion === 'grow'
|
|
656
|
+
? `${DURATION.slow + 100}ms ${EASING.sheet}`
|
|
657
|
+
: `${DURATION.normal}ms ${EASING.standard}`;
|
|
658
|
+
styles.push(
|
|
659
|
+
`transition: height ${timing}, left ${timing}, right ${timing}, bottom ${timing};`,
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
// The content, laid out at the sheet's size, stays inside the glass
|
|
663
|
+
// while it's smaller.
|
|
664
|
+
if (motion === 'grow' || motion === 'shrink' || source) {
|
|
665
|
+
styles.push('overflow: hidden;');
|
|
666
|
+
}
|
|
667
|
+
return styles.join(' ');
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* The opaque layer a glass sheet turns into as it reaches full height: it
|
|
672
|
+
* fades in over the glass with the sheet's progress. Its bottom corners
|
|
673
|
+
* square off once the sheet rests at full height, where it meets the
|
|
674
|
+
* screen's bottom edge: Lynx can't animate a radius, and on an iPhone the
|
|
675
|
+
* display's rounder corners hide the change.
|
|
676
|
+
*/
|
|
677
|
+
protected readonly opaqueStyle = computed(() => {
|
|
678
|
+
const progress = this.progress();
|
|
679
|
+
const radius = GLASS_RADIUS + 1;
|
|
680
|
+
const bottom = progress === 1 && this.#motion() === 'rest' ? 0 : radius;
|
|
681
|
+
const transition =
|
|
682
|
+
this.#motion() === 'settle'
|
|
683
|
+
? `opacity ${DURATION.slow}ms ${EASING.standard}`
|
|
684
|
+
: 'none';
|
|
685
|
+
return [
|
|
686
|
+
'left: -1px; right: -1px; top: -1px; bottom: -1px;',
|
|
687
|
+
`opacity: ${progress};`,
|
|
688
|
+
`border-top-left-radius: ${radius}px; border-top-right-radius: ${radius}px;`,
|
|
689
|
+
`border-bottom-left-radius: ${bottom}px; border-bottom-right-radius: ${bottom}px;`,
|
|
690
|
+
`transition: ${transition};`,
|
|
691
|
+
].join(' ');
|
|
692
|
+
});
|
|
693
|
+
|
|
197
694
|
// Backdrop scrim dimmed in tandem with the sheet's reveal: full dim while
|
|
198
695
|
// docked (dragOffset 0), lightening as the handle is dragged down so the
|
|
199
696
|
// content behind shows through progressively. Driven through the same live
|
|
200
|
-
// `[style]` path as
|
|
697
|
+
// `[style]` path as panelStyle — dragOffset updates on the drag's
|
|
201
698
|
// background thread, and inline styles reflect there (unlike element.animate()
|
|
202
699
|
// opacity tweens, which the open/close fade uses on the main thread instead).
|
|
203
700
|
// The alpha lives in the background-color rather than the element's opacity so
|
|
204
701
|
// it composes cleanly with the fadeIn/fadeOut opacity animations without the
|
|
205
|
-
// two mechanisms fighting over the same property.
|
|
702
|
+
// two mechanisms fighting over the same property. Clear until the sheet is
|
|
703
|
+
// presented.
|
|
704
|
+
//
|
|
705
|
+
// A sheet growing out of its control, or shrinking back into it, dims
|
|
706
|
+
// the backdrop through this color, with a transition, rather than fading
|
|
707
|
+
// the backdrop itself in and out: the panel is in the backdrop, and on
|
|
708
|
+
// screen meanwhile, and glass under a fading parent would vanish with it,
|
|
709
|
+
// or draw washed out.
|
|
206
710
|
protected readonly backdropStyle = computed(() => {
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
711
|
+
const motion = this.#motion();
|
|
712
|
+
const revealed =
|
|
713
|
+
this.#presented() && motion !== 'shrink'
|
|
714
|
+
? Math.max(0, 1 - this.dragOffset() / BACKDROP_CLEAR_DISTANCE)
|
|
715
|
+
: 0;
|
|
716
|
+
const color = `background-color: rgba(0, 0, 0, ${BACKDROP_DIM[this.look()] * revealed});`;
|
|
717
|
+
return this.#sourceFrame() || motion === 'grow' || motion === 'shrink'
|
|
718
|
+
? `${color} transition: background-color ${DURATION.normal}ms ${EASING.standard};`
|
|
719
|
+
: color;
|
|
212
720
|
});
|
|
213
721
|
|
|
722
|
+
protected onScreenLayout(event: unknown): void {
|
|
723
|
+
this.#screenHeight.set(layoutHeight(event));
|
|
724
|
+
this.#screenWidth.set(layoutWidth(event));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
protected onLargeLayout(event: unknown): void {
|
|
728
|
+
this.#largeHeight.set(layoutHeight(event));
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The panel's height at its content's height, the fit detent. Ignored
|
|
733
|
+
* while the panel has a height of its own.
|
|
734
|
+
*/
|
|
735
|
+
protected onPanelLayout(event: unknown): void {
|
|
736
|
+
if (this.#height() === null && this.#sourceFrame() === null) {
|
|
737
|
+
this.#fitHeight.set(layoutHeight(event));
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* @internal Called by the `uiSheetSource` directive: the control that
|
|
743
|
+
* presents the sheet, and its glass, which pairs with the sheet's.
|
|
744
|
+
*/
|
|
745
|
+
setSource(source: SourceElement | null, glass: UiGlass | null): void {
|
|
746
|
+
this.#sourceGlass?.pair(null);
|
|
747
|
+
this.#source.set(source);
|
|
748
|
+
this.#sourceGlass = glass;
|
|
749
|
+
glass?.pair(this.glassId);
|
|
750
|
+
}
|
|
751
|
+
|
|
214
752
|
protected onBackdropTap(): void {
|
|
215
|
-
if (this.#
|
|
216
|
-
this.#
|
|
753
|
+
if (this.#ignoreNextTap) {
|
|
754
|
+
this.#ignoreNextTap = false;
|
|
217
755
|
return;
|
|
218
756
|
}
|
|
219
757
|
this.open.set(false);
|
|
@@ -227,7 +765,7 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
227
765
|
* starts a new drag that resets the flag on release.
|
|
228
766
|
*/
|
|
229
767
|
protected onBackdropMouseDown(): void {
|
|
230
|
-
this.#
|
|
768
|
+
this.#ignoreNextTap = false;
|
|
231
769
|
}
|
|
232
770
|
|
|
233
771
|
/**
|
|
@@ -238,6 +776,22 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
238
776
|
*/
|
|
239
777
|
protected onPanelTap(): void {}
|
|
240
778
|
|
|
779
|
+
/**
|
|
780
|
+
* A tap on the grabber moves a resizable sheet to its next detent, and from
|
|
781
|
+
* the tallest back to the shortest, as iOS's grabber does. It's how
|
|
782
|
+
* VoiceOver and TalkBack users resize it too.
|
|
783
|
+
*/
|
|
784
|
+
protected onHandleTap(): void {
|
|
785
|
+
if (this.#ignoreNextTap) {
|
|
786
|
+
this.#ignoreNextTap = false;
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
if (!this.resizable()) return;
|
|
790
|
+
const ordered = this.#ordered();
|
|
791
|
+
const at = ordered.findIndex((detent) => detent === this.detent());
|
|
792
|
+
this.#settle(ordered[(at + 1) % ordered.length]);
|
|
793
|
+
}
|
|
794
|
+
|
|
241
795
|
/**
|
|
242
796
|
* Begin a drag on the handle strip.
|
|
243
797
|
*/
|
|
@@ -293,38 +847,159 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
293
847
|
|
|
294
848
|
/**
|
|
295
849
|
* Release the drag, from a touchend, touchcancel or mouseup, or a mouse
|
|
296
|
-
* move with no button held.
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
850
|
+
* move with no button held. Dragged down past the threshold below the
|
|
851
|
+
* shortest detent, we set `open` to false and let the effect drive teardown
|
|
852
|
+
* (#doClose → drag-aware #animateOut), which keeps the two-way `open`
|
|
853
|
+
* binding in sync and reuses the `closed` emit. Short of it, the sheet
|
|
854
|
+
* springs back up to its shortest detent. Released between detents, it
|
|
855
|
+
* settles at one (see #detentAfterDrag).
|
|
300
856
|
*/
|
|
301
857
|
protected onDragEnd(): void {
|
|
302
858
|
if (!this.#isDragging) return;
|
|
303
859
|
this.#isDragging = false;
|
|
304
|
-
this.#
|
|
305
|
-
|
|
860
|
+
this.#ignoreNextTap = this.#mouseDragMoved;
|
|
861
|
+
const offset = this.dragOffset();
|
|
862
|
+
if (offset > DISMISS_THRESHOLD) {
|
|
306
863
|
this.open.set(false);
|
|
864
|
+
} else if (offset > 0) {
|
|
865
|
+
this.#snapBack(offset);
|
|
866
|
+
this.#settle(this.#ordered()[0]);
|
|
307
867
|
} else {
|
|
308
|
-
this.#
|
|
868
|
+
this.#settle(this.#detentAfterDrag());
|
|
309
869
|
}
|
|
310
870
|
}
|
|
311
871
|
|
|
312
872
|
/**
|
|
313
873
|
* The open slideIn holds the panel at translateY(0%) via fill:'forwards',
|
|
314
874
|
* which would override our inline transform — cancel it so `dragOffset`
|
|
315
|
-
* (applied through
|
|
875
|
+
* (applied through panelStyle) actually moves the panel from here on.
|
|
316
876
|
*/
|
|
317
877
|
#startDrag(y: number): void {
|
|
318
878
|
this.#panelAnim?.cancel();
|
|
319
879
|
this.#isDragging = true;
|
|
320
880
|
this.#mouseDragMoved = false;
|
|
321
881
|
this.#dragStartY = y;
|
|
882
|
+
this.#dragFrom = this.#currentHeight();
|
|
883
|
+
this.#settleRun++;
|
|
884
|
+
this.#motion.set('drag');
|
|
322
885
|
}
|
|
323
886
|
|
|
887
|
+
/**
|
|
888
|
+
* Between the shortest and the tallest detent the sheet's height follows
|
|
889
|
+
* the finger. Below the shortest it rests there and moves down instead,
|
|
890
|
+
* toward dismissal. It doesn't go above the tallest.
|
|
891
|
+
*/
|
|
324
892
|
#moveDrag(y: number): void {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
this
|
|
893
|
+
const ordered = this.#ordered();
|
|
894
|
+
const shortest = this.#detentHeight(ordered[0]);
|
|
895
|
+
const tallest = this.#detentHeight(ordered[ordered.length - 1]);
|
|
896
|
+
const target = this.#dragFrom - (y - this.#dragStartY);
|
|
897
|
+
if (target > shortest && tallest > shortest) {
|
|
898
|
+
this.#height.set(Math.min(target, tallest));
|
|
899
|
+
this.dragOffset.set(0);
|
|
900
|
+
} else {
|
|
901
|
+
this.#height.set(this.#restingHeight(ordered[0]));
|
|
902
|
+
this.dragOffset.set(Math.max(0, shortest - target));
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/**
|
|
907
|
+
* The detent a drag that ended between detents settles at: the nearest one
|
|
908
|
+
* past where it began, in the direction it went, once it has gone further
|
|
909
|
+
* than DETENT_THRESHOLD; otherwise the nearest one, which is where it
|
|
910
|
+
* began.
|
|
911
|
+
*/
|
|
912
|
+
#detentAfterDrag(): SheetDetent {
|
|
913
|
+
const height = this.#currentHeight();
|
|
914
|
+
const moved = height - this.#dragFrom;
|
|
915
|
+
const all = this.#detentList();
|
|
916
|
+
const onward =
|
|
917
|
+
Math.abs(moved) < DETENT_THRESHOLD
|
|
918
|
+
? []
|
|
919
|
+
: all.filter(
|
|
920
|
+
(detent) =>
|
|
921
|
+
(this.#detentHeight(detent) - this.#dragFrom) * moved > 0,
|
|
922
|
+
);
|
|
923
|
+
return (onward.length > 0 ? onward : all).reduce((nearest, detent) =>
|
|
924
|
+
Math.abs(this.#detentHeight(detent) - height) <
|
|
925
|
+
Math.abs(this.#detentHeight(nearest) - height)
|
|
926
|
+
? detent
|
|
927
|
+
: nearest,
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* A detent's height, from the bottom of the screen to the sheet's top.
|
|
933
|
+
* The fit detent's is its content's, with a glass sheet's inset, and no
|
|
934
|
+
* taller than the large detent.
|
|
935
|
+
*/
|
|
936
|
+
#detentHeight(detent: SheetDetent): number {
|
|
937
|
+
switch (detent) {
|
|
938
|
+
case 'medium':
|
|
939
|
+
return this.#screenHeight() / 2;
|
|
940
|
+
case 'large':
|
|
941
|
+
return this.#largeHeight();
|
|
942
|
+
default: {
|
|
943
|
+
const fit =
|
|
944
|
+
this.#fitHeight() + (this.look() === 'glass' ? GLASS_INSET : 0);
|
|
945
|
+
const large = this.#largeHeight();
|
|
946
|
+
return large > 0 ? Math.min(fit, large) : fit;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* The sheet's height at rest at a detent: null fits its content.
|
|
953
|
+
*/
|
|
954
|
+
#restingHeight(detent: SheetDetent): number | null {
|
|
955
|
+
return detent === 'fit' ? null : this.#detentHeight(detent);
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
/**
|
|
959
|
+
* The sheet's height now.
|
|
960
|
+
*/
|
|
961
|
+
#currentHeight(): number {
|
|
962
|
+
return this.#height() ?? this.#detentHeight('fit');
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Comes to rest at a detent, transitioning the sheet's frame there: its
|
|
967
|
+
* height and, for glass, its inset, with the opaque layer's fade. A sheet
|
|
968
|
+
* fitting its content takes its content's height once the transition has
|
|
969
|
+
* reached it. Under Reduce Motion it moves at once.
|
|
970
|
+
*/
|
|
971
|
+
#settle(detent: SheetDetent): void {
|
|
972
|
+
this.#restingAt = detent;
|
|
973
|
+
this.detent.set(detent);
|
|
974
|
+
const target = this.#restingHeight(detent);
|
|
975
|
+
const run = ++this.#settleRun;
|
|
976
|
+
if (this.#reduceMotion() || this.#height() === target) {
|
|
977
|
+
this.#height.set(target);
|
|
978
|
+
this.#motion.set('rest');
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (this.#height() === null) {
|
|
982
|
+
// A transition can't start from a height that fits the content (a CSS
|
|
983
|
+
// transition from `auto` jumps), so pin the content's height for a
|
|
984
|
+
// frame first.
|
|
985
|
+
this.#height.set(this.#detentHeight('fit'));
|
|
986
|
+
this.#later(() => {
|
|
987
|
+
if (run === this.#settleRun) this.#transitionTo(detent, target, run);
|
|
988
|
+
}, FRAME);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
this.#transitionTo(detent, target, run);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
#transitionTo(detent: SheetDetent, target: number | null, run: number): void {
|
|
995
|
+
this.#motion.set('settle');
|
|
996
|
+
this.#height.set(this.#detentHeight(detent));
|
|
997
|
+
// +20ms: Lynx's timers can fire a little early (see #doClose).
|
|
998
|
+
this.#later(() => {
|
|
999
|
+
if (run !== this.#settleRun) return;
|
|
1000
|
+
this.#height.set(target);
|
|
1001
|
+
this.#motion.set('rest');
|
|
1002
|
+
}, DURATION.slow + 20);
|
|
328
1003
|
}
|
|
329
1004
|
|
|
330
1005
|
/**
|
|
@@ -355,12 +1030,130 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
355
1030
|
* targets elements that haven't been flushed to native yet and silently fails.
|
|
356
1031
|
*/
|
|
357
1032
|
#doOpen(): void {
|
|
1033
|
+
const sequence = ++this.#sequence;
|
|
358
1034
|
this.#later(() => {
|
|
359
1035
|
this.overlayVisible.set(true);
|
|
360
|
-
this.#later(() => this.#
|
|
1036
|
+
this.#later(() => this.#present(LAYOUT_TRIES, sequence), 0);
|
|
361
1037
|
}, 0);
|
|
362
1038
|
}
|
|
363
1039
|
|
|
1040
|
+
/**
|
|
1041
|
+
* Shows the sheet at its first detent, or at `detent` when the app chose
|
|
1042
|
+
* one. A detent other than the content's height needs the screen's: if it
|
|
1043
|
+
* hasn't been laid out yet (the first time the overlay shows), wait for it,
|
|
1044
|
+
* a frame at a time, then open at the content's height if it still isn't.
|
|
1045
|
+
*/
|
|
1046
|
+
#present(tries: number, sequence: number): void {
|
|
1047
|
+
const detents = this.#detentList();
|
|
1048
|
+
const laidOut = this.#screenHeight() > 0 && this.#largeHeight() > 0;
|
|
1049
|
+
// Growing out of its control needs the screen's size, as other detents
|
|
1050
|
+
// than the content's height do.
|
|
1051
|
+
const needsLayout =
|
|
1052
|
+
this.#grows() || detents.some((detent) => detent !== 'fit');
|
|
1053
|
+
if (needsLayout && !laidOut && tries > 0) {
|
|
1054
|
+
this.#later(() => this.#present(tries - 1, sequence), FRAME);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const chosen = this.detent();
|
|
1058
|
+
const detent =
|
|
1059
|
+
chosen !== undefined && detents.includes(chosen) ? chosen : detents[0];
|
|
1060
|
+
this.#restingAt = detent;
|
|
1061
|
+
this.detent.set(detent);
|
|
1062
|
+
this.#height.set(laidOut ? this.#restingHeight(detent) : null);
|
|
1063
|
+
this.#motion.set('rest');
|
|
1064
|
+
const source = this.#grows() ? this.#source() : null;
|
|
1065
|
+
if (source && laidOut) {
|
|
1066
|
+
void this.#growIn(source, detent, sequence);
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
this.#presented.set(true);
|
|
1070
|
+
this.#animateIn();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Grows the sheet out of the control that presents it: the panel shows at
|
|
1075
|
+
* the control's frame, in the same update that hides the control, then
|
|
1076
|
+
* moves and resizes to its detent, which iOS's glass follows frame by
|
|
1077
|
+
* frame. A control that can't be measured gets the slide instead.
|
|
1078
|
+
*/
|
|
1079
|
+
async #growIn(
|
|
1080
|
+
source: SourceElement,
|
|
1081
|
+
detent: SheetDetent,
|
|
1082
|
+
sequence: number,
|
|
1083
|
+
): Promise<void> {
|
|
1084
|
+
const frame = await this.#measureSource(source);
|
|
1085
|
+
this.#inSequence(sequence, () => {
|
|
1086
|
+
this.#sourceFrame.set(frame);
|
|
1087
|
+
this.#presented.set(true);
|
|
1088
|
+
if (!frame) {
|
|
1089
|
+
this.#animateIn();
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
// The backdrop dims through its color (see backdropStyle).
|
|
1093
|
+
this.#backdropAnim?.cancel();
|
|
1094
|
+
// See #animateIn.
|
|
1095
|
+
this.panelRef().nativeElement.invoke?.('requestAccessibilityFocus');
|
|
1096
|
+
// A frame at the control's place, so the native side draws it there,
|
|
1097
|
+
// then on to the detent, with a height to transition to (one that
|
|
1098
|
+
// fits the content is `auto`, which a transition jumps to), which the
|
|
1099
|
+
// content's own takes over at rest.
|
|
1100
|
+
this.#later(
|
|
1101
|
+
() =>
|
|
1102
|
+
this.#inSequence(sequence, () => {
|
|
1103
|
+
this.#height.set(this.#detentHeight(detent));
|
|
1104
|
+
this.#motion.set('grow');
|
|
1105
|
+
this.#sourceFrame.set(null);
|
|
1106
|
+
// +20ms: Lynx's timers can fire a little early (see #doClose).
|
|
1107
|
+
this.#later(
|
|
1108
|
+
() =>
|
|
1109
|
+
this.#inSequence(sequence, () => {
|
|
1110
|
+
// A drag that began meanwhile has the sheet now.
|
|
1111
|
+
if (this.#motion() !== 'grow') return;
|
|
1112
|
+
this.#height.set(this.#restingHeight(detent));
|
|
1113
|
+
this.#motion.set('rest');
|
|
1114
|
+
}),
|
|
1115
|
+
DURATION.slow + 120,
|
|
1116
|
+
);
|
|
1117
|
+
}),
|
|
1118
|
+
FRAME,
|
|
1119
|
+
);
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/**
|
|
1124
|
+
* Runs `step` of the open or close sequence numbered `sequence`, unless
|
|
1125
|
+
* another has started since.
|
|
1126
|
+
*/
|
|
1127
|
+
#inSequence(sequence: number, step: () => void): void {
|
|
1128
|
+
if (sequence === this.#sequence) step();
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* The control's frame, as the panel's style places it, or null when it
|
|
1133
|
+
* can't be measured. Measured boxes are in the screen's pixels, which the
|
|
1134
|
+
* docs' web previews scale down: the backdrop's measured width against its
|
|
1135
|
+
* laid out one undoes that (see UiMenuPopup, which does the same).
|
|
1136
|
+
*/
|
|
1137
|
+
async #measureSource(source: SourceElement): Promise<PanelFrame | null> {
|
|
1138
|
+
const [box, screen] = await Promise.all([
|
|
1139
|
+
measure(source),
|
|
1140
|
+
measure(this.backdropRef().nativeElement),
|
|
1141
|
+
]);
|
|
1142
|
+
const width = this.#screenWidth();
|
|
1143
|
+
// Gone, or nowhere: a control removed, or hidden with `display: none`.
|
|
1144
|
+
if (!box || !screen || !box.width || !width) return null;
|
|
1145
|
+
const scale = screen.width / width;
|
|
1146
|
+
const left = (box.left - screen.left) / scale;
|
|
1147
|
+
const top = (box.top - screen.top) / scale;
|
|
1148
|
+
const height = box.height / scale;
|
|
1149
|
+
return {
|
|
1150
|
+
left,
|
|
1151
|
+
right: width - left - box.width / scale,
|
|
1152
|
+
bottom: this.#screenHeight() - top - height,
|
|
1153
|
+
height,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
364
1157
|
/**
|
|
365
1158
|
* Reverse: animate out first, then hide the overlay once the animation
|
|
366
1159
|
* completes. The +20ms buffer absorbs timer imprecision in the Lynx
|
|
@@ -368,22 +1161,72 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
368
1161
|
* before the fade finishes.
|
|
369
1162
|
*/
|
|
370
1163
|
#doClose(): void {
|
|
1164
|
+
const sequence = ++this.#sequence;
|
|
371
1165
|
this.#later(() => {
|
|
1166
|
+
// A sheet dragged down goes on down, from the finger.
|
|
1167
|
+
const source =
|
|
1168
|
+
this.#grows() && this.#presented() && this.dragOffset() === 0
|
|
1169
|
+
? this.#source()
|
|
1170
|
+
: null;
|
|
1171
|
+
if (source) {
|
|
1172
|
+
void this.#shrinkOut(source, sequence);
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
372
1175
|
this.#animateOut();
|
|
373
|
-
this.#later(() =>
|
|
374
|
-
this.overlayVisible.set(false);
|
|
375
|
-
this.closed.emit();
|
|
376
|
-
// Reset for the next open: the sheet returns to its docked position
|
|
377
|
-
// while hidden, so a fresh open animates cleanly from off-screen.
|
|
378
|
-
this.dragOffset.set(0);
|
|
379
|
-
}, DURATION.normal + 20);
|
|
1176
|
+
this.#later(() => this.#finishClose(), DURATION.normal + 20);
|
|
380
1177
|
}, 0);
|
|
381
1178
|
}
|
|
382
1179
|
|
|
1180
|
+
/**
|
|
1181
|
+
* Shrinks the sheet back into the control that presented it, wherever the
|
|
1182
|
+
* control is now, or slides it down if it can't be measured.
|
|
1183
|
+
*/
|
|
1184
|
+
async #shrinkOut(source: SourceElement, sequence: number): Promise<void> {
|
|
1185
|
+
const frame = await this.#measureSource(source);
|
|
1186
|
+
this.#inSequence(sequence, () => {
|
|
1187
|
+
if (!frame) {
|
|
1188
|
+
this.#animateOut();
|
|
1189
|
+
this.#later(() => this.#finishClose(), DURATION.normal + 20);
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
// The backdrop clears through its color (see backdropStyle).
|
|
1193
|
+
this.#backdropAnim?.cancel();
|
|
1194
|
+
// A height to transition from (see #growIn) for a frame, then into
|
|
1195
|
+
// the control.
|
|
1196
|
+
this.#height.set(this.#currentHeight());
|
|
1197
|
+
this.#motion.set('shrink');
|
|
1198
|
+
this.#later(
|
|
1199
|
+
() =>
|
|
1200
|
+
this.#inSequence(sequence, () => {
|
|
1201
|
+
this.#sourceFrame.set(frame);
|
|
1202
|
+
this.#later(() => this.#finishClose(), DURATION.normal + 20);
|
|
1203
|
+
}),
|
|
1204
|
+
FRAME,
|
|
1205
|
+
);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Hides the overlay once the sheet has gone. A sheet that grew out of its
|
|
1211
|
+
* control hides its glass here too, in the update that shows the control.
|
|
1212
|
+
*/
|
|
1213
|
+
#finishClose(): void {
|
|
1214
|
+
this.overlayVisible.set(false);
|
|
1215
|
+
this.#presented.set(false);
|
|
1216
|
+
this.#sourceFrame.set(null);
|
|
1217
|
+
this.#motion.set('rest');
|
|
1218
|
+
this.closed.emit();
|
|
1219
|
+
// Reset for the next open: the sheet returns to its docked position
|
|
1220
|
+
// while hidden, so a fresh open animates cleanly from off-screen,
|
|
1221
|
+
// at its first detent unless the app sets another.
|
|
1222
|
+
this.dragOffset.set(0);
|
|
1223
|
+
this.#restingAt = undefined;
|
|
1224
|
+
this.detent.set(undefined);
|
|
1225
|
+
}
|
|
1226
|
+
|
|
383
1227
|
#animateIn(): void {
|
|
384
|
-
const backdrop = this.backdropRef()
|
|
385
|
-
const panel = this.panelRef()
|
|
386
|
-
if (!backdrop || !panel) return;
|
|
1228
|
+
const backdrop = this.backdropRef().nativeElement;
|
|
1229
|
+
const panel = this.panelRef().nativeElement;
|
|
387
1230
|
|
|
388
1231
|
this.#backdropAnim?.cancel();
|
|
389
1232
|
this.#panelAnim?.cancel();
|
|
@@ -394,62 +1237,146 @@ export class UiBottomSheet implements OnDestroy {
|
|
|
394
1237
|
// accessibility-exclusive-focus (see the template), it then can't leave
|
|
395
1238
|
// the panel until it closes.
|
|
396
1239
|
panel.invoke?.('requestAccessibilityFocus');
|
|
397
|
-
this.#panelAnim = slideIn(panel
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
1240
|
+
this.#panelAnim = this.#slideIn(panel);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/**
|
|
1244
|
+
* Slides the panel up from below the screen. Glass slides without fading
|
|
1245
|
+
* on its own: iOS draws glass under a fading parent as a washed-out patch,
|
|
1246
|
+
* so it only fades with the backdrop it sits on, which the slide outruns.
|
|
1247
|
+
* Under Reduce Motion the glass variant doesn't slide: it fades in with the
|
|
1248
|
+
* backdrop where it rests.
|
|
1249
|
+
*/
|
|
1250
|
+
#slideIn(panel: Element): AnimationHandle | undefined {
|
|
1251
|
+
const options = { duration: DURATION.slow, easing: EASING.sheet };
|
|
1252
|
+
if (this.#calm()) return undefined;
|
|
1253
|
+
if (this.look() !== 'glass') return slideIn(panel, 'up', options);
|
|
1254
|
+
// 110%: the inset glass sheet sits 8px above the screen's bottom edge,
|
|
1255
|
+
// which 100% would leave showing.
|
|
1256
|
+
return panel.animate(
|
|
1257
|
+
[{ transform: 'translateY(110%)' }, { transform: 'translateY(0%)' }],
|
|
1258
|
+
{ ...options, fill: 'forwards' },
|
|
1259
|
+
);
|
|
401
1260
|
}
|
|
402
1261
|
|
|
403
1262
|
#animateOut(): void {
|
|
404
|
-
const backdrop = this.backdropRef()
|
|
405
|
-
const panel = this.panelRef()
|
|
406
|
-
if (!backdrop || !panel) return;
|
|
1263
|
+
const backdrop = this.backdropRef().nativeElement;
|
|
1264
|
+
const panel = this.panelRef().nativeElement;
|
|
407
1265
|
|
|
408
1266
|
this.#backdropAnim?.cancel();
|
|
409
1267
|
this.#panelAnim?.cancel();
|
|
410
1268
|
|
|
411
1269
|
this.#backdropAnim = fadeOut(backdrop, { duration: DURATION.normal });
|
|
1270
|
+
this.#panelAnim = this.#slideOut(panel);
|
|
1271
|
+
}
|
|
412
1272
|
|
|
1273
|
+
/**
|
|
1274
|
+
* The reverse of #slideIn. Under Reduce Motion the glass variant fades out
|
|
1275
|
+
* with the backdrop where it rests.
|
|
1276
|
+
*/
|
|
1277
|
+
#slideOut(panel: Element): AnimationHandle | undefined {
|
|
1278
|
+
const options = {
|
|
1279
|
+
duration: DURATION.normal,
|
|
1280
|
+
easing: EASING.accelerate,
|
|
1281
|
+
fill: 'forwards' as const,
|
|
1282
|
+
};
|
|
1283
|
+
if (this.#calm()) return undefined;
|
|
1284
|
+
const glass = this.look() === 'glass';
|
|
413
1285
|
// If the sheet was dragged, continue from the finger's offset off-screen
|
|
414
1286
|
// (px → px) rather than slideOut's 0% → 100%, which would snap it up first.
|
|
415
1287
|
const offset = this.dragOffset();
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
1288
|
+
if (offset > 0) {
|
|
1289
|
+
const to = `translateY(${Math.max(DISMISS_TRANSLATE, this.#screenHeight())}px)`;
|
|
1290
|
+
const from = `translateY(${offset}px)`;
|
|
1291
|
+
return panel.animate(
|
|
1292
|
+
glass
|
|
1293
|
+
? [{ transform: from }, { transform: to }]
|
|
1294
|
+
: [
|
|
1295
|
+
{ transform: from, opacity: 1 },
|
|
1296
|
+
{ transform: to, opacity: 0.8 },
|
|
422
1297
|
],
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
});
|
|
1298
|
+
options,
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
return glass
|
|
1302
|
+
? panel.animate(
|
|
1303
|
+
[{ transform: 'translateY(0%)' }, { transform: 'translateY(110%)' }],
|
|
1304
|
+
options,
|
|
1305
|
+
)
|
|
1306
|
+
: slideOut(panel, 'down', options);
|
|
433
1307
|
}
|
|
434
1308
|
|
|
435
1309
|
/**
|
|
436
1310
|
* Release below the dismiss threshold: spring the sheet back to fully open,
|
|
437
|
-
* continuing smoothly from wherever the finger let go (px → 0px).
|
|
1311
|
+
* continuing smoothly from wherever the finger let go (px → 0px). Under
|
|
1312
|
+
* Reduce Motion the glass variant goes straight back.
|
|
438
1313
|
*/
|
|
439
1314
|
#snapBack(fromOffset: number): void {
|
|
440
|
-
const panel = this.panelRef()?.nativeElement;
|
|
441
1315
|
this.#panelAnim?.cancel();
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
1316
|
+
this.#panelAnim = this.#calm()
|
|
1317
|
+
? undefined
|
|
1318
|
+
: this.panelRef().nativeElement.animate(
|
|
1319
|
+
[
|
|
1320
|
+
{ transform: `translateY(${fromOffset}px)` },
|
|
1321
|
+
{ transform: 'translateY(0px)' },
|
|
1322
|
+
],
|
|
1323
|
+
{ duration: DURATION.fast, easing: EASING.spring, fill: 'forwards' },
|
|
1324
|
+
);
|
|
451
1325
|
// Match the inline transform to the animation's resting state so the two
|
|
452
1326
|
// agree once fill:'forwards' hands control back to the style binding.
|
|
453
1327
|
this.dragOffset.set(0);
|
|
454
1328
|
}
|
|
455
1329
|
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Marks the control that presents a sheet, which then grows out of it, as
|
|
1333
|
+
* iOS 26 grows a sheet out of the button that presents it, and shrinks back
|
|
1334
|
+
* into it as it closes.
|
|
1335
|
+
*
|
|
1336
|
+
* ```html
|
|
1337
|
+
* <blur-view
|
|
1338
|
+
* ui-glass
|
|
1339
|
+
* shape="circle"
|
|
1340
|
+
* class="h-11 w-11 items-center flex justify-center"
|
|
1341
|
+
* [uiSheetSource]="sheet"
|
|
1342
|
+
* (bindtap)="open.set(true)"
|
|
1343
|
+
* >
|
|
1344
|
+
* <ui-icon name="plus" />
|
|
1345
|
+
* </blur-view>
|
|
1346
|
+
* <ui-sheet #sheet variant="glass" [(open)]="open">…</ui-sheet>
|
|
1347
|
+
* ```
|
|
1348
|
+
*
|
|
1349
|
+
* It only says where the sheet comes from: open it as before. The Liquid
|
|
1350
|
+
* Glass sheet (`variant="glass"` on iOS 26 and later, in the web preview,
|
|
1351
|
+
* and on Android with `provideGlass({ android: 'glass' })`) grows out of the
|
|
1352
|
+
* control, which hides meanwhile. A control that's glass itself shares the
|
|
1353
|
+
* sheet's glass id, so on iOS the sheet takes its glass over (see UiGlass's
|
|
1354
|
+
* `glassId`). Elsewhere, and under Reduce Motion, the sheet slides up as it
|
|
1355
|
+
* does without one.
|
|
1356
|
+
*/
|
|
1357
|
+
@Directive({
|
|
1358
|
+
selector: '[uiSheetSource]',
|
|
1359
|
+
host: {
|
|
1360
|
+
'[style.visibility]': 'sheet().coversSource() ? "hidden" : null',
|
|
1361
|
+
},
|
|
1362
|
+
})
|
|
1363
|
+
export class UiSheetSource {
|
|
1364
|
+
/** The sheet this control presents: a `ui-sheet` or `ui-bottom-sheet`. */
|
|
1365
|
+
readonly sheet = input.required<SheetSourceTarget>({
|
|
1366
|
+
alias: 'uiSheetSource',
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
readonly #element =
|
|
1370
|
+
inject<ElementRef<SourceElement>>(ElementRef).nativeElement;
|
|
1371
|
+
readonly #glass = inject(UiGlass, { self: true, optional: true });
|
|
1372
|
+
|
|
1373
|
+
/**
|
|
1374
|
+
* Points the sheet at this control, and away from it when the directive
|
|
1375
|
+
* is destroyed or points at another sheet.
|
|
1376
|
+
*/
|
|
1377
|
+
protected readonly register = effect((onCleanup) => {
|
|
1378
|
+
const sheet = this.sheet();
|
|
1379
|
+
sheet.setSource(this.#element, this.#glass);
|
|
1380
|
+
onCleanup(() => sheet.setSource(null, null));
|
|
1381
|
+
});
|
|
1382
|
+
}
|