@retray-dev/ui-kit 1.7.0 → 2.3.0
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/COMPONENTS.md +258 -54
- package/README.md +6 -5
- package/dist/index.d.mts +113 -44
- package/dist/index.d.ts +113 -44
- package/dist/index.js +802 -324
- package/dist/index.mjs +794 -323
- package/package.json +6 -2
- package/src/components/Alert/Alert.tsx +24 -12
- package/src/components/AlertBanner/AlertBanner.tsx +83 -0
- package/src/components/AlertBanner/index.ts +2 -0
- package/src/components/Avatar/Avatar.tsx +1 -0
- package/src/components/Badge/Badge.tsx +44 -8
- package/src/components/Button/Button.tsx +12 -5
- package/src/components/Card/Card.tsx +86 -9
- package/src/components/Chip/Chip.tsx +173 -0
- package/src/components/Chip/index.ts +2 -0
- package/src/components/ConfirmDialog/ConfirmDialog.tsx +87 -0
- package/src/components/ConfirmDialog/index.ts +2 -0
- package/src/components/CurrencyDisplay/CurrencyDisplay.tsx +4 -2
- package/src/components/CurrencyInput/CurrencyInput.tsx +9 -1
- package/src/components/EmptyState/EmptyState.tsx +42 -7
- package/src/components/Input/Input.tsx +102 -21
- package/src/components/LabelValue/LabelValue.tsx +47 -0
- package/src/components/LabelValue/index.ts +2 -0
- package/src/components/ListItem/ListItem.tsx +121 -0
- package/src/components/ListItem/index.ts +2 -0
- package/src/components/MonthPicker/MonthPicker.tsx +92 -0
- package/src/components/MonthPicker/index.ts +2 -0
- package/src/components/Select/Select.tsx +189 -125
- package/src/components/Slider/Slider.tsx +64 -100
- package/src/components/Switch/Switch.tsx +25 -21
- package/src/components/Textarea/Textarea.tsx +12 -2
- package/src/components/Toggle/Toggle.tsx +13 -6
- package/src/index.ts +8 -2
- package/src/theme/ThemeProvider.tsx +11 -8
- package/src/theme/colors.ts +19 -18
- package/src/theme/types.ts +2 -0
- package/src/components/Alert/index.ts +0 -2
- package/src/components/CurrencyInputLarge/CurrencyInputLarge.tsx +0 -66
- package/src/components/CurrencyInputLarge/index.ts +0 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Modal, View, Text, StyleSheet, TouchableOpacity } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../theme'
|
|
4
|
+
import { Button } from '../Button'
|
|
5
|
+
|
|
6
|
+
export interface ConfirmDialogProps {
|
|
7
|
+
visible: boolean
|
|
8
|
+
title: string
|
|
9
|
+
description?: string
|
|
10
|
+
confirmLabel?: string
|
|
11
|
+
cancelLabel?: string
|
|
12
|
+
confirmVariant?: 'primary' | 'destructive'
|
|
13
|
+
onConfirm: () => void
|
|
14
|
+
onCancel: () => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ConfirmDialog({
|
|
18
|
+
visible,
|
|
19
|
+
title,
|
|
20
|
+
description,
|
|
21
|
+
confirmLabel = 'Confirm',
|
|
22
|
+
cancelLabel = 'Cancel',
|
|
23
|
+
confirmVariant = 'primary',
|
|
24
|
+
onConfirm,
|
|
25
|
+
onCancel,
|
|
26
|
+
}: ConfirmDialogProps) {
|
|
27
|
+
const { colors } = useTheme()
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<Modal visible={visible} transparent animationType="fade" onRequestClose={onCancel}>
|
|
31
|
+
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={onCancel}>
|
|
32
|
+
<View
|
|
33
|
+
style={[styles.dialog, { backgroundColor: colors.card }]}
|
|
34
|
+
onStartShouldSetResponder={() => true}
|
|
35
|
+
>
|
|
36
|
+
<Text style={[styles.title, { color: colors.cardForeground }]} allowFontScaling={true}>
|
|
37
|
+
{title}
|
|
38
|
+
</Text>
|
|
39
|
+
{description ? (
|
|
40
|
+
<Text style={[styles.description, { color: colors.mutedForeground }]} allowFontScaling={true}>
|
|
41
|
+
{description}
|
|
42
|
+
</Text>
|
|
43
|
+
) : null}
|
|
44
|
+
<View style={styles.actions}>
|
|
45
|
+
<Button label={cancelLabel} variant="outline" fullWidth onPress={onCancel} />
|
|
46
|
+
<Button label={confirmLabel} variant={confirmVariant} fullWidth onPress={onConfirm} />
|
|
47
|
+
</View>
|
|
48
|
+
</View>
|
|
49
|
+
</TouchableOpacity>
|
|
50
|
+
</Modal>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const styles = StyleSheet.create({
|
|
55
|
+
overlay: {
|
|
56
|
+
flex: 1,
|
|
57
|
+
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
58
|
+
justifyContent: 'center',
|
|
59
|
+
alignItems: 'center',
|
|
60
|
+
padding: 24,
|
|
61
|
+
},
|
|
62
|
+
dialog: {
|
|
63
|
+
width: '100%',
|
|
64
|
+
maxWidth: 400,
|
|
65
|
+
borderRadius: 16,
|
|
66
|
+
padding: 24,
|
|
67
|
+
gap: 12,
|
|
68
|
+
shadowColor: '#000',
|
|
69
|
+
shadowOffset: { width: 0, height: 8 },
|
|
70
|
+
shadowOpacity: 0.15,
|
|
71
|
+
shadowRadius: 16,
|
|
72
|
+
elevation: 8,
|
|
73
|
+
},
|
|
74
|
+
title: {
|
|
75
|
+
fontSize: 18,
|
|
76
|
+
fontWeight: '600',
|
|
77
|
+
lineHeight: 26,
|
|
78
|
+
},
|
|
79
|
+
description: {
|
|
80
|
+
fontSize: 15,
|
|
81
|
+
lineHeight: 22,
|
|
82
|
+
},
|
|
83
|
+
actions: {
|
|
84
|
+
gap: 10,
|
|
85
|
+
marginTop: 8,
|
|
86
|
+
},
|
|
87
|
+
})
|
|
@@ -8,6 +8,8 @@ export interface CurrencyDisplayProps {
|
|
|
8
8
|
prefix?: string
|
|
9
9
|
/** When true, shows two decimal places separated by a comma (e.g. `$25.000,00`). Defaults to `false`. */
|
|
10
10
|
showDecimals?: boolean
|
|
11
|
+
/** Override the color of the formatted text. Defaults to the `foreground` theme token. */
|
|
12
|
+
textColor?: string
|
|
11
13
|
style?: ViewStyle
|
|
12
14
|
}
|
|
13
15
|
|
|
@@ -24,13 +26,13 @@ function formatValue(value: number | string, prefix: string, showDecimals: boole
|
|
|
24
26
|
return `${sign}${prefix}${intPart}`
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
export function CurrencyDisplay({ value, prefix = '$', showDecimals = false, style }: CurrencyDisplayProps) {
|
|
29
|
+
export function CurrencyDisplay({ value, prefix = '$', showDecimals = false, textColor, style }: CurrencyDisplayProps) {
|
|
28
30
|
const { colors } = useTheme()
|
|
29
31
|
const formatted = formatValue(value, prefix, showDecimals)
|
|
30
32
|
|
|
31
33
|
return (
|
|
32
34
|
<View style={[styles.container, style]}>
|
|
33
|
-
<Text style={[styles.amount, { color: colors.foreground }]} allowFontScaling={true}>
|
|
35
|
+
<Text style={[styles.amount, { color: textColor ?? colors.foreground }]} allowFontScaling={true}>
|
|
34
36
|
{formatted}
|
|
35
37
|
</Text>
|
|
36
38
|
</View>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from 'react'
|
|
2
|
-
import { ViewStyle } from 'react-native'
|
|
2
|
+
import { ViewStyle, TextStyle } from 'react-native'
|
|
3
3
|
import { Input } from '../Input'
|
|
4
4
|
|
|
5
5
|
export interface CurrencyInputProps {
|
|
@@ -11,6 +11,8 @@ export interface CurrencyInputProps {
|
|
|
11
11
|
prefix?: string
|
|
12
12
|
/** Character used to separate groups of three digits. Defaults to `'.'`. */
|
|
13
13
|
thousandsSeparator?: '.' | ','
|
|
14
|
+
/** Font size variant. `'large'` renders at 36pt, `'default'` at 17pt. */
|
|
15
|
+
size?: 'default' | 'large'
|
|
14
16
|
label?: string
|
|
15
17
|
/** Red helper text; also changes input border to destructive color. */
|
|
16
18
|
error?: string
|
|
@@ -18,6 +20,7 @@ export interface CurrencyInputProps {
|
|
|
18
20
|
placeholder?: string
|
|
19
21
|
editable?: boolean
|
|
20
22
|
containerStyle?: ViewStyle
|
|
23
|
+
style?: TextStyle
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
function formatCurrency(raw: string, separator: '.' | ','): string {
|
|
@@ -32,12 +35,14 @@ export function CurrencyInput({
|
|
|
32
35
|
onChangeValue,
|
|
33
36
|
prefix = '$',
|
|
34
37
|
thousandsSeparator = '.',
|
|
38
|
+
size = 'default',
|
|
35
39
|
label,
|
|
36
40
|
error,
|
|
37
41
|
hint,
|
|
38
42
|
placeholder,
|
|
39
43
|
editable,
|
|
40
44
|
containerStyle,
|
|
45
|
+
style,
|
|
41
46
|
}: CurrencyInputProps) {
|
|
42
47
|
const handleChange = (text: string) => {
|
|
43
48
|
const withoutPrefix = prefix && text.startsWith(prefix) ? text.slice(prefix.length) : text
|
|
@@ -49,6 +54,8 @@ export function CurrencyInput({
|
|
|
49
54
|
onChangeValue?.(isNaN(raw) ? 0 : raw)
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
const inputStyle: TextStyle = size === 'large' ? { fontSize: 36 } : {}
|
|
58
|
+
|
|
52
59
|
return (
|
|
53
60
|
<Input
|
|
54
61
|
value={value}
|
|
@@ -60,6 +67,7 @@ export function CurrencyInput({
|
|
|
60
67
|
placeholder={placeholder ?? `${prefix}0`}
|
|
61
68
|
editable={editable}
|
|
62
69
|
containerStyle={containerStyle}
|
|
70
|
+
style={[inputStyle, style]}
|
|
63
71
|
/>
|
|
64
72
|
)
|
|
65
73
|
}
|
|
@@ -7,24 +7,47 @@ export interface EmptyStateProps {
|
|
|
7
7
|
title: string
|
|
8
8
|
description?: string
|
|
9
9
|
action?: React.ReactNode
|
|
10
|
+
/** `compact` hides description/action and uses tighter spacing and a smaller icon. */
|
|
11
|
+
size?: 'default' | 'compact'
|
|
10
12
|
style?: ViewStyle
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export function EmptyState({ icon, title, description, action, style }: EmptyStateProps) {
|
|
15
|
+
export function EmptyState({ icon, title, description, action, size = 'default', style }: EmptyStateProps) {
|
|
14
16
|
const { colors } = useTheme()
|
|
17
|
+
const isCompact = size === 'compact'
|
|
15
18
|
|
|
16
19
|
return (
|
|
17
|
-
<View
|
|
20
|
+
<View
|
|
21
|
+
style={[
|
|
22
|
+
styles.container,
|
|
23
|
+
isCompact && styles.containerCompact,
|
|
24
|
+
{ borderColor: colors.border },
|
|
25
|
+
style,
|
|
26
|
+
]}
|
|
27
|
+
>
|
|
18
28
|
{icon ? (
|
|
19
|
-
<View
|
|
29
|
+
<View
|
|
30
|
+
style={[
|
|
31
|
+
styles.iconWrapper,
|
|
32
|
+
isCompact && styles.iconWrapperCompact,
|
|
33
|
+
{ backgroundColor: colors.muted },
|
|
34
|
+
]}
|
|
35
|
+
>
|
|
36
|
+
{icon}
|
|
37
|
+
</View>
|
|
20
38
|
) : null}
|
|
21
39
|
<View style={styles.textWrapper}>
|
|
22
|
-
<Text
|
|
23
|
-
|
|
24
|
-
|
|
40
|
+
<Text
|
|
41
|
+
style={[styles.title, isCompact && styles.titleCompact, { color: colors.foreground }]}
|
|
42
|
+
allowFontScaling={true}
|
|
43
|
+
>
|
|
44
|
+
{title}
|
|
45
|
+
</Text>
|
|
46
|
+
{description && !isCompact ? (
|
|
47
|
+
<Text style={[styles.description, { color: colors.mutedForeground }]} allowFontScaling={true}>{description}</Text>
|
|
25
48
|
) : null}
|
|
26
49
|
</View>
|
|
27
|
-
{action ? <View style={styles.action}>{action}</View> : null}
|
|
50
|
+
{action && !isCompact ? <View style={styles.action}>{action}</View> : null}
|
|
28
51
|
</View>
|
|
29
52
|
)
|
|
30
53
|
}
|
|
@@ -39,6 +62,10 @@ const styles = StyleSheet.create({
|
|
|
39
62
|
padding: 32,
|
|
40
63
|
gap: 16,
|
|
41
64
|
},
|
|
65
|
+
containerCompact: {
|
|
66
|
+
padding: 20,
|
|
67
|
+
gap: 10,
|
|
68
|
+
},
|
|
42
69
|
iconWrapper: {
|
|
43
70
|
width: 48,
|
|
44
71
|
height: 48,
|
|
@@ -46,6 +73,11 @@ const styles = StyleSheet.create({
|
|
|
46
73
|
alignItems: 'center',
|
|
47
74
|
justifyContent: 'center',
|
|
48
75
|
},
|
|
76
|
+
iconWrapperCompact: {
|
|
77
|
+
width: 36,
|
|
78
|
+
height: 36,
|
|
79
|
+
borderRadius: 8,
|
|
80
|
+
},
|
|
49
81
|
textWrapper: {
|
|
50
82
|
alignItems: 'center',
|
|
51
83
|
gap: 8,
|
|
@@ -56,6 +88,9 @@ const styles = StyleSheet.create({
|
|
|
56
88
|
fontWeight: '500',
|
|
57
89
|
textAlign: 'center',
|
|
58
90
|
},
|
|
91
|
+
titleCompact: {
|
|
92
|
+
fontSize: 15,
|
|
93
|
+
},
|
|
59
94
|
description: {
|
|
60
95
|
fontSize: 14,
|
|
61
96
|
lineHeight: 20,
|
|
@@ -1,46 +1,104 @@
|
|
|
1
1
|
import React, { useState } from 'react'
|
|
2
|
-
import { TextInput, View, Text, StyleSheet, TextInputProps, ViewStyle } from 'react-native'
|
|
2
|
+
import { TextInput, View, Text, StyleSheet, TextInputProps, ViewStyle, TextStyle, TouchableOpacity, Platform } from 'react-native'
|
|
3
3
|
import { useTheme } from '../../theme'
|
|
4
4
|
|
|
5
|
+
const webInputResetStyle: any =
|
|
6
|
+
Platform.OS === 'web'
|
|
7
|
+
? { outlineStyle: 'none', outlineWidth: 0, outlineColor: 'transparent', boxShadow: 'none' }
|
|
8
|
+
: {}
|
|
9
|
+
|
|
5
10
|
export interface InputProps extends TextInputProps {
|
|
6
11
|
label?: string
|
|
7
12
|
/** Red helper text below the input; also changes border to `destructive` color. Takes priority over `hint`. */
|
|
8
13
|
error?: string
|
|
9
14
|
/** Helper text shown below the input when there is no error. */
|
|
10
15
|
hint?: string
|
|
11
|
-
/**
|
|
16
|
+
/** Text or component rendered before the input text. */
|
|
17
|
+
prefix?: React.ReactNode
|
|
18
|
+
/** Text or component rendered after the input text. */
|
|
19
|
+
suffix?: React.ReactNode
|
|
20
|
+
/** Style applied to prefix text if prefix is a string. */
|
|
21
|
+
prefixStyle?: TextStyle
|
|
22
|
+
/** Style applied to suffix text if suffix is a string. */
|
|
23
|
+
suffixStyle?: TextStyle
|
|
24
|
+
/** Input type. When set to \`'password'\`, shows a toggle button to reveal/hide text. */
|
|
25
|
+
type?: 'text' | 'password'
|
|
26
|
+
/** Style for the outer container \`View\`. Use \`style\` (from \`TextInputProps\`) to style the \`TextInput\` itself. */
|
|
12
27
|
containerStyle?: ViewStyle
|
|
13
28
|
}
|
|
14
29
|
|
|
15
|
-
export function Input({ label, error, hint, containerStyle, style, onFocus, onBlur, ...props }: InputProps) {
|
|
30
|
+
export function Input({ label, error, hint, prefix, suffix, prefixStyle, suffixStyle, type = 'text', containerStyle, style, onFocus, onBlur, secureTextEntry, ...props }: InputProps) {
|
|
16
31
|
const { colors } = useTheme()
|
|
17
32
|
const [focused, setFocused] = useState(false)
|
|
33
|
+
const [showPassword, setShowPassword] = useState(false)
|
|
34
|
+
|
|
35
|
+
const isPassword = type === 'password'
|
|
36
|
+
const effectiveSecure = isPassword ? !showPassword : secureTextEntry
|
|
37
|
+
|
|
38
|
+
// If type is password and no suffix is provided, add the toggle button
|
|
39
|
+
const effectiveSuffix = isPassword && !suffix ? (
|
|
40
|
+
<TouchableOpacity onPress={() => setShowPassword(!showPassword)} style={styles.passwordToggle} activeOpacity={0.6}>
|
|
41
|
+
<Text style={[styles.suffixText, { color: colors.mutedForeground }]}>{showPassword ? '👁' : '👁🗨'}</Text>
|
|
42
|
+
</TouchableOpacity>
|
|
43
|
+
) : suffix
|
|
18
44
|
|
|
19
45
|
return (
|
|
20
46
|
<View style={[styles.container, containerStyle]}>
|
|
21
47
|
{label ? <Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>{label}</Text> : null}
|
|
22
|
-
<
|
|
48
|
+
<View
|
|
23
49
|
style={[
|
|
24
|
-
styles.
|
|
50
|
+
styles.inputWrapper,
|
|
25
51
|
{
|
|
26
|
-
borderColor: error
|
|
27
|
-
|
|
52
|
+
borderColor: error
|
|
53
|
+
? colors.destructive
|
|
54
|
+
: focused
|
|
55
|
+
? (colors.ring ?? colors.primary)
|
|
56
|
+
: colors.border,
|
|
28
57
|
backgroundColor: colors.background,
|
|
29
58
|
},
|
|
30
|
-
style,
|
|
31
59
|
]}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
60
|
+
>
|
|
61
|
+
{prefix ? (
|
|
62
|
+
typeof prefix === 'string' ? (
|
|
63
|
+
<Text style={[styles.prefixText, { color: colors.mutedForeground }, prefixStyle]} allowFontScaling={true}>
|
|
64
|
+
{prefix}
|
|
65
|
+
</Text>
|
|
66
|
+
) : (
|
|
67
|
+
<View style={styles.prefixContainer}>{prefix}</View>
|
|
68
|
+
)
|
|
69
|
+
) : null}
|
|
70
|
+
<TextInput
|
|
71
|
+
style={[
|
|
72
|
+
styles.input,
|
|
73
|
+
{
|
|
74
|
+
color: colors.foreground,
|
|
75
|
+
},
|
|
76
|
+
webInputResetStyle,
|
|
77
|
+
style,
|
|
78
|
+
]}
|
|
79
|
+
onFocus={(e) => {
|
|
80
|
+
setFocused(true)
|
|
81
|
+
onFocus?.(e)
|
|
82
|
+
}}
|
|
83
|
+
onBlur={(e) => {
|
|
84
|
+
setFocused(false)
|
|
85
|
+
onBlur?.(e)
|
|
86
|
+
}}
|
|
87
|
+
placeholderTextColor={colors.mutedForeground}
|
|
88
|
+
allowFontScaling={true}
|
|
89
|
+
secureTextEntry={effectiveSecure}
|
|
90
|
+
{...props}
|
|
91
|
+
/>
|
|
92
|
+
{effectiveSuffix ? (
|
|
93
|
+
typeof effectiveSuffix === 'string' ? (
|
|
94
|
+
<Text style={[styles.suffixText, { color: colors.mutedForeground }, suffixStyle]} allowFontScaling={true}>
|
|
95
|
+
{effectiveSuffix}
|
|
96
|
+
</Text>
|
|
97
|
+
) : (
|
|
98
|
+
<View style={styles.suffixContainer}>{effectiveSuffix}</View>
|
|
99
|
+
)
|
|
100
|
+
) : null}
|
|
101
|
+
</View>
|
|
44
102
|
{error ? (
|
|
45
103
|
<Text style={[styles.helperText, { color: colors.destructive }]} allowFontScaling={true}>{error}</Text>
|
|
46
104
|
) : null}
|
|
@@ -60,12 +118,35 @@ const styles = StyleSheet.create({
|
|
|
60
118
|
fontWeight: '500',
|
|
61
119
|
marginBottom: 6,
|
|
62
120
|
},
|
|
63
|
-
|
|
121
|
+
inputWrapper: {
|
|
122
|
+
flexDirection: 'row',
|
|
123
|
+
alignItems: 'center',
|
|
64
124
|
borderWidth: 1.5,
|
|
65
125
|
borderRadius: 14,
|
|
66
126
|
paddingHorizontal: 20,
|
|
67
127
|
paddingVertical: 16,
|
|
128
|
+
},
|
|
129
|
+
input: {
|
|
130
|
+
flex: 1,
|
|
68
131
|
fontSize: 17,
|
|
132
|
+
paddingVertical: 0,
|
|
133
|
+
},
|
|
134
|
+
prefixContainer: {
|
|
135
|
+
marginRight: 8,
|
|
136
|
+
},
|
|
137
|
+
prefixText: {
|
|
138
|
+
fontSize: 17,
|
|
139
|
+
marginRight: 8,
|
|
140
|
+
},
|
|
141
|
+
suffixContainer: {
|
|
142
|
+
marginLeft: 8,
|
|
143
|
+
},
|
|
144
|
+
suffixText: {
|
|
145
|
+
fontSize: 17,
|
|
146
|
+
marginLeft: 8,
|
|
147
|
+
},
|
|
148
|
+
passwordToggle: {
|
|
149
|
+
padding: 4,
|
|
69
150
|
},
|
|
70
151
|
helperText: {
|
|
71
152
|
fontSize: 13,
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import { useTheme } from '../../theme'
|
|
4
|
+
|
|
5
|
+
export interface LabelValueProps {
|
|
6
|
+
label: string
|
|
7
|
+
value: string | React.ReactNode
|
|
8
|
+
style?: ViewStyle
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function LabelValue({ label, value, style }: LabelValueProps) {
|
|
12
|
+
const { colors } = useTheme()
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<View style={[styles.container, style]}>
|
|
16
|
+
<Text style={[styles.label, { color: colors.mutedForeground }]} allowFontScaling={true}>
|
|
17
|
+
{label}
|
|
18
|
+
</Text>
|
|
19
|
+
{typeof value === 'string' ? (
|
|
20
|
+
<Text style={[styles.value, { color: colors.foreground }]} allowFontScaling={true}>
|
|
21
|
+
{value}
|
|
22
|
+
</Text>
|
|
23
|
+
) : (
|
|
24
|
+
value
|
|
25
|
+
)}
|
|
26
|
+
</View>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const styles = StyleSheet.create({
|
|
31
|
+
container: {
|
|
32
|
+
flexDirection: 'row',
|
|
33
|
+
justifyContent: 'space-between',
|
|
34
|
+
alignItems: 'center',
|
|
35
|
+
gap: 12,
|
|
36
|
+
},
|
|
37
|
+
label: {
|
|
38
|
+
fontSize: 13,
|
|
39
|
+
lineHeight: 18,
|
|
40
|
+
},
|
|
41
|
+
value: {
|
|
42
|
+
fontSize: 15,
|
|
43
|
+
fontWeight: '500',
|
|
44
|
+
lineHeight: 22,
|
|
45
|
+
textAlign: 'right',
|
|
46
|
+
},
|
|
47
|
+
})
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React, { useRef } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
TouchableOpacity,
|
|
4
|
+
Animated,
|
|
5
|
+
View,
|
|
6
|
+
Text,
|
|
7
|
+
StyleSheet,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
Platform,
|
|
10
|
+
} from 'react-native'
|
|
11
|
+
import * as Haptics from 'expo-haptics'
|
|
12
|
+
import { useTheme } from '../../theme'
|
|
13
|
+
|
|
14
|
+
const nativeDriver = Platform.OS !== 'web'
|
|
15
|
+
|
|
16
|
+
export interface ListItemProps {
|
|
17
|
+
icon?: React.ReactNode
|
|
18
|
+
title: string
|
|
19
|
+
subtitle?: string
|
|
20
|
+
trailing?: string | React.ReactNode
|
|
21
|
+
onPress?: () => void
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
style?: ViewStyle
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ListItem({ icon, title, subtitle, trailing, onPress, disabled, style }: ListItemProps) {
|
|
27
|
+
const { colors } = useTheme()
|
|
28
|
+
const scale = useRef(new Animated.Value(1)).current
|
|
29
|
+
|
|
30
|
+
const handlePressIn = () => {
|
|
31
|
+
if (!onPress || disabled) return
|
|
32
|
+
Animated.spring(scale, {
|
|
33
|
+
toValue: 0.97,
|
|
34
|
+
useNativeDriver: nativeDriver,
|
|
35
|
+
speed: 40,
|
|
36
|
+
bounciness: 0,
|
|
37
|
+
}).start()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const handlePressOut = () => {
|
|
41
|
+
Animated.spring(scale, {
|
|
42
|
+
toValue: 1,
|
|
43
|
+
useNativeDriver: nativeDriver,
|
|
44
|
+
speed: 40,
|
|
45
|
+
bounciness: 4,
|
|
46
|
+
}).start()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const handlePress = () => {
|
|
50
|
+
Haptics.selectionAsync()
|
|
51
|
+
onPress?.()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<Animated.View style={[{ transform: [{ scale }] }, disabled && styles.disabled]}>
|
|
56
|
+
<TouchableOpacity
|
|
57
|
+
style={[styles.container, style]}
|
|
58
|
+
onPress={onPress ? handlePress : undefined}
|
|
59
|
+
onPressIn={handlePressIn}
|
|
60
|
+
onPressOut={handlePressOut}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
activeOpacity={1}
|
|
63
|
+
touchSoundDisabled={true}
|
|
64
|
+
>
|
|
65
|
+
{icon ? <View style={styles.iconWrapper}>{icon}</View> : null}
|
|
66
|
+
<View style={styles.content}>
|
|
67
|
+
<Text style={[styles.title, { color: colors.foreground }]} allowFontScaling={true}>
|
|
68
|
+
{title}
|
|
69
|
+
</Text>
|
|
70
|
+
{subtitle ? (
|
|
71
|
+
<Text style={[styles.subtitle, { color: colors.mutedForeground }]} allowFontScaling={true}>
|
|
72
|
+
{subtitle}
|
|
73
|
+
</Text>
|
|
74
|
+
) : null}
|
|
75
|
+
</View>
|
|
76
|
+
{trailing !== undefined ? (
|
|
77
|
+
typeof trailing === 'string' ? (
|
|
78
|
+
<Text style={[styles.trailing, { color: colors.mutedForeground }]} allowFontScaling={true}>
|
|
79
|
+
{trailing}
|
|
80
|
+
</Text>
|
|
81
|
+
) : (
|
|
82
|
+
trailing
|
|
83
|
+
)
|
|
84
|
+
) : null}
|
|
85
|
+
</TouchableOpacity>
|
|
86
|
+
</Animated.View>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const styles = StyleSheet.create({
|
|
91
|
+
container: {
|
|
92
|
+
flexDirection: 'row',
|
|
93
|
+
alignItems: 'center',
|
|
94
|
+
paddingHorizontal: 16,
|
|
95
|
+
paddingVertical: 14,
|
|
96
|
+
gap: 12,
|
|
97
|
+
},
|
|
98
|
+
iconWrapper: {
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
justifyContent: 'center',
|
|
101
|
+
},
|
|
102
|
+
content: {
|
|
103
|
+
flex: 1,
|
|
104
|
+
gap: 3,
|
|
105
|
+
},
|
|
106
|
+
title: {
|
|
107
|
+
fontSize: 16,
|
|
108
|
+
fontWeight: '500',
|
|
109
|
+
lineHeight: 22,
|
|
110
|
+
},
|
|
111
|
+
subtitle: {
|
|
112
|
+
fontSize: 13,
|
|
113
|
+
lineHeight: 18,
|
|
114
|
+
},
|
|
115
|
+
trailing: {
|
|
116
|
+
fontSize: 15,
|
|
117
|
+
},
|
|
118
|
+
disabled: {
|
|
119
|
+
opacity: 0.45,
|
|
120
|
+
},
|
|
121
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { View, Text, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native'
|
|
3
|
+
import * as Haptics from 'expo-haptics'
|
|
4
|
+
import { useTheme } from '../../theme'
|
|
5
|
+
|
|
6
|
+
const MONTH_NAMES = [
|
|
7
|
+
'January', 'February', 'March', 'April', 'May', 'June',
|
|
8
|
+
'July', 'August', 'September', 'October', 'November', 'December',
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
export interface MonthPickerValue {
|
|
12
|
+
/** Month number 1–12 */
|
|
13
|
+
month: number
|
|
14
|
+
year: number
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MonthPickerProps {
|
|
18
|
+
value: MonthPickerValue
|
|
19
|
+
onChange: (value: MonthPickerValue) => void
|
|
20
|
+
style?: ViewStyle
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function MonthPicker({ value, onChange, style }: MonthPickerProps) {
|
|
24
|
+
const { colors } = useTheme()
|
|
25
|
+
|
|
26
|
+
const handlePrev = () => {
|
|
27
|
+
Haptics.selectionAsync()
|
|
28
|
+
if (value.month === 1) {
|
|
29
|
+
onChange({ month: 12, year: value.year - 1 })
|
|
30
|
+
} else {
|
|
31
|
+
onChange({ month: value.month - 1, year: value.year })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const handleNext = () => {
|
|
36
|
+
Haptics.selectionAsync()
|
|
37
|
+
if (value.month === 12) {
|
|
38
|
+
onChange({ month: 1, year: value.year + 1 })
|
|
39
|
+
} else {
|
|
40
|
+
onChange({ month: value.month + 1, year: value.year })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View style={[styles.container, style]}>
|
|
46
|
+
<TouchableOpacity
|
|
47
|
+
style={styles.arrow}
|
|
48
|
+
onPress={handlePrev}
|
|
49
|
+
activeOpacity={0.6}
|
|
50
|
+
touchSoundDisabled={true}
|
|
51
|
+
>
|
|
52
|
+
<Text style={[styles.arrowText, { color: colors.foreground }]} allowFontScaling={true}>‹</Text>
|
|
53
|
+
</TouchableOpacity>
|
|
54
|
+
<Text style={[styles.label, { color: colors.foreground }]} allowFontScaling={true}>
|
|
55
|
+
{MONTH_NAMES[value.month - 1]} {value.year}
|
|
56
|
+
</Text>
|
|
57
|
+
<TouchableOpacity
|
|
58
|
+
style={styles.arrow}
|
|
59
|
+
onPress={handleNext}
|
|
60
|
+
activeOpacity={0.6}
|
|
61
|
+
touchSoundDisabled={true}
|
|
62
|
+
>
|
|
63
|
+
<Text style={[styles.arrowText, { color: colors.foreground }]} allowFontScaling={true}>›</Text>
|
|
64
|
+
</TouchableOpacity>
|
|
65
|
+
</View>
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const styles = StyleSheet.create({
|
|
70
|
+
container: {
|
|
71
|
+
flexDirection: 'row',
|
|
72
|
+
alignItems: 'center',
|
|
73
|
+
justifyContent: 'space-between',
|
|
74
|
+
},
|
|
75
|
+
arrow: {
|
|
76
|
+
width: 44,
|
|
77
|
+
height: 44,
|
|
78
|
+
alignItems: 'center',
|
|
79
|
+
justifyContent: 'center',
|
|
80
|
+
},
|
|
81
|
+
arrowText: {
|
|
82
|
+
fontSize: 24,
|
|
83
|
+
lineHeight: 30,
|
|
84
|
+
},
|
|
85
|
+
label: {
|
|
86
|
+
fontSize: 17,
|
|
87
|
+
fontWeight: '500',
|
|
88
|
+
lineHeight: 24,
|
|
89
|
+
textAlign: 'center',
|
|
90
|
+
minWidth: 160,
|
|
91
|
+
},
|
|
92
|
+
})
|