jfs-components 0.1.30 → 0.1.32

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 (41) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/lib/commonjs/components/CategoryCard/CategoryCard.js +264 -0
  3. package/lib/commonjs/components/CompareTable/CompareTable.js +140 -84
  4. package/lib/commonjs/components/ContentSheet/ContentSheet.js +28 -5
  5. package/lib/commonjs/components/CounterBadge/CounterBadge.js +78 -0
  6. package/lib/commonjs/components/FavoriteToggle/FavoriteToggle.js +180 -0
  7. package/lib/commonjs/components/HelloJioInput/HelloJioInput.js +287 -0
  8. package/lib/commonjs/components/ValueBackMetric/ValueBackMetric.js +219 -0
  9. package/lib/commonjs/components/index.js +35 -0
  10. package/lib/commonjs/design-tokens/figma-modes.generated.js +3 -0
  11. package/lib/commonjs/icons/registry.js +1 -1
  12. package/lib/module/components/CategoryCard/CategoryCard.js +258 -0
  13. package/lib/module/components/CompareTable/CompareTable.js +140 -84
  14. package/lib/module/components/ContentSheet/ContentSheet.js +29 -6
  15. package/lib/module/components/CounterBadge/CounterBadge.js +73 -0
  16. package/lib/module/components/FavoriteToggle/FavoriteToggle.js +174 -0
  17. package/lib/module/components/HelloJioInput/HelloJioInput.js +281 -0
  18. package/lib/module/components/ValueBackMetric/ValueBackMetric.js +214 -0
  19. package/lib/module/components/index.js +6 -1
  20. package/lib/module/design-tokens/figma-modes.generated.js +3 -0
  21. package/lib/module/icons/registry.js +1 -1
  22. package/lib/typescript/src/components/CategoryCard/CategoryCard.d.ts +72 -0
  23. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +16 -1
  24. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +7 -1
  25. package/lib/typescript/src/components/CounterBadge/CounterBadge.d.ts +19 -0
  26. package/lib/typescript/src/components/FavoriteToggle/FavoriteToggle.d.ts +66 -0
  27. package/lib/typescript/src/components/HelloJioInput/HelloJioInput.d.ts +111 -0
  28. package/lib/typescript/src/components/ValueBackMetric/ValueBackMetric.d.ts +86 -0
  29. package/lib/typescript/src/components/index.d.ts +5 -0
  30. package/lib/typescript/src/icons/registry.d.ts +1 -1
  31. package/package.json +1 -1
  32. package/src/components/CategoryCard/CategoryCard.tsx +404 -0
  33. package/src/components/CompareTable/CompareTable.tsx +201 -101
  34. package/src/components/ContentSheet/ContentSheet.tsx +40 -11
  35. package/src/components/CounterBadge/CounterBadge.tsx +95 -0
  36. package/src/components/FavoriteToggle/FavoriteToggle.tsx +243 -0
  37. package/src/components/HelloJioInput/HelloJioInput.tsx +513 -0
  38. package/src/components/ValueBackMetric/ValueBackMetric.tsx +298 -0
  39. package/src/components/index.ts +5 -0
  40. package/src/design-tokens/figma-modes.generated.ts +3 -0
  41. package/src/icons/registry.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ 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.1.31] - 2026-07-15
8
+
9
+ - Added `CategoryCard` — compact category rail tile (`categoryCard/*` tokens) with image (or `imageSlot`), optional overlapping `Badge`, fixed 60dp width (overridable via `width`), and `active` / `disabled` press states. Label truncation mirrors `Text` via `disableTruncation` / `singleLine`.
10
+ - Added `HelloJioInput` — pill-shaped HelloJio prompt field (`helloJioInput/*` tokens) with leading brand icon, single-line input, and send `IconButton`. Focus drives Idle → Active; `jioPlus` enables the frosted IdleJioPlus glass surface. Controlled/uncontrolled text with `onSubmit` from the send button and keyboard return key; `leading` / `trailing` slots for customization.
11
+ - Added `CounterBadge` — small token-driven value pill (`counterBadge/*` tokens).
12
+ - Added `FavoriteToggle` — glass circular favorite control (`favoriteToggle/*` tokens) with controlled `isActive` / uncontrolled `defaultActive`, `onChange`, and skeleton loading support.
13
+ - Added `ValueBackMetric` — compact metric card (JioPoints-style) with header icon + title, value, caption, and optional bottom `Link`; colours/gaps/padding from `metricdata/*` tokens.
14
+ - `CompareTable` — new `stickyHeaders` and `stickyHeaderLabel` props (default `true`) for sticky section headers in fixed-width scroll mode; horizontal scroll sync rewritten to track the actively dragged `ScrollView` and eliminate jitter from programmatic `scrollTo` echoes.
15
+ - `ContentSheet` — new `maxHeightPercent` prop (default `0.7`) caps sheet height as a fraction of the window and scrolls overflow content.
16
+ - Stories and MDX documentation updated for all new components and props.
17
+
18
+ ---
19
+
7
20
  ## [0.1.30] - 2026-07-10
