@oxyhq/bloom 0.13.0 → 0.15.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 (140) hide show
  1. package/lib/commonjs/dialog/CenteredDialog.js +2 -0
  2. package/lib/commonjs/dialog/CenteredDialog.js.map +1 -1
  3. package/lib/commonjs/dialog/CenteredDialog.web.js +2 -0
  4. package/lib/commonjs/dialog/CenteredDialog.web.js.map +1 -1
  5. package/lib/commonjs/dialog/Dialog.js +337 -43
  6. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  7. package/lib/commonjs/dialog/Dialog.web.js +414 -63
  8. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  9. package/lib/commonjs/dialog/index.js +6 -0
  10. package/lib/commonjs/dialog/index.js.map +1 -1
  11. package/lib/commonjs/dialog/index.web.js +6 -0
  12. package/lib/commonjs/dialog/index.web.js.map +1 -1
  13. package/lib/commonjs/dialog/placement.js +104 -0
  14. package/lib/commonjs/dialog/placement.js.map +1 -0
  15. package/lib/commonjs/index.js +22 -0
  16. package/lib/commonjs/index.js.map +1 -1
  17. package/lib/commonjs/index.web.js +28 -6
  18. package/lib/commonjs/index.web.js.map +1 -1
  19. package/lib/commonjs/responsive-sheet/ResponsiveSheet.js +97 -0
  20. package/lib/commonjs/responsive-sheet/ResponsiveSheet.js.map +1 -0
  21. package/lib/commonjs/responsive-sheet/ResponsiveSheet.web.js +84 -0
  22. package/lib/commonjs/responsive-sheet/ResponsiveSheet.web.js.map +1 -0
  23. package/lib/commonjs/responsive-sheet/index.js +19 -0
  24. package/lib/commonjs/responsive-sheet/index.js.map +1 -0
  25. package/lib/commonjs/responsive-sheet/index.web.js +19 -0
  26. package/lib/commonjs/responsive-sheet/index.web.js.map +1 -0
  27. package/lib/commonjs/responsive-sheet/types.js +6 -0
  28. package/lib/commonjs/responsive-sheet/types.js.map +1 -0
  29. package/lib/module/dialog/CenteredDialog.js +2 -0
  30. package/lib/module/dialog/CenteredDialog.js.map +1 -1
  31. package/lib/module/dialog/CenteredDialog.web.js +2 -0
  32. package/lib/module/dialog/CenteredDialog.web.js.map +1 -1
  33. package/lib/module/dialog/Dialog.js +336 -46
  34. package/lib/module/dialog/Dialog.js.map +1 -1
  35. package/lib/module/dialog/Dialog.web.js +412 -65
  36. package/lib/module/dialog/Dialog.web.js.map +1 -1
  37. package/lib/module/dialog/index.js +1 -1
  38. package/lib/module/dialog/index.js.map +1 -1
  39. package/lib/module/dialog/index.web.js +1 -1
  40. package/lib/module/dialog/index.web.js.map +1 -1
  41. package/lib/module/dialog/placement.js +100 -0
  42. package/lib/module/dialog/placement.js.map +1 -0
  43. package/lib/module/index.js +3 -1
  44. package/lib/module/index.js.map +1 -1
  45. package/lib/module/index.web.js +3 -1
  46. package/lib/module/index.web.js.map +1 -1
  47. package/lib/module/responsive-sheet/ResponsiveSheet.js +90 -0
  48. package/lib/module/responsive-sheet/ResponsiveSheet.js.map +1 -0
  49. package/lib/module/responsive-sheet/ResponsiveSheet.web.js +77 -0
  50. package/lib/module/responsive-sheet/ResponsiveSheet.web.js.map +1 -0
  51. package/lib/module/responsive-sheet/index.js +4 -0
  52. package/lib/module/responsive-sheet/index.js.map +1 -0
  53. package/lib/module/responsive-sheet/index.web.js +12 -0
  54. package/lib/module/responsive-sheet/index.web.js.map +1 -0
  55. package/lib/module/responsive-sheet/types.js +4 -0
  56. package/lib/module/responsive-sheet/types.js.map +1 -0
  57. package/lib/typescript/commonjs/dialog/CenteredDialog.d.ts +2 -0
  58. package/lib/typescript/commonjs/dialog/CenteredDialog.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/dialog/CenteredDialog.web.d.ts +2 -0
  60. package/lib/typescript/commonjs/dialog/CenteredDialog.web.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/dialog/Dialog.d.ts +18 -15
  62. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +18 -5
  64. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/dialog/centered-dialog-types.d.ts +4 -0
  66. package/lib/typescript/commonjs/dialog/centered-dialog-types.d.ts.map +1 -1
  67. package/lib/typescript/commonjs/dialog/index.d.ts +2 -2
  68. package/lib/typescript/commonjs/dialog/index.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/dialog/index.web.d.ts +2 -2
  70. package/lib/typescript/commonjs/dialog/index.web.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/dialog/placement.d.ts +71 -0
  72. package/lib/typescript/commonjs/dialog/placement.d.ts.map +1 -0
  73. package/lib/typescript/commonjs/dialog/types.d.ts +66 -3
  74. package/lib/typescript/commonjs/dialog/types.d.ts.map +1 -1
  75. package/lib/typescript/commonjs/index.d.ts +4 -2
  76. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/index.web.d.ts +4 -2
  78. package/lib/typescript/commonjs/index.web.d.ts.map +1 -1
  79. package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.d.ts +22 -0
  80. package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.d.ts.map +1 -0
  81. package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.web.d.ts +16 -0
  82. package/lib/typescript/commonjs/responsive-sheet/ResponsiveSheet.web.d.ts.map +1 -0
  83. package/lib/typescript/commonjs/responsive-sheet/index.d.ts +3 -0
  84. package/lib/typescript/commonjs/responsive-sheet/index.d.ts.map +1 -0
  85. package/lib/typescript/commonjs/responsive-sheet/index.web.d.ts +3 -0
  86. package/lib/typescript/commonjs/responsive-sheet/index.web.d.ts.map +1 -0
  87. package/lib/typescript/commonjs/responsive-sheet/types.d.ts +58 -0
  88. package/lib/typescript/commonjs/responsive-sheet/types.d.ts.map +1 -0
  89. package/lib/typescript/module/dialog/CenteredDialog.d.ts +2 -0
  90. package/lib/typescript/module/dialog/CenteredDialog.d.ts.map +1 -1
  91. package/lib/typescript/module/dialog/CenteredDialog.web.d.ts +2 -0
  92. package/lib/typescript/module/dialog/CenteredDialog.web.d.ts.map +1 -1
  93. package/lib/typescript/module/dialog/Dialog.d.ts +18 -15
  94. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  95. package/lib/typescript/module/dialog/Dialog.web.d.ts +18 -5
  96. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  97. package/lib/typescript/module/dialog/centered-dialog-types.d.ts +4 -0
  98. package/lib/typescript/module/dialog/centered-dialog-types.d.ts.map +1 -1
  99. package/lib/typescript/module/dialog/index.d.ts +2 -2
  100. package/lib/typescript/module/dialog/index.d.ts.map +1 -1
  101. package/lib/typescript/module/dialog/index.web.d.ts +2 -2
  102. package/lib/typescript/module/dialog/index.web.d.ts.map +1 -1
  103. package/lib/typescript/module/dialog/placement.d.ts +71 -0
  104. package/lib/typescript/module/dialog/placement.d.ts.map +1 -0
  105. package/lib/typescript/module/dialog/types.d.ts +66 -3
  106. package/lib/typescript/module/dialog/types.d.ts.map +1 -1
  107. package/lib/typescript/module/index.d.ts +4 -2
  108. package/lib/typescript/module/index.d.ts.map +1 -1
  109. package/lib/typescript/module/index.web.d.ts +4 -2
  110. package/lib/typescript/module/index.web.d.ts.map +1 -1
  111. package/lib/typescript/module/responsive-sheet/ResponsiveSheet.d.ts +22 -0
  112. package/lib/typescript/module/responsive-sheet/ResponsiveSheet.d.ts.map +1 -0
  113. package/lib/typescript/module/responsive-sheet/ResponsiveSheet.web.d.ts +16 -0
  114. package/lib/typescript/module/responsive-sheet/ResponsiveSheet.web.d.ts.map +1 -0
  115. package/lib/typescript/module/responsive-sheet/index.d.ts +3 -0
  116. package/lib/typescript/module/responsive-sheet/index.d.ts.map +1 -0
  117. package/lib/typescript/module/responsive-sheet/index.web.d.ts +3 -0
  118. package/lib/typescript/module/responsive-sheet/index.web.d.ts.map +1 -0
  119. package/lib/typescript/module/responsive-sheet/types.d.ts +58 -0
  120. package/lib/typescript/module/responsive-sheet/types.d.ts.map +1 -0
  121. package/package.json +18 -1
  122. package/src/__tests__/DialogPlacement.test.tsx +144 -0
  123. package/src/__tests__/ResponsiveSheet.test.tsx +88 -0
  124. package/src/dialog/CenteredDialog.tsx +2 -0
  125. package/src/dialog/CenteredDialog.web.tsx +2 -0
  126. package/src/dialog/Dialog.tsx +407 -50
  127. package/src/dialog/Dialog.web.tsx +490 -75
  128. package/src/dialog/centered-dialog-types.ts +4 -0
  129. package/src/dialog/index.ts +4 -1
  130. package/src/dialog/index.web.ts +4 -1
  131. package/src/dialog/placement.ts +113 -0
  132. package/src/dialog/types.ts +69 -3
  133. package/src/index.ts +12 -0
  134. package/src/index.web.ts +12 -0
  135. package/src/responsive-sheet/ResponsiveSheet.stories.tsx +54 -0
  136. package/src/responsive-sheet/ResponsiveSheet.tsx +88 -0
  137. package/src/responsive-sheet/ResponsiveSheet.web.tsx +76 -0
  138. package/src/responsive-sheet/index.ts +6 -0
  139. package/src/responsive-sheet/index.web.ts +14 -0
  140. package/src/responsive-sheet/types.ts +60 -0
