@oxyhq/bloom 0.48.0 → 0.49.1

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 (88) hide show
  1. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +3 -1
  2. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  3. package/lib/commonjs/dialog/Dialog.js +35 -4
  4. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  5. package/lib/commonjs/dialog/Dialog.web.js +48 -14
  6. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  7. package/lib/commonjs/dialog/DialogBottomSheet.js +30 -5
  8. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  9. package/lib/commonjs/dialog/DialogHeader.js +12 -6
  10. package/lib/commonjs/dialog/DialogHeader.js.map +1 -1
  11. package/lib/commonjs/dialog/DialogMorph.js +458 -0
  12. package/lib/commonjs/dialog/DialogMorph.js.map +1 -0
  13. package/lib/commonjs/dialog/index.js +7 -0
  14. package/lib/commonjs/dialog/index.js.map +1 -1
  15. package/lib/commonjs/dialog/index.web.js +7 -0
  16. package/lib/commonjs/dialog/index.web.js.map +1 -1
  17. package/lib/commonjs/settings-list/SettingsList.js +1 -1
  18. package/lib/commonjs/surfaces/SurfaceHost.js +1 -0
  19. package/lib/commonjs/surfaces/SurfaceHost.js.map +1 -1
  20. package/lib/module/bottom-sheet/BottomSheetBase.js +3 -1
  21. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  22. package/lib/module/dialog/Dialog.js +35 -4
  23. package/lib/module/dialog/Dialog.js.map +1 -1
  24. package/lib/module/dialog/Dialog.web.js +48 -15
  25. package/lib/module/dialog/Dialog.web.js.map +1 -1
  26. package/lib/module/dialog/DialogBottomSheet.js +31 -6
  27. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  28. package/lib/module/dialog/DialogHeader.js +12 -6
  29. package/lib/module/dialog/DialogHeader.js.map +1 -1
  30. package/lib/module/dialog/DialogMorph.js +451 -0
  31. package/lib/module/dialog/DialogMorph.js.map +1 -0
  32. package/lib/module/dialog/index.js +1 -0
  33. package/lib/module/dialog/index.js.map +1 -1
  34. package/lib/module/dialog/index.web.js +1 -0
  35. package/lib/module/dialog/index.web.js.map +1 -1
  36. package/lib/module/settings-list/SettingsList.js +1 -1
  37. package/lib/module/surfaces/SurfaceHost.js +1 -0
  38. package/lib/module/surfaces/SurfaceHost.js.map +1 -1
  39. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts +10 -3
  40. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +2 -2
  44. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/dialog/DialogMorph.d.ts +150 -0
  47. package/lib/typescript/commonjs/dialog/DialogMorph.d.ts.map +1 -0
  48. package/lib/typescript/commonjs/dialog/index.d.ts +2 -0
  49. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/dialog/index.web.d.ts +2 -0
  51. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/dialog/types.d.ts +30 -0
  53. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -1
  55. package/lib/typescript/commonjs/surfaces/types.d.ts +9 -0
  56. package/lib/typescript/commonjs/surfaces/types.d.ts.map +1 -1
  57. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts +10 -3
  58. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  59. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  60. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  61. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +2 -2
  62. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  63. package/lib/typescript/module/dialog/DialogHeader.d.ts.map +1 -1
  64. package/lib/typescript/module/dialog/DialogMorph.d.ts +150 -0
  65. package/lib/typescript/module/dialog/DialogMorph.d.ts.map +1 -0
  66. package/lib/typescript/module/dialog/index.d.ts +2 -0
  67. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  68. package/lib/typescript/module/dialog/index.web.d.ts +2 -0
  69. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  70. package/lib/typescript/module/dialog/types.d.ts +30 -0
  71. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  72. package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -1
  73. package/lib/typescript/module/surfaces/types.d.ts +9 -0
  74. package/lib/typescript/module/surfaces/types.d.ts.map +1 -1
  75. package/package.json +1 -1
  76. package/src/__tests__/DialogMorph.test.tsx +230 -0
  77. package/src/bottom-sheet/BottomSheetBase.tsx +15 -2
  78. package/src/dialog/Dialog.tsx +43 -11
  79. package/src/dialog/Dialog.web.tsx +40 -10
  80. package/src/dialog/DialogBottomSheet.tsx +37 -6
  81. package/src/dialog/DialogHeader.tsx +14 -5
  82. package/src/dialog/DialogMorph.tsx +576 -0
  83. package/src/dialog/index.ts +2 -0
  84. package/src/dialog/index.web.ts +2 -0
  85. package/src/dialog/types.ts +30 -0
  86. package/src/settings-list/SettingsList.tsx +1 -1
  87. package/src/surfaces/SurfaceHost.tsx +1 -0
  88. package/src/surfaces/types.ts +9 -0
