jfs-components 0.0.79 → 0.0.85

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 (138) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/lib/commonjs/components/AppBar/AppBar.js +70 -6
  3. package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
  4. package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
  5. package/lib/commonjs/components/Attached/Attached.js +76 -7
  6. package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
  7. package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
  8. package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
  9. package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
  10. package/lib/commonjs/components/Drawer/Drawer.js +6 -1
  11. package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
  12. package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  13. package/lib/commonjs/components/FormField/FormField.js +1 -14
  14. package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
  15. package/lib/commonjs/components/ListItem/ListItem.js +6 -11
  16. package/lib/commonjs/components/MessageField/MessageField.js +1 -13
  17. package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
  18. package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
  19. package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
  20. package/lib/commonjs/components/Spinner/Spinner.js +217 -0
  21. package/lib/commonjs/components/TextInput/TextInput.js +33 -18
  22. package/lib/commonjs/components/index.js +34 -0
  23. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  24. package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
  25. package/lib/commonjs/icons/components/IconArrowup.js +19 -0
  26. package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
  27. package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
  28. package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
  29. package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
  30. package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
  31. package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
  32. package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
  33. package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
  34. package/lib/commonjs/icons/components/IconSignin.js +19 -0
  35. package/lib/commonjs/icons/components/IconSignout.js +19 -0
  36. package/lib/commonjs/icons/components/index.js +132 -0
  37. package/lib/commonjs/icons/registry.js +2 -2
  38. package/lib/module/components/AppBar/AppBar.js +70 -6
  39. package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
  40. package/lib/module/components/AreaLineChart/chartMath.js +242 -0
  41. package/lib/module/components/Attached/Attached.js +76 -7
  42. package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
  43. package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
  44. package/lib/module/components/Checkbox/Checkbox.js +18 -2
  45. package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
  46. package/lib/module/components/Drawer/Drawer.js +6 -1
  47. package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
  48. package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
  49. package/lib/module/components/FormField/FormField.js +3 -16
  50. package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
  51. package/lib/module/components/ListItem/ListItem.js +6 -11
  52. package/lib/module/components/MessageField/MessageField.js +3 -15
  53. package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
  54. package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
  55. package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
  56. package/lib/module/components/Spinner/Spinner.js +212 -0
  57. package/lib/module/components/TextInput/TextInput.js +34 -19
  58. package/lib/module/components/index.js +4 -0
  59. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  60. package/lib/module/icons/components/IconArrowdown.js +12 -0
  61. package/lib/module/icons/components/IconArrowup.js +12 -0
  62. package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
  63. package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
  64. package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
  65. package/lib/module/icons/components/IconChevronupcircle.js +12 -0
  66. package/lib/module/icons/components/IconOsnavback.js +12 -0
  67. package/lib/module/icons/components/IconOsnavcenter.js +12 -0
  68. package/lib/module/icons/components/IconOsnavhome.js +12 -0
  69. package/lib/module/icons/components/IconOsnavtask.js +12 -0
  70. package/lib/module/icons/components/IconSignin.js +12 -0
  71. package/lib/module/icons/components/IconSignout.js +12 -0
  72. package/lib/module/icons/components/index.js +12 -0
  73. package/lib/module/icons/registry.js +2 -2
  74. package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
  75. package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
  76. package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
  77. package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
  78. package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
  79. package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
  80. package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
  81. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
  82. package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
  83. package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
  84. package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
  85. package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
  86. package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
  87. package/lib/typescript/src/components/index.d.ts +4 -0
  88. package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
  89. package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
  90. package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
  91. package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
  92. package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
  93. package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
  94. package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
  95. package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
  96. package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
  97. package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
  98. package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
  99. package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
  100. package/lib/typescript/src/icons/components/index.d.ts +12 -0
  101. package/lib/typescript/src/icons/registry.d.ts +1 -1
  102. package/package.json +3 -2
  103. package/src/components/AppBar/AppBar.tsx +92 -12
  104. package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
  105. package/src/components/AreaLineChart/chartMath.ts +265 -0
  106. package/src/components/Attached/Attached.tsx +94 -7
  107. package/src/components/BubbleChart/BubbleChart.tsx +319 -0
  108. package/src/components/BubbleChart/bubblePacking.ts +397 -0
  109. package/src/components/Checkbox/Checkbox.tsx +14 -2
  110. package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
  111. package/src/components/Drawer/Drawer.tsx +4 -0
  112. package/src/components/DropdownInput/DropdownInput.tsx +54 -20
  113. package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
  114. package/src/components/FormField/FormField.tsx +3 -19
  115. package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
  116. package/src/components/ListItem/ListItem.tsx +14 -16
  117. package/src/components/MessageField/MessageField.tsx +3 -18
  118. package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
  119. package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
  120. package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
  121. package/src/components/Spinner/Spinner.tsx +273 -0
  122. package/src/components/TextInput/TextInput.tsx +37 -19
  123. package/src/components/index.ts +4 -0
  124. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  125. package/src/icons/components/IconArrowdown.tsx +11 -0
  126. package/src/icons/components/IconArrowup.tsx +11 -0
  127. package/src/icons/components/IconChevrondowncircle.tsx +11 -0
  128. package/src/icons/components/IconChevronleftcircle.tsx +11 -0
  129. package/src/icons/components/IconChevronrightcircle.tsx +11 -0
  130. package/src/icons/components/IconChevronupcircle.tsx +11 -0
  131. package/src/icons/components/IconOsnavback.tsx +11 -0
  132. package/src/icons/components/IconOsnavcenter.tsx +11 -0
  133. package/src/icons/components/IconOsnavhome.tsx +11 -0
  134. package/src/icons/components/IconOsnavtask.tsx +11 -0
  135. package/src/icons/components/IconSignin.tsx +11 -0
  136. package/src/icons/components/IconSignout.tsx +11 -0
  137. package/src/icons/components/index.ts +12 -0
  138. package/src/icons/registry.ts +49 -1
