@oxyhq/bloom 0.16.1 → 0.16.2

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 (39) hide show
  1. package/lib/commonjs/dialog/Dialog.js +7 -7
  2. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  3. package/lib/commonjs/dialog/Dialog.web.js +9 -5
  4. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  5. package/lib/commonjs/dialog/DialogBottomSheet.js +18 -7
  6. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  7. package/lib/commonjs/dialog/placement.js +12 -1
  8. package/lib/commonjs/dialog/placement.js.map +1 -1
  9. package/lib/module/dialog/Dialog.js +8 -8
  10. package/lib/module/dialog/Dialog.js.map +1 -1
  11. package/lib/module/dialog/Dialog.web.js +10 -6
  12. package/lib/module/dialog/Dialog.web.js.map +1 -1
  13. package/lib/module/dialog/DialogBottomSheet.js +19 -8
  14. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  15. package/lib/module/dialog/placement.js +11 -0
  16. package/lib/module/dialog/placement.js.map +1 -1
  17. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  18. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +2 -2
  20. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/dialog/placement.d.ts +10 -0
  22. package/lib/typescript/commonjs/dialog/placement.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/dialog/types.d.ts +12 -0
  24. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  25. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  26. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  27. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +2 -2
  28. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  29. package/lib/typescript/module/dialog/placement.d.ts +10 -0
  30. package/lib/typescript/module/dialog/placement.d.ts.map +1 -1
  31. package/lib/typescript/module/dialog/types.d.ts +12 -0
  32. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  33. package/package.json +1 -1
  34. package/src/__tests__/DialogBottomSheet.test.tsx +129 -0
  35. package/src/dialog/Dialog.tsx +7 -5
  36. package/src/dialog/Dialog.web.tsx +10 -5
  37. package/src/dialog/DialogBottomSheet.tsx +22 -7
  38. package/src/dialog/placement.ts +11 -0
  39. package/src/dialog/types.ts +12 -0
@@ -5,6 +5,34 @@ import { act, fireEvent, render } from '@testing-library/react-native';
5
5
  import { Dialog, useDialogControl } from '../dialog';
6
6
  import { BloomThemeProvider } from '../theme/BloomThemeProvider';
7
7
 
