jfs-components 0.0.78 → 0.0.79

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 (33) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/lib/commonjs/components/Attached/Attached.js +144 -0
  3. package/lib/commonjs/components/Card/Card.js +25 -2
  4. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +4 -6
  5. package/lib/commonjs/components/ListItem/ListItem.js +22 -15
  6. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +328 -0
  7. package/lib/commonjs/components/Slot/Slot.js +73 -0
  8. package/lib/commonjs/components/index.js +21 -0
  9. package/lib/commonjs/icons/registry.js +1 -1
  10. package/lib/module/components/Attached/Attached.js +139 -0
  11. package/lib/module/components/Card/Card.js +25 -2
  12. package/lib/module/components/FullscreenModal/FullscreenModal.js +4 -6
  13. package/lib/module/components/ListItem/ListItem.js +22 -15
  14. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +322 -0
  15. package/lib/module/components/Slot/Slot.js +68 -0
  16. package/lib/module/components/index.js +3 -0
  17. package/lib/module/icons/registry.js +1 -1
  18. package/lib/typescript/src/components/Attached/Attached.d.ts +61 -0
  19. package/lib/typescript/src/components/Card/Card.d.ts +9 -2
  20. package/lib/typescript/src/components/ListItem/ListItem.d.ts +15 -5
  21. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +64 -0
  22. package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
  23. package/lib/typescript/src/components/index.d.ts +3 -0
  24. package/lib/typescript/src/icons/registry.d.ts +1 -1
  25. package/package.json +1 -1
  26. package/src/components/Attached/Attached.tsx +181 -0
  27. package/src/components/Card/Card.tsx +28 -1
  28. package/src/components/FullscreenModal/FullscreenModal.tsx +3 -3
  29. package/src/components/ListItem/ListItem.tsx +35 -16
  30. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +426 -0
  31. package/src/components/Slot/Slot.tsx +91 -0
  32. package/src/components/index.ts +3 -0
  33. package/src/icons/registry.ts +1 -1
@@ -0,0 +1,61 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewProps, type ViewStyle } from 'react-native';
3
+ /**
4
+ * Anchor point on the main content where the attached `badge` is centered.
5
+ * Mirrors the nine Figma `position` variants (corners, edge midpoints, center).
6
+ */
7
+ export type AttachedPosition = 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right';
8
+ export type AttachedProps = Omit<ViewProps, 'children'> & {
9
+ /**
10
+ * Main content the badge attaches to (the Figma "main slot"). Any node —
11
+ * typically an `IconCapsule`, `Avatar`, image, etc. `modes` are cascaded to
12
+ * every child via {@link cloneChildrenWithModes}.
13
+ */
14
+ children?: React.ReactNode;
15
+ /**
16
+ * The element attached on top of `children` (the Figma "slot"). Centered on
17
+ * the anchor point given by `position` so it straddles the edge/corner.
18
+ * `modes` are cascaded into it as well.
19
+ */
20
+ badge?: React.ReactNode;
21
+ /**
22
+ * Anchor point for the `badge` relative to the main content.
23
+ * @default 'bottom-right'
24
+ */
25
+ position?: AttachedPosition;
26
+ /**
27
+ * How the anchor point is computed for diagonal (corner) positions:
28
+ * - `false` (default): treat the main content as a **square** — corner
29
+ * anchors sit on the bounding-box corners.
30
+ * - `true`: treat the main content as a **circle** inscribed in its bounding
31
+ * box — corner anchors sit on the circle's circumference (the 45° point),
32
+ * so badges hug round content like a circular `IconCapsule` or `Avatar`.
33
+ *
34
+ * Edge (`top`/`bottom`/`left`/`right`) and `center` anchors are unaffected,
35
+ * since the circle meets the bounding box at those points.
36
+ * @default false
37
+ */
38
+ circular?: boolean;
39
+ /** Mode configuration cascaded to the token resolver and all children. */
40
+ modes?: Record<string, any>;
41
+ style?: StyleProp<ViewStyle>;
42
+ };
43
+ /**
44
+ * Attached — overlays a small `badge` on top of arbitrary main content,
45
+ * centered on one of nine anchor points (corners, edge midpoints, or center).
46
+ *
47
+ * The badge straddles the chosen anchor regardless of either element's size:
48
+ * both the main content and the badge are measured via `onLayout`, then the
49
+ * badge is absolutely positioned so its center lands exactly on the anchor.
50
+ *
51
+ * @example
52
+ * ```tsx
53
+ * <Attached position="bottom-right" badge={<InstitutionBadge modes={modes} />} modes={modes}>
54
+ * <IconCapsule iconName="ic_card" modes={modes} />
55
+ * </Attached>
56
+ * ```
57
+ */
58
+ declare function Attached({ children, badge, position, circular, modes: propModes, style, ...rest }: AttachedProps): import("react/jsx-runtime").JSX.Element;
59
+ declare const _default: React.MemoExoticComponent<typeof Attached>;
60
+ export default _default;
61
+ //# sourceMappingURL=Attached.d.ts.map
@@ -1,6 +1,11 @@
1
1
  import React from 'react';