@@ -0,0 +1,359 @@
1
+ import React, { useMemo, useState } from 'react'
2
+ import {
3
+ Pressable,
4
+ StyleSheet,
5
+ Text,
6
+ View,
7
+ type LayoutChangeEvent,
8
+ type StyleProp,
9
+ type TextStyle,
10
+ type ViewStyle,
11
+ } from 'react-native'
12
+ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
13
+ import { useTokens } from '../../design-tokens/JFSThemeProvider'
14
+ import { EMPTY_MODES } from '../../utils/react-utils'
15
+
16
+ /** Where the value/label text sits relative to the circle. */
17
+ export type ClusterBubbleLabelPlacement = 'inside' | 'outside' | 'auto'
18
+
19
+ /** Which side of the circle an *outside* label is anchored to. */
20
+ export type ClusterBubbleLabelDirection = 'top' | 'bottom' | 'left' | 'right'
21
+
22
+ export type ClusterBubbleProps = {
23
+ /**
24
+ * The bold, primary content rendered in/under the bubble — e.g. `"40%"`,
25
+ * `"₹270K"`. Strings are auto-wrapped in a `<Text>`; pass a node for full
26
+ * control (e.g. a `MoneyValue`).
27
+ */
28
+ value?: React.ReactNode
29
+ /** The secondary caption shown beside the value — e.g. `"Recommended"`. */
30
+ label?: React.ReactNode
31
+ /** Diameter of the circle in px. Defaults to `120`. */
32
+ size?: number
33
+ /**
34
+ * `Appearance / DataViz` mode used to resolve the fill from the
35
+ * `dataViz/bg` token (e.g. `Primary`, `Secondary`, `Tertiary`).
36
+ * Defaults to `Primary`. The *emphasis* of the fill is taken from the
37
+ * `Emphasis / DataViz` mode in `modes`.
38
+ */
39
+ appearance?: string
40
+ /** Hard-override the circle fill color (bypasses token resolution). */
41
+ color?: string
42
+ /**
43
+ * Where the text sits. `inside` centers it within the circle, `outside`
44
+ * anchors it just beyond the circle's edge, and `auto` (default) picks
45
+ * `inside` when the bubble is at least `autoInsideMinSize` px, otherwise
46
+ * `outside`.
47
+ */
48
+ labelPlacement?: ClusterBubbleLabelPlacement
49
+ /**
50
+ * Which side an *outside* label is placed on. The label is positioned
51
+ * exactly `labelGap` px beyond the circle's radius in this direction.
52
+ * Defaults to `bottom`.
53
+ */
54
+ labelDirection?: ClusterBubbleLabelDirection
55
+ /** Gap in px between the circle's edge and an outside label. Defaults to `8`. */
56
+ labelGap?: number
57
+ /** Diameter (px) at/above which `auto` places the text inside. Defaults to `88`. */
58
+ autoInsideMinSize?: number
59
+ /**
60
+ * Text color when the label sits *inside*. Defaults to an automatic
61
+ * black/white choice based on the fill's luminance for legibility.
62
+ */
63
+ insideTextColor?: string
64
+ /** Press handler — wraps the bubble in a `Pressable` when provided. */
65
+ onPress?: () => void
66
+ /** Style override for the value text. */
67
+ valueStyle?: StyleProp<TextStyle>
68
+ /** Style override for the label text. */
69
+ labelStyle?: StyleProp<TextStyle>
70
+ /** Style override for the circle view. */
71
+ circleStyle?: StyleProp<ViewStyle>
72
+ /** Style override for the outer container. */
73
+ style?: StyleProp<ViewStyle>
74
+ /** Design token modes for theming (e.g. `{ 'Color Mode': 'Light' }`). */
75
+ modes?: Record<string, any>
76
+ /** Accessibility label. Defaults to a `value + label` composite. */
77
+ accessibilityLabel?: string
78
+ }
79
+
80
+ const DEFAULT_FILL = '#5d00b5'
81
+
82
+ /** Parse `#rgb`, `#rrggbb`, `rgb()` / `rgba()` into 0–255 channels. */
83
+ function parseColor(input: string): { r: number; g: number; b: number } | null {
84
+ if (typeof input !== 'string') return null
85
+ const value = input.trim()
86
+
87
+ if (value[0] === '#') {
88
+ let hex = value.slice(1)
89
+ if (hex.length === 3) {
90
+ hex = hex
91
+ .split('')
92
+ .map((ch) => ch + ch)
93
+ .join('')
94
+ }
95
+ if (hex.length >= 6) {
96
+ const r = parseInt(hex.slice(0, 2), 16)
97
+ const g = parseInt(hex.slice(2, 4), 16)
98
+ const b = parseInt(hex.slice(4, 6), 16)
99
+ if ([r, g, b].every((n) => Number.isFinite(n))) return { r, g, b }
100
+ }
101
+ return null
102
+ }
103
+
104
+ const match = value.match(/rgba?\(([^)]+)\)/i)
105
+ if (match) {
106
+ const parts = match[1].split(',').map((p) => parseFloat(p))
107
+ if (parts.length >= 3 && parts.slice(0, 3).every((n) => Number.isFinite(n))) {
108
+ return { r: parts[0], g: parts[1], b: parts[2] }
109
+ }
110
+ }
111
+ return null
112
+ }
113
+
114
+ /** Pick a legible foreground (near-black or white) for a given background. */
115
+ function readableTextColor(background: string): string {
116
+ const rgb = parseColor(background)
117
+ if (!rgb) return '#ffffff'
118
+ // Perceived luminance (ITU-R BT.601).
119
+ const luminance = (0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b) / 255
120
+ return luminance > 0.6 ? '#0f0d0a' : '#ffffff'
121
+ }
122
+
123
+ /**
124
+ * `ClusterBubble` is the atomic circle that composes a `BubbleChart`. It renders
125
+ * a single token-colored disc with a bold `value` and a secondary `label`. The
126
+ * text can sit inside the circle or anchor just outside its edge on any side
127
+ * (`labelDirection`) at a precise `labelGap` distance — so consumers (or the
128
+ * chart) can steer labels toward free space. The inside text color adapts to
129
+ * the fill for legibility. It is fully usable standalone.
130
+ *
131
+ * @component
132
+ */
133
+ function ClusterBubble({
134
+ value,
135
+ label,
136
+ size = 120,
137
+ appearance = 'Primary',
138
+ color,
139
+ labelPlacement = 'auto',
140
+ labelDirection = 'bottom',
141
+ labelGap = 8,
142
+ autoInsideMinSize = 88,
143
+ insideTextColor,
144
+ onPress,
145
+ valueStyle,
146
+ labelStyle,
147
+ circleStyle,
148
+ style,
149
+ modes: propModes = EMPTY_MODES,
150
+ accessibilityLabel,
151
+ }: ClusterBubbleProps) {
152
+ const { modes: globalModes } = useTokens()
153
+ const modes = useMemo(() => ({ ...globalModes, ...propModes }), [globalModes, propModes])
154
+
155
+ // Emphasis is read from the `Emphasis / DataViz` mode (defaulting to the
156
+ // token's own default) rather than a dedicated prop.
157
+ const fill = useMemo(() => {
158
+ if (color) return color
159
+ return (
160
+ (getVariableByName('dataViz/bg', {
161
+ ...modes,
162
+ 'Appearance / DataViz': appearance,
163
+ }) as string | null) ?? DEFAULT_FILL
164
+ )
165
+ }, [color, modes, appearance])
166
+
167
+ const fontFamily =
168
+ (getVariableByName('text/fontFamily', modes) as string | null) ?? 'JioType'
169
+ const outsideTextColor =
170
+ (getVariableByName('text/foreground', modes) as string | null) ?? '#0f0d0a'
171
+
172
+ const placement: 'inside' | 'outside' =
173
+ labelPlacement === 'auto'
174
+ ? size >= autoInsideMinSize
175
+ ? 'inside'
176
+ : 'outside'
177
+ : labelPlacement
178
+
179
+ // Measure the outside label so it can be anchored precisely on any side
180
+ // without guessing its dimensions.
181
+ const [labelSize, setLabelSize] = useState<{ w: number; h: number } | null>(null)
182
+ const handleLabelLayout = (e: LayoutChangeEvent) => {
183
+ const { width, height } = e.nativeEvent.layout
184
+ setLabelSize((prev) =>
185
+ prev && Math.abs(prev.w - width) < 0.5 && Math.abs(prev.h - height) < 0.5
186
+ ? prev
187
+ : { w: width, h: height }
188
+ )
189
+ }
190
+
191
+ // Default typography scales with the bubble when inside (so it fits the
192
+ // disc); fixed comfortable sizes when anchored outside.
193
+ const valueFontSize =
194
+ placement === 'inside' ? Math.round(Math.min(48, Math.max(13, size * 0.17))) : 24
195
+ const labelFontSize =
196
+ placement === 'inside' ? Math.round(Math.min(18, Math.max(10, size * 0.085))) : 14
197
+
198
+ const textColor =
199
+ placement === 'inside' ? insideTextColor ?? readableTextColor(fill) : outsideTextColor
200
+
201
+ const renderText = (
202
+ node: React.ReactNode,
203
+ baseStyle: TextStyle,
204
+ override: StyleProp<TextStyle>
205
+ ) => {
206
+ if (node === undefined || node === null || node === false) return null
207
+ if (typeof node === 'string' || typeof node === 'number') {
208
+ return (
209
+ <Text style={[baseStyle, override]} numberOfLines={2}>
210
+ {node}
211
+ </Text>
212
+ )
213
+ }
214
+ return node
215
+ }
216
+
217
+ const valueNode = renderText(
218
+ value,
219
+ {
220
+ color: textColor,
221
+ fontFamily,
222
+ fontSize: valueFontSize,
223
+ lineHeight: Math.round(valueFontSize * 1.15),
224
+ fontWeight: '700',
225
+ textAlign: 'center',
226
+ letterSpacing: -0.5,
227
+ },
228
+ valueStyle
229
+ )
230
+
231
+ const labelNode = renderText(
232
+ label,
233
+ {
234
+ color: textColor,
235
+ fontFamily,
236
+ fontSize: labelFontSize,
237
+ lineHeight: Math.round(labelFontSize * 1.3),
238
+ fontWeight: '400',
239
+ textAlign: 'center',
240
+ letterSpacing: -0.2,
241
+ },
242
+ labelStyle
243
+ )
244
+
245
+ const hasText = !!valueNode || !!labelNode
246
+
247
+ const textBlock = hasText ? (
248
+ <View style={styles.textBlock}>
249
+ {valueNode}
250
+ {labelNode}
251
+ </View>
252
+ ) : null
253
+
254
+ const derivedA11y = [value, label]
255
+ .filter((v) => typeof v === 'string' || typeof v === 'number')
256
+ .join(', ')
257
+ const a11yLabel = accessibilityLabel ?? (derivedA11y || undefined)
258
+
259
+ const circle = (
260
+ <View
261
+ style={[
262
+ styles.circle,
263
+ { width: size, height: size, borderRadius: size / 2, backgroundColor: fill },
264
+ circleStyle,
265
+ ]}
266
+ >
267
+ {placement === 'inside' ? textBlock : null}
268
+ </View>
269
+ )
270
+
271
+ let content: React.ReactNode
272
+ if (placement === 'inside' || !textBlock) {
273
+ content = <View style={[styles.inlineContainer, style]}>{circle}</View>
274
+ } else {
275
+ // Anchor the label exactly `labelGap` beyond the radius on the chosen
276
+ // side. Hidden until measured to avoid a positioning flash.
277
+ const offset = labelOffset(labelDirection, size, labelGap, labelSize)
278
+ content = (
279
+ <View style={[{ width: size, height: size }, style]}>
280
+ {circle}
281
+ <View
282
+ onLayout={handleLabelLayout}
283
+ style={[
284
+ styles.outsideLabel,
285
+ { left: offset.left, top: offset.top, opacity: labelSize ? 1 : 0 },
286
+ ]}
287
+ pointerEvents="none"
288
+ >
289
+ {textBlock}
290
+ </View>
291
+ </View>
292
+ )
293
+ }
294
+
295
+ if (onPress) {
296
+ return (
297
+ <Pressable
298
+ onPress={onPress}
299
+ accessibilityRole="button"
300
+ accessibilityLabel={a11yLabel}
301
+ style={({ pressed }) => (pressed ? styles.pressed : undefined)}
302
+ >
303
+ {content}
304
+ </Pressable>
305
+ )
306
+ }
307
+
308
+ return (
309
+ <View accessibilityRole="image" accessibilityLabel={a11yLabel}>
310
+ {content}
311
+ </View>
312
+ )
313
+ }
314
+
315
+ /** Compute the absolute `left/top` of the outside label box for a direction. */
316
+ function labelOffset(
317
+ direction: ClusterBubbleLabelDirection,
318
+ size: number,
319
+ gap: number,
320
+ labelSize: { w: number; h: number } | null
321
+ ): { left: number; top: number } {
322
+ const center = size / 2
323
+ const w = labelSize?.w ?? 0
324
+ const h = labelSize?.h ?? 0
325
+ switch (direction) {
326
+ case 'top':
327
+ return { left: center - w / 2, top: -(gap + h) }
328
+ case 'bottom':
329
+ return { left: center - w / 2, top: size + gap }
330
+ case 'left':
331
+ return { left: -(gap + w), top: center - h / 2 }
332
+ case 'right':
333
+ return { left: size + gap, top: center - h / 2 }
334
+ }
335
+ }
336
+
337
+ const styles = StyleSheet.create({
338
+ inlineContainer: {
339
+ alignItems: 'center',
340
+ },
341
+ circle: {
342
+ alignItems: 'center',
343
+ justifyContent: 'center',
344
+ overflow: 'hidden',
345
+ },
346
+ textBlock: {
347
+ alignItems: 'center',
348
+ justifyContent: 'center',
349
+ paddingHorizontal: 8,
350
+ },
351
+ outsideLabel: {
352
+ position: 'absolute',
353
+ },
354
+ pressed: {
355
+ opacity: 0.85,
356
+ },
357
+ })
358
+
359
+ export default ClusterBubble
@@ -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
  >