8
21
 
9
22
  - `MoneyValue` — new `editable` prop (inline edit mode): tapping the value shows a numeric `TextInput` that accepts only digits + one decimal point; edit commits on blur/submit via new `onValueChange` callback; keyboard avoidance lifts the component above the keyboard using `Keyboard.addListener` + `measureInWindow` (same pattern as `ActionFooter`).
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.default = void 0;
7
+ var _react = _interopRequireWildcard(require("react"));
8
+ var _reactNative = require("react-native");
9
+ var _figmaVariablesResolver = require("../../design-tokens/figma-variables-resolver");
10
+ var _JFSThemeProvider = require("../../design-tokens/JFSThemeProvider");
11
+ var _reactUtils = require("../../utils/react-utils");
12
+ var _webPlatformUtils = require("../../utils/web-platform-utils");
13
+ var _Badge = _interopRequireDefault(require("../Badge/Badge"));
14
+ var _Image = _interopRequireDefault(require("../Image/Image"));
15
+ var _jsxRuntime = require("react/jsx-runtime");
16
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
17
+ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
18
+ const CATEGORY_CARD_STATE = 'Category Card State';
19
+ const BADGE_OVERLAP = 11;
20
+ const DEFAULT_CARD_WIDTH = 60;
21
+
22
+ // Default modes from the Figma CategoryCard component. Overridable via `modes`.
23
+ const CATEGORY_CARD_DEFAULT_MODES = Object.freeze({
24
+ AppearanceBrand: 'Tertiary',
25
+ 'Badge Size': 'Small',
26
+ Context4: 'Badge',
27
+ "Radius": "None"
28
+ });
29
+ const toNumber = (value, fallback) => {
30
+ if (typeof value === 'number') {
31
+ return Number.isFinite(value) ? value : fallback;
32
+ }
33
+ if (typeof value === 'string') {
34
+ const parsed = Number(value);
35
+ return Number.isFinite(parsed) ? parsed : fallback;
36
+ }
37
+ return fallback;
38
+ };
39
+ const toFontWeight = (value, fallback) => {
40
+ if (typeof value === 'number') return String(value);
41
+ if (typeof value === 'string') return value;
42
+ return fallback;
43
+ };
44
+ function resolveCategoryCardTokens(modes, active) {
45
+ const idleModes = {
46
+ ...modes,
47
+ [CATEGORY_CARD_STATE]: 'Idle'
48
+ };
49
+ const activeModes = {
50
+ ...modes,
51
+ [CATEGORY_CARD_STATE]: 'Active'
52
+ };
53
+ const resolvedModes = active ? activeModes : idleModes;
54
+ const gap = toNumber((0, _figmaVariablesResolver.getVariableByName)('cardTab/gap', resolvedModes), 6);
55
+ const paddingHorizontal = toNumber((0, _figmaVariablesResolver.getVariableByName)('cardTab/padding/horizontal', resolvedModes), 8);
56
+ const paddingVertical = toNumber((0, _figmaVariablesResolver.getVariableByName)('cardTab/padding/vertical', resolvedModes), 8);
57
+ const radius = toNumber((0, _figmaVariablesResolver.getVariableByName)('cardTab/radius', resolvedModes), 8);
58
+ const strokeWidth = toNumber((0, _figmaVariablesResolver.getVariableByName)('cardTab/strokeWidth', resolvedModes), 1);
59
+ const strokeColor = (0, _figmaVariablesResolver.getVariableByName)('cardTab/stroke/color', resolvedModes) ?? '#f5f5f5';
60
+ const idleBackground = (0, _figmaVariablesResolver.getVariableByName)('cardTab/background/color', idleModes) ?? '#ffffff';
61
+ const activeBackground = (0, _figmaVariablesResolver.getVariableByName)('cardTab/background/color', activeModes) ?? '#f5f5f5';
62
+ const backgroundColor = active ? activeBackground : idleBackground;
63
+ const imageSize = toNumber((0, _figmaVariablesResolver.getVariableByName)('cardTab/image/width', resolvedModes), 36);
64
+ const imageRadius = toNumber((0, _figmaVariablesResolver.getVariableByName)('image/radius', resolvedModes), 0);
65
+ const fontFamily = (0, _figmaVariablesResolver.getVariableByName)('cardTab/label/fontFamily', resolvedModes) ?? 'JioType Var';
66
+ const fontSize = toNumber((0, _figmaVariablesResolver.getVariableByName)('cardTab/label/fontSize', resolvedModes), 12);
67
+ const lineHeight = toNumber((0, _figmaVariablesResolver.getVariableByName)('cardTab/label/lineHeight', resolvedModes), Math.round(fontSize * 1.3));
68
+ const fontWeight = toFontWeight((0, _figmaVariablesResolver.getVariableByName)('cardTab/label/fontWeight', resolvedModes), '500');
69
+ const labelColor = (0, _figmaVariablesResolver.getVariableByName)('cardTab/label/color', resolvedModes) ?? '#000000';
70
+ return {
71
+ containerStyle: {
72
+ position: 'relative',
73
+ alignItems: 'center',
74
+ justifyContent: 'center',
75
+ backgroundColor,
76
+ borderColor: strokeColor,
77
+ borderWidth: strokeWidth,
78
+ borderStyle: 'solid',
79
+ borderRadius: radius,
80
+ paddingHorizontal,
81
+ paddingTop: paddingVertical,
82
+ paddingBottom: paddingVertical,
83
+ gap
84
+ },
85
+ labelStyle: {
86
+ color: labelColor,
87
+ fontFamily,
88
+ fontSize,
89
+ fontWeight,
90
+ lineHeight,
91
+ textAlign: 'center'
92
+ },
93
+ imageSize,
94
+ imageRadius
95
+ };
96
+ }
97
+ const pressedOverlayStyle = {
98
+ opacity: 0.85
99
+ };
100
+ const disabledOverlayStyle = {
101
+ opacity: 0.5
102
+ };
103
+
104
+ /**
105
+ * `CategoryCard` is a compact, tappable tile for category pickers and
106
+ * merchandising rails. It stacks a square image above a short label and can
107
+ * optionally show an overlapping badge at the top edge. Cards are typically
108
+ * laid out in a horizontal `ScrollView` or `HStack`.
109
+ *
110
+ * All visual values resolve through the `cardTab/*` design tokens with
111
+ * sensible Figma defaults so the card renders correctly out of the box.
112
+ *
113
+ * @component
114
+ * @param {CategoryCardProps} props
115
+ */
116
+ function CategoryCard({
117
+ label = 'Popular',
118
+ imageSource,
119
+ imageSlot,
120
+ badge,
121
+ active = false,
122
+ width = DEFAULT_CARD_WIDTH,
123
+ disableTruncation,
124
+ singleLine,
125
+ onPress,
126
+ disabled = false,
127
+ modes: propModes = _reactUtils.EMPTY_MODES,
128
+ style,
129
+ labelStyle,
130
+ accessibilityLabel,
131
+ accessibilityHint,
132
+ accessibilityState,
133
+ webAccessibilityProps,
134
+ testID,
135
+ ...rest
136
+ }) {
137
+ const {
138
+ modes: globalModes
139
+ } = (0, _JFSThemeProvider.useTokens)();
140
+ const modes = (0, _react.useMemo)(() => ({
141
+ ...globalModes,
142
+ ...CATEGORY_CARD_DEFAULT_MODES,
143
+ ...propModes
144
+ }), [globalModes, propModes]);
145
+ const resolvedModes = (0, _react.useMemo)(() => ({
146
+ ...modes,
147
+ [CATEGORY_CARD_STATE]: active ? 'Active' : 'Idle'
148
+ }), [modes, active]);
149
+ const tokens = (0, _react.useMemo)(() => resolveCategoryCardTokens(resolvedModes, active), [resolvedModes, active]);
150
+ const {
151
+ style: labelLayoutStyle,
152
+ ...labelTruncation
153
+ } = (0, _react.useMemo)(() => (0, _reactUtils.resolveTextLayout)({
154
+ disableTruncation,
155
+ singleLine,
156
+ numberOfLines: 1,
157
+ ellipsizeMode: 'tail'
158
+ }), [disableTruncation, singleLine]);
159
+ const processedImageSlot = (0, _react.useMemo)(() => {
160
+ if (!imageSlot) return null;
161
+ const processed = (0, _reactUtils.cloneChildrenWithModes)(imageSlot, resolvedModes);
162
+ return processed.length === 1 ? processed[0] : processed;
163
+ }, [imageSlot, resolvedModes]);
164
+ const imageNode = processedImageSlot ?? (imageSource !== undefined ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_Image.default, {
165
+ imageSource: imageSource,
166
+ width: tokens.imageSize,
167
+ height: tokens.imageSize,
168
+ ratio: 1,
169
+ resizeMode: "cover",
170
+ borderRadius: tokens.imageRadius,
171
+ accessibilityElementsHidden: true,
172
+ importantForAccessibility: "no"
173
+ }) : /*#__PURE__*/(0, _jsxRuntime.jsx)(_Image.default, {
174
+ imageSource: "https://www.figma.com/api/mcp/asset/0ee34871-646f-4736-be06-22fb6c0a4eed",
175
+ width: tokens.imageSize,
176
+ height: tokens.imageSize,
177
+ ratio: 1,
178
+ resizeMode: "cover",
179
+ borderRadius: tokens.imageRadius,
180
+ accessibilityElementsHidden: true,
181
+ importantForAccessibility: "no"
182
+ }));
183
+ const a11yLabel = accessibilityLabel ?? (badge ? `${label}, ${badge}` : label);
184
+ const webProps = (0, _webPlatformUtils.usePressableWebSupport)({
185
+ restProps: rest,
186
+ onPress: disabled ? undefined : onPress,
187
+ disabled,
188
+ accessibilityLabel: a11yLabel,
189
+ webAccessibilityProps
190
+ });
191
+ const badgeNode = badge ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
192
+ style: BADGE_ANCHOR,
193
+ pointerEvents: "none",
194
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_Badge.default, {
195
+ label: badge,
196
+ modes: resolvedModes,
197
+ style: BADGE_CENTER_STYLE
198
+ })
199
+ }) : null;
200
+
201
+ // Truncation needs a width bound so the ellipsis engages. `singleLine`
202
+ // must NOT fill the content box — otherwise the text is pinned to the left
203
+ // padding edge and only grows rightward. Intrinsic width + centered
204
+ // alignment lets overflow expand equally on both sides.
205
+ const labelWidthStyle = singleLine ? LABEL_OVERFLOW_CENTER_STYLE : LABEL_FILL_STYLE;
206
+ const pressableStyle = (0, _react.useCallback)(({
207
+ pressed
208
+ }) => [tokens.containerStyle, {
209
+ width
210
+ }, badge ? {
211
+ marginTop: BADGE_OVERLAP
212
+ } : null, disabled ? disabledOverlayStyle : null, pressed && !disabled ? pressedOverlayStyle : null, style], [tokens.containerStyle, width, badge, disabled, style]);
213
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.Pressable, {
214
+ accessibilityRole: "button",
215
+ accessibilityLabel: a11yLabel,
216
+ accessibilityHint: accessibilityHint,
217
+ accessibilityState: {
218
+ disabled,
219
+ selected: active,
220
+ ...accessibilityState
221
+ },
222
+ onPress: onPress,
223
+ disabled: disabled,
224
+ style: pressableStyle,
225
+ testID: testID,
226
+ ...webProps,
227
+ children: [badgeNode, imageNode, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
228
+ style: [tokens.labelStyle, labelWidthStyle, labelLayoutStyle, labelStyle],
229
+ ...labelTruncation,
230
+ accessibilityElementsHidden: true,
231
+ importantForAccessibility: "no",
232
+ children: label
233
+ })]
234
+ });
235
+ }
236
+ const BADGE_ANCHOR = {
237
+ position: 'absolute',
238
+ top: -BADGE_OVERLAP,
239
+ left: 0,
240
+ right: 0,
241
+ alignItems: 'center',
242
+ justifyContent: 'center',
243
+ zIndex: 1
244
+ };
245
+ const BADGE_CENTER_STYLE = {
246
+ alignSelf: 'center'
247
+ };
248
+
249
+ /** Lets the label fill the card width so the one-line ellipsis has a bound. */
250
+ const LABEL_FILL_STYLE = {
251
+ width: '100%',
252
+ alignSelf: 'stretch'
253
+ };
254
+
255
+ /**
256
+ * Intrinsic width, horizontally centred. When the label is wider than the
257
+ * card it overflows left and right equally instead of growing only rightward
258
+ * from the padded content edge.
259
+ */
260
+ const LABEL_OVERFLOW_CENTER_STYLE = {
261
+ alignSelf: 'center',
262
+ textAlign: 'center'
263
+ };
264
+ var _default = exports.default = /*#__PURE__*/_react.default.memo(CategoryCard);
@@ -113,7 +113,9 @@ function CompareTable({
113
113
  modes = _reactUtils.EMPTY_MODES,
114
114
  style,
115
115
  disableTruncation,
116
- columnWidth
116
+ columnWidth,
117
+ stickyHeaders = true,
118
+ stickyHeaderLabel = true
117
119
  }) {
118
120
  // --- selection card tokens ------------------------------------------------
119
121
  const cardBg = (0, _figmaVariablesResolver.getVariableByName)('selectionCard/background/color', modes) ?? '#ffffff';
@@ -185,12 +187,12 @@ function CompareTable({
185
187
  // Stable per-section ref-callback instances so React doesn't detach/reattach
186
188
  // on every render (which would churn the map and re-fire scrollTo).
187
189
  const bodyRefCallbacks = (0, _react.useRef)(new Map());
188
- /** Last offset we synced everyone to. Echoed onScroll events reporting this
189
- * same value are ignoredthis is what prevents feedback loops. */
190
+ /** Tracks which ScrollView the user is currently dragging. Only the
191
+ * actively-dragged ScrollView drives horizontal sync programmatic
192
+ * scrollTo echoes from synced peers are ignored, eliminating jitter. */
193
+ const draggingSource = (0, _react.useRef)(null);
190
194
  const lastSyncX = (0, _react.useRef)(0);
191
- const applySync = (0, _react.useCallback)((x, source) => {
192
- if (Math.abs(x - lastSyncX.current) < 0.5) return;
193
- lastSyncX.current = x;
195
+ const syncOthers = (0, _react.useCallback)((source, x) => {
194
196
  if (source !== 'cards' && cardsScrollRef.current) {
195
197
  cardsScrollRef.current.scrollTo({
196
198
  x,
@@ -206,12 +208,37 @@ function CompareTable({
206
208
  }
207
209
  });
208
210
  }, []);
209
- const onCardsScroll = (0, _react.useCallback)(e => {
210
- applySync(e.nativeEvent.contentOffset.x, 'cards');
211
- }, [applySync]);
212
- const onBodyScroll = (0, _react.useCallback)(idx => e => {
213
- applySync(e.nativeEvent.contentOffset.x, idx);
214
- }, [applySync]);
211
+ const onScrollBeginDrag = (0, _react.useCallback)(source => () => {
212
+ draggingSource.current = source;
213
+ }, []);
214
+ const onScrollEndDrag = (0, _react.useCallback)(source => e => {
215
+ const x = Math.round(e.nativeEvent.contentOffset.x);
216
+ if (x === lastSyncX.current) return;
217
+ lastSyncX.current = x;
218
+ syncOthers(source, x);
219
+ }, [syncOthers]);
220
+ const onMomentumScrollEnd = (0, _react.useCallback)(source => e => {
221
+ if (draggingSource.current === source) {
222
+ draggingSource.current = null;
223
+ }
224
+ const x = Math.round(e.nativeEvent.contentOffset.x);
225
+ if (x === lastSyncX.current) return;
226
+ lastSyncX.current = x;
227
+ syncOthers(source, x);
228
+ }, [syncOthers]);
229
+
230
+ /** Real-time scroll sync: only the actively-dragged ScrollView drives
231
+ * sync on native — echoed onScroll events from programmatic scrollTo
232
+ * on other ScrollViews are ignored because draggingSource won't match.
233
+ * On web, onScrollBeginDrag never fires for mouse/trackpad, so skip
234
+ * the guard and rely on the lastSyncX rounded-value check instead. */
235
+ const onScroll = (0, _react.useCallback)(source => e => {
236
+ if (_reactNative.Platform.OS !== 'web' && draggingSource.current !== source) return;
237
+ const x = Math.round(e.nativeEvent.contentOffset.x);
238
+ if (x === lastSyncX.current) return;
239
+ lastSyncX.current = x;
240
+ syncOthers(source, x);
241
+ }, [syncOthers]);
215
242
  const getBodyRefCallback = (0, _react.useCallback)(idx => {
216
243
  let cb = bodyRefCallbacks.current.get(idx);
217
244
  if (!cb) {
@@ -442,55 +469,45 @@ function CompareTable({
442
469
  })
443
470
  })]
444
471
  });
445
-
446
- // Render a single section in fixed-width scroll mode. Unlike `renderTable`
447
- // (used in flex mode), the gray header row is rendered full-width OUTSIDE
448
- // the body's horizontal ScrollView so it stays put horizontally (it does
449
- // not slide with the cells) while still scrolling vertically with the table.
450
- // The body rows live inside a horizontal ScrollView that is scroll-synced
451
- // with the cards row and every other section's body.
452
- const renderSection = (section, index) => /*#__PURE__*/(0, _jsxRuntime.jsxs)(_Accordion.default, {
453
- title: section.title,
454
- defaultExpanded: section.defaultExpanded ?? index === 0,
455
- modes: modes,
456
- children: [section.header != null && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
472
+ const renderHeaderContent = section => section.header != null ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
473
+ style: {
474
+ backgroundColor: headerBg,
475
+ paddingHorizontal: headerPaddingH,
476
+ paddingVertical: headerPaddingV,
477
+ borderBottomWidth: cellBorderSize,
478
+ borderBottomColor: cellBorderColor
479
+ },
480
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
481
+ style: headerTextStyle,
482
+ children: section.header
483
+ })
484
+ }) : null;
485
+ const renderBodyContent = (section, index) => /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
486
+ ref: getBodyRefCallback(index),
487
+ horizontal: true,
488
+ showsHorizontalScrollIndicator: false,
489
+ onScroll: onScroll(index),
490
+ onScrollBeginDrag: onScrollBeginDrag(index),
491
+ onScrollEndDrag: onScrollEndDrag(index),
492
+ onMomentumScrollEnd: onMomentumScrollEnd(index),
493
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
457
494
  style: {
458
- width: '100%',
459
- backgroundColor: headerBg,
460
- paddingHorizontal: headerPaddingH,
461
- paddingVertical: headerPaddingV,
462
- borderBottomWidth: cellBorderSize,
463
- borderBottomColor: cellBorderColor
495
+ width: contentWidth
464
496
  },
465
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.Text, {
466
- style: headerTextStyle,
467
- children: section.header
468
- })
469
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
470
- ref: getBodyRefCallback(index),
471
- horizontal: true,
472
- showsHorizontalScrollIndicator: false,
473
- scrollEventThrottle: 16,
474
- onScroll: onBodyScroll(index),
475
- children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
476
- style: {
477
- width: contentWidth
478
- },
479
- children: section.rows.map((row, rowIndex) => {
480
- const isLastRow = rowIndex === section.rows.length - 1;
481
- return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
482
- style: {
483
- flexDirection: 'row',
484
- width: '100%'
485
- },
486
- children: [Array.from({
487
- length: columnCount
488
- }).map((_, colIndex) => renderTableCell(renderCellContent(row.values?.[colIndex], `${rowIndex}-${colIndex}`), colIndex, isLastRow, colIndex)), showAddCard && renderTableCell(null, gridColumnCount - 1, isLastRow, '__add_spacer__')]
489
- }, row.key ?? rowIndex);
490
- })
497
+ children: section.rows.map((row, rowIndex) => {
498
+ const isLastRow = rowIndex === section.rows.length - 1;
499
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
500
+ style: {
501
+ flexDirection: 'row',
502
+ width: '100%'
503
+ },
504
+ children: [Array.from({
505
+ length: columnCount
506
+ }).map((_, colIndex) => renderTableCell(renderCellContent(row.values?.[colIndex], `${rowIndex}-${colIndex}`), colIndex, isLastRow, colIndex)), showAddCard && renderTableCell(null, gridColumnCount - 1, isLastRow, '__add_spacer__')]
507
+ }, row.key ?? rowIndex);
491
508
  })
