@oxyhq/bloom 0.46.0 → 0.48.0

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 (150) hide show
  1. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +34 -12
  2. package/lib/commonjs/bottom-sheet/BottomSheetBase.js.map +1 -1
  3. package/lib/commonjs/combobox/Combobox.js +3 -3
  4. package/lib/commonjs/combobox/Combobox.js.map +1 -1
  5. package/lib/commonjs/command/Command.js +3 -3
  6. package/lib/commonjs/command/Command.js.map +1 -1
  7. package/lib/commonjs/dialog/Dialog.js +74 -5
  8. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  9. package/lib/commonjs/dialog/Dialog.web.js +153 -10
  10. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  11. package/lib/commonjs/dialog/DialogBottomSheet.js +84 -29
  12. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  13. package/lib/commonjs/dialog/DialogHeader.js +365 -0
  14. package/lib/commonjs/dialog/DialogHeader.js.map +1 -0
  15. package/lib/commonjs/dialog/index.js +7 -0
  16. package/lib/commonjs/dialog/index.js.map +1 -1
  17. package/lib/commonjs/dialog/index.web.js +7 -0
  18. package/lib/commonjs/dialog/index.web.js.map +1 -1
  19. package/lib/commonjs/index.js +11 -4
  20. package/lib/commonjs/index.js.map +1 -1
  21. package/lib/commonjs/index.web.js +11 -4
  22. package/lib/commonjs/index.web.js.map +1 -1
  23. package/lib/commonjs/react-native-className.d.js +6 -0
  24. package/lib/commonjs/react-native-className.d.js.map +1 -0
  25. package/lib/commonjs/{search-input → search}/index.js +5 -3
  26. package/lib/commonjs/search/index.js.map +1 -0
  27. package/lib/commonjs/settings-list/SettingsList.js +5 -1
  28. package/lib/commonjs/settings-list/SettingsList.js.map +1 -1
  29. package/lib/commonjs/surfaces/SurfaceHost.js +22 -9
  30. package/lib/commonjs/surfaces/SurfaceHost.js.map +1 -1
  31. package/lib/commonjs/text-field/index.js +22 -6
  32. package/lib/commonjs/text-field/index.js.map +1 -1
  33. package/lib/module/bottom-sheet/BottomSheetBase.js +34 -12
  34. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  35. package/lib/module/combobox/Combobox.js +3 -3
  36. package/lib/module/combobox/Combobox.js.map +1 -1
  37. package/lib/module/command/Command.js +3 -3
  38. package/lib/module/command/Command.js.map +1 -1
  39. package/lib/module/dialog/Dialog.js +75 -6
  40. package/lib/module/dialog/Dialog.js.map +1 -1
  41. package/lib/module/dialog/Dialog.web.js +156 -13
  42. package/lib/module/dialog/Dialog.web.js.map +1 -1
  43. package/lib/module/dialog/DialogBottomSheet.js +85 -30
  44. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  45. package/lib/module/dialog/DialogHeader.js +356 -0
  46. package/lib/module/dialog/DialogHeader.js.map +1 -0
  47. package/lib/module/dialog/index.js +1 -0
  48. package/lib/module/dialog/index.js.map +1 -1
  49. package/lib/module/dialog/index.web.js +1 -0
  50. package/lib/module/dialog/index.web.js.map +1 -1
  51. package/lib/module/index.js +2 -2
  52. package/lib/module/index.js.map +1 -1
  53. package/lib/module/index.web.js +2 -2
  54. package/lib/module/index.web.js.map +1 -1
  55. package/lib/module/react-native-className.d.js +20 -0
  56. package/lib/module/react-native-className.d.js.map +1 -0
  57. package/lib/module/{search-input → search}/index.js +6 -2
  58. package/lib/module/search/index.js.map +1 -0
  59. package/lib/module/settings-list/SettingsList.js +5 -1
  60. package/lib/module/settings-list/SettingsList.js.map +1 -1
  61. package/lib/module/surfaces/SurfaceHost.js +22 -9
  62. package/lib/module/surfaces/SurfaceHost.js.map +1 -1
  63. package/lib/module/text-field/index.js +22 -6
  64. package/lib/module/text-field/index.js.map +1 -1
  65. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts +23 -0
  66. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/combobox/Combobox.d.ts +1 -1
  68. package/lib/typescript/commonjs/command/Command.d.ts +1 -1
  69. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +2 -2
  72. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts +86 -0
  74. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts.map +1 -0
  75. package/lib/typescript/commonjs/dialog/index.d.ts +2 -1
  76. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/dialog/index.web.d.ts +2 -1
  78. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/dialog/types.d.ts +75 -0
  80. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  81. package/lib/typescript/commonjs/icons/common.d.ts +1 -0
  82. package/lib/typescript/commonjs/icons/common.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/index.d.ts +3 -3
  84. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/index.web.d.ts +3 -3
  86. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/input-group/types.d.ts +1 -1
  88. package/lib/typescript/commonjs/{search-input → search}/index.d.ts +1 -1
  89. package/lib/typescript/commonjs/search/index.d.ts.map +1 -0
  90. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/surfaces/types.d.ts +17 -1
  92. package/lib/typescript/commonjs/surfaces/types.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/text-field/index.d.ts +2 -1
  94. package/lib/typescript/commonjs/text-field/index.d.ts.map +1 -1
  95. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts +23 -0
  96. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  97. package/lib/typescript/module/combobox/Combobox.d.ts +1 -1
  98. package/lib/typescript/module/command/Command.d.ts +1 -1
  99. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  100. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  101. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +2 -2
  102. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  103. package/lib/typescript/module/dialog/DialogHeader.d.ts +86 -0
  104. package/lib/typescript/module/dialog/DialogHeader.d.ts.map +1 -0
  105. package/lib/typescript/module/dialog/index.d.ts +2 -1
  106. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  107. package/lib/typescript/module/dialog/index.web.d.ts +2 -1
  108. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  109. package/lib/typescript/module/dialog/types.d.ts +75 -0
  110. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  111. package/lib/typescript/module/icons/common.d.ts +1 -0
  112. package/lib/typescript/module/icons/common.d.ts.map +1 -1
  113. package/lib/typescript/module/index.d.ts +3 -3
  114. package/lib/typescript/module/index.d.ts.map +1 -1
  115. package/lib/typescript/module/index.web.d.ts +3 -3
  116. package/lib/typescript/module/index.web.d.ts.map +1 -1
  117. package/lib/typescript/module/input-group/types.d.ts +1 -1
  118. package/lib/typescript/module/{search-input → search}/index.d.ts +1 -1
  119. package/lib/typescript/module/search/index.d.ts.map +1 -0
  120. package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -1
  121. package/lib/typescript/module/surfaces/types.d.ts +17 -1
  122. package/lib/typescript/module/surfaces/types.d.ts.map +1 -1
  123. package/lib/typescript/module/text-field/index.d.ts +2 -1
  124. package/lib/typescript/module/text-field/index.d.ts.map +1 -1
  125. package/package.json +8 -8
  126. package/src/__tests__/BottomSheet.test.tsx +47 -0
  127. package/src/__tests__/DialogBottomSheet.test.tsx +30 -5
  128. package/src/bottom-sheet/BottomSheetBase.tsx +59 -10
  129. package/src/combobox/Combobox.tsx +3 -3
  130. package/src/command/Command.tsx +3 -3
  131. package/src/dialog/Dialog.tsx +74 -4
  132. package/src/dialog/Dialog.web.tsx +154 -23
  133. package/src/dialog/DialogBottomSheet.tsx +90 -28
  134. package/src/dialog/DialogHeader.tsx +420 -0
  135. package/src/dialog/index.ts +2 -0
  136. package/src/dialog/index.web.ts +2 -0
  137. package/src/dialog/types.ts +76 -0
  138. package/src/index.ts +3 -1
  139. package/src/index.web.ts +3 -1
  140. package/src/input-group/types.ts +1 -1
  141. package/src/react-native-className.d.ts +38 -0
  142. package/src/{search-input → search}/index.tsx +8 -5
  143. package/src/settings-list/SettingsList.tsx +5 -1
  144. package/src/surfaces/SurfaceHost.tsx +16 -1
  145. package/src/surfaces/types.ts +17 -1
  146. package/src/text-field/index.tsx +24 -5
  147. package/lib/commonjs/search-input/index.js.map +0 -1
  148. package/lib/module/search-input/index.js.map +0 -1
  149. package/lib/typescript/commonjs/search-input/index.d.ts.map +0 -1
  150. package/lib/typescript/module/search-input/index.d.ts.map +0 -1
