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