jfs-components 0.0.95 → 0.0.99

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.
@@ -21,12 +21,20 @@ const SOLID_OVERLAY_COLOR = 'rgba(255, 255, 255, 1)';
21
21
  // `pointerEvents: 'none'` lives on the style (not the deprecated prop) so it
22
22
  // works on both native and React Native Web without warnings.
23
23
  const absoluteFillStyle = {
24
- ..._reactNative.StyleSheet.absoluteFillObject,
24
+ position: 'absolute',
25
+ top: 0,
26
+ left: 0,
27
+ right: 0,
28
+ bottom: 0,
25
29
  overflow: 'hidden',
26
30
  pointerEvents: 'none'
27
31
  };
28
32
  const solidOverlayStyle = {
29
- ..._reactNative.StyleSheet.absoluteFillObject,
33
+ position: 'absolute',
34
+ top: 0,
35
+ left: 0,
36
+ right: 0,
37
+ bottom: 0,
30
38
  backgroundColor: SOLID_OVERLAY_COLOR
31
39
  };
32
40
 
@@ -1,8 +1,8 @@
1
1
  "use strict";
2
2
 
3
- import React, { useCallback, useEffect, useState, useRef } from 'react';
3
+ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
4
4
  import { Platform, StyleSheet, Text, useWindowDimensions, View } from 'react-native';
5
- import { Gesture, GestureDetector, GestureHandlerRootView, ScrollView } from 'react-native-gesture-handler';
5
+ import { Gesture, GestureDetector, ScrollView } from 'react-native-gesture-handler';
6
6
  import Animated, { runOnJS, useAnimatedProps, useAnimatedScrollHandler, useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
7
7
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
8
8
  import { EMPTY_MODES } from '../../utils/react-utils';
@@ -40,11 +40,17 @@ function rubberBand(value, min, max, friction = 0.55) {
40
40
  }
41
41
  return value;
42
42
  }
43
+
44
+ /**
45
+ * Imperative handle exposed via `ref` for programmatic control of the drawer.
46
+ * Each method animates using the same spring as gesture-driven transitions.
47
+ */
48
+
43
49
  /**
44
50
  * Drawer component with nested scrolling support.
45
51
  * Uses react-native-gesture-handler and react-native-reanimated.
46
52
  */
