@nexub/react-native 2.1.0 → 2.2.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/README.md +3 -1
- package/lib/module/components/containers.js +13 -5
- package/lib/module/components/containers.js.map +1 -1
- package/lib/module/components/display.js +33 -0
- package/lib/module/components/display.js.map +1 -1
- package/lib/module/components/feedback.js +65 -45
- package/lib/module/components/feedback.js.map +1 -1
- package/lib/module/components/inputs.js +101 -55
- package/lib/module/components/inputs.js.map +1 -1
- package/lib/module/components/mobile.js +70 -63
- package/lib/module/components/mobile.js.map +1 -1
- package/lib/module/components/overlays.js +5 -2
- package/lib/module/components/overlays.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/patterns/home.js +201 -0
- package/lib/module/patterns/home.js.map +1 -0
- package/lib/module/patterns/index.js +1 -0
- package/lib/module/patterns/index.js.map +1 -1
- package/lib/module/primitives/Pressable.js +4 -2
- package/lib/module/primitives/Pressable.js.map +1 -1
- package/lib/module/theme/motion.js +89 -0
- package/lib/module/theme/motion.js.map +1 -0
- package/lib/typescript/components/containers.d.ts.map +1 -1
- package/lib/typescript/components/display.d.ts +7 -1
- package/lib/typescript/components/display.d.ts.map +1 -1
- package/lib/typescript/components/feedback.d.ts +4 -1
- package/lib/typescript/components/feedback.d.ts.map +1 -1
- package/lib/typescript/components/inputs.d.ts.map +1 -1
- package/lib/typescript/components/mobile.d.ts.map +1 -1
- package/lib/typescript/components/overlays.d.ts.map +1 -1
- package/lib/typescript/index.d.ts +2 -0
- package/lib/typescript/index.d.ts.map +1 -1
- package/lib/typescript/patterns/home.d.ts +50 -0
- package/lib/typescript/patterns/home.d.ts.map +1 -0
- package/lib/typescript/patterns/index.d.ts +2 -0
- package/lib/typescript/patterns/index.d.ts.map +1 -1
- package/lib/typescript/primitives/Pressable.d.ts.map +1 -1
- package/lib/typescript/theme/motion.d.ts +79 -0
- package/lib/typescript/theme/motion.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/components/containers.tsx +17 -6
- package/src/components/display.tsx +31 -0
- package/src/components/feedback.tsx +23 -4
- package/src/components/inputs.tsx +59 -11
- package/src/components/mobile.tsx +8 -4
- package/src/components/overlays.tsx +4 -1
- package/src/index.ts +2 -0
- package/src/patterns/home.tsx +211 -0
- package/src/patterns/index.ts +2 -0
- package/src/primitives/Pressable.tsx +4 -1
- package/src/theme/motion.ts +105 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import * as React from 'react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { moneyFormatter } from '@nexub/core';
|
|
4
|
+
import type { NativeIconProps } from '@nexub/icons/native';
|
|
5
|
+
import { Button } from '../components/actions';
|
|
6
|
+
import { Card, ListItem, ListSection } from '../components/containers';
|
|
7
|
+
import { Amount, Avatar, Badge } from '../components/display';
|
|
8
|
+
import { RefreshControl } from '../components/mobile';
|
|
9
|
+
import { Pressable } from '../primitives/Pressable';
|
|
10
|
+
import { ScreenContainer } from '../primitives/ScreenContainer';
|
|
11
|
+
import { Heading, Text } from '../primitives/Text';
|
|
12
|
+
import { useTheme } from '../theme/ThemeProvider';
|
|
13
|
+
import type { Transaction } from './money';
|
|
14
|
+
|
|
15
|
+
export interface QuickAction {
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
icon?: React.ComponentType<NativeIconProps>;
|
|
19
|
+
onPress: () => void;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface Insight {
|
|
23
|
+
id: string;
|
|
24
|
+
title: string;
|
|
25
|
+
body: string;
|
|
26
|
+
action?: { label: string; onPress: () => void };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HomeScreenProps {
|
|
30
|
+
/** e.g. "Good morning, Mei". Leave out when the native header shows the title. */
|
|
31
|
+
greeting?: string;
|
|
32
|
+
balance: { value: number; currency: string; label?: string };
|
|
33
|
+
/** Send, Add money, Request… Up to four fit one row. */
|
|
34
|
+
quickActions?: QuickAction[];
|
|
35
|
+
/** The latest few transactions. */
|
|
36
|
+
recent?: Transaction[];
|
|
37
|
+
onTransactionPress?: (t: Transaction) => void;
|
|
38
|
+
/** Shows "See all" beside Recent activity. */
|
|
39
|
+
onSeeAllActivity?: () => void;
|
|
40
|
+
insights?: Insight[];
|
|
41
|
+
onRefresh?: () => void;
|
|
42
|
+
refreshing?: boolean;
|
|
43
|
+
/** Copy overrides. */
|
|
44
|
+
labels?: Partial<{ totalBalance: string; recentActivity: string; seeAll: string; insights: string }>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Money-app home (Payub): balance hero with quick actions, recent activity,
|
|
49
|
+
* insights and pull-to-refresh. Web twin: `DashboardPage` in @nexub/patterns.
|
|
50
|
+
*/
|
|
51
|
+
export function HomeScreen({
|
|
52
|
+
greeting,
|
|
53
|
+
balance,
|
|
54
|
+
quickActions = [],
|
|
55
|
+
recent = [],
|
|
56
|
+
onTransactionPress,
|
|
57
|
+
onSeeAllActivity,
|
|
58
|
+
insights = [],
|
|
59
|
+
onRefresh,
|
|
60
|
+
refreshing = false,
|
|
61
|
+
labels,
|
|
62
|
+
}: HomeScreenProps) {
|
|
63
|
+
const { roles, tokens, locale } = useTheme();
|
|
64
|
+
const l = {
|
|
65
|
+
totalBalance: 'Total balance',
|
|
66
|
+
recentActivity: 'Recent activity',
|
|
67
|
+
seeAll: 'See all',
|
|
68
|
+
insights: 'Insights',
|
|
69
|
+
...labels,
|
|
70
|
+
};
|
|
71
|
+
const balanceLabel = balance.label ?? l.totalBalance;
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<ScreenContainer
|
|
75
|
+
gap="6"
|
|
76
|
+
refreshControl={
|
|
77
|
+
onRefresh ? <RefreshControl refreshing={refreshing} onRefresh={onRefresh} /> : undefined
|
|
78
|
+
}
|
|
79
|
+
>
|
|
80
|
+
{greeting && <Heading variant="headline-sm">{greeting}</Heading>}
|
|
81
|
+
|
|
82
|
+
<Card tone="surface-container" style={{ padding: tokens.spacing[5], gap: tokens.spacing[5] }}>
|
|
83
|
+
<View
|
|
84
|
+
accessible
|
|
85
|
+
accessibilityLabel={`${balanceLabel}, ${moneyFormatter(balance.currency, locale).format(balance.value)}`}
|
|
86
|
+
style={{ gap: tokens.spacing[1] }}
|
|
87
|
+
>
|
|
88
|
+
<Text variant="label-lg" color="on-surface-variant">
|
|
89
|
+
{balanceLabel}
|
|
90
|
+
</Text>
|
|
91
|
+
<Amount value={balance.value} currency={balance.currency} locale={locale} size="display" />
|
|
92
|
+
</View>
|
|
93
|
+
{quickActions.length > 0 && (
|
|
94
|
+
<View style={{ flexDirection: 'row', justifyContent: 'space-between', gap: tokens.spacing[2] }}>
|
|
95
|
+
{quickActions.slice(0, 4).map((a) => {
|
|
96
|
+
const Icon = a.icon;
|
|
97
|
+
return (
|
|
98
|
+
<Pressable
|
|
99
|
+
key={a.id}
|
|
100
|
+
accessibilityRole="button"
|
|
101
|
+
accessibilityLabel={a.label}
|
|
102
|
+
haptic="selection"
|
|
103
|
+
onPress={a.onPress}
|
|
104
|
+
style={{
|
|
105
|
+
flex: 1,
|
|
106
|
+
alignItems: 'center',
|
|
107
|
+
gap: tokens.spacing[2],
|
|
108
|
+
paddingVertical: tokens.spacing[2],
|
|
109
|
+
borderRadius: tokens.shape.container,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<View
|
|
113
|
+
style={{
|
|
114
|
+
width: 48,
|
|
115
|
+
height: 48,
|
|
116
|
+
borderRadius: 24,
|
|
117
|
+
alignItems: 'center',
|
|
118
|
+
justifyContent: 'center',
|
|
119
|
+
backgroundColor: roles['primary-container'],
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
{Icon && <Icon size="md" color={roles['on-primary-container']} />}
|
|
123
|
+
</View>
|
|
124
|
+
<Text variant="label-md" numberOfLines={1}>
|
|
125
|
+
{a.label}
|
|
126
|
+
</Text>
|
|
127
|
+
</Pressable>
|
|
128
|
+
);
|
|
129
|
+
})}
|
|
130
|
+
</View>
|
|
131
|
+
)}
|
|
132
|
+
</Card>
|
|
133
|
+
|
|
134
|
+
{recent.length > 0 && (
|
|
135
|
+
<View style={{ gap: tokens.spacing[2] }}>
|
|
136
|
+
<View
|
|
137
|
+
style={{
|
|
138
|
+
flexDirection: 'row',
|
|
139
|
+
alignItems: 'center',
|
|
140
|
+
justifyContent: 'space-between',
|
|
141
|
+
paddingStart: tokens.spacing[4],
|
|
142
|
+
}}
|
|
143
|
+
>
|
|
144
|
+
<Text variant="title-sm" color="on-surface-variant" accessibilityRole="header">
|
|
145
|
+
{l.recentActivity}
|
|
146
|
+
</Text>
|
|
147
|
+
{onSeeAllActivity && (
|
|
148
|
+
<Button variant="text" size="sm" onPress={onSeeAllActivity}>
|
|
149
|
+
{l.seeAll}
|
|
150
|
+
</Button>
|
|
151
|
+
)}
|
|
152
|
+
</View>
|
|
153
|
+
<ListSection inset={68}>
|
|
154
|
+
{recent.map((t) => (
|
|
155
|
+
<ListItem
|
|
156
|
+
key={t.id}
|
|
157
|
+
title={t.title}
|
|
158
|
+
subtitle={t.subtitle}
|
|
159
|
+
leading={<Avatar name={t.title} src={t.image} />}
|
|
160
|
+
trailing={
|
|
161
|
+
<View style={{ alignItems: 'flex-end', gap: 2 }}>
|
|
162
|
+
<Amount
|
|
163
|
+
value={t.amount}
|
|
164
|
+
currency={t.currency}
|
|
165
|
+
locale={locale}
|
|
166
|
+
tone="auto"
|
|
167
|
+
signDisplay="exceptZero"
|
|
168
|
+
/>
|
|
169
|
+
{t.status && (
|
|
170
|
+
<Badge tone={t.status === 'failed' ? 'danger' : 'warning'} size="sm">
|
|
171
|
+
{t.status === 'failed' ? 'Failed' : 'Pending'}
|
|
172
|
+
</Badge>
|
|
173
|
+
)}
|
|
174
|
+
</View>
|
|
175
|
+
}
|
|
176
|
+
variant={onTransactionPress ? 'navigation' : 'static'}
|
|
177
|
+
onPress={onTransactionPress && (() => onTransactionPress(t))}
|
|
178
|
+
/>
|
|
179
|
+
))}
|
|
180
|
+
</ListSection>
|
|
181
|
+
</View>
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{insights.length > 0 && (
|
|
185
|
+
<View style={{ gap: tokens.spacing[3] }}>
|
|
186
|
+
<Text
|
|
187
|
+
variant="title-sm"
|
|
188
|
+
color="on-surface-variant"
|
|
189
|
+
accessibilityRole="header"
|
|
190
|
+
style={{ paddingHorizontal: tokens.spacing[4] }}
|
|
191
|
+
>
|
|
192
|
+
{l.insights}
|
|
193
|
+
</Text>
|
|
194
|
+
{insights.map((i) => (
|
|
195
|
+
<Card key={i.id}>
|
|
196
|
+
<Text variant="title-sm">{i.title}</Text>
|
|
197
|
+
<Text variant="body-sm" color="on-surface-variant">
|
|
198
|
+
{i.body}
|
|
199
|
+
</Text>
|
|
200
|
+
{i.action && (
|
|
201
|
+
<Button variant="text" size="sm" onPress={i.action.onPress} style={{ alignSelf: 'flex-start' }}>
|
|
202
|
+
{i.action.label}
|
|
203
|
+
</Button>
|
|
204
|
+
)}
|
|
205
|
+
</Card>
|
|
206
|
+
))}
|
|
207
|
+
</View>
|
|
208
|
+
)}
|
|
209
|
+
</ScreenContainer>
|
|
210
|
+
);
|
|
211
|
+
}
|
package/src/patterns/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
// @nexub/react-native/patterns — screen templates built only from public components.
|
|
2
|
+
export { HomeScreen } from './home';
|
|
3
|
+
export type { HomeScreenProps, QuickAction, Insight } from './home';
|
|
2
4
|
export { AuthScreen, SignInScreen, VerifyCodeScreen, LockScreen } from './auth';
|
|
3
5
|
export type {
|
|
4
6
|
AuthScreenProps,
|
|
@@ -11,6 +11,9 @@ import {
|
|
|
11
11
|
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated';
|
|
12
12
|
import type { HapticToken } from '../theme/haptics';
|
|
13
13
|
import { useHaptics, useTheme, type RoleName } from '../theme/ThemeProvider';
|
|
14
|
+
import { nxSpring } from '../theme/motion';
|
|
15
|
+
|
|
16
|
+
const PRESS_SPRING = nxSpring('spatial', 'fast');
|
|
14
17
|
|
|
15
18
|
// The touchable itself animates and takes `style`, so layout props (flex,
|
|
16
19
|
// position, margins) apply to the element that sits in the parent's layout.
|
|
@@ -48,7 +51,7 @@ export function Pressable({
|
|
|
48
51
|
const { roles, tokens } = useTheme();
|
|
49
52
|
const play = useHaptics();
|
|
50
53
|
const pressed = useSharedValue(0);
|
|
51
|
-
const spring =
|
|
54
|
+
const spring = PRESS_SPRING;
|
|
52
55
|
const opacity = tokens.state.pressed;
|
|
53
56
|
const scale = pressScale === false ? 1 : pressScale;
|
|
54
57
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Easing,
|
|
3
|
+
FadeIn,
|
|
4
|
+
FadeInDown,
|
|
5
|
+
FadeInUp,
|
|
6
|
+
FadeOut,
|
|
7
|
+
FadeOutDown,
|
|
8
|
+
FadeOutUp,
|
|
9
|
+
LinearTransition,
|
|
10
|
+
ReduceMotion,
|
|
11
|
+
SlideOutLeft,
|
|
12
|
+
SlideOutRight,
|
|
13
|
+
ZoomIn,
|
|
14
|
+
ZoomOut,
|
|
15
|
+
} from 'react-native-reanimated';
|
|
16
|
+
import { native } from '@nexub/tokens/native';
|
|
17
|
+
|
|
18
|
+
// Layout-animation presets built from the same spring tokens as the web
|
|
19
|
+
// (`motion.spring.*` → CSS linear() there, Reanimated springs here).
|
|
20
|
+
// Enter rides `spatial` (position and size, a hair of settle); exit is quicker
|
|
21
|
+
// on `effects` and never overshoots. Every preset honours the OS Reduce Motion
|
|
22
|
+
// setting (ReduceMotion.System): the change lands without travel.
|
|
23
|
+
|
|
24
|
+
export type SpringFamily = keyof typeof native.motion.spring;
|
|
25
|
+
export type SpringSpeed = keyof (typeof native.motion.spring)['spatial'];
|
|
26
|
+
|
|
27
|
+
/** `withSpring` config for a spring token, honouring Reduce Motion. */
|
|
28
|
+
export function nxSpring(family: SpringFamily = 'spatial', speed: SpringSpeed = 'default') {
|
|
29
|
+
return { ...native.motion.spring[family][speed], reduceMotion: ReduceMotion.System };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type EnterKind = 'fade' | 'up' | 'down' | 'scale';
|
|
33
|
+
export type ExitKind = 'fade' | 'up' | 'down' | 'scale' | 'start' | 'end';
|
|
34
|
+
|
|
35
|
+
const ENTER = { fade: FadeIn, up: FadeInUp, down: FadeInDown, scale: ZoomIn } as const;
|
|
36
|
+
const EXIT = { fade: FadeOut, up: FadeOutUp, down: FadeOutDown, scale: ZoomOut } as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Settle time of a spring token (ms): when it is within 0.1% of rest. The
|
|
40
|
+
* same rule @nexub/tokens uses for the CSS spring durations.
|
|
41
|
+
*/
|
|
42
|
+
export function springSettleMs({ stiffness, damping, mass }: { stiffness: number; damping: number; mass: number }) {
|
|
43
|
+
const dt = 1 / 1000;
|
|
44
|
+
let x = 0;
|
|
45
|
+
let v = 0;
|
|
46
|
+
let still = 0;
|
|
47
|
+
for (let t = 0; t < 3; t += dt) {
|
|
48
|
+
const a = (stiffness * (1 - x) - damping * v) / mass;
|
|
49
|
+
v += a * dt;
|
|
50
|
+
x += v * dt;
|
|
51
|
+
still = Math.abs(1 - x) < 0.001 && Math.abs(v) < 0.01 ? still + 1 : 0;
|
|
52
|
+
if (still > 16) return Math.round((t - 0.016) * 1000);
|
|
53
|
+
}
|
|
54
|
+
return 3000;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Enter and exit run as timed animations lasting the spring token's settle
|
|
58
|
+
// time, on the system decelerate / accelerate curves: Reanimated's
|
|
59
|
+
// physics-spring entering animations (`springify()` with stiffness/damping)
|
|
60
|
+
// left entering views invisible on iOS. Layout transitions move views that are
|
|
61
|
+
// already visible, so they keep the physics spring.
|
|
62
|
+
const decelerate = Easing.bezier(0, 0, 0.2, 1);
|
|
63
|
+
const accelerate = Easing.bezier(0.4, 0, 1, 1);
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* `entering` animation, timed to the spatial spring.
|
|
67
|
+
* `up` rises into place (rows, toasts from below); `down` drops in (banners).
|
|
68
|
+
*
|
|
69
|
+
* ```tsx
|
|
70
|
+
* <Animated.View entering={nxEnter('up')} exiting={nxExit('down')} layout={nxLayout()} />
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
export function nxEnter(kind: EnterKind = 'fade', speed: SpringSpeed = 'default') {
|
|
74
|
+
return ENTER[kind]
|
|
75
|
+
.duration(springSettleMs(native.motion.spring.spatial[speed]))
|
|
76
|
+
.easing(decelerate)
|
|
77
|
+
.reduceMotion(ReduceMotion.System);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `exiting` animation, timed to the effects spring: quicker than enter.
|
|
82
|
+
* `start` / `end` slide out toward that edge (swipe-to-delete), following RTL.
|
|
83
|
+
*/
|
|
84
|
+
export function nxExit(kind: ExitKind = 'fade', speed: SpringSpeed = 'fast', rtl = false) {
|
|
85
|
+
const builder =
|
|
86
|
+
kind === 'start' || kind === 'end'
|
|
87
|
+
? (kind === 'start') !== rtl
|
|
88
|
+
? SlideOutLeft
|
|
89
|
+
: SlideOutRight
|
|
90
|
+
: EXIT[kind];
|
|
91
|
+
return builder
|
|
92
|
+
.duration(springSettleMs(native.motion.spring.effects[speed]))
|
|
93
|
+
.easing(accelerate)
|
|
94
|
+
.reduceMotion(ReduceMotion.System);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `layout` transition: siblings glide to their new place on the spatial spring. */
|
|
98
|
+
export function nxLayout(speed: SpringSpeed = 'default') {
|
|
99
|
+
const s = native.motion.spring.spatial[speed];
|
|
100
|
+
return LinearTransition.springify()
|
|
101
|
+
.stiffness(s.stiffness)
|
|
102
|
+
.damping(s.damping)
|
|
103
|
+
.mass(s.mass)
|
|
104
|
+
.reduceMotion(ReduceMotion.System);
|
|
105
|
+
}
|