@@ -30,6 +30,14 @@ import { useTheme } from '../theme/use-theme';
30
30
  import { Context, useDialogControl } from './context';
31
31
  import { DialogBody } from './DialogContent';
32
32
  import { DialogBottomSheet } from './DialogBottomSheet';
33
+ import {
34
+ DIALOG_NAV_BAR_HEIGHT,
35
+ type DialogHeaderController,
36
+ DialogHeaderProvider,
37
+ DialogLargeTitle,
38
+ DialogNavHeader,
39
+ useDialogHeaderController,
40
+ } from './DialogHeader';
33
41
  import {
34
42
  ANIMATION_DURATION,
35
43
  DEFAULT_DIALOG_CONTENT_PADDING,
@@ -103,6 +111,8 @@ function CenteredOrSideDialog({
103
111
  title,
104
112
  description,
105
113
  actions,
114
+ header,
115
+ scrollable,
106
116
  placement,
107
117
  layer,
108
118
  width = DEFAULT_SIDE_WIDTH,
@@ -119,6 +129,8 @@ function CenteredOrSideDialog({
119
129
  }: Omit<DialogProps, 'placement'> & { placement: 'center' | 'left' | 'right' }) {
120
130
  const isControlled = controlledOpen !== undefined;
121
131
  const isSide = placement === 'left' || placement === 'right';
132
+ const headerController = useDialogHeaderController();
133
+ const scrollableResolved = scrollable ?? true;
122
134
 
123
135
  // Imperative open state for the side placement (the BottomSheet path owns its
124
136
  // own visibility via its ref instead).
@@ -235,6 +247,24 @@ function CenteredOrSideDialog({
235
247
  </DialogBody>
236
248
  );
237
249
 
250
+ // Nav-header mode: the Dialog OWNS a sticky gradient nav bar + a large
251
+ // collapsing title over its own scroll content. The large title + screen body
252
+ // scroll under the bar, feeding the shared collapse controller. `contentPadding`
253
+ // is dropped (the large title + screens own their padding).
254
+ const headerBody = header ? (
255
+ scrollableResolved ? (
256
+ <>
257
+ <DialogLargeTitle controller={headerController} header={header} />
258
+ <DialogHeaderProvider controller={headerController}>{bodyNode}</DialogHeaderProvider>
259
+ </>
260
+ ) : (
261
+ <>
262
+ <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />
263
+ <DialogHeaderProvider controller={headerController}>{bodyNode}</DialogHeaderProvider>
264
+ </>
265
+ )
266
+ ) : null;
267
+
238
268
  if (isSide) {
239
269
  return (
240
270
  <Context.Provider value={context}>
@@ -253,6 +283,9 @@ function CenteredOrSideDialog({
253
283
  description={description}
254
284
  titleId={titleId}
255
285
  descriptionId={descriptionId}
286
+ header={header}
287
+ headerController={headerController}
288
+ onHeaderDismiss={close}
256
289
  panelStyle={panelStyle}
257
290
  panelClassName={panelClassName}
258
291
  containerStyle={containerStyle}
@@ -271,7 +304,21 @@ function CenteredOrSideDialog({
271
304
  onDismiss={handleDismiss}
272
305
  enablePanDownToClose
273
306
  detached
274
- showHandle
307
+ // Nav-header mode hides the drag handle (the nav bar sits at the very top)
308
+ // and coordinates the body pan with the scroll so content scrolls.
309
+ showHandle={!header}
310
+ manualActivation={!!header}
311
+ scrollY={header ? headerController.scrollY : undefined}
312
+ headerOverlay={
313
+ header ? (
314
+ <DialogNavHeader
315
+ controller={headerController}
316
+ header={header}
317
+ onDismiss={close}
318
+ collapse={scrollableResolved}
319
+ />
320
+ ) : undefined
321
+ }
275
322
  // Stronger dim when a Dialog is stacked over another sheet so the
276
323
  // underlying sheet's handle/content doesn't bleed through.
277
324
  backdropOpacity={0.7}
@@ -288,13 +335,14 @@ function CenteredOrSideDialog({
288
335
  // Detached BottomSheet already adds `marginBottom: insets.bottom + 16`
289
336
  // to the sheet container — the floating card sits ABOVE the
290
337
  // system gesture bar, so we don't add `insets.bottom` here.
291
- { padding: contentPadding },
338
+ // Nav-header mode: the large title + screens own their padding.
339
+ header ? null : { padding: contentPadding },
292
340
  { backgroundColor: theme.colors.background },
293
341
  style,
294
342
  panelStyle,
295
343
  ]}
296
344
  >
297
- {bodyNode}
345
+ {header ? headerBody : bodyNode}
298
346
  </View>
299
347
  </Context.Provider>
300
348
  </BottomSheet>
@@ -324,6 +372,9 @@ function SideSheet({
324
372
  description,
325
373
  titleId,
326
374
  descriptionId,
375
+ header,
376
+ headerController,
377
+ onHeaderDismiss,
327
378
  panelStyle,
328
379
  panelClassName,
329
380
  containerStyle,
@@ -345,6 +396,9 @@ function SideSheet({
345
396
  description?: string;
346
397
  titleId: string;
347
398
  descriptionId: string;
399
+ header?: DialogProps['header'];
400
+ headerController: DialogHeaderController;
401
+ onHeaderDismiss: () => void;
348
402
  panelStyle?: StyleProp<ViewStyle>;
349
403
  panelClassName?: string;
350
404
  containerStyle?: StyleProp<ViewStyle>;
@@ -476,7 +530,23 @@ function SideSheet({
476
530
  ]}
477
531
  pointerEvents="auto"
478
532
  >
479
- <View style={{ padding: contentPadding }}>{children}</View>
533
+ {header ? (
534
+ // Nav-header mode on a side drawer: a static titled bar (the drawer body
535
+ // has no Dialog scroll offset to drive a collapse) over content inset
536
+ // below the bar.
537
+ <View style={{ flex: 1, minHeight: 0 }}>
538
+ <DialogNavHeader
539
+ controller={headerController}
540
+ header={header}
541
+ onDismiss={onHeaderDismiss}
542
+ collapse={false}
543
+ />
544
+ <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />
545
+ <DialogHeaderProvider controller={headerController}>{children}</DialogHeaderProvider>
546
+ </View>
547
+ ) : (
548
+ <View style={{ padding: contentPadding }}>{children}</View>
549
+ )}
480
550
  </Animated.View>
481
551
  </View>
482
552
  );
@@ -10,6 +10,7 @@ import React, {
10
10
  } from 'react';
11
11
  import {
12
12
  Pressable,
13
+ ScrollView,
13
14
  useWindowDimensions,
14
15
  View,
15
16
  type StyleProp,
@@ -20,9 +21,16 @@ import { RemoveScrollBar } from 'react-remove-scroll-bar';
20
21
  import { Portal } from '../portal/index.web';
21
22
  import { createOverlayZIndex } from '../styles/z-index';
22
23
  import { useTheme } from '../theme/use-theme';
23
- import { Context, useDialogControl } from './context';
24
+ import { Context, useDialogContext, useDialogControl } from './context';
24
25
  import { DialogBody } from './DialogContent';
25
26
  import { DialogBottomSheet } from './DialogBottomSheet';
27
+ import {
28
+ DIALOG_NAV_BAR_HEIGHT,
29
+ DialogHeaderProvider,
30
+ DialogLargeTitle,
31
+ DialogNavHeader,
32
+ useDialogHeaderController,
33
+ } from './DialogHeader';
26
34
  import {
27
35
  ANIMATION_DURATION,
28
36
  CENTER_FADE_OUT_DURATION,
@@ -125,11 +133,13 @@ export function Dialog({ placement, ...rest }: DialogProps) {
125
133
  function CenterOrSideDialog({
126
134
  control,
127
135
  open: controlledOpen,
136
+ startOpen,
128
137
  onClose,
129
138
  testID,
130
139
  title,
131
140
  description,
132
141
  actions,
142
+ header,
133
143
  placement,
134
144
  layer,
135
145
  width = DEFAULT_SIDE_WIDTH,
@@ -137,6 +147,8 @@ function CenterOrSideDialog({
137
147
  inset,
138
148
  dismissOnBackdrop = true,
139
149
  contentPadding = DEFAULT_DIALOG_CONTENT_PADDING,
150
+ maxHeightRatio,
151
+ scrollable,
140
152
  style,
141
153
  panelStyle,
142
154
  panelClassName,
@@ -160,7 +172,13 @@ function CenterOrSideDialog({
160
172
  const isControlled = controlledOpen !== undefined;
161
173
  const resolvedPlacement = placement;
162
174
 
163
- const [isOpen, setIsOpen] = useState(false);
175
+ // Seed the initial visible state so a branch that mounts while the surface is
176
+ // already open (the imperative `startOpen` intent) or under a truthy
177
+ // controlled `open` renders open on its FIRST commit — this is what lets a
178
+ // responsive Dialog survive a placement swap (centered card ↔ bottom sheet on
179
+ // resize) without going blank. `startOpen` never flips the close semantics;
180
+ // the imperative `control.close()` still drives the exit + post-exit onClose.
181
+ const [isOpen, setIsOpen] = useState(() => controlledOpen ?? startOpen ?? false);
164
182
  const [isClosing, setIsClosing] = useState(false);
165
183
  const closeCallbacksRef = useRef<(() => void)[]>([]);
166
184
  // Read the latest controlled flag inside stable callbacks without re-binding.
@@ -299,9 +317,12 @@ function CenterOrSideDialog({
299
317
  title={title}
300
318
  description={description}
301
319
  actions={actions}
320
+ header={header}
302
321
  style={style}
303
322
  maxWidth={maxWidth}
304
323
  contentPadding={contentPadding}
324
+ maxHeightRatio={maxHeightRatio}
325
+ scrollable={scrollable}
305
326
  surfaceZIndex={dialogZIndex.surface}
306
327
  isClosing={isClosing}
307
328
  >
@@ -325,6 +346,8 @@ function CenterOrSideDialog({
325
346
  title={title}
326
347
  description={description}
327
348
  actions={actions}
349
+ header={header}
350
+ scrollable={scrollable}
328
351
  placement={resolvedPlacement}
329
352
  shown={!isClosing}
330
353
  width={width}
@@ -354,9 +377,12 @@ function DialogPanel({
354
377
  title,
355
378
  description,
356
379
  actions,
380
+ header,
357
381
  style,
358
382
  maxWidth,
359
383
  contentPadding,
384
+ maxHeightRatio,
385
+ scrollable,
360
386
  surfaceZIndex,
361
387
  isClosing,
362
388
  children,
@@ -366,16 +392,33 @@ function DialogPanel({
366
392
  title?: string;
367
393
  description?: string;
368
394
  actions?: DialogAction[];
395
+ header?: DialogProps['header'];
369
396
  style?: DialogProps['style'];
370
397
  maxWidth: number;
371
398
  contentPadding: number;
399
+ maxHeightRatio?: number;
400
+ scrollable?: boolean;
372
401
  surfaceZIndex: number;
373
402
  isClosing: boolean;
374
403
  children?: React.ReactNode;
375
404
  }) {
376
405
  const theme = useTheme();
406
+ const { close } = useDialogContext();
377
407
  const titleId = useId();
378
408
  const descriptionId = useId();
409
+ const headerController = useDialogHeaderController();
410
+
411
+ const body = (
412
+ <DialogBody
413
+ titleId={titleId}
414
+ descriptionId={descriptionId}
415
+ title={title}
416
+ description={description}
417
+ actions={actions}
418
+ >
419
+ {children}
420
+ </DialogBody>
421
+ );
379
422
 
380
423
  return (
381
424
  <View
@@ -393,6 +436,13 @@ function DialogPanel({
393
436
  borderRadius: 20,
394
437
  width: '100%',
395
438
  maxWidth,
439
+ // The Dialog OWNS the size cap + scroll boundary (its content renders
440
+ // bare, edge-to-edge). Short content stays natural; tall content is
441
+ // capped at `maxHeightRatio` of the viewport and scrolls inside the
442
+ // rounded card (via the ScrollView below) — consumers never add their
443
+ // own height cap / ScrollView. `overflow: hidden` clips to the radius.
444
+ maxHeight: `${Math.round((maxHeightRatio ?? 0.9) * 100)}%`,
445
+ overflow: 'hidden',
396
446
  backgroundColor: theme.colors.background,
397
447
  borderWidth: 1,
398
448
  borderColor: theme.isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
@@ -400,7 +450,6 @@ function DialogPanel({
400
450
  shadowOpacity: theme.isDark ? 0.4 : 0.1,
401
451
  shadowRadius: 30,
402
452
  shadowOffset: { width: 0, height: 4 },
403
- padding: contentPadding,
404
453
  zIndex: surfaceZIndex,
405
454
  },
406
455
  isClosing
@@ -409,15 +458,56 @@ function DialogPanel({
409
458
  style,
410
459
  ]}
411
460
  >
412
- <DialogBody
413
- titleId={titleId}
414
- descriptionId={descriptionId}
415
- title={title}
416
- description={description}
417
- actions={actions}
418
- >
419
- {children}
420
- </DialogBody>
461
+ {header ? (
462
+ // Nav-header mode: the Dialog OWNS a sticky gradient nav bar + a large
463
+ // collapsing title over its own scroll content (see `DialogHeader`). The
464
+ // bar overlays; the large title + screen body scroll under it, feeding
465
+ // the shared scroll offset that drives the small-title cross-fade.
466
+ <>
467
+ <DialogNavHeader
468
+ controller={headerController}
469
+ header={header}
470
+ onDismiss={close}
471
+ collapse={scrollable !== false}
472
+ />
473
+ {scrollable === false ? (
474
+ <View style={{ flex: 1, minHeight: 0 }}>
475
+ <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />
476
+ <DialogHeaderProvider controller={headerController}>{body}</DialogHeaderProvider>
477
+ </View>
478
+ ) : (
479
+ <ScrollView
480
+ style={{ flexShrink: 1 }}
481
+ contentContainerStyle={{ paddingBottom: contentPadding }}
482
+ showsVerticalScrollIndicator={false}
483
+ scrollEventThrottle={16}
484
+ onScroll={(e) => {
485
+ headerController.scrollY.value = e.nativeEvent.contentOffset.y;
486
+ }}
487
+ >
488
+ <DialogLargeTitle controller={headerController} header={header} />
489
+ <DialogHeaderProvider controller={headerController}>{body}</DialogHeaderProvider>
490
+ </ScrollView>
491
+ )}
492
+ </>
493
+ ) : scrollable === false ? (
494
+ // The content owns its own scrolling (a FlatList / VirtualizedList or a
495
+ // nested ScrollView). Render it bare in a bounded flex box — the card's
496
+ // `maxHeight` + `overflow: hidden` cap it and `flex: 1` + `minHeight: 0`
497
+ // hand the child a bounded height to scroll into — with NO wrapping
498
+ // ScrollView, so a VirtualizedList never nests inside one.
499
+ <View style={{ flex: 1, minHeight: 0, padding: contentPadding }}>
500
+ {body}
501
+ </View>
502
+ ) : (
503
+ <ScrollView
504
+ style={{ flexShrink: 1 }}
505
+ contentContainerStyle={{ padding: contentPadding }}
506
+ showsVerticalScrollIndicator={false}
507
+ >
508
+ {body}
509
+ </ScrollView>
510
+ )}
421
511
  </View>
422
512
  );
423
513
  }
@@ -439,6 +529,8 @@ function SheetSurface({
439
529
  title,
440
530
  description,
441
531
  actions,
532
+ header,
533
+ scrollable,
442
534
  placement,
443
535
  shown,
444
536
  width,
@@ -460,6 +552,8 @@ function SheetSurface({
460
552
  title?: string;
461
553
  description?: string;
462
554
  actions?: DialogAction[];
555
+ header?: DialogProps['header'];
556
+ scrollable?: boolean;
463
557
  placement: 'left' | 'right';
464
558
  shown: boolean;
465
559
  width: number;
@@ -479,6 +573,7 @@ function SheetSurface({
479
573
  const theme = useTheme();
480
574
  const titleId = useId();
481
575
  const descriptionId = useId();
576
+ const headerController = useDialogHeaderController();
482
577
  const { width: viewportWidth } = useWindowDimensions();
483
578
 
484
579
  // Defer the entry transition by a frame so the start state paints before the
@@ -588,17 +683,53 @@ function SheetSurface({
588
683
  ]}
589
684
  pointerEvents="auto"
590
685
  >
591
- <View style={{ padding: contentPadding }}>
592
- <DialogBody
593
- titleId={titleId}
594
- descriptionId={descriptionId}
595
- title={title}
596
- description={description}
597
- actions={actions}
598
- >
599
- {children}
600
- </DialogBody>
601
- </View>
686
+ {header ? (
687
+ // Nav-header mode on a side drawer: a static titled bar (the drawer
688
+ // body does not own a Dialog scroll offset to drive a collapse) over
689
+ // the content, which is inset below the bar.
690
+ <View style={{ flex: 1, minHeight: 0 }}>
691
+ <DialogNavHeader
692
+ controller={headerController}
693
+ header={header}
694
+ onDismiss={onDismiss}
695
+ collapse={false}
696
+ />
697
+ <ScrollView
698
+ style={{ flexShrink: 1 }}
699
+ contentContainerStyle={{ paddingTop: DIALOG_NAV_BAR_HEIGHT, paddingBottom: contentPadding }}
700
+ showsVerticalScrollIndicator={false}
701
+ scrollEventThrottle={16}
702
+ scrollEnabled={scrollable !== false}
703
+ onScroll={(e) => {
704
+ headerController.scrollY.value = e.nativeEvent.contentOffset.y;
705
+ }}
706
+ >
707
+ <DialogHeaderProvider controller={headerController}>
708
+ <DialogBody
709
+ titleId={titleId}
710
+ descriptionId={descriptionId}
711
+ title={title}
712
+ description={description}
713
+ actions={actions}
714
+ >
715
+ {children}
716
+ </DialogBody>
717
+ </DialogHeaderProvider>
718
+ </ScrollView>
719
+ </View>
720
+ ) : (
721
+ <View style={{ padding: contentPadding }}>
722
+ <DialogBody
723
+ titleId={titleId}
724
+ descriptionId={descriptionId}
725
+ title={title}
726
+ description={description}
727
+ actions={actions}
728
+ >
729
+ {children}
730
+ </DialogBody>
731
+ </View>
732
+ )}
602
733
  </View>
603
734
  </View>
604
735
  );
@@ -12,6 +12,13 @@ import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
12
12
  import { useTheme } from '../theme/use-theme';
13
13
  import { Context } from './context';
14
14
  import { DialogBody } from './DialogContent';
15
+ import {
16
+ DIALOG_NAV_BAR_HEIGHT,
17
+ DialogHeaderProvider,
18
+ DialogLargeTitle,
19
+ DialogNavHeader,
20
+ useDialogHeaderController,
21
+ } from './DialogHeader';
15
22
  import {
16
23
  DEFAULT_DIALOG_CONTENT_PADDING,
17
24
  DEFAULT_MAX_HEIGHT_RATIO,
@@ -43,15 +50,18 @@ import type { DialogControlProps, DialogProps } from './types';
43
50
  export function DialogBottomSheet({
44
51
  control,
45
52
  open: controlledOpen,
53
+ startOpen,
46
54
  onClose,
47
55
  testID,
48
56
  title,
49
57
  description,
50
58
  actions,
59
+ header,
51
60
  maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
52
61
  showHandle = true,
53
62
  dismissOnBackdrop = true,
54
63
  contentPadding = DEFAULT_DIALOG_CONTENT_PADDING,
64
+ scrollable,
55
65
  style,
56
66
  panelStyle,
57
67
  panelClassName,
@@ -66,6 +76,7 @@ export function DialogBottomSheet({
66
76
  const closeCallbacks = useRef<(() => void)[]>([]);
67
77
  const titleId = useId();
68
78
  const descriptionId = useId();
79
+ const headerController = useDialogHeaderController();
69
80
 
70
81
  // Read the latest controlled flag / `onClose` inside stable callbacks without
71
82
  // re-binding them (the context + imperative handle depend on `close` staying
@@ -161,41 +172,95 @@ export function DialogBottomSheet({
161
172
  }, [theme.colors.background, maxHeightRatio, panelStyle]);
162
173
 
163
174
  // Does this dialog render bloom's declarative chrome (title / description /
164
- // actions), or is it pure custom `children`? Pure custom children own their
165
- // own layout and scrolling, so we render them through a NON-scrollable
166
- // BottomSheet body otherwise the sheet's internal ScrollView would wrap a
167
- // child that already contains its own scroller, producing a scroll-in-scroll
168
- // (double scroll container). Declarative dialogs are short and keep the
169
- // sheet's default scrollable body so they degrade gracefully on small
170
- // viewports. This only changes what `DialogBottomSheet` passes to
171
- // `BottomSheet`; the standalone `BottomSheet` API and its `scrollable` default
172
- // are unchanged for direct consumers.
175
+ // actions)? This no longer gates the SCROLL decision (that is now the explicit
176
+ // `scrollable` prop below) it only opts declarative dialogs into the
177
+ // gorhom-style pan coordination for their internal ScrollView. A declarative
178
+ // dialog overflowing on a small viewport needs the coordinated pan so the body
179
+ // scrolls instead of the drag stealing every vertical gesture; pure-custom
180
+ // children keep the legacy always-active pan.
173
181
  const hasDeclarativeChrome =
174
182
  title !== undefined ||
175
183
  description !== undefined ||
176
184
  (actions !== undefined && actions.length > 0);
177
185
 
186
+ const scrollableResolved = scrollable ?? true;
187
+
188
+ const dialogBody = (
189
+ <DialogBody
190
+ titleId={titleId}
191
+ descriptionId={descriptionId}
192
+ title={title}
193
+ description={description}
194
+ actions={actions}
195
+ >
196
+ {children}
197
+ </DialogBody>
198
+ );
199
+
200
+ // Nav-header mode: the Dialog OWNS a sticky gradient nav bar + a large
201
+ // collapsing title over the sheet's OWN scroll content. The bar floats via the
202
+ // BottomSheet `headerOverlay` slot; the sheet's scroll offset feeds the shared
203
+ // controller (`scrollY`) that drives the small-title cross-fade. `contentPadding`
204
+ // is dropped (the large title + screens own their padding).
205
+ const headerBody = header ? (
206
+ scrollableResolved ? (
207
+ <>
208
+ <DialogLargeTitle controller={headerController} header={header} />
209
+ <DialogHeaderProvider controller={headerController}>{dialogBody}</DialogHeaderProvider>
210
+ </>
211
+ ) : (
212
+ <>
213
+ <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />
214
+ <DialogHeaderProvider controller={headerController}>{dialogBody}</DialogHeaderProvider>
215
+ </>
216
+ )
217
+ ) : null;
218
+
178
219
  return (
179
220
  <BottomSheet
180
221
  ref={ref}
222
+ // Seed the sheet's open state so a fresh mount that should be open renders
223
+ // visible on its FIRST commit instead of relying on a present()-in-effect
224
+ // that races the mount and flashes blank. In controlled mode that seed is
225
+ // `controlledOpen`; in imperative mode it is the surface's `startOpen`
226
+ // intent — which is what lets a responsive Dialog survive a resize
227
+ // placement swap (centered card → bottom sheet) STILL OPEN.
228
+ open={isControlled ? controlledOpen : startOpen}
181
229
  onDismiss={handleDismiss}
182
230
  onDismissAttempt={onDismissAttempt}
183
231
  enablePanDownToClose
184
- showHandle={showHandle}
185
- // Pure custom children own scrolling opt OUT of BottomSheet's internal
186
- // ScrollView so there is exactly ONE scroll container (no scroll-in-scroll).
187
- scrollable={hasDeclarativeChrome}
188
- // Declarative dialogs (title/description/actions) that overflow render a
189
- // tall body inside BottomSheet's internal ScrollView. Opt those sheets into
232
+ // Nav-header mode hides the drag handle (the nav bar sits at the very top);
233
+ // drag-to-dismiss still works via the coordinated body pan.
234
+ showHandle={header ? false : showHandle}
235
+ // Content scrolls inside the sheet's internal ScrollView by default; the
236
+ // Dialog owns the scroll boundary. A surface whose content owns its own
237
+ // scroller (a FlatList / VirtualizedList) passes `scrollable={false}` to
238
+ // opt OUT so there is exactly ONE scroll container (no scroll-in-scroll,
239
+ // no "VirtualizedList inside a plain ScrollView" warning).
240
+ scrollable={scrollableResolved}
241
+ // Declarative dialogs (title/description/actions) that overflow — AND every
242
+ // nav-header page (its scroll content overflows by design) — need
190
243
  // gorhom-style pan coordination: the drag-to-dismiss pan only activates
191
244
  // when the ScrollView is at the top AND the finger moves down >8dp —
192
245
  // otherwise the ScrollView owns the touch. Without this the always-active
193
246
  // legacy body-pan steals vertical drags and a tall body cannot scroll
194
247
  // (dragging up dismisses the sheet / bleeds into the parent instead). The
195
248
  // standalone `BottomSheet` API keeps its legacy `manualActivation={false}`
196
- // default, so direct consumers are unaffected. Gated on the same flag as
197
- // `scrollable` because only the internal-ScrollView path needs it.
198
- manualActivation={hasDeclarativeChrome}
249
+ // default, so direct consumers are unaffected.
250
+ manualActivation={header ? scrollableResolved : hasDeclarativeChrome}
251
+ // Feed the sheet's scroll offset into the nav header's collapse controller.
252
+ scrollY={header ? headerController.scrollY : undefined}
253
+ // Float the sticky nav bar over the scroll body (clipped to the sheet top).
254
+ headerOverlay={
255
+ header ? (
256
+ <DialogNavHeader
257
+ controller={headerController}
258
+ header={header}
259
+ onDismiss={close}
260
+ collapse={scrollableResolved}
261
+ />
262
+ ) : undefined
263
+ }
199
264
  // Stronger dim than a lone sheet so an underlying sheet's handle/content
200
265
  // doesn't bleed through when a Dialog is stacked over one.
201
266
  backdropOpacity={SHEET_BACKDROP_OPACITY + 0.3}
@@ -210,7 +275,9 @@ export function DialogBottomSheet({
210
275
  {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
211
276
  {...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
212
277
  style={[
213
- { padding: contentPadding },
278
+ // Nav-header mode: the large title + screens own their padding, so the
279
+ // wrapper adds none. Otherwise the Dialog's uniform content padding.
280
+ header ? null : { padding: contentPadding },
214
281
  // `containerStyle` carries the host's CSS-var theme scope; it wraps
215
282
  // the content subtree so descendants read the scoped palette.
216
283
  containerStyle,
@@ -218,15 +285,7 @@ export function DialogBottomSheet({
218
285
  panelStyle,
219
286
  ]}
220
287
  >
221
- <DialogBody
222
- titleId={titleId}
223
- descriptionId={descriptionId}
224
- title={title}
225
- description={description}
226
- actions={actions}
227
- >
228
- {children}
229
- </DialogBody>
288
+ {header ? headerBody : dialogBody}
230
289
  </View>
231
290
  </Context.Provider>
232
291
  </BottomSheet>
@@ -242,15 +301,18 @@ export type DialogBottomSheetProps = Pick<
242
301
  DialogProps,
243
302
  | 'control'
244
303
  | 'open'
304
+ | 'startOpen'
245
305
  | 'onClose'
246
306
  | 'testID'
247
307
  | 'title'
248
308
  | 'description'
249
309
  | 'actions'
310
+ | 'header'
250
311
  | 'maxHeightRatio'
251
312
  | 'showHandle'
252
313
  | 'dismissOnBackdrop'
253
314
  | 'contentPadding'
315
+ | 'scrollable'
254
316
  | 'style'
255
317
  | 'panelStyle'
256
318
  | 'panelClassName'