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.
Files changed (53) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/lib/commonjs/components/CategoryCard/CategoryCard.js +264 -0
  3. package/lib/commonjs/components/CompareTable/CompareTable.js +247 -25
  4. package/lib/commonjs/components/ContentSheet/ContentSheet.js +91 -8
  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/MoneyValue/MoneyValue.js +179 -54
  9. package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +7 -1
  10. package/lib/commonjs/components/Table/Table.js +27 -7
  11. package/lib/commonjs/components/ValueBackMetric/ValueBackMetric.js +219 -0
  12. package/lib/commonjs/components/index.js +35 -0
  13. package/lib/commonjs/design-tokens/figma-modes.generated.js +3 -0
  14. package/lib/commonjs/icons/registry.js +1 -1
  15. package/lib/module/components/CategoryCard/CategoryCard.js +258 -0
  16. package/lib/module/components/CompareTable/CompareTable.js +247 -26
  17. package/lib/module/components/ContentSheet/ContentSheet.js +94 -11
  18. package/lib/module/components/CounterBadge/CounterBadge.js +73 -0
  19. package/lib/module/components/FavoriteToggle/FavoriteToggle.js +174 -0
  20. package/lib/module/components/HelloJioInput/HelloJioInput.js +281 -0
  21. package/lib/module/components/MoneyValue/MoneyValue.js +182 -57
  22. package/lib/module/components/PdpCcCard/PdpCcCard.js +7 -1
  23. package/lib/module/components/Table/Table.js +27 -7
  24. package/lib/module/components/ValueBackMetric/ValueBackMetric.js +214 -0
  25. package/lib/module/components/index.js +6 -1
  26. package/lib/module/design-tokens/figma-modes.generated.js +3 -0
  27. package/lib/module/icons/registry.js +1 -1
  28. package/lib/typescript/src/components/CategoryCard/CategoryCard.d.ts +72 -0
  29. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +36 -1
  30. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +20 -1
  31. package/lib/typescript/src/components/CounterBadge/CounterBadge.d.ts +19 -0
  32. package/lib/typescript/src/components/FavoriteToggle/FavoriteToggle.d.ts +66 -0
  33. package/lib/typescript/src/components/HelloJioInput/HelloJioInput.d.ts +111 -0
  34. package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +10 -1
  35. package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +25 -1
  36. package/lib/typescript/src/components/Table/Table.d.ts +9 -1
  37. package/lib/typescript/src/components/ValueBackMetric/ValueBackMetric.d.ts +86 -0
  38. package/lib/typescript/src/components/index.d.ts +5 -0
  39. package/lib/typescript/src/icons/registry.d.ts +1 -1
  40. package/package.json +1 -1
  41. package/src/components/CategoryCard/CategoryCard.tsx +404 -0
  42. package/src/components/CompareTable/CompareTable.tsx +324 -30
  43. package/src/components/ContentSheet/ContentSheet.tsx +120 -11
  44. package/src/components/CounterBadge/CounterBadge.tsx +95 -0
  45. package/src/components/FavoriteToggle/FavoriteToggle.tsx +243 -0
  46. package/src/components/HelloJioInput/HelloJioInput.tsx +513 -0
  47. package/src/components/MoneyValue/MoneyValue.tsx +216 -65
  48. package/src/components/PdpCcCard/PdpCcCard.tsx +36 -1
  49. package/src/components/Table/Table.tsx +29 -4
  50. package/src/components/ValueBackMetric/ValueBackMetric.tsx +298 -0
  51. package/src/components/index.ts +5 -0
  52. package/src/design-tokens/figma-modes.generated.ts +3 -0
  53. 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
- 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
- ]}
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
- {(columnCount > 0 || showAddCard) && (
503
- <View style={{ flexDirection: 'row', width: '100%' }}>
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
- <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
- ))}
520
- </View>
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
- <View style={containerStyle} {...rest}>
282
+ <AnimatedSheet style={containerStyle} entrance={entrance} maxHeight={maxHeight} {...rest}>
228
283
  {header}
229
284
  {processedChildren}
230
- </View>
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 keyboardStyle = useAnimatedStyle(() => {
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: height > 0 ? -(height + spacing) : 0 }],
358
+ transform: [{ translateY: entrance.value }],
257
359
  }
258
360
  })
259
361
 
260
362
  return (
261
- <Animated.View style={[style, keyboardStyle]} {...rest}>
262
- {children}
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
  }