@oneplatformdev/react-native-uikit 1.2.10 → 1.2.11

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/README.md CHANGED
@@ -359,9 +359,9 @@ For non-scrolling keyboard avoidance, omitting `keyboardAvoidingBehavior` keeps
359
359
 
360
360
  `CustomAlert` animates a centered modal with a dismissible backdrop. Supply `header`, children, and optional `footer` content; compose your own actions with `Button`. Optional `closeAction` accepts an icon and an application-supplied accessibility label. No strings are translated by the kit.
361
361
 
362
- `BottomModal` provides slide/handle-drag dismissal and keyboard-height-aware positioning. Supply `children`, `visible`, `onClose`, and optionally `title`, `customHeader`, and a labeled `closeAction` icon. Without `sizing`, the existing `isFullScreen` prop still selects the original fullscreen layout; otherwise the modal follows its content. When both are supplied, `sizing` takes precedence over `isFullScreen`. Use `sizing={{ mode: 'content' }}` for intrinsic height, `sizing={{ mode: 'fixed', fraction: 0.5 }}` for half the available height, or `sizing={{ mode: 'fullscreen' }}` to fill the area below `topInset`. A fixed fraction must be finite, greater than 0 and at most 1; invalid values throw in development. In production, positive finite values above 1 clamp to 1, while non-positive or non-finite values use a fixed 0.5 fallback. Normal `ScrollView` or `FlatList` children remain caller-owned for tall content.
362
+ `BottomModal` provides slide/handle-drag dismissal and keyboard-height-aware positioning. Supply `children`, `visible`, `onClose`, and optionally `title`, `customHeader`, and a labeled `closeAction` icon. Without `sizing`, the existing `isFullScreen` prop still selects the original fullscreen layout; otherwise the modal follows its content. When both are supplied, `sizing` takes precedence over `isFullScreen`. Use `sizing={{ mode: 'content' }}` for intrinsic height, `sizing={{ mode: 'fixed', fraction: 0.5 }}` for half the available height, `sizing={{ mode: 'fullscreen' }}` to fill the area below `topInset`, or `sizing={{ mode: 'expandable', initialFraction: 0.6 }}` for two positions: 60% and the full available height. A fixed fraction must be finite, greater than 0 and at most 1; invalid values throw in development. In production, positive finite values above 1 clamp to 1, while non-positive or non-finite values use a fixed 0.5 fallback. An expandable fraction must be finite, greater than 0 and less than 1; invalid values throw in development and use 0.6 in production. Normal `ScrollView` or `FlatList` children remain caller-owned for tall content.
363
363
 
364
- `topInset` defaults to the resolved top safe-area inset; `topInset={0}` starts at the physical top. `showHeader={false}` removes the package header and ignores `customHeader`. `backdrop={false}` hides the backdrop; an object may override its `color`, `opacity`, or `dismissOnPress`, leaving other defaults intact. `cornerRadius` is an unscaled design value. `dragToDismiss="handle"` is the default; `"none"` disables dragging, and a hidden header has no drag surface. Content/list drag and multiple positions are not supported.
364
+ `topInset` defaults to the resolved top safe-area inset; `topInset={0}` starts at the physical top. `showHeader={false}` removes the package header and ignores `customHeader`. Expandable sizing requires `showHeader=true` to provide a resize gesture target; disabling it throws in development and leaves the modal collapsed without gesture resizing in production. A `customHeader` participates in the same handle gesture area. `backdrop={false}` hides the backdrop; an object may override its `color`, `opacity`, or `dismissOnPress`, leaving other defaults intact. `cornerRadius` is an unscaled design value. `dragToDismiss="handle"` is the default; `"none"` disables drag dismissal. In expandable mode, dragging the header up expands; dragging down collapses first, then dismisses from collapsed only when drag dismissal is enabled. The height animates after gesture release rather than tracking the finger. With `"none"`, header resizing still works. Scrollable content owns its gestures and does not resize the modal. Each new presentation starts collapsed; orientation changes recompute both heights. Backdrop and hardware Back always request close rather than collapse.
365
365
 
