jfs-components 0.0.78 → 0.0.84

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 (119) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/lib/commonjs/components/AppBar/AppBar.js +56 -6
  3. package/lib/commonjs/components/Attached/Attached.js +183 -0
  4. package/lib/commonjs/components/Card/Card.js +25 -2
  5. package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
  6. package/lib/commonjs/components/Drawer/Drawer.js +6 -1
  7. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
  8. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  9. package/lib/commonjs/components/FormField/FormField.js +1 -14
  10. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +9 -7
  11. package/lib/commonjs/components/ListItem/ListItem.js +26 -24
  12. package/lib/commonjs/components/MessageField/MessageField.js +1 -13
  13. package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
  14. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +237 -0
  15. package/lib/commonjs/components/Slot/Slot.js +73 -0
  16. package/lib/commonjs/components/Spinner/Spinner.js +217 -0
  17. package/lib/commonjs/components/TextInput/TextInput.js +33 -18
  18. package/lib/commonjs/components/index.js +28 -0
  19. package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
  20. package/lib/commonjs/icons/components/IconArrowup.js +19 -0
  21. package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
  22. package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
  23. package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
  24. package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
  25. package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
  26. package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
  27. package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
  28. package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
  29. package/lib/commonjs/icons/components/IconSignin.js +19 -0
  30. package/lib/commonjs/icons/components/IconSignout.js +19 -0
  31. package/lib/commonjs/icons/components/index.js +132 -0
  32. package/lib/commonjs/icons/registry.js +2 -2
  33. package/lib/module/components/AppBar/AppBar.js +56 -6
  34. package/lib/module/components/Attached/Attached.js +178 -0
  35. package/lib/module/components/Card/Card.js +25 -2
  36. package/lib/module/components/Checkbox/Checkbox.js +18 -2
  37. package/lib/module/components/Drawer/Drawer.js +6 -1
  38. package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
  39. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  40. package/lib/module/components/FormField/FormField.js +3 -16
  41. package/lib/module/components/FullscreenModal/FullscreenModal.js +9 -7
  42. package/lib/module/components/ListItem/ListItem.js +26 -24
  43. package/lib/module/components/MessageField/MessageField.js +3 -15
  44. package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
  45. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +234 -0
  46. package/lib/module/components/Slot/Slot.js +68 -0
  47. package/lib/module/components/Spinner/Spinner.js +212 -0
  48. package/lib/module/components/TextInput/TextInput.js +34 -19
  49. package/lib/module/components/index.js +4 -0
  50. package/lib/module/icons/components/IconArrowdown.js +12 -0
  51. package/lib/module/icons/components/IconArrowup.js +12 -0
  52. package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
  53. package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
  54. package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
  55. package/lib/module/icons/components/IconChevronupcircle.js +12 -0
  56. package/lib/module/icons/components/IconOsnavback.js +12 -0
  57. package/lib/module/icons/components/IconOsnavcenter.js +12 -0
  58. package/lib/module/icons/components/IconOsnavhome.js +12 -0
  59. package/lib/module/icons/components/IconOsnavtask.js +12 -0
  60. package/lib/module/icons/components/IconSignin.js +12 -0
  61. package/lib/module/icons/components/IconSignout.js +12 -0
  62. package/lib/module/icons/components/index.js +12 -0
  63. package/lib/module/icons/registry.js +2 -2
  64. package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
  65. package/lib/typescript/src/components/Attached/Attached.d.ts +64 -0
  66. package/lib/typescript/src/components/Card/Card.d.ts +9 -2
  67. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
  68. package/lib/typescript/src/components/ListItem/ListItem.d.ts +16 -6
  69. package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
  70. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +66 -0
  71. package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
  72. package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
  73. package/lib/typescript/src/components/index.d.ts +4 -0
  74. package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
  75. package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
  76. package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
  77. package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
  78. package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
  79. package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
  80. package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
  81. package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
  82. package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
  83. package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
  84. package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
  85. package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
  86. package/lib/typescript/src/icons/components/index.d.ts +12 -0
  87. package/lib/typescript/src/icons/registry.d.ts +1 -1
  88. package/package.json +3 -2
  89. package/src/components/AppBar/AppBar.tsx +79 -12
  90. package/src/components/Attached/Attached.tsx +237 -0
  91. package/src/components/Card/Card.tsx +28 -1
  92. package/src/components/Checkbox/Checkbox.tsx +14 -2
  93. package/src/components/Drawer/Drawer.tsx +4 -0
  94. package/src/components/DropdownInput/DropdownInput.tsx +54 -20
  95. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
  96. package/src/components/FormField/FormField.tsx +3 -19
  97. package/src/components/FullscreenModal/FullscreenModal.tsx +6 -3
  98. package/src/components/ListItem/ListItem.tsx +42 -25
  99. package/src/components/MessageField/MessageField.tsx +3 -18
  100. package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
  101. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +316 -0
  102. package/src/components/Slot/Slot.tsx +91 -0
  103. package/src/components/Spinner/Spinner.tsx +273 -0
  104. package/src/components/TextInput/TextInput.tsx +37 -19
  105. package/src/components/index.ts +4 -0
  106. package/src/icons/components/IconArrowdown.tsx +11 -0
  107. package/src/icons/components/IconArrowup.tsx +11 -0
  108. package/src/icons/components/IconChevrondowncircle.tsx +11 -0
  109. package/src/icons/components/IconChevronleftcircle.tsx +11 -0
  110. package/src/icons/components/IconChevronrightcircle.tsx +11 -0
  111. package/src/icons/components/IconChevronupcircle.tsx +11 -0
  112. package/src/icons/components/IconOsnavback.tsx +11 -0
  113. package/src/icons/components/IconOsnavcenter.tsx +11 -0
  114. package/src/icons/components/IconOsnavhome.tsx +11 -0
  115. package/src/icons/components/IconOsnavtask.tsx +11 -0
  116. package/src/icons/components/IconSignin.tsx +11 -0
  117. package/src/icons/components/IconSignout.tsx +11 -0
  118. package/src/icons/components/index.ts +12 -0
  119. package/src/icons/registry.ts +49 -1
