@oxyhq/bloom 0.45.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 (211) 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 +86 -8
  8. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  9. package/lib/commonjs/dialog/Dialog.web.js +176 -18
  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 +40 -4
  20. package/lib/commonjs/index.js.map +1 -1
  21. package/lib/commonjs/index.web.js +84 -48
  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 +202 -0
  30. package/lib/commonjs/surfaces/SurfaceHost.js.map +1 -0
  31. package/lib/commonjs/surfaces/api.js +83 -0
  32. package/lib/commonjs/surfaces/api.js.map +1 -0
  33. package/lib/commonjs/surfaces/index.js +82 -0
  34. package/lib/commonjs/surfaces/index.js.map +1 -0
  35. package/lib/commonjs/surfaces/index.web.js +77 -0
  36. package/lib/commonjs/surfaces/index.web.js.map +1 -0
  37. package/lib/commonjs/surfaces/prompts.js +154 -0
  38. package/lib/commonjs/surfaces/prompts.js.map +1 -0
  39. package/lib/commonjs/surfaces/surfaceStore.js +172 -0
  40. package/lib/commonjs/surfaces/surfaceStore.js.map +1 -0
  41. package/lib/commonjs/surfaces/types.js +6 -0
  42. package/lib/commonjs/surfaces/types.js.map +1 -0
  43. package/lib/commonjs/surfaces/useSurface.js +36 -0
  44. package/lib/commonjs/surfaces/useSurface.js.map +1 -0
  45. package/lib/commonjs/text-field/index.js +22 -6
  46. package/lib/commonjs/text-field/index.js.map +1 -1
  47. package/lib/module/bottom-sheet/BottomSheetBase.js +34 -12
  48. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  49. package/lib/module/combobox/Combobox.js +3 -3
  50. package/lib/module/combobox/Combobox.js.map +1 -1
  51. package/lib/module/command/Command.js +3 -3
  52. package/lib/module/command/Command.js.map +1 -1
  53. package/lib/module/dialog/Dialog.js +88 -10
  54. package/lib/module/dialog/Dialog.js.map +1 -1
  55. package/lib/module/dialog/Dialog.web.js +179 -21
  56. package/lib/module/dialog/Dialog.web.js.map +1 -1
  57. package/lib/module/dialog/DialogBottomSheet.js +85 -30
  58. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  59. package/lib/module/dialog/DialogHeader.js +356 -0
  60. package/lib/module/dialog/DialogHeader.js.map +1 -0
  61. package/lib/module/dialog/index.js +1 -0
  62. package/lib/module/dialog/index.js.map +1 -1
  63. package/lib/module/dialog/index.web.js +1 -0
  64. package/lib/module/dialog/index.web.js.map +1 -1
  65. package/lib/module/index.js +7 -2
  66. package/lib/module/index.js.map +1 -1
  67. package/lib/module/index.web.js +7 -2
  68. package/lib/module/index.web.js.map +1 -1
  69. package/lib/module/react-native-className.d.js +20 -0
  70. package/lib/module/react-native-className.d.js.map +1 -0
  71. package/lib/module/{search-input → search}/index.js +6 -2
  72. package/lib/module/search/index.js.map +1 -0
  73. package/lib/module/settings-list/SettingsList.js +5 -1
  74. package/lib/module/settings-list/SettingsList.js.map +1 -1
  75. package/lib/module/surfaces/SurfaceHost.js +197 -0
  76. package/lib/module/surfaces/SurfaceHost.js.map +1 -0
  77. package/lib/module/surfaces/api.js +48 -0
  78. package/lib/module/surfaces/api.js.map +1 -0
  79. package/lib/module/surfaces/index.js +24 -0
  80. package/lib/module/surfaces/index.js.map +1 -0
  81. package/lib/module/surfaces/index.web.js +19 -0
  82. package/lib/module/surfaces/index.web.js.map +1 -0
  83. package/lib/module/surfaces/prompts.js +148 -0
  84. package/lib/module/surfaces/prompts.js.map +1 -0
  85. package/lib/module/surfaces/surfaceStore.js +159 -0
  86. package/lib/module/surfaces/surfaceStore.js.map +1 -0
  87. package/lib/module/surfaces/types.js +4 -0
  88. package/lib/module/surfaces/types.js.map +1 -0
  89. package/lib/module/surfaces/useSurface.js +31 -0
  90. package/lib/module/surfaces/useSurface.js.map +1 -0
  91. package/lib/module/text-field/index.js +22 -6
  92. package/lib/module/text-field/index.js.map +1 -1
  93. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts +23 -0
  94. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/combobox/Combobox.d.ts +1 -1
  96. package/lib/typescript/commonjs/command/Command.d.ts +1 -1
  97. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +2 -2
  100. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  101. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts +86 -0
  102. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts.map +1 -0
  103. package/lib/typescript/commonjs/dialog/index.d.ts +2 -1
  104. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/dialog/index.web.d.ts +2 -1
  106. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/dialog/types.d.ts +85 -0
  108. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/icons/common.d.ts +1 -0
  110. package/lib/typescript/commonjs/icons/common.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/index.d.ts +5 -3
  112. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/index.web.d.ts +5 -3
  114. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/input-group/types.d.ts +1 -1
  116. package/lib/typescript/commonjs/{search-input → search}/index.d.ts +1 -1
  117. package/lib/typescript/commonjs/search/index.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts +25 -0
  119. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -0
  120. package/lib/typescript/commonjs/surfaces/api.d.ts +41 -0
  121. package/lib/typescript/commonjs/surfaces/api.d.ts.map +1 -0
  122. package/lib/typescript/commonjs/surfaces/index.d.ts +25 -0
  123. package/lib/typescript/commonjs/surfaces/index.d.ts.map +1 -0
  124. package/lib/typescript/commonjs/surfaces/index.web.d.ts +20 -0
  125. package/lib/typescript/commonjs/surfaces/index.web.d.ts.map +1 -0
  126. package/lib/typescript/commonjs/surfaces/prompts.d.ts +13 -0
  127. package/lib/typescript/commonjs/surfaces/prompts.d.ts.map +1 -0
  128. package/lib/typescript/commonjs/surfaces/surfaceStore.d.ts +51 -0
  129. package/lib/typescript/commonjs/surfaces/surfaceStore.d.ts.map +1 -0
  130. package/lib/typescript/commonjs/surfaces/types.d.ts +161 -0
  131. package/lib/typescript/commonjs/surfaces/types.d.ts.map +1 -0
  132. package/lib/typescript/commonjs/surfaces/useSurface.d.ts +21 -0
  133. package/lib/typescript/commonjs/surfaces/useSurface.d.ts.map +1 -0
  134. package/lib/typescript/commonjs/text-field/index.d.ts +2 -1
  135. package/lib/typescript/commonjs/text-field/index.d.ts.map +1 -1
  136. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts +23 -0
  137. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  138. package/lib/typescript/module/combobox/Combobox.d.ts +1 -1
  139. package/lib/typescript/module/command/Command.d.ts +1 -1
  140. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  141. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  142. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +2 -2
  143. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  144. package/lib/typescript/module/dialog/DialogHeader.d.ts +86 -0
  145. package/lib/typescript/module/dialog/DialogHeader.d.ts.map +1 -0
  146. package/lib/typescript/module/dialog/index.d.ts +2 -1
  147. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  148. package/lib/typescript/module/dialog/index.web.d.ts +2 -1
  149. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  150. package/lib/typescript/module/dialog/types.d.ts +85 -0
  151. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  152. package/lib/typescript/module/icons/common.d.ts +1 -0
  153. package/lib/typescript/module/icons/common.d.ts.map +1 -1
  154. package/lib/typescript/module/index.d.ts +5 -3
  155. package/lib/typescript/module/index.d.ts.map +1 -1
  156. package/lib/typescript/module/index.web.d.ts +5 -3
  157. package/lib/typescript/module/index.web.d.ts.map +1 -1
  158. package/lib/typescript/module/input-group/types.d.ts +1 -1
  159. package/lib/typescript/module/{search-input → search}/index.d.ts +1 -1
  160. package/lib/typescript/module/search/index.d.ts.map +1 -0
  161. package/lib/typescript/module/surfaces/SurfaceHost.d.ts +25 -0
  162. package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -0
  163. package/lib/typescript/module/surfaces/api.d.ts +41 -0
  164. package/lib/typescript/module/surfaces/api.d.ts.map +1 -0
  165. package/lib/typescript/module/surfaces/index.d.ts +25 -0
  166. package/lib/typescript/module/surfaces/index.d.ts.map +1 -0
  167. package/lib/typescript/module/surfaces/index.web.d.ts +20 -0
  168. package/lib/typescript/module/surfaces/index.web.d.ts.map +1 -0
  169. package/lib/typescript/module/surfaces/prompts.d.ts +13 -0
  170. package/lib/typescript/module/surfaces/prompts.d.ts.map +1 -0
  171. package/lib/typescript/module/surfaces/surfaceStore.d.ts +51 -0
  172. package/lib/typescript/module/surfaces/surfaceStore.d.ts.map +1 -0
  173. package/lib/typescript/module/surfaces/types.d.ts +161 -0
  174. package/lib/typescript/module/surfaces/types.d.ts.map +1 -0
  175. package/lib/typescript/module/surfaces/useSurface.d.ts +21 -0
  176. package/lib/typescript/module/surfaces/useSurface.d.ts.map +1 -0
  177. package/lib/typescript/module/text-field/index.d.ts +2 -1
  178. package/lib/typescript/module/text-field/index.d.ts.map +1 -1
  179. package/package.json +24 -8
  180. package/src/__tests__/BottomSheet.test.tsx +47 -0
  181. package/src/__tests__/DialogBottomSheet.test.tsx +30 -5
  182. package/src/__tests__/surfaceStore.test.ts +219 -0
  183. package/src/bottom-sheet/BottomSheetBase.tsx +59 -10
  184. package/src/combobox/Combobox.tsx +3 -3
  185. package/src/command/Command.tsx +3 -3
  186. package/src/dialog/Dialog.tsx +88 -7
  187. package/src/dialog/Dialog.web.tsx +179 -29
  188. package/src/dialog/DialogBottomSheet.tsx +90 -28
  189. package/src/dialog/DialogHeader.tsx +420 -0
  190. package/src/dialog/index.ts +2 -0
  191. package/src/dialog/index.web.ts +2 -0
  192. package/src/dialog/types.ts +86 -0
  193. package/src/index.ts +18 -1
  194. package/src/index.web.ts +18 -1
  195. package/src/input-group/types.ts +1 -1
  196. package/src/react-native-className.d.ts +38 -0
  197. package/src/{search-input → search}/index.tsx +8 -5
  198. package/src/settings-list/SettingsList.tsx +5 -1
  199. package/src/surfaces/SurfaceHost.tsx +222 -0
  200. package/src/surfaces/api.ts +55 -0
  201. package/src/surfaces/index.ts +38 -0
  202. package/src/surfaces/index.web.ts +33 -0
  203. package/src/surfaces/prompts.tsx +178 -0
  204. package/src/surfaces/surfaceStore.ts +168 -0
  205. package/src/surfaces/types.ts +172 -0
  206. package/src/surfaces/useSurface.ts +33 -0
  207. package/src/text-field/index.tsx +24 -5
  208. package/lib/commonjs/search-input/index.js.map +0 -1
  209. package/lib/module/search-input/index.js.map +0 -1
  210. package/lib/typescript/commonjs/search-input/index.d.ts.map +0 -1
  211. package/lib/typescript/module/search-input/index.d.ts.map +0 -1