8
+ /**
9
+ * Minimal shape of a `react-test-renderer` JSON node — enough to walk the tree
10
+ * by host `type` string without depending on `@types/react-test-renderer`.
11
+ */
12
+ type RenderedNode = {
13
+ type: string;
14
+ children: Array<RenderedNode | string> | null;
15
+ };
16
+
17
+ /** Count host nodes of a given `type` in a rendered `toJSON()` tree. */
18
+ function countNodesByType(
19
+ tree: RenderedNode | RenderedNode[] | null,
20
+ type: string,
21
+ ): number {
22
+ if (tree === null) return 0;
23
+ const roots = Array.isArray(tree) ? tree : [tree];
24
+ let count = 0;
25
+ for (const node of roots) {
26
+ if (node.type === type) count += 1;
27
+ if (node.children) {
28
+ for (const child of node.children) {
29
+ if (typeof child !== 'string') count += countNodesByType(child, type);
30
+ }
31
+ }
32
+ }
33
+ return count;
34
+ }
35
+
8
36
  /**
9
37
  * The Dialog `bottom` placement must delegate to the shared cross-platform
10
38
  * `BottomSheet` (via `DialogBottomSheet`) — the SAME surface native and web use
@@ -127,4 +155,105 @@ describe('Dialog bottom placement delegates to BottomSheet', () => {
127
155
  expect(flattened.backgroundColor).toBe('rebeccapurple');
128
156
  expect(flattened.paddingTop).toBe(7);
129
157
  });
158
+
159
+ it('applies the default 20px content padding when contentPadding is omitted', () => {
160
+ let control: ReturnType<typeof useDialogControl> | undefined;
161
+ const { getByTestId } = renderWithTheme(
162
+ <Harness>
163
+ {(c) => {
164
+ control = c;
165
+ return (
166
+ <Dialog control={c} placement="bottom" testID="default-pad">
167
+ <Text>Body</Text>
168
+ </Dialog>
169
+ );
170
+ }}
171
+ </Harness>,
172
+ );
173
+ act(() => {
174
+ control?.open();
175
+ });
176
+ const content = getByTestId('default-pad');
177
+ const flattened = Array.isArray(content.props.style)
178
+ ? Object.assign({}, ...content.props.style.filter(Boolean))
179
+ : content.props.style;
180
+ // Omitting `contentPadding` keeps the legacy 20px chrome padding.
181
+ expect(flattened.padding).toBe(20);
182
+ });
183
+
184
+ it('removes the body padding when contentPadding={0} (host owns its insets)', () => {
185
+ let control: ReturnType<typeof useDialogControl> | undefined;
186
+ const { getByTestId } = renderWithTheme(
187
+ <Harness>
188
+ {(c) => {
189
+ control = c;
190
+ return (
191
+ <Dialog control={c} placement="bottom" testID="flush-pad" contentPadding={0}>
192
+ <Text>Flush body</Text>
193
+ </Dialog>
194
+ );
195
+ }}
196
+ </Harness>,
197
+ );
198
+ act(() => {
199
+ control?.open();
200
+ });
201
+ const content = getByTestId('flush-pad');
202
+ const flattened = Array.isArray(content.props.style)
203
+ ? Object.assign({}, ...content.props.style.filter(Boolean))
204
+ : content.props.style;
205
+ // `contentPadding={0}` yields flush content the host pads itself.
206
+ expect(flattened.padding).toBe(0);
207
+ });
208
+
209
+ it('does NOT wrap pure custom children in a second scroll container (no scroll-in-scroll)', () => {
210
+ let control: ReturnType<typeof useDialogControl> | undefined;
211
+ const result = renderWithTheme(
212
+ <Harness>
213
+ {(c) => {
214
+ control = c;
215
+ return (
216
+ <Dialog control={c} placement="bottom">
217
+ <Text>Custom children own their own scrolling</Text>
218
+ </Dialog>
219
+ );
220
+ }}
221
+ </Harness>,
222
+ );
223
+ act(() => {
224
+ control?.open();
225
+ });
226
+ // Pure custom children → DialogBottomSheet passes `scrollable={false}` to
227
+ // BottomSheet, so the sheet does NOT add its internal ScrollView. A child
228
+ // that contains its own scroller is then the ONLY scroll container.
229
+ const tree: RenderedNode | RenderedNode[] | null = result.toJSON();
230
+ expect(countNodesByType(tree, 'Animated.ScrollView')).toBe(0);
231
+ });
232
+
233
+ it('keeps the internal scrollable body for declarative dialogs (title/description/actions)', () => {
234
+ let control: ReturnType<typeof useDialogControl> | undefined;
235
+ const result = renderWithTheme(
236
+ <Harness>
237
+ {(c) => {
238
+ control = c;
239
+ return (
240
+ <Dialog
241
+ control={c}
242
+ placement="bottom"
243
+ title="Confirm"
244
+ description="Short declarative body."
245
+ actions={[{ label: 'OK' }]}
246
+ />
247
+ );
248
+ }}
249
+ </Harness>,
250
+ );
251
+ act(() => {
252
+ control?.open();
253
+ });
254
+ // Declarative chrome → the default scrollable body is retained, so a long
255
+ // title/description list still scrolls gracefully on a small viewport.
256
+ const tree: RenderedNode | RenderedNode[] | null = result.toJSON();
257
+ expect(countNodesByType(tree, 'Animated.ScrollView')).toBe(1);
258
+ });
130
259
  });
@@ -32,6 +32,7 @@ import { DialogBody } from './DialogContent';
32
32
  import { DialogBottomSheet } from './DialogBottomSheet';
33
33
  import {
34
34
  ANIMATION_DURATION,
35
+ DEFAULT_DIALOG_CONTENT_PADDING,
35
36
  DEFAULT_SIDE_WIDTH,
36
37
  DIALOG_SHEET_BACKDROP_TESTID,
37
38
  PANEL_RADIUS,
@@ -106,6 +107,7 @@ function CenteredOrSideDialog({
106
107
  width = DEFAULT_SIDE_WIDTH,
107
108
  inset,
108
109
  dismissOnBackdrop = true,
110
+ contentPadding = DEFAULT_DIALOG_CONTENT_PADDING,
109
111
  style,
110
112
  panelStyle,
111
113
  panelClassName,
@@ -242,6 +244,7 @@ function CenteredOrSideDialog({
242
244
  width={width}
243
245
  inset={inset}
244
246
  dismissOnBackdrop={dismissOnBackdrop}
247
+ contentPadding={contentPadding}
245
248
  testID={testID}
246
249
  label={label}
247
250
  title={title}
@@ -283,7 +286,7 @@ function CenteredOrSideDialog({
283
286
  // Detached BottomSheet already adds `marginBottom: insets.bottom + 16`
284
287
  // to the sheet container — the floating card sits ABOVE the
285
288
  // system gesture bar, so we don't add `insets.bottom` here.
286
- { paddingTop: 20, paddingHorizontal: 20, paddingBottom: 20 },
289
+ { padding: contentPadding },
287
290
  { backgroundColor: theme.colors.background },
288
291
  style,
289
292
  panelStyle,
@@ -311,6 +314,7 @@ function SideSheet({
311
314
  width,
312
315
  inset,
313
316
  dismissOnBackdrop,
317
+ contentPadding,
314
318
  testID,
315
319
  label,
316
320
  title,
@@ -330,6 +334,7 @@ function SideSheet({
330
334
  width: number;
331
335
  inset?: DialogInset;
332
336
  dismissOnBackdrop: boolean;
337
+ contentPadding: number;
333
338
  testID?: string;
334
339
  label?: string;
335
340
  title?: string;
@@ -461,7 +466,7 @@ function SideSheet({
461
466
  ]}
462
467
  pointerEvents="auto"
463
468
  >
464
- <View style={sideStyles.body}>{children}</View>
469
+ <View style={{ padding: contentPadding }}>{children}</View>
465
470
  </Animated.View>
466
471
  </View>
467
472
  );
@@ -484,9 +489,6 @@ const sideStyles = StyleSheet.create({
484
489
  shadowOffset: { width: 0, height: 8 },
485
490
  elevation: 12,
486
491
  },
487
- body: {
488
- padding: 20,
489
- },
490
492
  });
491
493
 
492
494
  /**
@@ -27,6 +27,7 @@ import {
27
27
  ANIMATION_DURATION,
28
28
  CENTER_FADE_OUT_DURATION,
29
29
  DEFAULT_CENTER_MAX_WIDTH,
30
+ DEFAULT_DIALOG_CONTENT_PADDING,
30
31
  DEFAULT_SIDE_WIDTH,
31
32
  DIALOG_SHEET_BACKDROP_TESTID,
32
33
  EASE_OUT,
@@ -106,6 +107,7 @@ function CenterOrSideDialog({
106
107
  maxWidth = DEFAULT_CENTER_MAX_WIDTH,
107
108
  inset,
108
109
  dismissOnBackdrop = true,
110
+ contentPadding = DEFAULT_DIALOG_CONTENT_PADDING,
109
111
  style,
110
112
  panelStyle,
111
113
  panelClassName,
@@ -260,6 +262,7 @@ function CenterOrSideDialog({
260
262
  actions={actions}
261
263
  style={style}
262
264
  maxWidth={maxWidth}
265
+ contentPadding={contentPadding}
263
266
  isClosing={isClosing}
264
267
  >
265
268
  {children}
@@ -287,6 +290,7 @@ function CenterOrSideDialog({
287
290
  width={width}
288
291
  inset={inset}
289
292
  dismissOnBackdrop={dismissOnBackdrop}
293
+ contentPadding={contentPadding}
290
294
  onDismiss={close}
291
295
  panelStyle={panelStyle}
292
296
  panelClassName={panelClassName}
@@ -310,6 +314,7 @@ function DialogPanel({
310
314
  actions,
311
315
  style,
312
316
  maxWidth,
317
+ contentPadding,
313
318
  isClosing,
314
319
  children,
315
320
  }: {
@@ -320,6 +325,7 @@ function DialogPanel({
320
325
  actions?: DialogAction[];
321
326
  style?: DialogProps['style'];
322
327
  maxWidth: number;
328
+ contentPadding: number;
323
329
  isClosing: boolean;
324
330
  children?: React.ReactNode;
325
331
  }) {
@@ -350,7 +356,7 @@ function DialogPanel({
350
356
  shadowOpacity: theme.isDark ? 0.4 : 0.1,
351
357
  shadowRadius: 30,
352
358
  shadowOffset: { width: 0, height: 4 },
353
- padding: 20,
359
+ padding: contentPadding,
354
360
  zIndex: dialogZIndex.surface,
355
361
  },
356
362
  isClosing
@@ -394,6 +400,7 @@ function SheetSurface({
394
400
  width,
395
401
  inset,
396
402
  dismissOnBackdrop,
403
+ contentPadding,
397
404
  onDismiss,
398
405
  panelStyle,
399
406
  panelClassName,
@@ -412,6 +419,7 @@ function SheetSurface({
412
419
  width: number;
413
420
  inset?: DialogInset;
414
421
  dismissOnBackdrop: boolean;
422
+ contentPadding: number;
415
423
  onDismiss: () => void;
416
424
  panelStyle?: StyleProp<ViewStyle>;
417
425
  panelClassName?: string;
@@ -528,7 +536,7 @@ function SheetSurface({
528
536
  ]}
529
537
  pointerEvents="auto"
530
538
  >
531
- <View style={sheetStyles.body}>
539
+ <View style={{ padding: contentPadding }}>
532
540
  <DialogBody
533
541
  titleId={titleId}
534
542
  descriptionId={descriptionId}
@@ -641,9 +649,6 @@ const sheetStyles = {
641
649
  shadowOffset: { width: 0, height: 8 },
642
650
  zIndex: dialogZIndex.surface,
643
651
  } as ViewStyle,
644
- body: {
645
- padding: 20,
646
- } as ViewStyle,
647
652
  };
648
653
 
649
654
  /**
@@ -13,18 +13,13 @@ import { useTheme } from '../theme/use-theme';
13
13
  import { Context } from './context';
14
14
  import { DialogBody } from './DialogContent';
15
15
  import {
16
+ DEFAULT_DIALOG_CONTENT_PADDING,
16
17
  DEFAULT_MAX_HEIGHT_RATIO,
17
18
  PANEL_RADIUS,
18
19
  SHEET_BACKDROP_OPACITY,
19
20
  } from './placement';
20
21
  import type { DialogControlProps, DialogProps } from './types';
21
22
 
22
- /**
23
- * Padding of the bottom-sheet content container (px). Matches the side-sheet
24
- * and centered-panel body padding so every placement reads identically.
25
- */
26
- const SHEET_CONTENT_PADDING = 20;
27
-
28
23
  /**
29
24
  * Shared `BottomSheet`-backed surface for the `bottom` placement, used by BOTH
30
25
  * `Dialog.tsx` (native) and `Dialog.web.tsx` (web). `BottomSheet` is itself a
@@ -56,6 +51,7 @@ export function DialogBottomSheet({
56
51
  maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
57
52
  showHandle = true,
58
53
  dismissOnBackdrop = true,
54
+ contentPadding = DEFAULT_DIALOG_CONTENT_PADDING,
59
55
  style,
60
56
  panelStyle,
61
57
  panelClassName,
@@ -164,6 +160,21 @@ export function DialogBottomSheet({
164
160
  ];
165
161
  }, [theme.colors.background, maxHeightRatio, panelStyle]);
166
162
 
163
+ // 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.
173
+ const hasDeclarativeChrome =
174
+ title !== undefined ||
175
+ description !== undefined ||
176
+ (actions !== undefined && actions.length > 0);
177
+
167
178
  return (
168
179
  <BottomSheet
169
180
  ref={ref}
@@ -171,6 +182,9 @@ export function DialogBottomSheet({
171
182
  onDismissAttempt={onDismissAttempt}
172
183
  enablePanDownToClose
173
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}
174
188
  // Stronger dim than a lone sheet so an underlying sheet's handle/content
175
189
  // doesn't bleed through when a Dialog is stacked over one.
176
190
  backdropOpacity={SHEET_BACKDROP_OPACITY + 0.3}
@@ -185,7 +199,7 @@ export function DialogBottomSheet({
185
199
  {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
186
200
  {...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
187
201
  style={[
188
- { padding: SHEET_CONTENT_PADDING },
202
+ { padding: contentPadding },
189
203
  // `containerStyle` carries the host's CSS-var theme scope; it wraps
190
204
  // the content subtree so descendants read the scoped palette.
191
205
  containerStyle,
@@ -225,6 +239,7 @@ export type DialogBottomSheetProps = Pick<
225
239
  | 'maxHeightRatio'
226
240
  | 'showHandle'
227
241
  | 'dismissOnBackdrop'
242
+ | 'contentPadding'
228
243
  | 'style'
229
244
  | 'panelStyle'
230
245
  | 'panelClassName'
@@ -60,6 +60,17 @@ export const DEFAULT_MAX_HEIGHT_RATIO = 0.9;
60
60
  /** Corner radius shared by the side-sheet panel and the bottom-sheet top. */
