@blotch/dolan 22.2.4 → 22.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/dist/commands/add.test.js +2 -0
  2. package/dist/commands/add.test.js.map +1 -1
  3. package/dist/commands/doctor.js +78 -21
  4. package/dist/commands/doctor.js.map +1 -1
  5. package/dist/commands/doctor.test.js +167 -26
  6. package/dist/commands/doctor.test.js.map +1 -1
  7. package/dist/commands/init.test.js +4 -7
  8. package/dist/commands/init.test.js.map +1 -1
  9. package/dist/commands/update.d.ts +3 -1
  10. package/dist/commands/update.js +78 -15
  11. package/dist/commands/update.js.map +1 -1
  12. package/dist/commands/update.test.js +257 -2
  13. package/dist/commands/update.test.js.map +1 -1
  14. package/dist/lockfile.d.ts +10 -0
  15. package/dist/lockfile.js +20 -1
  16. package/dist/lockfile.js.map +1 -1
  17. package/dist/lockfile.test.js +43 -3
  18. package/dist/lockfile.test.js.map +1 -1
  19. package/dist/registry.js +73 -11
  20. package/dist/registry.js.map +1 -1
  21. package/dist/registry.test.js +16 -3
  22. package/dist/registry.test.js.map +1 -1
  23. package/dist/test-utils.d.ts +24 -2
  24. package/dist/test-utils.js +41 -2
  25. package/dist/test-utils.js.map +1 -1
  26. package/dist/ui/boolean-inputs.test.ts +76 -0
  27. package/dist/ui/components/action-sheet/action-sheet.test.ts +370 -1
  28. package/dist/ui/components/action-sheet/action-sheet.ts +306 -28
  29. package/dist/ui/components/action-sheet/index.ts +1 -0
  30. package/dist/ui/components/alert-dialog/alert-dialog.test.ts +552 -8
  31. package/dist/ui/components/alert-dialog/alert-dialog.ts +644 -36
  32. package/dist/ui/components/alert-dialog/index.ts +5 -0
  33. package/dist/ui/components/area-chart/area-chart.ts +3 -2
  34. package/dist/ui/components/badge/badge.ts +2 -1
  35. package/dist/ui/components/bottom-sheet/bottom-sheet.test.ts +968 -3
  36. package/dist/ui/components/bottom-sheet/bottom-sheet.ts +1021 -95
  37. package/dist/ui/components/bottom-sheet/index.ts +7 -1
  38. package/dist/ui/components/button/button.test.ts +36 -1
  39. package/dist/ui/components/button/button.ts +17 -7
  40. package/dist/ui/components/button-group/button-group.test.ts +53 -9
  41. package/dist/ui/components/button-group/button-group.ts +67 -21
  42. package/dist/ui/components/card/card.test.ts +16 -0
  43. package/dist/ui/components/card/card.ts +2 -1
  44. package/dist/ui/components/chart/chart-presets.ts +6 -6
  45. package/dist/ui/components/chart/chart.test.ts +14 -0
  46. package/dist/ui/components/chart/chart.ts +10 -3
  47. package/dist/ui/components/checkbox/checkbox.ts +2 -1
  48. package/dist/ui/components/collapsible/collapsible.ts +2 -1
  49. package/dist/ui/components/dialog/dialog.ts +6 -6
  50. package/dist/ui/components/dock/dock-scroll.ts +9 -0
  51. package/dist/ui/components/dock/dock-search.test.ts +594 -0
  52. package/dist/ui/components/dock/dock.test.ts +4 -1
  53. package/dist/ui/components/dock/dock.ts +529 -51
  54. package/dist/ui/components/dynamic-color/dynamic-color.test.ts +144 -0
  55. package/dist/ui/components/dynamic-color/dynamic-color.ts +146 -0
  56. package/dist/ui/components/dynamic-color/index.ts +5 -0
  57. package/dist/ui/components/glass/glass.test.ts +39 -1
  58. package/dist/ui/components/glass/glass.ts +87 -17
  59. package/dist/ui/components/glass/index.ts +1 -0
  60. package/dist/ui/components/icon/icon-theme.test.ts +28 -0
  61. package/dist/ui/components/icon/icon-theme.ts +38 -7
  62. package/dist/ui/components/input/input.ts +2 -1
  63. package/dist/ui/components/label/label.ts +8 -2
  64. package/dist/ui/components/line-chart/line-chart.ts +3 -2
  65. package/dist/ui/components/list/list.ts +5 -4
  66. package/dist/ui/components/menu/menu-popup.test.ts +18 -14
  67. package/dist/ui/components/menu/menu-popup.ts +24 -3
  68. package/dist/ui/components/menu/menu.test.ts +99 -5
  69. package/dist/ui/components/menu/menu.ts +74 -34
  70. package/dist/ui/components/nav-drawer/nav-drawer.ts +3 -2
  71. package/dist/ui/components/navigation-bar/index.ts +1 -0
  72. package/dist/ui/components/navigation-bar/navigation-bar.test.ts +425 -0
  73. package/dist/ui/components/navigation-bar/navigation-bar.ts +411 -0
  74. package/dist/ui/components/popover/index.ts +9 -0
  75. package/dist/ui/components/popover/popover-panel.test.ts +735 -0
  76. package/dist/ui/components/popover/popover-panel.ts +603 -0
  77. package/dist/ui/components/popover/popover.test.ts +286 -0
  78. package/dist/ui/components/popover/popover.ts +207 -0
  79. package/dist/ui/components/progress/progress.ts +2 -1
  80. package/dist/ui/components/radio-group/radio-group.ts +2 -1
  81. package/dist/ui/components/scroll-area/scroll-area.ts +4 -3
  82. package/dist/ui/components/search-field/index.ts +15 -0
  83. package/dist/ui/components/search-field/keyboard.test.ts +147 -0
  84. package/dist/ui/components/search-field/keyboard.ts +123 -0
  85. package/dist/ui/components/search-field/search-field.test.ts +549 -0
  86. package/dist/ui/components/search-field/search-field.ts +628 -0
  87. package/dist/ui/components/segmented-control/index.ts +8 -0
  88. package/dist/ui/components/segmented-control/segmented-control.test.ts +861 -0
  89. package/dist/ui/components/segmented-control/segmented-control.ts +884 -0
  90. package/dist/ui/components/select/select.ts +2 -1
  91. package/dist/ui/components/sheet/sheet.test.ts +90 -3
  92. package/dist/ui/components/sheet/sheet.ts +59 -4
  93. package/dist/ui/components/slider/index.ts +1 -0
  94. package/dist/ui/components/slider/slider.test.ts +695 -0
  95. package/dist/ui/components/slider/slider.ts +901 -0
  96. package/dist/ui/components/switch/index.ts +1 -1
  97. package/dist/ui/components/switch/switch.test.ts +464 -48
  98. package/dist/ui/components/switch/switch.ts +613 -43
  99. package/dist/ui/components/tabs/index.ts +7 -1
  100. package/dist/ui/components/tabs/tabs.test.ts +278 -2
  101. package/dist/ui/components/tabs/tabs.ts +233 -30
  102. package/dist/ui/components/textarea/textarea.ts +2 -1
  103. package/dist/ui/components/toggle/index.ts +1 -0
  104. package/dist/ui/components/toggle/toggle.test.ts +310 -4
  105. package/dist/ui/components/toggle/toggle.ts +241 -26
  106. package/dist/ui/components/toolbar/bar-scroll.test.ts +310 -0
  107. package/dist/ui/components/toolbar/bar-scroll.ts +372 -0
  108. package/dist/ui/components/toolbar/index.ts +12 -0
  109. package/dist/ui/components/toolbar/toolbar.test.ts +438 -0
  110. package/dist/ui/components/toolbar/toolbar.ts +535 -0
  111. package/dist/utils/analyze.d.ts +10 -5
  112. package/dist/utils/analyze.js +3 -2
  113. package/dist/utils/analyze.js.map +1 -1
  114. package/dist/utils/installed.d.ts +13 -1
  115. package/dist/utils/installed.js +17 -0
  116. package/dist/utils/installed.js.map +1 -1
  117. package/dist/utils/installed.test.js +39 -1
  118. package/dist/utils/installed.test.js.map +1 -1
  119. package/dist/utils/tailwind.d.ts +25 -5
  120. package/dist/utils/tailwind.js +53 -14
  121. package/dist/utils/tailwind.js.map +1 -1
  122. package/dist/utils/tailwind.test.js +85 -22
  123. package/dist/utils/tailwind.test.js.map +1 -1
  124. package/package.json +1 -1