2
2
  import { type ViewStyle, type TextStyle, type StyleProp } from 'react-native';
3
3
  export interface CardProps {
4
+ /**
5
+ * Content rendered in the header slot at the top of the card (e.g. a brand logo).
6
+ * Sits above the media slot, with its own padding.
7
+ */
8
+ header?: React.ReactNode;
4
9
  /**
5
10
  * The content to be rendered in the media slot (e.g. an Image).
6
11
  * This content is wrapped in a container that respects the `aspectRatio`.
@@ -28,16 +33,18 @@ export interface CardProps {
28
33
  * Card component implementation from Figma node 765:6186.
29
34
  *
30
35
  * Supports a `media` slot (with aspect ratio) and a content area.
36
+ * Supports an optional `header` slot (e.g. a brand logo), a `media` slot
37
+ * (with aspect ratio) and a content area.
31
38
  * Usage:
32
39
  * ```tsx
33
- * <Card media={<Image source={...} />} modes={modes}>
40
+ * <Card header={<GoldLogo />} media={<Image source={...} />} modes={modes}>
34
41
  * <Card.SupportText>Support text</Card.SupportText>
35
42
  * <Card.Title>Title</Card.Title>
36
43
  * <Card.SupportText>Support text</Card.SupportText>
37
44
  * </Card>
38
45
  * ```
39
46
  */
40
- export declare function Card({ media, children, modes, mediaAspectRatio, style, }: CardProps): import("react/jsx-runtime").JSX.Element;
47
+ export declare function Card({ header, media, children, modes, mediaAspectRatio, style, }: CardProps): import("react/jsx-runtime").JSX.Element;
41
48
  export declare namespace Card {
42
49
  var Title: ({ children, style, modes: propModes }: CardTextProps) => import("react/jsx-runtime").JSX.Element;
43
50
  var SupportText: ({ children, style, modes: propModes }: CardTextProps) => import("react/jsx-runtime").JSX.Element;
@@ -6,8 +6,16 @@ type ListItemProps = {
6
6
  title?: string;
7
7
  supportText?: string;
8
8
  showSupportText?: boolean;
9
+ /** Leading slot (Figma "leading"). Defaults to an `IconCapsule` when omitted. */
9
10
  leading?: React.ReactNode;
10
11
  supportSlot?: React.ReactNode;
12
+ /** Trailing slot (Figma "trailing"), e.g. `MoneyValue` or `Button`. Horizontal layout only. */
13
+ trailing?: React.ReactNode;
14
+ /**
15
+ * @deprecated Renamed to `trailing` for a symmetric `leading` / `trailing`
16
+ * slot API. Still honored for backward compatibility; `trailing` wins when
17
+ * both are provided. Will be removed in a future major version.
18
+ */
11
19
  endSlot?: React.ReactNode;
12
20
  /** Whether to show the NavArrow on the far right (Horizontal layout only). Defaults to true. */
13
21
  navArrow?: boolean;
@@ -33,9 +41,11 @@ type ListItemProps = {
33
41
  * - **design-token driven styling** via `getVariableByName` and `modes`
34
42
  *
35
43
  * Wherever the Figma layer name contains "Slot", this component exposes a
36
- * dedicated React "slot" prop:
44
+ * dedicated React "slot" prop. The leading and trailing edges share a
45
+ * symmetric `leading` / `trailing` slot API:
46
+ * - Slot "leading" → `leading`
37
47
  * - Slot "support text" → `supportSlot`
38
- * - Slot "end" → `endSlot`
48
+ * - Slot "trailing" → `trailing`
39
49
  *
40
50
  * @component
41
51
  * @param {Object} props
@@ -43,9 +53,9 @@ type ListItemProps = {
43
53
  * @param {string} [props.title='Title'] - Primary title used in the horizontal layout.
44
54
  * @param {string} [props.supportText='Support Text'] - Support text used in both layouts when `supportSlot` is not provided.
45
55
  * @param {boolean} [props.showSupportText=true] - Toggles rendering of the support text in Horizontal layout.
46
- * @param {React.ReactNode} [props.leading] - Optional leading element. Defaults to `IconCapsule`.
56
+ * @param {React.ReactNode} [props.leading] - Optional leading slot. Defaults to `IconCapsule`.
47
57
  * @param {React.ReactNode} [props.supportSlot] - Optional custom slot used instead of the default support text block.
48
- * @param {React.ReactNode} [props.endSlot] - Optional custom trailing slot (Figma Slot "end").
58
+ * @param {React.ReactNode} [props.trailing] - Optional trailing slot (Figma Slot "trailing"). Horizontal layout only.
49
59
  * @param {boolean} [props.navArrow=true] - Whether to show NavArrow on the far right (Horizontal layout only).
50
60
  * @param {Object} [props.modes={}] - Modes object passed to `getVariableByName` for all design tokens.
51
61
  * @param {Function} [props.onPress] - When provided, the entire item becomes pressable (navigation variant).
@@ -67,7 +77,7 @@ type ListItemProps = {
67
77
  * handlers stay referentially stable across renders.
68
78
  * - The component is wrapped in `React.memo`.
69
79
  */
70
- declare function ListItemImpl({ layout, title, supportText, showSupportText, leading, supportSlot, endSlot, navArrow, modes, onPress, style, contentStyle, accessibilityLabel, accessibilityHint, accessibilityState, webAccessibilityProps, ...rest }: ListItemProps): import("react/jsx-runtime").JSX.Element;
80
+ declare function ListItemImpl({ layout, title, supportText, showSupportText, leading, supportSlot, trailing, endSlot, navArrow, modes, onPress, style, contentStyle, accessibilityLabel, accessibilityHint, accessibilityState, webAccessibilityProps, ...rest }: ListItemProps): import("react/jsx-runtime").JSX.Element;
71
81
  declare const ListItem: React.MemoExoticComponent<typeof ListItemImpl>;
72
82
  export default ListItem;
73
83
  //# sourceMappingURL=ListItem.d.ts.map
@@ -0,0 +1,64 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewStyle } from 'react-native';
3
+ /**
4
+ * A single plan column header (the label column has no header of its own).
5
+ */
6
+ export type PlanComparisonColumn = {
7
+ /** Header text for the plan column. */
8
+ label: string;
9
+ /**
10
+ * Render the header in the brand accent colour (gold) — use it to
11
+ * highlight the recommended / upsell plan.
12
+ * @default false
13
+ */
14
+ brand?: boolean;
15
+ };
16
+ /**
17
+ * Value rendered inside a plan cell.
18
+ * - `string` / `number` → rendered as value text.
19
+ * - `false` → renders the muted "not available" cross icon.
20
+ * - any React node → rendered as-is (e.g. a `Badge`, `MoneyValue`, icon…).
21
+ * - `null` / `undefined` / `true` → empty cell.
22
+ */
23
+ export type PlanComparisonCellValue = string | number | boolean | null | undefined | React.ReactElement;
24
+ export type PlanComparisonRow = {
25
+ /** Feature label shown in the first (left) column. */
26
+ label: string;
27
+ /**
28
+ * Show an info icon after the label. When `onInfoPress` is provided the
29
+ * icon becomes tappable; otherwise it is purely decorative.
30
+ */
31
+ showInfo?: boolean;
32
+ /** Handler for the info icon. Implies `showInfo`. */
33
+ onInfoPress?: () => void;
34
+ /**
35
+ * One value per plan column, in the same order as `columns`. See
36
+ * {@link PlanComparisonCellValue} for how each value is rendered.
37
+ */
38
+ values: PlanComparisonCellValue[];
39
+ /** Stable key. Falls back to the label / index. */
40
+ key?: React.Key;
41
+ };
42
+ export type PlanComparisonCardProps = {
43
+ /**
44
+ * Plan column headers (excludes the leading label column). The order here
45
+ * maps 1:1 to each row's `values` array.
46
+ */
47
+ columns?: PlanComparisonColumn[];
48
+ /** Feature rows compared across the plan columns. */
49
+ rows?: PlanComparisonRow[];
50
+ /**
51
+ * Minimum flex-grow on the label column when the table is given extra
52
+ * horizontal space. Plan columns always size to their content and never
53
+ * shrink below it.
54
+ * @default 0
55
+ */
56
+ labelColumnFlex?: number;
57
+ /** Design token modes for theming (e.g. `{ "Color Mode": "Light" }`). */
58
+ modes?: Record<string, any>;
59
+ /** Override the outer container style. */
60
+ style?: StyleProp<ViewStyle>;
61
+ };
62
+ declare function PlanComparisonCard({ columns, rows, labelColumnFlex, modes, style, }: PlanComparisonCardProps): import("react/jsx-runtime").JSX.Element;
63
+ export default PlanComparisonCard;
64
+ //# sourceMappingURL=PlanComparisonCard.d.ts.map
@@ -0,0 +1,52 @@
1
+ import React from 'react';
2
+ import { type StyleProp, type ViewProps, type ViewStyle } from 'react-native';
3
+ export type SlotLayoutDirection = 'vertical' | 'horizontal';
4
+ export type SlotProps = ViewProps & {
5
+ /**
6
+ * Content laid out inside the slot. `modes` are cascaded to every child via
7
+ * {@link cloneChildrenWithModes}.
8
+ */
9
+ children?: React.ReactNode;
10
+ /**
11
+ * Main-axis direction for slot children. Matches the Figma Slot variant:
12
+ * - `vertical` (default): stacks children in a column
13
+ * - `horizontal`: arranges children in a row
14
+ */
15
+ layoutDirection?: SlotLayoutDirection;
16
+ /**
17
+ * Alignment along the cross axis.
18
+ * Defaults to `stretch` for vertical and `flex-start` for horizontal.
19
+ */
20
+ alignCrossAxis?: ViewStyle['alignItems'];
21
+ /**
22
+ * Distribution along the main axis (maps to `justifyContent`).
23
+ */
24
+ justifyMainAxis?: ViewStyle['justifyContent'];
25
+ /**
26
+ * Mode configuration passed to the token resolver and cascaded to children.
27
+ */
28
+ modes?: Record<string, any>;
29
+ style?: StyleProp<ViewStyle>;
30
+ };
31
+ /**
32
+ * Slot — a token-driven layout container for grouped slot content.
33
+ *
34
+ * Use `Slot` instead of a raw `View` when you need a vertical or horizontal
35
+ * stack with design-token gap spacing and automatic `modes` propagation to
36
+ * children. Typical usage is nesting a column of actions inside a
37
+ * direction-locked parent such as `ActionFooter`:
38
+ *
39
+ * @example
40
+ * ```tsx
41
+ * <ActionFooter modes={modes}>
42
+ * <Slot layoutDirection="vertical" modes={modes}>
43
+ * <Button label="Continue" modes={primaryModes} />
44
+ * <Disclaimer disclaimer="Terms apply." modes={modes} />
45
+ * </Slot>
46
+ * </ActionFooter>
47
+ * ```
48
+ */
49
+ declare function Slot({ children, layoutDirection, alignCrossAxis, justifyMainAxis, modes: propModes, style, ...rest }: SlotProps): import("react/jsx-runtime").JSX.Element;
50
+ declare const _default: React.MemoExoticComponent<typeof Slot>;
51
+ export default _default;
52
+ //# sourceMappingURL=Slot.d.ts.map
@@ -1,5 +1,6 @@
1
1
  export { default as AccountCard, type AccountCardProps, type AccountCardState } from './AccountCard/AccountCard';
2
2
  export { default as ActionFooter, type ActionFooterProps } from './ActionFooter/ActionFooter';
3
+ export { default as Attached, type AttachedProps, type AttachedPosition } from './Attached/Attached';
3
4
  export { default as AppBar } from './AppBar/AppBar';
4
5
  export { default as Avatar, type AvatarProps } from './Avatar/Avatar';
5
6
  export { default as AvatarGroup } from './AvatarGroup/AvatarGroup';
@@ -63,6 +64,7 @@ export { default as Numpad, type NumpadProps, type NumpadKeyValue } from './Nump
63
64
  export { default as Title, type TitleProps } from './Title/Title';
64
65
  export { default as Screen, type ScreenProps } from './Screen/Screen';
65
66
  export { default as Section } from './Section/Section';
67
+ export { default as Slot, type SlotProps, type SlotLayoutDirection } from './Slot/Slot';
66
68
  export { default as Stepper, type StepperProps } from './Stepper/Stepper';
67
69
  export { Step, type StepProps, type StepStatus } from './Stepper/Step';
68
70
  export { StepLabel, type StepLabelProps } from './Stepper/StepLabel';
@@ -110,6 +112,7 @@ export { default as AmountInput, type AmountInputProps } from './AmountInput/Amo
110
112
  export { default as PageHero, type PageHeroProps } from './PageHero/PageHero';
111
113
  export { default as Popup, type PopupProps, type PopupRef } from './Popup/Popup';
112
114
  export { default as PortfolioHero, type PortfolioHeroProps } from './PortfolioHero/PortfolioHero';
115
+ export { default as PlanComparisonCard, type PlanComparisonCardProps, type PlanComparisonColumn, type PlanComparisonRow, type PlanComparisonCellValue } from './PlanComparisonCard/PlanComparisonCard';
113
116
  export { default as PoweredByLabel, type PoweredByLabelProps } from './PoweredByLabel/PoweredByLabel';
114
117
  export { default as ProductLabel, type ProductLabelProps } from './ProductLabel/ProductLabel';
115
118
  export { default as ProductOverview, type ProductOverviewProps, type ProductOverviewStat } from './ProductOverview/ProductOverview';
@@ -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-05-29T10:37:24.494Z
7
+ * Generated: 2026-05-29T17:01:15.629Z
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.0.78",
3
+ "version": "0.0.79",
4
4
  "description": "React Native Jio Finance Components Library",
5
5
  "author": "sunshuaiqi@gmail.com",
6
6
  "license": "MIT",
@@ -0,0 +1,181 @@
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
+ import {
3
+ View,
4
+ type LayoutChangeEvent,
5
+ type StyleProp,
6
+ type ViewProps,
7
+ type ViewStyle,
8
+ } from 'react-native'
9
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
10
+ import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
11
+
12
+ /**
13
+ * Anchor point on the main content where the attached `badge` is centered.
14
+ * Mirrors the nine Figma `position` variants (corners, edge midpoints, center).
15
+ */
16
+ export type AttachedPosition =
17
+ | 'top-left'
18
+ | 'top'
19
+ | 'top-right'
20
+ | 'left'
21
+ | 'center'
22
+ | 'right'
23
+ | 'bottom-left'
24
+ | 'bottom'
25
+ | 'bottom-right'
26
+
27
+ export type AttachedProps = Omit<ViewProps, 'children'> & {
28
+ /**
29
+ * Main content the badge attaches to (the Figma "main slot"). Any node —
30
+ * typically an `IconCapsule`, `Avatar`, image, etc. `modes` are cascaded to
31
+ * every child via {@link cloneChildrenWithModes}.
32
+ */
33
+ children?: React.ReactNode
34
+ /**
35
+ * The element attached on top of `children` (the Figma "slot"). Centered on
36
+ * the anchor point given by `position` so it straddles the edge/corner.
37
+ * `modes` are cascaded into it as well.
38
+ */
39
+ badge?: React.ReactNode
40
+ /**
41
+ * Anchor point for the `badge` relative to the main content.
42
+ * @default 'bottom-right'
43
+ */
44
+ position?: AttachedPosition
45
+ /**
46
+ * How the anchor point is computed for diagonal (corner) positions:
47
+ * - `false` (default): treat the main content as a **square** — corner
48
+ * anchors sit on the bounding-box corners.
49
+ * - `true`: treat the main content as a **circle** inscribed in its bounding
50
+ * box — corner anchors sit on the circle's circumference (the 45° point),
51
+ * so badges hug round content like a circular `IconCapsule` or `Avatar`.
52
+ *
53
+ * Edge (`top`/`bottom`/`left`/`right`) and `center` anchors are unaffected,
54
+ * since the circle meets the bounding box at those points.
55
+ * @default false
56
+ */
57
+ circular?: boolean
58
+ /** Mode configuration cascaded to the token resolver and all children. */
59
+ modes?: Record<string, any>
60
+ style?: StyleProp<ViewStyle>
61
+ }
62
+
63
+ type Size = { width: number; height: number }
64
+
65
+ const ZERO_SIZE: Size = { width: 0, height: 0 }
66
+
67
+ /**
68
+ * Fraction (0 | 0.5 | 1) of the main content's width/height at which the badge
69
+ * center should sit, derived from the `position` anchor.
70
+ */
71
+ function resolveAnchorFractions(position: AttachedPosition): { fx: number; fy: number } {
72
+ const fx = position.includes('left') ? 0 : position.includes('right') ? 1 : 0.5
73
+ const fy = position.startsWith('top') ? 0 : position.startsWith('bottom') ? 1 : 0.5
74
+ return { fx, fy }
75
+ }
76
+
77
+ /**
78
+ * Attached — overlays a small `badge` on top of arbitrary main content,
79
+ * centered on one of nine anchor points (corners, edge midpoints, or center).
80
+ *
81
+ * The badge straddles the chosen anchor regardless of either element's size:
82
+ * both the main content and the badge are measured via `onLayout`, then the
83
+ * badge is absolutely positioned so its center lands exactly on the anchor.
84
+ *
85
+ * @example
86
+ * ```tsx
87
+ * <Attached position="bottom-right" badge={<InstitutionBadge modes={modes} />} modes={modes}>
88
+ * <IconCapsule iconName="ic_card" modes={modes} />
89
+ * </Attached>
90
+ * ```
91
+ */
92
+ function Attached({
93
+ children,
94
+ badge,
95
+ position = 'bottom-right',
96
+ circular = true,
97
+ modes: propModes = EMPTY_MODES,
98
+ style,
99
+ ...rest
100
+ }: AttachedProps) {
101
+ const { modes: globalModes } = useTokens()
102
+ const modes = useMemo(
103
+ () =>
104
+ globalModes === EMPTY_MODES && propModes === EMPTY_MODES
105
+ ? EMPTY_MODES
106
+ : { ...globalModes, ...propModes },
107
+ [globalModes, propModes]
108
+ )
109
+
110
+ const [mainSize, setMainSize] = useState<Size>(ZERO_SIZE)
111
+ const [badgeSize, setBadgeSize] = useState<Size>(ZERO_SIZE)
112
+
113
+ const onMainLayout = useCallback((e: LayoutChangeEvent) => {
114
+ const { width, height } = e.nativeEvent.layout
115
+ setMainSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
116
+ }, [])
117
+
118
+ const onBadgeLayout = useCallback((e: LayoutChangeEvent) => {
119
+ const { width, height } = e.nativeEvent.layout
120
+ setBadgeSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
121
+ }, [])
122
+
123
+ const mainChildren = useMemo(
124
+ () => (children != null ? cloneChildrenWithModes(children, modes) : null),
125
+ [children, modes]
126
+ )
127
+ const badgeChildren = useMemo(
128
+ () => (badge != null ? cloneChildrenWithModes(badge, modes) : null),
129
+ [badge, modes]
130
+ )
131
+
132
+ const badgePlacement = useMemo<ViewStyle>(() => {
133
+ const { fx, fy } = resolveAnchorFractions(position)
134
+ const measured = mainSize.width > 0 && badgeSize.width > 0
135
+
136
+ let anchorX: number
137
+ let anchorY: number
138
+ if (circular) {
139
+ // Project the anchor onto the circle inscribed in the bounding box, so
140
+ // corner badges land on the circumference (45°) instead of the box corner.
141
+ const cx = mainSize.width / 2
142
+ const cy = mainSize.height / 2
143
+ const radius = Math.min(mainSize.width, mainSize.height) / 2
144
+ const dx = (fx - 0.5) * 2 // -1 | 0 | 1
145
+ const dy = (fy - 0.5) * 2 // -1 | 0 | 1
146
+ const len = Math.hypot(dx, dy) || 1 // 'center' → 0, guard against /0
147
+ anchorX = cx + (dx / len) * radius
148
+ anchorY = cy + (dy / len) * radius
149
+ } else {
150
+ anchorX = mainSize.width * fx
151
+ anchorY = mainSize.height * fy
152
+ }
153
+
154
+ return {
155
+ position: 'absolute',
156
+ left: anchorX - badgeSize.width / 2,
157
+ top: anchorY - badgeSize.height / 2,
158
+ // Hide until both elements are measured to avoid a one-frame flash at (0,0).
159
+ opacity: measured ? 1 : 0,
160
+ }
161
+ }, [position, circular, mainSize, badgeSize])
162
+
163
+ return (
164
+ <View style={[styles.container, style]} {...rest}>
165
+ <View onLayout={onMainLayout}>{mainChildren}</View>
166
+ {badgeChildren != null && (
167
+ <View style={badgePlacement} onLayout={onBadgeLayout} pointerEvents="box-none">
168
+ {badgeChildren}
169
+ </View>
170
+ )}
171
+ </View>
172
+ )
173
+ }
174
+
175
+ const styles = {
176
+ // alignSelf flex-start so the wrapper hugs the main content; anchors are then
177
+ // computed relative to the content size rather than a stretched parent.
178
+ container: { position: 'relative', alignSelf: 'flex-start' } as ViewStyle,
179
+ }
180
+
181
+ export default React.memo(Attached)
@@ -11,6 +11,11 @@ import { EMPTY_MODES } from '../../utils/react-utils';
11
11
  const CardContext = createContext<{ modes?: Record<string, any> }>({});
12
12
 
13
13
  export interface CardProps {
14
+ /**
15
+ * Content rendered in the header slot at the top of the card (e.g. a brand logo).
16
+ * Sits above the media slot, with its own padding.
17
+ */
18
+ header?: React.ReactNode;
14
19
  /**
15
20
  * The content to be rendered in the media slot (e.g. an Image).
16
21
  * This content is wrapped in a container that respects the `aspectRatio`.
@@ -39,9 +44,11 @@ export interface CardProps {
39
44
  * Card component implementation from Figma node 765:6186.
40
45
  *
41
46
  * Supports a `media` slot (with aspect ratio) and a content area.
47
+ * Supports an optional `header` slot (e.g. a brand logo), a `media` slot
48
+ * (with aspect ratio) and a content area.
42
49
  * Usage:
43
50
  * ```tsx
44
- * <Card media={<Image source={...} />} modes={modes}>
51
+ * <Card header={<GoldLogo />} media={<Image source={...} />} modes={modes}>
45
52
  * <Card.SupportText>Support text</Card.SupportText>
46
53
  * <Card.Title>Title</Card.Title>
47
54
  * <Card.SupportText>Support text</Card.SupportText>
@@ -49,6 +56,7 @@ export interface CardProps {
49
56
  * ```
50
57
  */
51
58
  export function Card({
59
+ header,
52
60
  media,
53
61
  children,
54
62
  modes = EMPTY_MODES,
@@ -74,6 +82,11 @@ export function Card({
74
82
  ? cloneElement(media as any, { modes: { ...(media.props as any).modes, ...modes } })
75
83
  : media;
76
84
 
85
+ // Clone header to pass modes if it's a valid element
86
+ const headerWithModes = isValidElement(header)
87
+ ? cloneElement(header as any, { modes: { ...(header.props as any).modes, ...modes } })
88
+ : header;
89
+
77
90
  const containerStyle: ViewStyle = {
78
91
  backgroundColor,
79
92
  borderColor,
@@ -85,6 +98,15 @@ export function Card({
85
98
  overflow: 'hidden', // Ensure border radius clips content
86
99
  };
87
100
 
101
+ // Header wrap uses fixed padding from Figma (no dedicated tokens defined).
102
+ const headerWrapperStyle: ViewStyle = {
103
+ width: '100%',
104
+ flexDirection: 'row',
105
+ alignItems: 'flex-start',
106
+ paddingHorizontal: 12,
107
+ paddingVertical: 16,
108
+ };
109
+
88
110
  const mediaWrapperStyle: ViewStyle = {
89
111
  width: '100%',
90
112
  aspectRatio: mediaAspectRatio,
@@ -104,6 +126,11 @@ export function Card({
104
126
  return (
105
127
  <CardContext.Provider value={{ modes }}>
106
128
  <View style={[containerStyle, style]}>
129
+ {header && (
130
+ <View style={headerWrapperStyle}>
131
+ {headerWithModes}
132
+ </View>
133
+ )}
107
134
  {media && (
108
135
  <View style={mediaWrapperStyle}>
109
136
  {mediaWithModes}
@@ -21,6 +21,7 @@ import Button from '../Button/Button'
21
21
  import Disclaimer from '../Disclaimer/Disclaimer'
22
22
  import IconButton from '../IconButton/IconButton'
23
23
  import ActionFooter from '../ActionFooter/ActionFooter'
24
+ import Slot from '../Slot/Slot'
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // Forced modes
@@ -351,7 +352,7 @@ function FullscreenModal({
351
352
  footerContent = footer
352
353
  } else if (primaryActionLabel) {
353
354
  footerContent = (
354
- <View style={footerColumnStyle}>
355
+ <Slot layoutDirection="vertical" modes={modes}>
355
356
  <Button
356
357
  label={primaryActionLabel}
357
358
  modes={modes}
@@ -359,7 +360,7 @@ function FullscreenModal({
359
360
  {...(onPrimaryAction ? { onPress: onPrimaryAction } : {})}
360
361
  />
361
362
  {disclaimer ? <Disclaimer disclaimer={disclaimer} modes={modes} /> : null}
362
- </View>
363
+ </Slot>
363
364
  )
364
365
  }
365
366
 
@@ -407,7 +408,6 @@ function FullscreenModal({
407
408
  const rootStyle: ViewStyle = { flex: 1, width: '100%', position: 'relative' }
408
409
  const scrollViewStyle: ViewStyle = { flex: 1 }
409
410
  const scrollContentStyle: ViewStyle = { flexGrow: 1 }
410
- const footerColumnStyle: ViewStyle = { width: '100%', gap: 8 }
411
411
  const fullWidthStyle: ViewStyle = { width: '100%' }
412
412
  const closeButtonStyle: ViewStyle = { position: 'absolute', top: 12, right: 12 }
413
413