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.
- package/CHANGELOG.md +11 -0
- package/lib/commonjs/components/AppBar/AppBar.js +56 -6
- package/lib/commonjs/components/Attached/Attached.js +183 -0
- package/lib/commonjs/components/Card/Card.js +25 -2
- package/lib/commonjs/components/Checkbox/Checkbox.js +18 -2
- 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 +9 -7
- package/lib/commonjs/components/ListItem/ListItem.js +26 -24
- package/lib/commonjs/components/MessageField/MessageField.js +1 -13
- package/lib/commonjs/components/PaymentFeedback/PaymentFeedback.js +12 -9
- package/lib/commonjs/components/PlanComparisonCard/PlanComparisonCard.js +237 -0
- package/lib/commonjs/components/Slot/Slot.js +73 -0
- package/lib/commonjs/components/Spinner/Spinner.js +217 -0
- package/lib/commonjs/components/TextInput/TextInput.js +33 -18
- package/lib/commonjs/components/index.js +28 -0
- 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 +56 -6
- package/lib/module/components/Attached/Attached.js +178 -0
- package/lib/module/components/Card/Card.js +25 -2
- package/lib/module/components/Checkbox/Checkbox.js +18 -2
- 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 +9 -7
- package/lib/module/components/ListItem/ListItem.js +26 -24
- package/lib/module/components/MessageField/MessageField.js +3 -15
- package/lib/module/components/PaymentFeedback/PaymentFeedback.js +13 -9
- package/lib/module/components/PlanComparisonCard/PlanComparisonCard.js +234 -0
- package/lib/module/components/Slot/Slot.js +68 -0
- 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/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/Attached/Attached.d.ts +64 -0
- package/lib/typescript/src/components/Card/Card.d.ts +9 -2
- package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +3 -2
- package/lib/typescript/src/components/ListItem/ListItem.d.ts +16 -6
- package/lib/typescript/src/components/PaymentFeedback/PaymentFeedback.d.ts +5 -1
- package/lib/typescript/src/components/PlanComparisonCard/PlanComparisonCard.d.ts +66 -0
- package/lib/typescript/src/components/Slot/Slot.d.ts +52 -0
- 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 +79 -12
- package/src/components/Attached/Attached.tsx +237 -0
- package/src/components/Card/Card.tsx +28 -1
- package/src/components/Checkbox/Checkbox.tsx +14 -2
- 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 +6 -3
- package/src/components/ListItem/ListItem.tsx +42 -25
- package/src/components/MessageField/MessageField.tsx +3 -18
- package/src/components/PaymentFeedback/PaymentFeedback.tsx +15 -8
- package/src/components/PlanComparisonCard/PlanComparisonCard.tsx +316 -0
- package/src/components/Slot/Slot.tsx +91 -0
- 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/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
|
@@ -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
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
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:
|
|
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
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
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
|
-
{
|
|
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
|
|
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
|
>
|