@@ -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'
@@ -0,0 +1,420 @@
1
+ import React, {
2
+ createContext,
3
+ memo,
4
+ useContext,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ useSyncExternalStore,
8
+ } from 'react';
9
+ import {
10
+ StyleSheet,
11
+ View,
12
+ type LayoutChangeEvent,
13
+ type StyleProp,
14
+ type ViewStyle,
15
+ } from 'react-native';
16
+ import Animated, {
17
+ Extrapolation,
18
+ interpolate,
19
+ useAnimatedStyle,
20
+ useSharedValue,
21
+ type SharedValue,
22
+ } from 'react-native-reanimated';
23
+
24
+ import {
25
+ ChevronLeft_Stroke2_Corner0_Rounded,
26
+ TimesLarge_Stroke2_Corner0_Rounded,
27
+ } from '../icons';
28
+ import { FrostedIconButton } from '../frosted-icon-button';
29
+ import { useTheme } from '../theme/use-theme';
30
+ import { H1, Text } from '../typography';
31
+ import type { DialogHeaderConfig } from './types';
32
+
33
+ export type { DialogHeaderConfig } from './types';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Dialog-owned navigation header (iOS large-title collapse).
37
+ //
38
+ // This is a DISTINCT surface mode from the declarative
39
+ // `title`/`description`/`actions` centered-heading path: it turns the Dialog
40
+ // into a scrolling page with a sticky, gradient top bar (left + right slots)
41
+ // and a large collapsing title that lives in the Dialog's OWN scroll content.
42
+ // As the large title scrolls under the bar, a small title cross-fades into the
43
+ // bar center.
44
+ //
45
+ // Screens render NO header of their own — they declare their title/subtitle
46
+ // (via the surface registry) or contribute runtime slots (a Save action, a
47
+ // dynamic title) through {@link useDialogHeader}.
48
+ // ---------------------------------------------------------------------------
49
+
50
+ /** Interactive nav-bar row height (px). Buttons + collapsed title live here. */
51
+ export const DIALOG_NAV_BAR_HEIGHT = 52;
52
+
53
+ /** Extra gradient tail (px) below the bar, over which scrolled content fades. */
54
+ const GRADIENT_FADE = 20;
55
+
56
+ /** Total gradient overlay height (bar + fade tail). */
57
+ export const DIALOG_HEADER_OVERLAY_HEIGHT = DIALOG_NAV_BAR_HEIGHT + GRADIENT_FADE;
58
+
59
+ /**
60
+ * Top inset applied to the Dialog's scroll content so the large title starts
61
+ * BELOW the whole gradient overlay (fully clear at rest, then scrolls under it).
62
+ */
63
+ export const DIALOG_HEADER_CONTENT_TOP = DIALOG_HEADER_OVERLAY_HEIGHT;
64
+
65
+ /** Horizontal gutter — matches the `screen-margin` (20px) SDK screens use. */
66
+ const HEADER_H_PADDING = 20;
67
+
68
+ /** Circular icon-button diameter for the default back / close affordances. */
69
+ const NAV_BUTTON_SIZE = 36;
70
+
71
+ // --- Runtime override store (child screen → nav bar / large title) ----------
72
+ //
73
+ // The nav bar overlay and the in-content large title live in DIFFERENT subtrees
74
+ // (the bar is a sibling ABOVE the scroller; the large title + the screen are
75
+ // inside it), so a plain React ancestor context cannot bridge a screen's runtime
76
+ // contribution up to the bar. We use a tiny external store instead: the screen
77
+ // writes its override through {@link useDialogHeader}; the bar and large title
78
+ // read it via `useSyncExternalStore`. Only those two re-render — never the
79
+ // screen subtree — and there is no shared-ancestor constraint.
80
+
81
+ interface HeaderStore {
82
+ getSnapshot: () => DialogHeaderConfig | null;
83
+ setOverride: (next: DialogHeaderConfig | null) => void;
84
+ subscribe: (listener: () => void) => () => void;
85
+ }
86
+
87
+ function configsEqual(a: DialogHeaderConfig | null, b: DialogHeaderConfig | null): boolean {
88
+ if (a === b) return true;
89
+ if (!a || !b) return false;
90
+ return (
91
+ a.title === b.title &&
92
+ a.subtitle === b.subtitle &&
93
+ a.largeTitle === b.largeTitle &&
94
+ a.left === b.left &&
95
+ a.right === b.right &&
96
+ a.onBack === b.onBack &&
97
+ a.showClose === b.showClose
98
+ );
99
+ }
100
+
101
+ function createHeaderStore(): HeaderStore {
102
+ let override: DialogHeaderConfig | null = null;
103
+ const listeners = new Set<() => void>();
104
+ return {
105
+ getSnapshot: () => override,
106
+ setOverride: (next) => {
107
+ if (configsEqual(override, next)) return;
108
+ override = next;
109
+ for (const l of listeners) l();
110
+ },
111
+ subscribe: (listener) => {
112
+ listeners.add(listener);
113
+ return () => listeners.delete(listener);
114
+ },
115
+ };
116
+ }
117
+
118
+ /**
119
+ * The per-surface header controller — the scroll offset + measured large-title
120
+ * height shared values plus the runtime-override store. Created once per Dialog
121
+ * (via {@link useDialogHeaderController}) and threaded into the scroller (writes
122
+ * `scrollY`), the nav bar, the large title, and the child provider.
123
+ */
124
+ export interface DialogHeaderController {
125
+ scrollY: SharedValue<number>;
126
+ largeTitleHeight: SharedValue<number>;
127
+ store: HeaderStore;
128
+ }
129
+
130
+ /** Create the header controller for one Dialog. Stable for the Dialog's life. */
131
+ export function useDialogHeaderController(): DialogHeaderController {
132
+ const scrollY = useSharedValue(0);
133
+ const largeTitleHeight = useSharedValue(0);
134
+ const store = useMemo(() => createHeaderStore(), []);
135
+ return useMemo(
136
+ () => ({ scrollY, largeTitleHeight, store }),
137
+ [scrollY, largeTitleHeight, store],
138
+ );
139
+ }
140
+
141
+ const DialogHeaderContext = createContext<DialogHeaderController | null>(null);
142
+
143
+ /**
144
+ * Wraps the Dialog's scroll content so a mounted screen can contribute header
145
+ * slots via {@link useDialogHeader}. Only the child subtree needs this — the nav
146
+ * bar and large title read the same controller directly.
147
+ */
148
+ export function DialogHeaderProvider({
149
+ controller,
150
+ children,
151
+ }: {
152
+ controller: DialogHeaderController;
153
+ children: React.ReactNode;
154
+ }): React.ReactElement {
155
+ return (
156
+ <DialogHeaderContext.Provider value={controller}>
157
+ {children}
158
+ </DialogHeaderContext.Provider>
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Contribute header content from within a Dialog surface — a dynamic title, a
164
+ * Save action, custom slots. Merges over the surface's static (registry) header
165
+ * config; unmounting clears the contribution. Slot nodes (`left`/`right`) MUST
166
+ * be referentially stable (memoize them) so the effect does not thrash.
167
+ *
168
+ * No-op outside a header-mode Dialog (e.g. a confirm dialog), so a screen can
169
+ * call it unconditionally.
170
+ */
171
+ export function useDialogHeader(config: DialogHeaderConfig | null | undefined): void {
172
+ const controller = useContext(DialogHeaderContext);
173
+ const store = controller?.store;
174
+ // Set synchronously in the commit's layout phase so the bar/title fill in
175
+ // BEFORE the browser paints — no first-frame flash of an empty bar.
176
+ useLayoutEffect(() => {
177
+ if (!store) return;
178
+ store.setOverride(config ?? null);
179
+ return () => store.setOverride(null);
180
+ // Slot nodes are compared by identity; callers memoize them.
181
+ }, [
182
+ store,
183
+ config?.title,
184
+ config?.subtitle,
185
+ config?.largeTitle,
186
+ config?.left,
187
+ config?.right,
188
+ config?.onBack,
189
+ config?.showClose,
190
+ ]);
191
+ }
192
+
193
+ /** Read the merged (base + runtime-override) header config, reactively. */
194
+ function useMergedHeaderConfig(
195
+ base: DialogHeaderConfig,
196
+ store: HeaderStore,
197
+ ): DialogHeaderConfig {
198
+ const override = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
199
+ return useMemo(() => (override ? { ...base, ...override } : base), [base, override]);
200
+ }
201
+
202
+ // --- Nav bar overlay --------------------------------------------------------
203
+
204
+ /**
205
+ * The sticky nav bar: gradient background + left / center (collapsing title) /
206
+ * right slots. Absolutely positioned at the top of the Dialog surface, painted
207
+ * ABOVE the scroll content. Reads the scroll offset to cross-fade the small
208
+ * title in as the large title scrolls under it.
209
+ *
210
+ * `onDismiss` is the Dialog's own close (the default right-slot close button
211
+ * calls it) — passed explicitly rather than read from the dialog context because
212
+ * the bar is rendered as a sibling of the scroller, outside that context on the
213
+ * bottom-sheet path.
214
+ */
215
+ export const DialogNavHeader = memo(function DialogNavHeader({
216
+ controller,
217
+ header,
218
+ onDismiss,
219
+ collapse = true,
220
+ style,
221
+ }: {
222
+ controller: DialogHeaderController;
223
+ header: DialogHeaderConfig;
224
+ onDismiss: () => void;
225
+ /**
226
+ * Whether the small title cross-fades in on scroll (paired with an in-content
227
+ * {@link DialogLargeTitle}). Defaults to `true`. Set `false` for a surface
228
+ * whose content owns its own scroller (no Dialog scroll offset to drive the
229
+ * collapse): the small title is then always visible — a plain titled nav bar.
230
+ */
231
+ collapse?: boolean;
232
+ style?: StyleProp<ViewStyle>;
233
+ }) {
234
+ const theme = useTheme();
235
+ const config = useMergedHeaderConfig(header, controller.store);
236
+ const { scrollY, largeTitleHeight } = controller;
237
+
238
+ const hasLargeTitle = collapse && (config.largeTitle ?? true) && !!config.title;
239
+
240
+ const titleStyle = useAnimatedStyle(() => {
241
+ // When there is no large title to collapse under, the bar title is always
242
+ // shown (a plain, non-collapsing nav bar). Otherwise it cross-fades in as the
243
+ // large title scrolls up under the bar.
244
+ if (!hasLargeTitle) return { opacity: 1, transform: [{ translateY: 0 }] };
245
+ const threshold = largeTitleHeight.value > 0 ? largeTitleHeight.value : 44;
246
+ const start = threshold * 0.35;
247
+ const end = threshold * 0.9;
248
+ return {
249
+ opacity: interpolate(scrollY.value, [start, end], [0, 1], Extrapolation.CLAMP),
250
+ transform: [
251
+ { translateY: interpolate(scrollY.value, [start, end], [10, 0], Extrapolation.CLAMP) },
252
+ ],
253
+ };
254
+ // Reanimated on RN-Web has no worklets plugin: every shared value the mapper
255
+ // reads MUST be in the deps array or it freezes on the first frame.
256
+ }, [scrollY, largeTitleHeight, hasLargeTitle]);
257
+
258
+ const left =
259
+ config.left ??
260
+ (config.onBack ? (
261
+ <FrostedIconButton
262
+ onPress={config.onBack}
263
+ accessibilityLabel="Go back"
264
+ icon={<ChevronLeft_Stroke2_Corner0_Rounded size="md" />}
265
+ />
266
+ ) : null);
267
+
268
+ const right =
269
+ config.right ??
270
+ (config.showClose !== false ? (
271
+ <FrostedIconButton
272
+ onPress={onDismiss}
273
+ accessibilityLabel="Close"
274
+ icon={<TimesLarge_Stroke2_Corner0_Rounded size="md" />}
275
+ />
276
+ ) : null);
277
+
278
+ return (
279
+ <View
280
+ pointerEvents="box-none"
281
+ style={[styles.overlay, style]}
282
+ >
283
+ {/* Opaque (surface bg) at the top → transparent at the bottom, so scrolled
284
+ content fades out under the bar. Pure NativeWind — no SVG dependency. */}
285
+ <View
286
+ pointerEvents="none"
287
+ className="bg-gradient-to-b from-bg to-transparent"
288
+ style={[StyleSheet.absoluteFill, { height: DIALOG_HEADER_OVERLAY_HEIGHT }]}
289
+ />
290
+ <View pointerEvents="box-none" style={styles.navRow}>
291
+ <View style={styles.side}>{left}</View>
292
+ <Animated.View
293
+ pointerEvents="none"
294
+ testID="dialog-nav-title"
295
+ style={[styles.centerTitle, titleStyle]}
296
+ >
297
+ {config.title ? (
298
+ <Text numberOfLines={1} style={[styles.smallTitle, { color: theme.colors.text }]}>
299
+ {config.title}
300
+ </Text>
301
+ ) : null}
302
+ {/* The subtitle rides along ONLY in the static (non-collapsing) bar —
303
+ own-scroller screens whose subtitle is a short count. A large-title
304
+ screen keeps a clean single-line collapsed title. */}
305
+ {!hasLargeTitle && config.subtitle ? (
306
+ <Text
307
+ numberOfLines={1}
308
+ style={[styles.smallSubtitle, { color: theme.colors.textSecondary }]}
309
+ >
310
+ {config.subtitle}
311
+ </Text>
312
+ ) : null}
313
+ </Animated.View>
314
+ <View style={[styles.side, styles.sideRight]}>{right}</View>
315
+ </View>
316
+ </View>
317
+ );
318
+ });
319
+
320
+ // --- In-content large title -------------------------------------------------
321
+
322
+ /**
323
+ * The large collapsing title, rendered at the TOP of the Dialog's scroll content
324
+ * (before the screen body). Owns the content top inset (so the title starts
325
+ * below the gradient overlay) and measures its own text height into the
326
+ * controller so the nav bar knows when to cross-fade the small title in.
327
+ */
328
+ export const DialogLargeTitle = memo(function DialogLargeTitle({
329
+ controller,
330
+ header,
331
+ }: {
332
+ controller: DialogHeaderController;
333
+ header: DialogHeaderConfig;
334
+ }) {
335
+ const theme = useTheme();
336
+ const config = useMergedHeaderConfig(header, controller.store);
337
+ const { largeTitleHeight } = controller;
338
+
339
+ const hasLargeTitle = (config.largeTitle ?? true) && !!config.title;
340
+
341
+ const onLayout = (e: LayoutChangeEvent) => {
342
+ largeTitleHeight.value = e.nativeEvent.layout.height;
343
+ };
344
+
345
+ if (!hasLargeTitle) {
346
+ // No large title: just inset the content below the nav bar.
347
+ return <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />;
348
+ }
349
+
350
+ return (
351
+ <>
352
+ <View style={{ height: DIALOG_HEADER_CONTENT_TOP }} />
353
+ <View onLayout={onLayout} testID="dialog-large-title" style={styles.largeTitleBlock}>
354
+ <H1 style={[styles.largeTitle, { color: theme.colors.text }]}>{config.title}</H1>
355
+ {config.subtitle ? (
356
+ <Text style={[styles.largeSubtitle, { color: theme.colors.textSecondary }]}>
357
+ {config.subtitle}
358
+ </Text>
359
+ ) : null}
360
+ </View>
361
+ </>
362
+ );
363
+ });
364
+
365
+ const styles = StyleSheet.create({
366
+ overlay: {
367
+ position: 'absolute',
368
+ top: 0,
369
+ left: 0,
370
+ right: 0,
371
+ height: DIALOG_HEADER_OVERLAY_HEIGHT,
372
+ zIndex: 10,
373
+ },
374
+ navRow: {
375
+ height: DIALOG_NAV_BAR_HEIGHT,
376
+ flexDirection: 'row',
377
+ alignItems: 'center',
378
+ paddingHorizontal: HEADER_H_PADDING - 4,
379
+ },
380
+ side: {
381
+ minWidth: NAV_BUTTON_SIZE,
382
+ flexDirection: 'row',
383
+ alignItems: 'center',
384
+ },
385
+ sideRight: {
386
+ justifyContent: 'flex-end',
387
+ },
388
+ centerTitle: {
389
+ flex: 1,
390
+ alignItems: 'center',
391
+ justifyContent: 'center',
392
+ paddingHorizontal: 8,
393
+ },
394
+ smallTitle: {
395
+ fontSize: 16,
396
+ fontWeight: '600',
397
+ letterSpacing: -0.2,
398
+ lineHeight: 20,
399
+ },
400
+ smallSubtitle: {
401
+ fontSize: 12,
402
+ fontWeight: '400',
403
+ lineHeight: 14,
404
+ },
405
+ largeTitleBlock: {
406
+ paddingHorizontal: HEADER_H_PADDING,
407
+ paddingBottom: 4,
408
+ },
409
+ largeTitle: {
410
+ fontSize: 32,
411
+ lineHeight: 38,
412
+ letterSpacing: -0.5,
413
+ fontWeight: '700',
414
+ },
415
+ largeSubtitle: {
416
+ fontSize: 16,
417
+ lineHeight: 22,
418
+ marginTop: 4,
419
+ },
420
+ });
@@ -2,6 +2,7 @@ export { Dialog, DIALOG_SHEET_BACKDROP_TESTID } from './Dialog';
2
2
  export { BloomDialogProvider } from './BloomDialogProvider';
3
3
  export { alert } from './alert';
4
4
  export { useDialogContext, useDialogControl } from './context';
5
+ export { useDialogHeader } from './DialogHeader';
5
6
  export type {
6
7
  AlertButton,
7
8
  AlertButtonStyle,
@@ -11,6 +12,7 @@ export type {
11
12
  DialogActionColor,
12
13
  DialogContextProps,
13
14
  DialogControlProps,
15
+ DialogHeaderConfig,
14
16
  DialogInset,
15
17
  DialogPlacement,
16
18
  DialogProps,
@@ -12,6 +12,7 @@ export { Dialog, BLOOM_DIALOG_CSS, DIALOG_SHEET_BACKDROP_TESTID } from './Dialog
12
12
  export { BloomDialogProvider } from './BloomDialogProvider.web';
13
13
  export { alert } from './alert';
14
14
  export { useDialogContext, useDialogControl } from './context';
15
+ export { useDialogHeader } from './DialogHeader';
15
16
  export type {
16
17
  AlertButton,
17
18
  AlertButtonStyle,
@@ -21,6 +22,7 @@ export type {
21
22
  DialogActionColor,
22
23
  DialogContextProps,
23
24
  DialogControlProps,
25
+ DialogHeaderConfig,
24
26
  DialogInset,
25
27
  DialogPlacement,
26
28
  DialogProps,