jfs-components 0.1.28 → 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 (26) hide show
  1. package/CHANGELOG.md +15 -2
  2. package/lib/commonjs/components/CompareTable/CompareTable.js +191 -25
  3. package/lib/commonjs/components/ContentSheet/ContentSheet.js +66 -6
  4. package/lib/commonjs/components/MoneyValue/MoneyValue.js +179 -54
  5. package/lib/commonjs/components/PdpCcCard/PdpCcCard.js +7 -1
  6. package/lib/commonjs/components/Table/Table.js +27 -7
  7. package/lib/commonjs/icons/registry.js +1 -1
  8. package/lib/module/components/CompareTable/CompareTable.js +191 -26
  9. package/lib/module/components/ContentSheet/ContentSheet.js +69 -9
  10. package/lib/module/components/MoneyValue/MoneyValue.js +182 -57
  11. package/lib/module/components/PdpCcCard/PdpCcCard.js +7 -1
  12. package/lib/module/components/Table/Table.js +27 -7
  13. package/lib/module/icons/registry.js +1 -1
  14. package/lib/typescript/src/components/CompareTable/CompareTable.d.ts +21 -1
  15. package/lib/typescript/src/components/ContentSheet/ContentSheet.d.ts +14 -1
  16. package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +10 -1
  17. package/lib/typescript/src/components/PdpCcCard/PdpCcCard.d.ts +25 -1
  18. package/lib/typescript/src/components/Table/Table.d.ts +9 -1
  19. package/lib/typescript/src/icons/registry.d.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/components/CompareTable/CompareTable.tsx +224 -30
  22. package/src/components/ContentSheet/ContentSheet.tsx +88 -8
  23. package/src/components/MoneyValue/MoneyValue.tsx +216 -65
  24. package/src/components/PdpCcCard/PdpCcCard.tsx +36 -1
  25. package/src/components/Table/Table.tsx +29 -4
  26. package/src/icons/registry.ts +1 -1
@@ -76,6 +76,26 @@ export type CompareTableProps = {
76
76
  * lines as it needs even when horizontal space is tight.
77
77
  */
78
78
  disableTruncation?: boolean;
79
+ /**
80
+ * Fixed width (px) for every selection card and every table cell — all
81
+ * columns share this width. When set, the component enters **scroll mode**:
82
+ *
83
+ * - The cards row and each section's table body become independent
84
+ * horizontal `ScrollView`s whose scroll events are **chained** — moving
85
+ * one moves every other in lockstep (the cards always stay aligned with
86
+ * the cells beneath them).
87
+ * - The cards row becomes a **sticky header** at the top: it pins in place
88
+ * on the vertical axis (does not scroll away) while the sections scroll
89
+ * beneath it. Requires the host to give `CompareTable` a bounded height.
90
+ * - The gray table-header rows (each section's `header`) are rendered
91
+ * full-width **outside** the horizontal scroll, so they stay put
92
+ * horizontally (they don't slide with the cells) while still scrolling
93
+ * vertically with the table.
94
+ *
95
+ * Omit to keep the original flex layout (columns share the available width,
96
+ * no scrolling). @default undefined
97
+ */
98
+ columnWidth?: number;
79
99
  };
80
100
  /**
81
101
  * CompareTable renders a product comparison surface: a row of selection cards
@@ -89,6 +109,6 @@ export type CompareTableProps = {
89
109
  *
90
110
  * @component
91
111
  */
92
- declare function CompareTable({ columns, sections, onAddColumn, addColumnLabel, maxColumns, modes, style, disableTruncation, }: CompareTableProps): import("react/jsx-runtime").JSX.Element;
112
+ declare function CompareTable({ columns, sections, onAddColumn, addColumnLabel, maxColumns, modes, style, disableTruncation, columnWidth, }: CompareTableProps): import("react/jsx-runtime").JSX.Element;
93
113
  export default CompareTable;
94
114
  //# sourceMappingURL=CompareTable.d.ts.map
