@oxyhq/bloom 0.16.0 → 0.16.1

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 +38 -150
  2. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  3. package/lib/commonjs/dialog/Dialog.web.js +55 -193
  4. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  5. package/lib/commonjs/dialog/DialogBottomSheet.js +205 -0
  6. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -0
  7. package/lib/commonjs/dialog/DialogContent.js +155 -0
  8. package/lib/commonjs/dialog/DialogContent.js.map +1 -0
  9. package/lib/module/dialog/Dialog.js +41 -153
  10. package/lib/module/dialog/Dialog.js.map +1 -1
  11. package/lib/module/dialog/Dialog.web.js +57 -195
  12. package/lib/module/dialog/Dialog.web.js.map +1 -1
  13. package/lib/module/dialog/DialogBottomSheet.js +200 -0
  14. package/lib/module/dialog/DialogBottomSheet.js.map +1 -0
  15. package/lib/module/dialog/DialogContent.js +148 -0
  16. package/lib/module/dialog/DialogContent.js.map +1 -0
  17. package/lib/typescript/commonjs/dialog/Dialog.d.ts +1 -1
  18. package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
  19. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +11 -8
  20. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +29 -0
  22. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -0
  23. package/lib/typescript/commonjs/dialog/DialogContent.d.ts +32 -0
  24. package/lib/typescript/commonjs/dialog/DialogContent.d.ts.map +1 -0
  25. package/lib/typescript/module/dialog/Dialog.d.ts +1 -1
  26. package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
  27. package/lib/typescript/module/dialog/Dialog.web.d.ts +11 -8
  28. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  29. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +29 -0
  30. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -0
  31. package/lib/typescript/module/dialog/DialogContent.d.ts +32 -0
  32. package/lib/typescript/module/dialog/DialogContent.d.ts.map +1 -0
  33. package/package.json +1 -1
  34. package/src/__tests__/BottomSheetGesture.test.tsx +135 -0
  35. package/src/__tests__/DialogBottomSheet.test.tsx +130 -0
  36. package/src/dialog/Dialog.tsx +34 -166
  37. package/src/dialog/Dialog.web.tsx +46 -206
  38. package/src/dialog/DialogBottomSheet.tsx +235 -0
  39. package/src/dialog/DialogContent.tsx +157 -0
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import type { ThemeColors } from '../theme/types';
3
+ import type { DialogAction, DialogActionColor } from './types';
4
+ /**
5
+ * Shared dialog content primitives used by every `<Dialog>` placement on both
6
+ * platforms — the centered panel, the side-sheets, and the `BottomSheet`-backed
7
+ * bottom placement. Keeping them here is the single source of truth for the
8
+ * declarative `title` / `description` / `actions` chrome, so the surfaces never
9
+ * drift in spacing, typography, or action-button behaviour.
10
+ */
11
+ /**
12
+ * Renders the dialog's body: optional declarative title + description, any
13
+ * `children`, then the action row. The `titleId`/`descriptionId` are wired by
14
+ * the caller onto the dialog role element for `aria-labelledby` /
15
+ * `aria-describedby`.
16
+ */
17
+ export declare function DialogBody({ titleId, descriptionId, title, description, actions, children, }: {
18
+ titleId: string;
19
+ descriptionId: string;
20
+ title?: string;
21
+ description?: string;
22
+ actions?: DialogAction[];
23
+ children?: React.ReactNode;
24
+ }): import("react/jsx-runtime").JSX.Element;
25
+ export declare function ActionRow({ actions }: {
26
+ actions: DialogAction[];
27
+ }): import("react/jsx-runtime").JSX.Element;
28
+ export declare function getActionPalette(color: DialogActionColor, colors: ThemeColors): {
29
+ background: string;
30
+ foreground: string;
31
+ };
32
+ //# sourceMappingURL=DialogContent.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DialogContent.d.ts","sourceRoot":"","sources":["../../../../src/dialog/DialogContent.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAsB,MAAM,OAAO,CAAC;AAQ3C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAGlD,OAAO,KAAK,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AAE/D;;;;;;GAMG;AAEH;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,EACzB,OAAO,EACP,aAAa,EACb,KAAK,EACL,WAAW,EACX,OAAO,EACP,QAAQ,GACT,EAAE;IACD,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,YAAY,EAAE,CAAC;IACzB,QAAQ,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;CAC5B,2CAmCA;AAED,wBAAgB,SAAS,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,YAAY,EAAE,CAAA;CAAE,2CAQjE;AAkDD,wBAAgB,gBAAgB,CAC9B,KAAK,EAAE,iBAAiB,EACxB,MAAM,EAAE,WAAW,GAClB;IAAE,UAAU,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAA;CAAE,CAiB5C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/bloom",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
5
5
  "main": "lib/commonjs/index.js",