47
- function Drawer({
53
+ function DrawerInner({
48
54
  modes = EMPTY_MODES,
49
55
  style,
50
56
  title,
@@ -53,6 +59,7 @@ function Drawer({
53
59
  collapsedHeight = 200,
54
60
  expandedRatio = 0.90,
55
61
  initialState = 'collapsed',
62
+ state,
56
63
  contentStyle,
57
64
  sheetStyle,
58
65
  accessibilityLabel,
@@ -61,7 +68,7 @@ function Drawer({
61
68
  showsVerticalScrollIndicator = false,
62
69
  bottomInset = 80,
63
70
  onStateChange
64
- }) {
71
+ }, ref) {
65
72
  const {
66
73
  height: screenHeight
67
74
  } = useWindowDimensions();
@@ -123,15 +130,58 @@ function Drawer({
123
130
  translateY.value = withSpring(destination, SPRING_CONFIG);
124
131
  }, [translateY]);
125
132
 
126
- // Update JS state for accessibility/logic if needed
133
+ // Update the JS-side mode. Pure: only schedules the state update. Side
134
+ // effects (notifying the parent via onStateChange) MUST NOT live inside the
135
+ // setState updater — doing so calls the parent's setState during React's
136
+ // render phase, which throws "Cannot update a component while rendering a
137
+ // different component" and causes an infinite update loop. We report changes
138
+ // from a dedicated effect below instead.
127
139
  const updateMode = useCallback(newMode => {
128
- setMode(prev => {
129
- if (prev !== newMode) {
130
- onStateChange?.(newMode);
131
- }
132
- return newMode;
133
- });
134
- }, [onStateChange]);
140
+ setMode(newMode);
141
+ }, []);
142
+
143
+ // Notify the parent exactly once per settled mode change, after commit.
144
+ const reportedModeRef = useRef(mode);
145
+ useEffect(() => {
146
+ if (reportedModeRef.current !== mode) {
147
+ reportedModeRef.current = mode;
148
+ onStateChange?.(mode);
149
+ }
150
+ }, [mode, onStateChange]);
151
+
152
+ // Programmatic transition (JS thread). Reuses the same spring as gestures:
153
+ // animates `translateY`, keeps the scroll-gate (`isFullyExpanded`) in sync,
154
+ // and updates `mode` (which notifies via the onStateChange effect above).
155
+ const applyMode = useCallback(newMode => {
156
+ const target = newMode === 'expanded' ? minTranslateY : maxTranslateY;
157
+ translateY.value = withSpring(target, SPRING_CONFIG);
158
+ isFullyExpanded.value = newMode === 'expanded';
159
+ setMode(newMode);
160
+ }, [minTranslateY, maxTranslateY, translateY, isFullyExpanded]);
161
+
162
+ // Controlled mode: react ONLY to genuine changes of the `state` prop, tracked
163
+ // against its previous value. We must NOT reconcile `state` against the
164
+ // internal `mode` on every render: a gesture updates `mode` optimistically
165
+ // one render before the parent echoes it back through `onStateChange` into
166
+ // `state`, so a `state !== mode` check would "correct" the gesture and fight
167
+ // the echo, ping-ponging forever (Maximum update depth exceeded).
168
+ // `applyMode` is idempotent, so re-applying the already-current mode is a
169
+ // no-op when the parent simply mirrors our own change back.
170
+ const prevStateProp = useRef(state);
171
+ useEffect(() => {
172
+ if (state === undefined) return;
173
+ if (state === prevStateProp.current) return;
174
+ prevStateProp.current = state;
175
+ applyMode(state);
176
+ }, [state, applyMode]);
177
+
178
+ // Imperative API for parents holding a ref.
179
+ useImperativeHandle(ref, () => ({
180
+ expand: () => applyMode('expanded'),
181
+ collapse: () => applyMode('collapsed'),
182
+ toggle: () => applyMode(mode === 'expanded' ? 'collapsed' : 'expanded'),
183
+ getState: () => mode
184
+ }), [applyMode, mode]);
135
185
 
136
186
  // Gesture policy:
137
187
  // • activeOffsetY: require a clear *vertical* drag (10px) before this
@@ -304,84 +354,96 @@ function Drawer({
304
354
  default: {}
305
355
  });
306
356
  const defaultAccessibilityLabel = accessibilityLabel || title || 'Drawer';
307
- return /*#__PURE__*/_jsx(GestureHandlerRootView, {
308
- style: [styles.host, style],
309
- pointerEvents: "box-none",
310
- children: /*#__PURE__*/_jsx(GestureDetector, {
311
- gesture: gesture,
312
- children: /*#__PURE__*/_jsx(Animated.View, {
313
- style: [styles.sheet, {
314
- // Constraint the height strictly to the expanded height
315
- // This ensures the ScrollView has a finite frame to scroll within
316
- height: expandedHeight,
317
- backgroundColor,
318
- borderTopLeftRadius: radius,
319
- borderTopRightRadius: radius
320
- }, shadowStyle, sheetStyle, animatedStyle],
321
- accessible: true,
322
- ...(Platform.OS === 'web' ? {
323
- accessibilityRole: 'dialog'
324
- } : undefined),
325
- accessibilityLabel: undefined,
326
- accessibilityHint: accessibilityHint || 'Swipe up to expand, swipe down to collapse',
327
- children: /*#__PURE__*/_jsxs(View, {
328
- style: [styles.sheetInner, {
357
+ return (
358
+ /*#__PURE__*/
359
+ // IMPORTANT: the host is a plain box-none View, NOT a GestureHandlerRootView.
360
+ // On Android, GestureHandlerRootView renders a *native* root view that
361
+ // intercepts every touch within its bounds (to feed the gesture system) and
362
+ // does NOT honor pointerEvents="box-none". Because this host fills the whole
363
+ // screen as an overlay, using GestureHandlerRootView here swallowed all
364
+ // touches to content rendered behind the drawer (buttons, page content).
365
+ // Per the standard react-native-gesture-handler architecture, a single
366
+ // GestureHandlerRootView must wrap the app root; this overlay only needs to
367
+ // let touches fall through where the sheet isn't.
368
+ _jsx(View, {
369
+ style: [styles.host, style],
370
+ pointerEvents: "box-none",
371
+ children: /*#__PURE__*/_jsx(GestureDetector, {
372
+ gesture: gesture,
373
+ children: /*#__PURE__*/_jsx(Animated.View, {
374
+ style: [styles.sheet, {
375
+ // Constraint the height strictly to the expanded height
376
+ // This ensures the ScrollView has a finite frame to scroll within
377
+ height: expandedHeight,
378
+ backgroundColor,
329
379
  borderTopLeftRadius: radius,
330
- borderTopRightRadius: radius,
331
- paddingLeft,
332
- paddingRight,
333
- paddingBottom,
334
- rowGap: drawerGap
335
- }],
336
- children: [/*#__PURE__*/_jsx(View, {
337
- style: [styles.handleArea, !title && !header && {
338
- paddingBottom: 0
380
+ borderTopRightRadius: radius
381
+ }, shadowStyle, sheetStyle, animatedStyle],
382
+ accessible: true,
383
+ ...(Platform.OS === 'web' ? {
384
+ accessibilityRole: 'dialog'
385
+ } : undefined),
386
+ accessibilityLabel: undefined,
387
+ accessibilityHint: accessibilityHint || 'Swipe up to expand, swipe down to collapse',
388
+ children: /*#__PURE__*/_jsxs(View, {
389
+ style: [styles.sheetInner, {
390
+ borderTopLeftRadius: radius,
391
+ borderTopRightRadius: radius,
392
+ paddingLeft,
393
+ paddingRight,
394
+ paddingBottom,
395
+ rowGap: drawerGap
339
396
  }],
340
- children: /*#__PURE__*/_jsx(View, {
397
+ children: [/*#__PURE__*/_jsx(View, {
398
+ style: [styles.handleArea, !title && !header && {
399
+ paddingBottom: 0
400
+ }],
401
+ children: /*#__PURE__*/_jsx(View, {
402
+ style: [{
403
+ backgroundColor: handleColor,
404
+ width: handleWidth,
405
+ height: handleHeight,
406
+ borderRadius: handleRadius
407
+ }]
408
+ })
409
+ }), header, title && /*#__PURE__*/_jsx(Text, {
341
410
  style: [{
342
- backgroundColor: handleColor,
343
- width: handleWidth,
344
- height: handleHeight,
345
- borderRadius: handleRadius
346
- }]
347
- })
348
- }), header, title && /*#__PURE__*/_jsx(Text, {
349
- style: [{
350
- color: titleColor,
351
- fontSize: titleSize,
352
- fontWeight: titleWeight,
353
- lineHeight: titleLineHeight,
354
- marginBottom: titlePaddingBottom
355
- }],
356
- children: title
357
- }), /*#__PURE__*/_jsx(AnimatedScrollView, {
358
- ref: scrollRef,
359
- style: [styles.content, contentStyle],
360
- contentContainerStyle: [{
361
- paddingBottom: paddingBottom + bottomInset,
362
- gap: drawerGap,
363
- flexDirection: 'column',
364
- alignItems: 'stretch'
365
- }, contentContainerStyle],
366
- showsVerticalScrollIndicator: showsVerticalScrollIndicator
367
- // Let a tap on an input inside the sheet focus it on the FIRST tap
368
- // even while the keyboard is already open (default 'never' would
369
- // eat that tap just to dismiss the keyboard).
370
- ,
371
- keyboardShouldPersistTaps: "handled",
372
- animatedProps: animatedScrollProps,
373
- alwaysBounceVertical: false,
374
- overScrollMode: "always",
375
- onScroll: useAnimatedScrollHandler(event => {
376
- scrollY.value = event.contentOffset.y;
377
- }),
378
- scrollEventThrottle: 16,
379
- children: children
380
- })]
411
+ color: titleColor,
412
+ fontSize: titleSize,
413
+ fontWeight: titleWeight,
414
+ lineHeight: titleLineHeight,
415
+ marginBottom: titlePaddingBottom
416
+ }],
417
+ children: title
418
+ }), /*#__PURE__*/_jsx(AnimatedScrollView, {
419
+ ref: scrollRef,
420
+ style: [styles.content, contentStyle],
421
+ contentContainerStyle: [{
422
+ paddingBottom: paddingBottom + bottomInset,
423
+ gap: drawerGap,
424
+ flexDirection: 'column',
425
+ alignItems: 'stretch'
426
+ }, contentContainerStyle],
427
+ showsVerticalScrollIndicator: showsVerticalScrollIndicator
428
+ // Let a tap on an input inside the sheet focus it on the FIRST tap
429
+ // even while the keyboard is already open (default 'never' would
430
+ // eat that tap just to dismiss the keyboard).
431
+ ,
432
+ keyboardShouldPersistTaps: "handled",
433
+ animatedProps: animatedScrollProps,
434
+ alwaysBounceVertical: false,
435
+ overScrollMode: "always",
436
+ onScroll: useAnimatedScrollHandler(event => {
437
+ scrollY.value = event.contentOffset.y;
438
+ }),
439
+ scrollEventThrottle: 16,
440
+ children: children
441
+ })]
442
+ })
381
443
  })
382
444
  })
383
445
  })
384
- });
446
+ );
385
447
  }
386
448
  const styles = StyleSheet.create({
387
449
  host: {
@@ -411,4 +473,6 @@ const styles = StyleSheet.create({
411
473
  flex: 1
412
474
  }
413
475
  });
476
+ const Drawer = /*#__PURE__*/forwardRef(DrawerInner);
477
+ Drawer.displayName = 'Drawer';
414
478
  export default Drawer;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useMemo, useRef } from 'react';
4
4
  import { View, Text, Animated } from 'react-native';
5
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
6
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
6
7
  import { useTokens } from '../../design-tokens/JFSThemeProvider';
7
8
  import { EMPTY_MODES, cloneChildrenWithModes } from '../../utils/react-utils';
@@ -212,6 +213,24 @@ function FullscreenModal({
212
213
  }), [globalModes, propModes]);
213
214
  const rootGap = Number(getVariableByName('fullScreenModal/gap', modes)) || 16;
214
215
 
216
+ // Safe-area insets so the floating chrome clears the system bars: the close
217
+ // button drops below the status bar / notch, and the sticky footer keeps its
218
+ // designed bottom padding ON TOP of the bottom inset (home indicator /
219
+ // Android gesture or nav bar). On web — and anywhere without a
220
+ // SafeAreaProvider — every inset is 0, so the layout is unchanged.
221
+ const insets = useSafeAreaInsets();
222
+ const closeButtonInsetStyle = useMemo(() => ({
223
+ top: 12 + insets.top
224
+ }), [insets.top]);
225
+ // Extend (not replace) the footer's token bottom padding by the bottom inset
226
+ // so the action button never sits under the system navigation area.
227
+ const footerInsetStyle = useMemo(() => {
228
+ const base = Number(getVariableByName('actionFooter/padding/bottom', modes)) || 41;
229
+ return {
230
+ paddingBottom: base + insets.bottom
231
+ };
232
+ }, [modes, insets.bottom]);
233
+
215
234
  // Drives the background's parallax-free sync with the scroll. The hero media
216
235
  // lives at the ROOT (so it is never clipped to the content height and sits
217
236
  // behind the transparent footer), but we translate it up by the exact scroll
@@ -311,12 +330,13 @@ function FullscreenModal({
311
330
  })
312
331
  }), footerContent ? /*#__PURE__*/_jsx(ActionFooter, {
313
332
  modes: modes,
333
+ style: footerInsetStyle,
314
334
  children: footerContent
315
335
  }) : null, showClose ? /*#__PURE__*/_jsx(IconButton, {
316
336
  iconName: "ic_close",
317
337
  modes: modes,
318
338
  accessibilityLabel: closeAccessibilityLabel,
319
- style: closeButtonStyle,
339
+ style: [closeButtonStyle, closeButtonInsetStyle],
320
340
  ...(onClose ? {
321
341
  onPress: onClose
322
342
  } : {})
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
 
3
3
  import React, { useEffect } from 'react';
4
- import { StyleSheet, View } from 'react-native';
4
+ import { View } from 'react-native';
5
5
  import Animated, { Easing, cancelAnimation, useAnimatedStyle, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
6
6
  import Svg, { Path } from 'react-native-svg';
7
7
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver';
@@ -115,7 +115,11 @@ const useSegmentRotation = (clock, index, gravity, spreadMinRad, spreadMaxRad, s
115
115
  };
116
116
  }, [gravity, index, spreadMinRad, spreadMaxRad, spreadOutFrac]);
117
117
  const fullSize = {
118
- ...StyleSheet.absoluteFillObject
118
+ position: 'absolute',
119
+ top: 0,
120
+ left: 0,
121
+ right: 0,
122
+ bottom: 0
119
123
  };
120
124
  function Spinner({
121
125
  size = DEFAULT_SIZE,