jfs-components 0.0.63 → 0.0.64

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/CHANGELOG.md CHANGED
@@ -4,6 +4,29 @@ All notable changes to this project are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [0.0.64] - 2026-04-20
8
+
9
+ ### Added
10
+
11
+ - **`Section.Bento` — expandable grid:** New built-in expand/collapse UX. Pass `collapsedCount` (default `4`) plus the full set of cells in `navSlot`, and `Section.Bento` auto-injects a toggle cell, owns the `expanded` boolean (uncontrolled), and animates extra cells in/out with a staggered fade cascade. Supports controlled mode via `expanded` + `onExpandedChange`, customizable labels (`toggleMoreLabel`, `toggleLessLabel`) and icons (`toggleMoreIcon`, `toggleLessIcon`), and a `renderToggle` escape hatch for non-`ListItem` toggles. The toggle is only injected when `navSlot.length > collapsedCount`, so existing usage at or below the threshold renders unchanged.
12
+ - **Drawer drop-shadow tokens:** New design tokens `drawer/shadow/primary/{offsetX,offsetY,blur,color}` and `drawer/shadow/secondary/{offsetX,color}` resolve a layered Figma drop shadow. Web stacks both shadows via `boxShadow`; iOS applies the primary shadow natively; Android uses elevation.
13
+
14
+ ### Changed
15
+
16
+ - **`SlotGrid` sizing — first-row-anchored:** `SlotGrid` (used by `Section.Bento`) now measures only the first row's cells once on mount and applies that max width to every cell in every row. Previously every cell was measured every render, which caused width jumps when the cell count changed (expand/collapse) and could cancel `Animated.View` `entering` cascades by re-rendering them in the same React batch as the mount. Result: stable cell width across toggles, no layout shift, animations play uninterrupted. Edge-flush behavior (first cell flush-left, last cell flush-right) is preserved via `justify-content: space-between`.
17
+ - **`Section.Bento` height transition:** The section grows and shrinks via an explicit measured-height spring (`overflow: 'hidden'` clip + `withSpring`) instead of `LinearTransition`. Cells inside are never resized during the animation — only the container's clip rectangle interpolates.
18
+ - **Drawer gesture policy:** Pan now requires a 10px vertical drag to activate (`activeOffsetY([-10, 10])`) and surrenders the gesture entirely after ~16px of horizontal movement (`failOffsetX([-16, 16])`). A defense-in-depth check in `onUpdate` skips Y translation on frames where horizontal motion dominates. This lets horizontal children (carousels, sliders, horizontal `FlatList`s) inside the drawer scroll cleanly without the sheet also translating.
19
+
20
+ ### Fixed
21
+
22
+ - **Drawer iOS shadow clipping:** The sheet now renders content inside an inner clip layer so `overflow: 'hidden'` no longer trims the outer view's drop shadow on iOS.
23
+
24
+ ### Accessibility
25
+
26
+ - All new `Section.Bento` animations honor the OS reduce-motion setting via Reanimated's `ReduceMotion.System`.
27
+
28
+ ---
29
+
7
30
  ## [0.0.63] - 2026-04-20
8
31
 
9
32
  ### Performance