@@ -0,0 +1,576 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useLayoutEffect,
7
+ useMemo,
8
+ useRef,
9
+ } from 'react';
10
+ import type { LayoutChangeEvent, StyleProp, ViewStyle } from 'react-native';
11
+ import Animated, {
12
+ cancelAnimation,
13
+ Easing,
14
+ useAnimatedStyle,
15
+ useSharedValue,
16
+ withTiming,
17
+ } from 'react-native-reanimated';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Size morphing — the panel reshapes across an IN-PLACE content swap.
21
+ //
22
+ // A surface has two axes. DEPTH: a new surface is presented on top (its own
23
+ // backdrop / z-layer) — untouched by this module. NAV-WITHIN: the content
24
+ // INSIDE one surface is swapped for another screen. Without morphing that swap
25
+ // hard-cuts: the panel jumps from the outgoing screen's size to the incoming
26
+ // one's in a single frame. Morphing animates the panel between the two sizes
27
+ // while the incoming content fades in, so navigating within a surface reads as
28
+ // ONE container reshaping.
29
+ //
30
+ // The Dialog cannot see a nav-within swap from its props: the content that
31
+ // changes lives deep inside `children` (a host that subscribes to its own
32
+ // route store), so the Dialog re-renders nothing. The content therefore
33
+ // DECLARES its identity through {@link useDialogFrame} — the same
34
+ // external-channel bridge `useDialogHeader` uses for the nav bar — and the
35
+ // surface runs the morph when that identity changes.
36
+ //
37
+ // Geometry, per placement:
38
+ // - centered card — height grows/shrinks about its centre (the backdrop
39
+ // centres it), and `maxWidth` morphs too when the surface changes it.
40
+ // - bottom sheet — height grows/shrinks from its anchored bottom edge.
41
+ // - side sheet — fixed geometry (pinned top/bottom, fixed width), so
42
+ // there is nothing to morph; those placements never mount this.
43
+ // ---------------------------------------------------------------------------
44
+
45
+ /** Panel reshape duration (ms) between two content frames. */
46
+ export const DIALOG_MORPH_DURATION = 260;
47
+
48
+ /**
49
+ * Incoming-frame fade-in (ms). Deliberately shorter than the reshape so the new
50
+ * content has landed by the time the panel finishes settling.
51
+ */
52
+ export const DIALOG_MORPH_FADE_DURATION = 150;
53
+
54
+ /**
55
+ * How long the panel stays pinned at the outgoing size waiting for the incoming
56
+ * frame to report its own. A frame whose content measures IDENTICALLY never
57
+ * re-reports (nothing resized), so the pin is released on this timeout — there
58
+ * was no size change to morph.
59
+ */
60
+ const MORPH_MEASURE_TIMEOUT_MS = 200;
61
+
62
+ /** Reshape easing — matches the ease-out the rest of the Dialog motion uses. */
63
+ const MORPH_EASING = Easing.out(Easing.cubic);
64
+
65
+ /**
66
+ * Largest plausible gap between the panel box and its content (borders, the
67
+ * scroll container's padding). A bigger difference means the sample is not
68
+ * chrome — the panel is capped, or the content wrapper is being stretched — so
69
+ * it is dropped rather than baked into the next reshape target.
70
+ */
71
+ const MAX_PANEL_CHROME = 96;
72
+
73
+ /**
74
+ * Below this, an incoming frame is treated as having NO natural height rather
75
+ * than a tiny one: content that fills the space it is given (a list that flexes
76
+ * to its container) measures ~0 inside a content-sized wrapper, and reshaping
77
+ * the panel down to that would collapse the surface. Such a frame keeps the
78
+ * panel's normal sizing instead.
79
+ */
80
+ const MIN_MEASURABLE_CONTENT = 40;
81
+
82
+ /**
83
+ * The identity of the content currently rendered inside a Dialog surface,
84
+ * declared by that content via {@link useDialogFrame}.
85
+ */
86
+ export interface DialogFrame {
87
+ /**
88
+ * Stable identity of the current content. A CHANGE while the surface stays
89
+ * open is what marks an in-place swap and triggers the morph.
90
+ */
91
+ key: string;
92
+ /**
93
+ * Opt this frame OUT of the size morph — the panel keeps its normal sizing
94
+ * and the swap hard-cuts (the incoming content still fades in). Defaults to
95
+ * `true`. Use it for content that must not be resized into, e.g. a full-bleed
96
+ * picker that always fills the surface.
97
+ */
98
+ morph?: boolean;
99
+ /**
100
+ * An EXPLICIT target size the panel morphs TO for this frame, instead of the
101
+ * measured natural height / the surface `maxWidth`. This is what lets an
102
+ * own-scroller frame (whose content owns its own scroll, so the panel cannot
103
+ * measure it) still GROW its container to a large declared size — the panel
104
+ * animates up to `height`/`maxWidth` on entry and back down when the next frame
105
+ * declares (or measures) a smaller size, while the frame's own list scrolls
106
+ * within. Both fields are px; either may be omitted (the omitted axis keeps its
107
+ * normal behaviour). `height` is clamped to the viewport; `maxWidth` is honoured
108
+ * only on width-varying placements (the centered card).
109
+ */
110
+ size?: { height?: number; maxWidth?: number };
111
+ }
112
+
113
+ type FrameHandler = (next: DialogFrame, previous: DialogFrame) => void;
114
+
115
+ /**
116
+ * The bridge between a surface's content (which knows WHEN it swapped) and the
117
+ * surface (which owns the panel). Deliberately not a React store: the surface
118
+ * does not need to re-render on a frame change — it drives shared values.
119
+ */
120
+ export interface DialogFrameChannel {
121
+ /** Declare the frame currently rendered. Called from the content's layout phase. */
122
+ setFrame: (frame: DialogFrame) => void;
123
+ /** Register the surface's morph runner (fires on a KEY CHANGE — the animation). */
124
+ setHandler: (handler: FrameHandler) => () => void;
125
+ /**
126
+ * Register a listener that fires on EVERY `setFrame` (every render/layout, not
127
+ * only a key change). The surface uses it to keep the active frame's declared
128
+ * target size (its `size`) as DERIVED state, so the panel re-settles at that
129
+ * size after a placement swap re-lays it out (a swap does not change the key,
130
+ * so the key-change handler never re-runs). Fires immediately with the current
131
+ * frame on register so a freshly-mounted surface (a placement swap remounts it)
132
+ * picks up the active frame's size without waiting for the next render.
133
+ */
134
+ setFrameListener: (listener: (frame: DialogFrame) => void) => () => void;
135
+ }
136
+
137
+ function createFrameChannel(): DialogFrameChannel {
138
+ let last: DialogFrame | null = null;
139
+ let handler: FrameHandler | null = null;
140
+ let frameListener: ((frame: DialogFrame) => void) | null = null;
141
+ return {
142
+ setFrame: (frame) => {
143
+ const previous = last;
144
+ last = frame;
145
+ frameListener?.(frame);
146
+ if (previous && previous.key !== frame.key) handler?.(frame, previous);
147
+ },
148
+ setHandler: (next) => {
149
+ handler = next;
150
+ return () => {
151
+ if (handler === next) handler = null;
152
+ };
153
+ },
154
+ setFrameListener: (next) => {
155
+ frameListener = next;
156
+ if (last) next(last);
157
+ return () => {
158
+ if (frameListener === next) frameListener = null;
159
+ };
160
+ },
161
+ };
162
+ }
163
+
164
+ const DialogFrameContext = createContext<DialogFrameChannel | null>(null);
165
+
166
+ /**
167
+ * Declare the identity of the content currently rendered inside a Dialog
168
+ * surface. When the `key` changes while the surface stays open, the surface
169
+ * MORPHS its panel from the outgoing content's size to the incoming one's
170
+ * instead of hard-cutting.
171
+ *
172
+ * Called by the host that owns the surface's content (the SDK's `SurfaceScreen`
173
+ * declares `route#step`). No-op outside a morph-capable Dialog surface — a side
174
+ * sheet, or a plain confirm dialog — so a host can call it unconditionally.
175
+ *
176
+ * EXACTLY ONE host per surface may declare frames: two writers with different
177
+ * identities would each read the other's key as a swap and morph on every
178
+ * render. A screen that wants its own sub-views to morph must fold them into
179
+ * the surface host's key rather than declaring a second frame.
180
+ */
181
+ export function useDialogFrame(frame: DialogFrame | null | undefined): void {
182
+ const channel = useContext(DialogFrameContext);
183
+ const key = frame?.key;
184
+ const morph = frame?.morph;
185
+ const sizeHeight = frame?.size?.height;
186
+ const sizeMaxWidth = frame?.size?.maxWidth;
187
+ // Declared in the commit's layout phase, BEFORE the browser paints the new
188
+ // content: that is what lets the surface pin the panel at the outgoing size
189
+ // in the same frame the swap commits (the panel's own layout for the incoming
190
+ // content is not reported until after this). Depend on the size PRIMITIVES (not
191
+ // the object) so a fresh `size` object each render does not re-fire the effect.
192
+ useLayoutEffect(() => {
193
+ if (!channel || key === undefined) return;
194
+ const size =
195
+ sizeHeight !== undefined || sizeMaxWidth !== undefined
196
+ ? { height: sizeHeight, maxWidth: sizeMaxWidth }
197
+ : undefined;
198
+ channel.setFrame({ key, morph, size });
199
+ }, [channel, key, morph, sizeHeight, sizeMaxWidth]);
200
+ }
201
+
202
+ /** Options a surface passes when it wires up morphing for its panel. */
203
+ export interface DialogMorphOptions {
204
+ /** Surface-level enable — the `Dialog` `morph` prop (defaults to `true`). */
205
+ enabled: boolean;
206
+ /**
207
+ * Whether this surface can measure the NATURAL height of its content, i.e.
208
+ * whether the Dialog owns the scroll container (`scrollable !== false`). A
209
+ * surface whose content owns its own scroller also owns its own size, so
210
+ * there is nothing for the panel to measure — those surfaces cross-fade the
211
+ * incoming frame but never pin/resize the panel.
212
+ */
213
+ measurable: boolean;
214
+ /**
215
+ * Hard upper bound (px) for the reshape target — pass the viewport height.
216
+ *
217
+ * This is deliberately NOT the panel's exact size cap: that cap is a
218
+ * percentage/inset mix which resolves differently per platform, and guessing
219
+ * it low leaves a visible snap when the pin is released. The panel's own
220
+ * `maxHeight` style already clamps the RENDERED height at every frame, so a
221
+ * target above the cap simply lands on the cap and releases onto it — the
222
+ * bound here only keeps the animation from chasing a runaway content height.
223
+ */
224
+ maxHeight: number;
225
+ /**
226
+ * The panel's `maxWidth` (px) when the placement lets width vary (the centered
227
+ * card). A surface that changes it per frame gets a width morph alongside the
228
+ * height one. `undefined` on placements whose width is fixed.
229
+ */
230
+ maxWidth?: number;
231
+ }
232
+
233
+ /**
234
+ * Wire size morphing into one Dialog surface. Returns the panel/content styles
235
+ * and the two measurement callbacks the surface must attach.
236
+ *
237
+ * While idle the panel style resolves to `height: 'auto'` — byte-for-byte the
238
+ * natural sizing every placement had before morphing existed — so first open,
239
+ * dismissal, and a responsive placement swap are untouched. The height is only
240
+ * driven for the ~260ms of an actual reshape, then released back to natural.
241
+ *
242
+ * A frame that declares an explicit `size` is different: that size is DERIVED
243
+ * state the panel rests at at EVERY layout (`declaredHeight`/`declaredMaxWidth`),
244
+ * not a one-shot animation target — so a placement swap (which re-lays the panel
245
+ * out WITHOUT a key change, so the animation never re-runs) still settles at the
246
+ * declared size instead of falling back to natural.
247
+ */
248
+ export function useDialogMorph({
249
+ enabled,
250
+ measurable,
251
+ maxHeight,
252
+ maxWidth,
253
+ }: DialogMorphOptions) {
254
+ const channel = useMemo(() => createFrameChannel(), []);
255
+
256
+ /** 1 while the panel height is driven by an in-flight morph animation. */
257
+ const pinned = useSharedValue(0);
258
+ const height = useSharedValue(0);
259
+ /** 1 while `maxWidth` is driven by an in-flight morph animation. */
260
+ const widthPinned = useSharedValue(0);
261
+ const width = useSharedValue(0);
262
+ /**
263
+ * The ACTIVE frame's declared explicit size (0 = none) — the panel's RESTING
264
+ * size, re-derived on every `setFrame` so it survives a placement swap. `height`
265
+ * is clamped to the viewport; `maxWidth` widens the centered card.
266
+ */
267
+ const declaredHeight = useSharedValue(0);
268
+ const declaredMaxWidth = useSharedValue(0);
269
+ /** Incoming-frame opacity. Rests at 1; dips to 0 and fades back on a swap. */
270
+ const fade = useSharedValue(1);
271
+
272
+ /** Last laid-out panel height — the size a morph starts FROM. */
273
+ const panelHeight = useRef(0);
274
+ /** Last laid-out height of the content wrapper — the content's NATURAL height. */
275
+ const contentHeight = useRef(0);
276
+ /**
277
+ * Panel box height MINUS its scroll content height, sampled while idle and
278
+ * unclamped: the panel's own chrome (borders, scroll-container padding).
279
+ * Self-calibrating, so no placement has to hard-code its own box math.
280
+ */
281
+ const chrome = useRef(0);
282
+ /** True between a frame change and the moment the incoming size arrives. */
283
+ const awaitingSize = useRef(false);
284
+ const releaseTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
285
+ /** Mirrors of render-time values the (stable) callbacks below need to read. */
286
+ const maxHeightRef = useRef(maxHeight);
287
+ maxHeightRef.current = maxHeight;
288
+ const measurableRef = useRef(measurable);
289
+ measurableRef.current = measurable;
290
+ const enabledRef = useRef(enabled);
291
+ enabledRef.current = enabled;
292
+ /**
293
+ * The EFFECTIVE `maxWidth` of the CURRENT frame — its explicit `size.maxWidth`
294
+ * if it declared one, else the surface `maxWidth`. Held so the next frame change
295
+ * knows the width to morph FROM. The handler owns it (not a passive effect) so a
296
+ * per-frame width is not clobbered mid-flow.
297
+ */
298
+ const activeMaxWidth = useRef(maxWidth);
299
+
300
+ // Keep the active frame's DECLARED size current on every render/layout — this
301
+ // is the panel's resting size, so it survives a placement swap (which remounts
302
+ // this surface and fires the listener immediately with the current frame). A
303
+ // frame with no explicit size resets these to 0 (natural sizing). A LAYOUT
304
+ // effect so the seed lands BEFORE paint on a placement-swap remount (the child
305
+ // `SurfaceScreen`'s `setFrame` layout-effect runs first, setting the channel's
306
+ // last frame; registering here then fires immediately with it — no small-size
307
+ // flash). NOTE: must NOT touch `activeMaxWidth` — the listener runs BEFORE the
308
+ // key-change handler in the same `setFrame`, and the handler needs the PREVIOUS
309
+ // frame's width as the animation's FROM.
310
+ useLayoutEffect(() => {
311
+ return channel.setFrameListener((frame) => {
312
+ declaredHeight.value = frame.size?.height ?? 0;
313
+ declaredMaxWidth.value = frame.size?.maxWidth ?? 0;
314
+ });
315
+ }, [channel, declaredHeight, declaredMaxWidth]);
316
+
317
+ const clearReleaseTimer = useCallback(() => {
318
+ if (releaseTimer.current === null) return;
319
+ clearTimeout(releaseTimer.current);
320
+ releaseTimer.current = null;
321
+ }, []);
322
+
323
+ /** Hand the panel back to natural sizing. */
324
+ const release = useCallback(() => {
325
+ awaitingSize.current = false;
326
+ clearReleaseTimer();
327
+ cancelAnimation(height);
328
+ pinned.value = 0;
329
+ }, [clearReleaseTimer, height, pinned]);
330
+
331
+ /** Start the reshape once the incoming frame's natural height is known. */
332
+ const settle = useCallback(
333
+ (naturalContentHeight: number) => {
334
+ if (!awaitingSize.current) return;
335
+ awaitingSize.current = false;
336
+ clearReleaseTimer();
337
+ if (naturalContentHeight < MIN_MEASURABLE_CONTENT) {
338
+ release();
339
+ return;
340
+ }
341
+ const from = height.value;
342
+ const to = Math.max(
343
+ 0,
344
+ Math.min(naturalContentHeight + chrome.current, maxHeightRef.current),
345
+ );
346
+ if (Math.abs(to - from) < 1) {
347
+ release();
348
+ return;
349
+ }
350
+ height.value = withTiming(
351
+ to,
352
+ { duration: DIALOG_MORPH_DURATION, easing: MORPH_EASING },
353
+ (finished) => {
354
+ 'worklet';
355
+ // Release only on a clean finish — a cancel means a newer morph has
356
+ // already taken the pin over.
357
+ if (finished) pinned.value = 0;
358
+ },
359
+ );
360
+ },
361
+ [clearReleaseTimer, height, pinned, release],
362
+ );
363
+
364
+ // The morph runner. Registered on the channel the content writes into.
365
+ useEffect(() => {
366
+ return channel.setHandler((next) => {
367
+ if (!enabledRef.current) return;
368
+
369
+ // The incoming frame always fades in, morph or not — a hard content cut
370
+ // inside a surface that stays put is the jarring part.
371
+ cancelAnimation(fade);
372
+ fade.value = 0;
373
+ fade.value = withTiming(1, {
374
+ duration: DIALOG_MORPH_FADE_DURATION,
375
+ easing: MORPH_EASING,
376
+ });
377
+
378
+ if (next.morph === false) {
379
+ release();
380
+ return;
381
+ }
382
+
383
+ // Pin at the size the panel is CURRENTLY painted at. `panelHeight` is
384
+ // still the outgoing frame's: this runs in the swap's layout phase, before
385
+ // the incoming content's layout is reported.
386
+ const from = panelHeight.current;
387
+
388
+ // Width, where the placement allows it: ANIMATE the panel's maxWidth from
389
+ // the outgoing frame's effective width to the incoming one's (its explicit
390
+ // `size.maxWidth`, else the surface `maxWidth`). The resting width is the
391
+ // DERIVED `declaredMaxWidth`, so we release `widthPinned` on finish — nothing
392
+ // snaps, and a placement swap re-settles at the declared width on its own.
393
+ const fromWidth = activeMaxWidth.current;
394
+ const toWidth = next.size?.maxWidth ?? maxWidth;
395
+ activeMaxWidth.current = toWidth;
396
+ if (fromWidth !== undefined && toWidth !== undefined && fromWidth !== toWidth) {
397
+ cancelAnimation(width);
398
+ width.value = fromWidth;
399
+ widthPinned.value = 1;
400
+ width.value = withTiming(
401
+ toWidth,
402
+ { duration: DIALOG_MORPH_DURATION, easing: MORPH_EASING },
403
+ (finished) => {
404
+ 'worklet';
405
+ if (finished) widthPinned.value = 0;
406
+ },
407
+ );
408
+ }
409
+
410
+ // Height. An EXPLICIT `size.height` animates the panel DIRECTLY to it (grows
411
+ // or shrinks even a non-measurable / own-scroller frame to a big declared
412
+ // size); otherwise the panel can only reshape when the surface owns the
413
+ // scroll container (`measurable`) and we measure the incoming content. The
414
+ // resting height is the DERIVED `declaredHeight`, so we release the pin on
415
+ // finish — no snap to 'auto', and a placement swap re-settles there itself.
416
+ if (next.size?.height !== undefined) {
417
+ if (from <= 0) return;
418
+ cancelAnimation(height);
419
+ height.value = from;
420
+ pinned.value = 1;
421
+ const to = Math.max(0, Math.min(next.size.height, maxHeightRef.current));
422
+ height.value = withTiming(
423
+ to,
424
+ { duration: DIALOG_MORPH_DURATION, easing: MORPH_EASING },
425
+ (finished) => {
426
+ 'worklet';
427
+ if (finished) pinned.value = 0;
428
+ },
429
+ );
430
+ return;
431
+ }
432
+
433
+ if (!measurableRef.current) {
434
+ release();
435
+ return;
436
+ }
437
+ if (from <= 0) return;
438
+ cancelAnimation(height);
439
+ height.value = from;
440
+ pinned.value = 1;
441
+ awaitingSize.current = true;
442
+ clearReleaseTimer();
443
+ releaseTimer.current = setTimeout(release, MORPH_MEASURE_TIMEOUT_MS);
444
+ });
445
+ }, [
446
+ channel,
447
+ clearReleaseTimer,
448
+ fade,
449
+ height,
450
+ maxWidth,
451
+ pinned,
452
+ release,
453
+ width,
454
+ widthPinned,
455
+ ]);
456
+
457
+ useEffect(
458
+ () => () => {
459
+ clearReleaseTimer();
460
+ cancelAnimation(height);
461
+ cancelAnimation(width);
462
+ cancelAnimation(fade);
463
+ },
464
+ [clearReleaseTimer, fade, height, width],
465
+ );
466
+
467
+ /** Re-derive the panel's own chrome height whenever it can be sampled cleanly. */
468
+ const calibrate = useCallback(() => {
469
+ if (!measurableRef.current) return;
470
+ if (pinned.value === 1 || awaitingSize.current) return;
471
+ if (panelHeight.current <= 0 || contentHeight.current <= 0) return;
472
+ const delta = panelHeight.current - contentHeight.current;
473
+ // A panel SHORTER than its content is capped, and one far taller is not
474
+ // reporting chrome at all — neither says anything usable.
475
+ if (delta < 0 || delta > MAX_PANEL_CHROME) return;
476
+ chrome.current = delta;
477
+ }, [pinned]);
478
+
479
+ /** Attach to the panel: tracks the size a morph starts from. */
480
+ const onPanelLayout = useCallback(
481
+ (event: LayoutChangeEvent) => {
482
+ panelHeight.current = event.nativeEvent.layout.height;
483
+ calibrate();
484
+ },
485
+ [calibrate],
486
+ );
487
+
488
+ /**
489
+ * Attached to the content wrapper, which lives INSIDE the surface's scroll
490
+ * container and is sized by its own content — so it reports the natural height
491
+ * of the incoming frame even while the panel is pinned at the outgoing one,
492
+ * which is exactly what a pinned panel cannot measure for itself.
493
+ */
494
+ const onContentLayout = useCallback(
495
+ (event: LayoutChangeEvent) => {
496
+ const natural = event.nativeEvent.layout.height;
497
+ contentHeight.current = natural;
498
+ if (awaitingSize.current) {
499
+ settle(natural);
500
+ return;
501
+ }
502
+ calibrate();
503
+ },
504
+ [calibrate, settle],
505
+ );
506
+
507
+ const panelStyle = useAnimatedStyle<ViewStyle>(() => {
508
+ // Height: the in-flight animation while pinned, else the RESTING size — the
509
+ // active frame's `declaredHeight` when it has one (survives a placement swap),
510
+ // else natural (`'auto'`).
511
+ const resolvedHeight: ViewStyle['height'] =
512
+ pinned.value === 1
513
+ ? height.value
514
+ : declaredHeight.value > 0
515
+ ? declaredHeight.value
516
+ : 'auto';
517
+ // Width only varies on a placement that HAS a `maxWidth` (the centered card).
518
+ // A full-bleed placement (bottom sheet, `maxWidth === undefined`) is never
519
+ // width-capped — only height matters there. On the centered card the width is
520
+ // the in-flight animation while pinned, else the frame's declared width (which
521
+ // survives a placement swap), else the surface `maxWidth`.
522
+ if (maxWidth === undefined) return { height: resolvedHeight };
523
+ const resolvedMaxWidth =
524
+ widthPinned.value === 1
525
+ ? width.value
526
+ : declaredMaxWidth.value > 0
527
+ ? declaredMaxWidth.value
528
+ : maxWidth;
529
+ return { height: resolvedHeight, maxWidth: resolvedMaxWidth };
530
+ // RN-Web has no worklets babel plugin: every shared value the mapper READS
531
+ // must be listed here or it runs once and freezes at the first frame.
532
+ }, [pinned, height, declaredHeight, widthPinned, width, declaredMaxWidth, maxWidth]);
533
+
534
+ const contentStyle = useAnimatedStyle<ViewStyle>(
535
+ () => ({ opacity: fade.value }),
536
+ [fade],
537
+ );
538
+
539
+ return {
540
+ channel,
541
+ onPanelLayout,
542
+ onContentLayout,
543
+ panelStyle,
544
+ contentStyle,
545
+ };
546
+ }
547
+
548
+ export type DialogMorph = ReturnType<typeof useDialogMorph>;
549
+
550
+ /**
551
+ * Wraps a surface's content: publishes the frame channel to it (so it can
552
+ * declare swaps via {@link useDialogFrame}), measures the content's natural
553
+ * height, and carries the incoming-frame fade.
554
+ *
555
+ * MUST be rendered inside the surface's scroll content and MUST NOT be given a
556
+ * growing/stretching style: it is sized by its own content, which is what lets
557
+ * it report the incoming frame's natural height while the panel is still pinned
558
+ * at the outgoing one.
559
+ */
560
+ export function DialogMorphContent({
561
+ morph,
562
+ style,
563
+ children,
564
+ }: {
565
+ morph: DialogMorph;
566
+ style?: StyleProp<ViewStyle>;
567
+ children: React.ReactNode;
568
+ }): React.ReactElement {
569
+ return (
570
+ <DialogFrameContext.Provider value={morph.channel}>
571
+ <Animated.View onLayout={morph.onContentLayout} style={[style, morph.contentStyle]}>
572
+ {children}
573
+ </Animated.View>
574
+ </DialogFrameContext.Provider>
575
+ );
576
+ }
@@ -3,10 +3,12 @@ export { BloomDialogProvider } from './BloomDialogProvider';
3
3
  export { alert } from './alert';