@@ -0,0 +1,88 @@
1
+ import React from 'react';
2
+ import { Text } from 'react-native';
3
+ import { act, render } from '@testing-library/react-native';
4
+
5
+ import { ResponsiveSheet } from '../responsive-sheet';
6
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
7
+
8
+ function renderWithTheme(ui: React.ReactElement) {
9
+ return render(
10
+ <BloomThemeProvider mode="light" colorPreset="teal">
11
+ {ui}
12
+ </BloomThemeProvider>,
13
+ );
14
+ }
15
+
16
+ function rerenderWithTheme(
17
+ rerender: ReturnType<typeof render>['rerender'],
18
+ ui: React.ReactElement,
19
+ ) {
20
+ rerender(
21
+ <BloomThemeProvider mode="light" colorPreset="teal">
22
+ {ui}
23
+ </BloomThemeProvider>,
24
+ );
25
+ }
26
+
27
+ const LABEL = 'Filters sheet';
28
+
29
+ // `ResponsiveSheet` is now a deprecated thin wrapper over `<Dialog>`. These
30
+ // tests verify the controlled wrapper contract it still owns (open/close
31
+ // presence + delegation). The jest env reports a 375px-wide viewport (below
32
+ // the default `md` breakpoint) so the wrapper resolves to the bottom-sheet
33
+ // placement, which delegates to bloom's `BottomSheet`.
34
+ describe('ResponsiveSheet (deprecated Dialog wrapper)', () => {
35
+ it('renders children when open', () => {
36
+ const { getByText } = renderWithTheme(
37
+ <ResponsiveSheet open onClose={() => {}} label={LABEL}>
38
+ <Text>Sheet body</Text>
39
+ </ResponsiveSheet>,
40
+ );
41
+ expect(getByText('Sheet body')).toBeTruthy();
42
+ });
43
+
44
+ it('renders nothing when closed (never opened)', () => {
45
+ const { queryByText } = renderWithTheme(
46
+ <ResponsiveSheet open={false} onClose={() => {}} label={LABEL}>
47
+ <Text>Sheet body</Text>
48
+ </ResponsiveSheet>,
49
+ );
50
+ expect(queryByText('Sheet body')).toBeNull();
51
+ });
52
+
53
+ it('unmounts the body after a controlled close settles', () => {
54
+ jest.useFakeTimers();
55
+ try {
56
+ const { getByText, queryByText, rerender } = renderWithTheme(
57
+ <ResponsiveSheet open onClose={() => {}} label={LABEL}>
58
+ <Text>Sheet body</Text>
59
+ </ResponsiveSheet>,
60
+ );
61
+ expect(getByText('Sheet body')).toBeTruthy();
62
+
63
+ rerenderWithTheme(
64
+ rerender,
65
+ <ResponsiveSheet open={false} onClose={() => {}} label={LABEL}>
66
+ <Text>Sheet body</Text>
67
+ </ResponsiveSheet>,
68
+ );
69
+
70
+ // After the close animation/fallback settles the body is gone.
71
+ act(() => {
72
+ jest.advanceTimersByTime(500);
73
+ });
74
+ expect(queryByText('Sheet body')).toBeNull();
75
+ } finally {
76
+ jest.useRealTimers();
77
+ }
78
+ });
79
+
80
+ it('forwards a custom label to the dialog surface', () => {
81
+ const { getByLabelText } = renderWithTheme(
82
+ <ResponsiveSheet open onClose={() => {}} label={LABEL}>
83
+ <Text>Sheet body</Text>
84
+ </ResponsiveSheet>,
85
+ );
86
+ expect(getByLabelText(LABEL)).toBeTruthy();
87
+ });
88
+ });
@@ -26,6 +26,8 @@ const CLOSE_HIT_SLOP = { top: 8, bottom: 8, left: 8, right: 8 } as const;
26
26
  export const CENTERED_DIALOG_BACKDROP_TESTID = 'bloom-centered-dialog-backdrop';