@@ -131,7 +131,17 @@ function Drawer({
131
131
  const updateMode = (0, _react.useCallback)(newMode => {
132
132
  setMode(newMode);
133
133
  }, []);
134
- const gesture = _reactNativeGestureHandler.Gesture.Pan().simultaneousWithExternalGesture(scrollRef).activeOffsetY([-5, 5]).activeOffsetX([-5, 5]).onStart(() => {
134
+
135
+ // Gesture policy:
136
+ // • activeOffsetY: require a clear *vertical* drag (10px) before this
137
+ // pan claims the gesture. Matches typical iOS scroll activation feel.
138
+ // • failOffsetX: if the finger crosses ~16px horizontally *before* we
139
+ // activate, surrender the gesture entirely so any horizontal child
140
+ // (FlatList horizontal, swiper, slider, etc.) can scroll cleanly
141
+ // without the drawer also translating on Y.
142
+ // • simultaneousWithExternalGesture(scrollRef): cooperate with the
143
+ // drawer's own internal vertical ScrollView for nested scrolling.
144
+ const gesture = _reactNativeGestureHandler.Gesture.Pan().simultaneousWithExternalGesture(scrollRef).activeOffsetY([-10, 10]).failOffsetX([-16, 16]).onStart(() => {
135
145
  context.value = {
136
146
  y: translateY.value
137
147
  };
@@ -140,6 +150,16 @@ function Drawer({
140
150
  prevAtTop.value = scrollY.value <= 1;
141
151
  scrollTopTranslationOffset.value = 0;
142
152
  }).onUpdate(event => {
153
+ // Defense-in-depth: even after vertical activation, if the *current*
154
+ // motion is dominantly horizontal (e.g., the user activated with a
155
+ // small Y nudge and then curved into a horizontal swipe on a child
156
+ // carousel), don't translate the drawer this frame. failOffsetX
157
+ // already prevents activation in pure-horizontal swipes; this guards
158
+ // the diagonal-then-horizontal case.
159
+ if (Math.abs(event.translationX) > Math.abs(event.translationY) * 1.5) {
160
+ return;
161
+ }
162
+
143
163
  // Logic for nested scrolling:
144
164
  // If we are at the expanded position (minTranslateY) AND content is
145
165
  // scrolled down (scrollY > 0), let the ScrollView handle the gesture.
@@ -251,71 +271,108 @@ function Drawer({
251
271
  const titleWeight = (0, _figmaVariablesResolver.getVariableByName)('drawer/title/fontWeight', modes) || '700';
252
272
  const titleLineHeight = (0, _figmaVariablesResolver.getVariableByName)('drawer/title/lineHeight', modes) || 17;
253
273
  const titlePaddingBottom = (0, _figmaVariablesResolver.getVariableByName)('drawer/titleWrap/padding/bottom', modes) || 8;
274
+
275
+ // Drop shadow — Figma layers two shadows (primary + secondary) sharing
276
+ // the same offsetY/blur but with their own offsetX and color.
277
+ const shadowPrimaryOffsetX = (0, _figmaVariablesResolver.getVariableByName)('drawer/shadow/primary/offsetX', modes) ?? 0;
278
+ const shadowPrimaryOffsetY = (0, _figmaVariablesResolver.getVariableByName)('drawer/shadow/primary/offsetY', modes) ?? 16;
279
+ const shadowPrimaryBlur = (0, _figmaVariablesResolver.getVariableByName)('drawer/shadow/primary/blur', modes) ?? 48;
280
+ const shadowPrimaryColor = (0, _figmaVariablesResolver.getVariableByName)('drawer/shadow/primary/color', modes) ?? 'rgba(12, 13, 16, 0.16)';
281
+ const shadowSecondaryOffsetX = (0, _figmaVariablesResolver.getVariableByName)('drawer/shadow/secondary/offsetX', modes) ?? 0;
282
+ const shadowSecondaryColor = (0, _figmaVariablesResolver.getVariableByName)('drawer/shadow/secondary/color', modes) ?? 'rgba(12, 13, 16, 0.12)';
283
+
284
+ // Cross-platform shadow style. Web supports stacking two shadows via
285
+ // boxShadow. iOS only supports a single native shadow per view, so we
286
+ // apply the more prominent (primary) one. Android uses elevation.
287
+ const shadowStyle = _reactNative.Platform.select({
288
+ web: {
289
+ boxShadow: `${shadowSecondaryOffsetX}px ${shadowPrimaryOffsetY}px ${shadowPrimaryBlur}px 0px ${shadowSecondaryColor}, ` + `${shadowPrimaryOffsetX}px ${shadowPrimaryOffsetY}px ${shadowPrimaryBlur}px 0px ${shadowPrimaryColor}`
290
+ },
291
+ ios: {
292
+ shadowColor: shadowPrimaryColor,
293
+ shadowOffset: {
294
+ width: shadowPrimaryOffsetX,
295
+ height: shadowPrimaryOffsetY
296
+ },
297
+ shadowOpacity: 1,
298
+ shadowRadius: shadowPrimaryBlur / 2
299
+ },
300
+ android: {
301
+ elevation: 16
302
+ },
303
+ default: {}
304
+ });
254
305
  const defaultAccessibilityLabel = accessibilityLabel || title || 'Drawer';
255
306
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureHandlerRootView, {
256
307
  style: [styles.host, style],
257
308
  pointerEvents: "box-none",
258
309
  children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeGestureHandler.GestureDetector, {
259
310
  gesture: gesture,
260
- children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeReanimated.default.View, {
311
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
261
312
  style: [styles.sheet, {
262
313
  // Constraint the height strictly to the expanded height
263
314
  // This ensures the ScrollView has a finite frame to scroll within
264
315
  height: expandedHeight,
265
316
  backgroundColor,
266
317
  borderTopLeftRadius: radius,
267
- borderTopRightRadius: radius,
268
- paddingLeft,
269
- paddingRight,
270
- paddingBottom,
271
- rowGap: drawerGap
272
- }, sheetStyle, animatedStyle],
318
+ borderTopRightRadius: radius
319
+ }, shadowStyle, sheetStyle, animatedStyle],
273
320
  accessible: true,
274
321
  ...(_reactNative.Platform.OS === 'web' ? {
275
322
  accessibilityRole: 'dialog'
276
323
  } : undefined),
277
324
  accessibilityLabel: undefined,
278
325
  accessibilityHint: accessibilityHint || 'Swipe up to expand, swipe down to collapse',
279
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
280
- style: [styles.handleArea, !title && !header && {
281
- paddingBottom: 0
326
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
327
+ style: [styles.sheetInner, {
328
+ borderTopLeftRadius: radius,
329
+ borderTopRightRadius: radius,
330
+ paddingLeft,
331
+ paddingRight,
332
+ paddingBottom,
333
+ rowGap: drawerGap
282
334
  }],
283
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
335
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
336
+ style: [styles.handleArea, !title && !header && {
337
+ paddingBottom: 0
338
+ }],
339
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
340
+ style: [{
341
+ backgroundColor: handleColor,
342
+ width: handleWidth,
343
+ height: handleHeight,
344
+ borderRadius: handleRadius
345
+ }]
346
+ })
347
+ }), header, title && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
284
348
  style: [{
285
- backgroundColor: handleColor,
286
- width: handleWidth,
287
- height: handleHeight,
288
- borderRadius: handleRadius
289
- }]
290
- })
291
- }), header, title && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
292
- style: [{
293
- color: titleColor,
294
- fontSize: titleSize,
295
- fontWeight: titleWeight,
296
- lineHeight: titleLineHeight,
297
- marginBottom: titlePaddingBottom
298
- }],
299
- children: title
300
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(AnimatedScrollView, {
301
- ref: scrollRef,
302
- style: [styles.content, contentStyle],
303
- contentContainerStyle: [{
304
- paddingBottom: paddingBottom + bottomInset,
305
- gap: drawerGap,
306
- flexDirection: 'column',
307
- alignItems: 'stretch'
308
- }, contentContainerStyle],
309
- showsVerticalScrollIndicator: showsVerticalScrollIndicator,
310
- animatedProps: animatedScrollProps,
311
- alwaysBounceVertical: false,
312
- overScrollMode: "always",
313
- onScroll: (0, _reactNativeReanimated.useAnimatedScrollHandler)(event => {
314
- scrollY.value = event.contentOffset.y;
315
- }),
316
- scrollEventThrottle: 16,
317
- children: children
318
- })]
349
+ color: titleColor,
350
+ fontSize: titleSize,
351
+ fontWeight: titleWeight,
352
+ lineHeight: titleLineHeight,
353
+ marginBottom: titlePaddingBottom
354
+ }],
355
+ children: title
356
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(AnimatedScrollView, {
357
+ ref: scrollRef,
358
+ style: [styles.content, contentStyle],
359
+ contentContainerStyle: [{
360
+ paddingBottom: paddingBottom + bottomInset,
361
+ gap: drawerGap,
362
+ flexDirection: 'column',
363
+ alignItems: 'stretch'
364
+ }, contentContainerStyle],
365
+ showsVerticalScrollIndicator: showsVerticalScrollIndicator,
366
+ animatedProps: animatedScrollProps,
367
+ alwaysBounceVertical: false,
368
+ overScrollMode: "always",
369
+ onScroll: (0, _reactNativeReanimated.useAnimatedScrollHandler)(event => {
370
+ scrollY.value = event.contentOffset.y;
371
+ }),
372
+ scrollEventThrottle: 16,
373
+ children: children
374
+ })]
375
+ })
319
376
  })
320
377
  })
321
378
  });
@@ -333,7 +390,10 @@ const styles = _reactNative.StyleSheet.create({
333
390
  sheet: {
334
391
  width: '100%',
335
392
  position: 'absolute',
336
- top: 0,
393
+ top: 0
394
+ },
395
+ sheetInner: {
396
+ flex: 1,
337
397
  overflow: 'hidden'
338
398
  },
339
399
  handleArea: {