jfs-components 0.1.25 → 0.1.30

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 (97) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/lib/commonjs/components/AutoplayControl/AutoplayControl.js +66 -0
  3. package/lib/commonjs/components/Carousel/Carousel.js +112 -19
  4. package/lib/commonjs/components/CompareTable/CompareTable.js +191 -25
  5. package/lib/commonjs/components/ContentSheet/ContentSheet.js +104 -10
  6. package/lib/commonjs/components/FormUpload/Additem.js +168 -0
  7. package/lib/commonjs/components/FormUpload/FormUpload.js +198 -0
  8. package/lib/commonjs/components/Grid/Grid.js +206 -0
  9. package/lib/commonjs/components/MoneyValue/MoneyValue.js +179 -54
  10. package/lib/commonjs/components/NumberPagination/NumberPagination.js +144 -0
  11. package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +7 -1
  12. package/lib/commonjs/components/ProfileCard/ProfileCard.js +208 -0
  13. package/lib/commonjs/components/Table/Table.js +27 -7
  14. package/lib/commonjs/components/UpiHandle/UpiHandle.js +23 -11
  15. package/lib/commonjs/components/index.js +42 -0
  16. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  17. package/lib/commonjs/design-tokens/figma-modes.generated.js +21 -10
  18. package/lib/commonjs/icons/components/IconBrokenimage.js +19 -0
  19. package/lib/commonjs/icons/components/IconImage.js +19 -0
  20. package/lib/commonjs/icons/components/IconMic.js +19 -0
  21. package/lib/commonjs/icons/components/IconMicoff.js +19 -0
  22. package/lib/commonjs/icons/components/IconPause.js +19 -0
  23. package/lib/commonjs/icons/components/IconPlay.js +19 -0
  24. package/lib/commonjs/icons/components/index.js +66 -0
  25. package/lib/commonjs/icons/registry.js +2 -2
  26. package/lib/module/components/AutoplayControl/AutoplayControl.js +60 -0
  27. package/lib/module/components/Carousel/Carousel.js +111 -19
  28. package/lib/module/components/CompareTable/CompareTable.js +191 -26
  29. package/lib/module/components/ContentSheet/ContentSheet.js +108 -14
  30. package/lib/module/components/FormUpload/Additem.js +162 -0
  31. package/lib/module/components/FormUpload/FormUpload.js +192 -0
  32. package/lib/module/components/Grid/Grid.js +201 -0
  33. package/lib/module/components/MoneyValue/MoneyValue.js +182 -57
  34. package/lib/module/components/NumberPagination/NumberPagination.js +138 -0
  35. package/lib/module/components/PdpCcCard/PdpCcCard.js +7 -1
  36. package/lib/module/components/ProfileCard/ProfileCard.js +202 -0
  37. package/lib/module/components/Table/Table.js +27 -7
  38. package/lib/module/components/UpiHandle/UpiHandle.js +23 -11
  39. package/lib/module/components/index.js +7 -1
  40. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  41. package/lib/module/design-tokens/figma-modes.generated.js +21 -10
  42. package/lib/module/icons/components/IconBrokenimage.js +12 -0
  43. package/lib/module/icons/components/IconImage.js +12 -0
  44. package/lib/module/icons/components/IconMic.js +12 -0
  45. package/lib/module/icons/components/IconMicoff.js +12 -0
  46. package/lib/module/icons/components/IconPause.js +12 -0
  47. package/lib/module/icons/components/IconPlay.js +12 -0
  48. package/lib/module/icons/components/index.js +6 -0
  49. package/lib/module/icons/registry.js +2 -2
  50. package/lib/typescript/src/components/AutoplayControl/AutoplayControl.d.ts +15 -0
  51. package/lib/typescript/src/components/Carousel/Carousel.d.ts +8 -1
  52. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +21 -1
  53. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +20 -1
  54. package/lib/typescript/src/components/FormUpload/Additem.d.ts +96 -0
  55. package/lib/typescript/src/components/FormUpload/FormUpload.d.ts +85 -0
  56. package/lib/typescript/src/components/Grid/Grid.d.ts +87 -0
  57. package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +10 -1
  58. package/lib/typescript/src/components/NumberPagination/NumberPagination.d.ts +21 -0
  59. package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +25 -1
  60. package/lib/typescript/src/components/ProfileCard/ProfileCard.d.ts +89 -0
  61. package/lib/typescript/src/components/Table/Table.d.ts +9 -1
  62. package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +7 -1
  63. package/lib/typescript/src/components/index.d.ts +34 -28
  64. package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +10 -4
  65. package/lib/typescript/src/icons/components/IconBrokenimage.d.ts +3 -0
  66. package/lib/typescript/src/icons/components/IconImage.d.ts +3 -0
  67. package/lib/typescript/src/icons/components/IconMic.d.ts +3 -0
  68. package/lib/typescript/src/icons/components/IconMicoff.d.ts +3 -0
  69. package/lib/typescript/src/icons/components/IconPause.d.ts +3 -0
  70. package/lib/typescript/src/icons/components/IconPlay.d.ts +3 -0
  71. package/lib/typescript/src/icons/components/index.d.ts +6 -0
  72. package/lib/typescript/src/icons/registry.d.ts +1 -1
  73. package/package.json +1 -1
  74. package/src/components/AutoplayControl/AutoplayControl.tsx +77 -0
  75. package/src/components/Carousel/Carousel.tsx +133 -23
  76. package/src/components/CompareTable/CompareTable.tsx +224 -30
  77. package/src/components/ContentSheet/ContentSheet.tsx +138 -8
  78. package/src/components/FormUpload/Additem.tsx +262 -0
  79. package/src/components/FormUpload/FormUpload.tsx +313 -0
  80. package/src/components/Grid/Grid.tsx +368 -0
  81. package/src/components/MoneyValue/MoneyValue.tsx +216 -65
  82. package/src/components/NumberPagination/NumberPagination.tsx +170 -0
  83. package/src/components/PdpCcCard/PdpCcCard.tsx +36 -1
  84. package/src/components/ProfileCard/ProfileCard.tsx +268 -0
  85. package/src/components/Table/Table.tsx +29 -4
  86. package/src/components/UpiHandle/UpiHandle.tsx +35 -24
  87. package/src/components/index.ts +329 -182
  88. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  89. package/src/design-tokens/figma-modes.generated.ts +21 -10
  90. package/src/icons/components/IconBrokenimage.tsx +11 -0
  91. package/src/icons/components/IconImage.tsx +11 -0
  92. package/src/icons/components/IconMic.tsx +11 -0
  93. package/src/icons/components/IconMicoff.tsx +11 -0
  94. package/src/icons/components/IconPause.tsx +11 -0
  95. package/src/icons/components/IconPlay.tsx +11 -0
  96. package/src/icons/components/index.ts +6 -0
  97. package/src/icons/registry.ts +25 -1