@@ -53,6 +53,19 @@ export type ContentSheetProps = {
53
53
  * works in both modes.
54
54
  */
55
55
  pinToBottom?: boolean;
56
+ /**
57
+ * Controls whether the sheet is on-screen. When this becomes `true` the sheet
58
+ * springs up from the bottom edge (off-screen → on-screen) using the same
59
+ * spring as the {@link Drawer} (`damping: 32`, `stiffness: 300`); when it
60
+ * becomes `false` it springs back down below the bottom edge. The sheet pops
61
+ * up on mount by default.
62
+ *
63
+ * The animation runs entirely on the UI thread via `react-native-reanimated`
64
+ * (no re-renders) and composes with keyboard avoidance — the entrance
65
+ * `translateY` and the keyboard `translateY` are summed in a single
66
+ * `useAnimatedStyle`. Default `true`.
67
+ */
68
+ visible?: boolean;
56
69
  /** Optional style override applied to the sheet container. */
57
70
  style?: StyleProp<ViewStyle>;
58
71
  } & Omit<ViewProps, 'style' | 'children'>;
@@ -72,6 +85,6 @@ export type ContentSheetProps = {
72
85
  * - **Token-driven** styling via `getVariableByName` + `modes`, with `modes`
73
86
  * cascaded to all slot children.
74
87
  */
75
- declare function ContentSheet({ children, modes, title, avoidKeyboard, keyboardSpacing, safeAreaBottom, pinToBottom, style, ...rest }: ContentSheetProps): import("react/jsx-runtime").JSX.Element;
88
+ declare function ContentSheet({ children, modes, title, avoidKeyboard, keyboardSpacing, safeAreaBottom, pinToBottom, visible, style, ...rest }: ContentSheetProps): import("react/jsx-runtime").JSX.Element;
76
89
  export default ContentSheet;
77
90
  //# sourceMappingURL=ContentSheet.d.ts.map
@@ -12,6 +12,15 @@ export type MoneyValueProps = {
12
12
  hidden?: boolean;
13
13
  /** When true, a blinking vertical cursor is shown at the end of the value text. */
14
14
  focused?: boolean;
15
+ /**
16
+ * When true, tapping the component enters an inline edit mode where the value
17
+ * becomes a numeric TextInput. The user can type digits or delete the existing
18
+ * amount. Only numeric characters (including one decimal point) are accepted.
19
+ * On blur or submit the edit is committed and the display restores.
20
+ */
21
+ editable?: boolean;
22
+ /** Called when the value changes during editing (after commit on blur). */
23
+ onValueChange?: (newValue: string | number) => void;
15
24
  /** Modes configuration mapped to Figma tokens. */
16
25
  modes?: Modes;
17
26
  style?: StyleProp<ViewStyle>;
@@ -32,6 +41,6 @@ export type MoneyValueProps = {
32
41
  * the `focused` prop is provided to this component, it will display a natural
33
42
  * blinking text cursor.
34
43
  */
35
- declare function MoneyValue({ value, currency, negative, focused, hidden, modes, style, valueStyle, currencyStyle, negativeSignStyle, accessibilityLabel, accessibilityHint, onPress, ...rest }: MoneyValueProps): import("react/jsx-runtime").JSX.Element;
44
+ declare function MoneyValue({ value, currency, negative, focused, hidden, editable, onValueChange, modes, style, valueStyle, currencyStyle, negativeSignStyle, accessibilityLabel, accessibilityHint, onPress, ...rest }: MoneyValueProps): import("react/jsx-runtime").JSX.Element;
36
45
  export default MoneyValue;
37
46
  //# sourceMappingURL=MoneyValue.d.ts.map
@@ -32,6 +32,30 @@ export interface PdpCcCardProps {
32
32
  title?: string;
33
33
  /** Subtitle rendered below the title (14px medium). */
34
34
  subtitle?: string;
35
+ /**
36
+ * Number of lines to limit the title to. The `Title` component defaults to
37
+ * `1` when this is unset, so the headline is a single line with an ellipsis
38
+ * by default. Pass a larger value (e.g. `2`) to allow the title to wrap onto
39
+ * that many lines before truncating, or set {@link disableTruncation} to
40
+ * remove the clamp entirely. The subtitle is never line-clamped.
41
+ */
42
+ numberOfLines?: number;
43
+ /**
44
+ * When `true`, disables truncation for both the title and subtitle: the
45
+ * trailing ellipsis (`…`) is never shown and the `numberOfLines` clamp is
46
+ * ignored. The text is also never shrunk by a tight parent — it keeps its
47
+ * full intrinsic size and overflows (even breaking the parent layout) rather
48
+ * than ever clipping into an ellipsis. The text still wraps onto multiple
49
+ * lines.
50
+ */
51
+ disableTruncation?: boolean;
52
+ /**
53
+ * When `true`, forces both the title and subtitle onto a single line with no
54
+ * wrapping (implies {@link disableTruncation}). On web the line overflows
55
+ * horizontally — brutally breaking the parent layout if it has to — instead
56
+ * of wrapping or truncating.
57
+ */
58
+ singleLine?: boolean;
35
59
  /**
36
60
  * The metric columns rendered in the stats row. Vertical dividers are
37
61
  * inserted automatically between adjacent metrics. Defaults to two sample
@@ -79,6 +103,6 @@ export interface PdpCcCardProps {
79
103
  *
80
104
  * All defaults can be overridden via `modes`.
81
105
  */
82
- declare function PdpCcCard({ imageSource, imageWidth, imageHeight, media, title, subtitle, metrics, buttonLabel, buttonIcon, onButtonPress, button, showButton, onPress, width, modes, style, accessibilityLabel, }: PdpCcCardProps): import("react/jsx-runtime").JSX.Element;
106
+ declare function PdpCcCard({ imageSource, imageWidth, imageHeight, media, title, subtitle, numberOfLines, disableTruncation, singleLine, metrics, buttonLabel, buttonIcon, onButtonPress, button, showButton, onPress, width, modes, style, accessibilityLabel, }: PdpCcCardProps): import("react/jsx-runtime").JSX.Element;
83
107
  export default PdpCcCard;
84
108
  //# sourceMappingURL=PdpCcCard.d.ts.map
@@ -102,6 +102,14 @@ export type TableProps<T = Record<string, unknown>> = {
102
102
  * for wide tables that exceed the viewport on mobile. @default false
103
103
  */
104
104
  scrollable?: boolean;
105
+ /**
106
+ * Fixed width (px) applied to every column — header and body alike — so
107
+ * all columns share the same width. An explicit per-column `width` (on
108
+ * `TableColumn`, or on an individual `Table.Cell`/`Table.HeaderCell`) still
109
+ * takes precedence when set. Omit to let columns flex to share the
110
+ * available space.
111
+ */
112
+ columnWidth?: number;
105
113
  /** Accessibility label for the table container. */
106
114
  accessibilityLabel?: string;
107
115
  /** Design token modes for theming (e.g. `{ "Color Mode": "Light" }`). */
@@ -126,7 +134,7 @@ export type TableProps<T = Record<string, unknown>> = {
126
134
  *
127
135
  * @component
128
136
  */
129
- declare function Table<T = Record<string, unknown>>({ columns, data, children, showHeader, getRowKey, onRowPress, scrollable, accessibilityLabel, modes, style, }: TableProps<T>): import("react/jsx-runtime").JSX.Element;
137
+ declare function Table<T = Record<string, unknown>>({ columns, data, children, showHeader, getRowKey, onRowPress, scrollable, columnWidth, accessibilityLabel, modes, style, }: TableProps<T>): import("react/jsx-runtime").JSX.Element;
130
138
  declare namespace Table {
131
139
  var Header: typeof TableHeader;
132
140
  var HeaderCell: typeof TableHeaderCell;
@@ -4,7 +4,7 @@
4
4
  * Auto-generated from SVG files in src/icons/
5
5
  * DO NOT EDIT MANUALLY - Run "npm run icons:generate" to regenerate
6
6
  *
7
- * Generated: 2026-07-07T17:35:34.036Z
7
+ * Generated: 2026-07-10T20:55:36.505Z
8
8
  */
9
9
  export declare const iconRegistry: Record<string, {
10
10
  path: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jfs-components",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "React Native Jio Finance Components Library",
5
5
  "author": "sunshuaiqi@gmail.com",
6
6
  "license": "MIT",
@@ -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
  }
@@ -1,8 +1,9 @@
1
- import React, { useContext, useMemo } from 'react'
1
+ import React, { useContext, useEffect, useMemo } from 'react'
2
2
  import {
3
3
  Platform,
4
4
  Text,
5
5
  View,
6
+ useWindowDimensions,
6
7
  type StyleProp,
7
8
  type TextStyle,
8
9
  type ViewProps,
@@ -11,6 +12,9 @@ import {
11
12
  import Animated, {
12
13
  useAnimatedKeyboard,
13
14
  useAnimatedStyle,
15
+ useSharedValue,
16
+ withSpring,
17
+ type SharedValue,
14
18
  } from 'react-native-reanimated'
15
19
  import { SafeAreaInsetsContext } from 'react-native-safe-area-context'
16
20
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
@@ -19,6 +23,18 @@ import type { Modes } from '../../design-tokens'
19
23
 
20
24
  const IS_WEB = Platform.OS === 'web'
21
25
 
26
+ // Spring config — mirrors the Drawer's bottom pop-up spring so the two
27
+ // bottom-sheet surfaces share the exact same entrance feel (slight bounce,
28
+ // snappy). Keep in sync with `Drawer.tsx` SPRING_CONFIG.
29
+ const SPRING_CONFIG = {
30
+ damping: 32,
31
+ stiffness: 300,
32
+ mass: 1,
33
+ overshootClamping: false,
34
+ restDisplacementThreshold: 0.1,
35
+ restSpeedThreshold: 0.1,
36
+ }
37
+
22
38
  export type ContentSheetProps = {
23
39
  /**
24
40
  * Sheet content. The sheet is one big slot: whatever you place here is
@@ -71,6 +87,19 @@ export type ContentSheetProps = {
71
87
  * works in both modes.
72
88
  */
73
89
  pinToBottom?: boolean
90
+ /**
91
+ * Controls whether the sheet is on-screen. When this becomes `true` the sheet
92
+ * springs up from the bottom edge (off-screen → on-screen) using the same
93
+ * spring as the {@link Drawer} (`damping: 32`, `stiffness: 300`); when it
94
+ * becomes `false` it springs back down below the bottom edge. The sheet pops
95
+ * up on mount by default.
96
+ *
97
+ * The animation runs entirely on the UI thread via `react-native-reanimated`
98
+ * (no re-renders) and composes with keyboard avoidance — the entrance
99
+ * `translateY` and the keyboard `translateY` are summed in a single
100
+ * `useAnimatedStyle`. Default `true`.
101
+ */
102
+ visible?: boolean
74
103
  /** Optional style override applied to the sheet container. */
75
104
  style?: StyleProp<ViewStyle>
76
105
  } & Omit<ViewProps, 'style' | 'children'>
@@ -172,6 +201,7 @@ function ContentSheet({
172
201
  keyboardSpacing = 0,
173
202
  safeAreaBottom = true,
174
203
  pinToBottom = true,
204
+ visible = true,
175
205
  style,
176
206
  ...rest
177
207
  }: ContentSheetProps) {
@@ -200,10 +230,23 @@ function ContentSheet({
200
230
  [pinToBottom, resolved.container, paddingBottom, style]
201
231
  )
202
232
 
233
+ // Entrance / exit "pop up from bottom" animation — mirrors the Drawer. The
234
+ // sheet is bottom-anchored, so a positive `translateY` equal to the window
235
+ // height parks it fully below the bottom edge. Springing it back to `0`
236
+ // produces the same bottom pop-up the Drawer uses. The shared value is
237
+ // initialised off-screen so the very first mount pops in (no flash).
238
+ const { height: windowHeight } = useWindowDimensions()
239
+ const entrance = useSharedValue(windowHeight)
240
+ useEffect(() => {
241
+ entrance.value = withSpring(visible ? 0 : windowHeight, SPRING_CONFIG)
242
+ }, [visible, windowHeight, entrance])
243
+
203
244
  // Switching between the keyboard-aware and plain implementation is keyed off
204
245
  // `avoidKeyboard` (and platform). Because they are distinct component types,
205
246
  // React remounts on toggle, so the conditional `useAnimatedKeyboard` hook
206
- // inside `KeyboardAwareSheet` always runs in a stable hook order.
247
+ // inside `KeyboardAwareSheet` always runs in a stable hook order. Both
248
+ // branches receive the `entrance` shared value so the bottom pop-up plays in
249
+ // every configuration.
207
250
  const header = title ? (
208
251
  <View style={resolved.headerStyle}>
209
252
  <Text style={resolved.titleStyle}>{title}</Text>
@@ -215,6 +258,7 @@ function ContentSheet({
215
258
  <KeyboardAwareSheet
216
259
  style={containerStyle}
217
260
  spacing={keyboardSpacing}
261
+ entrance={entrance}
218
262
  {...rest}
219
263
  >
220
264
  {header}
@@ -224,41 +268,77 @@ function ContentSheet({
224
268
  }
225
269
 
226
270
  return (
227
- <View style={containerStyle} {...rest}>
271
+ <AnimatedSheet style={containerStyle} entrance={entrance} {...rest}>
228
272
  {header}
229
273
  {processedChildren}
230
- </View>
274
+ </AnimatedSheet>
231
275
  )
232
276
  }
233
277
 
234
278
  type KeyboardAwareSheetProps = {
235
279
  style: StyleProp<ViewStyle>
236
280
  spacing: number
281
+ entrance: SharedValue<number>
282
+ children: React.ReactNode
283
+ } & Omit<ViewProps, 'style' | 'children'>
284
+
285
+ type AnimatedSheetProps = {
286
+ style: StyleProp<ViewStyle>
287
+ entrance: SharedValue<number>
237
288
  children: React.ReactNode
238
289
  } & Omit<ViewProps, 'style' | 'children'>
239
290
 
240
291
  /**
241
292
  * Native-only wrapper that lifts the sheet by the live keyboard height. The
242
293
  * translation is computed on the UI thread from `useAnimatedKeyboard`, so the
243
- * sheet tracks the keyboard frame-for-frame without any JS work.
294
+ * sheet tracks the keyboard frame-for-frame without any JS work. The keyboard
295
+ * offset is summed with the entrance `translateY` (the bottom pop-up) inside a
296
+ * single `useAnimatedStyle` so both motions stay on the UI thread and never
297
+ * fight each other.
244
298
  */
245
299
  function KeyboardAwareSheet({
246
300
  style,
247
301
  spacing,
302
+ entrance,
248
303
  children,
249
304
  ...rest
250
305
  }: KeyboardAwareSheetProps) {
251
306
  const keyboard = useAnimatedKeyboard()
252
307
 
253
- const keyboardStyle = useAnimatedStyle(() => {
308
+ const animatedStyle = useAnimatedStyle(() => {
254
309
  const height = keyboard.height.value
310
+ const keyboardOffset = height > 0 ? -(height + spacing) : 0
311
+ return {
312
+ transform: [{ translateY: entrance.value + keyboardOffset }],
313
+ }
314
+ })
315
+
316
+ return (
317
+ <Animated.View style={[style, animatedStyle]} {...rest}>
318
+ {children}
319
+ </Animated.View>
320
+ )
321
+ }
322
+
323
+ /**
324
+ * Entrance-only animated sheet (web, or when keyboard avoidance is disabled).
325
+ * Applies the same bottom pop-up `translateY` as `KeyboardAwareSheet`, minus
326
+ * the keyboard offset.
327
+ */
328
+ function AnimatedSheet({
329
+ style,
330
+ entrance,
331
+ children,
332
+ ...rest
333
+ }: AnimatedSheetProps) {
334
+ const animatedStyle = useAnimatedStyle(() => {
255
335
  return {
256
- transform: [{ translateY: height > 0 ? -(height + spacing) : 0 }],
336
+ transform: [{ translateY: entrance.value }],
257
337
  }
258
338
  })
259
339
 
260
340
  return (
261
- <Animated.View style={[style, keyboardStyle]} {...rest}>
341
+ <Animated.View style={[style, animatedStyle]} {...rest}>
262
342
  {children}
263
343
  </Animated.View>
264
344
  )