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.
- package/CHANGELOG.md +29 -0
- package/lib/commonjs/components/AppBar/AppBar.js +70 -6
- package/lib/commonjs/components/AreaLineChart/AreaLineChart.js +866 -0
- package/lib/commonjs/components/AreaLineChart/chartMath.js +252 -0
- package/lib/commonjs/components/Attached/Attached.js +76 -7
- package/lib/commonjs/components/BubbleChart/BubbleChart.js +191 -0
- package/lib/commonjs/components/BubbleChart/bubblePacking.js +378 -0
- package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
- package/lib/commonjs/components/ClusterBubble/ClusterBubble.js +272 -0
- package/lib/commonjs/components/Drawer/Drawer.js +6 -1
- package/lib/commonjs/components/DropdownInput/DropdownInput.js +30 -6
- package/lib/commonjs/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
- package/lib/commonjs/components/FormField/FormField.js +1 -14
- package/lib/commonjs/components/FullscreenModal/FullscreenModal.js +5 -1
- package/lib/commonjs/components/ListItem/ListItem.js +6 -11
- package/lib/commonjs/components/MessageField/MessageField.js +1 -13
- package/lib/commonjs/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +69 -160
- package/lib/commonjs/components/Spinner/Spinner.js +217 -0
- package/lib/commonjs/components/TextInput/TextInput.js +33 -18
- package/lib/commonjs/components/index.js +34 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/components/IconArrowdown.js +19 -0
- package/lib/commonjs/icons/components/IconArrowup.js +19 -0
- package/lib/commonjs/icons/components/IconChevrondowncircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronleftcircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronrightcircle.js +19 -0
- package/lib/commonjs/icons/components/IconChevronupcircle.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavback.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavcenter.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavhome.js +19 -0
- package/lib/commonjs/icons/components/IconOsnavtask.js +19 -0
- package/lib/commonjs/icons/components/IconSignin.js +19 -0
- package/lib/commonjs/icons/components/IconSignout.js +19 -0
- package/lib/commonjs/icons/components/index.js +132 -0
- package/lib/commonjs/icons/registry.js +2 -2
- package/lib/module/components/AppBar/AppBar.js +70 -6
- package/lib/module/components/AreaLineChart/AreaLineChart.js +859 -0
- package/lib/module/components/AreaLineChart/chartMath.js +242 -0
- package/lib/module/components/Attached/Attached.js +76 -7
- package/lib/module/components/BubbleChart/BubbleChart.js +185 -0
- package/lib/module/components/BubbleChart/bubblePacking.js +370 -0
- package/lib/module/components/Checkbox/Checkbox.js +18 -2
- package/lib/module/components/ClusterBubble/ClusterBubble.js +267 -0
- package/lib/module/components/Drawer/Drawer.js +6 -1
- package/lib/module/components/DropdownInput/DropdownInput.js +30 -6
- package/lib/module/components/ExpandableCheckbox/ExpandableCheckbox.js +17 -11
- package/lib/module/components/FormField/FormField.js +3 -16
- package/lib/module/components/FullscreenModal/FullscreenModal.js +5 -1
- package/lib/module/components/ListItem/ListItem.js +6 -11
- package/lib/module/components/MessageField/MessageField.js +3 -15
- package/lib/module/components/MetricLegendItem/MetricLegendItem.js +7 -1
- package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +72 -160
- package/lib/module/components/Spinner/Spinner.js +212 -0
- package/lib/module/components/TextInput/TextInput.js +34 -19
- package/lib/module/components/index.js +4 -0
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/components/IconArrowdown.js +12 -0
- package/lib/module/icons/components/IconArrowup.js +12 -0
- package/lib/module/icons/components/IconChevrondowncircle.js +12 -0
- package/lib/module/icons/components/IconChevronleftcircle.js +12 -0
- package/lib/module/icons/components/IconChevronrightcircle.js +12 -0
- package/lib/module/icons/components/IconChevronupcircle.js +12 -0
- package/lib/module/icons/components/IconOsnavback.js +12 -0
- package/lib/module/icons/components/IconOsnavcenter.js +12 -0
- package/lib/module/icons/components/IconOsnavhome.js +12 -0
- package/lib/module/icons/components/IconOsnavtask.js +12 -0
- package/lib/module/icons/components/IconSignin.js +12 -0
- package/lib/module/icons/components/IconSignout.js +12 -0
- package/lib/module/icons/components/index.js +12 -0
- package/lib/module/icons/registry.js +2 -2
- package/lib/typescript/src/components/AppBar/AppBar.d.ts +12 -1
- package/lib/typescript/src/components/AreaLineChart/AreaLineChart.d.ts +212 -0
- package/lib/typescript/src/components/AreaLineChart/chartMath.d.ts +90 -0
- package/lib/typescript/src/components/Attached/Attached.d.ts +19 -16
- package/lib/typescript/src/components/BubbleChart/BubbleChart.d.ts +81 -0
- package/lib/typescript/src/components/BubbleChart/bubblePacking.d.ts +83 -0
- package/lib/typescript/src/components/ClusterBubble/ClusterBubble.d.ts +76 -0
- package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
- package/lib/typescript/src/components/ListItem/ListItem.d.ts +3 -3
- package/lib/typescript/src/components/MetricLegendItem/MetricLegendItem.d.ts +7 -1
- package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +10 -8
- package/lib/typescript/src/components/Spinner/Spinner.d.ts +45 -0
- package/lib/typescript/src/components/index.d.ts +4 -0
- package/lib/typescript/src/icons/components/IconArrowdown.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconArrowup.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevrondowncircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronleftcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronrightcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconChevronupcircle.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavback.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavcenter.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavhome.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconOsnavtask.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconSignin.d.ts +3 -0
- package/lib/typescript/src/icons/components/IconSignout.d.ts +3 -0
- package/lib/typescript/src/icons/components/index.d.ts +12 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +3 -2
- package/src/components/AppBar/AppBar.tsx +92 -12
- package/src/components/AreaLineChart/AreaLineChart.tsx +1161 -0
- package/src/components/AreaLineChart/chartMath.ts +265 -0
- package/src/components/Attached/Attached.tsx +94 -7
- package/src/components/BubbleChart/BubbleChart.tsx +319 -0
- package/src/components/BubbleChart/bubblePacking.ts +397 -0
- package/src/components/Checkbox/Checkbox.tsx +14 -2
- package/src/components/ClusterBubble/ClusterBubble.tsx +359 -0
- package/src/components/Drawer/Drawer.tsx +4 -0
- package/src/components/DropdownInput/DropdownInput.tsx +54 -20
- package/src/components/ExpandableCheckbox/ExpandableCheckbox.tsx +13 -9
- package/src/components/FormField/FormField.tsx +3 -19
- package/src/components/FullscreenModal/FullscreenModal.tsx +3 -0
- package/src/components/ListItem/ListItem.tsx +14 -16
- package/src/components/MessageField/MessageField.tsx +3 -18
- package/src/components/MetricLegendItem/MetricLegendItem.tsx +20 -6
- package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +82 -192
- package/src/components/Spinner/Spinner.tsx +273 -0
- package/src/components/TextInput/TextInput.tsx +37 -19
- package/src/components/index.ts +4 -0
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/components/IconArrowdown.tsx +11 -0
- package/src/icons/components/IconArrowup.tsx +11 -0
- package/src/icons/components/IconChevrondowncircle.tsx +11 -0
- package/src/icons/components/IconChevronleftcircle.tsx +11 -0
- package/src/icons/components/IconChevronrightcircle.tsx +11 -0
- package/src/icons/components/IconChevronupcircle.tsx +11 -0
- package/src/icons/components/IconOsnavback.tsx +11 -0
- package/src/icons/components/IconOsnavcenter.tsx +11 -0
- package/src/icons/components/IconOsnavhome.tsx +11 -0
- package/src/icons/components/IconOsnavtask.tsx +11 -0
- package/src/icons/components/IconSignin.tsx +11 -0
- package/src/icons/components/IconSignout.tsx +11 -0
- package/src/icons/components/index.ts +12 -0
- 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
|
|
121
|
-
*
|
|
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
|
|
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 +
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
|
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
|
-
() => ({ ...
|
|
198
|
+
() => ({ ...modes, ...BUTTON_DEFAULT_MODES }),
|
|
195
199
|
[modes]
|
|
196
200
|
)
|
|
197
201
|
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import React, { useCallback, useMemo,
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|