@oxyhq/bloom 0.46.0 → 0.49.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 (160) hide show
  1. package/lib/commonjs/bottom-sheet/BottomSheetBase.js +36 -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 +103 -8
  8. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  9. package/lib/commonjs/dialog/Dialog.web.js +189 -12
  10. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  11. package/lib/commonjs/dialog/DialogBottomSheet.js +111 -31
  12. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  13. package/lib/commonjs/dialog/DialogHeader.js +371 -0
  14. package/lib/commonjs/dialog/DialogHeader.js.map +1 -0
  15. package/lib/commonjs/dialog/DialogMorph.js +458 -0
  16. package/lib/commonjs/dialog/DialogMorph.js.map +1 -0
  17. package/lib/commonjs/dialog/index.js +14 -0
  18. package/lib/commonjs/dialog/index.js.map +1 -1
  19. package/lib/commonjs/dialog/index.web.js +14 -0
  20. package/lib/commonjs/dialog/index.web.js.map +1 -1
  21. package/lib/commonjs/index.js +11 -4
  22. package/lib/commonjs/index.js.map +1 -1
  23. package/lib/commonjs/index.web.js +11 -4
  24. package/lib/commonjs/index.web.js.map +1 -1
  25. package/lib/commonjs/react-native-className.d.js +6 -0
  26. package/lib/commonjs/react-native-className.d.js.map +1 -0
  27. package/lib/commonjs/{search-input → search}/index.js +5 -3
  28. package/lib/commonjs/search/index.js.map +1 -0
  29. package/lib/commonjs/settings-list/SettingsList.js +6 -2
  30. package/lib/commonjs/settings-list/SettingsList.js.map +1 -1
  31. package/lib/commonjs/surfaces/SurfaceHost.js +23 -9
  32. package/lib/commonjs/surfaces/SurfaceHost.js.map +1 -1
  33. package/lib/commonjs/text-field/index.js +22 -6
  34. package/lib/commonjs/text-field/index.js.map +1 -1
  35. package/lib/module/bottom-sheet/BottomSheetBase.js +36 -12
  36. package/lib/module/bottom-sheet/BottomSheetBase.js.map +1 -1
  37. package/lib/module/combobox/Combobox.js +3 -3
  38. package/lib/module/combobox/Combobox.js.map +1 -1
  39. package/lib/module/command/Command.js +3 -3
  40. package/lib/module/command/Command.js.map +1 -1
  41. package/lib/module/dialog/Dialog.js +104 -9
  42. package/lib/module/dialog/Dialog.js.map +1 -1
  43. package/lib/module/dialog/Dialog.web.js +192 -16
  44. package/lib/module/dialog/Dialog.web.js.map +1 -1
  45. package/lib/module/dialog/DialogBottomSheet.js +113 -33
  46. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  47. package/lib/module/dialog/DialogHeader.js +362 -0
  48. package/lib/module/dialog/DialogHeader.js.map +1 -0
  49. package/lib/module/dialog/DialogMorph.js +451 -0
  50. package/lib/module/dialog/DialogMorph.js.map +1 -0
  51. package/lib/module/dialog/index.js +2 -0
  52. package/lib/module/dialog/index.js.map +1 -1
  53. package/lib/module/dialog/index.web.js +2 -0
  54. package/lib/module/dialog/index.web.js.map +1 -1
  55. package/lib/module/index.js +2 -2
  56. package/lib/module/index.js.map +1 -1
  57. package/lib/module/index.web.js +2 -2
  58. package/lib/module/index.web.js.map +1 -1
  59. package/lib/module/react-native-className.d.js +20 -0
  60. package/lib/module/react-native-className.d.js.map +1 -0
  61. package/lib/module/{search-input → search}/index.js +6 -2
  62. package/lib/module/search/index.js.map +1 -0
  63. package/lib/module/settings-list/SettingsList.js +6 -2
  64. package/lib/module/settings-list/SettingsList.js.map +1 -1
  65. package/lib/module/surfaces/SurfaceHost.js +23 -9
  66. package/lib/module/surfaces/SurfaceHost.js.map +1 -1
  67. package/lib/module/text-field/index.js +22 -6
  68. package/lib/module/text-field/index.js.map +1 -1
  69. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts +31 -1
  70. package/lib/typescript/commonjs/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/combobox/Combobox.d.ts +1 -1
  72. package/lib/typescript/commonjs/command/Command.d.ts +1 -1
  73. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +2 -2
  76. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts +86 -0
  78. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts.map +1 -0
  79. package/lib/typescript/commonjs/dialog/DialogMorph.d.ts +150 -0
  80. package/lib/typescript/commonjs/dialog/DialogMorph.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/dialog/index.d.ts +4 -1
  82. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  83. package/lib/typescript/commonjs/dialog/index.web.d.ts +4 -1
  84. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  85. package/lib/typescript/commonjs/dialog/types.d.ts +105 -0
  86. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  87. package/lib/typescript/commonjs/icons/common.d.ts +1 -0
  88. package/lib/typescript/commonjs/icons/common.d.ts.map +1 -1
  89. package/lib/typescript/commonjs/index.d.ts +3 -3
  90. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/index.web.d.ts +3 -3
  92. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/input-group/types.d.ts +1 -1
  94. package/lib/typescript/commonjs/{search-input → search}/index.d.ts +1 -1
  95. package/lib/typescript/commonjs/search/index.d.ts.map +1 -0
  96. package/lib/typescript/commonjs/surfaces/SurfaceHost.d.ts.map +1 -1
  97. package/lib/typescript/commonjs/surfaces/types.d.ts +26 -1
  98. package/lib/typescript/commonjs/surfaces/types.d.ts.map +1 -1
  99. package/lib/typescript/commonjs/text-field/index.d.ts +2 -1
  100. package/lib/typescript/commonjs/text-field/index.d.ts.map +1 -1
  101. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts +31 -1
  102. package/lib/typescript/module/bottom-sheet/BottomSheetBase.d.ts.map +1 -1
  103. package/lib/typescript/module/combobox/Combobox.d.ts +1 -1
  104. package/lib/typescript/module/command/Command.d.ts +1 -1
  105. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  106. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  107. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +2 -2
  108. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  109. package/lib/typescript/module/dialog/DialogHeader.d.ts +86 -0
  110. package/lib/typescript/module/dialog/DialogHeader.d.ts.map +1 -0
  111. package/lib/typescript/module/dialog/DialogMorph.d.ts +150 -0
  112. package/lib/typescript/module/dialog/DialogMorph.d.ts.map +1 -0
  113. package/lib/typescript/module/dialog/index.d.ts +4 -1
  114. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  115. package/lib/typescript/module/dialog/index.web.d.ts +4 -1
  116. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  117. package/lib/typescript/module/dialog/types.d.ts +105 -0
  118. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  119. package/lib/typescript/module/icons/common.d.ts +1 -0
  120. package/lib/typescript/module/icons/common.d.ts.map +1 -1
  121. package/lib/typescript/module/index.d.ts +3 -3
  122. package/lib/typescript/module/index.d.ts.map +1 -1
  123. package/lib/typescript/module/index.web.d.ts +3 -3
  124. package/lib/typescript/module/index.web.d.ts.map +1 -1
  125. package/lib/typescript/module/input-group/types.d.ts +1 -1
  126. package/lib/typescript/module/{search-input → search}/index.d.ts +1 -1
  127. package/lib/typescript/module/search/index.d.ts.map +1 -0
  128. package/lib/typescript/module/surfaces/SurfaceHost.d.ts.map +1 -1
  129. package/lib/typescript/module/surfaces/types.d.ts +26 -1
  130. package/lib/typescript/module/surfaces/types.d.ts.map +1 -1
  131. package/lib/typescript/module/text-field/index.d.ts +2 -1
  132. package/lib/typescript/module/text-field/index.d.ts.map +1 -1
  133. package/package.json +8 -8
  134. package/src/__tests__/BottomSheet.test.tsx +47 -0
  135. package/src/__tests__/DialogBottomSheet.test.tsx +30 -5
  136. package/src/__tests__/DialogMorph.test.tsx +230 -0
  137. package/src/bottom-sheet/BottomSheetBase.tsx +72 -11
  138. package/src/combobox/Combobox.tsx +3 -3
  139. package/src/command/Command.tsx +3 -3
  140. package/src/dialog/Dialog.tsx +111 -14
  141. package/src/dialog/Dialog.web.tsx +186 -25
  142. package/src/dialog/DialogBottomSheet.tsx +122 -30
  143. package/src/dialog/DialogHeader.tsx +429 -0
  144. package/src/dialog/DialogMorph.tsx +576 -0
  145. package/src/dialog/index.ts +4 -0
  146. package/src/dialog/index.web.ts +4 -0
  147. package/src/dialog/types.ts +106 -0
  148. package/src/index.ts +3 -1
  149. package/src/index.web.ts +3 -1
  150. package/src/input-group/types.ts +1 -1
  151. package/src/react-native-className.d.ts +38 -0
  152. package/src/{search-input → search}/index.tsx +8 -5
  153. package/src/settings-list/SettingsList.tsx +6 -2
  154. package/src/surfaces/SurfaceHost.tsx +17 -1
  155. package/src/surfaces/types.ts +26 -1
  156. package/src/text-field/index.tsx +24 -5
  157. package/lib/commonjs/search-input/index.js.map +0 -1
  158. package/lib/module/search-input/index.js.map +0 -1
  159. package/lib/typescript/commonjs/search-input/index.d.ts.map +0 -1
  160. package/lib/typescript/module/search-input/index.d.ts.map +0 -1