4
4
  export { useDialogContext, useDialogControl } from './context';
5
5
  export { useDialogHeader } from './DialogHeader';
6
+ export { useDialogFrame } from './DialogMorph';
6
7
  export type {
7
8
  AlertButton,
8
9
  AlertButtonStyle,
9
10
  } from './alert-store';
11
+ export type { DialogFrame } from './DialogMorph';
10
12
  export type {
11
13
  DialogAction,
12
14
  DialogActionColor,
@@ -13,10 +13,12 @@ export { BloomDialogProvider } from './BloomDialogProvider.web';
13
13
  export { alert } from './alert';
14
14
  export { useDialogContext, useDialogControl } from './context';
15
15
  export { useDialogHeader } from './DialogHeader';
16
+ export { useDialogFrame } from './DialogMorph';
16
17
  export type {
17
18
  AlertButton,
18
19
  AlertButtonStyle,
19
20
  } from './alert-store';
21
+ export type { DialogFrame } from './DialogMorph';
20
22
  export type {
21
23
  DialogAction,
22
24
  DialogActionColor,
@@ -20,6 +20,15 @@ export type { DialogPlacement, ResponsiveDialogPlacement } from './placement';
20
20
  export interface DialogHeaderConfig {
21
21
  /** Collapsing title — rendered large in-content and small in the nav bar. */
22
22
  title?: string;
23
+ /**
24
+ * A branded node rendered centered in the nav bar INSTEAD of the collapsing
25
+ * text title — e.g. a product wordmark. Always visible (it never collapses),
26
+ * and it suppresses the large in-content title/subtitle entirely, so the
27
+ * surface starts flush under the bar. `title`/`subtitle` are ignored while
28
+ * this is set. Must be referentially stable (memoize it) so the header does
29
+ * not thrash.
30
+ */
31
+ titleContent?: ReactNode;
23
32
  /** Supporting copy under the large title (and, optionally, in the bar). */
24
33
  subtitle?: string;
25
34
  /**
@@ -224,6 +233,27 @@ export type DialogProps = React.PropsWithChildren<{
224
233
  contentPadding?: number;
225
234
  /** Whether tapping the backdrop dismisses the dialog. Defaults to `true`. */
226
235
  dismissOnBackdrop?: boolean;
236
+ /**
237
+ * Whether the panel MORPHS across an in-place content swap. Defaults to
238
+ * `true`.
239
+ *
240
+ * When the content inside an OPEN dialog is replaced (a nav-within drill-in,
241
+ * as opposed to presenting a new surface on top), the panel animates from the
242
+ * outgoing content's size to the incoming one's while the incoming content
243
+ * fades in, instead of hard-cutting. The swap is signalled by the content
244
+ * itself through `useDialogFrame` — a dialog whose content never declares a
245
+ * frame is unaffected either way.
246
+ *
247
+ * The centered card morphs its height (about its centre) and its `maxWidth`;
248
+ * the bottom sheet morphs its height from its anchored edge; side sheets have
249
+ * fixed geometry and never morph. Morphing also requires the Dialog to own the
250
+ * scroll container: a `scrollable={false}` surface owns its own size, so its
251
+ * content only cross-fades.
252
+ *
253
+ * Set `false` for content that must never be resized into — e.g. a full-bleed
254
+ * picker that always fills the surface.
255
+ */
256
+ morph?: boolean;
227
257
  /**
228
258
  * Style overrides applied to the inner content container on native (the
229
259
  * floating bottom-sheet card) and to the modal panel on web.
@@ -207,7 +207,7 @@ const styles = StyleSheet.create({
207
207
  itemContainer: {
208
208
  flexDirection: 'row',
209
209
  alignItems: 'center',
210
- paddingHorizontal: 16,
210
+ paddingHorizontal: 12,
211
211
  paddingVertical: 10,
212
212
  minHeight: 44,
213
213
  gap: 12,
@@ -40,6 +40,7 @@ function placementFor(p: SurfacePresentation): Partial<DialogProps> {
40
40
  contentPadding: p.contentPadding,
41
41
  header: p.header,
42
42
  scrollable: p.scrollable,
43
+ morph: p.morph,
43
44
  style: p.style,
44
45
  panelStyle: p.panelStyle,
45
46
  panelClassName: p.panelClassName,
@@ -55,6 +55,15 @@ export interface SurfacePresentation {
55
55
  * to `Dialog`.
56
56
  */
57
57
  scrollable?: boolean;
58
+ /**
59
+ * Whether the surface MORPHS its panel when its content is swapped in place —
60
+ * navigation WITHIN this surface, as opposed to presenting a new surface on
61
+ * top. Defaults to `true`. The content signals the swap through
62
+ * `useDialogFrame`; a surface whose content never declares a frame is
63
+ * unaffected. Set `false` for content that must never be resized into (a
64
+ * full-bleed picker). Forwarded to `Dialog`.
65
+ */
66
+ morph?: boolean;
58
67
  /** Style overrides applied to the inner content container. */
59
68
  style?: StyleProp<ViewStyle>;
60
69
  /** Style overrides for the side/bottom placement panel surface. */