@@ -8,6 +8,14 @@ import { cloneChildrenWithModes, EMPTY_MODES } from '../../utils/react-utils'
8
8
 
9
9
  type AppBarType = 'MainPage' | 'SubPage'
10
10
 
11
+ // SubPage "slot wrap" geometry, taken directly from the Figma design
12
+ // (node 449:7876). The middle slot is an absolutely-centered box of a fixed
13
+ // size; its inner content (node 3991:4125) is a `flex: 1 0 0; min-width: 1px`
14
+ // item so it fills / shrinks responsively within that box.
15
+ const SUBPAGE_MIDDLE_DEFAULT_WIDTH = 192
16
+ const SUBPAGE_MIDDLE_HEIGHT = 32
17
+ const SUBPAGE_MIDDLE_PADDING_HORIZONTAL = 21
18
+
11
19
  export type AppBarProps = {
12
20
  /**
13
21
  * Type of the App Bar.
@@ -26,8 +34,19 @@ export type AppBarProps = {
26
34
  /**
27
35
  * Slot for the middle content.
28
36
  * Often used for "Page Title" in SubPage.
37
+ *
38
+ * On `SubPage` this is rendered as an absolutely-centered box (matching the
39
+ * Figma "slot wrap"): it stays centered in the bar regardless of how wide
40
+ * the leading/actions slots are, and its content fills/shrinks responsively
41
+ * within {@link middleSlotWidth}.
29
42
  */
30
43
  middleSlot?: React.ReactNode;
44
+ /**
45
+ * Width of the centered `SubPage` middle slot, in px.
46
+ * Defaults to the Figma value (192). Has no effect on `MainPage`.
47
+ * @default 192
48
+ */
49
+ middleSlotWidth?: number;
31
50
  /**
32
51
  * Slot for the actions on the right.
33
52
  */
@@ -52,6 +71,7 @@ export default function AppBar({
52
71
  type = 'MainPage',
53
72
  leadingSlot,
54
73
  middleSlot,
74
+ middleSlotWidth = SUBPAGE_MIDDLE_DEFAULT_WIDTH,
55
75
  actionsSlot,
56
76
  modes: propModes = EMPTY_MODES,
57
77
  onLeadingPress,
@@ -160,13 +180,39 @@ export default function AppBar({
160
180
  ? <View style={actionsStyle}>{cloneChildrenWithModes(React.Children.toArray(actionsSlot), modes)}</View>
161
181
  : null
162
182
 
163
- // When there is no middleSlot we want leading & actions pinned to the
164
- // outer edges, so we apply `space-between` at the wrapper. With a middle
165
- // slot present, the middle (flex: 1) absorbs the remaining space, so
166
- // `space-between` is a no-op.
183
+ // SubPage centers its middle slot via absolute positioning (see Figma
184
+ // "slot wrap"), so it never participates in the row flow. Only MainPage
185
+ // keeps the legacy in-flow middle slot.
186
+ const hasInFlowMiddle = isMain && !!processedMiddle
187
+
188
+ // With an in-flow middle (MainPage) the middle (flex: 1) absorbs the
189
+ // remaining space, so leading & actions sit at the edges naturally. In all
190
+ // other cases we pin leading & actions to the outer edges with
191
+ // `space-between`; the SubPage middle floats above, centered.
167
192
  const wrapperStyle: ViewStyle = {
168
193
  ...containerStyle,
169
- justifyContent: processedMiddle ? 'flex-start' : 'space-between',
194
+ justifyContent: hasInFlowMiddle ? 'flex-start' : 'space-between',
195
+ }
196
+
197
+ // Absolutely-centered middle box for SubPage, mirroring the Figma geometry.
198
+ // `left/top: 50%` + a negative translate keeps it centered regardless of the
199
+ // bar width, while the fixed width clips overly-wide content (overflow:
200
+ // hidden) instead of letting it bleed under the leading/actions slots.
201
+ const subPageMiddleStyle: ViewStyle = {
202
+ position: 'absolute',
203
+ top: '50%',
204
+ left: '50%',
205
+ width: middleSlotWidth,
206
+ height: SUBPAGE_MIDDLE_HEIGHT,
207
+ transform: [
208
+ { translateX: -middleSlotWidth / 2 },
209
+ { translateY: -SUBPAGE_MIDDLE_HEIGHT / 2 },
210
+ ],
211
+ flexDirection: 'row',
212
+ alignItems: 'center',
213
+ justifyContent: 'center',
214
+ paddingHorizontal: SUBPAGE_MIDDLE_PADDING_HORIZONTAL,
215
+ overflow: 'hidden',
170
216
  }
171
217
 
172
218
  return (
@@ -183,14 +229,12 @@ export default function AppBar({
183
229
  </View>
184
230
 
185
231
  {/*
186
- * Middle Section — rendered as an in-flow flex item (`flex: 1`) so it
187
- * occupies the space between leading and actions but never overflows
188
- * past them. This fixes wide children (e.g. <LinearProgress /> with
189
- * width: '100%') stretching edge-to-edge under the leading/actions.
190
- * `minWidth: 0` is required so the flex item can shrink below its
191
- * content's intrinsic width on platforms that respect it (web).
232
+ * MainPage in-flow middle occupies the space between leading and
233
+ * actions (`flex: 1`) without overflowing. `minWidth: 0` lets the flex
234
+ * item shrink below its content's intrinsic width on platforms that
235
+ * respect it (web).
192
236
  */}
193
- {processedMiddle && (
237
+ {hasInFlowMiddle && (
194
238
  <View
195
239
  style={{
196
240
  flex: 1,
@@ -209,6 +253,29 @@ export default function AppBar({
209
253
  <View style={actionsStyle}>
210
254
  {processedActions}
211
255
  </View>
256
+
257
+ {/*
258
+ * SubPage middle — absolutely centered "slot wrap". The inner wrapper is
259
+ * a responsive `flex: 1` item (matching Figma's `flex-[1_0_0] min-w-px`)
260
+ * so its content fills / shrinks within the fixed-width box.
261
+ */}
262
+ {isSub && processedMiddle && (
263
+ <View style={subPageMiddleStyle} pointerEvents="box-none">
264
+ <View
265
+ style={{
266
+ flex: 1,
267
+ minWidth: 1,
268
+ height: '100%',
269
+ flexDirection: 'row',
270
+ alignItems: 'center',
271
+ justifyContent: 'center',
272
+ }}
273
+ pointerEvents="box-none"
274
+ >
275
+ {processedMiddle}
276
+ </View>
277
+ </View>
278
+ )}
212
279
  </View>
213
280
  )
214
281
  }
@@ -0,0 +1,237 @@
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
+ * Enforces a fixed square size (in px) on the `badge` slot, regardless of
42
+ * what node is passed. The badge is wrapped in a box of
43
+ * `badgeSize × badgeSize` with `overflow: 'hidden'`, and the badge content is
44
+ * stretched to fill it. Use this to guarantee the design-token size even when
45
+ * a consumer drops in an arbitrary node (e.g. an `Image`) whose intrinsic
46
+ * size/aspect-ratio would otherwise win.
47
+ *
48
+ * When omitted, the badge keeps its own intrinsic size (legacy behavior).
49
+ */
50
+ badgeSize?: number
51
+ /**
52
+ * Corner radius used to clip the `badge` box. Only applies when `badgeSize`
53
+ * is set. Anything that overflows the rounded box (e.g. a non-square image)
54
+ * is clipped.
55
+ * @default badgeSize / 2 (a full circle)
56
+ */
57
+ badgeRadius?: number
58
+ /**
59
+ * Anchor point for the `badge` relative to the main content.
60
+ * @default 'bottom-right'
61
+ */
62
+ position?: AttachedPosition
63
+ /**
64
+ * How the anchor point is computed for diagonal (corner) positions:
65
+ * - `false` (default): treat the main content as a **square** — corner
66
+ * anchors sit on the bounding-box corners.
67
+ * - `true`: treat the main content as a **circle** inscribed in its bounding
68
+ * box — corner anchors sit on the circle's circumference (the 45° point),
69
+ * so badges hug round content like a circular `IconCapsule` or `Avatar`.
70
+ *
71
+ * Edge (`top`/`bottom`/`left`/`right`) and `center` anchors are unaffected,
72
+ * since the circle meets the bounding box at those points.
73
+ * @default false
74
+ */
75
+ circular?: boolean
76
+ /** Mode configuration cascaded to the token resolver and all children. */
77
+ modes?: Record<string, any>
78
+ style?: StyleProp<ViewStyle>
79
+ }
80
+
81
+ type Size = { width: number; height: number }
82
+
83
+ const ZERO_SIZE: Size = { width: 0, height: 0 }
84
+
85
+ /**
86
+ * Fraction (0 | 0.5 | 1) of the main content's width/height at which the badge
87
+ * center should sit, derived from the `position` anchor.
88
+ */
89
+ function resolveAnchorFractions(position: AttachedPosition): { fx: number; fy: number } {
90
+ const fx = position.includes('left') ? 0 : position.includes('right') ? 1 : 0.5
91
+ const fy = position.startsWith('top') ? 0 : position.startsWith('bottom') ? 1 : 0.5
92
+ return { fx, fy }
93
+ }
94
+
95
+ /**
96
+ * Attached — overlays a small `badge` on top of arbitrary main content,
97
+ * centered on one of nine anchor points (corners, edge midpoints, or center).
98
+ *
99
+ * The badge straddles the chosen anchor regardless of either element's size:
100
+ * both the main content and the badge are measured via `onLayout`, then the
101
+ * badge is absolutely positioned so its center lands exactly on the anchor.
102
+ *
103
+ * @example
104
+ * ```tsx
105
+ * <Attached position="bottom-right" badge={<InstitutionBadge modes={modes} />} modes={modes}>
106
+ * <IconCapsule iconName="ic_card" modes={modes} />
107
+ * </Attached>
108
+ * ```
109
+ */
110
+ /**
111
+ * Stretches the immediate badge child/children to fill the enforced badge box.
112
+ * Merges `{ width: '100%', height: '100%' }` into each top-level element's
113
+ * `style` so an arbitrary node (e.g. an `Image` with its own width/aspectRatio)
114
+ * fills the fixed `badgeSize` box instead of laying out at its intrinsic size.
115
+ * The wrapping box's `overflow: 'hidden'` clips anything that still overflows.
116
+ */
117
+ function forceBadgeFill(children: React.ReactNode): React.ReactNode {
118
+ return React.Children.map(children, (child) => {
119
+ if (!React.isValidElement(child)) return child
120
+ const childStyle = (child.props as any)?.style
121
+ return React.cloneElement(child as React.ReactElement<any>, {
122
+ style: [FILL_STYLE, childStyle],
123
+ })
124
+ })
125
+ }
126
+
127
+ function Attached({
128
+ children,
129
+ badge,
130
+ badgeSize,
131
+ badgeRadius,
132
+ position = 'bottom-right',
133
+ circular = true,
134
+ modes: propModes = EMPTY_MODES,
135
+ style,
136
+ ...rest
137
+ }: AttachedProps) {
138
+ const { modes: globalModes } = useTokens()
139
+ const modes = useMemo(
140
+ () =>
141
+ globalModes === EMPTY_MODES && propModes === EMPTY_MODES
142
+ ? EMPTY_MODES
143
+ : { ...globalModes, ...propModes },
144
+ [globalModes, propModes]
145
+ )
146
+
147
+ const [mainSize, setMainSize] = useState<Size>(ZERO_SIZE)
148
+ const [measuredBadgeSize, setMeasuredBadgeSize] = useState<Size>(ZERO_SIZE)
149
+
150
+ const onMainLayout = useCallback((e: LayoutChangeEvent) => {
151
+ const { width, height } = e.nativeEvent.layout
152
+ setMainSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
153
+ }, [])
154
+
155
+ const onBadgeLayout = useCallback((e: LayoutChangeEvent) => {
156
+ const { width, height } = e.nativeEvent.layout
157
+ setMeasuredBadgeSize((prev) => (prev.width === width && prev.height === height ? prev : { width, height }))
158
+ }, [])
159
+
160
+ const mainChildren = useMemo(
161
+ () => (children != null ? cloneChildrenWithModes(children, modes) : null),
162
+ [children, modes]
163
+ )
164
+ const badgeChildren = useMemo(
165
+ () => (badge != null ? cloneChildrenWithModes(badge, modes) : null),
166
+ [badge, modes]
167
+ )
168
+
169
+ // When a fixed size is requested, the badge is wrapped in a clipped box and
170
+ // its content is force-stretched to fill it (see `forceBadgeFill`).
171
+ const badgeBoxStyle = useMemo<ViewStyle | null>(() => {
172
+ if (badgeSize == null) return null
173
+ return {
174
+ width: badgeSize,
175
+ height: badgeSize,
176
+ borderRadius: badgeRadius ?? badgeSize / 2,
177
+ overflow: 'hidden',
178
+ }
179
+ }, [badgeSize, badgeRadius])
180
+
181
+ const badgePlacement = useMemo<ViewStyle>(() => {
182
+ const { fx, fy } = resolveAnchorFractions(position)
183
+ const measured = mainSize.width > 0 && measuredBadgeSize.width > 0
184
+
185
+ let anchorX: number
186
+ let anchorY: number
187
+ if (circular) {
188
+ // Project the anchor onto the circle inscribed in the bounding box, so
189
+ // corner badges land on the circumference (45°) instead of the box corner.
190
+ const cx = mainSize.width / 2
191
+ const cy = mainSize.height / 2
192
+ const radius = Math.min(mainSize.width, mainSize.height) / 2
193
+ const dx = (fx - 0.5) * 2 // -1 | 0 | 1
194
+ const dy = (fy - 0.5) * 2 // -1 | 0 | 1
195
+ const len = Math.hypot(dx, dy) || 1 // 'center' → 0, guard against /0
196
+ anchorX = cx + (dx / len) * radius
197
+ anchorY = cy + (dy / len) * radius
198
+ } else {
199
+ anchorX = mainSize.width * fx
200
+ anchorY = mainSize.height * fy
201
+ }
202
+
203
+ return {
204
+ position: 'absolute',
205
+ left: anchorX - measuredBadgeSize.width / 2,
206
+ top: anchorY - measuredBadgeSize.height / 2,
207
+ // Hide until both elements are measured to avoid a one-frame flash at (0,0).
208
+ opacity: measured ? 1 : 0,
209
+ }
210
+ }, [position, circular, mainSize, measuredBadgeSize])
211
+
212
+ return (
213
+ <View style={[styles.container, style]} {...rest}>
214
+ <View onLayout={onMainLayout}>{mainChildren}</View>
215
+ {badgeChildren != null && (
216
+ <View style={badgePlacement} onLayout={onBadgeLayout} pointerEvents="box-none">
217
+ {badgeBoxStyle != null ? (
218
+ <View style={badgeBoxStyle}>{forceBadgeFill(badgeChildren)}</View>
219
+ ) : (
220
+ badgeChildren
221
+ )}
222
+ </View>
223
+ )}
224
+ </View>
225
+ )
226
+ }
227
+
228
+ const styles = {
229
+ // alignSelf flex-start so the wrapper hugs the main content; anchors are then
230
+ // computed relative to the content size rather than a stretched parent.
231
+ container: { position: 'relative', alignSelf: 'flex-start' } as ViewStyle,
232
+ }
233
+
234
+ /** Fill style merged into badge content when `badgeSize` enforces a fixed box. */
235
+ const FILL_STYLE = { width: '100%', height: '100%' } as ViewStyle
236
+
237
+ 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}
@@ -55,12 +55,21 @@ function useFocusVisible() {
55
55
  const MIN_TOUCH_TARGET = 44
56
56
 
57
57
  const touchTargetStyle: ViewStyle = {
58
- minWidth: MIN_TOUCH_TARGET,
59
- minHeight: MIN_TOUCH_TARGET,
60
58
  alignItems: 'center',
61
59
  justifyContent: 'center',
62
60
  }
63
61
 
62
+ /**
63
+ * Expands the tappable region to the 44pt minimum without changing layout.
64
+ * `hitSlop` extends the press-responder bounds beyond the visual box on both
65
+ * native and web (react-native-web ≥ 0.19), so the Pressable keeps its natural
66
+ * checkbox-sized footprint and sibling alignment stays intact.
67
+ */
68
+ function invisibleTouchHitSlop(checkboxSize: number) {
69
+ const slop = Math.max(0, Math.ceil((MIN_TOUCH_TARGET - checkboxSize) / 2))
70
+ return { top: slop, bottom: slop, left: slop, right: slop }
71
+ }
72
+
64
73
  export interface CheckboxProps {
65
74
  /** Whether the checkbox is checked (controlled) */
66
75
  checked?: boolean
@@ -216,9 +225,12 @@ function Checkbox({
216
225
  ? (disabledActiveMark as string)
217
226
  : (selectedMarkColor as string)
218
227
 
228
+ const hitSlop = invisibleTouchHitSlop(size as number)
229
+
219
230
  return (
220
231
  <Pressable
221
232
  style={[touchTargetStyle, style]}
233
+ hitSlop={hitSlop}
222
234
  onPress={handlePress}
223
235
  disabled={disabled}
224
236
  onHoverIn={() => setIsHovered(true)}
@@ -439,6 +439,10 @@ function Drawer({
439
439
  style={[styles.content, contentStyle]}
440
440
  contentContainerStyle={[{ paddingBottom: paddingBottom + bottomInset, gap: drawerGap, flexDirection: 'column', alignItems: 'stretch' }, contentContainerStyle]}
441
441
  showsVerticalScrollIndicator={showsVerticalScrollIndicator}
442
+ // Let a tap on an input inside the sheet focus it on the FIRST tap
443
+ // even while the keyboard is already open (default 'never' would
444
+ // eat that tap just to dismiss the keyboard).
445
+ keyboardShouldPersistTaps="handled"
442
446
  animatedProps={animatedScrollProps}
443
447
  alwaysBounceVertical={false}
444
448
  overScrollMode="always"
@@ -117,8 +117,9 @@ export type DropdownInputProps = {
117
117
  */
118
118
  menuMaxHeight?: number
119
119
  /**
120
- * Pixel offset between the trigger and the popup. Defaults to 4 so the
121
- * popup visually peeks below the input.
120
+ * Pixel gap between the trigger and the popup. When omitted, it defaults
121
+ * to the `formField/gap` design token so the menu sits the same distance
122
+ * below the input as the rest of the field's internal spacing.
122
123
  */
123
124
  menuOffset?: number
124
125
  /**
@@ -325,7 +326,7 @@ function DropdownInput({
325
326
  supportText,
326
327
  errorMessage,
327
328
  menuMaxHeight = 240,
328
- menuOffset = 6,
329
+ menuOffset,
329
330
  matchTriggerWidth = true,
330
331
  closeOnBackdropPress = true,
331
332
  modes: propModes = EMPTY_MODES,
@@ -422,11 +423,30 @@ function DropdownInput({
422
423
  const tokens = useFormFieldTokens(modes)
423
424
  const chevron = useChevronTokens(modes)
424
425
 
426
+ // Gap between the input and the popup. Falls back to the `formField/gap`
427
+ // token so the menu's offset matches the field's own internal spacing.
428
+ const effectiveMenuOffset = menuOffset ?? tokens.gap
429
+
425
430
  // ---------------- Layout / measurement ----------------
426
431
  const triggerRef = useRef<View>(null)
427
432
  const [triggerRect, setTriggerRect] = useState<Rect | null>(null)
428
433
  const insets = useSafeAreaInsets()
429
434
 
435
+ // Android coordinate-space bridge.
436
+ //
437
+ // The popup lives inside a `statusBarTranslucent` Modal, whose window is
438
+ // laid out from the PHYSICAL top of the screen (behind the status bar).
439
+ // The trigger, however, is rendered inside the app's content area (Expo
440
+ // Router / react-native-screens under edge-to-edge), so its
441
+ // `measureInWindow` Y is relative to the content area — it does NOT include
442
+ // the status bar height. Feeding that Y straight into the Modal would place
443
+ // the popup one status-bar-height too high, landing it on top of the input.
444
+ //
445
+ // Adding `insets.top` converts the trigger's content-relative Y into the
446
+ // Modal's full-screen coordinate space. iOS/web share a single coordinate
447
+ // space for the Modal and the trigger, so no shift is needed there.
448
+ const windowTopOffset = Platform.OS === 'android' ? insets.top : 0
449
+
430
450
  const measure = useCallback(() => {
431
451
  if (!triggerRef.current) return
432
452
  triggerRef.current.measureInWindow((x, y, width, height) => {
@@ -503,7 +523,7 @@ function DropdownInput({
503
523
  menuSize?.height ?? menuMaxHeight,
504
524
  menuMaxHeight
505
525
  )
506
- const needed = desiredHeight + menuOffset + 8
526
+ const needed = desiredHeight + effectiveMenuOffset + 8
507
527
  if (placement === 'top') {
508
528
  return spaceAbove >= needed || spaceAbove >= spaceBelow
509
529
  ? 'top'
@@ -523,7 +543,7 @@ function DropdownInput({
523
543
  windowHeight,
524
544
  menuSize?.height,
525
545
  menuMaxHeight,
526
- menuOffset,
546
+ effectiveMenuOffset,
527
547
  insets.top,
528
548
  insets.bottom,
529
549
  ])
@@ -544,15 +564,18 @@ function DropdownInput({
544
564
  if (leftPos > maxLeft) leftPos = maxLeft
545
565
  if (leftPos < minLeft) leftPos = minLeft
546
566
 
567
+ // Trigger top expressed in the Modal's (full-screen) coordinate space.
568
+ const triggerTop = triggerRect.y + windowTopOffset
569
+
547
570
  let topPos: number
548
571
  if (computedPlacement === 'top') {
549
572
  const desiredHeight = menuSize?.height ?? menuMaxHeight
550
- topPos = triggerRect.y - desiredHeight - menuOffset
573
+ topPos = triggerTop - desiredHeight - effectiveMenuOffset
551
574
  if (topPos < insets.top + screenPadding) {
552
575
  topPos = insets.top + screenPadding
553
576
  }
554
577
  } else {
555
- topPos = triggerRect.y + triggerRect.height + menuOffset
578
+ topPos = triggerTop + triggerRect.height + effectiveMenuOffset
556
579
  }
557
580
 
558
581
  const style: ViewStyle = {
@@ -569,7 +592,8 @@ function DropdownInput({
569
592
  triggerRect,
570
593
  computedPlacement,
571
594
  menuSize,
572
- menuOffset,
595
+ effectiveMenuOffset,
596
+ windowTopOffset,
573
597
  menuMaxHeight,
574
598
  matchTriggerWidth,
575
599
  windowWidth,
@@ -779,22 +803,32 @@ function DropdownInput({
779
803
  )}
780
804
 
781
805
  {/*
782
- IMPORTANT: do NOT pass `statusBarTranslucent` to this Modal.
783
- On Android, a `statusBarTranslucent` Modal opens its own window
784
- that spans the entire screen (origin at screen-top, including
785
- the status bar), but `measureInWindow` on the trigger returns
786
- coordinates relative to the *activity* window — which on a
787
- default Android setup starts BELOW the status bar. The two
788
- coordinate spaces then differ by `StatusBar.currentHeight`, so
789
- `triggerRect.y + triggerRect.height + menuOffset` lands roughly
790
- one status-bar-height ABOVE the visible input, making the
791
- popup overlap the input row. Leaving `statusBarTranslucent`
792
- off keeps the Modal's window aligned with the activity
793
- window, which is what every measurement here assumes.
806
+ IMPORTANT: this Modal MUST be `statusBarTranslucent` (and
807
+ `navigationBarTranslucent`) on Android.
808
+
809
+ The app runs edge-to-edge (Expo SDK 54+ / Android 15 enforce it
810
+ and it cannot be disabled). That means the activity window spans
811
+ the entire physical screen, so `measureInWindow` on the trigger
812
+ returns a `y` measured from the very TOP of the screen — the
813
+ status bar height is INCLUDED.
814
+
815
+ A non-translucent Modal, however, opens a window whose content
816
+ area starts BELOW the status bar, so `top: 0` inside it maps to
817
+ screen-Y = statusBarHeight. Every `top` we compute is then
818
+ shifted UP by one status-bar-height relative to the trigger,
819
+ which (because the input row height is roughly a status bar tall)
820
+ drops the popup right on top of the input.
821
+
822
+ Making the Modal translucent gives it a full-screen window whose
823
+ origin matches the edge-to-edge activity window, so the
824
+ `measureInWindow` coordinates and the popup's absolute `top`/
825
+ `left` finally live in the same coordinate space.
794
826
  */}
795
827
  <Modal
796
828
  visible={isOpen}
797
829
  transparent
830
+ statusBarTranslucent
831
+ navigationBarTranslucent
798
832
  animationType="fade"
799
833
  onRequestClose={closeMenu}
800
834
  >