@@ -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 { LYNX_ELEMENTS } from '@blotch/angular-lynx';
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 BACKDROP_MAX_DIM alpha when the sheet
41
- // is fully open and lightens toward transparent as the handle is dragged down,
42
- // so the dimming reads as coupled to how much of the sheet is still on screen.
43
- const BACKDROP_MAX_DIM = 0.5;
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 down by its handle to dismiss.
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
- <overlay [attr.visible]="overlayVisible()" [style]="overlayStyle()">
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]="panelPositionStyle()"
304
+ [style]="panelStyle()"
91
305
  [accessibility-exclusive-focus]="overlayVisible()"
92
306
  (catchtap)="onPanelTap()"
307
+ (bindlayoutchange)="onPanelLayout($event)"
93
308
  >
94
- <view
95
- class="items-center pb-3 pt-2 flex justify-center"
96
- (catchtouchstart)="onHandleTouchStart($event)"
97
- (catchtouchmove)="onHandleTouchMove($event)"
98
- (catchtouchend)="onDragEnd()"
99
- (catchtouchcancel)="onDragEnd()"
100
- (catchmousedown)="onHandleMouseDown($event)"
101
- >
102
- <view class="h-1 w-10 rounded-full bg-muted" />
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
@@ -137,21 +406,65 @@ export class UiBottomSheet implements OnDestroy {
137
406
  // them.
138
407
  readonly #timers = new Set<ReturnType<typeof setTimeout>>();
139
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
+
140
447
  // --- Drag-to-dismiss state ---
141
- // Live downward drag distance (px), applied to the panel as a translateY via
142
- // `panelPositionStyle()` so the sheet tracks the finger in real time.
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.
143
451
  protected readonly dragOffset = signal(0);
144
- // Screen Y where the current drag began; the delta from here drives dragOffset.
452
+ // Screen Y where the current drag began; the delta from here drives the
453
+ // drag.
145
454
  #dragStartY = 0;
455
+ // The sheet's height when the current drag began.
456
+ #dragFrom = 0;
146
457
  // True only between touchstart and touchend on the handle, so unrelated move
147
458
  // events can't shift the sheet.
148
459
  #isDragging = false;
149
460
  // Set when a mouse drag that actually moved is released. The browser follows
150
461
  // that mouseup with a click on the nearest element under both the mousedown
151
- // and the mouseup, which is the backdrop whenever the cursor ended up off the
152
- // panel, and Lynx web maps that click to the backdrop's tap. Without this, a
153
- // drag released above the panel (where the sheet snaps back) would close it.
154
- #ignoreNextBackdropTap = false;
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;
155
468
  // Whether the current drag is a mouse drag that has moved: only those are
156
469
  // followed by a click. A moved touch produces no tap.
157
470
  #mouseDragMoved = false;
@@ -176,11 +489,116 @@ export class UiBottomSheet implements OnDestroy {
176
489
  }
177
490
  });
