jfs-components 0.1.28 → 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.
- package/CHANGELOG.md +28 -2
- package/lib/commonjs/components/CategoryCard/CategoryCard.js +264 -0
- package/lib/commonjs/components/CompareTable/CompareTable.js +247 -25
- package/lib/commonjs/components/ContentSheet/ContentSheet.js +91 -8
- package/lib/commonjs/components/CounterBadge/CounterBadge.js +78 -0
- package/lib/commonjs/components/FavoriteToggle/FavoriteToggle.js +180 -0
- package/lib/commonjs/components/HelloJioInput/HelloJioInput.js +287 -0
- package/lib/commonjs/components/MoneyValue/MoneyValue.js +179 -54
- package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +7 -1
- package/lib/commonjs/components/Table/Table.js +27 -7
- package/lib/commonjs/components/ValueBackMetric/ValueBackMetric.js +219 -0
- package/lib/commonjs/components/index.js +35 -0
- package/lib/commonjs/design-tokens/figma-modes.generated.js +3 -0
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/CategoryCard/CategoryCard.js +258 -0
- package/lib/module/components/CompareTable/CompareTable.js +247 -26
- package/lib/module/components/ContentSheet/ContentSheet.js +94 -11
- package/lib/module/components/CounterBadge/CounterBadge.js +73 -0
- package/lib/module/components/FavoriteToggle/FavoriteToggle.js +174 -0
- package/lib/module/components/HelloJioInput/HelloJioInput.js +281 -0
- package/lib/module/components/MoneyValue/MoneyValue.js +182 -57
- package/lib/module/components/PdpCcCard/PdpCcCard.js +7 -1
- package/lib/module/components/Table/Table.js +27 -7
- package/lib/module/components/ValueBackMetric/ValueBackMetric.js +214 -0
- package/lib/module/components/index.js +6 -1
- package/lib/module/design-tokens/figma-modes.generated.js +3 -0
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/CategoryCard/CategoryCard.d.ts +72 -0
- package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +36 -1
- package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +20 -1
- package/lib/typescript/src/components/CounterBadge/CounterBadge.d.ts +19 -0
- package/lib/typescript/src/components/FavoriteToggle/FavoriteToggle.d.ts +66 -0
- package/lib/typescript/src/components/HelloJioInput/HelloJioInput.d.ts +111 -0
- package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +10 -1
- package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +25 -1
- package/lib/typescript/src/components/Table/Table.d.ts +9 -1
- package/lib/typescript/src/components/ValueBackMetric/ValueBackMetric.d.ts +86 -0
- package/lib/typescript/src/components/index.d.ts +5 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/CategoryCard/CategoryCard.tsx +404 -0
- package/src/components/CompareTable/CompareTable.tsx +324 -30
- package/src/components/ContentSheet/ContentSheet.tsx +120 -11
- package/src/components/CounterBadge/CounterBadge.tsx +95 -0
- package/src/components/FavoriteToggle/FavoriteToggle.tsx +243 -0
- package/src/components/HelloJioInput/HelloJioInput.tsx +513 -0
- package/src/components/MoneyValue/MoneyValue.tsx +216 -65
- package/src/components/PdpCcCard/PdpCcCard.tsx +36 -1
- package/src/components/Table/Table.tsx +29 -4
- package/src/components/ValueBackMetric/ValueBackMetric.tsx +298 -0
- package/src/components/index.ts +5 -0
- package/src/design-tokens/figma-modes.generated.ts +3 -0
- package/src/icons/registry.ts +1 -1
|
@@ -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,41 @@ 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;
|
|
166
|
+
/**
|
|
167
|
+
* When `true` (default), each section's header row (the gray header label)
|
|
168
|
+
* sticks to the top of the viewport when scrolling vertically, with multiple
|
|
169
|
+
* sticky headers stacking up as each reaches the top. Only applies in
|
|
170
|
+
* fixed-width scroll mode (`columnWidth` is set).
|
|
171
|
+
* @default true
|
|
172
|
+
*/
|
|
173
|
+
stickyHeaders?: boolean;
|
|
174
|
+
/**
|
|
175
|
+
* When `true` (default), the section header label stays fixed on the left
|
|
176
|
+
* when scrolling horizontally. Only applies in fixed-width scroll mode
|
|
177
|
+
* (`columnWidth` is set).
|
|
178
|
+
* @default true
|
|
179
|
+
*/
|
|
180
|
+
stickyHeaderLabel?: boolean;
|
|
143
181
|
};
|
|
144
182
|
|
|
145
183
|
const DEFAULT_COLUMNS: CompareTableColumn[] = [
|
|
@@ -181,6 +219,9 @@ function CompareTable({
|
|
|
181
219
|
modes = EMPTY_MODES,
|
|
182
220
|
style,
|
|
183
221
|
disableTruncation,
|
|
222
|
+
columnWidth,
|
|
223
|
+
stickyHeaders = true,
|
|
224
|
+
stickyHeaderLabel = true,
|
|
184
225
|
}: CompareTableProps) {
|
|
185
226
|
// --- selection card tokens ------------------------------------------------
|
|
186
227
|
const cardBg = (getVariableByName('selectionCard/background/color', modes) as string) ?? '#ffffff'
|
|
@@ -238,6 +279,108 @@ function CompareTable({
|
|
|
238
279
|
const showAddCard = onAddColumn != null && columnCount < maxColumns
|
|
239
280
|
/** Grid columns shared by the selection-card row and every table row. */
|
|
240
281
|
const gridColumnCount = columnCount + (showAddCard ? 1 : 0)
|
|
282
|
+
const showCardsRow = columnCount > 0 || showAddCard
|
|
283
|
+
|
|
284
|
+
// --- fixed-width scroll mode -------------------------------------------
|
|
285
|
+
// `columnWidth` opts the component into scroll mode (see prop doc). In that
|
|
286
|
+
// mode the cards row and each section's table body are independent
|
|
287
|
+
// horizontal ScrollViews; their scroll events are chained so they move in
|
|
288
|
+
// lockstep, and the cards row pins to the top on the vertical axis.
|
|
289
|
+
const useFixedWidth = columnWidth != null
|
|
290
|
+
/** Total scrollable content width shared by every horizontal surface. */
|
|
291
|
+
const contentWidth = useFixedWidth ? gridColumnCount * (columnWidth as number) : 0
|
|
292
|
+
|
|
293
|
+
const cardsScrollRef = useRef<ScrollView>(null)
|
|
294
|
+
const bodyScrollRefs = useRef<Map<number, ScrollView>>(new Map())
|
|
295
|
+
// Stable per-section ref-callback instances so React doesn't detach/reattach
|
|
296
|
+
// on every render (which would churn the map and re-fire scrollTo).
|
|
297
|
+
const bodyRefCallbacks = useRef<Map<number, (r: ScrollView | null) => void>>(new Map())
|
|
298
|
+
/** Tracks which ScrollView the user is currently dragging. Only the
|
|
299
|
+
* actively-dragged ScrollView drives horizontal sync — programmatic
|
|
300
|
+
* scrollTo echoes from synced peers are ignored, eliminating jitter. */
|
|
301
|
+
const draggingSource = useRef<'cards' | number | null>(null)
|
|
302
|
+
const lastSyncX = useRef(0)
|
|
303
|
+
|
|
304
|
+
const syncOthers = useCallback((source: 'cards' | number, x: number) => {
|
|
305
|
+
if (source !== 'cards' && cardsScrollRef.current) {
|
|
306
|
+
cardsScrollRef.current.scrollTo({ x, animated: false })
|
|
307
|
+
}
|
|
308
|
+
bodyScrollRefs.current.forEach((ref, idx) => {
|
|
309
|
+
if (source === 'cards' || idx !== source) {
|
|
310
|
+
ref.scrollTo({ x, animated: false })
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
}, [])
|
|
314
|
+
|
|
315
|
+
const onScrollBeginDrag = useCallback(
|
|
316
|
+
(source: 'cards' | number) => () => {
|
|
317
|
+
draggingSource.current = source
|
|
318
|
+
},
|
|
319
|
+
[],
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
const onScrollEndDrag = useCallback(
|
|
323
|
+
(source: 'cards' | number) =>
|
|
324
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
325
|
+
const x = Math.round(e.nativeEvent.contentOffset.x)
|
|
326
|
+
if (x === lastSyncX.current) return
|
|
327
|
+
lastSyncX.current = x
|
|
328
|
+
syncOthers(source, x)
|
|
329
|
+
},
|
|
330
|
+
[syncOthers],
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
const onMomentumScrollEnd = useCallback(
|
|
334
|
+
(source: 'cards' | number) =>
|
|
335
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
336
|
+
if (draggingSource.current === source) {
|
|
337
|
+
draggingSource.current = null
|
|
338
|
+
}
|
|
339
|
+
const x = Math.round(e.nativeEvent.contentOffset.x)
|
|
340
|
+
if (x === lastSyncX.current) return
|
|
341
|
+
lastSyncX.current = x
|
|
342
|
+
syncOthers(source, x)
|
|
343
|
+
},
|
|
344
|
+
[syncOthers],
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
/** Real-time scroll sync: only the actively-dragged ScrollView drives
|
|
348
|
+
* sync on native — echoed onScroll events from programmatic scrollTo
|
|
349
|
+
* on other ScrollViews are ignored because draggingSource won't match.
|
|
350
|
+
* On web, onScrollBeginDrag never fires for mouse/trackpad, so skip
|
|
351
|
+
* the guard and rely on the lastSyncX rounded-value check instead. */
|
|
352
|
+
const onScroll = useCallback(
|
|
353
|
+
(source: 'cards' | number) =>
|
|
354
|
+
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
355
|
+
if (Platform.OS !== 'web' && draggingSource.current !== source) return
|
|
356
|
+
|
|
357
|
+
const x = Math.round(e.nativeEvent.contentOffset.x)
|
|
358
|
+
if (x === lastSyncX.current) return
|
|
359
|
+
lastSyncX.current = x
|
|
360
|
+
syncOthers(source, x)
|
|
361
|
+
},
|
|
362
|
+
[syncOthers],
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
const getBodyRefCallback = useCallback((idx: number) => {
|
|
366
|
+
let cb = bodyRefCallbacks.current.get(idx)
|
|
367
|
+
if (!cb) {
|
|
368
|
+
cb = (ref: ScrollView | null) => {
|
|
369
|
+
if (ref) {
|
|
370
|
+
bodyScrollRefs.current.set(idx, ref)
|
|
371
|
+
// Bring a freshly-expanded section's body into sync with
|
|
372
|
+
// the rest of the surfaces.
|
|
373
|
+
if (lastSyncX.current > 0) {
|
|
374
|
+
ref.scrollTo({ x: lastSyncX.current, animated: false })
|
|
375
|
+
}
|
|
376
|
+
} else {
|
|
377
|
+
bodyScrollRefs.current.delete(idx)
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
bodyRefCallbacks.current.set(idx, cb)
|
|
381
|
+
}
|
|
382
|
+
return cb
|
|
383
|
+
}, [])
|
|
241
384
|
|
|
242
385
|
const cardLabelStyle: TextStyle = {
|
|
243
386
|
...NO_WRAP_TEXT,
|
|
@@ -273,8 +416,7 @@ function CompareTable({
|
|
|
273
416
|
}
|
|
274
417
|
|
|
275
418
|
const cardStyle: ViewStyle = {
|
|
276
|
-
flex: 1,
|
|
277
|
-
minWidth: 0,
|
|
419
|
+
...(useFixedWidth ? { width: columnWidth as number } : { flex: 1, minWidth: 0 }),
|
|
278
420
|
minHeight: 147,
|
|
279
421
|
backgroundColor: cardBg,
|
|
280
422
|
borderRadius: cardRadius,
|
|
@@ -420,6 +562,7 @@ function CompareTable({
|
|
|
420
562
|
key={key}
|
|
421
563
|
modes={modes}
|
|
422
564
|
flex={1}
|
|
565
|
+
width={useFixedWidth ? columnWidth : undefined}
|
|
423
566
|
align="left"
|
|
424
567
|
isLastColumn={isLastCol}
|
|
425
568
|
isLastRow={isLastRow}
|
|
@@ -486,38 +629,189 @@ function CompareTable({
|
|
|
486
629
|
</Table>
|
|
487
630
|
)
|
|
488
631
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
{
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
|
|
632
|
+
const renderHeaderContent = (section: CompareTableSection) =>
|
|
633
|
+
section.header != null ? (
|
|
634
|
+
<View
|
|
635
|
+
style={{
|
|
636
|
+
backgroundColor: headerBg,
|
|
637
|
+
paddingHorizontal: headerPaddingH,
|
|
638
|
+
paddingVertical: headerPaddingV,
|
|
639
|
+
borderBottomWidth: cellBorderSize,
|
|
640
|
+
borderBottomColor: cellBorderColor,
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
643
|
+
<Text style={headerTextStyle}>{section.header}</Text>
|
|
644
|
+
</View>
|
|
645
|
+
) : null
|
|
646
|
+
|
|
647
|
+
const renderBodyContent = (section: CompareTableSection, index: number) => (
|
|
648
|
+
<ScrollView
|
|
649
|
+
ref={getBodyRefCallback(index)}
|
|
650
|
+
horizontal
|
|
651
|
+
showsHorizontalScrollIndicator={false}
|
|
652
|
+
onScroll={onScroll(index)}
|
|
653
|
+
onScrollBeginDrag={onScrollBeginDrag(index)}
|
|
654
|
+
onScrollEndDrag={onScrollEndDrag(index)}
|
|
655
|
+
onMomentumScrollEnd={onMomentumScrollEnd(index)}
|
|
501
656
|
>
|
|
502
|
-
{
|
|
503
|
-
|
|
657
|
+
<View style={{ width: contentWidth }}>
|
|
658
|
+
{section.rows.map((row, rowIndex) => {
|
|
659
|
+
const isLastRow = rowIndex === section.rows.length - 1
|
|
660
|
+
return (
|
|
661
|
+
<View
|
|
662
|
+
key={row.key ?? rowIndex}
|
|
663
|
+
style={{ flexDirection: 'row', width: '100%' }}
|
|
664
|
+
>
|
|
665
|
+
{Array.from({ length: columnCount }).map((_, colIndex) =>
|
|
666
|
+
renderTableCell(
|
|
667
|
+
renderCellContent(
|
|
668
|
+
row.values?.[colIndex],
|
|
669
|
+
`${rowIndex}-${colIndex}`,
|
|
670
|
+
),
|
|
671
|
+
colIndex,
|
|
672
|
+
isLastRow,
|
|
673
|
+
colIndex,
|
|
674
|
+
),
|
|
675
|
+
)}
|
|
676
|
+
{showAddCard &&
|
|
677
|
+
renderTableCell(
|
|
678
|
+
null,
|
|
679
|
+
gridColumnCount - 1,
|
|
680
|
+
isLastRow,
|
|
681
|
+
'__add_spacer__',
|
|
682
|
+
)}
|
|
683
|
+
</View>
|
|
684
|
+
)
|
|
685
|
+
})}
|
|
686
|
+
</View>
|
|
687
|
+
</ScrollView>
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
const outerStyle: ViewStyle = {
|
|
691
|
+
width: '100%',
|
|
692
|
+
backgroundColor: cardBg,
|
|
693
|
+
borderWidth: 1,
|
|
694
|
+
borderColor: '#e8e8e8',
|
|
695
|
+
overflow: 'hidden',
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// --- flex mode (columnWidth omitted): original behaviour, unchanged -------
|
|
699
|
+
if (!useFixedWidth) {
|
|
700
|
+
return (
|
|
701
|
+
<View style={[outerStyle, style]}>
|
|
702
|
+
{showCardsRow && (
|
|
703
|
+
<View style={{ flexDirection: 'row', width: '100%' }}>
|
|
704
|
+
{columns.map(renderCard)}
|
|
705
|
+
{showAddCard && renderAddCard()}
|
|
706
|
+
</View>
|
|
707
|
+
)}
|
|
708
|
+
|
|
709
|
+
<View style={{ width: '100%' }}>
|
|
710
|
+
{sections.map((section, index) => (
|
|
711
|
+
<Accordion
|
|
712
|
+
key={section.key ?? section.title ?? index}
|
|
713
|
+
title={section.title}
|
|
714
|
+
defaultExpanded={section.defaultExpanded ?? index === 0}
|
|
715
|
+
modes={modes}
|
|
716
|
+
>
|
|
717
|
+
{renderTable(section)}
|
|
718
|
+
</Accordion>
|
|
719
|
+
))}
|
|
720
|
+
</View>
|
|
721
|
+
</View>
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// --- fixed-width scroll mode: chained horizontal sync + sticky cards -----
|
|
726
|
+
// Build children and stickyHeaderIndices for the vertical ScrollView.
|
|
727
|
+
// When stickyHeaders is on, section headers are injected as direct children
|
|
728
|
+
// so React Native's stickyHeaderIndices can pin them with stacking.
|
|
729
|
+
const scrollChildren: React.ReactNode[] = []
|
|
730
|
+
const stickyIndices: number[] = []
|
|
731
|
+
|
|
732
|
+
if (showCardsRow) {
|
|
733
|
+
stickyIndices.push(scrollChildren.length)
|
|
734
|
+
scrollChildren.push(
|
|
735
|
+
<ScrollView
|
|
736
|
+
key="cards-row"
|
|
737
|
+
ref={cardsScrollRef}
|
|
738
|
+
horizontal
|
|
739
|
+
showsHorizontalScrollIndicator={false}
|
|
740
|
+
scrollEventThrottle={16}
|
|
741
|
+
onScroll={onScroll('cards')}
|
|
742
|
+
onScrollBeginDrag={onScrollBeginDrag('cards')}
|
|
743
|
+
onScrollEndDrag={onScrollEndDrag('cards')}
|
|
744
|
+
onMomentumScrollEnd={onMomentumScrollEnd('cards')}
|
|
745
|
+
>
|
|
746
|
+
<View style={{ flexDirection: 'row', width: contentWidth }}>
|
|
504
747
|
{columns.map(renderCard)}
|
|
505
748
|
{showAddCard && renderAddCard()}
|
|
506
749
|
</View>
|
|
507
|
-
|
|
750
|
+
</ScrollView>,
|
|
751
|
+
)
|
|
752
|
+
}
|
|
508
753
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
754
|
+
sections.forEach((section, index) => {
|
|
755
|
+
const sectionKey = section.key ?? section.title ?? index
|
|
756
|
+
const headerContent = renderHeaderContent(section)
|
|
757
|
+
|
|
758
|
+
if (stickyHeaders && headerContent != null) {
|
|
759
|
+
stickyIndices.push(scrollChildren.length)
|
|
760
|
+
scrollChildren.push(
|
|
761
|
+
<View
|
|
762
|
+
key={`sticky-header-${sectionKey}`}
|
|
763
|
+
style={{ width: '100%' }}
|
|
764
|
+
>
|
|
765
|
+
{stickyHeaderLabel ? (
|
|
766
|
+
headerContent
|
|
767
|
+
) : (
|
|
768
|
+
<ScrollView
|
|
769
|
+
horizontal
|
|
770
|
+
showsHorizontalScrollIndicator={false}
|
|
771
|
+
>
|
|
772
|
+
<View style={{ width: contentWidth }}>
|
|
773
|
+
{headerContent}
|
|
774
|
+
</View>
|
|
775
|
+
</ScrollView>
|
|
776
|
+
)}
|
|
777
|
+
</View>,
|
|
778
|
+
)
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
scrollChildren.push(
|
|
782
|
+
<Accordion
|
|
783
|
+
key={sectionKey}
|
|
784
|
+
title={section.title}
|
|
785
|
+
defaultExpanded={section.defaultExpanded ?? index === 0}
|
|
786
|
+
modes={modes}
|
|
787
|
+
>
|
|
788
|
+
{!stickyHeaders && headerContent != null && (
|
|
789
|
+
stickyHeaderLabel ? headerContent : (
|
|
790
|
+
<ScrollView
|
|
791
|
+
horizontal
|
|
792
|
+
showsHorizontalScrollIndicator={false}
|
|
793
|
+
>
|
|
794
|
+
<View style={{ width: contentWidth }}>
|
|
795
|
+
{headerContent}
|
|
796
|
+
</View>
|
|
797
|
+
</ScrollView>
|
|
798
|
+
)
|
|
799
|
+
)}
|
|
800
|
+
{renderBodyContent(section, index)}
|
|
801
|
+
</Accordion>,
|
|
802
|
+
)
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
return (
|
|
806
|
+
<View style={[outerStyle, style, { flexDirection: 'column', flex: 1 }]}>
|
|
807
|
+
<ScrollView
|
|
808
|
+
style={{ width: '100%', flex: 1 }}
|
|
809
|
+
stickyHeaderIndices={stickyIndices}
|
|
810
|
+
showsVerticalScrollIndicator={false}
|
|
811
|
+
directionalLockEnabled
|
|
812
|
+
>
|
|
813
|
+
{scrollChildren}
|
|
814
|
+
</ScrollView>
|
|
521
815
|
</View>
|
|
522
816
|
)
|
|
523
817
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import React, { useContext, useMemo } from 'react'
|
|
1
|
+
import React, { useContext, useEffect, useMemo } from 'react'
|
|
2
2
|
import {
|
|
3
3
|
Platform,
|
|
4
|
+
ScrollView,
|
|
4
5
|
Text,
|
|
5
6
|
View,
|
|
7
|
+
useWindowDimensions,
|
|
6
8
|
type StyleProp,
|
|
7
9
|
type TextStyle,
|
|
8
10
|
type ViewProps,
|
|
@@ -11,6 +13,9 @@ import {
|
|
|
11
13
|
import Animated, {
|
|
12
14
|
useAnimatedKeyboard,
|
|
13
15
|
useAnimatedStyle,
|
|
16
|
+
useSharedValue,
|
|
17
|
+
withSpring,
|
|
18
|
+
type SharedValue,
|
|
14
19
|
} from 'react-native-reanimated'
|
|
15
20
|
import { SafeAreaInsetsContext } from 'react-native-safe-area-context'
|
|
16
21
|
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
@@ -19,6 +24,18 @@ import type { Modes } from '../../design-tokens'
|
|
|
19
24
|
|
|
20
25
|
const IS_WEB = Platform.OS === 'web'
|
|
21
26
|
|
|
27
|
+
// Spring config — mirrors the Drawer's bottom pop-up spring so the two
|
|
28
|
+
// bottom-sheet surfaces share the exact same entrance feel (slight bounce,
|
|
29
|
+
// snappy). Keep in sync with `Drawer.tsx` SPRING_CONFIG.
|
|
30
|
+
const SPRING_CONFIG = {
|
|
31
|
+
damping: 32,
|
|
32
|
+
stiffness: 300,
|
|
33
|
+
mass: 1,
|
|
34
|
+
overshootClamping: false,
|
|
35
|
+
restDisplacementThreshold: 0.1,
|
|
36
|
+
restSpeedThreshold: 0.1,
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
export type ContentSheetProps = {
|
|
23
40
|
/**
|
|
24
41
|
* Sheet content. The sheet is one big slot: whatever you place here is
|
|
@@ -71,6 +88,25 @@ export type ContentSheetProps = {
|
|
|
71
88
|
* works in both modes.
|
|
72
89
|
*/
|
|
73
90
|
pinToBottom?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* Controls whether the sheet is on-screen. When this becomes `true` the sheet
|
|
93
|
+
* springs up from the bottom edge (off-screen → on-screen) using the same
|
|
94
|
+
* spring as the {@link Drawer} (`damping: 32`, `stiffness: 300`); when it
|
|
95
|
+
* becomes `false` it springs back down below the bottom edge. The sheet pops
|
|
96
|
+
* up on mount by default.
|
|
97
|
+
*
|
|
98
|
+
* The animation runs entirely on the UI thread via `react-native-reanimated`
|
|
99
|
+
* (no re-renders) and composes with keyboard avoidance — the entrance
|
|
100
|
+
* `translateY` and the keyboard `translateY` are summed in a single
|
|
101
|
+
* `useAnimatedStyle`. Default `true`.
|
|
102
|
+
*/
|
|
103
|
+
visible?: boolean
|
|
104
|
+
/**
|
|
105
|
+
* Max height of the sheet as a fraction (0–1) of the screen height.
|
|
106
|
+
* When content reaches this height the sheet becomes vertically scrollable.
|
|
107
|
+
* Default `0.7`.
|
|
108
|
+
*/
|
|
109
|
+
maxHeightPercent?: number
|
|
74
110
|
/** Optional style override applied to the sheet container. */
|
|
75
111
|
style?: StyleProp<ViewStyle>
|
|
76
112
|
} & Omit<ViewProps, 'style' | 'children'>
|
|
@@ -172,6 +208,8 @@ function ContentSheet({
|
|
|
172
208
|
keyboardSpacing = 0,
|
|
173
209
|
safeAreaBottom = true,
|
|
174
210
|
pinToBottom = true,
|
|
211
|
+
visible = true,
|
|
212
|
+
maxHeightPercent = 0.7,
|
|
175
213
|
style,
|
|
176
214
|
...rest
|
|
177
215
|
}: ContentSheetProps) {
|
|
@@ -190,20 +228,35 @@ function ContentSheet({
|
|
|
190
228
|
const paddingBottom =
|
|
191
229
|
resolved.paddingBottom + (safeAreaBottom ? bottomInset : 0)
|
|
192
230
|
|
|
231
|
+
// Entrance / exit "pop up from bottom" animation — mirrors the Drawer. The
|
|
232
|
+
// sheet is bottom-anchored, so a positive `translateY` equal to the window
|
|
233
|
+
// height parks it fully below the bottom edge. Springing it back to `0`
|
|
234
|
+
// produces the same bottom pop-up the Drawer uses. The shared value is
|
|
235
|
+
// initialised off-screen so the very first mount pops in (no flash).
|
|
236
|
+
const { height: windowHeight } = useWindowDimensions()
|
|
237
|
+
const maxHeight = windowHeight * maxHeightPercent
|
|
238
|
+
|
|
193
239
|
const containerStyle = useMemo<StyleProp<ViewStyle>>(
|
|
194
240
|
() => [
|
|
195
241
|
pinToBottom ? pinnedStyle : null,
|
|
196
242
|
resolved.container,
|
|
197
|
-
{ paddingBottom },
|
|
243
|
+
{ maxHeight, paddingBottom },
|
|
198
244
|
style,
|
|
199
245
|
],
|
|
200
|
-
[pinToBottom, resolved.container, paddingBottom, style]
|
|
246
|
+
[pinToBottom, resolved.container, maxHeight, paddingBottom, style]
|
|
201
247
|
)
|
|
202
248
|
|
|
249
|
+
const entrance = useSharedValue(windowHeight)
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
entrance.value = withSpring(visible ? 0 : windowHeight, SPRING_CONFIG)
|
|
252
|
+
}, [visible, windowHeight, entrance])
|
|
253
|
+
|
|
203
254
|
// Switching between the keyboard-aware and plain implementation is keyed off
|
|
204
255
|
// `avoidKeyboard` (and platform). Because they are distinct component types,
|
|
205
256
|
// React remounts on toggle, so the conditional `useAnimatedKeyboard` hook
|
|
206
|
-
// inside `KeyboardAwareSheet` always runs in a stable hook order.
|
|
257
|
+
// inside `KeyboardAwareSheet` always runs in a stable hook order. Both
|
|
258
|
+
// branches receive the `entrance` shared value so the bottom pop-up plays in
|
|
259
|
+
// every configuration.
|
|
207
260
|
const header = title ? (
|
|
208
261
|
<View style={resolved.headerStyle}>
|
|
209
262
|
<Text style={resolved.titleStyle}>{title}</Text>
|
|
@@ -215,6 +268,8 @@ function ContentSheet({
|
|
|
215
268
|
<KeyboardAwareSheet
|
|
216
269
|
style={containerStyle}
|
|
217
270
|
spacing={keyboardSpacing}
|
|
271
|
+
entrance={entrance}
|
|
272
|
+
maxHeight={maxHeight}
|
|
218
273
|
{...rest}
|
|
219
274
|
>
|
|
220
275
|
{header}
|
|
@@ -224,42 +279,96 @@ function ContentSheet({
|
|
|
224
279
|
}
|
|
225
280
|
|
|
226
281
|
return (
|
|
227
|
-
<
|
|
282
|
+
<AnimatedSheet style={containerStyle} entrance={entrance} maxHeight={maxHeight} {...rest}>
|
|
228
283
|
{header}
|
|
229
284
|
{processedChildren}
|
|
230
|
-
</
|
|
285
|
+
</AnimatedSheet>
|
|
231
286
|
)
|
|
232
287
|
}
|
|
233
288
|
|
|
234
289
|
type KeyboardAwareSheetProps = {
|
|
235
290
|
style: StyleProp<ViewStyle>
|
|
236
291
|
spacing: number
|
|
292
|
+
entrance: SharedValue<number>
|
|
293
|
+
maxHeight: number
|
|
294
|
+
children: React.ReactNode
|
|
295
|
+
} & Omit<ViewProps, 'style' | 'children'>
|
|
296
|
+
|
|
297
|
+
type AnimatedSheetProps = {
|
|
298
|
+
style: StyleProp<ViewStyle>
|
|
299
|
+
entrance: SharedValue<number>
|
|
300
|
+
maxHeight: number
|
|
237
301
|
children: React.ReactNode
|
|
238
302
|
} & Omit<ViewProps, 'style' | 'children'>
|
|
239
303
|
|
|
240
304
|
/**
|
|
241
305
|
* Native-only wrapper that lifts the sheet by the live keyboard height. The
|
|
242
306
|
* translation is computed on the UI thread from `useAnimatedKeyboard`, so the
|
|
243
|
-
* sheet tracks the keyboard frame-for-frame without any JS work.
|
|
307
|
+
* sheet tracks the keyboard frame-for-frame without any JS work. The keyboard
|
|
308
|
+
* offset is summed with the entrance `translateY` (the bottom pop-up) inside a
|
|
309
|
+
* single `useAnimatedStyle` so both motions stay on the UI thread and never
|
|
310
|
+
* fight each other.
|
|
244
311
|
*/
|
|
245
312
|
function KeyboardAwareSheet({
|
|
246
313
|
style,
|
|
247
314
|
spacing,
|
|
315
|
+
entrance,
|
|
316
|
+
maxHeight,
|
|
248
317
|
children,
|
|
249
318
|
...rest
|
|
250
319
|
}: KeyboardAwareSheetProps) {
|
|
251
320
|
const keyboard = useAnimatedKeyboard()
|
|
252
321
|
|
|
253
|
-
const
|
|
322
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
254
323
|
const height = keyboard.height.value
|
|
324
|
+
const keyboardOffset = height > 0 ? -(height + spacing) : 0
|
|
325
|
+
return {
|
|
326
|
+
transform: [{ translateY: entrance.value + keyboardOffset }],
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<Animated.View style={[style, animatedStyle]} {...rest}>
|
|
332
|
+
<ScrollView
|
|
333
|
+
style={{ maxHeight }}
|
|
334
|
+
bounces={false}
|
|
335
|
+
showsVerticalScrollIndicator={false}
|
|
336
|
+
keyboardShouldPersistTaps="handled"
|
|
337
|
+
>
|
|
338
|
+
{children}
|
|
339
|
+
</ScrollView>
|
|
340
|
+
</Animated.View>
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Entrance-only animated sheet (web, or when keyboard avoidance is disabled).
|
|
346
|
+
* Applies the same bottom pop-up `translateY` as `KeyboardAwareSheet`, minus
|
|
347
|
+
* the keyboard offset.
|
|
348
|
+
*/
|
|
349
|
+
function AnimatedSheet({
|
|
350
|
+
style,
|
|
351
|
+
entrance,
|
|
352
|
+
maxHeight,
|
|
353
|
+
children,
|
|
354
|
+
...rest
|
|
355
|
+
}: AnimatedSheetProps) {
|
|
356
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
255
357
|
return {
|
|
256
|
-
transform: [{ translateY:
|
|
358
|
+
transform: [{ translateY: entrance.value }],
|
|
257
359
|
}
|
|
258
360
|
})
|
|
259
361
|
|
|
260
362
|
return (
|
|
261
|
-
<Animated.View style={[style,
|
|
262
|
-
|
|
363
|
+
<Animated.View style={[style, animatedStyle]} {...rest}>
|
|
364
|
+
<ScrollView
|
|
365
|
+
style={{ maxHeight }}
|
|
366
|
+
bounces={false}
|
|
367
|
+
showsVerticalScrollIndicator={false}
|
|
368
|
+
keyboardShouldPersistTaps="handled"
|
|
369
|
+
>
|
|
370
|
+
{children}
|
|
371
|
+
</ScrollView>
|
|
263
372
|
</Animated.View>
|
|
264
373
|
)
|
|
265
374
|
}
|