@@ -129,10 +129,6 @@ function ExpandableCheckbox({
129
129
 
130
130
  const rowGap =
131
131
  (getVariableByName('checkboxItem/gap', modes) as number | null) ?? 8
132
- const rowPaddingHorizontal =
133
- (getVariableByName('checkboxItem/padding/horizontal', modes) as number | null) ?? 0
134
- const rowPaddingVertical =
135
- (getVariableByName('checkboxItem/padding/vertical', modes) as number | null) ?? 0
136
132
 
137
133
  const labelColor =
138
134
  (getVariableByName('checkboxItem/foreground', modes) as string | null) ?? '#1a1c1f'
@@ -163,12 +159,10 @@ function ExpandableCheckbox({
163
159
  alignSelf: isExpanded ? 'stretch' : 'auto',
164
160
  minWidth: 0,
165
161
  flexDirection: 'row',
166
- alignItems: 'flex-start',
162
+ alignItems: isExpanded ? 'flex-start' : 'center',
167
163
  gap: rowGap,
168
- paddingHorizontal: rowPaddingHorizontal,
169
- paddingVertical: rowPaddingVertical,
170
164
  }),
171
- [isExpanded, rowGap, rowPaddingHorizontal, rowPaddingVertical]
165
+ [isExpanded, rowGap]
172
166
  )
173
167
 
174
168
  const resolvedLabelStyle: TextStyle = useMemo(
@@ -180,6 +174,13 @@ function ExpandableCheckbox({
180
174
  fontSize: labelFontSize,
181
175
  lineHeight: labelLineHeight,
182
176
  fontWeight: labelFontWeight,
177
+ // Android adds asymmetric font padding and top-aligns the glyph inside
178
+ // an inflated line box when `lineHeight` is set. That makes the centered
179
+ // checkbox look like it drops below the text. Disabling the extra
180
+ // padding + centering the glyph keeps the single-line label optically
181
+ // aligned with the checkbox. No-op on iOS / web.
182
+ includeFontPadding: false,
183
+ textAlignVertical: isExpanded ? 'top' : 'center',
183
184
  }),
184
185
  [
185
186
  labelColor,
@@ -187,11 +188,14 @@ function ExpandableCheckbox({
187
188
  labelFontSize,
188
189
  labelLineHeight,
189
190
  labelFontWeight,
191
+ isExpanded,
190
192
  ]
191
193
  )
192
194
 
195
+ // Layer component modes first (e.g. Color Mode), then button defaults so
196
+ // Secondary / XS / Low always win unless a dedicated override prop is added.
193
197
  const buttonModes = useMemo(
194
- () => ({ ...BUTTON_DEFAULT_MODES, ...modes }),
198
+ () => ({ ...modes, ...BUTTON_DEFAULT_MODES }),
195
199
  [modes]
196
200
  )
197
201
 
@@ -1,8 +1,7 @@
1
- import React, { useCallback, useMemo, useRef, useState } from 'react'
1
+ import React, { useCallback, useMemo, useState } from 'react'
2
2
  import {
3
3
  View,
4
4
  Text,
5
- Pressable,
6
5
  TextInput as RNTextInput,
7
6
  type StyleProp,
8
7
  type TextInputProps as RNTextInputProps,
@@ -347,16 +346,6 @@ function FormField({
347
346
  const [isFocused, setIsFocused] = useState(false)
348
347
  const interactive = !isDisabled && !isReadOnly
349
348
 
350
- // Ref to the native input so tapping anywhere in the input row (padding,
351
- // leading/trailing gutters) focuses it on the FIRST tap — fixing the Android
352
- // "two taps to open the keyboard" issue caused by the row intercepting the
353
- // initial touch.
354
- const inputRef = useRef<RNTextInput>(null)
355
- const focusInput = useCallback(() => {
356
- if (!interactive) return
357
- inputRef.current?.focus()
358
- }, [interactive])
359
-
360
349
  // FormField States cascade — error > read only/disabled > active (focused) > idle.
361
350
  // Disabled maps to "Read Only" since there is no dedicated disabled mode and
362
351
  // the visual treatment is closest. This is only the DEFAULT — an explicit
@@ -552,11 +541,7 @@ function FormField({
552
541
  </View>
553
542
  )}
554
543
 
555
- <Pressable
556
- style={[inputRowStyle, inputStyle]}
557
- onPress={focusInput}
558
- accessible={false}
559
- >
544
+ <View style={[inputRowStyle, inputStyle]}>
560
545
  {processedLeading != null && (
561
546
  <View
562
547
  accessibilityElementsHidden
@@ -566,7 +551,6 @@ function FormField({
566
551
  </View>
567
552
  )}
568
553
  <RNTextInput
569
- ref={inputRef}
570
554
  style={[inputTextStyles, inputTextStyle]}
571
555
  value={value ?? ''}
572
556
  onChangeText={handleChangeText}
@@ -594,7 +578,7 @@ function FormField({
594
578
  {processedTrailing}
595
579
  </View>
596
580
  )}
597
- </Pressable>
581
+ </View>
598
582
 
599
583
  {supportLabel != null && supportLabel !== '' && (
600
584
  <SupportText
@@ -374,6 +374,9 @@ function FullscreenModal({
374
374
  showsVerticalScrollIndicator={false}
375
375
  onScroll={onScroll}
376
376
  scrollEventThrottle={16}
377
+ // Tap an input in the body and it focuses on the FIRST tap, even when
378
+ // the keyboard is already open (default 'never' eats that tap).
379
+ keyboardShouldPersistTaps="handled"
377
380
  >
378
381
  <View style={heroTextRegionStyle}>
379
382
  <HeroText