@@ -21,6 +21,8 @@ import {
21
21
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
22
22
  import { EMPTY_MODES } from '../../utils/react-utils'
23
23
  import type { Modes } from '../../design-tokens'
24
+ import AutoplayControl from '../AutoplayControl/AutoplayControl'
25
+ import NumberPagination from '../NumberPagination/NumberPagination'
24
26
 
25
27
  // ---------------------------------------------------------------------------
26
28
  // Context
@@ -75,6 +77,13 @@ export interface CarouselProps {
75
77
  paddingHorizontal?: number
76
78
  /** Explicit vertical padding (px) for the outer container. Overrides `carousel/padding/vertical`. */
77
79
  paddingVertical?: number
80
+ /**
81
+ * Carousel visual type.
82
+ * - `'Default'` — standard carousel with dot pagination below.
83
+ * - `'Numbered'` — full-bleed carousel with floating AutoplayControl and
84
+ * NumberPagination overlaid at the bottom center.
85
+ */
86
+ type?: 'Default' | 'Numbered'
78
87
 
79
88
  /** Called when the active index changes. */
80
89
  onIndexChange?: (index: number) => void
@@ -97,10 +106,12 @@ export function Carousel({
97
106
  itemWidth: itemWidthProp,
98
107
  paddingHorizontal: paddingHorizontalProp,
99
108
  paddingVertical: paddingVerticalProp,
109
+ type = 'Default',
100
110
 
101
111
  onIndexChange,
102
112
  style,
103
113
  }: CarouselProps) {
114
+ const isNumbered = type === 'Numbered'
104
115
  // ---- Token resolution ----
105
116
  const tokenGap = parseFloat(
106
117
  getVariableByName('carousel/gap', modes),
@@ -119,15 +130,32 @@ export function Carousel({
119
130
  const maxHeight = parseFloat(
120
131
  getVariableByName('carousel/maxHeight', modes),
121
132
  )
133
+ // Gap between AutoplayControl and NumberPagination in the Numbered overlay.
134
+ const controlGap = parseFloat(
135
+ getVariableByName('carouselControl/gap', modes),
136
+ )
137
+
122
138
  // Spacing between the cards row and the pagination dots uses `carousel/gap`.
123
139
  const paginationOffset = gap
124
140
 
125
141
  // ---- Refs & state ----
126
142
  const scrollRef = useRef<ScrollView>(null)
127
143
  const [activeIndex, setActiveIndex] = useState(0)
144
+ const activeIndexRef = useRef(0)
128
145
  const [containerWidth, setContainerWidth] = useState(0)
129
146
  const autoPlayTimer = useRef<ReturnType<typeof setInterval> | null>(null)
130
147
  const userInteracting = useRef(false)
148
+ // Guard: suppress handleScroll index updates during programmatic scrollTo
149
+ // animations so intermediate offsets don't flicker activeIndex back.
150
+ const programmaticScroll = useRef(false)
151
+ const programmaticTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
152
+
153
+ useEffect(() => {
154
+ activeIndexRef.current = activeIndex
155
+ }, [activeIndex])
156
+
157
+ // Interactive play/pause state for the Numbered variant.
158
+ const [isPlaying, setIsPlaying] = useState(autoPlay)
131
159
 
132
160
  // Flatten children so we can count items
133
161
  const items = useMemo(
@@ -137,13 +165,15 @@ export function Carousel({
137
165
 
138
166
  const totalItems = items.length
139
167
 
140
- // Effective item width: provided prop, or full container width minus peek offsets
168
+ // Effective item width: provided prop, or full container width minus peek offsets.
169
+ // For the Numbered variant the items are full-bleed (no peek padding).
141
170
  const effectiveItemWidth = useMemo(() => {
142
171
  if (itemWidthProp != null) return itemWidthProp
143
172
  if (containerWidth === 0) return 0
173
+ if (isNumbered) return containerWidth
144
174
  // Full-width minus peekOffset on each side
145
175
  return containerWidth - containerPaddingH * 4
146
- }, [itemWidthProp, containerWidth, containerPaddingH])
176
+ }, [itemWidthProp, containerWidth, containerPaddingH, isNumbered])
147
177
 
148
178
  // Snap interval = item width + gap
149
179
  const snapInterval = effectiveItemWidth + gap
@@ -158,6 +188,17 @@ export function Carousel({
158
188
  } else {
159
189
  targetIndex = Math.max(0, Math.min(index, totalItems - 1))
160
190
  }
191
+ // Lock out handleScroll during the animated scroll so intermediate
192
+ // offsets don't flicker activeIndex back to the old value.
193
+ if (animated) {
194
+ programmaticScroll.current = true
195
+ if (programmaticTimer.current != null) {
196
+ clearTimeout(programmaticTimer.current)
197
+ }
198
+ programmaticTimer.current = setTimeout(() => {
199
+ programmaticScroll.current = false
200
+ }, 500)
201
+ }
161
202
  scrollRef.current?.scrollTo({
162
203
  x: targetIndex * snapInterval,
163
204
  animated,
@@ -185,6 +226,9 @@ export function Carousel({
185
226
  const handleScroll = useCallback(
186
227
  (event: NativeSyntheticEvent<NativeScrollEvent>) => {
187
228
  if (snapInterval === 0) return
229
+ // Skip index recalculation during programmatic scrollTo animations
230
+ // to prevent the active indicator from flickering back to the old page.
231
+ if (programmaticScroll.current) return
188
232
  const offsetX = event.nativeEvent.contentOffset.x
189
233
  const newIndex = Math.round(offsetX / snapInterval)
190
234
  const clampedIndex = Math.max(0, Math.min(newIndex, totalItems - 1))
@@ -205,31 +249,28 @@ export function Carousel({
205
249
  }, [])
206
250
 
207
251
  const startAutoPlay = useCallback(() => {
208
- if (!autoPlay || totalItems <= 1) return
252
+ if ((!autoPlay && !isPlaying) || totalItems <= 1) return
253
+ if (!isPlaying) return
209
254
  clearAutoPlay()
210
255
  autoPlayTimer.current = setInterval(() => {
211
256
  if (!userInteracting.current) {
212
- setActiveIndex((prev) => {
213
- const next = loop
214
- ? (prev + 1) % totalItems
215
- : Math.min(prev + 1, totalItems - 1)
216
- scrollRef.current?.scrollTo({
217
- x: next * snapInterval,
218
- animated: true,
219
- })
220
- onIndexChange?.(next)
221
- return next
222
- })
257
+ const prev = activeIndexRef.current
258
+ const next = loop
259
+ ? (prev + 1) % totalItems
260
+ : Math.min(prev + 1, totalItems - 1)
261
+ // Route through scrollToIndex so the programmaticScroll guard
262
+ // suppresses intermediate handleScroll offsets during the animation.
263
+ scrollToIndex(next, true)
223
264
  }
224
265
  }, autoPlayInterval)
225
266
  }, [
226
267
  autoPlay,
268
+ isPlaying,
227
269
  totalItems,
228
270
  loop,
229
- snapInterval,
230
271
  autoPlayInterval,
231
272
  clearAutoPlay,
232
- onIndexChange,
273
+ scrollToIndex,
233
274
  ])
234
275
 
235
276
  useEffect(() => {
@@ -244,8 +285,27 @@ export function Carousel({
244
285
  const handleScrollEndDrag = useCallback(() => {
245
286
  userInteracting.current = false
246
287
  // Reset autoplay timer after interaction
247
- if (autoPlay) startAutoPlay()
248
- }, [autoPlay, startAutoPlay])
288
+ if (autoPlay || isPlaying) startAutoPlay()
289
+ }, [autoPlay, isPlaying, startAutoPlay])
290
+
291
+ const finishProgrammaticScroll = useCallback(
292
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
293
+ programmaticScroll.current = false
294
+ if (programmaticTimer.current != null) {
295
+ clearTimeout(programmaticTimer.current)
296
+ programmaticTimer.current = null
297
+ }
298
+ if (snapInterval === 0) return
299
+ const offsetX = event.nativeEvent.contentOffset.x
300
+ const newIndex = Math.round(offsetX / snapInterval)
301
+ const clampedIndex = Math.max(0, Math.min(newIndex, totalItems - 1))
302
+ if (clampedIndex !== activeIndexRef.current) {
303
+ setActiveIndex(clampedIndex)
304
+ onIndexChange?.(clampedIndex)
305
+ }
306
+ },
307
+ [snapInterval, totalItems, onIndexChange],
308
+ )
249
309
 
250
310
  // ---- Layout ----
251
311
  const handleLayout = useCallback((e: LayoutChangeEvent) => {
@@ -265,15 +325,37 @@ export function Carousel({
265
325
  [modes, activeIndex, totalItems, goTo, goNext, goPrev],
266
326
  )
267
327
 
328
+ // ---- Toggle handler for Numbered variant ----
329
+ const handleToggleAutoplay = useCallback(() => {
330
+ setIsPlaying((prev) => {
331
+ if (prev) {
332
+ // Pausing
333
+ clearAutoPlay()
334
+ return false
335
+ }
336
+ // Resuming — autoplay will restart via the effect
337
+ return true
338
+ })
339
+ }, [clearAutoPlay])
340
+
341
+ // Restart autoplay whenever isPlaying flips to true
342
+ useEffect(() => {
343
+ if (isPlaying) startAutoPlay()
344
+ else clearAutoPlay()
345
+ }, [isPlaying, startAutoPlay, clearAutoPlay])
346
+
268
347
  // ---- Render ----
269
348
  // Explicit padding props win verbatim; otherwise fall back to the
270
349
  // token-derived values (horizontal keeps its legacy ×2 "peek" multiplier).
271
- const effectivePaddingH = paddingHorizontalProp ?? containerPaddingH
272
- const effectivePaddingV = paddingVerticalProp ?? containerPaddingV
350
+ // Numbered variant is full-bleed: zero padding.
351
+ const effectivePaddingH = isNumbered ? 0 : (paddingHorizontalProp ?? containerPaddingH)
352
+ const effectivePaddingV = isNumbered ? 0 : (paddingVerticalProp ?? containerPaddingV)
273
353
 
274
354
  const outerStyle: ViewStyle = {
275
355
  paddingVertical: effectivePaddingV,
276
- maxHeight,
356
+ maxHeight: isNumbered ? undefined : maxHeight,
357
+ // Numbered needs relative positioning for the floating controls overlay.
358
+ ...(isNumbered ? { overflow: 'hidden' as const } : {}),
277
359
  }
278
360
 
279
361
  const contentContainerStyle: ViewStyle = {
@@ -299,7 +381,7 @@ export function Carousel({
299
381
  scrollEventThrottle={16}
300
382
  onScrollBeginDrag={handleScrollBeginDrag}
301
383
  onScrollEndDrag={handleScrollEndDrag}
302
-
384
+ onMomentumScrollEnd={finishProgrammaticScroll}
303
385
  >
304
386
  {items.map((child, index) => {
305
387
  // Strict slot box: width must be honored; never grow or shrink with
@@ -343,12 +425,40 @@ export function Carousel({
343
425
  })}
344
426
  </ScrollView>
345
427
 
346
- {showPagination && totalItems > 1 && (
428
+ {!isNumbered && showPagination && totalItems > 1 && (
347
429
  <Pagination
348
430
  modes={modes}
349
431
  style={{ marginTop: paginationOffset }}
350
432
  />
351
433
  )}
434
+
435
+ {isNumbered && showPagination && totalItems > 1 && (
436
+ <View
437
+ style={{
438
+ position: 'absolute',
439
+ bottom: 12,
440
+ left: 0,
441
+ right: 0,
442
+ flexDirection: 'row',
443
+ justifyContent: 'center',
444
+ alignItems: 'center',
445
+ gap: controlGap,
446
+ }}
447
+ pointerEvents="box-none"
448
+ >
449
+ <AutoplayControl
450
+ state={isPlaying ? 'pause' : 'play'}
451
+ onPress={handleToggleAutoplay}
452
+ modes={modes}
453
+ />
454
+ <NumberPagination
455
+ activePage={activeIndex + 1}
456
+ totalPages={totalItems}
457
+ onPageChange={(page) => goTo(page - 1)}
458
+ modes={modes}
459
+ />
460
+ </View>
461
+ )}
352
462
  </View>
353
463
  </CarouselContext.Provider>
354
464
  )
@@ -1,10 +1,13 @@
1
- import React from 'react'
1
+ import React, { useCallback, useRef } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
5
  Pressable,
6
6
  Platform,
7
+ ScrollView,
7
8
  type ImageSourcePropType,
9
+ type NativeScrollEvent,
10
+ type NativeSyntheticEvent,
8
11
  type StyleProp,
9
12
  type ViewStyle,
10
13
  type TextStyle,
@@ -140,6 +143,26 @@ export type CompareTableProps = {
140
143
  * lines as it needs even when horizontal space is tight.
141
144
  */
142
145
  disableTruncation?: boolean;
146
+ /**
147
+ * Fixed width (px) for every selection card and every table cell — all
148
+ * columns share this width. When set, the component enters **scroll mode**:
149
+ *
150
+ * - The cards row and each section's table body become independent
151
+ * horizontal `ScrollView`s whose scroll events are **chained** — moving
152
+ * one moves every other in lockstep (the cards always stay aligned with
153
+ * the cells beneath them).
154
+ * - The cards row becomes a **sticky header** at the top: it pins in place
155
+ * on the vertical axis (does not scroll away) while the sections scroll
156
+ * beneath it. Requires the host to give `CompareTable` a bounded height.
157
+ * - The gray table-header rows (each section's `header`) are rendered
158
+ * full-width **outside** the horizontal scroll, so they stay put
159
+ * horizontally (they don't slide with the cells) while still scrolling
160
+ * vertically with the table.
161
+ *
162
+ * Omit to keep the original flex layout (columns share the available width,
163
+ * no scrolling). @default undefined
164
+ */
165
+ columnWidth?: number;
143
166
  };
144
167
 
145
168
  const DEFAULT_COLUMNS: CompareTableColumn[] = [
@@ -181,6 +204,7 @@ function CompareTable({
181
204
  modes = EMPTY_MODES,
182
205
  style,
183
206
  disableTruncation,
207
+ columnWidth,
184
208
  }: CompareTableProps) {
185
209
  // --- selection card tokens ------------------------------------------------
186
210
  const cardBg = (getVariableByName('selectionCard/background/color', modes) as string) ?? '#ffffff'
@@ -238,6 +262,72 @@ function CompareTable({
238
262
  const showAddCard = onAddColumn != null && columnCount < maxColumns
239
263
  /** Grid columns shared by the selection-card row and every table row. */
240
264
  const gridColumnCount = columnCount + (showAddCard ? 1 : 0)
265
+ const showCardsRow = columnCount > 0 || showAddCard
266
+
267
+ // --- fixed-width scroll mode -------------------------------------------
268
+ // `columnWidth` opts the component into scroll mode (see prop doc). In that
269
+ // mode the cards row and each section's table body are independent
270
+ // horizontal ScrollViews; their scroll events are chained so they move in
271
+ // lockstep, and the cards row pins to the top on the vertical axis.
272
+ const useFixedWidth = columnWidth != null
273
+ /** Total scrollable content width shared by every horizontal surface. */
274
+ const contentWidth = useFixedWidth ? gridColumnCount * (columnWidth as number) : 0
275
+
276
+ const cardsScrollRef = useRef<ScrollView>(null)
277
+ const bodyScrollRefs = useRef<Map<number, ScrollView>>(new Map())
278
+ // Stable per-section ref-callback instances so React doesn't detach/reattach
279
+ // on every render (which would churn the map and re-fire scrollTo).
280
+ const bodyRefCallbacks = useRef<Map<number, (r: ScrollView | null) => void>>(new Map())
281
+ /** Last offset we synced everyone to. Echoed onScroll events reporting this
282
+ * same value are ignored — this is what prevents feedback loops. */
283
+ const lastSyncX = useRef(0)
284
+
285
+ const applySync = useCallback((x: number, source: 'cards' | number) => {
286
+ if (Math.abs(x - lastSyncX.current) < 0.5) return
287
+ lastSyncX.current = x
288
+ if (source !== 'cards' && cardsScrollRef.current) {
289
+ cardsScrollRef.current.scrollTo({ x, animated: false })
290
+ }
291
+ bodyScrollRefs.current.forEach((ref, idx) => {
292
+ if (source === 'cards' || idx !== source) {
293
+ ref.scrollTo({ x, animated: false })
294
+ }
295
+ })
296
+ }, [])
297
+
298
+ const onCardsScroll = useCallback(
299
+ (e: NativeSyntheticEvent<NativeScrollEvent>) => {
300
+ applySync(e.nativeEvent.contentOffset.x, 'cards')
301
+ },
302
+ [applySync],
303
+ )
304
+
305
+ const onBodyScroll = useCallback(
306
+ (idx: number) => (e: NativeSyntheticEvent<NativeScrollEvent>) => {
307
+ applySync(e.nativeEvent.contentOffset.x, idx)
308
+ },
309
+ [applySync],
310
+ )
311
+
312
+ const getBodyRefCallback = useCallback((idx: number) => {
313
+ let cb = bodyRefCallbacks.current.get(idx)
314
+ if (!cb) {
315
+ cb = (ref: ScrollView | null) => {
316
+ if (ref) {
317
+ bodyScrollRefs.current.set(idx, ref)
318
+ // Bring a freshly-expanded section's body into sync with
319
+ // the rest of the surfaces.
320
+ if (lastSyncX.current > 0) {
321
+ ref.scrollTo({ x: lastSyncX.current, animated: false })
322
+ }
323
+ } else {
324
+ bodyScrollRefs.current.delete(idx)
325
+ }
326
+ }
327
+ bodyRefCallbacks.current.set(idx, cb)
328
+ }
329
+ return cb
330
+ }, [])
241
331
 
242
332
  const cardLabelStyle: TextStyle = {
243
333
  ...NO_WRAP_TEXT,
@@ -273,8 +363,7 @@ function CompareTable({
273
363
  }
274
364
 
275
365
  const cardStyle: ViewStyle = {
276
- flex: 1,
277
- minWidth: 0,
366
+ ...(useFixedWidth ? { width: columnWidth as number } : { flex: 1, minWidth: 0 }),
278
367
  minHeight: 147,
279
368
  backgroundColor: cardBg,
280
369
  borderRadius: cardRadius,
@@ -420,6 +509,7 @@ function CompareTable({
420
509
  key={key}
421
510
  modes={modes}
422
511
  flex={1}
512
+ width={useFixedWidth ? columnWidth : undefined}
423
513
  align="left"
424
514
  isLastColumn={isLastCol}
425
515
  isLastRow={isLastRow}
@@ -486,38 +576,142 @@ function CompareTable({
486
576
  </Table>
487
577
  )
488
578
 
489
- return (
490
- <View
491
- style={[
492
- {
493
- width: '100%',
494
- backgroundColor: cardBg,
495
- borderWidth: 1,
496
- borderColor: '#e8e8e8',
497
- overflow: 'hidden',
498
- },
499
- style,
500
- ]}
579
+ // Render a single section in fixed-width scroll mode. Unlike `renderTable`
580
+ // (used in flex mode), the gray header row is rendered full-width OUTSIDE
581
+ // the body's horizontal ScrollView so it stays put horizontally (it does
582
+ // not slide with the cells) while still scrolling vertically with the table.
583
+ // The body rows live inside a horizontal ScrollView that is scroll-synced
584
+ // with the cards row and every other section's body.
585
+ const renderSection = (section: CompareTableSection, index: number) => (
586
+ <Accordion
587
+ key={section.key ?? section.title ?? index}
588
+ title={section.title}
589
+ defaultExpanded={section.defaultExpanded ?? index === 0}
590
+ modes={modes}
501
591
  >
502
- {(columnCount > 0 || showAddCard) && (
503
- <View style={{ flexDirection: 'row', width: '100%' }}>
504
- {columns.map(renderCard)}
505
- {showAddCard && renderAddCard()}
592
+ {section.header != null && (
593
+ <View
594
+ style={{
595
+ width: '100%',
596
+ backgroundColor: headerBg,
597
+ paddingHorizontal: headerPaddingH,
598
+ paddingVertical: headerPaddingV,
599
+ borderBottomWidth: cellBorderSize,
600
+ borderBottomColor: cellBorderColor,
601
+ }}
602
+ >
603
+ <Text style={headerTextStyle}>{section.header}</Text>
506
604
  </View>
507
605
  )}
606
+ <ScrollView
607
+ ref={getBodyRefCallback(index)}
608
+ horizontal
609
+ showsHorizontalScrollIndicator={false}
610
+ scrollEventThrottle={16}
611
+ onScroll={onBodyScroll(index)}
612
+ >
613
+ <View style={{ width: contentWidth }}>
614
+ {section.rows.map((row, rowIndex) => {
615
+ const isLastRow = rowIndex === section.rows.length - 1
616
+ return (
617
+ <View
618
+ key={row.key ?? rowIndex}
619
+ style={{ flexDirection: 'row', width: '100%' }}
620
+ >
621
+ {Array.from({ length: columnCount }).map((_, colIndex) =>
622
+ renderTableCell(
623
+ renderCellContent(
624
+ row.values?.[colIndex],
625
+ `${rowIndex}-${colIndex}`,
626
+ ),
627
+ colIndex,
628
+ isLastRow,
629
+ colIndex,
630
+ ),
631
+ )}
632
+ {showAddCard &&
633
+ renderTableCell(
634
+ null,
635
+ gridColumnCount - 1,
636
+ isLastRow,
637
+ '__add_spacer__',
638
+ )}
639
+ </View>
640
+ )
641
+ })}
642
+ </View>
643
+ </ScrollView>
644
+ </Accordion>
645
+ )
508
646
 
509
- <View style={{ width: '100%' }}>
510
- {sections.map((section, index) => (
511
- <Accordion
512
- key={section.key ?? section.title ?? index}
513
- title={section.title}
514
- defaultExpanded={section.defaultExpanded ?? index === 0}
515
- modes={modes}
516
- >
517
- {renderTable(section)}
518
- </Accordion>
519
- ))}
647
+ const outerStyle: ViewStyle = {
648
+ width: '100%',
649
+ backgroundColor: cardBg,
650
+ borderWidth: 1,
651
+ borderColor: '#e8e8e8',
652
+ overflow: 'hidden',
653
+ }
654
+
655
+ // --- flex mode (columnWidth omitted): original behaviour, unchanged -------
656
+ if (!useFixedWidth) {
657
+ return (
658
+ <View style={[outerStyle, style]}>
659
+ {showCardsRow && (
660
+ <View style={{ flexDirection: 'row', width: '100%' }}>
661
+ {columns.map(renderCard)}
662
+ {showAddCard && renderAddCard()}
663
+ </View>
664
+ )}
665
+
666
+ <View style={{ width: '100%' }}>
667
+ {sections.map((section, index) => (
668
+ <Accordion
669
+ key={section.key ?? section.title ?? index}
670
+ title={section.title}
671
+ defaultExpanded={section.defaultExpanded ?? index === 0}
672
+ modes={modes}
673
+ >
674
+ {renderTable(section)}
675
+ </Accordion>
676
+ ))}
677
+ </View>
520
678
  </View>
679
+ )
680
+ }
681
+
682
+ // --- fixed-width scroll mode: chained horizontal sync + sticky cards -----
683
+ // The cards row (index 0) is a sticky header of the vertical ScrollView so
684
+ // it pins to the top while the sections scroll beneath. The cards row is
685
+ // itself a horizontal ScrollView, synced with every section body. For the
686
+ // vertical scroll (and thus the sticky behaviour) to engage, the host must
687
+ // give CompareTable a bounded height (e.g. a sized container or flex:1).
688
+ return (
689
+ <View style={[outerStyle, style, { flexDirection: 'column', flex: 1 }]}>
690
+ <ScrollView
691
+ style={{ width: '100%', flex: 1 }}
692
+ stickyHeaderIndices={showCardsRow ? [0] : []}
693
+ showsVerticalScrollIndicator={false}
694
+ directionalLockEnabled
695
+ >
696
+ {showCardsRow && (
697
+ <ScrollView
698
+ ref={cardsScrollRef}
699
+ horizontal
700
+ showsHorizontalScrollIndicator={false}
701
+ scrollEventThrottle={16}
702
+ onScroll={onCardsScroll}
703
+ >
704
+ <View style={{ flexDirection: 'row', width: contentWidth }}>
705
+ {columns.map(renderCard)}
706
+ {showAddCard && renderAddCard()}
707
+ </View>
708
+ </ScrollView>
709
+ )}
710
+
711
+ <View style={{ width: '100%' }}>
712
+ {sections.map(renderSection)}
713
+ </View>
714
+ </ScrollView>
521
715
  </View>
522
716
  )
523
717
  }