jfs-components 0.1.55 → 0.1.57

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.
@@ -58,8 +58,11 @@ export function cloneChildrenWithModes(children, modes, forcedModes) {
58
58
  } : modes;
59
59
  let processedChildren;
60
60
  if (hasChildren) {
61
+ // Descendants inherit this child's merged modes, not the ancestor's
62
+ // raw modes — so nested containers (e.g. ButtonGroup Neutral inside
63
+ // a CardCTA slot that injects Secondary) can cascade correctly.
61
64
  const childArray = React.Children.toArray(childChildren);
62
- const processed = cloneChildrenWithModes(childArray, modes, forcedModes);
65
+ const processed = cloneChildrenWithModes(childArray, mergedModes, forcedModes);
63
66
  processedChildren = processed.length === 1 ? processed[0] : processed;
64
67
  }
65
68
  result.push(/*#__PURE__*/React.cloneElement(child, {
@@ -9,7 +9,7 @@ export type ButtonGroupProps = {
9
9
  children?: React.ReactNode;
10
10
  /**
11
11
  * Mode configuration for design tokens (e.g., {"Button / Size": "M", "Emphasis": "High"})
12
- * These modes are passed down to all child components.
12
+ * These modes cascade to all children; a child's own `modes` still win on conflict.
13
13
  */
14
14
  modes?: Modes;
15
15
  /**
@@ -21,14 +21,14 @@ export type ButtonGroupProps = {
21
21
  * ButtonGroup component that aggregates multiple buttons (e.g., IconButton)
22
22
  * and handles their layout and styling/theming via design tokens.
23
23
  *
24
- * It passes the provided `modes` to all its immediate React Element children.
25
- * Non-IconButton children (e.g., Button) are automatically stretched to fill
26
- * available space. IconButton children keep their natural width.
24
+ * Group `modes` cascade to every child (and nested slot children). A child's
25
+ * own `modes` still win on conflict. Non-IconButton children (e.g. Button) are
26
+ * stretched to fill available space; IconButton children keep intrinsic width.
27
27
  *
28
28
  * @component
29
29
  * @example
30
30
  * ```jsx
31
- * <ButtonGroup modes={{"Color Mode": "Light"}}>
31
+ * <ButtonGroup modes={{"Color Mode": "Light", AppearanceBrand: "Neutral"}}>
32
32
  * <IconButton iconName="ic_qr_code" />
33
33
  * <Button label="Pay" />
34
34
  * </ButtonGroup>
@@ -31,6 +31,19 @@ export type CardCTAProps = {
31
31
  ratingLabel?: string;
32
32
  /** Show like/dislike actions in the rating footer */
33
33
  showRatingActions?: boolean;
34
+ /**
35
+ * [rating] Show the primary footer button (default `Save` / `buttonSlot`).
36
+ * When `false`, only rating actions render and they stay start-aligned —
37
+ * no empty button slot and no consumer `buttonSlot={<Fragment />}` hack.
38
+ * Default: `true`.
39
+ */
40
+ showButton?: boolean;
41
+ /**
42
+ * [rating] Horizontal alignment of the media / badge slot above the title.
43
+ * Charts and dataviz should use `'center'` (default). Use `'start'` for
44
+ * left-aligned badges/labels.
45
+ */
46
+ ratingMediaAlign?: 'start' | 'center';
34
47
  /** Callback for the default like action */
35
48
  onPressLike?: () => void;
36
49
  /** Callback for the default dislike action */
@@ -58,6 +71,6 @@ export type CardCTAProps = {
58
71
  /** Container style overrides */
59
72
  style?: StyleProp<ViewStyle>;
60
73
  };
61
- declare function CardCTA({ type, title, body, iconName, buttonLabel, onPressButton, ratingLabel, showRatingActions, onPressLike, onPressDislike, headline, description, subtitle, avatarSource, modes: propModes, iconSlot, buttonSlot, ratingBadgeSlot, ratingActionsSlot, avatarSlot, style, }: CardCTAProps): import("react/jsx-runtime").JSX.Element;
74
+ declare function CardCTA({ type, title, body, iconName, buttonLabel, onPressButton, ratingLabel, showRatingActions, showButton, ratingMediaAlign, onPressLike, onPressDislike, headline, description, subtitle, avatarSource, modes: propModes, iconSlot, buttonSlot, ratingBadgeSlot, ratingActionsSlot, avatarSlot, style, }: CardCTAProps): import("react/jsx-runtime").JSX.Element;
62
75
  export default CardCTA;
63
76
  //# sourceMappingURL=CardCTA.d.ts.map
@@ -18,16 +18,44 @@ export interface CarouselProps {
18
18
  gap?: number;
19
19
  /** Width of each carousel item. When undefined, items take full container width. */
20
20
  itemWidth?: number;
21
+ /**
22
+ * Inset from each horizontal edge of the carousel to the active item.
23
+ * Sets scroll `paddingHorizontal` to this value and, when `itemWidth` is
24
+ * omitted, derives `itemWidth` as `containerWidth - itemInset * 2`.
25
+ * Prefer this over manually pairing `itemWidth` + `paddingHorizontal`.
26
+ */
27
+ itemInset?: number;
28
+ /**
29
+ * Negative horizontal margin (px) so the carousel can bleed past a padded
30
+ * parent (e.g. Section / VStack). Combine with `itemInset` for
31
+ * screen-edge-aligned cards without consumer math.
32
+ */
33
+ bleedHorizontal?: number;
21
34
  /**
22
35
  * Explicit horizontal padding (px) for the scroll content. When provided it
23
36
  * overrides the token-derived padding verbatim — the built-in `carousel/
24
37
  * padding/horizontal × 2` "peek" multiplier is NOT applied. Use this for
25
38
  * presets (e.g. the account-card strip) whose own tokens define a flush,
26
- * non-peeking layout.
39
+ * non-peeking layout. Ignored when `itemInset` is set.
27
40
  */
28
41
  paddingHorizontal?: number;
29
42
  /** Explicit vertical padding (px) for the outer container. Overrides `carousel/padding/vertical`. */
30
43
  paddingVertical?: number;
44
+ /**
45
+ * When true (default), every slide stretches to the tallest slide's height
46
+ * so mixed-content cards share one row height. Set `false` to keep each
47
+ * slide at its intrinsic height.
48
+ */
49
+ equalHeight?: boolean;
50
+ /**
51
+ * Hard max height for the carousel shell.
52
+ * - `undefined` (default): apply the `carousel/maxHeight` token only when
53
+ * `equalHeight` is `false` (legacy clipped strip). With `equalHeight`
54
+ * (default) the shell hugs content and is not clipped.
55
+ * - `number`: always apply that max height.
56
+ * - `false`: never apply a max height.
57
+ */
58
+ maxHeight?: number | false;
31
59
  /**
32
60
  * Carousel visual type.
33
61
  * - `'Default'` — standard carousel with dot pagination below.
@@ -40,7 +68,7 @@ export interface CarouselProps {
40
68
  /** Style overrides for the outermost container. */
41
69
  style?: StyleProp<ViewStyle>;
42
70
  }
43
- export declare function Carousel({ children, modes, autoPlay, autoPlayInterval, showPagination, loop, gap: gapProp, itemWidth: itemWidthProp, paddingHorizontal: paddingHorizontalProp, paddingVertical: paddingVerticalProp, type, onIndexChange, style, }: CarouselProps): import("react/jsx-runtime").JSX.Element;
71
+ export declare function Carousel({ children, modes, autoPlay, autoPlayInterval, showPagination, loop, gap: gapProp, itemWidth: itemWidthProp, itemInset, bleedHorizontal, paddingHorizontal: paddingHorizontalProp, paddingVertical: paddingVerticalProp, equalHeight, maxHeight: maxHeightProp, type, onIndexChange, style, }: CarouselProps): import("react/jsx-runtime").JSX.Element;
44
72
  export declare namespace Carousel {
45
73
  var Item: typeof import("./Carousel").Item;
46
74
  var Pagination: typeof import("./Carousel").Pagination;
@@ -179,6 +179,7 @@ export declare const FIGMA_MODES: {
179
179
  readonly "NavArrow Direction": readonly ["Left&Right", "Top&Bottom"];
180
180
  readonly "NoteInput / Output": readonly ["Default"];
181
181
  readonly "Nudge / Output": readonly ["Default"];
182
+ readonly "Nudge Border Radius ": readonly ["Default", "Pill"];
182
183
  readonly "Nudge padding": readonly ["Default", "None"];
183
184
  readonly "number pagination": readonly ["Default"];
184
185
  readonly "Numpad / Output": readonly ["Default"];
@@ -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-27T16:45:55.755Z
7
+ * Generated: 2026-07-27T20:45:13.986Z
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.55",
3
+ "version": "0.1.57",
4
4
  "description": "React Native Jio Finance Components Library",
5
5
  "author": "sunshuaiqi@gmail.com",
6
6
  "license": "MIT",
@@ -5,7 +5,11 @@ import {
5
5
  type ViewStyle,
6
6
  } from 'react-native'
7
7
  import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
8
- import { EMPTY_MODES, flattenChildren } from '../../utils/react-utils'
8
+ import {
9
+ EMPTY_MODES,
10
+ cloneChildrenWithModes,
11
+ flattenChildren,
12
+ } from '../../utils/react-utils'
9
13
  import IconButton from '../IconButton/IconButton'
10
14
  import type { Modes } from '../../design-tokens'
11
15
 
@@ -17,7 +21,7 @@ export type ButtonGroupProps = {
17
21
  children?: React.ReactNode;
18
22
  /**
19
23
  * Mode configuration for design tokens (e.g., {"Button / Size": "M", "Emphasis": "High"})
20
- * These modes are passed down to all child components.
24
+ * These modes cascade to all children; a child's own `modes` still win on conflict.
21
25
  */
22
26
  modes?: Modes;
23
27
  /**
@@ -30,14 +34,14 @@ export type ButtonGroupProps = {
30
34
  * ButtonGroup component that aggregates multiple buttons (e.g., IconButton)
31
35
  * and handles their layout and styling/theming via design tokens.
32
36
  *
33
- * It passes the provided `modes` to all its immediate React Element children.
34
- * Non-IconButton children (e.g., Button) are automatically stretched to fill
35
- * available space. IconButton children keep their natural width.
37
+ * Group `modes` cascade to every child (and nested slot children). A child's
38
+ * own `modes` still win on conflict. Non-IconButton children (e.g. Button) are
39
+ * stretched to fill available space; IconButton children keep intrinsic width.
36
40
  *
37
41
  * @component
38
42
  * @example
39
43
  * ```jsx
40
- * <ButtonGroup modes={{"Color Mode": "Light"}}>
44
+ * <ButtonGroup modes={{"Color Mode": "Light", AppearanceBrand: "Neutral"}}>
41
45
  * <IconButton iconName="ic_qr_code" />
42
46
  * <Button label="Pay" />
43
47
  * </ButtonGroup>
@@ -48,12 +52,10 @@ function ButtonGroup({
48
52
  modes = EMPTY_MODES,
49
53
  style,
50
54
  }: ButtonGroupProps) {
51
- // Resolve design tokens
52
55
  const gap = getVariableByName('buttonGroup/gap', modes) ?? 12
53
56
  const paddingHorizontal = getVariableByName('buttonGroup/padding/horizontal', modes) ?? 0
54
57
  const paddingVertical = getVariableByName('buttonGroup/padding/vertical', modes) ?? 0
55
58
 
56
- // Container style
57
59
  const containerStyle: ViewStyle = {
58
60
  flexDirection: 'row',
59
61
  alignItems: 'center',
@@ -62,25 +64,21 @@ function ButtonGroup({
62
64
  paddingVertical: paddingVertical,
63
65
  }
64
66
 
65
- // Flatten children to handle Fragments properly
66
67
  const flatChildren = flattenChildren(children)
68
+ const cascadedChildren = cloneChildrenWithModes(flatChildren, modes)
67
69
 
68
- const childrenWithModes = React.Children.map(flatChildren, (child) => {
69
- if (React.isValidElement(child)) {
70
- const element = child as React.ReactElement<any>
71
- const childModes = element.props.modes || {}
72
- const mergedModes = { ...modes, ...childModes }
73
- const isIconButton = element.type === IconButton
74
- const stretchStyle = isIconButton
75
- ? undefined
76
- : { flex: 1 }
77
-
78
- return React.cloneElement(element, {
79
- modes: mergedModes,
80
- style: [stretchStyle, element.props.style],
81
- })
70
+ const childrenWithModes = React.Children.map(cascadedChildren, (child) => {
71
+ if (!React.isValidElement(child)) {
72
+ return child
82
73
  }
83
- return child
74
+
75
+ const element = child as React.ReactElement<any>
76
+ const isIconButton = element.type === IconButton
77
+ const stretchStyle = isIconButton ? undefined : { flex: 1 }
78
+
79
+ return React.cloneElement(element, {
80
+ style: [stretchStyle, element.props.style],
81
+ })
84
82
  })
85
83
 
86
84
  return (
@@ -63,6 +63,19 @@ export type CardCTAProps = {
63
63
  ratingLabel?: string;
64
64
  /** Show like/dislike actions in the rating footer */
65
65
  showRatingActions?: boolean;
66
+ /**
67
+ * [rating] Show the primary footer button (default `Save` / `buttonSlot`).
68
+ * When `false`, only rating actions render and they stay start-aligned —
69
+ * no empty button slot and no consumer `buttonSlot={<Fragment />}` hack.
70
+ * Default: `true`.
71
+ */
72
+ showButton?: boolean;
73
+ /**
74
+ * [rating] Horizontal alignment of the media / badge slot above the title.
75
+ * Charts and dataviz should use `'center'` (default). Use `'start'` for
76
+ * left-aligned badges/labels.
77
+ */
78
+ ratingMediaAlign?: 'start' | 'center';
66
79
  /** Callback for the default like action */
67
80
  onPressLike?: () => void;
68
81
  /** Callback for the default dislike action */
@@ -100,6 +113,8 @@ function CardCTA({
100
113
  onPressButton,
101
114
  ratingLabel = '+28 Rating',
102
115
  showRatingActions = true,
116
+ showButton = true,
117
+ ratingMediaAlign = 'center',
103
118
  onPressLike,
104
119
  onPressDislike,
105
120
  headline = 'Headline',
@@ -187,7 +202,15 @@ function CardCTA({
187
202
  const avatarModes = { ...modes, 'Avatar Size': 'L' }
188
203
 
189
204
  const buttonModes = {...modes, AppearanceBrand: 'Secondary', 'Button / Size': 'S'}
190
- const iconButtonModes = {'Button / Size': 'S', 'Emphasis': 'Low', 'AppearanceBrand':'Neutral',...modes }
205
+ // Rating footer IconButtons are Neutral / S / Low per Figma. Apply those
206
+ // AFTER cascaded modes so card-level AppearanceBrand (often Secondary)
207
+ // cannot override the footer chrome.
208
+ const iconButtonModes: Modes = {
209
+ ...modes,
210
+ 'Button / Size': 'S',
211
+ Emphasis: 'Low',
212
+ AppearanceBrand: 'Neutral',
213
+ }
191
214
  const effectiveButtonLabel = buttonLabel ?? (isRating ? 'Save' : 'Button')
192
215
  const nonWrappingButtonLabel = effectiveButtonLabel.replace(/\s/g, '\u00A0')
193
216
  const [measuredButtonLabelWidth, setMeasuredButtonLabelWidth] = React.useState<number | null>(null)
@@ -261,6 +284,14 @@ function CardCTA({
261
284
  borderColor,
262
285
  flexDirection: isRating || isSip ? 'column' : 'row',
263
286
  overflow: 'visible',
287
+ // Pin footer to the bottom when a parent (e.g. equal-height Carousel)
288
+ // assigns an explicit height. Do not flexGrow — that unbounded-grows
289
+ // inside horizontal ScrollViews and blows cards to infinite height.
290
+ ...(isRating
291
+ ? {
292
+ justifyContent: 'space-between' as const,
293
+ }
294
+ : {}),
264
295
  }
265
296
 
266
297
  // NOTE: `minWidth: 0` + explicit `flexShrink: 1` are required on native.
@@ -417,16 +448,24 @@ function CardCTA({
417
448
  paddingVertical: ratingContentPaddingV,
418
449
  gap: ratingContentGap,
419
450
  alignItems: 'flex-start',
451
+ alignSelf: 'stretch',
452
+ }
453
+
454
+ const ratingMediaStyle: ViewStyle = {
455
+ alignSelf: 'stretch',
456
+ width: '100%',
457
+ alignItems: ratingMediaAlign === 'center' ? 'center' : 'flex-start',
420
458
  }
421
459
 
422
460
  const ratingFooterStyle: ViewStyle = {
423
461
  flexDirection: 'row',
424
462
  alignItems: 'flex-start',
425
- justifyContent: 'space-between',
463
+ justifyContent: showButton ? 'space-between' : 'flex-start',
426
464
  paddingHorizontal: ratingFooterPaddingH,
427
465
  paddingTop: ratingFooterPaddingTop,
428
466
  paddingBottom: ratingFooterPaddingBottom,
429
467
  overflow: 'visible',
468
+ alignSelf: 'stretch',
430
469
  }
431
470
 
432
471
  const buttonLabelStyle: TextStyle = {
@@ -503,30 +542,34 @@ function CardCTA({
503
542
  return (
504
543
  <View style={[containerStyle, style]}>
505
544
  <View style={ratingContentStyle}>
506
- {ratingBadgeSlot ? (
507
- cloneChildrenWithModes(ratingBadgeSlot, modes)
508
- ) : (
509
- <Badge label={ratingLabel} modes={modes} />
510
- )}
545
+ <View style={ratingMediaStyle}>
546
+ {ratingBadgeSlot ? (
547
+ cloneChildrenWithModes(ratingBadgeSlot, modes)
548
+ ) : (
549
+ <Badge label={ratingLabel} modes={modes} />
550
+ )}
551
+ </View>
511
552
  <View style={textWrapStyle}>
512
553
  <Text style={titleStyle}>{title}</Text>
513
554
  <Text style={bodyStyle}>{body}</Text>
514
555
  </View>
515
556
  </View>
516
557
  <View style={ratingFooterStyle}>
517
- <View style={ratingButtonWrapStyle}>
518
- {buttonSlot ? (
519
- cloneChildrenWithModes(buttonSlot, buttonModes)
520
- ) : (
521
- <Button
522
- label={effectiveButtonLabel}
523
- onPress={onPressButton || (() => {})}
524
- modes={buttonModes}
525
- style={ratingButtonStyle}
526
- renderContent={renderButtonContent(ratingButtonLabelStyle)}
527
- />
528
- )}
529
- </View>
558
+ {showButton ? (
559
+ <View style={ratingButtonWrapStyle}>
560
+ {buttonSlot ? (
561
+ cloneChildrenWithModes(buttonSlot, buttonModes)
562
+ ) : (
563
+ <Button
564
+ label={effectiveButtonLabel}
565
+ onPress={onPressButton || (() => {})}
566
+ modes={buttonModes}
567
+ style={ratingButtonStyle}
568
+ renderContent={renderButtonContent(ratingButtonLabelStyle)}
569
+ />
570
+ )}
571
+ </View>
572
+ ) : null}
530
573
  {showRatingActions ? (
531
574
  ratingActionsSlot ? (
532
575
  cloneChildrenWithModes(ratingActionsSlot, iconButtonModes)
@@ -535,11 +578,13 @@ function CardCTA({
535
578
  <IconButton
536
579
  iconName="ic_like"
537
580
  accessibilityLabel="Like"
581
+ modes={iconButtonModes}
538
582
  {...(onPressLike ? { onPress: onPressLike } : {})}
539
583
  />
540
584
  <IconButton
541
585
  iconName="ic_dislike"
542
586
  accessibilityLabel="Dislike"
587
+ modes={iconButtonModes}
543
588
  {...(onPressDislike ? { onPress: onPressDislike } : {})}
544
589
  />
545
590
  </ButtonGroup>
@@ -12,6 +12,7 @@ import {
12
12
  ScrollView,
13
13
  Animated,
14
14
  Pressable,
15
+ StyleSheet,
15
16
  type ViewStyle,
16
17
  type StyleProp,
17
18
  type NativeSyntheticEvent,
@@ -67,16 +68,44 @@ export interface CarouselProps {
67
68
  gap?: number
68
69
  /** Width of each carousel item. When undefined, items take full container width. */
69
70
  itemWidth?: number
71
+ /**
72
+ * Inset from each horizontal edge of the carousel to the active item.
73
+ * Sets scroll `paddingHorizontal` to this value and, when `itemWidth` is
74
+ * omitted, derives `itemWidth` as `containerWidth - itemInset * 2`.
75
+ * Prefer this over manually pairing `itemWidth` + `paddingHorizontal`.
76
+ */
77
+ itemInset?: number
78
+ /**
79
+ * Negative horizontal margin (px) so the carousel can bleed past a padded
80
+ * parent (e.g. Section / VStack). Combine with `itemInset` for
81
+ * screen-edge-aligned cards without consumer math.
82
+ */
83
+ bleedHorizontal?: number
70
84
  /**
71
85
  * Explicit horizontal padding (px) for the scroll content. When provided it
72
86
  * overrides the token-derived padding verbatim — the built-in `carousel/
73
87
  * padding/horizontal × 2` "peek" multiplier is NOT applied. Use this for
74
88
  * presets (e.g. the account-card strip) whose own tokens define a flush,
75
- * non-peeking layout.
89
+ * non-peeking layout. Ignored when `itemInset` is set.
76
90
  */
77
91
  paddingHorizontal?: number
78
92
  /** Explicit vertical padding (px) for the outer container. Overrides `carousel/padding/vertical`. */
79
93
  paddingVertical?: number
94
+ /**
95
+ * When true (default), every slide stretches to the tallest slide's height
96
+ * so mixed-content cards share one row height. Set `false` to keep each
97
+ * slide at its intrinsic height.
98
+ */
99
+ equalHeight?: boolean
100
+ /**
101
+ * Hard max height for the carousel shell.
102
+ * - `undefined` (default): apply the `carousel/maxHeight` token only when
103
+ * `equalHeight` is `false` (legacy clipped strip). With `equalHeight`
104
+ * (default) the shell hugs content and is not clipped.
105
+ * - `number`: always apply that max height.
106
+ * - `false`: never apply a max height.
107
+ */
108
+ maxHeight?: number | false
80
109
  /**
81
110
  * Carousel visual type.
82
111
  * - `'Default'` — standard carousel with dot pagination below.
@@ -104,8 +133,12 @@ export function Carousel({
104
133
  loop = true,
105
134
  gap: gapProp,
106
135
  itemWidth: itemWidthProp,
136
+ itemInset,
137
+ bleedHorizontal,
107
138
  paddingHorizontal: paddingHorizontalProp,
108
139
  paddingVertical: paddingVerticalProp,
140
+ equalHeight = true,
141
+ maxHeight: maxHeightProp,
109
142
  type = 'Default',
110
143
 
111
144
  onIndexChange,
@@ -127,7 +160,7 @@ export function Carousel({
127
160
  getVariableByName('carousel/padding/vertical', modes),
128
161
  )
129
162
  // Outer container max height per Figma (`carousel/maxHeight`).
130
- const maxHeight = parseFloat(
163
+ const tokenMaxHeight = parseFloat(
131
164
  getVariableByName('carousel/maxHeight', modes),
132
165
  )
133
166
  // Gap between AutoplayControl and NumberPagination in the Numbered overlay.
@@ -165,15 +198,56 @@ export function Carousel({
165
198
 
166
199
  const totalItems = items.length
167
200
 
168
- // Effective item width: provided prop, or full container width minus peek offsets.
201
+ // Effective item width: explicit prop, itemInset derivation, or token peek.
169
202
  // For the Numbered variant the items are full-bleed (no peek padding).
170
203
  const effectiveItemWidth = useMemo(() => {
171
204
  if (itemWidthProp != null) return itemWidthProp
172
205
  if (containerWidth === 0) return 0
173
206
  if (isNumbered) return containerWidth
207
+ if (itemInset != null) {
208
+ return Math.max(0, containerWidth - itemInset * 2)
209
+ }
174
210
  // Full-width minus peekOffset on each side
175
211
  return containerWidth - containerPaddingH * 4
176
- }, [itemWidthProp, containerWidth, containerPaddingH, isNumbered])
212
+ }, [itemWidthProp, itemInset, containerWidth, containerPaddingH, isNumbered])
213
+
214
+ // Equal-height: measure each slide's intrinsic height, then lock every slot
215
+ // to the tallest. Do NOT use contentContainerStyle.alignItems:'stretch' or
216
+ // height:'100%' without a bounded cross-axis — in a horizontal ScrollView
217
+ // that resolves to an unbounded/infinite height.
218
+ const slideHeightsRef = useRef<Record<number, number>>({})
219
+ const [equalizedHeight, setEqualizedHeight] = useState<number | undefined>()
220
+ const equalizedHeightRef = useRef<number | undefined>(undefined)
221
+ equalizedHeightRef.current = equalizedHeight
222
+
223
+ useEffect(() => {
224
+ slideHeightsRef.current = {}
225
+ equalizedHeightRef.current = undefined
226
+ setEqualizedHeight(undefined)
227
+ }, [totalItems, effectiveItemWidth, equalHeight])
228
+
229
+ const handleSlideLayout = useCallback(
230
+ (index: number) => (event: LayoutChangeEvent) => {
231
+ if (!equalHeight) return
232
+ // Only measure while unconstrained. After lock, the slot height is forced
233
+ // and onLayout would just echo equalizedHeight.
234
+ if (equalizedHeightRef.current != null) return
235
+
236
+ const height = Math.ceil(event.nativeEvent.layout.height)
237
+ if (height <= 0) return
238
+
239
+ const previous = slideHeightsRef.current[index] ?? 0
240
+ if (height <= previous) return
241
+
242
+ slideHeightsRef.current[index] = height
243
+ const measured = items.map((_, i) => slideHeightsRef.current[i])
244
+ if (measured.some(value => value == null)) return
245
+
246
+ const next = Math.max(...(measured as number[]))
247
+ setEqualizedHeight(next)
248
+ },
249
+ [equalHeight, items],
250
+ )
177
251
 
178
252
  // Snap interval = item width + gap
179
253
  const snapInterval = effectiveItemWidth + gap
@@ -345,15 +419,31 @@ export function Carousel({
345
419
  }, [isPlaying, startAutoPlay, clearAutoPlay])
346
420
 
347
421
  // ---- Render ----
348
- // Explicit padding props win verbatim; otherwise fall back to the
349
- // token-derived values (horizontal keeps its legacy ×2 "peek" multiplier).
350
- // Numbered variant is full-bleed: zero padding.
351
- const effectivePaddingH = isNumbered ? 0 : (paddingHorizontalProp ?? containerPaddingH)
422
+ // Padding priority: Numbered 0; itemInset inset; explicit padding →
423
+ // verbatim; else token (legacy ×2 peek is applied via item width, not here).
424
+ const effectivePaddingH = isNumbered
425
+ ? 0
426
+ : (itemInset ?? paddingHorizontalProp ?? containerPaddingH)
352
427
  const effectivePaddingV = isNumbered ? 0 : (paddingVerticalProp ?? containerPaddingV)
353
428
 
429
+ // Equal-height rows hug content — don't clip with the token maxHeight unless
430
+ // the caller opts into a numeric max (or disables equalHeight for legacy strips).
431
+ const resolvedMaxHeight = isNumbered
432
+ ? undefined
433
+ : maxHeightProp === false
434
+ ? undefined
435
+ : typeof maxHeightProp === 'number'
436
+ ? maxHeightProp
437
+ : equalHeight
438
+ ? undefined
439
+ : tokenMaxHeight
440
+
354
441
  const outerStyle: ViewStyle = {
355
442
  paddingVertical: effectivePaddingV,
356
- maxHeight: isNumbered ? undefined : maxHeight,
443
+ maxHeight: resolvedMaxHeight,
444
+ ...(bleedHorizontal != null
445
+ ? { marginHorizontal: -Math.abs(bleedHorizontal) }
446
+ : {}),
357
447
  // Numbered needs relative positioning for the floating controls overlay.
358
448
  ...(isNumbered ? { overflow: 'hidden' as const } : {}),
359
449
  }
@@ -361,7 +451,8 @@ export function Carousel({
361
451
  const contentContainerStyle: ViewStyle = {
362
452
  paddingHorizontal: effectivePaddingH,
363
453
  gap,
364
- // Align items to the start so snap works correctly
454
+ // Always flex-start on the cross axis. Stretch + unbounded ScrollView
455
+ // height is what caused infinite-tall slides.
365
456
  alignItems: 'flex-start',
366
457
  }
367
458
 
@@ -384,6 +475,9 @@ export function Carousel({
384
475
  onMomentumScrollEnd={finishProgrammaticScroll}
385
476
  >
386
477
  {items.map((child, index) => {
478
+ const hasEqualizedHeight =
479
+ equalHeight && equalizedHeight != null && equalizedHeight > 0
480
+
387
481
  // Strict slot box: width must be honored; never grow or shrink with
388
482
  // content, and clip anything that misbehaves (e.g. a child whose
389
483
  // inner flex layout would otherwise leak into the next slot on
@@ -394,6 +488,7 @@ export function Carousel({
394
488
  flexShrink: 0,
395
489
  flexBasis: effectiveItemWidth > 0 ? effectiveItemWidth : 'auto',
396
490
  overflow: 'hidden',
491
+ ...(hasEqualizedHeight ? { height: equalizedHeight } : {}),
397
492
  }
398
493
 
399
494
  // The cloned style forces the child's outer node to also honor the
@@ -404,6 +499,9 @@ export function Carousel({
404
499
  maxWidth: effectiveItemWidth > 0 ? effectiveItemWidth : undefined,
405
500
  flexGrow: 0,
406
501
  flexShrink: 0,
502
+ ...(hasEqualizedHeight
503
+ ? { height: '100%' as const, alignSelf: 'stretch' }
504
+ : {}),
407
505
  }
408
506
 
409
507
  // Pass modes down to children
@@ -419,7 +517,17 @@ export function Carousel({
419
517
 
420
518
  return (
421
519
  <View key={index} style={slotStyle}>
422
- {childWithModes}
520
+ {/*
521
+ Measure intrinsic height on an unconstrained inner wrapper.
522
+ After equalization the outer slot is height-locked and this
523
+ inner fills it — further onLayouts are ignored.
524
+ */}
525
+ <View
526
+ style={hasEqualizedHeight ? styles.equalizedFill : undefined}
527
+ onLayout={handleSlideLayout(index)}
528
+ >
529
+ {childWithModes}
530
+ </View>
423
531
  </View>
424
532
  )
425
533
  })}
@@ -612,6 +720,12 @@ function AnimatedDot({
612
720
  )
613
721
  }
614
722
 
723
+ const styles = StyleSheet.create({
724
+ equalizedFill: {
725
+ flex: 1,
726
+ },
727
+ })
728
+
615
729
  // ---------------------------------------------------------------------------
616
730
  // Attach sub-components
617
731
  // ---------------------------------------------------------------------------