6
6
  "module": "lib/module/index.js",
@@ -0,0 +1,135 @@
1
+ import React, { createRef } from 'react';
2
+ import { Text } from 'react-native';
3
+ import { act, render } from '@testing-library/react-native';
4
+
5
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
6
+ import BottomSheet, { type BottomSheetRef } from '../bottom-sheet';
7
+
8
+ /**
9
+ * Pan-to-dismiss is the whole point of routing the Dialog `bottom` placement
10
+ * through `BottomSheet` on BOTH platforms. These tests assert the pan gesture
11
+ * is actually wired (an `onEnd` worklet is registered) and that the
12
+ * dismiss-threshold logic inside it runs end-to-end — i.e. a fast/far downward
13
+ * swipe commits the close and fires `onDismiss`, while a small drag springs the
14
+ * sheet back without dismissing.
15
+ *
16
+ * This file overrides the default gesture-handler mock (which discards the
17
+ * registered callbacks) with a RECORDING mock that captures the pan gesture's
18
+ * handlers. Invoking the captured `onEnd` exercises the real threshold math
19
+ * from `bottom-sheet/index.tsx` — there is no `Platform.OS` gating around the
20
+ * `<GestureDetector>` or the pan builder, so this is the SAME code path web
21
+ * runs (drag-to-dismiss is active on web).
22
+ */
23
+
24
+ interface PanEndEvent {
25
+ velocityY: number;
26
+ translationY: number;
27
+ }
28
+
29
+ interface RecordedGesture {
30
+ onEnd?: (event: PanEndEvent) => void;
31
+ onUpdate?: (event: { translationY: number }) => void;
32
+ }
33
+
34
+ // Recorded `Gesture.Pan()` instances from the most recent render. Must be
35
+ // `mock`-prefixed so the hoisted `jest.mock` factory may reference it.
36
+ const mockPanGestures: RecordedGesture[] = [];
37
+
38
+ jest.mock('react-native-gesture-handler', () => {
39
+ const builder = (record?: RecordedGesture) => {
40
+ const self = {
41
+ enabled: () => self,
42
+ simultaneousWithExternalGesture: () => self,
43
+ withRef: () => self,
44
+ manualActivation: () => self,
45
+ activeOffsetY: () => self,
46
+ activeOffsetX: () => self,
47
+ onStart: () => self,
48
+ onUpdate: (fn: RecordedGesture['onUpdate']) => {
49
+ if (record) record.onUpdate = fn;
50
+ return self;
51
+ },
52
+ onEnd: (fn: RecordedGesture['onEnd']) => {
53
+ if (record) record.onEnd = fn;
54
+ return self;
55
+ },
56
+ onTouchesDown: () => self,
57
+ onTouchesMove: () => self,
58
+ onTouchesUp: () => self,
59
+ onTouchesCancelled: () => self,
60
+ };
61
+ return self;
62
+ };
63
+
64
+ return {
65
+ Gesture: {
66
+ Pan: () => {
67
+ const record: RecordedGesture = {};
68
+ mockPanGestures.push(record);
69
+ return builder(record);
70
+ },
71
+ Native: () => builder(),
72
+ },
73
+ GestureDetector: ({ children }: { children: React.ReactNode }) => children,
74
+ GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
75
+ };
76
+ });
77
+
78
+ function renderSheet(onDismiss: () => void) {
79
+ const ref = createRef<BottomSheetRef>();
80
+ const utils = render(
81
+ <BloomThemeProvider mode="light" colorPreset="teal">
82
+ <BottomSheet ref={ref} onDismiss={onDismiss}>
83
+ <Text>Body</Text>
84
+ </BottomSheet>
85
+ </BloomThemeProvider>,
86
+ );
87
+ // Present so the sheet mounts and the pan gesture is constructed.
88
+ act(() => {
89
+ ref.current?.present();
90
+ });
91
+ return utils;
92
+ }
93
+
94
+ describe('BottomSheet pan-to-dismiss gesture', () => {
95
+ beforeEach(() => {
96
+ mockPanGestures.length = 0;
97
+ });
98
+
99
+ it('wires a pan gesture with an onEnd handler (drag is active, not web-gated)', () => {
100
+ renderSheet(() => {});
101
+ // A pan gesture must have been constructed with an onEnd worklet — proof the
102
+ // drag pipeline is built unconditionally (the source has no
103
+ // `Platform.OS !== 'web'` guard around the GestureDetector or pan builder).
104
+ expect(mockPanGestures.length).toBeGreaterThan(0);
105
+ const bodyPan = mockPanGestures.find((g) => typeof g.onEnd === 'function');
106
+ expect(bodyPan).toBeDefined();
107
+ expect(typeof bodyPan?.onEnd).toBe('function');
108
+ });
109
+
110
+ it('dismisses when a fast downward swipe crosses the close threshold', () => {
111
+ const onDismiss = jest.fn();
112
+ renderSheet(onDismiss);
113
+ const bodyPan = mockPanGestures.find((g) => typeof g.onEnd === 'function');
114
+
115
+ // A high downward velocity is past `fastSwipeThreshold` (900) → the onEnd
116
+ // worklet commits the close, which (under the reanimated mock that runs
117
+ // withTiming callbacks synchronously) drains through to `onDismiss`.
118
+ act(() => {
119
+ bodyPan?.onEnd?.({ velocityY: 1500, translationY: 50 });
120
+ });
121
+ expect(onDismiss).toHaveBeenCalledTimes(1);
122
+ });
123
+
124
+ it('does not dismiss on a small, slow drag below the threshold', () => {
125
+ const onDismiss = jest.fn();
126
+ renderSheet(onDismiss);
127
+ const bodyPan = mockPanGestures.find((g) => typeof g.onEnd === 'function');
128
+
129
+ // Tiny translation + near-zero velocity → springs back, no close.
130
+ act(() => {
131
+ bodyPan?.onEnd?.({ velocityY: 10, translationY: 12 });
132
+ });
133
+ expect(onDismiss).not.toHaveBeenCalled();
134
+ });
135
+ });
@@ -0,0 +1,130 @@
1
+ import React, { createRef } from 'react';
2
+ import { Text, type View } from 'react-native';
3
+ import { act, fireEvent, render } from '@testing-library/react-native';
4
+
5
+ import { Dialog, useDialogControl } from '../dialog';
6
+ import { BloomThemeProvider } from '../theme/BloomThemeProvider';
7
+
8
+ /**
9
+ * The Dialog `bottom` placement must delegate to the shared cross-platform
10
+ * `BottomSheet` (via `DialogBottomSheet`) — the SAME surface native and web use
11
+ * — instead of a bespoke CSS slide-up sheet. These tests pin that delegation:
12
+ * the declarative content renders through the sheet, the close lifecycle bridges
13
+ * `open`/`onClose` correctly, and brand theming (`panelStyle` surface paint +
14
+ * `containerStyle` theme scope) threads through to the sheet surface.
15
+ *
16
+ * The jest environment resolves `../dialog` to the native barrel (`Dialog.tsx`),
17
+ * which routes `bottom` through `DialogBottomSheet` → `BottomSheet`. The web
18
+ * fork (`Dialog.web.tsx`) routes through the EXACT same `DialogBottomSheet`, so
19
+ * this delegation is one shared code path across platforms.
20
+ */
21
+
22
+ function renderWithTheme(ui: React.ReactElement) {
23
+ return render(
24
+ <BloomThemeProvider mode="light" colorPreset="teal">
25
+ {ui}
26
+ </BloomThemeProvider>,
27
+ );
28
+ }
29
+
30
+ function Harness({
31
+ children,
32
+ }: {
33
+ children: (control: ReturnType<typeof useDialogControl>) => React.ReactNode;
34
+ }) {
35
+ const control = useDialogControl();
36
+ return <>{children(control)}</>;
37
+ }
38
+
39
+ describe('Dialog bottom placement delegates to BottomSheet', () => {
40
+ it('does not render bottom-sheet content until opened', () => {
41
+ const { queryByText } = renderWithTheme(
42
+ <Harness>
43
+ {(control) => (
44
+ <Dialog control={control} placement="bottom" title="Sheet title">
45
+ <Text>Sheet body</Text>
46
+ </Dialog>
47
+ )}
48
+ </Harness>,
49
+ );
50
+ // BottomSheet keeps its subtree unmounted until present() — proof the bottom
51
+ // placement is the sheet, not an always-mounted CSS panel.
52
+ expect(queryByText('Sheet title')).toBeNull();
53
+ expect(queryByText('Sheet body')).toBeNull();
54
+ });
55
+
56
+ it('renders declarative title + description + actions through the sheet when opened', () => {
57
+ let control: ReturnType<typeof useDialogControl> | undefined;
58
+ const { getByText } = renderWithTheme(
59
+ <Harness>
60
+ {(c) => {
61
+ control = c;
62
+ return (
63
+ <Dialog
64
+ control={c}
65
+ placement="bottom"
66
+ title="Delete item?"
67
+ description="This cannot be undone."
68
+ actions={[{ label: 'Delete', color: 'destructive' }, { label: 'Cancel', color: 'cancel' }]}
69
+ />
70
+ );
71
+ }}
72
+ </Harness>,
73
+ );
74
+ act(() => {
75
+ control?.open();
76
+ });
77
+ expect(getByText('Delete item?')).toBeTruthy();
78
+ expect(getByText('This cannot be undone.')).toBeTruthy();
79
+ expect(getByText('Delete')).toBeTruthy();
80
+ expect(getByText('Cancel')).toBeTruthy();
81
+ });
82
+
83
+ it('requests close via onClose exactly once on action press in controlled mode (no double-fire)', () => {
84
+ const onClose = jest.fn();
85
+ const { getByText } = renderWithTheme(
86
+ <Dialog open onClose={onClose} placement="bottom" title="Confirm" actions={[{ label: 'Done' }]} />,
87
+ );
88
+ act(() => {
89
+ fireEvent.press(getByText('Done'));
90
+ });
91
+ // Controlled mode: the action's close() requests dismissal via onClose once;
92
+ // the host (not the dialog) then flips `open`. It must not fire the
93
+ // close-completion onClose a second time.
94
+ expect(onClose).toHaveBeenCalledTimes(1);
95
+ });
96
+
97
+ it('threads panelStyle (brand surface) and containerStyle (theme scope) onto the sheet content', () => {
98
+ let control: ReturnType<typeof useDialogControl> | undefined;
99
+ const panelRef = createRef<View>();
100
+ const { getByTestId } = renderWithTheme(
101
+ <Harness>
102
+ {(c) => {
103
+ control = c;
104
+ return (
105
+ <Dialog
106
+ control={c}
107
+ placement="bottom"
108
+ testID="brand-sheet"
109
+ panelStyle={{ backgroundColor: 'rebeccapurple' }}
110
+ containerStyle={{ paddingTop: 7 }}
111
+ >
112
+ <Text ref={panelRef}>Branded</Text>
113
+ </Dialog>
114
+ );
115
+ }}
116
+ </Harness>,
117
+ );
118
+ act(() => {
119
+ control?.open();
120
+ });
121
+ const content = getByTestId('brand-sheet');
122
+ const flattened = Array.isArray(content.props.style)
123
+ ? Object.assign({}, ...content.props.style.filter(Boolean))
124
+ : content.props.style;
125
+ // panelStyle paints the surface; containerStyle (theme-var scope) wraps the
126
+ // content subtree. Both land on the sheet content container.
127
+ expect(flattened.backgroundColor).toBe('rebeccapurple');
128
+ expect(flattened.paddingTop).toBe(7);
129
+ });
130
+ });
@@ -10,11 +10,8 @@ import React, {
10
10
  import {
11
11
  Pressable,
12
12
  StyleSheet,
13
- Text,
14
- TouchableOpacity,
15
13
  useWindowDimensions,
16
14
  View,
17
- type GestureResponderEvent,
18
15
  type StyleProp,
19
16
  type ViewStyle,
20
17
  } from 'react-native';
@@ -29,12 +26,12 @@ import Animated, {
29
26
 
30
27
  import { BottomSheet, type BottomSheetRef } from '../bottom-sheet';
31
28
  import { Z_INDEX } from '../styles/z-index';
32
- import type { ThemeColors } from '../theme/types';
33
29
  import { useTheme } from '../theme/use-theme';
34
- import { Context, useDialogContext, useDialogControl } from './context';
30
+ import { Context, useDialogControl } from './context';
31
+ import { DialogBody } from './DialogContent';
32
+ import { DialogBottomSheet } from './DialogBottomSheet';
35
33
  import {
36
34
  ANIMATION_DURATION,
37
- DEFAULT_MAX_HEIGHT_RATIO,
38
35
  DEFAULT_SIDE_WIDTH,
39
36
  DIALOG_SHEET_BACKDROP_TESTID,
40
37
  PANEL_RADIUS,
@@ -44,7 +41,6 @@ import {
44
41
  } from './placement';
45
42
  import type {
46
43
  DialogAction,
47
- DialogActionColor,
48
44
  DialogControlProps,
49
45
  DialogInset,
50
46
  DialogProps,
@@ -74,6 +70,31 @@ export { DIALOG_SHEET_BACKDROP_TESTID };
74
70
  * 3. Pure children — no `title`/`description`/`actions`.
75
71
  */
76
72
  export function Dialog({
73
+ placement,
74
+ ...rest
75
+ }: DialogProps) {
76
+ const resolvedPlacement = useResolvedPlacement(placement);
77
+
78
+ // `bottom` routes to the shared cross-platform `BottomSheet` surface — one
79
+ // code path with drag-to-dismiss on web and native. `center` (default) and
80
+ // the side-sheets keep their existing native lifecycle below. Each branch is
81
+ // its own component so the dispatcher only ever calls `useResolvedPlacement`,
82
+ // keeping the rules-of-hooks contract intact across a responsive placement
83
+ // change.
84
+ if (resolvedPlacement === 'bottom') {
85
+ return <DialogBottomSheet {...rest} />;
86
+ }
87
+ return <CenteredOrSideDialog {...rest} placement={resolvedPlacement} />;
88
+ }
89
+
90
+ /**
91
+ * Native `center` and `left`/`right` placements. `center` uses bloom's
92
+ * `BottomSheet` in `detached` mode (a floating, content-hugging card that
93
+ * degrades from sheet to centered card via the 500px cap); the side placements
94
+ * use a reanimated drawer. These share one open/close lifecycle. The `bottom`
95
+ * placement is handled separately by `DialogBottomSheet`.
96
+ */
97
+ function CenteredOrSideDialog({
77
98
  control,
78
99
  open: controlledOpen,
79
100
  onClose,
@@ -83,9 +104,7 @@ export function Dialog({
83
104
  actions,
84
105
  placement,
85
106
  width = DEFAULT_SIDE_WIDTH,
86
- maxHeightRatio = DEFAULT_MAX_HEIGHT_RATIO,
87
107
  inset,
88
- showHandle = true,
89
108
  dismissOnBackdrop = true,
90
109
  style,
91
110
  panelStyle,
@@ -94,10 +113,9 @@ export function Dialog({
94
113
  containerClassName,
95
114
  label,
96
115
  children,
97
- }: DialogProps) {
116
+ }: Omit<DialogProps, 'placement'> & { placement: 'center' | 'left' | 'right' }) {
98
117
  const isControlled = controlledOpen !== undefined;
99
- const resolvedPlacement = useResolvedPlacement(placement);
100
- const isSide = resolvedPlacement === 'left' || resolvedPlacement === 'right';
118
+ const isSide = placement === 'left' || placement === 'right';
101
119
 
102
120
  // Imperative open state for the side placement (the BottomSheet path owns its
103
121
  // own visibility via its ref instead).
@@ -220,7 +238,7 @@ export function Dialog({
220
238
  <SideSheet
221
239
  open={sideOpen}
222
240
  onDismiss={handleDismiss}
223
- side={resolvedPlacement}
241
+ side={placement}
224
242
  width={width}
225
243
  inset={inset}
226
244
  dismissOnBackdrop={dismissOnBackdrop}
@@ -242,19 +260,17 @@ export function Dialog({
242
260
  );
243
261
  }
244
262
 
245
- const isBottom = resolvedPlacement === 'bottom';
246
-
247
263
  return (
248
264
  <BottomSheet
249
265
  ref={ref}
250
266
  onDismiss={handleDismiss}
251
267
  enablePanDownToClose
252
- detached={!isBottom}
253
- showHandle={isBottom ? showHandle : true}
268
+ detached
269
+ showHandle
254
270
  // Stronger dim when a Dialog is stacked over another sheet so the
255
271
  // underlying sheet's handle/content doesn't bleed through.
256
272
  backdropOpacity={0.7}
257
- style={isBottom ? bottomSheetStyle(theme.colors.background, maxHeightRatio) : sheetStyle}
273
+ style={sheetStyle}
258
274
  >
259
275
  <Context.Provider value={context}>
260
276
  <View
@@ -280,71 +296,6 @@ export function Dialog({
280
296
  );
281
297
  }
282
298
 
283
- function bottomSheetStyle(background: string, maxHeightRatio: number): ViewStyle {
284
- return {
285
- backgroundColor: background,
286
- borderTopLeftRadius: PANEL_RADIUS + 4,
287
- borderTopRightRadius: PANEL_RADIUS + 4,
288
- maxHeight: `${Math.round(maxHeightRatio * 100)}%`,
289
- };
290
- }
291
-
292
- /**
293
- * Renders the dialog's body: optional declarative title + description, any
294
- * `children`, then the action row. Shared by all placements. The
295
- * `titleId`/`descriptionId` are wired by the caller for accessibility.
296
- */
297
- function DialogBody({
298
- titleId,
299
- descriptionId,
300
- title,
301
- description,
302
- actions,
303
- children,
304
- }: {
305
- titleId: string;
306
- descriptionId: string;
307
- title?: string;
308
- description?: string;
309
- actions?: DialogAction[];
310
- children?: React.ReactNode;
311
- }) {
312
- const theme = useTheme();
313
- return (
314
- <>
315
- {title ? (
316
- <Text
317
- nativeID={titleId}
318
- style={{
319
- fontSize: 22,
320
- fontWeight: '600',
321
- color: theme.colors.text,
322
- paddingBottom: description ? 4 : 16,
323
- lineHeight: 30,
324
- }}
325
- >
326
- {title}
327
- </Text>
328
- ) : null}
329
- {description ? (
330
- <Text
331
- nativeID={descriptionId}
332
- style={{
333
- fontSize: 16,
334
- color: theme.colors.textSecondary,
335
- paddingBottom: 16,
336
- lineHeight: 22,
337
- }}
338
- >
339
- {description}
340
- </Text>
341
- ) : null}
342
- {children}
343
- {actions && actions.length > 0 ? <ActionRow actions={actions} /> : null}
344
- </>
345
- );
346
- }
347
-
348
299
  /**
349
300
  * Reanimated side-sheet/drawer for the `left`/`right` placements (native).
350
301
  *
@@ -516,89 +467,6 @@ function SideSheet({
516
467
  );
517
468
  }
518
469
 
519
- function ActionRow({ actions }: { actions: DialogAction[] }) {
520
- return (
521
- <View style={{ width: '100%', gap: 8, justifyContent: 'flex-end' }}>
522
- {actions.map((action, idx) => (
523
- <ActionButton
524
- key={`${action.label}-${idx}`}
525
- action={action}
526
- />
527
- ))}
528
- </View>
529
- );
530
- }
531
-
532
- function ActionButton({ action }: { action: DialogAction }) {
533
- const { close } = useDialogContext();
534
- const theme = useTheme();
535
- const color: DialogActionColor = action.color ?? 'default';
536
- const shouldCloseOnPress = action.shouldCloseOnPress ?? true;
537
-
538
- const { background, foreground } = getActionPalette(color, theme.colors);
539
-
540
- const handlePress = useCallback(
541
- (e: GestureResponderEvent) => {
542
- const onPress = action.onPress;
543
- if (color === 'cancel') {
544
- // Cancel always dismisses; consumer's onPress (rare) runs after.
545
- close(onPress ? () => onPress(e) : undefined);
546
- return;
547
- }
548
- if (shouldCloseOnPress) {
549
- close(onPress ? () => onPress(e) : undefined);
550
- } else {
551
- onPress?.(e);
552
- }
553
- },
554
- [action.onPress, close, color, shouldCloseOnPress],
555
- );
556
-
557
- return (
558
- <TouchableOpacity
559
- style={{
560
- borderRadius: 9999,
561
- alignItems: 'center',
562
- justifyContent: 'center',
563
- backgroundColor: background,
564
- opacity: action.disabled ? 0.5 : 1,
565
- paddingVertical: 12,
566
- paddingHorizontal: 24,
567
- }}
568
- onPress={handlePress}
569
- disabled={action.disabled}
570
- activeOpacity={0.7}
571
- testID={action.testID}
572
- >
573
- <Text style={{ fontSize: 16, fontWeight: '500', color: foreground }}>
574
- {action.label}
575
- </Text>
576
- </TouchableOpacity>
577
- );
578
- }
579
-
580
- function getActionPalette(
581
- color: DialogActionColor,
582
- colors: ThemeColors,
583
- ): { background: string; foreground: string } {
584
- switch (color) {
585
- case 'destructive':
586
- return {
587
- background: colors.negative,
588
- foreground: colors.negativeForeground,
589
- };
590
- case 'cancel':
591
- return { background: colors.contrast50, foreground: colors.text };
592
- case 'default':
593
- return { background: colors.primary, foreground: colors.primaryForeground };
594
- /* c8 ignore next 2 -- TS exhaustiveness check guards this branch */
595
- default: {
596
- const _exhaustive: never = color;
597
- return { background: colors.primary, foreground: colors.primaryForeground };
598
- }
599
- }
600
- }
601
-
602
470
  const sideStyles = StyleSheet.create({
603
471
  root: {
604
472
  ...StyleSheet.absoluteFill,