492
- })]
493
- }, section.key ?? section.title ?? index);
509
+ })
510
+ });
494
511
  const outerStyle = {
495
512
  width: '100%',
496
513
  backgroundColor: cardBg,
@@ -524,43 +541,82 @@ function CompareTable({
524
541
  }
525
542
 
526
543
  // --- fixed-width scroll mode: chained horizontal sync + sticky cards -----
527
- // The cards row (index 0) is a sticky header of the vertical ScrollView so
528
- // it pins to the top while the sections scroll beneath. The cards row is
529
- // itself a horizontal ScrollView, synced with every section body. For the
530
- // vertical scroll (and thus the sticky behaviour) to engage, the host must
531
- // give CompareTable a bounded height (e.g. a sized container or flex:1).
544
+ // Build children and stickyHeaderIndices for the vertical ScrollView.
545
+ // When stickyHeaders is on, section headers are injected as direct children
546
+ // so React Native's stickyHeaderIndices can pin them with stacking.
547
+ const scrollChildren = [];
548
+ const stickyIndices = [];
549
+ if (showCardsRow) {
550
+ stickyIndices.push(scrollChildren.length);
551
+ scrollChildren.push(/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
552
+ ref: cardsScrollRef,
553
+ horizontal: true,
554
+ showsHorizontalScrollIndicator: false,
555
+ scrollEventThrottle: 16,
556
+ onScroll: onScroll('cards'),
557
+ onScrollBeginDrag: onScrollBeginDrag('cards'),
558
+ onScrollEndDrag: onScrollEndDrag('cards'),
559
+ onMomentumScrollEnd: onMomentumScrollEnd('cards'),
560
+ children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
561
+ style: {
562
+ flexDirection: 'row',
563
+ width: contentWidth
564
+ },
565
+ children: [columns.map(renderCard), showAddCard && renderAddCard()]
566
+ })
567
+ }, "cards-row"));
568
+ }
569
+ sections.forEach((section, index) => {
570
+ const sectionKey = section.key ?? section.title ?? index;
571
+ const headerContent = renderHeaderContent(section);
572
+ if (stickyHeaders && headerContent != null) {
573
+ stickyIndices.push(scrollChildren.length);
574
+ scrollChildren.push(/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
575
+ style: {
576
+ width: '100%'
577
+ },
578
+ children: stickyHeaderLabel ? headerContent : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
579
+ horizontal: true,
580
+ showsHorizontalScrollIndicator: false,
581
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
582
+ style: {
583
+ width: contentWidth
584
+ },
585
+ children: headerContent
586
+ })
587
+ })
588
+ }, `sticky-header-${sectionKey}`));
589
+ }
590
+ scrollChildren.push(/*#__PURE__*/(0, _jsxRuntime.jsxs)(_Accordion.default, {
591
+ title: section.title,
592
+ defaultExpanded: section.defaultExpanded ?? index === 0,
593
+ modes: modes,
594
+ children: [!stickyHeaders && headerContent != null && (stickyHeaderLabel ? headerContent : /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
595
+ horizontal: true,
596
+ showsHorizontalScrollIndicator: false,
597
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
598
+ style: {
599
+ width: contentWidth
600
+ },
601
+ children: headerContent
602
+ })
603
+ })), renderBodyContent(section, index)]
604
+ }, sectionKey));
605
+ });
532
606
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
533
607
  style: [outerStyle, style, {
534
608
  flexDirection: 'column',
535
609
  flex: 1
536
610
  }],
