@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.
- package/lib/commonjs/dialog/Dialog.js +38 -150
- package/lib/commonjs/dialog/Dialog.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.web.js +55 -193
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/dialog/DialogBottomSheet.js +205 -0
- package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -0
- package/lib/commonjs/dialog/DialogContent.js +155 -0
- package/lib/commonjs/dialog/DialogContent.js.map +1 -0
- package/lib/module/dialog/Dialog.js +41 -153
- package/lib/module/dialog/Dialog.js.map +1 -1
- package/lib/module/dialog/Dialog.web.js +57 -195
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/dialog/DialogBottomSheet.js +200 -0
- package/lib/module/dialog/DialogBottomSheet.js.map +1 -0
- package/lib/module/dialog/DialogContent.js +148 -0
- package/lib/module/dialog/DialogContent.js.map +1 -0
- package/lib/typescript/commonjs/dialog/Dialog.d.ts +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts +11 -8
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts +29 -0
- package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -0
- package/lib/typescript/commonjs/dialog/DialogContent.d.ts +32 -0
- package/lib/typescript/commonjs/dialog/DialogContent.d.ts.map +1 -0
- package/lib/typescript/module/dialog/Dialog.d.ts +1 -1
- package/lib/typescript/module/dialog/Dialog.d.ts.map +1 -1
- package/lib/typescript/module/dialog/Dialog.web.d.ts +11 -8
- package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/module/dialog/DialogBottomSheet.d.ts +29 -0
- package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -0
- package/lib/typescript/module/dialog/DialogContent.d.ts +32 -0
- package/lib/typescript/module/dialog/DialogContent.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/BottomSheetGesture.test.tsx +135 -0
- package/src/__tests__/DialogBottomSheet.test.tsx +130 -0
- package/src/dialog/Dialog.tsx +34 -166
- package/src/dialog/Dialog.web.tsx +46 -206
- package/src/dialog/DialogBottomSheet.tsx +235 -0
- 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
|
@@ -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
|
+
});
|
package/src/dialog/Dialog.tsx
CHANGED
|
@@ -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,
|
|
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
|
|
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={
|
|
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
|
|
253
|
-
showHandle
|
|
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={
|
|
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,
|