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.
- package/CHANGELOG.md +53 -0
- package/lib/commonjs/components/AutoplayControl/AutoplayControl.js +66 -0
- package/lib/commonjs/components/Carousel/Carousel.js +112 -19
- package/lib/commonjs/components/CompareTable/CompareTable.js +191 -25
- package/lib/commonjs/components/ContentSheet/ContentSheet.js +104 -10
- package/lib/commonjs/components/FormUpload/Additem.js +168 -0
- package/lib/commonjs/components/FormUpload/FormUpload.js +198 -0
- package/lib/commonjs/components/Grid/Grid.js +206 -0
- package/lib/commonjs/components/MoneyValue/MoneyValue.js +179 -54
- package/lib/commonjs/components/NumberPagination/NumberPagination.js +144 -0
- package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +7 -1
- package/lib/commonjs/components/ProfileCard/ProfileCard.js +208 -0
- package/lib/commonjs/components/Table/Table.js +27 -7
- package/lib/commonjs/components/UpiHandle/UpiHandle.js +23 -11
- package/lib/commonjs/components/index.js +42 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/design-tokens/figma-modes.generated.js +21 -10
- package/lib/commonjs/icons/components/IconBrokenimage.js +19 -0
- package/lib/commonjs/icons/components/IconImage.js +19 -0
- package/lib/commonjs/icons/components/IconMic.js +19 -0
- package/lib/commonjs/icons/components/IconMicoff.js +19 -0
- package/lib/commonjs/icons/components/IconPause.js +19 -0
- package/lib/commonjs/icons/components/IconPlay.js +19 -0
- package/lib/commonjs/icons/components/index.js +66 -0
- package/lib/commonjs/icons/registry.js +2 -2
- package/lib/module/components/AutoplayControl/AutoplayControl.js +60 -0
- package/lib/module/components/Carousel/Carousel.js +111 -19
- package/lib/module/components/CompareTable/CompareTable.js +191 -26
- package/lib/module/components/ContentSheet/ContentSheet.js +108 -14
- package/lib/module/components/FormUpload/Additem.js +162 -0
- package/lib/module/components/FormUpload/FormUpload.js +192 -0
- package/lib/module/components/Grid/Grid.js +201 -0
- package/lib/module/components/MoneyValue/MoneyValue.js +182 -57
- package/lib/module/components/NumberPagination/NumberPagination.js +138 -0
- package/lib/module/components/PdpCcCard/PdpCcCard.js +7 -1
- package/lib/module/components/ProfileCard/ProfileCard.js +202 -0
- package/lib/module/components/Table/Table.js +27 -7
- package/lib/module/components/UpiHandle/UpiHandle.js +23 -11
- package/lib/module/components/index.js +7 -1
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/design-tokens/figma-modes.generated.js +21 -10
- package/lib/module/icons/components/IconBrokenimage.js +12 -0
- package/lib/module/icons/components/IconImage.js +12 -0
- package/lib/module/icons/components/IconMic.js +12 -0
- package/lib/module/icons/components/IconMicoff.js +12 -0
- package/lib/module/icons/components/IconPause.js +12 -0
- package/lib/module/icons/components/IconPlay.js +12 -0
- package/lib/module/icons/components/index.js +6 -0
- package/lib/module/icons/registry.js +2 -2
- package/lib/typescript/src/components/AutoplayControl/AutoplayControl.d.ts +15 -0
- package/lib/typescript/src/components/Carousel/Carousel.d.ts +8 -1
- package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +21 -1
- package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +20 -1
- package/lib/typescript/src/components/FormUpload/Additem.d.ts +96 -0
- package/lib/typescript/src/components/FormUpload/FormUpload.d.ts +85 -0
- package/lib/typescript/src/components/Grid/Grid.d.ts +87 -0
- package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +10 -1
- package/lib/typescript/src/components/NumberPagination/NumberPagination.d.ts +21 -0
- package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +25 -1
- package/lib/typescript/src/components/ProfileCard/ProfileCard.d.ts +89 -0
- package/lib/typescript/src/components/Table/Table.d.ts +9 -1
- package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +7 -1
- package/lib/typescript/src/components/index.d.ts +34 -28
- package/lib/typescript/src/design-tokens/figma-modes.generated.d.ts +10 -4
- package/lib/typescript/src/icons/components/IconBrokenimage.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconImage.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconMic.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconMicoff.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconPause.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconPlay.d.ts +3 -0
- package/lib/typescript/src/icons/components/index.d.ts +6 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/AutoplayControl/AutoplayControl.tsx +77 -0
- package/src/components/Carousel/Carousel.tsx +133 -23
- package/src/components/CompareTable/CompareTable.tsx +224 -30
- package/src/components/ContentSheet/ContentSheet.tsx +138 -8
- package/src/components/FormUpload/Additem.tsx +262 -0
- package/src/components/FormUpload/FormUpload.tsx +313 -0
- package/src/components/Grid/Grid.tsx +368 -0
- package/src/components/MoneyValue/MoneyValue.tsx +216 -65
- package/src/components/NumberPagination/NumberPagination.tsx +170 -0
- package/src/components/PdpCcCard/PdpCcCard.tsx +36 -1
- package/src/components/ProfileCard/ProfileCard.tsx +268 -0
- package/src/components/Table/Table.tsx +29 -4
- package/src/components/UpiHandle/UpiHandle.tsx +35 -24
- package/src/components/index.ts +329 -182
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/design-tokens/figma-modes.generated.ts +21 -10
- package/src/icons/components/IconBrokenimage.tsx +11 -0
- package/src/icons/components/IconImage.tsx +11 -0
- package/src/icons/components/IconMic.tsx +11 -0
- package/src/icons/components/IconMicoff.tsx +11 -0
- package/src/icons/components/IconPause.tsx +11 -0
- package/src/icons/components/IconPlay.tsx +11 -0
- package/src/icons/components/index.ts +6 -0
- 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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
272
|
-
const
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
{
|
|
503
|
-
<View
|
|
504
|
-
{
|
|
505
|
-
|
|
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
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
}
|