27
27
 
28
28
  /**
29
+ * @deprecated Use `<Dialog placement="center">` instead.
30
+ *
29
31
  * Native variant of `<CenteredDialog>`.
30
32
  *
31
33
  * A controlled, centered modal: a transparent RN `Modal` (fade animation,
@@ -27,6 +27,8 @@ export const CENTERED_DIALOG_BACKDROP_TESTID = 'bloom-centered-dialog-backdrop';
27
27
  const stopPropagation = (e: { stopPropagation: () => void }) => e.stopPropagation();
28
28
 
29
29
  /**
30
+ * @deprecated Use `<Dialog placement="center">` instead.
31
+ *
30
32
  * Web variant of `<CenteredDialog>`.
31
33
  *
32
34
  * A centered modal card rendered into the Bloom Portal at the end of
@@ -5,63 +5,119 @@ import React, {
5
5
  useImperativeHandle,
6
6
  useMemo,
7
7
  useRef,
8
+ useState,
8
9
  } from 'react';
9
10
  import {
11
+ Pressable,
12
+ StyleSheet,
10
13
  Text,
11
14
  TouchableOpacity,
15
+ useWindowDimensions,
12
16
  View,
13
17
  type GestureResponderEvent,
18
+ type StyleProp,
19
+ type ViewStyle,
14
20
  } from 'react-native';
21
+ import Animated, {
22
+ cancelAnimation,
23
+ Easing,
24
+ runOnJS,
25
+ useAnimatedStyle,
26
+ useSharedValue,
27
+ withTiming,
28
+ } from 'react-native-reanimated';
15
29
 
16
30
  import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
31
+ import { Z_INDEX } from '../styles/z-index';
17
32
  import type { ThemeColors } from '../theme/types';
18
33
  import { useTheme } from '../theme/use-theme';
19
34
  import { Context, useDialogContext, useDialogControl } from './context';
35
+ import {
36
+ ANIMATION_DURATION,
37
+ DEFAULT_MAX_HEIGHT_RATIO,
38
+ DEFAULT_SIDE_WIDTH,
39
+ DIALOG_SHEET_BACKDROP_TESTID,
40
+ PANEL_RADIUS,
41
+ SHEET_BACKDROP_OPACITY,
42
+ SIDE_SHEET_MIN_GUTTER,
43
+ useResolvedPlacement,
44
+ } from './placement';
20
45
  import type {
21
46
  DialogAction,
22
47
  DialogActionColor,
23
48
  DialogControlProps,
49
+ DialogInset,
24
50
  DialogProps,
25
51
  } from './types';
26
52
 
53
+ export { DIALOG_SHEET_BACKDROP_TESTID };
54
+
27
55
  /**
28
56
  * Native variant of `<Dialog>`.
29
57
  *
30
- * Uses bloom's own `BottomSheet` in `detached` mode as the underlying
31
- * surface a floating, dynamically-sized, content-hugging card that
32
- * gracefully degrades from sheet (phone) to centered card (tablet, via the
33
- * 500px max-width cap).
58
+ * Open/close is driven by EITHER the imperative `control` (legacy) OR the
59
+ * controlled `open` prop. When `open` is provided it wins.
34
60
  *
35
- * The component accepts three rendering modes simultaneously:
61
+ * `placement` selects the surface:
36
62
  *
37
- * 1. Declarative `title`, `description`, `actions`. Bloom renders a
38
- * standard confirm/destructive/cancel layout.
39
- * 2. Custom children caller passes JSX, bloom renders the chrome (title
40
- * + close behaviour) and the children fill the body.
41
- * 3. Pure childrenno `title`/`description`/`actions`; caller owns
42
- * every pixel.
63
+ * - `'center'` (default) / `'bottom'` bloom's `BottomSheet` (`detached`
64
+ * for center: a floating, content-hugging card that degrades from sheet to
65
+ * centered card via the 500px cap; flush for bottom: a full-width sheet
66
+ * with a drag handle). Default behavior is unchanged.
67
+ * - `'left'` / `'right'` an anchored side-sheet/drawer driven by
68
+ * reanimated (the `responsive-sheet` native technique). Exit animations
69
+ * are safe on native.
43
70
  *
44
- * All three share the same dismissal semantics: tapping the backdrop, the
45
- * drag handle, swiping down, or `control.close()` runs the queued close
46
- * callbacks once the sheet's exit animation has settled.
71
+ * The component accepts three rendering modes simultaneously:
72
+ *
73
+ * 1. Declarative `title`, `description`, `actions`.
74
+ * 2. Custom children — caller passes JSX, bloom renders the chrome.
75
+ * 3. Pure children — no `title`/`description`/`actions`.
47
76
  */