61
61
  export const PANEL_RADIUS = 20;
62
62
 
63
+ /**
64
+ * Default inner padding (px) of the dialog content container, applied uniformly
65
+ * across every placement (centered panel, side-sheet body, bottom-sheet body)
66
+ * so the chrome reads identically regardless of which surface resolves. This is
67
+ * the legacy value — omitting the `contentPadding` prop keeps 20px everywhere,
68
+ * byte-for-byte unchanged. A host that owns its own insets (e.g. custom
69
+ * `children` that already manage padding) can override it via
70
+ * `Dialog`'s `contentPadding` prop (e.g. `contentPadding={0}`).
71
+ */
72
+ export const DEFAULT_DIALOG_CONTENT_PADDING = 20;
73
+
63
74
  /** Open/close transition duration (ms). ~280ms ease-out reads smooth. */
64
75
  export const ANIMATION_DURATION = 280;
65
76
 
@@ -134,6 +134,18 @@ export type DialogProps = React.PropsWithChildren<{
134
134
  inset?: DialogInset;
135
135
  /** Whether to render the drag handle in bottom-sheet mode. Defaults to `true`. */
136
136
  showHandle?: boolean;
137
+ /**
138
+ * Inner padding (px) of the dialog content container, applied uniformly across
139
+ * every placement (centered panel, side-sheet body, bottom-sheet body).
140
+ * Defaults to `20`.
141
+ *
142
+ * The default chrome padding is correct for the declarative
143
+ * `title`/`description`/`actions` layout, but a host passing pure custom
144
+ * `children` that already own their insets can set `contentPadding={0}` to
145
+ * render flush content and manage padding itself. Omitting the prop keeps the
146
+ * 20px default on every surface.
147
+ */
148
+ contentPadding?: number;
137
149
  /** Whether tapping the backdrop dismisses the dialog. Defaults to `true`. */
138
150
  dismissOnBackdrop?: boolean;
139
151
  /**