jfs-components 0.0.64 → 0.0.66
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 +8 -0
- package/lib/commonjs/components/CardCTA/CardCTA.js +15 -1
- package/lib/commonjs/components/Carousel/Carousel.js +34 -13
- package/lib/commonjs/components/Drawer/Drawer.js +9 -3
- package/lib/commonjs/components/IconButton/IconButton.js +42 -6
- package/lib/commonjs/components/IconCapsule/IconCapsule.js +5 -0
- package/lib/commonjs/components/Popup/Popup.js +2 -2
- package/lib/commonjs/components/Section/Section.js +22 -7
- package/lib/commonjs/components/UpiHandle/UpiHandle.js +19 -7
- package/lib/commonjs/icons/Icon.js +72 -75
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/commonjs/utils/MediaSource.js +181 -0
- package/lib/commonjs/utils/index.js +9 -1
- package/lib/module/components/CardCTA/CardCTA.js +15 -1
- package/lib/module/components/Carousel/Carousel.js +34 -13
- package/lib/module/components/Drawer/Drawer.js +9 -3
- package/lib/module/components/IconButton/IconButton.js +42 -6
- package/lib/module/components/IconCapsule/IconCapsule.js +5 -0
- package/lib/module/components/Popup/Popup.js +2 -2
- package/lib/module/components/Section/Section.js +23 -8
- package/lib/module/components/UpiHandle/UpiHandle.js +20 -8
- package/lib/module/icons/Icon.js +72 -75
- package/lib/module/icons/registry.js +1 -1
- package/lib/module/utils/MediaSource.js +176 -0
- package/lib/module/utils/index.js +2 -1
- package/lib/typescript/src/components/Drawer/Drawer.d.ts +6 -1
- package/lib/typescript/src/components/IconButton/IconButton.d.ts +25 -14
- package/lib/typescript/src/components/IconCapsule/IconCapsule.d.ts +12 -1
- package/lib/typescript/src/components/UpiHandle/UpiHandle.d.ts +17 -3
- package/lib/typescript/src/icons/Icon.d.ts +35 -16
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/lib/typescript/src/utils/MediaSource.d.ts +63 -0
- package/lib/typescript/src/utils/index.d.ts +2 -0
- package/package.json +1 -1
- package/src/components/CardCTA/CardCTA.tsx +13 -0
- package/src/components/Carousel/Carousel.tsx +37 -20
- package/src/components/Drawer/Drawer.tsx +13 -2
- package/src/components/IconButton/IconButton.tsx +70 -11
- package/src/components/IconCapsule/IconCapsule.tsx +13 -0
- package/src/components/Popup/Popup.tsx +2 -2
- package/src/components/Section/Section.tsx +29 -12
- package/src/components/UpiHandle/UpiHandle.tsx +37 -11
- package/src/icons/Icon.tsx +91 -76
- package/src/icons/registry.ts +1 -1
- package/src/utils/MediaSource.tsx +220 -0
- package/src/utils/index.ts +2 -0
|
@@ -79,6 +79,11 @@ type DrawerProps = {
|
|
|
79
79
|
* as a tab bar. Defaults to 80.
|
|
80
80
|
*/
|
|
81
81
|
bottomInset?: number
|
|
82
|
+
/**
|
|
83
|
+
* Called whenever the drawer settles into a new state (collapsed or
|
|
84
|
+
* expanded), so parent components can react programmatically.
|
|
85
|
+
*/
|
|
86
|
+
onStateChange?: (state: 'collapsed' | 'expanded') => void
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
/**
|
|
@@ -102,6 +107,7 @@ function Drawer({
|
|
|
102
107
|
contentContainerStyle,
|
|
103
108
|
showsVerticalScrollIndicator = false,
|
|
104
109
|
bottomInset = 80,
|
|
110
|
+
onStateChange,
|
|
105
111
|
}: DrawerProps) {
|
|
106
112
|
const { height: screenHeight } = useWindowDimensions()
|
|
107
113
|
|
|
@@ -164,8 +170,13 @@ function Drawer({
|
|
|
164
170
|
|
|
165
171
|
// Update JS state for accessibility/logic if needed
|
|
166
172
|
const updateMode = useCallback((newMode: 'collapsed' | 'expanded') => {
|
|
167
|
-
setMode(
|
|
168
|
-
|
|
173
|
+
setMode((prev) => {
|
|
174
|
+
if (prev !== newMode) {
|
|
175
|
+
onStateChange?.(newMode)
|
|
176
|
+
}
|
|
177
|
+
return newMode
|
|
178
|
+
})
|
|
179
|
+
}, [onStateChange])
|
|
169
180
|
|
|
170
181
|
// Gesture policy:
|
|
171
182
|
// • activeOffsetY: require a clear *vertical* drag (10px) before this
|
|
@@ -11,9 +11,19 @@ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
|
11
11
|
import Icon from '../../icons/Icon'
|
|
12
12
|
import { usePressableWebSupport, type SafePressableProps, type WebAccessibilityProps } from '../../utils/web-platform-utils'
|
|
13
13
|
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
14
|
+
import type { UnifiedSource } from '../../utils/MediaSource'
|
|
14
15
|
|
|
15
16
|
type IconButtonProps = SafePressableProps & {
|
|
17
|
+
/** Built-in icon name from the registry (default state). */
|
|
16
18
|
iconName?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Unified fallback source for the default state, used when `iconName` is
|
|
21
|
+
* missing or not in the registry. Accepts a remote URI, an inline SVG XML
|
|
22
|
+
* string, a `require()` asset, an SVG React component, or a React element.
|
|
23
|
+
* The result is tinted with the button's mode-resolved icon color so it
|
|
24
|
+
* follows design tokens just like a built-in icon. See {@link UnifiedSource}.
|
|
25
|
+
*/
|
|
26
|
+
source?: UnifiedSource;
|
|
17
27
|
modes?: Record<string, any>;
|
|
18
28
|
onPress?: () => void;
|
|
19
29
|
disabled?: boolean;
|
|
@@ -29,10 +39,24 @@ type IconButtonProps = SafePressableProps & {
|
|
|
29
39
|
* Icon to display when isToggle is true and isActive is true
|
|
30
40
|
*/
|
|
31
41
|
activeIcon?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Unified fallback source for the active state. Used when `activeIcon` is
|
|
44
|
+
* missing or not in the registry (and only when `isToggle` is true and
|
|
45
|
+
* `isActive` is true). Falls back to the default `source` if not provided.
|
|
46
|
+
* See {@link UnifiedSource}.
|
|
47
|
+
*/
|
|
48
|
+
activeSource?: UnifiedSource;
|
|
32
49
|
/**
|
|
33
50
|
* Icon to display when isToggle is true and isActive is false
|
|
34
51
|
*/
|
|
35
52
|
inactiveIcon?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Unified fallback source for the inactive state. Used when `inactiveIcon`
|
|
55
|
+
* is missing or not in the registry (and only when `isToggle` is true and
|
|
56
|
+
* `isActive` is false). Falls back to the default `source` if not provided.
|
|
57
|
+
* See {@link UnifiedSource}.
|
|
58
|
+
*/
|
|
59
|
+
inactiveSource?: UnifiedSource;
|
|
36
60
|
/**
|
|
37
61
|
* Whether the toggle button is in active state (only used when isToggle is true)
|
|
38
62
|
*/
|
|
@@ -109,8 +133,14 @@ function resolveIconButtonTokens(modes: Record<string, any>, disabled: boolean):
|
|
|
109
133
|
* pressed transform mirrored via React state) — removed.
|
|
110
134
|
* - Wrapped in `React.memo`.
|
|
111
135
|
*/
|
|
136
|
+
// Legacy default icon used when neither a `name` nor a `source` is supplied
|
|
137
|
+
// for the resolved slot. Kept as a constant rather than a destructuring
|
|
138
|
+
// default so source-only call sites don't accidentally render `'ic_card'`.
|
|
139
|
+
const LEGACY_DEFAULT_ICON_NAME = 'ic_card'
|
|
140
|
+
|
|
112
141
|
function IconButton({
|
|
113
|
-
iconName
|
|
142
|
+
iconName,
|
|
143
|
+
source,
|
|
114
144
|
modes = EMPTY_MODES,
|
|
115
145
|
onPress,
|
|
116
146
|
disabled = false,
|
|
@@ -121,7 +151,9 @@ function IconButton({
|
|
|
121
151
|
webAccessibilityProps,
|
|
122
152
|
isToggle = false,
|
|
123
153
|
activeIcon,
|
|
154
|
+
activeSource,
|
|
124
155
|
inactiveIcon,
|
|
156
|
+
inactiveSource,
|
|
125
157
|
isActive = false,
|
|
126
158
|
...rest
|
|
127
159
|
}: IconButtonProps) {
|
|
@@ -160,16 +192,42 @@ function IconButton({
|
|
|
160
192
|
userHandlersRef.current.onHoverIn = (rest as any)?.onHoverIn
|
|
161
193
|
userHandlersRef.current.onHoverOut = (rest as any)?.onHoverOut
|
|
162
194
|
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
195
|
+
// Resolve the active (name + source) pair for the current slot. Toggle
|
|
196
|
+
// mode picks active/inactive based on `isActive`; per-state overrides
|
|
197
|
+
// fall back to the default `iconName` / `source` when omitted. We then
|
|
198
|
+
// apply the legacy default icon only as a last resort, so a source-only
|
|
199
|
+
// call site (`<IconButton source="…" />`) renders the source instead of
|
|
200
|
+
// bleeding through to `'ic_card'`.
|
|
201
|
+
let resolvedIconName: string | undefined
|
|
202
|
+
let resolvedSource: UnifiedSource | undefined
|
|
203
|
+
if (isToggle) {
|
|
204
|
+
if (isActive) {
|
|
205
|
+
resolvedIconName = activeIcon ?? iconName
|
|
206
|
+
resolvedSource = activeSource ?? source
|
|
207
|
+
} else {
|
|
208
|
+
resolvedIconName = inactiveIcon ?? iconName
|
|
209
|
+
resolvedSource = inactiveSource ?? source
|
|
210
|
+
}
|
|
211
|
+
} else {
|
|
212
|
+
resolvedIconName = iconName
|
|
213
|
+
resolvedSource = source
|
|
214
|
+
}
|
|
215
|
+
if (!resolvedIconName && resolvedSource === undefined) {
|
|
216
|
+
resolvedIconName = LEGACY_DEFAULT_ICON_NAME
|
|
217
|
+
}
|
|
170
218
|
|
|
171
|
-
// Generate default accessibility label from icon name
|
|
172
|
-
|
|
219
|
+
// Generate default accessibility label from the resolved icon name when
|
|
220
|
+
// possible. Source-only call sites should provide an explicit
|
|
221
|
+
// `accessibilityLabel`; we fall back to a generic 'Icon button' so we
|
|
222
|
+
// never crash on `iconName.replace(...)` when only a `source` is supplied.
|
|
223
|
+
const defaultAccessibilityLabel =
|
|
224
|
+
accessibilityLabel ||
|
|
225
|
+
(resolvedIconName
|
|
226
|
+
? resolvedIconName
|
|
227
|
+
.replace(/^ic_/, '')
|
|
228
|
+
.replace(/_/g, ' ')
|
|
229
|
+
.replace(/\b\w/g, (l) => l.toUpperCase())
|
|
230
|
+
: 'Icon button')
|
|
173
231
|
|
|
174
232
|
const webProps = usePressableWebSupport({
|
|
175
233
|
restProps: rest,
|
|
@@ -235,7 +293,8 @@ function IconButton({
|
|
|
235
293
|
{...webProps}
|
|
236
294
|
>
|
|
237
295
|
<Icon
|
|
238
|
-
name
|
|
296
|
+
{...(resolvedIconName !== undefined ? { name: resolvedIconName } : {})}
|
|
297
|
+
{...(resolvedSource !== undefined ? { source: resolvedSource } : {})}
|
|
239
298
|
size={tokens.iconSize}
|
|
240
299
|
color={tokens.iconColor}
|
|
241
300
|
accessibilityElementsHidden={true}
|
|
@@ -4,9 +4,19 @@ import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
|
4
4
|
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
5
5
|
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
6
6
|
import Icon from '../../icons/Icon'
|
|
7
|
+
import type { UnifiedSource } from '../../utils/MediaSource'
|
|
7
8
|
|
|
8
9
|
type IconCapsuleProps = {
|
|
9
10
|
iconName?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Unified fallback source rendered when `iconName` is missing or not in the
|
|
13
|
+
* registry. Accepts a remote URI, an inline SVG XML string, a `require()`
|
|
14
|
+
* asset, an SVG React component, or an already-rendered element. The
|
|
15
|
+
* resulting media is tinted with the capsule's mode-resolved icon color so
|
|
16
|
+
* it follows design tokens just like a built-in icon. See
|
|
17
|
+
* {@link UnifiedSource}.
|
|
18
|
+
*/
|
|
19
|
+
source?: UnifiedSource;
|
|
10
20
|
modes?: Record<string, any>;
|
|
11
21
|
accessibilityLabel?: string;
|
|
12
22
|
accessibilityRole?: string;
|
|
@@ -55,6 +65,7 @@ function resolveIconCapsuleTokens(modes: Record<string, any>): IconCapsuleTokens
|
|
|
55
65
|
* @component
|
|
56
66
|
* @param {Object} props - Component props
|
|
57
67
|
* @param {string} [props.iconName="ic_card"] - The name of the icon to display from the icon registry
|
|
68
|
+
* @param {UnifiedSource} [props.source] - Fallback source (remote URI, inline SVG XML, `require()` asset, SVG React component, or React element). Used when `iconName` is missing or unknown. Tinted with the mode-resolved icon color so it follows design tokens just like a built-in icon.
|
|
58
69
|
* @param {Object} [props.modes={}] - Mode configuration for design tokens (e.g., {"Appearance": "Primary"})
|
|
59
70
|
* @param {string} [props.accessibilityLabel] - Accessibility label for screen readers
|
|
60
71
|
* @param {string} [props.accessibilityRole] - Accessibility role (defaults to "image" for decorative icons)
|
|
@@ -68,6 +79,7 @@ function resolveIconCapsuleTokens(modes: Record<string, any>): IconCapsuleTokens
|
|
|
68
79
|
*/
|
|
69
80
|
function IconCapsule({
|
|
70
81
|
iconName = 'ic_card',
|
|
82
|
+
source,
|
|
71
83
|
modes: propModes = EMPTY_MODES,
|
|
72
84
|
// accessibilityLabel is accepted on the type for API back-compat but the
|
|
73
85
|
// component intentionally renders `accessibilityLabel={undefined}` (icons
|
|
@@ -105,6 +117,7 @@ function IconCapsule({
|
|
|
105
117
|
>
|
|
106
118
|
<Icon
|
|
107
119
|
name={iconName}
|
|
120
|
+
{...(source !== undefined ? { source } : {})}
|
|
108
121
|
size={tokens.iconSize}
|
|
109
122
|
color={tokens.iconColor}
|
|
110
123
|
accessibilityElementsHidden={true}
|
|
@@ -162,12 +162,12 @@ const Popup = forwardRef<PopupRef, PopupProps>(function Popup(
|
|
|
162
162
|
<View style={styles.overlay}>
|
|
163
163
|
<Animated.View
|
|
164
164
|
style={[
|
|
165
|
-
StyleSheet.
|
|
165
|
+
StyleSheet.absoluteFill,
|
|
166
166
|
{ backgroundColor: backdropColor, opacity: backdropAnim },
|
|
167
167
|
]}
|
|
168
168
|
>
|
|
169
169
|
<Pressable
|
|
170
|
-
style={StyleSheet.
|
|
170
|
+
style={StyleSheet.absoluteFill}
|
|
171
171
|
onPress={closeOnBackdropPress ? handleClose : undefined}
|
|
172
172
|
accessibilityRole="button"
|
|
173
173
|
accessibilityLabel="Close popup"
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import React, { useState, useMemo, useRef, useCallback } from 'react'
|
|
2
2
|
import { View, Text, Pressable, Platform, type StyleProp, type ViewStyle, type PressableStateCallbackType } from 'react-native'
|
|
3
3
|
import Animated, {
|
|
4
|
+
Easing,
|
|
4
5
|
FadeInUp,
|
|
5
6
|
FadeOutUp,
|
|
6
7
|
ReduceMotion,
|
|
7
8
|
useAnimatedStyle,
|
|
8
9
|
useSharedValue,
|
|
9
|
-
|
|
10
|
+
withTiming,
|
|
10
11
|
} from 'react-native-reanimated'
|
|
11
12
|
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
12
13
|
import NavArrow from '../NavArrow/NavArrow'
|
|
@@ -82,7 +83,14 @@ const SLOT_GRID_MAX_COLUMNS = 4
|
|
|
82
83
|
const SLOT_GRID_STAGGER_CAP = 8
|
|
83
84
|
const SLOT_GRID_ENTER_STAGGER_MS = 35
|
|
84
85
|
const SLOT_GRID_EXIT_STAGGER_MS = 20
|
|
86
|
+
const SLOT_GRID_ENTER_DURATION_MS = 220
|
|
85
87
|
const SLOT_GRID_EXIT_DURATION_MS = 160
|
|
88
|
+
const SLOT_GRID_HEIGHT_DURATION_MS = 280
|
|
89
|
+
|
|
90
|
+
// Standard ease-out cubic curve. Calm, professional, no overshoot — matches
|
|
91
|
+
// system-style transitions. Defined once at module scope so it isn't
|
|
92
|
+
// re-allocated per render.
|
|
93
|
+
const SLOT_GRID_EASING = Easing.out(Easing.cubic)
|
|
86
94
|
|
|
87
95
|
type SlotGridProps = {
|
|
88
96
|
items: React.ReactNode[];
|
|
@@ -97,10 +105,10 @@ type SlotGridProps = {
|
|
|
97
105
|
animateExtrasFromIndex?: number;
|
|
98
106
|
/**
|
|
99
107
|
* If true, the rows container animates its height via an explicit
|
|
100
|
-
* `useSharedValue` + `
|
|
101
|
-
* inner content
|
|
102
|
-
* inside always render at their natural size
|
|
103
|
-
* during the transition. Default false.
|
|
108
|
+
* `useSharedValue` + `withTiming` (ease-out cubic, no overshoot) driven by
|
|
109
|
+
* `onLayout` measurements of the inner content, with `overflow: 'hidden'`
|
|
110
|
+
* to clip mid-animation. Cells inside always render at their natural size
|
|
111
|
+
* — they are *never* resized during the transition. Default false.
|
|
104
112
|
*/
|
|
105
113
|
animateContainerLayout?: boolean;
|
|
106
114
|
}
|
|
@@ -157,6 +165,13 @@ const SlotGrid = React.memo(function SlotGrid({
|
|
|
157
165
|
}
|
|
158
166
|
|
|
159
167
|
const containerStyle = useMemo<ViewStyle>(() => ({ gap }), [gap])
|
|
168
|
+
// Strict `width` (not `minWidth`) so every cell in every row is exactly the
|
|
169
|
+
// same size — `space-between` then distributes identical leftover into
|
|
170
|
+
// identical inter-cell gaps on every row, which keeps column N of row 1
|
|
171
|
+
// aligned with column N of rows 2/3/etc. Cells whose label is wider than
|
|
172
|
+
// `cellWidth` simply wrap their text onto more lines (taking more vertical
|
|
173
|
+
// space; the row's height grows naturally to fit the tallest cell, and the
|
|
174
|
+
// animated-height clip springs to the new total).
|
|
160
175
|
const cellStyle = useMemo<ViewStyle | undefined>(
|
|
161
176
|
() => (cellWidth !== null ? { width: cellWidth } : undefined),
|
|
162
177
|
[cellWidth]
|
|
@@ -197,8 +212,9 @@ const SlotGrid = React.memo(function SlotGrid({
|
|
|
197
212
|
// and an explicit `height` driven by a shared value.
|
|
198
213
|
// 3. The inner view reports its natural height via `onLayout`. The first
|
|
199
214
|
// measurement snaps the shared value (no first-mount animation). Every
|
|
200
|
-
// subsequent change (e.g. expand/collapse adds or removes rows)
|
|
201
|
-
// the shared value to the new natural height
|
|
215
|
+
// subsequent change (e.g. expand/collapse adds or removes rows) eases
|
|
216
|
+
// the shared value to the new natural height with a calm ease-out
|
|
217
|
+
// timing curve — no spring, no bounce, no overshoot.
|
|
202
218
|
//
|
|
203
219
|
// Visually: the container reveals/conceals content like a curtain, and the
|
|
204
220
|
// cells never deform.
|
|
@@ -213,9 +229,9 @@ const SlotGrid = React.memo(function SlotGrid({
|
|
|
213
229
|
animatedHeight.value = h
|
|
214
230
|
return
|
|
215
231
|
}
|
|
216
|
-
animatedHeight.value =
|
|
217
|
-
|
|
218
|
-
|
|
232
|
+
animatedHeight.value = withTiming(h, {
|
|
233
|
+
duration: SLOT_GRID_HEIGHT_DURATION_MS,
|
|
234
|
+
easing: SLOT_GRID_EASING,
|
|
219
235
|
reduceMotion: ReduceMotion.System,
|
|
220
236
|
})
|
|
221
237
|
},
|
|
@@ -261,11 +277,12 @@ const SlotGrid = React.memo(function SlotGrid({
|
|
|
261
277
|
reverseOrdinal,
|
|
262
278
|
SLOT_GRID_STAGGER_CAP
|
|
263
279
|
)
|
|
264
|
-
const entering = FadeInUp.
|
|
265
|
-
.
|
|
280
|
+
const entering = FadeInUp.duration(SLOT_GRID_ENTER_DURATION_MS)
|
|
281
|
+
.easing(SLOT_GRID_EASING)
|
|
266
282
|
.delay(enterStaggerSteps * SLOT_GRID_ENTER_STAGGER_MS)
|
|
267
283
|
.reduceMotion(ReduceMotion.System)
|
|
268
284
|
const exiting = FadeOutUp.duration(SLOT_GRID_EXIT_DURATION_MS)
|
|
285
|
+
.easing(SLOT_GRID_EASING)
|
|
269
286
|
.delay(exitStaggerSteps * SLOT_GRID_EXIT_STAGGER_MS)
|
|
270
287
|
.reduceMotion(ReduceMotion.System)
|
|
271
288
|
return (
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
Pressable,
|
|
4
4
|
View,
|
|
5
5
|
Text,
|
|
6
|
-
Image,
|
|
7
6
|
Platform,
|
|
8
7
|
type ViewStyle,
|
|
9
8
|
type TextStyle,
|
|
@@ -15,10 +14,11 @@ import {
|
|
|
15
14
|
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
16
15
|
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
17
16
|
import { EMPTY_MODES } from '../../utils/react-utils'
|
|
17
|
+
import MediaSource, { type UnifiedSource } from '../../utils/MediaSource'
|
|
18
18
|
import Icon from '../../icons/Icon'
|
|
19
19
|
|
|
20
20
|
// Default static asset from the component folder.
|
|
21
|
-
// Consumers can override the image via the `
|
|
21
|
+
// Consumers can override the image via the `source` prop if needed.
|
|
22
22
|
const DEFAULT_AVATAR_IMAGE = require('./Image.png')
|
|
23
23
|
|
|
24
24
|
const IS_WEB = Platform.OS === 'web'
|
|
@@ -33,7 +33,19 @@ type UpiHandleProps = {
|
|
|
33
33
|
modes?: Record<string, any>;
|
|
34
34
|
showIcon?: boolean;
|
|
35
35
|
iconName?: string;
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* Unified avatar source. Accepts a remote URI (raster or `.svg`), an
|
|
38
|
+
* inline SVG XML string, a `require()` asset, an SVG React component, or
|
|
39
|
+
* an already-rendered element. See {@link UnifiedSource}. Avatars are
|
|
40
|
+
* intentionally **not** tinted — the source renders as-is.
|
|
41
|
+
*/
|
|
42
|
+
source?: UnifiedSource;
|
|
43
|
+
/**
|
|
44
|
+
* @deprecated Use `source` instead. Kept as an alias for back-compat.
|
|
45
|
+
* Accepts the same shapes as `source` plus the legacy
|
|
46
|
+
* `ImageSourcePropType` from the previous API.
|
|
47
|
+
*/
|
|
48
|
+
avatarSource?: ImageSourcePropType | UnifiedSource;
|
|
37
49
|
accessibilityLabel?: string;
|
|
38
50
|
accessibilityHint?: string;
|
|
39
51
|
onPress?: () => void;
|
|
@@ -115,7 +127,8 @@ function resolveUpiHandleTokens(modes: Record<string, any>): UpiHandleTokens {
|
|
|
115
127
|
* @param {Object} [props.modes={}] - Modes object passed directly to `getVariableByName`.
|
|
116
128
|
* @param {boolean} [props.showIcon=true] - Toggles the trailing icon visibility.
|
|
117
129
|
* @param {string} [props.iconName='ic_scan_qr_code'] - Icon name from the actions set.
|
|
118
|
-
* @param {
|
|
130
|
+
* @param {UnifiedSource} [props.source] - Unified avatar source (URI, inline SVG XML, `require()` asset, SVG React component, or React element). Smart-detects raster vs SVG so the same prop works on iOS, Android and web.
|
|
131
|
+
* @param {ImageSourcePropType|UnifiedSource} [props.avatarSource] - Deprecated alias for `source`; kept for back-compat.
|
|
119
132
|
* @param {Function} [props.onClick] - Click/tap handler. Works as an alias for `onPress`.
|
|
120
133
|
* @param {string} [props.accessibilityLabel] - Accessibility label for screen readers
|
|
121
134
|
* @param {string} [props.accessibilityHint] - Additional accessibility hint for screen readers
|
|
@@ -133,6 +146,7 @@ function UpiHandle({
|
|
|
133
146
|
modes: propModes = EMPTY_MODES,
|
|
134
147
|
showIcon = true,
|
|
135
148
|
iconName = 'ic_scan_qr_code',
|
|
149
|
+
source,
|
|
136
150
|
avatarSource,
|
|
137
151
|
onPress,
|
|
138
152
|
onClick,
|
|
@@ -202,15 +216,27 @@ function UpiHandle({
|
|
|
202
216
|
[tokens.containerStyle, isFocused]
|
|
203
217
|
)
|
|
204
218
|
|
|
219
|
+
// `source` wins; `avatarSource` is the legacy fallback. Both are accepted
|
|
220
|
+
// as a UnifiedSource (string / number / {uri} / component / element), and
|
|
221
|
+
// the legacy `ImageSourcePropType` shapes naturally fit that union too.
|
|
222
|
+
const resolvedAvatarSource: UnifiedSource =
|
|
223
|
+
(source as UnifiedSource | undefined) ??
|
|
224
|
+
(avatarSource as UnifiedSource | undefined) ??
|
|
225
|
+
(DEFAULT_AVATAR_IMAGE as UnifiedSource)
|
|
226
|
+
|
|
227
|
+
const avatarSize = (tokens.avatarStyle.width as number | undefined) ?? 23
|
|
228
|
+
|
|
205
229
|
const innerContent = (
|
|
206
230
|
<>
|
|
207
|
-
<
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
231
|
+
<View style={tokens.avatarStyle}>
|
|
232
|
+
<MediaSource
|
|
233
|
+
source={resolvedAvatarSource}
|
|
234
|
+
size={avatarSize}
|
|
235
|
+
resizeMode="cover"
|
|
236
|
+
accessibilityElementsHidden={true}
|
|
237
|
+
importantForAccessibility="no"
|
|
238
|
+
/>
|
|
239
|
+
</View>
|
|
214
240
|
<Text
|
|
215
241
|
style={tokens.labelStyle}
|
|
216
242
|
numberOfLines={1}
|
package/src/icons/Icon.tsx
CHANGED
|
@@ -1,109 +1,124 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
-
import { View, AccessibilityProps } from 'react-native';
|
|
2
|
+
import { View, AccessibilityProps, type StyleProp, type ViewStyle } from 'react-native';
|
|
3
3
|
import Svg, { Path } from 'react-native-svg';
|
|
4
4
|
import { getIcon, hasIcon } from './registry';
|
|
5
|
+
import MediaSource, { type UnifiedSource } from '../utils/MediaSource';
|
|
5
6
|
|
|
6
7
|
type IconProps = AccessibilityProps & {
|
|
7
|
-
|
|
8
|
+
/**
|
|
9
|
+
* Built-in icon name from the registry (e.g. `'ic_card'`, `'ic_scan_qr_code'`).
|
|
10
|
+
* If omitted or not found in the registry, the component falls back to
|
|
11
|
+
* `source` (when provided).
|
|
12
|
+
*/
|
|
13
|
+
name?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Unified fallback source rendered when `name` is missing or not in the
|
|
16
|
+
* registry. Accepts a remote URI, an inline SVG XML string, a `require()`
|
|
17
|
+
* asset, an SVG React component, or an already-rendered element. See
|
|
18
|
+
* {@link UnifiedSource}. The icon is tinted with `color` so it follows
|
|
19
|
+
* design-token modes the same way built-in icons do.
|
|
20
|
+
*/
|
|
21
|
+
source?: UnifiedSource;
|
|
8
22
|
size?: number;
|
|
9
23
|
color?: string;
|
|
10
|
-
style?:
|
|
24
|
+
style?: StyleProp<ViewStyle>;
|
|
11
25
|
};
|
|
12
26
|
|
|
13
27
|
/**
|
|
14
|
-
* Generic Icon
|
|
15
|
-
*
|
|
16
|
-
* Renders an icon from the registry by name
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* @param {number} [props.size=24] - Icon size in pixels (width and height)
|
|
22
|
-
* @param {string} [props.color='#141414'] - Icon color (hex, rgb, or named color)
|
|
23
|
-
* @param {Object} [props.style] - Additional styles for the container View
|
|
24
|
-
*
|
|
28
|
+
* Generic Icon component.
|
|
29
|
+
*
|
|
30
|
+
* Renders an icon from the registry by `name`, or falls back to a
|
|
31
|
+
* smart-detected `source` (SVG / PNG / JPG / require / SVG component /
|
|
32
|
+
* remote URI). External sources are tinted with `color` so they participate
|
|
33
|
+
* in the design-token modes just like built-in icons.
|
|
34
|
+
*
|
|
25
35
|
* @example
|
|
26
|
-
* ```
|
|
36
|
+
* ```tsx
|
|
37
|
+
* // Built-in icon from the registry.
|
|
27
38
|
* <Icon name="ic_ccv" size={24} color="#141414" />
|
|
28
|
-
*
|
|
29
|
-
*
|
|
39
|
+
*
|
|
40
|
+
* // Fallback to a remote SVG (auto-detected by the .svg extension).
|
|
41
|
+
* <Icon source="https://cdn.example.com/avatar.svg" size={24} color="#5c00b5" />
|
|
42
|
+
*
|
|
43
|
+
* // Fallback to a local raster asset.
|
|
44
|
+
* <Icon source={require('./brand.png')} size={32} />
|
|
45
|
+
*
|
|
46
|
+
* // Fallback to an SVG React component (e.g. via react-native-svg-transformer).
|
|
47
|
+
* import BrandLogo from './brand.svg';
|
|
48
|
+
* <Icon source={BrandLogo} size={24} color="red" />
|
|
30
49
|
* ```
|
|
31
50
|
*/
|
|
32
51
|
function Icon({
|
|
33
52
|
name,
|
|
53
|
+
source,
|
|
34
54
|
size = 24,
|
|
35
55
|
color = '#141414',
|
|
36
56
|
style,
|
|
37
57
|
...rest
|
|
38
58
|
}: IconProps) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
59
|
+
const containerStyle: StyleProp<ViewStyle> = [
|
|
60
|
+
{
|
|
61
|
+
width: size,
|
|
62
|
+
height: size,
|
|
63
|
+
alignItems: 'center',
|
|
64
|
+
justifyContent: 'center',
|
|
65
|
+
},
|
|
66
|
+
style,
|
|
67
|
+
];
|
|
50
68
|
|
|
51
|
-
|
|
52
|
-
const iconData = getIcon(name);
|
|
53
|
-
if (!iconData) {
|
|
54
|
-
return null;
|
|
55
|
-
}
|
|
69
|
+
const iconData = name && hasIcon(name) ? getIcon(name) : null;
|
|
56
70
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const viewBoxHeight = parseFloat(viewBoxParts[3]) || size;
|
|
71
|
+
if (iconData) {
|
|
72
|
+
const viewBoxParts = iconData.viewBox.split(' ');
|
|
73
|
+
const viewBoxWidth = parseFloat(viewBoxParts[2] ?? `${size}`) || size;
|
|
74
|
+
const viewBoxHeight = parseFloat(viewBoxParts[3] ?? `${size}`) || size;
|
|
75
|
+
const aspectRatio = viewBoxWidth / viewBoxHeight;
|
|
63
76
|
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
let width = size;
|
|
78
|
+
let height = size;
|
|
79
|
+
if (Math.abs(aspectRatio - 1) > 0.01) {
|
|
80
|
+
if (aspectRatio > 1) {
|
|
81
|
+
height = size / aspectRatio;
|
|
82
|
+
} else {
|
|
83
|
+
width = size * aspectRatio;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
66
86
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
87
|
+
return (
|
|
88
|
+
<View style={containerStyle} {...rest}>
|
|
89
|
+
<Svg
|
|
90
|
+
width={width}
|
|
91
|
+
height={height}
|
|
92
|
+
viewBox={iconData.viewBox}
|
|
93
|
+
preserveAspectRatio="xMidYMid meet"
|
|
94
|
+
>
|
|
95
|
+
<Path
|
|
96
|
+
d={iconData.path}
|
|
97
|
+
fill={color}
|
|
98
|
+
fillRule={(iconData.fillRule || 'nonzero') as 'nonzero' | 'evenodd'}
|
|
99
|
+
/>
|
|
100
|
+
</Svg>
|
|
101
|
+
</View>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
70
104
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Taller than wide
|
|
78
|
-
width = size * aspectRatio;
|
|
79
|
-
}
|
|
105
|
+
if (source !== undefined) {
|
|
106
|
+
return (
|
|
107
|
+
<View style={containerStyle} {...rest}>
|
|
108
|
+
<MediaSource source={source} size={size} tintColor={color} resizeMode="contain" />
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
80
111
|
}
|
|
81
112
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
justifyContent: 'center',
|
|
87
|
-
...style,
|
|
88
|
-
};
|
|
113
|
+
if (!name) {
|
|
114
|
+
console.warn('Icon: either `name` or `source` is required');
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
89
117
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<Svg
|
|
93
|
-
width={width}
|
|
94
|
-
height={height}
|
|
95
|
-
viewBox={iconData.viewBox}
|
|
96
|
-
preserveAspectRatio="xMidYMid meet"
|
|
97
|
-
>
|
|
98
|
-
<Path
|
|
99
|
-
d={iconData.path}
|
|
100
|
-
fill={color}
|
|
101
|
-
fillRule={(iconData.fillRule || 'nonzero') as any}
|
|
102
|
-
/>
|
|
103
|
-
</Svg>
|
|
104
|
-
</View>
|
|
118
|
+
console.warn(
|
|
119
|
+
`Icon: "${name}" not found in registry and no \`source\` fallback was provided.`
|
|
105
120
|
);
|
|
121
|
+
return null;
|
|
106
122
|
}
|
|
107
123
|
|
|
108
124
|
export default Icon;
|
|
109
|
-
|
package/src/icons/registry.ts
CHANGED