48
77
  export function Dialog({
49
78
  control,
79
+ open: controlledOpen,
50
80
  onClose,
51
81
  testID,
52
82
  title,
53
83
  description,
54
84
  actions,
85
+ placement,
86
+ width = DEFAULT_SIDE_WIDTH,
87
+ maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
88
+ inset,
89
+ showHandle = true,
90
+ dismissOnBackdrop = true,
55
91
  style,
92
+ panelStyle,
93
+ panelClassName,
94
+ containerStyle,
95
+ containerClassName,
56
96
  label,
57
97
  children,
58
98
  }: DialogProps) {
99
+ const isControlled = controlledOpen !== undefined;
100
+ const resolvedPlacement = useResolvedPlacement(placement);
101
+ const isSide = resolvedPlacement === 'left' || resolvedPlacement === 'right';
102
+
103
+ // Imperative open state for the side placement (the BottomSheet path owns its
104
+ // own visibility via its ref instead).
105
+ const [sideOpen, setSideOpen] = useState(false);
106
+
59
107
  const theme = useTheme();
60
108
  const ref = useRef<BottomSheetRef>(null);
61
109
  const closeCallbacks = useRef<(() => void)[]>([]);
62
110
  const titleId = useId();
63
111
  const descriptionId = useId();
64
112
 
113
+ // Read the latest controlled flag / `onClose` inside stable callbacks
114
+ // without re-binding them (the context + imperative handle depend on `close`
115
+ // staying referentially stable).
116
+ const isControlledRef = useRef(isControlled);
117
+ isControlledRef.current = isControlled;
118
+ const onCloseRef = useRef(onClose);
119
+ onCloseRef.current = onClose;
120
+
65
121
  // Drain queued close callbacks atomically — capturing the list and
66
122
  // resetting it before invocation ensures a callback that synchronously
67
123
  // re-opens the dialog (and queues fresh callbacks) does not see the old
@@ -81,23 +137,51 @@ export function Dialog({
81
137
  }, []);
82
138
 
83
139
  const open = useCallback(() => {
140
+ setSideOpen(true);
84
141
  ref.current?.present();
85
142
  }, []);
86
143
 
144
+ // A dismissal request (backdrop, pan-to-close, action button). In controlled
145
+ // mode it asks the host to close via `onClose` (the host flips `open`); in
146
+ // imperative mode it dismisses the surface directly. The optional callback
147
+ // runs once the dialog has finished closing.
87
148
  const close = useCallback<DialogControlProps['close']>((cb) => {
88
149
  if (typeof cb === 'function') {
89
150
  closeCallbacks.current.push(cb);
90
151
  }
152
+ if (isControlledRef.current) {
153
+ onCloseRef.current?.();
154
+ return;
155
+ }
156
+ setSideOpen(false);
91
157
  ref.current?.dismiss();
92
158
  }, []);
93
159
 
160
+ // Fired when the underlying surface has finished closing. Drains queued
161
+ // callbacks, then fires `onClose` ONLY in imperative mode — controlled mode
162
+ // already requested the close through `onClose` (the host then flipped
163
+ // `open`), so firing it again here would double-call it.
94
164
  const handleDismiss = useCallback(() => {
95
165
  callQueuedCallbacks();
96
- onClose?.();
97
- }, [callQueuedCallbacks, onClose]);
166
+ if (!isControlledRef.current) onCloseRef.current?.();
167
+ }, [callQueuedCallbacks]);
168
+
169
+ // In controlled mode, mirror the `open` prop onto the underlying surface so
170
+ // both modes share one lifecycle. The BottomSheet path presents/dismisses by
171
+ // ref; the side path flips `sideOpen`.
172
+ useEffect(() => {
173
+ if (!isControlled) return;
174
+ if (controlledOpen) {
175
+ setSideOpen(true);
176
+ ref.current?.present();
177
+ } else {
178
+ setSideOpen(false);
179
+ ref.current?.dismiss();
180
+ }
181
+ }, [isControlled, controlledOpen]);
98
182
 
