jfs-components 0.0.95 → 0.1.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 (31) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/commonjs/components/Drawer/Drawer.js +146 -82
  3. package/lib/commonjs/components/Dropdown/Dropdown.js +37 -18
  4. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +22 -1
  5. package/lib/commonjs/components/Spinner/Spinner.js +5 -1
  6. package/lib/commonjs/components/TestimonialsCard/TestimonialsCard.js +121 -0
  7. package/lib/commonjs/components/index.js +7 -0
  8. package/lib/commonjs/icons/registry.js +1 -1
  9. package/lib/commonjs/skeleton/Skeleton.js +10 -2
  10. package/lib/module/components/Drawer/Drawer.js +148 -84
  11. package/lib/module/components/Dropdown/Dropdown.js +37 -18
  12. package/lib/module/components/FullscreenModal/FullscreenModal.js +22 -1
  13. package/lib/module/components/Spinner/Spinner.js +6 -2
  14. package/lib/module/components/TestimonialsCard/TestimonialsCard.js +116 -0
  15. package/lib/module/components/index.js +1 -0
  16. package/lib/module/icons/registry.js +1 -1
  17. package/lib/module/skeleton/Skeleton.js +11 -3
  18. package/lib/typescript/src/components/Drawer/Drawer.d.ts +23 -4
  19. package/lib/typescript/src/components/FullscreenModal/FullscreenModal.d.ts +7 -1
  20. package/lib/typescript/src/components/TestimonialsCard/TestimonialsCard.d.ts +51 -0
  21. package/lib/typescript/src/components/index.d.ts +2 -1
  22. package/lib/typescript/src/icons/registry.d.ts +1 -1
  23. package/package.json +1 -1
  24. package/src/components/Drawer/Drawer.tsx +94 -15
  25. package/src/components/Dropdown/Dropdown.tsx +38 -18
  26. package/src/components/FullscreenModal/FullscreenModal.tsx +29 -2
  27. package/src/components/Spinner/Spinner.tsx +2 -2
  28. package/src/components/TestimonialsCard/TestimonialsCard.tsx +162 -0
  29. package/src/components/index.ts +2 -1
  30. package/src/icons/registry.ts +1 -1
  31. package/src/skeleton/Skeleton.tsx +10 -3
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  import React, { useId, useMemo, useState } from 'react';
4
- import { StyleSheet, View } from 'react-native';
4
+ import { View } from 'react-native';
5
5
  import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
6
6
  import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from 'react-native-svg';
7
7
  import { getVariableByName } from '../design-tokens/figma-variables-resolver';
@@ -16,12 +16,20 @@ const SOLID_OVERLAY_COLOR = 'rgba(255, 255, 255, 1)';
16
16
  // `pointerEvents: 'none'` lives on the style (not the deprecated prop) so it
17
17
  // works on both native and React Native Web without warnings.
18
18
  const absoluteFillStyle = {
19
- ...StyleSheet.absoluteFillObject,
19
+ position: 'absolute',
20
+ top: 0,
21
+ left: 0,
22
+ right: 0,
23
+ bottom: 0,
20
24
  overflow: 'hidden',
21
25
  pointerEvents: 'none'
22
26
  };
23
27
  const solidOverlayStyle = {
24
- ...StyleSheet.absoluteFillObject,
28
+ position: 'absolute',
29
+ top: 0,
30
+ left: 0,
31
+ right: 0,
32
+ bottom: 0,
25
33
  backgroundColor: SOLID_OVERLAY_COLOR
26
34
  };
27
35
 
@@ -1,5 +1,5 @@
1
1
  import React from 'react';