@@ -12,7 +12,7 @@ import { useTheme } from '../theme/use-theme';
12
12
  import { useControllableState } from '../hooks/useControllableState';
13
13
  import { Text } from '../typography';
14
14
  import { Item } from '../item';
15
- import { SearchInput } from '../search-input';
15
+ import { Search } from '../search';
16
16
  import { borderRadius, fontSize, space } from '../styles/tokens';
17
17
  import {
18
18
  ChevronTopBottom_Stroke2_Corner0_Rounded as ChevronUpDownIcon,
@@ -55,7 +55,7 @@ function defaultFilter<T>(option: ComboboxOption<T>, query: string): boolean {
55
55
  * `.web` resolution. The combobox logic is single-source.
56
56
  *
57
57
  * `Combobox` is a filterable autocomplete select that delegates its overlay to
58
- * Bloom's `Popover` and composes `SearchInput` + `Item`. The trigger + panel
58
+ * Bloom's `Popover` and composes `Search` + `Item`. The trigger + panel
59
59
  * read the overlay control from `Popover` context (via an inner component) so
60
60
  * selecting an option reliably closes the overlay on every platform.
61
61
  */
@@ -153,7 +153,7 @@ export function createCombobox(PopoverImpl: PopoverModule) {
153
153
 
154
154
  <PopoverImpl.PopoverContent label={accessibleLabel} placement="bottom-start" minWidth={240}>
155
155
  <View style={styles.searchWrap}>
156
- <SearchInput
156
+ <Search
157
157
  ref={searchRef}
158
158
  label={label ?? 'Search'}
159
159
  value={query}
@@ -12,7 +12,7 @@ import { useControllableState } from '../hooks/useControllableState';
12
12
  import { Text } from '../typography';
13
13
  import { Item } from '../item';
14
14
  import { Kbd } from '../kbd';
15
- import { SearchInput } from '../search-input';
15
+ import { Search } from '../search';
16
16
  import { fontSize, space } from '../styles/tokens';
17
17
  import type { DialogProps } from '../dialog';
18
18
  import { useDialogControl } from '../dialog/context';
@@ -46,7 +46,7 @@ interface FlatEntry {
46
46
  * relies on implicit `.web` resolution. The body is single-source.
47
47
  *
48
48
  * `Command` is a ⌘K command palette built on `<Dialog placement="center">`,
49
- * `SearchInput`, an `Item` results list and `Kbd` shortcut hints. Items group
49
+ * `Search`, an `Item` results list and `Kbd` shortcut hints. Items group
50
50
  * by their `group` field (ungrouped first). On web the list is
51
51
  * keyboard-navigable (Up/Down/Enter); on native, tap to select.
52
52
  *
@@ -231,7 +231,7 @@ export function createCommand(Dialog: DialogComponent) {
231
231
  testID={testID}>
232
232
  <View {...webKeyHandler}>
233
233
  <View style={[styles.searchWrap, { borderBottomColor: theme.colors.borderLight }]}>
234
- <SearchInput
234
+ <Search
235
235
  ref={searchRef}
236
236
  label={placeholder}
237
237
  placeholder={placeholder}
@@ -30,6 +30,15 @@ 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';
41
+ import { DialogMorphContent, useDialogMorph } from './DialogMorph';
33
42
  import {
34
43
  ANIMATION_DURATION,
35
44
  DEFAULT_DIALOG_CONTENT_PADDING,
@@ -103,6 +112,9 @@ function CenteredOrSideDialog({
103
112
  title,
104
113
  description,
105
114
  actions,
115
+ header,
116
+ scrollable,
117
+ morph,
106
118
  placement,
107
119
  layer,
108
120
  width = DEFAULT_SIDE_WIDTH,
@@ -119,6 +131,21 @@ function CenteredOrSideDialog({
119
131
  }: Omit<DialogProps, 'placement'> & { placement: 'center' | 'left' | 'right' }) {
120
132
  const isControlled = controlledOpen !== undefined;
121
133
  const isSide = placement === 'left' || placement === 'right';
134
+ const headerController = useDialogHeaderController();
135
+ const scrollableResolved = scrollable ?? true;
136
+ const { height: viewportHeight } = useWindowDimensions();
137
+
138
+ // Size morphing across an in-place content swap. The detached centered card
139
+ // is content-hugging, so animating its height reshapes it about its own
140
+ // centre; the side placements are anchored top-to-bottom at a fixed width and
141
+ // have no size to morph, so they never pin (`measurable: false`). The card's
142
+ // own `maxHeight` (inside `BottomSheet`) does the exact capping — the viewport
143
+ // is just the bound the reshape may not exceed.
144
+ const morphState = useDialogMorph({
145
+ enabled: morph !== false && !isSide,
146
+ measurable: scrollableResolved && !isSide,
147
+ maxHeight: viewportHeight,
148
+ });
122
149
 
123
150
  // Imperative open state for the side placement (the BottomSheet path owns its
124
151
  // own visibility via its ref instead).
@@ -211,16 +238,21 @@ function CenteredOrSideDialog({
211
238
  [close],
212
239
  );
213
240
 
214
- const sheetStyle = useMemo(
215
- () => ({
216
- maxWidth: 500,
217
- backgroundColor: theme.colors.background,
218
- // All four corners rounded — bloom's BottomSheet defaults to top-only
219
- // radius in flush mode, but we use `detached` so the whole card is
220
- // floating and rounded uniformly.
221
- borderRadius: 20,
222
- }),
223
- [theme.colors.background],
241
+ const sheetStyle = useMemo<StyleProp<ViewStyle>>(
242
+ () => [
243
+ {
244
+ maxWidth: 500,
245
+ backgroundColor: theme.colors.background,
246
+ // All four corners rounded bloom's BottomSheet defaults to top-only
247
+ // radius in flush mode, but we use `detached` so the whole card is
248
+ // floating and rounded uniformly.
249
+ borderRadius: 20,
250
+ },
251
+ // Drives the card's `height` only while a morph is in flight; at rest it
252
+ // resolves to `height: 'auto'` — the card's normal content sizing.
253
+ morphState.panelStyle,
254
+ ],
255
+ [theme.colors.background, morphState.panelStyle],
224
256
  );
225
257
 
226
258
  const bodyNode = (
@@ -235,6 +267,24 @@ function CenteredOrSideDialog({
235
267
  </DialogBody>
236
268
  );
237
269
 
270
+ // Nav-header mode: the Dialog OWNS a sticky gradient nav bar + a large
271
+ // collapsing title over its own scroll content. The large title + screen body
272
+ // scroll under the bar, feeding the shared collapse controller. `contentPadding`
273
+ // is dropped (the large title + screens own their padding).
274
+ const headerBody = header ? (
275
+ scrollableResolved ? (
276
+ <>
277
+ <DialogLargeTitle controller={headerController} header={header} />
278
+ <DialogHeaderProvider controller={headerController}>{bodyNode}</DialogHeaderProvider>
279
+ </>
280
+ ) : (
281
+ <>
282
+ <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />
283
+ <DialogHeaderProvider controller={headerController}>{bodyNode}</DialogHeaderProvider>
284
+ </>
285
+ )
286
+ ) : null;
287
+
238
288
  if (isSide) {
239
289
  return (
240
290
  <Context.Provider value={context}>
@@ -253,6 +303,9 @@ function CenteredOrSideDialog({
253
303
  description={description}
254
304
  titleId={titleId}
255
305
  descriptionId={descriptionId}
306
+ header={header}
307
+ headerController={headerController}
308
+ onHeaderDismiss={close}
256
309
  panelStyle={panelStyle}
257
310
  panelClassName={panelClassName}
258
311
  containerStyle={containerStyle}
@@ -271,10 +324,26 @@ function CenteredOrSideDialog({
271
324
  onDismiss={handleDismiss}
272
325
  enablePanDownToClose
273
326
  detached
274
- showHandle
327
+ // Nav-header mode hides the drag handle (the nav bar sits at the very top)
328
+ // and coordinates the body pan with the scroll so content scrolls.
329
+ showHandle={!header}
330
+ manualActivation={!!header}
331
+ scrollY={header ? headerController.scrollY : undefined}
332
+ headerOverlay={
333
+ header ? (
334
+ <DialogNavHeader
335
+ controller={headerController}
336
+ header={header}
337
+ onDismiss={close}
338
+ collapse={scrollableResolved}
339
+ />
340
+ ) : undefined
341
+ }
275
342
  // Stronger dim when a Dialog is stacked over another sheet so the
276
343
  // underlying sheet's handle/content doesn't bleed through.
277
344
  backdropOpacity={0.7}
345
+ // Measures the floating card — the size a morph starts from.
346
+ onLayout={morphState.onPanelLayout}
278
347
  style={sheetStyle}
279
348
  >
280
349
  <Context.Provider value={context}>
@@ -288,13 +357,19 @@ function CenteredOrSideDialog({
288
357
  // Detached BottomSheet already adds `marginBottom: insets.bottom + 16`
289
358
  // to the sheet container — the floating card sits ABOVE the
290
359
  // system gesture bar, so we don't add `insets.bottom` here.
291
- { padding: contentPadding },
360
+ // Nav-header mode: the large title + screens own their padding.
361
+ header ? null : { padding: contentPadding },
292
362
  { backgroundColor: theme.colors.background },
293
363
  style,
294
364
  panelStyle,
295
365
  ]}
296
366
  >
297
- {bodyNode}
367
+ {/* The morph layer is its own node INSIDE the class-name'd container:
368
+ a className'd component's `onLayout` never fires on web, and this
369
+ one has to measure the incoming frame. */}
370
+ <DialogMorphContent morph={morphState}>
371
+ {header ? headerBody : bodyNode}
372
+ </DialogMorphContent>
298
373
  </View>
299
374
  </Context.Provider>
300
375
  </BottomSheet>
@@ -324,6 +399,9 @@ function SideSheet({
324
399
  description,
325
400
  titleId,
326
401
  descriptionId,
402
+ header,
403
+ headerController,
404
+ onHeaderDismiss,
327
405
  panelStyle,
328
406
  panelClassName,
329
407
  containerStyle,
@@ -345,6 +423,9 @@ function SideSheet({
345
423
  description?: string;
346
424
  titleId: string;
347
425
  descriptionId: string;
426
+ header?: DialogProps['header'];
427
+ headerController: DialogHeaderController;
428
+ onHeaderDismiss: () => void;
348
429
  panelStyle?: StyleProp<ViewStyle>;
349
430
  panelClassName?: string;
350
431
  containerStyle?: StyleProp<ViewStyle>;
@@ -476,7 +557,23 @@ function SideSheet({
476
557
  ]}
477
558
  pointerEvents="auto"
478
559
  >
479
- <View style={{ padding: contentPadding }}>{children}</View>
560
+ {header ? (
561
+ // Nav-header mode on a side drawer: a static titled bar (the drawer body
562
+ // has no Dialog scroll offset to drive a collapse) over content inset
563
+ // below the bar.
564
+ <View style={{ flex: 1, minHeight: 0 }}>
565
+ <DialogNavHeader
566
+ controller={headerController}
567
+ header={header}
568
+ onDismiss={onHeaderDismiss}
569
+ collapse={false}
570
+ />
571
+ <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />
572
+ <DialogHeaderProvider controller={headerController}>{children}</DialogHeaderProvider>
573
+ </View>
574
+ ) : (
575
+ <View style={{ padding: contentPadding }}>{children}</View>
576
+ )}
480
577
  </Animated.View>
481
578
  </View>
482
579
  );
@@ -10,24 +10,35 @@ import React, {
10
10
  } from 'react';
11
11
  import {
12
12
  Pressable,
13
+ ScrollView,
13
14
  useWindowDimensions,
14
15
  View,
15
16
  type StyleProp,
16
17
  type ViewStyle,
17
18
  } from 'react-native';
19
+ import Animated from 'react-native-reanimated';
18
20
  import { RemoveScrollBar } from 'react-remove-scroll-bar';
19
21
 
20
22
  import { Portal } from '../portal/index.web';
21
23
  import { createOverlayZIndex } from '../styles/z-index';
22
24
  import { useTheme } from '../theme/use-theme';
23
- import { Context, useDialogControl } from './context';
25
+ import { Context, useDialogContext, useDialogControl } from './context';
24
26
  import { DialogBody } from './DialogContent';
25
27
  import { DialogBottomSheet } from './DialogBottomSheet';
28
+ import {
29
+ DIALOG_NAV_BAR_HEIGHT,
30
+ DialogHeaderProvider,
31
+ DialogLargeTitle,
32
+ DialogNavHeader,
33
+ useDialogHeaderController,
34
+ } from './DialogHeader';
35
+ import { DialogMorphContent, useDialogMorph } from './DialogMorph';
26
36
  import {
27
37
  ANIMATION_DURATION,
28
38
  CENTER_FADE_OUT_DURATION,
29
39
  DEFAULT_CENTER_MAX_WIDTH,
30
40
  DEFAULT_DIALOG_CONTENT_PADDING,
41
+ DEFAULT_MAX_HEIGHT_RATIO,
31
42
  DEFAULT_SIDE_WIDTH,
32
43
  DIALOG_SHEET_BACKDROP_TESTID,
33
44
  EASE_OUT,
@@ -125,11 +136,13 @@ export function Dialog({ placement, ...rest }: DialogProps) {
125
136
  function CenterOrSideDialog({
126
137
  control,
127
138
  open: controlledOpen,
139
+ startOpen,
128
140
  onClose,
129
141
  testID,
130
142
  title,
131
143
  description,
132
144
  actions,
145
+ header,
133
146
  placement,
134
147
  layer,
135
148
  width = DEFAULT_SIDE_WIDTH,
@@ -137,6 +150,9 @@ function CenterOrSideDialog({
137
150
  inset,
138
151
  dismissOnBackdrop = true,
139
152
  contentPadding = DEFAULT_DIALOG_CONTENT_PADDING,
153
+ maxHeightRatio,
154
+ scrollable,
155
+ morph,
140
156
  style,
141
157
  panelStyle,
142
158
  panelClassName,
@@ -160,7 +176,13 @@ function CenterOrSideDialog({
160
176
  const isControlled = controlledOpen !== undefined;
161
177
  const resolvedPlacement = placement;
162
178
 
163
- const [isOpen, setIsOpen] = useState(false);
179
+ // Seed the initial visible state so a branch that mounts while the surface is
180
+ // already open (the imperative `startOpen` intent) or under a truthy
181
+ // controlled `open` renders open on its FIRST commit — this is what lets a
182
+ // responsive Dialog survive a placement swap (centered card ↔ bottom sheet on
183
+ // resize) without going blank. `startOpen` never flips the close semantics;
184
+ // the imperative `control.close()` still drives the exit + post-exit onClose.
185
+ const [isOpen, setIsOpen] = useState(() => controlledOpen ?? startOpen ?? false);
164
186
  const [isClosing, setIsClosing] = useState(false);
165
187
  const closeCallbacksRef = useRef<(() => void)[]>([]);
166
188
  // Read the latest controlled flag inside stable callbacks without re-binding.
@@ -299,9 +321,13 @@ function CenterOrSideDialog({
299
321
  title={title}
300
322
  description={description}
301
323
  actions={actions}
324
+ header={header}
302
325
  style={style}
303
326
  maxWidth={maxWidth}
304
327
  contentPadding={contentPadding}
328
+ maxHeightRatio={maxHeightRatio}
329
+ scrollable={scrollable}
330
+ morph={morph}
305
331
  surfaceZIndex={dialogZIndex.surface}
306
332
  isClosing={isClosing}
307
333
  >
@@ -325,6 +351,8 @@ function CenterOrSideDialog({
325
351
  title={title}
326
352
  description={description}
327
353
  actions={actions}
354
+ header={header}
355
+ scrollable={scrollable}
328
356
  placement={resolvedPlacement}
329
357
  shown={!isClosing}
330
358
  width={width}
@@ -354,9 +382,13 @@ function DialogPanel({
354
382
  title,
355
383
  description,
356
384
  actions,
385
+ header,
357
386
  style,
358
387
  maxWidth,
359
388
  contentPadding,
389
+ maxHeightRatio,
390
+ scrollable,
391
+ morph,
360
392
  surfaceZIndex,
361
393
  isClosing,
362
394
  children,
@@ -366,24 +398,56 @@ function DialogPanel({
366
398
  title?: string;
367
399
  description?: string;
368
400
  actions?: DialogAction[];
401
+ header?: DialogProps['header'];
369
402
  style?: DialogProps['style'];
370
403
  maxWidth: number;
371
404
  contentPadding: number;
405
+ maxHeightRatio?: number;
406
+ scrollable?: boolean;
407
+ morph?: boolean;
372
408
  surfaceZIndex: number;
373
409
  isClosing: boolean;
374
410
  children?: React.ReactNode;
375
411
  }) {
376
412
  const theme = useTheme();
413
+ const { close } = useDialogContext();
377
414
  const titleId = useId();
378
415
  const descriptionId = useId();
416
+ const headerController = useDialogHeaderController();
417
+ const { height: viewportHeight } = useWindowDimensions();
418
+ const heightRatio = maxHeightRatio ?? DEFAULT_MAX_HEIGHT_RATIO;
419
+
420
+ // Size morphing across an in-place content swap. The centered card is the one
421
+ // placement whose width can vary too, so `maxWidth` is handed over as well.
422
+ // The card's own `maxHeight` (the ratio percentage below) does the exact
423
+ // capping — the viewport is just the bound the reshape may not exceed.
424
+ const morphState = useDialogMorph({
425
+ enabled: morph !== false,
426
+ measurable: scrollable !== false,
427
+ maxHeight: viewportHeight,
428
+ maxWidth,
429
+ });
430
+
431
+ const body = (
432
+ <DialogBody
433
+ titleId={titleId}
434
+ descriptionId={descriptionId}
435
+ title={title}
436
+ description={description}
437
+ actions={actions}
438
+ >
439
+ {children}
440
+ </DialogBody>
441
+ );
379
442
 
380
443
  return (
381
- <View
444
+ <Animated.View
382
445
  role="dialog"
383
446
  aria-label={label}
384
447
  aria-labelledby={title ? titleId : undefined}
385
448
  aria-describedby={description ? descriptionId : undefined}
386
449
  testID={testID}
450
+ onLayout={morphState.onPanelLayout}
387
451
  onStartShouldSetResponder={() => true}
388
452
  onResponderRelease={stopPropagation}
389
453
  {...({ onClick: stopPropagation } as Record<string, unknown>)}
@@ -393,6 +457,13 @@ function DialogPanel({
393
457
  borderRadius: 20,
394
458
  width: '100%',
395
459
  maxWidth,
460
+ // The Dialog OWNS the size cap + scroll boundary (its content renders
461
+ // bare, edge-to-edge). Short content stays natural; tall content is
462
+ // capped at `maxHeightRatio` of the viewport and scrolls inside the
463
+ // rounded card (via the ScrollView below) — consumers never add their
464
+ // own height cap / ScrollView. `overflow: hidden` clips to the radius.
465
+ maxHeight: `${Math.round(heightRatio * 100)}%`,
466
+ overflow: 'hidden',
396
467
  backgroundColor: theme.colors.background,
397
468
  borderWidth: 1,
398
469
  borderColor: theme.isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
@@ -400,25 +471,74 @@ function DialogPanel({
400
471
  shadowOpacity: theme.isDark ? 0.4 : 0.1,
401
472
  shadowRadius: 30,
402
473
  shadowOffset: { width: 0, height: 4 },
403
- padding: contentPadding,
404
474
  zIndex: surfaceZIndex,
405
475
  },
406
476
  isClosing
407
477
  ? ({ animation: `bloomDialogZoomFadeOut ease-in ${FADE_OUT_DURATION}ms forwards` } as ViewStyle)
408
478
  : ({ animation: 'bloomDialogZoomFadeIn cubic-bezier(0.16, 1, 0.3, 1) 0.3s' } as ViewStyle),
479
+ // Drives `height` (and `maxWidth`) only while a morph is in flight; at
480
+ // rest it resolves to `height: 'auto'` — the natural sizing above. Placed
481
+ // before `style` so a consumer's explicit size still wins.
482
+ morphState.panelStyle,
409
483
  style,
410
484
  ]}
411
485
  >
412
- <DialogBody
413
- titleId={titleId}
414
- descriptionId={descriptionId}
415
- title={title}
416
- description={description}
417
- actions={actions}
418
- >
419
- {children}
420
- </DialogBody>
421
- </View>
486
+ {header ? (
487
+ // Nav-header mode: the Dialog OWNS a sticky gradient nav bar + a large
488
+ // collapsing title over its own scroll content (see `DialogHeader`). The
489
+ // bar overlays; the large title + screen body scroll under it, feeding
490
+ // the shared scroll offset that drives the small-title cross-fade.
491
+ <>
492
+ <DialogNavHeader
493
+ controller={headerController}
494
+ header={header}
495
+ onDismiss={close}
496
+ collapse={scrollable !== false}
497
+ />
498
+ {scrollable === false ? (
499
+ <DialogMorphContent morph={morphState} style={{ flex: 1, minHeight: 0 }}>
500
+ <View style={{ height: DIALOG_NAV_BAR_HEIGHT }} />
501
+ <DialogHeaderProvider controller={headerController}>{body}</DialogHeaderProvider>
502
+ </DialogMorphContent>
503
+ ) : (
504
+ <ScrollView
505
+ style={{ flexShrink: 1 }}
506
+ contentContainerStyle={{ paddingBottom: contentPadding }}
507
+ showsVerticalScrollIndicator={false}
508
+ scrollEventThrottle={16}
509
+ onScroll={(e) => {
510
+ headerController.scrollY.value = e.nativeEvent.contentOffset.y;
511
+ }}
512
+ >
513
+ <DialogMorphContent morph={morphState}>
514
+ <DialogLargeTitle controller={headerController} header={header} />
515
+ <DialogHeaderProvider controller={headerController}>{body}</DialogHeaderProvider>
516
+ </DialogMorphContent>
517
+ </ScrollView>
518
+ )}
519
+ </>
520
+ ) : scrollable === false ? (
521
+ // The content owns its own scrolling (a FlatList / VirtualizedList or a
522
+ // nested ScrollView). Render it bare in a bounded flex box — the card's
523
+ // `maxHeight` + `overflow: hidden` cap it and `flex: 1` + `minHeight: 0`
524
+ // hand the child a bounded height to scroll into — with NO wrapping
525
+ // ScrollView, so a VirtualizedList never nests inside one.
526
+ <DialogMorphContent
527
+ morph={morphState}
528
+ style={{ flex: 1, minHeight: 0, padding: contentPadding }}
529
+ >
530
+ {body}
531
+ </DialogMorphContent>
532
+ ) : (
533
+ <ScrollView
534
+ style={{ flexShrink: 1 }}
535
+ contentContainerStyle={{ padding: contentPadding }}
536
+ showsVerticalScrollIndicator={false}
537
+ >
538
+ <DialogMorphContent morph={morphState}>{body}</DialogMorphContent>
539
+ </ScrollView>
540
+ )}
541
+ </Animated.View>
422
542
  );
423
543
  }
424
544
 
@@ -439,6 +559,8 @@ function SheetSurface({
439
559
  title,
440
560
  description,
441
561
  actions,
562
+ header,
563
+ scrollable,
442
564
  placement,
443
565
  shown,
444
566
  width,
@@ -460,6 +582,8 @@ function SheetSurface({
460
582
  title?: string;
461
583
  description?: string;
462
584
  actions?: DialogAction[];
585
+ header?: DialogProps['header'];
586
+ scrollable?: boolean;
463
587
  placement: 'left' | 'right';
464
588
  shown: boolean;
465
589
  width: number;
@@ -479,6 +603,7 @@ function SheetSurface({
479
603
  const theme = useTheme();
480
604
  const titleId = useId();
481
605
  const descriptionId = useId();
606
+ const headerController = useDialogHeaderController();
482
607
  const { width: viewportWidth } = useWindowDimensions();
483
608
 
484
609
  // Defer the entry transition by a frame so the start state paints before the
@@ -588,17 +713,53 @@ function SheetSurface({
588
713
  ]}
589
714
  pointerEvents="auto"
590
715
  >
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>
716
+ {header ? (
717
+ // Nav-header mode on a side drawer: a static titled bar (the drawer
718
+ // body does not own a Dialog scroll offset to drive a collapse) over
719
+ // the content, which is inset below the bar.
720
+ <View style={{ flex: 1, minHeight: 0 }}>
721
+ <DialogNavHeader
722
+ controller={headerController}
723
+ header={header}
724
+ onDismiss={onDismiss}
725
+ collapse={false}
726
+ />
727
+ <ScrollView
728
+ style={{ flexShrink: 1 }}
729
+ contentContainerStyle={{ paddingTop: DIALOG_NAV_BAR_HEIGHT, paddingBottom: contentPadding }}
730
+ showsVerticalScrollIndicator={false}
731
+ scrollEventThrottle={16}
732
+ scrollEnabled={scrollable !== false}
733
+ onScroll={(e) => {
734
+ headerController.scrollY.value = e.nativeEvent.contentOffset.y;
735
+ }}
736
+ >
737
+ <DialogHeaderProvider controller={headerController}>
738
+ <DialogBody
739
+ titleId={titleId}
740
+ descriptionId={descriptionId}
741
+ title={title}
742
+ description={description}
743
+ actions={actions}
744
+ >
745
+ {children}
746
+ </DialogBody>
747
+ </DialogHeaderProvider>
748
+ </ScrollView>
749
+ </View>
750
+ ) : (
751
+ <View style={{ padding: contentPadding }}>
752
+ <DialogBody
753
+ titleId={titleId}
754
+ descriptionId={descriptionId}
755
+ title={title}
756
+ description={description}
757
+ actions={actions}
758
+ >
759
+ {children}
760
+ </DialogBody>
761
+ </View>
762
+ )}
602
763
  </View>
603
764
  </View>
604
765
  );