99
183
  useImperativeHandle(
100
- control.ref,
184
+ control?.ref,
101
185
  () => ({ open, close }),
102
186
  [open, close],
103
187
  );
@@ -119,16 +203,59 @@ export function Dialog({
119
203
  [theme.colors.background],
120
204
  );
121
205
 
206
+ const bodyNode = (
207
+ <DialogBody
208
+ titleId={titleId}
209
+ descriptionId={descriptionId}
210
+ title={title}
211
+ description={description}
212
+ actions={actions}
213
+ >
214
+ {children}
215
+ </DialogBody>
216
+ );
217
+
218
+ if (isSide) {
219
+ return (
220
+ <Context.Provider value={context}>
221
+ <SideSheet
222
+ open={sideOpen}
223
+ onDismiss={handleDismiss}
224
+ side={resolvedPlacement}
225
+ width={width}
226
+ inset={inset}
227
+ dismissOnBackdrop={dismissOnBackdrop}
228
+ testID={testID}
229
+ label={label}
230
+ title={title}
231
+ description={description}
232
+ titleId={titleId}
233
+ descriptionId={descriptionId}
234
+ panelStyle={panelStyle}
235
+ panelClassName={panelClassName}
236
+ containerStyle={containerStyle}
237
+ containerClassName={containerClassName}
238
+ style={style}
239
+ >
240
+ {bodyNode}
241
+ </SideSheet>
242
+ </Context.Provider>
243
+ );
244
+ }
245
+
246
+ const isBottom = resolvedPlacement === 'bottom';
247
+
122
248
  return (
123
249
  <BottomSheet
124
250
  ref={ref}
125
251
  onDismiss={handleDismiss}
126
252
  enablePanDownToClose
127
- detached
253
+ detached={!isBottom}
254
+ showHandle={isBottom ? showHandle : true}
128
255
  // Stronger dim when a Dialog is stacked over another sheet so the
129
256
  // underlying sheet's handle/content doesn't bleed through.
130
257
  backdropOpacity={0.7}
131
- style={sheetStyle}
258
+ style={isBottom ? bottomSheetStyle(theme.colors.background, maxHeightRatio) : sheetStyle}
132
259
  >
133
260
  <Context.Provider value={context}>
134
261
  <View
@@ -136,6 +263,7 @@ export function Dialog({
136
263
  accessibilityLabel={label}
137
264
  aria-labelledby={title ? titleId : undefined}
138
265
  aria-describedby={description ? descriptionId : undefined}
266
+ {...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
139
267
  style={[
140
268
  // Detached BottomSheet already adds `marginBottom: insets.bottom + 16`
141
269
  // to the sheet container — the floating card sits ABOVE the
@@ -143,45 +271,252 @@ export function Dialog({
143
271
  { paddingTop: 20, paddingHorizontal: 20, paddingBottom: 20 },
144
272
  { backgroundColor: theme.colors.background },
145
273
  style,
274
+ panelStyle,
146
275
  ]}
147
276
  >
148
- {title ? (
149
- <Text
150
- nativeID={titleId}
151
- style={{
152
- fontSize: 22,
153
- fontWeight: '600',
154
- color: theme.colors.text,
155
- paddingBottom: description ? 4 : 16,
156
- lineHeight: 30,
157
- }}
158
- >
159
- {title}
160
- </Text>
161
- ) : null}
162
- {description ? (
163
- <Text
164
- nativeID={descriptionId}
165
- style={{
166
- fontSize: 16,
167
- color: theme.colors.textSecondary,
168
- paddingBottom: 16,
169
- lineHeight: 22,
170
- }}
171
- >
172
- {description}
173
- </Text>
174
- ) : null}
175
- {children}
176
- {actions && actions.length > 0 ? (
177
- <ActionRow actions={actions} />
178
- ) : null}
277
+ {bodyNode}
179
278
  </View>
180
279
  </Context.Provider>
181
280
  </BottomSheet>
182
281
  );
183
282
  }
184
283
 
284
+ function bottomSheetStyle(background: string, maxHeightRatio: number): ViewStyle {
285
+ return {
286
+ backgroundColor: background,
287
+ borderTopLeftRadius: PANEL_RADIUS + 4,
288
+ borderTopRightRadius: PANEL_RADIUS + 4,
289
+ maxHeight: `${Math.round(maxHeightRatio * 100)}%`,
290
+ };
291
+ }
292
+
293
+ /**
294
+ * Renders the dialog's body: optional declarative title + description, any
295
+ * `children`, then the action row. Shared by all placements. The
296
+ * `titleId`/`descriptionId` are wired by the caller for accessibility.
297
+ */
298
+ function DialogBody({
299
+ titleId,
300
+ descriptionId,
301
+ title,
302
+ description,
303
+ actions,
304
+ children,
305
+ }: {
306
+ titleId: string;
307
+ descriptionId: string;
308
+ title?: string;
309
+ description?: string;
310
+ actions?: DialogAction[];
311
+ children?: React.ReactNode;
312
+ }) {
313
+ const theme = useTheme();
314
+ return (
315
+ <>
316
+ {title ? (
317
+ <Text
318
+ nativeID={titleId}
319
+ style={{
320
+ fontSize: 22,
321
+ fontWeight: '600',
322
+ color: theme.colors.text,
323
+ paddingBottom: description ? 4 : 16,
324
+ lineHeight: 30,
325
+ }}
326
+ >
327
+ {title}
328
+ </Text>
329
+ ) : null}
330
+ {description ? (
331
+ <Text
332
+ nativeID={descriptionId}
333
+ style={{
334
+ fontSize: 16,
335
+ color: theme.colors.textSecondary,
336
+ paddingBottom: 16,
337
+ lineHeight: 22,
338
+ }}
339
+ >
340
+ {description}
341
+ </Text>
342
+ ) : null}
343
+ {children}
344
+ {actions && actions.length > 0 ? <ActionRow actions={actions} /> : null}
345
+ </>
346
+ );
347
+ }
348
+
349
+ /**
350
+ * Reanimated side-sheet/drawer for the `left`/`right` placements (native).
351
+ *
352
+ * A shared `progress` value times 0 -> 1 on open and 1 -> 0 on close, mapped to
353
+ * the panel translateX and the backdrop opacity. The node stays mounted through
354
+ * the close timing and unmounts from the animation completion callback. Native
355
+ * exit animations are safe (the web `removeChild` crash does not apply).
356
+ */
357
+ function SideSheet({
358
+ open,
359
+ onDismiss,
360
+ side,
361
+ width,
362
+ inset,
363
+ dismissOnBackdrop,
364
+ testID,
365
+ label,
366
+ title,
367
+ description,
368
+ titleId,
369
+ descriptionId,
370
+ panelStyle,
371
+ panelClassName,
372
+ containerStyle,
373
+ containerClassName,
374
+ style,
375
+ children,
376
+ }: {
377
+ open: boolean;
378
+ onDismiss: () => void;
379
+ side: 'left' | 'right';
380
+ width: number;
381
+ inset?: DialogInset;
382
+ dismissOnBackdrop: boolean;
383
+ testID?: string;
384
+ label?: string;
385
+ title?: string;
386
+ description?: string;
387
+ titleId: string;
388
+ descriptionId: string;
389
+ panelStyle?: StyleProp<ViewStyle>;
390
+ panelClassName?: string;
391
+ containerStyle?: StyleProp<ViewStyle>;
392
+ containerClassName?: string;
393
+ style?: StyleProp<ViewStyle>;
394
+ children: React.ReactNode;
395
+ }) {
396
+ const theme = useTheme();
397
+ const { width: viewportWidth } = useWindowDimensions();
398
+
399
+ const [mounted, setMounted] = useState(open);
400
+ const progress = useSharedValue(open ? 1 : 0);
401
+
402
+ const insetTop = inset?.top ?? 0;
403
+ const insetBottom = inset?.bottom ?? 0;
404
+ const insetLeft = inset?.left ?? 0;
405
+ const insetRight = inset?.right ?? 0;
406
+ const anchorInset = side === 'left' ? insetLeft : insetRight;
407
+ const oppositeInset = side === 'left' ? insetRight : insetLeft;
408
+
409
+ const sideWidth = useMemo(() => {
410
+ const available = viewportWidth - anchorInset - oppositeInset - SIDE_SHEET_MIN_GUTTER;
411
+ return Math.max(0, Math.min(width, available));
412
+ }, [viewportWidth, anchorInset, oppositeInset, width]);
413
+
414
+ // Travel distance for the hidden state: the panel's measured width, with the
415
+ // geometry target as the pre-measurement fallback.
416
+ const travel = useSharedValue(sideWidth);
417
+ useEffect(() => {
418
+ travel.value = sideWidth;
419
+ }, [sideWidth, travel]);
420
+
421
+ const finishClose = useCallback(() => {
422
+ setMounted(false);
423
+ onDismiss();
424
+ }, [onDismiss]);
425
+
426
+ useEffect(() => {
427
+ cancelAnimation(progress);
428
+ const timing = { duration: ANIMATION_DURATION, easing: Easing.out(Easing.cubic) };
429
+ if (open) {
430
+ setMounted(true);
431
+ progress.value = withTiming(1, timing);
432
+ return;
433
+ }
434
+ if (!mounted) return;
435
+ progress.value = withTiming(0, timing, (finished) => {
436
+ if (finished) runOnJS(finishClose)();
437
+ });
438
+ }, [open, mounted, progress, finishClose]);
439
+
440
+ const handleBackdropPress = useCallback(() => {
441
+ if (dismissOnBackdrop) onDismiss();
442
+ }, [dismissOnBackdrop, onDismiss]);
443
+
444
+ const backdropAnimatedStyle = useAnimatedStyle(() => ({
445
+ opacity: progress.value * SHEET_BACKDROP_OPACITY,
446
+ }));
447
+
448
+ const hiddenSign = side === 'left' ? -1 : 1;
449
+ const panelAnimatedStyle = useAnimatedStyle(() => {
450
+ const hidden = 1 - progress.value;
451
+ return {
452
+ opacity: progress.value,
453
+ transform: [{ translateX: hidden * travel.value * hiddenSign }],
454
+ };
455
+ }, [hiddenSign]);
456
+
457
+ const measurePanel = useCallback(
458
+ (e: { nativeEvent: { layout: { width: number } } }) => {
459
+ const measured = e.nativeEvent.layout.width;
460
+ if (measured > 0) travel.value = measured;
461
+ },
462
+ [travel],
463
+ );
464
+
465
+ const panelGeometry = useMemo<ViewStyle>(
466
+ () =>
467
+ ({
468
+ top: insetTop,
469
+ bottom: insetBottom,
470
+ [side]: anchorInset,
471
+ width: sideWidth,
472
+ borderRadius: PANEL_RADIUS,
473
+ }) as ViewStyle,
474
+ [insetTop, insetBottom, side, anchorInset, sideWidth],
475
+ );
476
+
477
+ if (!mounted) return null;
478
+
479
+ return (
480
+ <View
481
+ style={[sideStyles.root, containerStyle]}
482
+ {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
483
+ pointerEvents="box-none"
484
+ >
485
+ <Animated.View style={[sideStyles.backdrop, backdropAnimatedStyle]} pointerEvents="auto">
486
+ <Pressable
487
+ testID={testID ? `${testID}-backdrop` : DIALOG_SHEET_BACKDROP_TESTID}
488
+ accessibilityRole="button"
489
+ accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
490
+ onPress={handleBackdropPress}
491
+ disabled={!dismissOnBackdrop}
492
+ style={StyleSheet.absoluteFill}
493
+ />
494
+ </Animated.View>
495
+
496
+ <Animated.View
497
+ accessibilityViewIsModal
498
+ accessibilityLabel={label}
499
+ aria-labelledby={title ? titleId : undefined}
500
+ aria-describedby={description ? descriptionId : undefined}
501
+ testID={testID}
502
+ onLayout={measurePanel}
503
+ {...(panelClassName ? ({ className: panelClassName } as Record<string, string>) : {})}
504
+ style={[
505
+ sideStyles.panel,
506
+ { backgroundColor: theme.colors.background, shadowColor: theme.colors.shadow },
507
+ panelGeometry,
508
+ panelAnimatedStyle,
509
+ panelStyle,
510
+ style,
511
+ ]}
512
+ pointerEvents="auto"
513
+ >
514
+ <View style={sideStyles.body}>{children}</View>
515
+ </Animated.View>
516
+ </View>
517
+ );
518
+ }
519
+
185
520
  function ActionRow({ actions }: { actions: DialogAction[] }) {
186
521
  return (
187
522
  <View style={{ width: '100%', gap: 8, justifyContent: 'flex-end' }}>
@@ -265,6 +600,28 @@ function getActionPalette(
265
600
  }
266
601
  }
267
602
 
603
+ const sideStyles = StyleSheet.create({
604
+ root: {
605
+ ...StyleSheet.absoluteFill,
606
+ zIndex: Z_INDEX.fullscreen,
607
+ },
608
+ backdrop: {
609
+ ...StyleSheet.absoluteFill,
610
+ backgroundColor: '#000',
611
+ },
612
+ panel: {
613
+ position: 'absolute',
614
+ overflow: 'hidden',
615
+ shadowOpacity: 0.18,
616
+ shadowRadius: 24,
617
+ shadowOffset: { width: 0, height: 8 },
618
+ elevation: 12,
619
+ },
620
+ body: {
621
+ padding: 20,
622
+ },
623
+ });
624
+
268
625
  /**
269
626
  * Helper used by the imperative `alert()` API. Mounts a `<Dialog>` against
270
627
  * a fresh control and presents it immediately. `onResolve` is invoked