jfs-components 0.0.42 → 0.0.44
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/lib/commonjs/components/Button/Button.js +15 -1
- package/lib/commonjs/components/Checkbox/Checkbox.js +208 -0
- package/lib/commonjs/components/MoneyValue/MoneyValue.js +81 -49
- package/lib/commonjs/components/NoteInput/NoteInput.js +120 -0
- package/lib/commonjs/components/NoteInput/index.js +13 -0
- package/lib/commonjs/components/Numpad/Numpad.js +108 -0
- package/lib/commonjs/components/StatusHero/StatusHero.js +148 -0
- package/lib/commonjs/components/Tabs/TabItem.js +79 -0
- package/lib/commonjs/components/Tabs/Tabs.js +88 -0
- package/lib/commonjs/components/Toast/Toast.js +93 -0
- package/lib/commonjs/components/Toast/ToastProvider.js +61 -0
- package/lib/commonjs/components/Toast/useToast.js +61 -0
- package/lib/commonjs/components/index.js +81 -0
- package/lib/commonjs/design-tokens/JFS Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/module/components/Button/Button.js +14 -1
- package/lib/module/components/Checkbox/Checkbox.js +205 -0
- package/lib/module/components/MoneyValue/MoneyValue.js +81 -49
- package/lib/module/components/NoteInput/NoteInput.js +115 -0
- package/lib/module/components/NoteInput/index.js +3 -0
- package/lib/module/components/Numpad/Numpad.js +103 -0
- package/lib/module/components/StatusHero/StatusHero.js +142 -0
- package/lib/module/components/Tabs/TabItem.js +74 -0
- package/lib/module/components/Tabs/Tabs.js +78 -0
- package/lib/module/components/Toast/Toast.js +88 -0
- package/lib/module/components/Toast/ToastProvider.js +55 -0
- package/lib/module/components/Toast/useToast.js +54 -0
- package/lib/module/components/index.js +10 -1
- package/lib/module/design-tokens/JFS Variables-variables-full.json +1 -1
- package/lib/module/icons/registry.js +1 -1
- package/lib/typescript/src/components/Button/Button.d.ts +6 -1
- package/lib/typescript/src/components/Checkbox/Checkbox.d.ts +30 -0
- package/lib/typescript/src/components/MoneyValue/MoneyValue.d.ts +18 -26
- package/lib/typescript/src/components/NoteInput/NoteInput.d.ts +23 -0
- package/lib/typescript/src/components/NoteInput/index.d.ts +3 -0
- package/lib/typescript/src/components/Numpad/Numpad.d.ts +35 -0
- package/lib/typescript/src/components/StatusHero/StatusHero.d.ts +47 -0
- package/lib/typescript/src/components/Tabs/TabItem.d.ts +29 -0
- package/lib/typescript/src/components/Tabs/Tabs.d.ts +44 -0
- package/lib/typescript/src/components/Toast/Toast.d.ts +14 -0
- package/lib/typescript/src/components/Toast/ToastProvider.d.ts +11 -0
- package/lib/typescript/src/components/Toast/useToast.d.ts +24 -0
- package/lib/typescript/src/components/index.d.ts +9 -0
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/package.json +1 -1
- package/src/components/Button/Button.tsx +14 -1
- package/src/components/Checkbox/Checkbox.tsx +238 -0
- package/src/components/MoneyValue/MoneyValue.tsx +134 -79
- package/src/components/NoteInput/NoteInput.tsx +146 -0
- package/src/components/NoteInput/index.ts +2 -0
- package/src/components/Numpad/Numpad.tsx +162 -0
- package/src/components/StatusHero/StatusHero.tsx +156 -0
- package/src/components/Tabs/TabItem.tsx +96 -0
- package/src/components/Tabs/Tabs.tsx +105 -0
- package/src/components/Toast/Toast.tsx +105 -0
- package/src/components/Toast/ToastProvider.tsx +75 -0
- package/src/components/Toast/useToast.ts +80 -0
- package/src/components/index.ts +9 -0
- package/src/design-tokens/JFS Variables-variables-full.json +1 -1
- package/src/icons/registry.ts +1 -1
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import React, { useMemo, useCallback } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
View,
|
|
4
|
+
Text,
|
|
5
|
+
Pressable,
|
|
6
|
+
type StyleProp,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
type TextStyle,
|
|
9
|
+
} from 'react-native'
|
|
10
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
11
|
+
import { IconDeletebackspace } from '../../icons/components/IconDeletebackspace'
|
|
12
|
+
|
|
13
|
+
export type NumpadKeyValue =
|
|
14
|
+
| '0'
|
|
15
|
+
| '1'
|
|
16
|
+
| '2'
|
|
17
|
+
| '3'
|
|
18
|
+
| '4'
|
|
19
|
+
| '5'
|
|
20
|
+
| '6'
|
|
21
|
+
| '7'
|
|
22
|
+
| '8'
|
|
23
|
+
| '9'
|
|
24
|
+
| '.'
|
|
25
|
+
| 'backspace'
|
|
26
|
+
|
|
27
|
+
export interface NumpadProps {
|
|
28
|
+
/** Design token modes for theming (e.g., {"Color Mode": "Light"}) */
|
|
29
|
+
modes?: Record<string, any>
|
|
30
|
+
/** Callback fired when any key is pressed */
|
|
31
|
+
onKeyPress?: (key: NumpadKeyValue) => void
|
|
32
|
+
/** Whether to show the decimal point key (default: true) */
|
|
33
|
+
showDecimal?: boolean
|
|
34
|
+
/**
|
|
35
|
+
* When true, digit positions (0-9) are randomised on each mount for
|
|
36
|
+
* anti-keylogging / shoulder-surfing protection. The decimal and
|
|
37
|
+
* backspace keys keep their fixed positions.
|
|
38
|
+
*/
|
|
39
|
+
shuffle?: boolean
|
|
40
|
+
/** Override container styles */
|
|
41
|
+
style?: StyleProp<ViewStyle>
|
|
42
|
+
/** Override individual key styles */
|
|
43
|
+
keyStyle?: StyleProp<ViewStyle>
|
|
44
|
+
/** Override text styles on digit / decimal keys */
|
|
45
|
+
keyTextStyle?: StyleProp<TextStyle>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function shuffleArray<T>(arr: T[]): T[] {
|
|
49
|
+
const shuffled = [...arr]
|
|
50
|
+
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
51
|
+
const j = Math.floor(Math.random() * (i + 1))
|
|
52
|
+
;[shuffled[i]!, shuffled[j]!] = [shuffled[j]!, shuffled[i]!]
|
|
53
|
+
}
|
|
54
|
+
return shuffled
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Secure numpad component for the JFS finance system.
|
|
59
|
+
*
|
|
60
|
+
* Renders a 3×4 grid of digit keys (0-9), an optional decimal key, and a
|
|
61
|
+
* backspace key. Digit positions are shuffled by default to guard against
|
|
62
|
+
* keylogging and shoulder-surfing attacks on mobile devices.
|
|
63
|
+
*
|
|
64
|
+
* @component
|
|
65
|
+
* @param {NumpadProps} props
|
|
66
|
+
*/
|
|
67
|
+
function Numpad({
|
|
68
|
+
modes = {},
|
|
69
|
+
onKeyPress,
|
|
70
|
+
showDecimal = true,
|
|
71
|
+
shuffle = true,
|
|
72
|
+
style,
|
|
73
|
+
keyStyle,
|
|
74
|
+
keyTextStyle,
|
|
75
|
+
}: NumpadProps) {
|
|
76
|
+
const foreground = getVariableByName('numpad/foreground', modes) ?? '#141414'
|
|
77
|
+
const lineHeight = getVariableByName('numpad/lineHeight', modes) ?? 32
|
|
78
|
+
const fontFamily = getVariableByName('numpad/fontFamily', modes) ?? 'JioType Var'
|
|
79
|
+
const fontSize = getVariableByName('numpad/fontSize', modes) ?? 32
|
|
80
|
+
const rowGap = getVariableByName('numpad/gridRowGap/vertical', modes) ?? 12
|
|
81
|
+
const columnGap = getVariableByName('numpad/gridColumnGap/horizontal', modes) ?? 12
|
|
82
|
+
|
|
83
|
+
const digits = useMemo(() => {
|
|
84
|
+
const base = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0']
|
|
85
|
+
return shuffle ? shuffleArray(base) : base
|
|
86
|
+
}, [shuffle])
|
|
87
|
+
|
|
88
|
+
const rows: (string | null)[][] = useMemo(
|
|
89
|
+
() => [
|
|
90
|
+
digits.slice(0, 3),
|
|
91
|
+
digits.slice(3, 6),
|
|
92
|
+
digits.slice(6, 9),
|
|
93
|
+
[showDecimal ? '.' : null, digits[9]!, 'backspace'],
|
|
94
|
+
],
|
|
95
|
+
[digits, showDecimal],
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const handlePress = useCallback(
|
|
99
|
+
(key: NumpadKeyValue) => {
|
|
100
|
+
onKeyPress?.(key)
|
|
101
|
+
},
|
|
102
|
+
[onKeyPress],
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const textStyle: TextStyle = {
|
|
106
|
+
color: foreground as string,
|
|
107
|
+
fontFamily: fontFamily as string,
|
|
108
|
+
fontSize: fontSize as number,
|
|
109
|
+
lineHeight: lineHeight as number,
|
|
110
|
+
textAlign: 'center',
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<View
|
|
115
|
+
style={[{ gap: rowGap as number }, style]}
|
|
116
|
+
accessibilityRole="none"
|
|
117
|
+
>
|
|
118
|
+
{rows.map((row, rowIndex) => (
|
|
119
|
+
<View key={rowIndex} style={{ flexDirection: 'row', gap: columnGap as number }}>
|
|
120
|
+
{row.map((key, colIndex) => {
|
|
121
|
+
if (key === null) {
|
|
122
|
+
return <View key={`empty-${colIndex}`} style={{ flex: 1 }} />
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const isBackspace = key === 'backspace'
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<Pressable
|
|
129
|
+
key={`${key}-${colIndex}`}
|
|
130
|
+
style={({ pressed }) => [
|
|
131
|
+
{
|
|
132
|
+
flex: 1,
|
|
133
|
+
justifyContent: 'center',
|
|
134
|
+
alignItems: 'center',
|
|
135
|
+
minHeight: 46,
|
|
136
|
+
},
|
|
137
|
+
pressed && { opacity: 0.4 },
|
|
138
|
+
keyStyle,
|
|
139
|
+
]}
|
|
140
|
+
onPress={() => handlePress(key as NumpadKeyValue)}
|
|
141
|
+
accessibilityRole="button"
|
|
142
|
+
accessibilityLabel={isBackspace ? 'Backspace' : key}
|
|
143
|
+
>
|
|
144
|
+
{isBackspace ? (
|
|
145
|
+
<IconDeletebackspace
|
|
146
|
+
width={fontSize as number}
|
|
147
|
+
height={fontSize as number}
|
|
148
|
+
fill={foreground as string}
|
|
149
|
+
/>
|
|
150
|
+
) : (
|
|
151
|
+
<Text style={[textStyle, keyTextStyle]}>{key}</Text>
|
|
152
|
+
)}
|
|
153
|
+
</Pressable>
|
|
154
|
+
)
|
|
155
|
+
})}
|
|
156
|
+
</View>
|
|
157
|
+
))}
|
|
158
|
+
</View>
|
|
159
|
+
)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default Numpad
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, { isValidElement, cloneElement, type ReactNode } from 'react'
|
|
2
|
+
import { View, Text, type ViewStyle, type TextStyle } from 'react-native'
|
|
3
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
4
|
+
import { useTokens } from '../../design-tokens/JFSThemeProvider'
|
|
5
|
+
import IconCapsule from '../IconCapsule/IconCapsule'
|
|
6
|
+
import MoneyValue from '../MoneyValue/MoneyValue'
|
|
7
|
+
|
|
8
|
+
export type StatusHeroProps = {
|
|
9
|
+
/**
|
|
10
|
+
* Custom media slot content.
|
|
11
|
+
* Defaults to an IconCapsule above a MoneyValue.
|
|
12
|
+
* Modes are automatically injected if the slot is a valid React element.
|
|
13
|
+
*/
|
|
14
|
+
renderMedia?: ReactNode
|
|
15
|
+
/** Title text displayed below the media slot */
|
|
16
|
+
title?: string
|
|
17
|
+
/** Whether to render the title */
|
|
18
|
+
showTitle?: boolean
|
|
19
|
+
/** Body/subtitle text displayed below the title */
|
|
20
|
+
subtitle?: string
|
|
21
|
+
/** Icon name used in the default media slot */
|
|
22
|
+
iconName?: string
|
|
23
|
+
/** Monetary value shown in the default MoneyValue inside the media slot */
|
|
24
|
+
value?: string
|
|
25
|
+
/** Currency symbol or ISO code for the default MoneyValue */
|
|
26
|
+
currency?: string
|
|
27
|
+
/** Mode configuration for design tokens */
|
|
28
|
+
modes?: Record<string, any>
|
|
29
|
+
style?: ViewStyle
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* StatusHero component that displays a hero section for payment/transaction status screens.
|
|
34
|
+
*
|
|
35
|
+
* Contains a media slot (defaults to IconCapsule + MoneyValue) and a content area
|
|
36
|
+
* with an optional title and a subtitle. All visual values are resolved from Figma
|
|
37
|
+
* design tokens via `getVariableByName`.
|
|
38
|
+
*
|
|
39
|
+
* @component
|
|
40
|
+
* @example
|
|
41
|
+
* ```tsx
|
|
42
|
+
* <StatusHero
|
|
43
|
+
* iconName="ic_confirm"
|
|
44
|
+
* value="50"
|
|
45
|
+
* currency="INR"
|
|
46
|
+
* title="You're set to make payments"
|
|
47
|
+
* subtitle="₹50 will be auto-invested daily, stay consistent & stay golden."
|
|
48
|
+
* modes={{ 'Color Mode': 'Light' }}
|
|
49
|
+
* />
|
|
50
|
+
* ```
|
|
51
|
+
*/
|
|
52
|
+
export default function StatusHero({
|
|
53
|
+
renderMedia,
|
|
54
|
+
title = "You're set to make payments",
|
|
55
|
+
showTitle = true,
|
|
56
|
+
subtitle = '₹50 will be auto-invested daily,\nstay consistent & stay golden.',
|
|
57
|
+
iconName = 'ic_confirm',
|
|
58
|
+
value = '50',
|
|
59
|
+
currency = 'INR',
|
|
60
|
+
modes: propModes = {},
|
|
61
|
+
style,
|
|
62
|
+
}: StatusHeroProps) {
|
|
63
|
+
const { modes: globalModes } = useTokens()
|
|
64
|
+
const modes = { ...globalModes, ...propModes }
|
|
65
|
+
|
|
66
|
+
// Container
|
|
67
|
+
const gap = Number(getVariableByName('statusHero/gap', modes)) || 12
|
|
68
|
+
const padding = Number(getVariableByName('statusHero/padding', modes)) || 8
|
|
69
|
+
|
|
70
|
+
// Media slot wrap (gap between icon and money value in default slot)
|
|
71
|
+
const slotWrapGap = Number(getVariableByName('statusHero/slotWrap/gap', modes)) || 46
|
|
72
|
+
|
|
73
|
+
// Content wrap
|
|
74
|
+
const contentWrapGap = Number(getVariableByName('statusHero/contentWrap/gap', modes)) || 12
|
|
75
|
+
|
|
76
|
+
// Title
|
|
77
|
+
const titleColor = getVariableByName('statusHero/title/foreground', modes) || '#0c0d10'
|
|
78
|
+
const titleFontSize = Number(getVariableByName('statusHero/title/fontSize', modes)) || 20
|
|
79
|
+
const titleFontFamily = getVariableByName('statusHero/title/fontFamily', modes) || 'System'
|
|
80
|
+
const titleLineHeight = Number(getVariableByName('statusHero/title/lineHeight', modes)) || 22
|
|
81
|
+
const titleFontWeight = getVariableByName('statusHero/title/fontWeight', modes) || '700'
|
|
82
|
+
|
|
83
|
+
// Body
|
|
84
|
+
const bodyColor = getVariableByName('statusHero/body/foreground', modes) || '#24262b'
|
|
85
|
+
const bodyFontSize = Number(getVariableByName('statusHero/body/fontSize', modes)) || 14
|
|
86
|
+
const bodyFontFamily = getVariableByName('statusHero/body/fontFamily', modes) || 'System'
|
|
87
|
+
const bodyLineHeight = Number(getVariableByName('statusHero/body/lineHeight', modes)) || 17
|
|
88
|
+
const bodyFontWeight = getVariableByName('statusHero/body/fontWeight', modes) || '500'
|
|
89
|
+
|
|
90
|
+
const containerStyle: ViewStyle = {
|
|
91
|
+
flexDirection: 'column',
|
|
92
|
+
alignItems: 'center',
|
|
93
|
+
justifyContent: 'center',
|
|
94
|
+
gap,
|
|
95
|
+
padding,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const defaultMediaWrapStyle: ViewStyle = {
|
|
99
|
+
flexDirection: 'column',
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
gap: slotWrapGap,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const contentWrapStyle: ViewStyle = {
|
|
105
|
+
flexDirection: 'column',
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
gap: contentWrapGap,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const titleStyle: TextStyle = {
|
|
111
|
+
color: titleColor,
|
|
112
|
+
fontSize: titleFontSize,
|
|
113
|
+
fontFamily: titleFontFamily,
|
|
114
|
+
lineHeight: titleLineHeight,
|
|
115
|
+
fontWeight: String(titleFontWeight) as TextStyle['fontWeight'],
|
|
116
|
+
textAlign: 'center',
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const bodyStyle: TextStyle = {
|
|
120
|
+
color: bodyColor,
|
|
121
|
+
fontSize: bodyFontSize,
|
|
122
|
+
fontFamily: bodyFontFamily,
|
|
123
|
+
lineHeight: bodyLineHeight,
|
|
124
|
+
fontWeight: String(bodyFontWeight) as TextStyle['fontWeight'],
|
|
125
|
+
textAlign: 'center',
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Inject modes into the provided slot if it's a valid React element
|
|
129
|
+
const mediaContent = isValidElement(renderMedia)
|
|
130
|
+
? cloneElement(renderMedia as React.ReactElement<any>, { modes })
|
|
131
|
+
: renderMedia
|
|
132
|
+
|
|
133
|
+
const defaultMediaContent = (
|
|
134
|
+
<View style={defaultMediaWrapStyle}>
|
|
135
|
+
<IconCapsule
|
|
136
|
+
iconName={iconName}
|
|
137
|
+
modes={{ ...modes, 'Icon Capsule Size': 'L', Emphasis: 'High', AppearanceBrand: 'Primary' }}
|
|
138
|
+
/>
|
|
139
|
+
<MoneyValue
|
|
140
|
+
value={value}
|
|
141
|
+
currency={currency}
|
|
142
|
+
modes={{ ...modes, Context3: 'Balance' }}
|
|
143
|
+
/>
|
|
144
|
+
</View>
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<View style={[containerStyle, style]}>
|
|
149
|
+
{mediaContent ?? defaultMediaContent}
|
|
150
|
+
<View style={contentWrapStyle}>
|
|
151
|
+
{showTitle && title ? <Text style={titleStyle}>{title}</Text> : null}
|
|
152
|
+
{subtitle ? <Text style={bodyStyle}>{subtitle}</Text> : null}
|
|
153
|
+
</View>
|
|
154
|
+
</View>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Pressable,
|
|
4
|
+
Text,
|
|
5
|
+
View,
|
|
6
|
+
type StyleProp,
|
|
7
|
+
type ViewStyle,
|
|
8
|
+
type TextStyle,
|
|
9
|
+
} from 'react-native'
|
|
10
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
11
|
+
|
|
12
|
+
export interface TabItemProps {
|
|
13
|
+
/** Label text to display */
|
|
14
|
+
label?: string
|
|
15
|
+
/** Whether this tab item is currently active/selected */
|
|
16
|
+
active?: boolean
|
|
17
|
+
/** Callback when this tab item is pressed */
|
|
18
|
+
onPress?: () => void
|
|
19
|
+
/** Design token modes for theming */
|
|
20
|
+
modes?: Record<string, any>
|
|
21
|
+
/** Override container styles */
|
|
22
|
+
style?: StyleProp<ViewStyle>
|
|
23
|
+
/** Override label text styles */
|
|
24
|
+
labelStyle?: StyleProp<TextStyle>
|
|
25
|
+
/** Accessibility label for screen readers */
|
|
26
|
+
accessibilityLabel?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Individual tab item used inside the Tabs container.
|
|
31
|
+
*
|
|
32
|
+
* Supports idle and active states driven by design tokens.
|
|
33
|
+
* When active, a bottom indicator bar is shown beneath the label.
|
|
34
|
+
*
|
|
35
|
+
* @component
|
|
36
|
+
* @param {TabItemProps} props
|
|
37
|
+
*/
|
|
38
|
+
function TabItem({
|
|
39
|
+
label = 'Tab item',
|
|
40
|
+
active = false,
|
|
41
|
+
onPress,
|
|
42
|
+
modes = {},
|
|
43
|
+
style,
|
|
44
|
+
labelStyle,
|
|
45
|
+
accessibilityLabel,
|
|
46
|
+
}: TabItemProps) {
|
|
47
|
+
const paddingVertical = (getVariableByName('tabItem/padding/vertical', modes) ?? 8) as number
|
|
48
|
+
const fontFamily = (getVariableByName('tabItem/label/fontFamily', modes) ?? 'JioType Var') as string
|
|
49
|
+
const fontSize = (getVariableByName('tabItem/label/size', modes) ?? 14) as number
|
|
50
|
+
const lineHeight = (getVariableByName('tabItem/label/lineHeight', modes) ?? 17) as number
|
|
51
|
+
|
|
52
|
+
const idleLabelColor = (getVariableByName('tabItem/idle/label/color', modes) ?? '#303338') as string
|
|
53
|
+
const activeLabelColor = (getVariableByName('tabItem/active/label/color', modes) ?? '#cea15a') as string
|
|
54
|
+
|
|
55
|
+
const labelColor = active ? activeLabelColor : idleLabelColor
|
|
56
|
+
|
|
57
|
+
const containerStyle: ViewStyle = {
|
|
58
|
+
flexDirection: 'column',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
justifyContent: 'center',
|
|
61
|
+
paddingVertical,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const textStyle: TextStyle = {
|
|
65
|
+
color: labelColor,
|
|
66
|
+
fontFamily,
|
|
67
|
+
fontSize,
|
|
68
|
+
lineHeight,
|
|
69
|
+
fontWeight: '500',
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const indicatorStyle: ViewStyle = {
|
|
73
|
+
position: 'absolute',
|
|
74
|
+
bottom: 0,
|
|
75
|
+
left: 0,
|
|
76
|
+
right: 0,
|
|
77
|
+
height: 2,
|
|
78
|
+
backgroundColor: activeLabelColor,
|
|
79
|
+
borderRadius: 1,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Pressable
|
|
84
|
+
style={({ pressed }) => [containerStyle, pressed && { opacity: 0.7 }, style]}
|
|
85
|
+
onPress={onPress}
|
|
86
|
+
accessibilityRole="tab"
|
|
87
|
+
accessibilityState={{ selected: active }}
|
|
88
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
89
|
+
>
|
|
90
|
+
<Text style={[textStyle, labelStyle]}>{label}</Text>
|
|
91
|
+
{active && <View style={indicatorStyle} />}
|
|
92
|
+
</Pressable>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default TabItem
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
ScrollView,
|
|
4
|
+
View,
|
|
5
|
+
type StyleProp,
|
|
6
|
+
type ViewStyle,
|
|
7
|
+
} from 'react-native'
|
|
8
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
9
|
+
import TabItem from './TabItem'
|
|
10
|
+
|
|
11
|
+
export interface TabsProps {
|
|
12
|
+
/**
|
|
13
|
+
* Tab item children. Each child should be a <TabItem> component.
|
|
14
|
+
* Modes are automatically forwarded to all TabItem children.
|
|
15
|
+
*/
|
|
16
|
+
children: React.ReactNode
|
|
17
|
+
/** Design token modes for theming */
|
|
18
|
+
modes?: Record<string, any>
|
|
19
|
+
/**
|
|
20
|
+
* When true, the tabs row scrolls horizontally (useful for many items).
|
|
21
|
+
* @default false
|
|
22
|
+
*/
|
|
23
|
+
scrollable?: boolean
|
|
24
|
+
/** Override container styles */
|
|
25
|
+
style?: StyleProp<ViewStyle>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Tabs container component that lays out TabItem children horizontally.
|
|
30
|
+
*
|
|
31
|
+
* The "Tab items" slot maps to React children — each child should be a
|
|
32
|
+
* `<TabItem>` element. The `modes` prop is automatically forwarded to
|
|
33
|
+
* every TabItem child so theming is consistent.
|
|
34
|
+
*
|
|
35
|
+
* @component
|
|
36
|
+
* @param {TabsProps} props
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const [activeIndex, setActiveIndex] = useState(0);
|
|
41
|
+
*
|
|
42
|
+
* <Tabs modes={{ 'Color Mode': 'Light' }}>
|
|
43
|
+
* <TabItem label="Tab 1" active={activeIndex === 0} onPress={() => setActiveIndex(0)} />
|
|
44
|
+
* <TabItem label="Tab 2" active={activeIndex === 1} onPress={() => setActiveIndex(1)} />
|
|
45
|
+
* <TabItem label="Tab 3" active={activeIndex === 2} onPress={() => setActiveIndex(2)} />
|
|
46
|
+
* </Tabs>
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
function Tabs({
|
|
50
|
+
children,
|
|
51
|
+
modes = {},
|
|
52
|
+
scrollable = false,
|
|
53
|
+
style,
|
|
54
|
+
}: TabsProps) {
|
|
55
|
+
const gap = (getVariableByName('tabs/gap', modes) ?? 16) as number
|
|
56
|
+
const paddingTop = (getVariableByName('tabs/padding/top', modes) ?? 0) as number
|
|
57
|
+
const paddingBottom = (getVariableByName('tabs/padding/bottom', modes) ?? 0) as number
|
|
58
|
+
const paddingLeft = (getVariableByName('tabs/padding/left', modes) ?? 0) as number
|
|
59
|
+
const paddingRight = (getVariableByName('tabs/padding/right', modes) ?? 0) as number
|
|
60
|
+
|
|
61
|
+
// Forward modes to all TabItem children
|
|
62
|
+
const enhancedChildren = React.Children.map(children, (child) => {
|
|
63
|
+
if (React.isValidElement(child) && child.type === TabItem) {
|
|
64
|
+
const childElement = child as React.ReactElement<any>;
|
|
65
|
+
return React.cloneElement(childElement, {
|
|
66
|
+
modes: { ...modes, ...(childElement.props.modes ?? {}) },
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
return child
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
const containerStyle: ViewStyle = {
|
|
73
|
+
flexDirection: 'row',
|
|
74
|
+
gap,
|
|
75
|
+
paddingTop,
|
|
76
|
+
paddingBottom,
|
|
77
|
+
paddingLeft,
|
|
78
|
+
paddingRight,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (scrollable) {
|
|
82
|
+
return (
|
|
83
|
+
<ScrollView
|
|
84
|
+
horizontal
|
|
85
|
+
showsHorizontalScrollIndicator={false}
|
|
86
|
+
accessibilityRole="tablist"
|
|
87
|
+
contentContainerStyle={[containerStyle, style]}
|
|
88
|
+
>
|
|
89
|
+
{enhancedChildren}
|
|
90
|
+
</ScrollView>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<View
|
|
96
|
+
style={[containerStyle, style]}
|
|
97
|
+
accessibilityRole="tablist"
|
|
98
|
+
>
|
|
99
|
+
{enhancedChildren}
|
|
100
|
+
</View>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { TabItem }
|
|
105
|
+
export default Tabs
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react'
|
|
2
|
+
import { StyleSheet, Text, type StyleProp, type ViewStyle } from 'react-native'
|
|
3
|
+
import Animated, { FadeIn, FadeOut, SlideInDown, SlideInUp } from 'react-native-reanimated'
|
|
4
|
+
import { getVariableByName } from '../../design-tokens/figma-variables-resolver'
|
|
5
|
+
import { closeToast, type ToastPlacement } from './useToast'
|
|
6
|
+
|
|
7
|
+
export interface ToastProps {
|
|
8
|
+
id: string
|
|
9
|
+
title: string
|
|
10
|
+
timeout?: number | undefined
|
|
11
|
+
onClose?: (() => void) | undefined
|
|
12
|
+
modes?: Record<string, any> | undefined
|
|
13
|
+
placement?: ToastPlacement | undefined
|
|
14
|
+
style?: StyleProp<ViewStyle> | undefined
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ANIMATION_DURATION = 250
|
|
18
|
+
|
|
19
|
+
function Toast({
|
|
20
|
+
id,
|
|
21
|
+
title,
|
|
22
|
+
timeout = 4000,
|
|
23
|
+
onClose,
|
|
24
|
+
modes = {},
|
|
25
|
+
placement = 'bottom',
|
|
26
|
+
style,
|
|
27
|
+
}: ToastProps) {
|
|
28
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
29
|
+
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (timeout <= 0) return
|
|
32
|
+
timerRef.current = setTimeout(() => closeToast(id), timeout)
|
|
33
|
+
return () => {
|
|
34
|
+
if (timerRef.current) clearTimeout(timerRef.current)
|
|
35
|
+
}
|
|
36
|
+
}, [id, timeout])
|
|
37
|
+
|
|
38
|
+
const backgroundColor = getVariableByName('toast/background', modes) || '#303338'
|
|
39
|
+
const foreground = getVariableByName('toast/foreground', modes) || '#ffffff'
|
|
40
|
+
const fontSize = getVariableByName('toast/fontSize', modes) || 14
|
|
41
|
+
const fontFamily = getVariableByName('toast/fontFamily', modes) || undefined
|
|
42
|
+
const fontWeight = getVariableByName('toast/fontWeight', modes) || '500'
|
|
43
|
+
const lineHeight = getVariableByName('toast/lineHeight', modes) || 18
|
|
44
|
+
const radius = getVariableByName('toast/radius', modes) || 14
|
|
45
|
+
const paddingHorizontal = getVariableByName('toast/padding/horizontal', modes) || 16
|
|
46
|
+
const paddingVertical = getVariableByName('toast/padding/vertical', modes) || 14
|
|
47
|
+
const gap = getVariableByName('toast/gap', modes) || 8
|
|
48
|
+
const borderWidth = getVariableByName('toast/border/size', modes) || 1
|
|
49
|
+
const borderColor = getVariableByName('toast/border/color', modes) || 'rgba(255,255,255,0.1)'
|
|
50
|
+
const shadowBlurPrimary = getVariableByName('toast/shadow/primary/blur', modes) || 48
|
|
51
|
+
const shadowOffsetYPrimary = getVariableByName('toast/shadow/primary/offsetY', modes) || 16
|
|
52
|
+
const shadowColorPrimary = getVariableByName('toast/shadow/primary/color', modes) || 'rgba(13,13,15,0.16)'
|
|
53
|
+
|
|
54
|
+
const enterAnimation = placement === 'top'
|
|
55
|
+
? SlideInUp.duration(ANIMATION_DURATION)
|
|
56
|
+
: SlideInDown.duration(ANIMATION_DURATION)
|
|
57
|
+
|
|
58
|
+
const containerStyle: ViewStyle = {
|
|
59
|
+
backgroundColor,
|
|
60
|
+
borderRadius: radius,
|
|
61
|
+
paddingHorizontal,
|
|
62
|
+
paddingVertical,
|
|
63
|
+
gap,
|
|
64
|
+
borderWidth,
|
|
65
|
+
borderColor,
|
|
66
|
+
shadowColor: shadowColorPrimary,
|
|
67
|
+
shadowOffset: { width: 0, height: shadowOffsetYPrimary },
|
|
68
|
+
shadowOpacity: 1,
|
|
69
|
+
shadowRadius: shadowBlurPrimary / 2,
|
|
70
|
+
elevation: 8,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const textStyle = {
|
|
74
|
+
color: foreground,
|
|
75
|
+
fontSize,
|
|
76
|
+
fontFamily,
|
|
77
|
+
fontWeight: String(fontWeight) as any,
|
|
78
|
+
lineHeight,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Animated.View
|
|
83
|
+
entering={enterAnimation}
|
|
84
|
+
exiting={FadeOut.duration(ANIMATION_DURATION)}
|
|
85
|
+
style={[styles.toast, containerStyle, style]}
|
|
86
|
+
accessibilityRole="alert"
|
|
87
|
+
accessibilityLiveRegion="assertive"
|
|
88
|
+
>
|
|
89
|
+
<Text style={textStyle} numberOfLines={2}>
|
|
90
|
+
{title}
|
|
91
|
+
</Text>
|
|
92
|
+
</Animated.View>
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const styles = StyleSheet.create({
|
|
97
|
+
toast: {
|
|
98
|
+
alignSelf: 'center',
|
|
99
|
+
maxWidth: '90%',
|
|
100
|
+
minWidth: 200,
|
|
101
|
+
overflow: 'hidden',
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
export default Toast
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React, { useMemo } from 'react'
|
|
2
|
+
import { StyleSheet, View } from 'react-native'
|
|
3
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
|
4
|
+
import { useToast, type ToastPlacement } from './useToast'
|
|
5
|
+
import Toast from './Toast'
|
|
6
|
+
|
|
7
|
+
export interface ToastProviderProps {
|
|
8
|
+
children: React.ReactNode
|
|
9
|
+
maxVisibleToasts?: number
|
|
10
|
+
placement?: ToastPlacement
|
|
11
|
+
modes?: Record<string, any>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function ToastProvider({
|
|
15
|
+
children,
|
|
16
|
+
maxVisibleToasts = 3,
|
|
17
|
+
placement = 'bottom',
|
|
18
|
+
modes,
|
|
19
|
+
}: ToastProviderProps) {
|
|
20
|
+
const { toasts } = useToast()
|
|
21
|
+
const insets = useSafeAreaInsets()
|
|
22
|
+
|
|
23
|
+
const visibleToasts = useMemo(
|
|
24
|
+
() => toasts.slice(-maxVisibleToasts),
|
|
25
|
+
[toasts, maxVisibleToasts],
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const regionStyle = useMemo(
|
|
29
|
+
() => [
|
|
30
|
+
styles.region,
|
|
31
|
+
placement === 'top'
|
|
32
|
+
? { top: insets.top + 8 }
|
|
33
|
+
: { bottom: insets.bottom + 8 },
|
|
34
|
+
],
|
|
35
|
+
[placement, insets.top, insets.bottom],
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<View style={styles.container}>
|
|
40
|
+
{children}
|
|
41
|
+
{visibleToasts.length > 0 && (
|
|
42
|
+
<View style={regionStyle} pointerEvents="box-none">
|
|
43
|
+
{visibleToasts.map((entry) => (
|
|
44
|
+
<Toast
|
|
45
|
+
key={entry.id}
|
|
46
|
+
id={entry.id}
|
|
47
|
+
title={entry.title}
|
|
48
|
+
timeout={entry.timeout}
|
|
49
|
+
onClose={entry.onClose}
|
|
50
|
+
modes={entry.modes ?? modes}
|
|
51
|
+
placement={placement}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</View>
|
|
55
|
+
)}
|
|
56
|
+
</View>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const styles = StyleSheet.create({
|
|
61
|
+
container: {
|
|
62
|
+
flex: 1,
|
|
63
|
+
},
|
|
64
|
+
region: {
|
|
65
|
+
position: 'absolute',
|
|
66
|
+
left: 0,
|
|
67
|
+
right: 0,
|
|
68
|
+
alignItems: 'center',
|
|
69
|
+
gap: 8,
|
|
70
|
+
zIndex: 9999,
|
|
71
|
+
pointerEvents: 'box-none',
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
export default ToastProvider
|