178
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;
507
+ }
508
+ this.#settle(detent);
509
+ });
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
+ });
596
+
179
597
  protected readonly panelClass = computed(() =>
180
598
  cn(
181
- 'flex-col rounded-t-2xl border-t border-border bg-background flex',
182
- 'w-full',
183
- // The panel docks at bottom: 0 (see panelPositionStyle), so its lower edge
599
+ 'flex-col flex',
600
+ PANEL_CLASS[this.look()],
601
+ // The panel docks at bottom: 0 (see panelStyle), so its lower edge
184
602
  // sits behind the home indicator. `pb-safe` pads the bottom by
185
603
  // env(safe-area-inset-bottom), lifting the projected content clear of it
186
604
  // while the panel background still fills to the screen edge. Degrades to no
@@ -191,35 +609,149 @@ export class UiBottomSheet implements OnDestroy {
191
609
  ),
192
610
  );
193
611
 
194
- // Docked at the bottom, full width; `translateY(dragOffset)` layers the live
195
- // drag on top so the sheet follows the finger. The open/close slide
196
- // animations run via element.animate() and override this transform while they
197
- // play (see #animateIn/#animateOut), then hand back to it once canceled.
198
- protected readonly panelPositionStyle = computed(
199
- () =>
200
- `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()]),
614
+ );
615
+
616
+ protected readonly grabberClass = computed(() =>
617
+ cn('rounded-full', GRABBER_CLASS[this.look()]),
201
618
  );
202
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
+
203
694
  // Backdrop scrim dimmed in tandem with the sheet's reveal: full dim while
204
695
  // docked (dragOffset 0), lightening as the handle is dragged down so the
205
696
  // content behind shows through progressively. Driven through the same live
206
- // `[style]` path as panelPositionStyle — dragOffset updates on the drag's
697
+ // `[style]` path as panelStyle — dragOffset updates on the drag's
207
698
  // background thread, and inline styles reflect there (unlike element.animate()
208
699
  // opacity tweens, which the open/close fade uses on the main thread instead).
209
700
  // The alpha lives in the background-color rather than the element's opacity so
210
701
  // it composes cleanly with the fadeIn/fadeOut opacity animations without the
211
- // 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.
212
710
  protected readonly backdropStyle = computed(() => {
213
- const revealed = Math.max(
214
- 0,
215
- 1 - this.dragOffset() / BACKDROP_CLEAR_DISTANCE,
216
- );
217
- return `background-color: rgba(0, 0, 0, ${BACKDROP_MAX_DIM * revealed});`;
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;
218
720
  });
219
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
+
220
752
  protected onBackdropTap(): void {
221
- if (this.#ignoreNextBackdropTap) {
222
- this.#ignoreNextBackdropTap = false;
753
+ if (this.#ignoreNextTap) {
754
+ this.#ignoreNextTap = false;
223
755
  return;
224
756
  }
225
757
  this.open.set(false);
@@ -233,7 +765,7 @@ export class UiBottomSheet implements OnDestroy {
233
765
  * starts a new drag that resets the flag on release.
234
766
  */
235
767
  protected onBackdropMouseDown(): void {
236
- this.#ignoreNextBackdropTap = false;
768
+ this.#ignoreNextTap = false;
237
769
  }
238
770
 
239
771
  /**
@@ -244,6 +776,22 @@ export class UiBottomSheet implements OnDestroy {
244
776
  */
245
777
  protected onPanelTap(): void {}
246
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
+
247
795
  /**
248
796
  * Begin a drag on the handle strip.
249
797
  */
@@ -299,38 +847,159 @@ export class UiBottomSheet implements OnDestroy {
299
847
 
300
848
  /**
301
849
  * Release the drag, from a touchend, touchcancel or mouseup, or a mouse
302
- * move with no button held. Past the threshold we set `open` to false and let the
303
- * effect drive teardown (#doClose → drag-aware #animateOut), which keeps the
304
- * two-way `open` binding in sync and reuses the `closed` emit. Otherwise the
305
- * sheet springs back to fully open.
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).
306
856
  */
307
857
  protected onDragEnd(): void {
308
858
  if (!this.#isDragging) return;
309
859
  this.#isDragging = false;
310
- this.#ignoreNextBackdropTap = this.#mouseDragMoved;
311
- if (this.dragOffset() > DISMISS_THRESHOLD) {
860
+ this.#ignoreNextTap = this.#mouseDragMoved;
861
+ const offset = this.dragOffset();
862
+ if (offset > DISMISS_THRESHOLD) {
312
863
  this.open.set(false);
864
+ } else if (offset > 0) {
865
+ this.#snapBack(offset);
866
+ this.#settle(this.#ordered()[0]);
313
867
  } else {
314
- this.#snapBack(this.dragOffset());
868
+ this.#settle(this.#detentAfterDrag());
315
869
  }
316
870
  }
317
871
 
318
872
  /**
319
873
  * The open slideIn holds the panel at translateY(0%) via fill:'forwards',
320
874
  * which would override our inline transform — cancel it so `dragOffset`
321
- * (applied through panelPositionStyle) actually moves the panel from here on.
875
+ * (applied through panelStyle) actually moves the panel from here on.
322
876
  */
323
877
  #startDrag(y: number): void {
324
878
  this.#panelAnim?.cancel();
325
879
  this.#isDragging = true;
326
880
  this.#mouseDragMoved = false;
327
881
  this.#dragStartY = y;
882
+ this.#dragFrom = this.#currentHeight();
883
+ this.#settleRun++;
884
+ this.#motion.set('drag');
328
885
  }
329
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
+ */
330
892
  #moveDrag(y: number): void {
331
- // Clamp to >= 0: the sheet is already docked at the bottom, so only a
332
- // downward (dismiss-direction) drag should move it.
333
- this.dragOffset.set(Math.max(0, y - this.#dragStartY));
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);
334
1003
  }
335
1004
 
336
1005
  /**
@@ -361,12 +1030,130 @@ export class UiBottomSheet implements OnDestroy {
361
1030
  * targets elements that haven't been flushed to native yet and silently fails.
362
1031
  */
363
1032
  #doOpen(): void {
1033
+ const sequence = ++this.#sequence;
364
1034
  this.#later(() => {
365
1035
  this.overlayVisible.set(true);
366
- this.#later(() => this.#animateIn(), 0);
1036
+ this.#later(() => this.#present(LAYOUT_TRIES, sequence), 0);
367
1037
  }, 0);
368
1038
  }
369
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
+
370
1157
  /**
371
1158
  * Reverse: animate out first, then hide the overlay once the animation
372
1159
  * completes. The +20ms buffer absorbs timer imprecision in the Lynx
@@ -374,18 +1161,69 @@ export class UiBottomSheet implements OnDestroy {
374
1161
  * before the fade finishes.
375
1162
  */
376
1163
  #doClose(): void {
1164
+ const sequence = ++this.#sequence;
377
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
+ }
378
1175
  this.#animateOut();
379
- this.#later(() => {
380
- this.overlayVisible.set(false);
381
- this.closed.emit();
382
- // Reset for the next open: the sheet returns to its docked position
383
- // while hidden, so a fresh open animates cleanly from off-screen.
384
- this.dragOffset.set(0);
385
- }, DURATION.normal + 20);
1176
+ this.#later(() => this.#finishClose(), DURATION.normal + 20);
386
1177
  }, 0);
387
1178
  }
388
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
+
389
1227
  #animateIn(): void {
390
1228
  const backdrop = this.backdropRef().nativeElement;
391
1229
  const panel = this.panelRef().nativeElement;
@@ -399,10 +1237,26 @@ export class UiBottomSheet implements OnDestroy {
399
1237
  // accessibility-exclusive-focus (see the template), it then can't leave
400
1238
  // the panel until it closes.
401
1239
  panel.invoke?.('requestAccessibilityFocus');
402
- this.#panelAnim = slideIn(panel, 'up', {
403
- duration: DURATION.slow,
404
- easing: EASING.sheet,
405
- });
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
+ );
406
1260
  }
407
1261
 
408
1262
  #animateOut(): void {
@@ -413,44 +1267,116 @@ export class UiBottomSheet implements OnDestroy {
413
1267
  this.#panelAnim?.cancel();
414
1268
 
415
1269
  this.#backdropAnim = fadeOut(backdrop, { duration: DURATION.normal });
1270
+ this.#panelAnim = this.#slideOut(panel);
1271
+ }
416
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';
417
1285
  // If the sheet was dragged, continue from the finger's offset off-screen
418
1286
  // (px → px) rather than slideOut's 0% → 100%, which would snap it up first.
419
1287
  const offset = this.dragOffset();
420
- this.#panelAnim =
421
- offset > 0
422
- ? panel.animate(
423
- [
424
- { transform: `translateY(${offset}px)`, opacity: 1 },
425
- { transform: `translateY(${DISMISS_TRANSLATE}px)`, opacity: 0.8 },
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 },
426
1297
  ],
427
- {
428
- duration: DURATION.normal,
429
- easing: EASING.accelerate,
430
- fill: 'forwards',
431
- },
432
- )
433
- : slideOut(panel, 'down', {
434
- duration: DURATION.normal,
435
- easing: EASING.accelerate,
436
- });
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);
437
1307
  }
438
1308
 
439
1309
  /**
440
1310
  * Release below the dismiss threshold: spring the sheet back to fully open,
441
- * 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.
442
1313
  */
443
1314
  #snapBack(fromOffset: number): void {
444
1315
  this.#panelAnim?.cancel();
445
- this.#panelAnim = this.panelRef().nativeElement.animate(
446
- [
447
- { transform: `translateY(${fromOffset}px)` },
448
- { transform: 'translateY(0px)' },
449
- ],
450
- { duration: DURATION.fast, easing: EASING.spring, fill: 'forwards' },
451
- );
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
+ );
452
1325
  // Match the inline transform to the animation's resting state so the two
453
1326
  // agree once fill:'forwards' hands control back to the style binding.
454
1327
  this.dragOffset.set(0);
455
1328
  }
456
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
+ }