2
- type DrawerProps = {
2
+ export type DrawerProps = {
3
3
  modes?: Record<string, any>;
4
4
  style?: import('react-native').StyleProp<import('react-native').ViewStyle>;
5
5
  title?: string;
@@ -11,7 +11,19 @@ type DrawerProps = {
11
11
  children?: React.ReactNode;
12
12
  collapsedHeight?: number;
13
13
  expandedRatio?: number;
14
+ /**
15
+ * Sets the drawer state when it first mounts (uncontrolled mode).
16
+ * Ignored once `state` is provided (controlled mode).
17
+ */
14
18
  initialState?: 'collapsed' | 'expanded';
19
+ /**
20
+ * Programmatically controls the drawer's collapsed/expanded state.
21
+ * When provided, the drawer becomes a controlled component: it will
22
+ * animate to match this value (reusing the same spring used by gestures)
23
+ * and gesture-driven changes are reported via `onStateChange` for the
24
+ * parent to apply back. Leave undefined for uncontrolled behaviour.
25
+ */
26
+ state?: 'collapsed' | 'expanded';
15
27
  contentStyle?: any;
16
28
  sheetStyle?: any;
17
29
  accessibilityLabel?: string;
@@ -31,9 +43,16 @@ type DrawerProps = {
31
43
  onStateChange?: (state: 'collapsed' | 'expanded') => void;
32
44
  };
33
45
  /**
34
- * Drawer component with nested scrolling support.
35
- * Uses react-native-gesture-handler and react-native-reanimated.
46
+ * Imperative handle exposed via `ref` for programmatic control of the drawer.
47
+ * Each method animates using the same spring as gesture-driven transitions.
36
48
  */
37
- declare function Drawer({ modes, style, title, header, children, collapsedHeight, expandedRatio, initialState, contentStyle, sheetStyle, accessibilityLabel, accessibilityHint, contentContainerStyle, showsVerticalScrollIndicator, bottomInset, onStateChange, }: DrawerProps): import("react/jsx-runtime").JSX.Element;
49
+ export type DrawerHandle = {
50
+ expand: () => void;
51
+ collapse: () => void;
52
+ toggle: () => void;
53
+ /** Current settled state of the drawer. */
54
+ getState: () => 'collapsed' | 'expanded';
55
+ };
56
+ declare const Drawer: React.ForwardRefExoticComponent<DrawerProps & React.RefAttributes<DrawerHandle>>;
38
57
  export default Drawer;
39
58
  //# sourceMappingURL=Drawer.d.ts.map
@@ -29,6 +29,12 @@ export type FullscreenModalProps = {
29
29
  heroHeight?: number;
30
30
  /** Whether to render the floating close button (top-right). Defaults to true. */
31
31
  showClose?: boolean;
32
+ /**
33
+ * Additional vertical offset (in px) applied to the floating close button's
34
+ * top position, on top of the base inset (`12 + safe-area top`). Positive
35
+ * values push it further down. Defaults to 0.
36
+ */
37
+ closeOffsetY?: number;
32
38
  /** Press handler for the close button. */
33
39
  onClose?: () => void;
34
40
  /** Accessibility label for the close button. */
@@ -104,6 +110,6 @@ export type FullscreenModalProps = {
104
110
  * </FullscreenModal>
105
111
  * ```
106
112
  */
107
- declare function FullscreenModal({ eyebrow, headline, supportingText, priceText, heroMedia, heroHeight, showClose, onClose, closeAccessibilityLabel, footer, primaryActionLabel, onPrimaryAction, disclaimer, children, modes: propModes, style, contentContainerStyle, testID, }: FullscreenModalProps): import("react/jsx-runtime").JSX.Element;
113
+ declare function FullscreenModal({ eyebrow, headline, supportingText, priceText, heroMedia, heroHeight, showClose, closeOffsetY, onClose, closeAccessibilityLabel, footer, primaryActionLabel, onPrimaryAction, disclaimer, children, modes: propModes, style, contentContainerStyle, testID, }: FullscreenModalProps): import("react/jsx-runtime").JSX.Element;
108
114
  export default FullscreenModal;
109
115
  //# sourceMappingURL=FullscreenModal.d.ts.map
@@ -0,0 +1,51 @@
1
+ import React from 'react';
2
+ import { type ViewStyle, type StyleProp } from 'react-native';
3
+ import { type AvatarProps } from '../Avatar/Avatar';
4
+ export type TestimonialsCardProps = {
5
+ /**
6
+ * The testimonial heading, typically the author's name.
7
+ */
8
+ title?: string;
9
+ /**
10
+ * The testimonial body copy.
11
+ */
12
+ body?: string;
13
+ /**
14
+ * Mode configuration passed to the token resolver. Also forwarded to the
15
+ * Avatar child for consistent theming.
16
+ */
17
+ modes?: Record<string, any>;
18
+ /**
19
+ * Optional style overrides for the card container.
20
+ */
21
+ style?: StyleProp<ViewStyle>;
22
+ /**
23
+ * Props forwarded to the Avatar component (e.g. a custom `imageSource`).
24
+ */
25
+ avatarProps?: Partial<AvatarProps>;
26
+ /**
27
+ * Accessibility label for the card region. Falls back to a label generated
28
+ * from the title and body.
29
+ */
30
+ accessibilityLabel?: string;
31
+ };
32
+ /**
33
+ * TestimonialsCard renders a compact, fixed-width card with a circular avatar,
34
+ * a bold title, and a body paragraph. It is typically used inside a horizontal
35
+ * carousel of customer testimonials.
36
+ *
37
+ * All styling values are resolved from Figma design tokens using the provided
38
+ * `modes`.
39
+ *
40
+ * @example
41
+ * ```tsx
42
+ * <TestimonialsCard
43
+ * title="Aarav S."
44
+ * body="I was dreading renewing my car insurance, but JioFinance made it a breeze."
45
+ * />
46
+ * ```
47
+ */
48
+ declare function TestimonialsCard({ title, body, modes, style, avatarProps, accessibilityLabel, }: TestimonialsCardProps): import("react/jsx-runtime").JSX.Element;
49
+ declare const _default: React.MemoExoticComponent<typeof TestimonialsCard>;
50
+ export default _default;
51
+ //# sourceMappingURL=TestimonialsCard.d.ts.map
@@ -22,7 +22,7 @@ export { default as CardFinancialCondition, type CardFinancialConditionProps } f
22
22
  export { default as CardInsight, type CardInsightProps } from './CardInsight/CardInsight';
23
23
  export { default as Disclaimer } from './Disclaimer/Disclaimer';
24
24
  export { default as Divider, type DividerProps, type DividerDirection } from './Divider/Divider';
25
- export { default as Drawer } from './Drawer/Drawer';
25
+ export { default as Drawer, type DrawerProps, type DrawerHandle } from './Drawer/Drawer';
26
26
  export { default as Dropdown, DropdownItem, type DropdownProps, type DropdownItemProps } from './Dropdown/Dropdown';
27
27
  export { default as DropdownInput, type DropdownInputProps, type DropdownInputOption, type DropdownInputOptionValue } from './DropdownInput/DropdownInput';
28
28
  export { default as SuggestiveSearch, type SuggestiveSearchProps, type SuggestiveSearchOption, type SuggestiveSearchOptionValue, type SuggestiveSearchItem } from './SuggestiveSearch/SuggestiveSearch';
@@ -130,6 +130,7 @@ export { default as StatItem, type StatItemProps, type StatItemLabelPosition } f
130
130
  export { default as StatGroup, type StatGroupProps, type StatGroupItem } from './StatGroup/StatGroup';
131
131
  export { default as StrengthIndicator, type StrengthIndicatorProps, type StrengthIndicatorConfidence, type StrengthIndicatorConfidenceValue } from './StrengthIndicator/StrengthIndicator';
132
132
  export { default as SummaryTile, type SummaryTileProps } from './SummaryTile/SummaryTile';
133
+ export { default as TestimonialsCard, type TestimonialsCardProps } from './TestimonialsCard/TestimonialsCard';
133
134
  export { default as Text, type TextProps } from './Text/Text';
134
135
  export { default as SegmentedControl, type SegmentedControlProps, type SegmentedControlItem } from './SegmentedControl/SegmentedControl';
135
136
  export { default as Toggle, type ToggleProps } from './Toggle/Toggle';
@@ -4,7 +4,7 @@
4
4
  * Auto-generated from SVG files in src/icons/
5
5
  * DO NOT EDIT MANUALLY - Run "npm run icons:generate" to regenerate
6
6
  *
7
- * Generated: 2026-06-04T14:40:09.533Z
7
+ * Generated: 2026-06-09T13:55:40.250Z
8
8
  */
9
9
  export declare const iconRegistry: Record<string, {
10
10
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jfs-components",
3
- "version": "0.0.95",
3
+ "version": "0.1.0",
4
4
  "description": "React Native Jio Finance Components Library",
5
5
  "author": "sunshuaiqi@gmail.com",
6
6
  "license": "MIT",
@@ -1,9 +1,8 @@
1
- import React, { useCallback, useEffect, useState, useRef } from 'react'
1
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
2
2
  import { Platform, StyleSheet, Text, useWindowDimensions, View, ViewStyle } from 'react-native'
3
3
  import {
4
4
  Gesture,
5
5
  GestureDetector,
6
- GestureHandlerRootView,
7
6
  ScrollView,
8
7
  } from 'react-native-gesture-handler'
9
8
  import Animated, {
@@ -53,7 +52,7 @@ function rubberBand(value: number, min: number, max: number, friction: number =
53
52
  return value
54
53
  }
55
54
 
56
- type DrawerProps = {
55
+ export type DrawerProps = {
57
56
 
58
57
  modes?: Record<string, any>
59
58
  style?: import('react-native').StyleProp<import('react-native').ViewStyle>
@@ -66,7 +65,19 @@ type DrawerProps = {
66
65
  children?: React.ReactNode
67
66
  collapsedHeight?: number
68
67
  expandedRatio?: number
68
+ /**
69
+ * Sets the drawer state when it first mounts (uncontrolled mode).
70
+ * Ignored once `state` is provided (controlled mode).
71
+ */
69
72
  initialState?: 'collapsed' | 'expanded'
73
+ /**
74
+ * Programmatically controls the drawer's collapsed/expanded state.
75
+ * When provided, the drawer becomes a controlled component: it will
76
+ * animate to match this value (reusing the same spring used by gestures)
77
+ * and gesture-driven changes are reported via `onStateChange` for the
78
+ * parent to apply back. Leave undefined for uncontrolled behaviour.
79
+ */
80
+ state?: 'collapsed' | 'expanded'
70
81
  contentStyle?: any
71
82
  sheetStyle?: any
72
83
  accessibilityLabel?: string
@@ -86,11 +97,23 @@ type DrawerProps = {
86
97
  onStateChange?: (state: 'collapsed' | 'expanded') => void
87
98
  }
88
99
 
100
+ /**
101
+ * Imperative handle exposed via `ref` for programmatic control of the drawer.
102
+ * Each method animates using the same spring as gesture-driven transitions.
103
+ */
104
+ export type DrawerHandle = {
105
+ expand: () => void
106
+ collapse: () => void
107
+ toggle: () => void
108
+ /** Current settled state of the drawer. */
109
+ getState: () => 'collapsed' | 'expanded'
110
+ }
111
+
89
112
  /**
90
113
  * Drawer component with nested scrolling support.
91
114
  * Uses react-native-gesture-handler and react-native-reanimated.
92
115
  */
93
- function Drawer({
116
+ function DrawerInner({
94
117
 
95
118
  modes = EMPTY_MODES,
96
119
  style,
@@ -100,6 +123,7 @@ function Drawer({
100
123
  collapsedHeight = 200,
101
124
  expandedRatio = 0.90,
102
125
  initialState = 'collapsed',
126
+ state,
103
127
  contentStyle,
104
128
  sheetStyle,
105
129
  accessibilityLabel,
@@ -108,7 +132,7 @@ function Drawer({
108
132
  showsVerticalScrollIndicator = false,
109
133
  bottomInset = 80,
110
134
  onStateChange,
111
- }: DrawerProps) {
135
+ }: DrawerProps, ref: React.Ref<DrawerHandle>) {
112
136
  const { height: screenHeight } = useWindowDimensions()
113
137
 
114
138
  // Calculate snap points
@@ -168,15 +192,58 @@ function Drawer({
168
192
  translateY.value = withSpring(destination, SPRING_CONFIG)
169
193
  }, [translateY])
170
194
 
171
- // Update JS state for accessibility/logic if needed
195
+ // Update the JS-side mode. Pure: only schedules the state update. Side
196
+ // effects (notifying the parent via onStateChange) MUST NOT live inside the
197
+ // setState updater — doing so calls the parent's setState during React's
198
+ // render phase, which throws "Cannot update a component while rendering a
199
+ // different component" and causes an infinite update loop. We report changes
200
+ // from a dedicated effect below instead.
172
201
  const updateMode = useCallback((newMode: 'collapsed' | 'expanded') => {
173
- setMode((prev) => {
174
- if (prev !== newMode) {
175
- onStateChange?.(newMode)
176
- }
177
- return newMode
178
- })
179
- }, [onStateChange])
202
+ setMode(newMode)
203
+ }, [])
204
+
205
+ // Notify the parent exactly once per settled mode change, after commit.
206
+ const reportedModeRef = useRef(mode)
207
+ useEffect(() => {
208
+ if (reportedModeRef.current !== mode) {
209
+ reportedModeRef.current = mode
210
+ onStateChange?.(mode)
211
+ }
212
+ }, [mode, onStateChange])
213
+
214
+ // Programmatic transition (JS thread). Reuses the same spring as gestures:
215
+ // animates `translateY`, keeps the scroll-gate (`isFullyExpanded`) in sync,
216
+ // and updates `mode` (which notifies via the onStateChange effect above).
217
+ const applyMode = useCallback((newMode: 'collapsed' | 'expanded') => {
218
+ const target = newMode === 'expanded' ? minTranslateY : maxTranslateY
219
+ translateY.value = withSpring(target, SPRING_CONFIG)
220
+ isFullyExpanded.value = newMode === 'expanded'
221
+ setMode(newMode)
222
+ }, [minTranslateY, maxTranslateY, translateY, isFullyExpanded])
223
+
224
+ // Controlled mode: react ONLY to genuine changes of the `state` prop, tracked
225
+ // against its previous value. We must NOT reconcile `state` against the
226
+ // internal `mode` on every render: a gesture updates `mode` optimistically
227
+ // one render before the parent echoes it back through `onStateChange` into
228
+ // `state`, so a `state !== mode` check would "correct" the gesture and fight
229
+ // the echo, ping-ponging forever (Maximum update depth exceeded).
230
+ // `applyMode` is idempotent, so re-applying the already-current mode is a
231
+ // no-op when the parent simply mirrors our own change back.
232
+ const prevStateProp = useRef(state)
233
+ useEffect(() => {
234
+ if (state === undefined) return
235
+ if (state === prevStateProp.current) return
236
+ prevStateProp.current = state
237
+ applyMode(state)
238
+ }, [state, applyMode])
239
+
240
+ // Imperative API for parents holding a ref.
241
+ useImperativeHandle(ref, () => ({
242
+ expand: () => applyMode('expanded'),
243
+ collapse: () => applyMode('collapsed'),
244
+ toggle: () => applyMode(mode === 'expanded' ? 'collapsed' : 'expanded'),
245
+ getState: () => mode,
246
+ }), [applyMode, mode])
180
247
 
181
248
  // Gesture policy:
182
249
  // • activeOffsetY: require a clear *vertical* drag (10px) before this
@@ -362,7 +429,16 @@ function Drawer({
362
429
  const defaultAccessibilityLabel = accessibilityLabel || title || 'Drawer'
363
430
 
364
431
  return (
365
- <GestureHandlerRootView style={[styles.host, style]} pointerEvents="box-none">
432
+ // IMPORTANT: the host is a plain box-none View, NOT a GestureHandlerRootView.
433
+ // On Android, GestureHandlerRootView renders a *native* root view that
434
+ // intercepts every touch within its bounds (to feed the gesture system) and
435
+ // does NOT honor pointerEvents="box-none". Because this host fills the whole
436
+ // screen as an overlay, using GestureHandlerRootView here swallowed all
437
+ // touches to content rendered behind the drawer (buttons, page content).
438
+ // Per the standard react-native-gesture-handler architecture, a single
439
+ // GestureHandlerRootView must wrap the app root; this overlay only needs to
440
+ // let touches fall through where the sheet isn't.
441
+ <View style={[styles.host, style]} pointerEvents="box-none">
366
442
  <GestureDetector gesture={gesture}>
367
443
  <Animated.View
368
444
  style={[
@@ -457,7 +533,7 @@ function Drawer({
457
533
  </View>
458
534
  </Animated.View>
459
535
  </GestureDetector>
460
- </GestureHandlerRootView>
536
+ </View>
461
537
  )
462
538
  }
463
539
 
@@ -492,4 +568,7 @@ const styles = StyleSheet.create({
492
568
  },
493
569
  })
494
570
 
571
+ const Drawer = forwardRef<DrawerHandle, DrawerProps>(DrawerInner)
572
+ Drawer.displayName = 'Drawer'
573
+
495
574
  export default Drawer
@@ -290,15 +290,33 @@ export function Dropdown({
290
290
  const shadowBlur =
291
291
  parseInt(getVariableByName('dropdown/shadow/blur', modes), 10) || 16
292
292
 
293
- const containerStyle: ViewStyle = {
293
+ // Shadow lives on the OUTER view, which must NOT set `overflow: 'hidden'`.
294
+ // On native, clipping a view also clips its shadow (iOS clips the layer
295
+ // shadow; Android clips the elevation shadow), so the soft popup shadow
296
+ // that renders fine on web (CSS box-shadow paints outside the box) would
297
+ // get cut off. The rounded-corner clipping is moved to a separate inner
298
+ // view below.
299
+ //
300
+ // The `boxShadow` style prop (RN 0.76+ / react-native-web) is used as the
301
+ // single source of truth so the designed color/offset/blur are honored on
302
+ // iOS, Android AND web. We intentionally do NOT also set the legacy
303
+ // `shadow*` / `elevation` props: on the new architecture (and web) those
304
+ // are translated into a box-shadow internally, so combining them with an
305
+ // explicit `boxShadow` would stack two shadows. Android's legacy
306
+ // `elevation` is also undesirable here because it ignores the shadow color
307
+ // and only paints a generic gray shadow.
308
+ const shadowStyle: ViewStyle & { boxShadow?: string } = {
294
309
  backgroundColor: background,
310
+ borderRadius: radius,
311
+ boxShadow: `${shadowOffsetX}px ${shadowOffsetY}px ${shadowBlur}px 0px ${shadowColor}`,
312
+ }
313
+
314
+ // Inner view carries the rounded corners + clipping so list/scroll content
315
+ // stays inside the radius without affecting the outer view's shadow.
316
+ const clipStyle: ViewStyle = {
295
317
  borderRadius: radius,
296
318
  overflow: 'hidden',
297
- shadowColor,
298
- shadowOffset: { width: shadowOffsetX, height: shadowOffsetY },
299
- shadowOpacity: 1,
300
- shadowRadius: shadowBlur / 2,
301
- elevation: 4,
319
+ backgroundColor: background,
302
320
  }
303
321
 
304
322
  const content = (
@@ -309,21 +327,23 @@ export function Dropdown({
309
327
 
310
328
  return (
311
329
  <View
312
- style={[containerStyle, style]}
330
+ style={[shadowStyle, style]}
313
331
  accessibilityRole="menu"
314
332
  accessibilityLabel={accessibilityLabel || 'Dropdown menu'}
315
333
  >
316
- {maxHeight != null ? (
317
- <ScrollView
318
- style={{ maxHeight }}
319
- showsVerticalScrollIndicator={true}
320
- keyboardShouldPersistTaps="handled"
321
- >
322
- {content}
323
- </ScrollView>
324
- ) : (
325
- content
326
- )}
334
+ <View style={clipStyle}>
335
+ {maxHeight != null ? (
336
+ <ScrollView
337
+ style={{ maxHeight }}
338
+ showsVerticalScrollIndicator={true}
339
+ keyboardShouldPersistTaps="handled"
340
+ >
341
+ {content}
342
+ </ScrollView>
343
+ ) : (
344
+ content
345
+ )}
346
+ </View>
327
347
  </View>
328
348
  )
329
349
  }
@@ -7,6 +7,7 @@ import {
7
7
  type ViewStyle,
8
8
  type TextStyle,
9
9
  } from 'react-native'
10
+ import { useSafeAreaInsets } from 'react-native-safe-area-context'
10
11
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
11
12
  import { useTokens } from '../../design-tokens/JFSThemeProvider'
12
13
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils'
@@ -70,6 +71,12 @@ export type FullscreenModalProps = {
70
71
  heroHeight?: number
71
72
  /** Whether to render the floating close button (top-right). Defaults to true. */
72
73
  showClose?: boolean
74
+ /**
75
+ * Additional vertical offset (in px) applied to the floating close button's
76
+ * top position, on top of the base inset (`12 + safe-area top`). Positive
77
+ * values push it further down. Defaults to 0.
78
+ */
79
+ closeOffsetY?: number
73
80
  /** Press handler for the close button. */
74
81
  onClose?: () => void
75
82
  /** Accessibility label for the close button. */
@@ -229,6 +236,7 @@ function FullscreenModal({
229
236
  heroMedia,
230
237
  heroHeight = 420,
231
238
  showClose = true,
239
+ closeOffsetY = 0,
232
240
  onClose,
233
241
  closeAccessibilityLabel = 'Close',
234
242
  footer,
@@ -260,6 +268,23 @@ function FullscreenModal({
260
268
 
261
269
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16
262
270
 
271
+ // Safe-area insets so the floating chrome clears the system bars: the close
272
+ // button drops below the status bar / notch, and the sticky footer keeps its
273
+ // designed bottom padding ON TOP of the bottom inset (home indicator /
274
+ // Android gesture or nav bar). On web — and anywhere without a
275
+ // SafeAreaProvider — every inset is 0, so the layout is unchanged.
276
+ const insets = useSafeAreaInsets()
277
+ const closeButtonInsetStyle = useMemo<ViewStyle>(
278
+ () => ({ top: 12 + insets.top + closeOffsetY }),
279
+ [insets.top, closeOffsetY]
280
+ )
281
+ // Extend (not replace) the footer's token bottom padding by the bottom inset
282
+ // so the action button never sits under the system navigation area.
283
+ const footerInsetStyle = useMemo<ViewStyle>(() => {
284
+ const base = Number(getVariableByName('actionFooter/padding/bottom', modes)) || 41
285
+ return { paddingBottom: base + insets.bottom }
286
+ }, [modes, insets.bottom])
287
+
263
288
  // Drives the background's parallax-free sync with the scroll. The hero media
264
289
  // lives at the ROOT (so it is never clipped to the content height and sits
265
290
  // behind the transparent footer), but we translate it up by the exact scroll
@@ -388,7 +413,9 @@ function FullscreenModal({
388
413
  </Animated.ScrollView>
389
414
 
390
415
  {footerContent ? (
391
- <ActionFooter modes={modes}>{footerContent}</ActionFooter>
416
+ <ActionFooter modes={modes} style={footerInsetStyle}>
417
+ {footerContent}
418
+ </ActionFooter>
392
419
  ) : null}
393
420
 
394
421
  {showClose ? (
@@ -396,7 +423,7 @@ function FullscreenModal({
396
423
  iconName="ic_close"
397
424
  modes={modes}
398
425
  accessibilityLabel={closeAccessibilityLabel}
399
- style={closeButtonStyle}
426
+ style={[closeButtonStyle, closeButtonInsetStyle]}
400
427
  {...(onClose ? { onPress: onClose } : {})}
401
428
  />
402
429
  ) : null}
@@ -1,5 +1,5 @@
1
1
  import React, { useEffect } from 'react'
2
- import { StyleSheet, View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native'
2
+ import { View, type StyleProp, type ViewProps, type ViewStyle } from 'react-native'
3
3
  import Animated, {
4
4
  Easing,
5
5
  cancelAnimation,
@@ -171,7 +171,7 @@ const useSegmentRotation = (
171
171
  }
172
172
  }, [gravity, index, spreadMinRad, spreadMaxRad, spreadOutFrac])
173
173
 
174
- const fullSize: ViewStyle = { ...StyleSheet.absoluteFillObject }
174
+ const fullSize: ViewStyle = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
175
175
 
176
176
  function Spinner({
177
177
  size = DEFAULT_SIZE,