537
- children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.ScrollView, {
611
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
538
612
  style: {
539
613
  width: '100%',
540
614
  flex: 1
541
615
  },
542
- stickyHeaderIndices: showCardsRow ? [0] : [],
616
+ stickyHeaderIndices: stickyIndices,
543
617
  showsVerticalScrollIndicator: false,
544
618
  directionalLockEnabled: true,
545
- children: [showCardsRow && /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
546
- ref: cardsScrollRef,
547
- horizontal: true,
548
- showsHorizontalScrollIndicator: false,
549
- scrollEventThrottle: 16,
550
- onScroll: onCardsScroll,
551
- children: /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
552
- style: {
553
- flexDirection: 'row',
554
- width: contentWidth
555
- },
556
- children: [columns.map(renderCard), showAddCard && renderAddCard()]
557
- })
558
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
559
- style: {
560
- width: '100%'
561
- },
562
- children: sections.map(renderSection)
563
- })]
619
+ children: scrollChildren
564
620
  })
565
621
  });
566
622
  }
@@ -109,6 +109,7 @@ function ContentSheet({
109
109
  safeAreaBottom = true,
110
110
  pinToBottom = true,
111
111
  visible = true,
112
+ maxHeightPercent = 0.7,
112
113
  style,
113
114
  ...rest
114
115
  }) {
@@ -120,9 +121,6 @@ function ContentSheet({
120
121
  const resolved = (0, _react.useMemo)(() => resolveContentSheetStyle(modes), [modes]);
121
122
  const processedChildren = (0, _react.useMemo)(() => (0, _reactUtils.cloneChildrenWithModes)((0, _reactUtils.flattenChildren)(children), modes), [children, modes]);
122
123
  const paddingBottom = resolved.paddingBottom + (safeAreaBottom ? bottomInset : 0);
123
- const containerStyle = (0, _react.useMemo)(() => [pinToBottom ? pinnedStyle : null, resolved.container, {
124
- paddingBottom
125
- }, style], [pinToBottom, resolved.container, paddingBottom, style]);
126
124
 
127
125
  // Entrance / exit "pop up from bottom" animation — mirrors the Drawer. The
128
126
  // sheet is bottom-anchored, so a positive `translateY` equal to the window
@@ -132,6 +130,11 @@ function ContentSheet({
132
130
  const {
133
131
  height: windowHeight
134
132
  } = (0, _reactNative.useWindowDimensions)();
133
+ const maxHeight = windowHeight * maxHeightPercent;
134
+ const containerStyle = (0, _react.useMemo)(() => [pinToBottom ? pinnedStyle : null, resolved.container, {
135
+ maxHeight,
136
+ paddingBottom
137
+ }, style], [pinToBottom, resolved.container, maxHeight, paddingBottom, style]);
135
138
  const entrance = (0, _reactNativeReanimated.useSharedValue)(windowHeight);
136
139
  (0, _react.useEffect)(() => {
137
140
  entrance.value = (0, _reactNativeReanimated.withSpring)(visible ? 0 : windowHeight, SPRING_CONFIG);
@@ -155,6 +158,7 @@ function ContentSheet({
155
158
  style: containerStyle,
156
159
  spacing: keyboardSpacing,
157
160
  entrance: entrance,
161
+ maxHeight: maxHeight,
158
162
  ...rest,
159
163
  children: [header, processedChildren]
160
164
  });
@@ -162,6 +166,7 @@ function ContentSheet({
162
166
  return /*#__PURE__*/(0, _jsxRuntime.jsxs)(AnimatedSheet, {
163
167
  style: containerStyle,
164
168
  entrance: entrance,
169
+ maxHeight: maxHeight,
165
170
  ...rest,
166
171
  children: [header, processedChildren]
167
172
  });
@@ -178,6 +183,7 @@ function KeyboardAwareSheet({
178
183
  style,
179
184
  spacing,
180
185
  entrance,
186
+ maxHeight,
181
187
  children,
182
188
  ...rest
183
189
  }) {
@@ -194,7 +200,15 @@ function KeyboardAwareSheet({
194
200
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
195
201
  style: [style, animatedStyle],
196
202
  ...rest,
197
- children: children
203
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
204
+ style: {
205
+ maxHeight
206
+ },
207
+ bounces: false,
208
+ showsVerticalScrollIndicator: false,
209
+ keyboardShouldPersistTaps: "handled",
210
+ children: children
211
+ })
198
212
  });
199
213
  }
200
214
 
@@ -206,6 +220,7 @@ function KeyboardAwareSheet({
206
220
  function AnimatedSheet({
207
221
  style,
208
222
  entrance,
223
+ maxHeight,
209
224
  children,
210
225
  ...rest
211
226
  }) {
@@ -219,7 +234,15 @@ function AnimatedSheet({
219
234
  return /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
220
235
  style: [style, animatedStyle],
221
236
  ...rest,
222
- children: children
237
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.ScrollView, {
238
+ style: {
239
+ maxHeight
240
+ },
241
+ bounces: false,
242
+ showsVerticalScrollIndicator: false,
243
+ keyboardShouldPersistTaps: "handled",
244
+ children: children
245
+ })
223
246
  });
224
247
  }
225
248
  var _default = exports.default = ContentSheet;