366
366
  A close action, dismissible backdrop, handle gesture, or Android hardware back calls `onCloseRequest(reason)` once when closing starts. The keyboard is dismissed, and the existing `onClose` runs after the 250-ms exit animation; the parent still owns `visible`. An external `visible: true → false` hides immediately, cancels any pending package animation, and does not call `onCloseRequest` or `onClose`. `onDismiss` fires once after a committed controlled `true → false` transition, not after a guaranteed native OS dialog teardown. An internal close calls it only if the parent subsequently sets `visible=false`; do not use it yet to sequence native modal stacking or camera/gallery presentation. `closeRef` retains its previous close behavior without a close-request reason. The kit does not render app-specific toasts.
367
367
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oneplatformdev/react-native-uikit",
3
- "version": "1.2.10",
3
+ "version": "1.2.11",
4
4
  "main": "src/index.ts",
5
5
  "types": "src/index.ts",
6
6
  "bin": {
@@ -5,7 +5,7 @@ import { useBottomModalInsets } from './useBottomModalInsets';
5
5
 
6
6
  type UseBottomModalProps = Pick<
7
7
  BottomModalProps,
8
- 'visible' | 'onClose' | 'onCloseRequest' | 'onDismiss' | 'closeRef' | 'topInset'
8
+ 'visible' | 'onClose' | 'onCloseRequest' | 'onDismiss' | 'closeRef' | 'topInset' | 'sizing' | 'dragToDismiss'
9
9
  >;
10
10
 
11
11
  const CLOSE_DISTANCE = 120;
@@ -15,6 +15,8 @@ const CLOSE_ANIMATION_DURATION = 250;
15
15
 
16
16
  const isDragDownGesture = (gestureState: { dx: number; dy: number }) =>
17
17
  gestureState.dy > 0 && Math.abs(gestureState.dy) > Math.abs(gestureState.dx);
18
+ const isVerticalGesture = (gestureState: { dx: number; dy: number }) =>
19
+ gestureState.dy !== 0 && Math.abs(gestureState.dy) > Math.abs(gestureState.dx);
18
20
 
19
21
  export const useBottomModal = ({
20
22
  visible,
@@ -23,11 +25,24 @@ export const useBottomModal = ({
23
25
  onDismiss,
24
26
  closeRef,
25
27
  topInset: requestedTopInset,
28
+ sizing,
29
+ dragToDismiss,
26
30
  }: UseBottomModalProps) => {
27
31
  const { height } = useWindowDimensions();
28
32
  const { topInset, bottomInset } = useBottomModalInsets(requestedTopInset);
33
+ const isExpandable = sizing?.mode === 'expandable';
34
+ const initialFraction = sizing?.mode === 'expandable' ? sizing.initialFraction : undefined;
35
+ const validInitialFraction = initialFraction !== undefined && Number.isFinite(initialFraction)
36
+ && initialFraction > 0 && initialFraction < 1;
37
+ if (__DEV__ && isExpandable && !validInitialFraction) {
38
+ throw new Error('BottomModal expandable initialFraction must be finite, greater than 0 and less than 1.');
39
+ }
40
+ const maxHeight = Math.max(0, height - topInset);
41
+ const collapsedHeight = maxHeight * (validInitialFraction ? initialFraction : 0.6);
29
42
  const [translateY] = useState(() => new Animated.Value(height));
30
43
  const [backdropOpacity] = useState(() => new Animated.Value(0));
44
+ const [expandableHeight] = useState(() => new Animated.Value(collapsedHeight));
45
+ const expandedRef = useRef(false);
31
46
  const previousVisibleRef = useRef(visible);
32
47
  const visibleRef = useRef(visible);
33
48
  const openedRef = useRef(false);
@@ -39,6 +54,28 @@ export const useBottomModal = ({
39
54
  onCloseRef.current = onClose;
40
55
  onCloseRequestRef.current = onCloseRequest;
41
56
 
57
+ useLayoutEffect(() => {
58
+ if (!isExpandable) {
59
+ expandedRef.current = false;
60
+ expandableHeight.stopAnimation();
61
+ return;
62
+ }
63
+ expandableHeight.stopAnimation();
64
+ expandableHeight.setValue(expandedRef.current ? maxHeight : collapsedHeight);
65
+ }, [collapsedHeight, expandableHeight, isExpandable, maxHeight]);
66
+
67
+ const onResize = useCallback((expanded: boolean) => {
68
+ if (closingRef.current) return;
69
+ expandedRef.current = expanded;
70
+ expandableHeight.stopAnimation();
71
+ Animated.timing(expandableHeight, {
72
+ toValue: expanded ? maxHeight : collapsedHeight,
73
+ duration: CLOSE_ANIMATION_DURATION,
74
+ easing: Easing.out(Easing.cubic),
75
+ useNativeDriver: false,
76
+ }).start();
77
+ }, [collapsedHeight, expandableHeight, maxHeight]);
78
+
42
79
  const onRunCloseAnimation = useCallback(
43
80
  (shouldNotify: boolean, reason?: BottomModalCloseReason) => {
44
81
  if (closingRef.current) return;
@@ -46,6 +83,7 @@ export const useBottomModal = ({
46
83
  const animationId = ++animationIdRef.current;
47
84
  if (reason) onCloseRequestRef.current?.(reason);
48
85
  Keyboard.dismiss();
86
+ if (isExpandable) expandableHeight.stopAnimation();
49
87
  translateY.stopAnimation();
50
88
  backdropOpacity.stopAnimation();
51
89
  Animated.parallel([
@@ -67,7 +105,7 @@ export const useBottomModal = ({
67
105
  if (shouldNotify) onCloseRef.current();
68
106
  });
69
107
  },
70
- [backdropOpacity, height, translateY],
108
+ [backdropOpacity, expandableHeight, height, isExpandable, translateY],
71
109
  );
72
110
  const onRunOpenAnimation = useCallback((reset: boolean) => {
73
111
  ++animationIdRef.current;
@@ -108,16 +146,23 @@ export const useBottomModal = ({
108
146
  translateY.stopAnimation();
109
147
  backdropOpacity.stopAnimation();
110
148
  if (visible) {
149
+ expandedRef.current = false;
150
+ if (isExpandable) expandableHeight.setValue(collapsedHeight);
111
151
  openedRef.current = true;
112
152
  onRunOpenAnimation(false);
113
153
  } else {
114
154
  openedRef.current = false;
155
+ if (isExpandable) {
156
+ expandableHeight.stopAnimation();
157
+ expandedRef.current = false;
158
+ expandableHeight.setValue(collapsedHeight);
159
+ }
115
160
  translateY.setValue(height);
116
161
  backdropOpacity.setValue(0);
117
162
  // Report the controlled hidden commit, not native dialog teardown.
118
163
  onDismiss?.();
119
164
  }
120
- }, [backdropOpacity, height, onDismiss, onRunOpenAnimation, translateY, visible]);
165
+ }, [backdropOpacity, collapsedHeight, expandableHeight, height, isExpandable, onDismiss, onRunOpenAnimation, translateY, visible]);
121
166
 
122
167
  const onClosePress = useCallback(() => onRunCloseAnimation(true), [onRunCloseAnimation]);
123
168
  const onCloseAction = useCallback(() => onRunCloseAnimation(true, 'closeAction'), [onRunCloseAnimation]);
@@ -129,7 +174,8 @@ export const useBottomModal = ({
129
174
  ++animationIdRef.current;
130
175
  translateY.stopAnimation();
131
176
  backdropOpacity.stopAnimation();
132
- }, [backdropOpacity, translateY]);
177
+ expandableHeight.stopAnimation();
178
+ }, [backdropOpacity, expandableHeight, translateY]);
133
179
 
134
180
  useEffect(() => {
135
181
  if (!closeRef) return;
@@ -139,12 +185,23 @@ export const useBottomModal = ({
139
185
 
140
186
  const panResponder = useMemo(
141
187
  () => PanResponder.create({
142
- onMoveShouldSetPanResponderCapture: (_, gestureState) => isDragDownGesture(gestureState),
143
- onMoveShouldSetPanResponder: (_, gestureState) => isDragDownGesture(gestureState),
188
+ onMoveShouldSetPanResponderCapture: (_, gestureState) => isExpandable
189
+ ? isVerticalGesture(gestureState) : isDragDownGesture(gestureState),
190
+ onMoveShouldSetPanResponder: (_, gestureState) => isExpandable
191
+ ? isVerticalGesture(gestureState) : isDragDownGesture(gestureState),
144
192
  onPanResponderMove: (_, gestureState) => {
193
+ if (isExpandable) return;
145
194
  if (gestureState.dy > 0) translateY.setValue(gestureState.dy);
146
195
  },
147
196
  onPanResponderRelease: (_, gestureState) => {
197
+ if (isExpandable) {
198
+ const swipedUp = -gestureState.dy > CLOSE_DISTANCE || -gestureState.vy > CLOSE_VELOCITY;
199
+ const swipedDown = gestureState.dy > CLOSE_DISTANCE || gestureState.vy > CLOSE_VELOCITY;
200
+ if (expandedRef.current && swipedDown) onResize(false);
201
+ else if (!expandedRef.current && swipedUp) onResize(true);
202
+ else if (!expandedRef.current && swipedDown && dragToDismiss === 'handle') onGestureClose();
203
+ return;
204
+ }
148
205
  if (gestureState.dy > CLOSE_DISTANCE || gestureState.vy > CLOSE_VELOCITY) {
149
206
  onGestureClose();
150
207
  return;
@@ -152,10 +209,11 @@ export const useBottomModal = ({
152
209
  Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0, speed: 18 }).start();
153
210
  },
154
211
  onPanResponderTerminate: () => {
212
+ if (isExpandable) return;
155
213
  Animated.spring(translateY, { toValue: 0, useNativeDriver: true, bounciness: 0, speed: 18 }).start();
156
214
  },
157
215
  }),
158
- [onGestureClose, translateY],
216
+ [dragToDismiss, isExpandable, onGestureClose, onResize, translateY],
159
217
  );
160
218
 
161
219
  return {
@@ -164,6 +222,8 @@ export const useBottomModal = ({
164
222
  bottomInset,
165
223
  translateY,
166
224
  backdropOpacity,
225
+ expandableHeight,
226
+ isExpandable,
167
227
  onCloseAction,
168
228
  onBackdropPress,
169
229
  onHardwareBack,
@@ -11,7 +11,8 @@ export interface BottomModalCloseAction {
11
11
  export type BottomModalSizing =
12
12
  | { mode: 'content' }
13
13
  | { mode: 'fixed'; fraction: number }
14
- | { mode: 'fullscreen' };
14
+ | { mode: 'fullscreen' }
15
+ | { mode: 'expandable'; initialFraction: number };
15
16
 
16
17
  export type BottomModalBackdrop = false | {
17
18
  color?: string;
@@ -44,12 +44,14 @@ export const BottomModal = ({
44
44
  topInset: resolvedTopInset,
45
45
  translateY,
46
46
  backdropOpacity,
47
+ expandableHeight,
48
+ isExpandable,
47
49
  onCloseAction,
48
50
  onBackdropPress,
49
51
  onHardwareBack,
50
52
  onShow,
51
53
  panHandlers,
52
- } = useBottomModal({ visible, onClose, onCloseRequest, onDismiss, closeRef, topInset });
54
+ } = useBottomModal({ visible, onClose, onCloseRequest, onDismiss, closeRef, topInset, sizing, dragToDismiss });
53
55
 
54
56
  const {
55
57
  modalContentStyle,
@@ -59,9 +61,13 @@ export const BottomModal = ({
59
61
  isBoundedHeight,
60
62
  } = useBottomModalAnimatedStyles(
61
63
  height, resolvedTopInset, bottomInset, translateY, backdropOpacity,
62
- isFullScreen, shouldAvoidKeyboard, sizing, targetBackdropOpacity,
64
+ isFullScreen, shouldAvoidKeyboard, sizing, targetBackdropOpacity, expandableHeight,
63
65
  );
64
66
 
67
+ if (__DEV__ && isExpandable && !showHeader) {
68
+ throw new Error('BottomModal expandable sizing requires showHeader=true for its resize gesture.');
69
+ }
70
+
65
71
  const styles = useMemo(
66
72
  () => getStyles(colors, spacing, radius, scaling, bottomInset, backdropColor, resolvedCornerRadius),
67
73
  [backdropColor, bottomInset, colors, radius, resolvedCornerRadius, scaling, spacing],
@@ -89,7 +95,7 @@ export const BottomModal = ({
89
95
  style={[styles.modalContent, modalContentStyle]}
90
96
  >
91
97
  <View style={[styles.container, isBoundedHeight && styles.fullScreenContainer]}>
92
- {showHeader ? <View {...(dragToDismiss === 'handle' ? panHandlers : {})}>
98
+ {showHeader ? <View {...(isExpandable || dragToDismiss === 'handle' ? panHandlers : {})}>
93
99
  <TouchableWithoutFeedback>
94
100
  {customHeader ? (
95
101
  <View>{customHeader}</View>
@@ -1,6 +1,5 @@
1
1
  import { useMemo } from 'react';
2
2
  import { Animated, StyleSheet } from 'react-native';
3
- import type { ViewStyle } from 'react-native';
4
3
  import { useAnimatedStyle } from 'react-native-reanimated';
5
4
  import { useAnimatedKeyboard } from 'react-native-keyboard-controller';
6
5
  import type { UIColors, UIRadius, UISpacing } from '../../../theme';
@@ -100,6 +99,7 @@ export const useBottomModalAnimatedStyles = (
100
99
  shouldAvoidKeyboard: boolean,
101
100
  sizing?: BottomModalSizing,
102
101
  targetBackdropOpacity = 0.5,
102
+ expandableHeight?: Animated.Value,
103
103
  ) => {
104
104
  const maxHeight = Math.max(0, height - topInset);
105
105
  const mode = sizing?.mode ?? (isFullScreen ? 'fullscreen' : 'content');
@@ -112,7 +112,7 @@ export const useBottomModalAnimatedStyles = (
112
112
  ? Math.min(1, fraction) : 0.5;
113
113
  const modalHeight = mode === 'fullscreen' ? maxHeight
114
114
  : mode === 'fixed' ? maxHeight * resolvedFraction : undefined;
115
- const isBoundedHeight = modalHeight !== undefined;
115
+ const isBoundedHeight = modalHeight !== undefined || mode === 'expandable';
116
116
  const { height: keyboardHeight } = useAnimatedKeyboard();
117
117
  const animatedBackdropStyle = useMemo(
118
118
  () => ({ opacity: backdropOpacity.interpolate({ inputRange: [0, 1], outputRange: [0, targetBackdropOpacity] }) }),
@@ -126,9 +126,13 @@ export const useBottomModalAnimatedStyles = (
126
126
  const animatedKeyboardBackgroundStyle = useAnimatedStyle(() => ({
127
127
  height: shouldAvoidKeyboard ? Math.max(keyboardHeight.value - bottomInset, 0) : 0,
128
128
  }));
129
- const modalContentStyle = useMemo<ViewStyle>(
130
- () => ({ maxHeight, height: modalHeight, transform: [{ translateY }] }),
131
- [maxHeight, modalHeight, translateY],
129
+ const modalContentStyle = useMemo(
130
+ () => ({
131
+ maxHeight,
132
+ height: mode === 'expandable' ? expandableHeight : modalHeight,
133
+ transform: [{ translateY }],
134
+ }),
135
+ [expandableHeight, maxHeight, modalHeight, mode, translateY],
132
136
  );
133
137
 
134
138
  return { animatedBackdropStyle, animatedKeyboardContainerStyle, animatedKeyboardBackgroundStyle, modalContentStyle, isBoundedHeight };