jfs-components 0.0.73 → 0.0.77
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 +115 -6
- package/lib/commonjs/components/AccountCard/AccountCard.js +247 -0
- package/lib/commonjs/components/ActionFooter/ActionFooter.js +147 -82
- package/lib/commonjs/components/AppBar/AppBar.js +17 -11
- package/lib/commonjs/components/Avatar/Avatar.js +20 -0
- package/lib/commonjs/components/Badge/Badge.js +23 -0
- package/lib/commonjs/components/Button/Button.js +37 -0
- package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +18 -2
- package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +40 -25
- package/lib/commonjs/components/Dropdown/Dropdown.js +214 -0
- package/lib/commonjs/components/DropdownInput/DropdownInput.js +542 -0
- package/lib/commonjs/components/FormField/FormField.js +328 -178
- package/lib/commonjs/components/IconButton/IconButton.js +20 -0
- package/lib/commonjs/components/Image/Image.js +26 -1
- package/lib/commonjs/components/LottieIntroBlock/LottieIntroBlock.js +150 -0
- package/lib/commonjs/components/LottiePlayer/LottiePlayer.js +116 -0
- package/lib/commonjs/components/LottiePlayer/LottiePlayer.web.js +82 -0
- package/lib/commonjs/components/LottiePlayer/loadNativeLottieView.js +74 -0
- package/lib/commonjs/components/LottiePlayer/loadWebLottieView.js +50 -0
- package/lib/commonjs/components/PageHero/PageHero.js +189 -0
- package/lib/commonjs/components/PoweredByLabel/PoweredByLabel.js +135 -0
- package/lib/commonjs/components/PoweredByLabel/finvu.png +0 -0
- package/lib/commonjs/components/RechargeCard/RechargeCard.js +32 -17
- package/lib/commonjs/components/Text/Text.js +40 -3
- package/lib/commonjs/components/Tooltip/Tooltip.js +34 -27
- package/lib/commonjs/components/index.js +67 -0
- package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/commonjs/icons/Icon.js +16 -0
- package/lib/commonjs/icons/registry.js +1 -1
- package/lib/commonjs/index.js +12 -0
- package/lib/commonjs/skeleton/Skeleton.js +234 -0
- package/lib/commonjs/skeleton/SkeletonGroup.js +140 -0
- package/lib/commonjs/skeleton/index.js +58 -0
- package/lib/commonjs/skeleton/shimmer-tokens.js +189 -0
- package/lib/commonjs/skeleton/useReducedMotion.js +64 -0
- package/lib/module/components/AccountCard/AccountCard.js +241 -0
- package/lib/module/components/ActionFooter/ActionFooter.js +146 -82
- package/lib/module/components/AppBar/AppBar.js +17 -11
- package/lib/module/components/Avatar/Avatar.js +19 -0
- package/lib/module/components/Badge/Badge.js +23 -0
- package/lib/module/components/Button/Button.js +37 -0
- package/lib/module/components/CardBankAccount/CardBankAccount.js +17 -2
- package/lib/module/components/CheckboxItem/CheckboxItem.js +41 -26
- package/lib/module/components/Dropdown/Dropdown.js +206 -0
- package/lib/module/components/DropdownInput/DropdownInput.js +536 -0
- package/lib/module/components/FormField/FormField.js +330 -180
- package/lib/module/components/IconButton/IconButton.js +20 -0
- package/lib/module/components/Image/Image.js +25 -1
- package/lib/module/components/LottieIntroBlock/LottieIntroBlock.js +144 -0
- package/lib/module/components/LottiePlayer/LottiePlayer.js +111 -0
- package/lib/module/components/LottiePlayer/LottiePlayer.web.js +77 -0
- package/lib/module/components/LottiePlayer/loadNativeLottieView.js +69 -0
- package/lib/module/components/LottiePlayer/loadWebLottieView.js +45 -0
- package/lib/module/components/PageHero/PageHero.js +183 -0
- package/lib/module/components/PoweredByLabel/PoweredByLabel.js +130 -0
- package/lib/module/components/PoweredByLabel/finvu.png +0 -0
- package/lib/module/components/RechargeCard/RechargeCard.js +33 -17
- package/lib/module/components/Text/Text.js +40 -3
- package/lib/module/components/Tooltip/Tooltip.js +34 -27
- package/lib/module/components/index.js +8 -1
- package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
- package/lib/module/icons/Icon.js +16 -0
- package/lib/module/icons/registry.js +1 -1
- package/lib/module/index.js +2 -1
- package/lib/module/skeleton/Skeleton.js +229 -0
- package/lib/module/skeleton/SkeletonGroup.js +133 -0
- package/lib/module/skeleton/index.js +6 -0
- package/lib/module/skeleton/shimmer-tokens.js +181 -0
- package/lib/module/skeleton/useReducedMotion.js +61 -0
- package/lib/typescript/src/components/AccountCard/AccountCard.d.ts +81 -0
- package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +26 -21
- package/lib/typescript/src/components/Avatar/Avatar.d.ts +7 -1
- package/lib/typescript/src/components/Badge/Badge.d.ts +7 -1
- package/lib/typescript/src/components/Button/Button.d.ts +8 -1
- package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +9 -2
- package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +18 -2
- package/lib/typescript/src/components/Dropdown/Dropdown.d.ts +62 -0
- package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +107 -0
- package/lib/typescript/src/components/FormField/FormField.d.ts +76 -19
- package/lib/typescript/src/components/IconButton/IconButton.d.ts +7 -1
- package/lib/typescript/src/components/Image/Image.d.ts +8 -1
- package/lib/typescript/src/components/LottieIntroBlock/LottieIntroBlock.d.ts +58 -0
- package/lib/typescript/src/components/LottiePlayer/LottiePlayer.d.ts +85 -0
- package/lib/typescript/src/components/LottiePlayer/LottiePlayer.web.d.ts +28 -0
- package/lib/typescript/src/components/LottiePlayer/loadNativeLottieView.d.ts +11 -0
- package/lib/typescript/src/components/LottiePlayer/loadWebLottieView.d.ts +11 -0
- package/lib/typescript/src/components/PageHero/PageHero.d.ts +79 -0
- package/lib/typescript/src/components/PoweredByLabel/PoweredByLabel.d.ts +70 -0
- package/lib/typescript/src/components/Text/Text.d.ts +31 -2
- package/lib/typescript/src/components/Tooltip/Tooltip.d.ts +13 -2
- package/lib/typescript/src/components/index.d.ts +8 -1
- package/lib/typescript/src/icons/Icon.d.ts +7 -1
- package/lib/typescript/src/icons/registry.d.ts +1 -1
- package/lib/typescript/src/index.d.ts +1 -0
- package/lib/typescript/src/skeleton/Skeleton.d.ts +60 -0
- package/lib/typescript/src/skeleton/SkeletonGroup.d.ts +78 -0
- package/lib/typescript/src/skeleton/index.d.ts +5 -0
- package/lib/typescript/src/skeleton/shimmer-tokens.d.ts +160 -0
- package/lib/typescript/src/skeleton/useReducedMotion.d.ts +15 -0
- package/package.json +11 -3
- package/src/components/AccountCard/AccountCard.tsx +376 -0
- package/src/components/ActionFooter/ActionFooter.tsx +152 -86
- package/src/components/AppBar/AppBar.tsx +25 -14
- package/src/components/Avatar/Avatar.tsx +26 -0
- package/src/components/Badge/Badge.tsx +27 -0
- package/src/components/Button/Button.tsx +40 -0
- package/src/components/CardBankAccount/CardBankAccount.tsx +29 -3
- package/src/components/CheckboxItem/CheckboxItem.tsx +65 -30
- package/src/components/Dropdown/Dropdown.tsx +331 -0
- package/src/components/DropdownInput/DropdownInput.tsx +819 -0
- package/src/components/FormField/FormField.tsx +542 -215
- package/src/components/IconButton/IconButton.tsx +27 -0
- package/src/components/Image/Image.tsx +25 -0
- package/src/components/LottieIntroBlock/LottieIntroBlock.tsx +202 -0
- package/src/components/LottiePlayer/LottiePlayer.tsx +145 -0
- package/src/components/LottiePlayer/LottiePlayer.web.tsx +94 -0
- package/src/components/LottiePlayer/loadNativeLottieView.tsx +87 -0
- package/src/components/LottiePlayer/loadWebLottieView.tsx +64 -0
- package/src/components/PageHero/PageHero.tsx +257 -0
- package/src/components/PoweredByLabel/PoweredByLabel.tsx +221 -0
- package/src/components/PoweredByLabel/finvu.png +0 -0
- package/src/components/RechargeCard/RechargeCard.tsx +32 -24
- package/src/components/Text/Text.tsx +78 -3
- package/src/components/Tooltip/Tooltip.tsx +50 -25
- package/src/components/index.ts +16 -1
- package/src/design-tokens/Coin Variables-variables-full.json +1 -1
- package/src/icons/Icon.tsx +17 -0
- package/src/icons/registry.ts +1 -1
- package/src/index.ts +1 -0
- package/src/skeleton/Skeleton.tsx +298 -0
- package/src/skeleton/SkeletonGroup.tsx +193 -0
- package/src/skeleton/index.ts +10 -0
- package/src/skeleton/shimmer-tokens.ts +221 -0
- package/src/skeleton/useReducedMotion.ts +72 -0
package/lib/module/index.js
CHANGED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { useId, useMemo, useState } from 'react';
|
|
4
|
+
import { StyleSheet, View } from 'react-native';
|
|
5
|
+
import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated';
|
|
6
|
+
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from 'react-native-svg';
|
|
7
|
+
import { getVariableByName } from '../design-tokens/figma-variables-resolver';
|
|
8
|
+
import { SHIMMER, SHIMMER_GRADIENT_STOPS, SKELETON_FALLBACK, SKELETON_TOKEN, computeShimmerMotion, staggerDelayMs } from './shimmer-tokens';
|
|
9
|
+
import { useSkeleton, useStaggerIndex } from './SkeletonGroup';
|
|
10
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
11
|
+
// Solid-white overlay used by reduced-motion mode (also acts as the safe
|
|
12
|
+
// fallback when SVG can't render or when the box hasn't been measured yet).
|
|
13
|
+
// Allocated once at module scope so per-render allocation is zero.
|
|
14
|
+
const SOLID_OVERLAY_COLOR = 'rgba(255, 255, 255, 1)';
|
|
15
|
+
|
|
16
|
+
// `pointerEvents: 'none'` lives on the style (not the deprecated prop) so it
|
|
17
|
+
// works on both native and React Native Web without warnings.
|
|
18
|
+
const absoluteFillStyle = {
|
|
19
|
+
...StyleSheet.absoluteFillObject,
|
|
20
|
+
overflow: 'hidden',
|
|
21
|
+
pointerEvents: 'none'
|
|
22
|
+
};
|
|
23
|
+
const solidOverlayStyle = {
|
|
24
|
+
...StyleSheet.absoluteFillObject,
|
|
25
|
+
backgroundColor: SOLID_OVERLAY_COLOR
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Atomic skeleton placeholder. Renders a base rectangle (background colour +
|
|
30
|
+
* radius per kind) plus an animated overlay that produces the shimmer.
|
|
31
|
+
*
|
|
32
|
+
* Two visual modes share the same engine:
|
|
33
|
+
*
|
|
34
|
+
* - Normal mode (gradient: true): a soft 135° band translates diagonally
|
|
35
|
+
* across the box end-to-end. Its alpha inside the moving band varies
|
|
36
|
+
* 33% → 100% → 33% via gradient stops. The sawtooth clock travels from
|
|
37
|
+
* fully off-screen (−overshoot) to fully off-screen (+overshoot) so the
|
|
38
|
+
* transparent gradient tails clear the box before the loop resets.
|
|
39
|
+
*
|
|
40
|
+
* - Reduced-motion mode (gradient: false): a solid white overlay covers the
|
|
41
|
+
* box and pulses opacity 33% → 100% → 33% in place, with no translation,
|
|
42
|
+
* ease-in-out timing and no stagger.
|
|
43
|
+
*
|
|
44
|
+
* Critical for cross-platform consistency:
|
|
45
|
+
*
|
|
46
|
+
* - The SVG gradient uses `gradientUnits="userSpaceOnUse"` with absolute
|
|
47
|
+
* pixel coordinates that have Δx === Δy. This guarantees the visual angle
|
|
48
|
+
* is exactly 45° in screen pixels (= 135° CSS) on ANY aspect ratio. With
|
|
49
|
+
* the default `objectBoundingBox` units, the gradient line is normalised
|
|
50
|
+
* to the box and tilts off-angle on non-square boxes.
|
|
51
|
+
*
|
|
52
|
+
* - The whole animated overlay sits inside a clipping parent (`overflow:
|
|
53
|
+
* hidden` + `borderRadius`). Translating an `Animated.View` is GPU-cheap
|
|
54
|
+
* on both iOS/Android (Reanimated UI thread) and on web (CSS transforms).
|
|
55
|
+
*
|
|
56
|
+
* - The SVG `id` for the gradient is per-instance (`useId`) so multiple
|
|
57
|
+
* skeletons on a web page don't collide on `url(#…)` resolution.
|
|
58
|
+
*/
|
|
59
|
+
function SkeletonImpl({
|
|
60
|
+
kind = 'other',
|
|
61
|
+
width,
|
|
62
|
+
height,
|
|
63
|
+
style,
|
|
64
|
+
modes
|
|
65
|
+
}) {
|
|
66
|
+
const {
|
|
67
|
+
active,
|
|
68
|
+
reducedMotion,
|
|
69
|
+
clock
|
|
70
|
+
} = useSkeleton();
|
|
71
|
+
const index = useStaggerIndex();
|
|
72
|
+
const reactId = useId();
|
|
73
|
+
// SVG ids must match `^[A-Za-z][A-Za-z0-9_.-]*$`; React's useId returns
|
|
74
|
+
// colons which are illegal in fragment refs, so strip them.
|
|
75
|
+
const gradientId = `jfsSkeletonGradient-${reactId.replace(/[^A-Za-z0-9_-]/g, '')}`;
|
|
76
|
+
|
|
77
|
+
// Measured pixel size of the base box. The moving shimmer geometry depends
|
|
78
|
+
// on real layout so we capture it via onLayout. Before the first layout
|
|
79
|
+
// pass W and H are 0 and we render no overlay (the next render after layout
|
|
80
|
+
// fills it in).
|
|
81
|
+
const [layout, setLayout] = useState({
|
|
82
|
+
width: 0,
|
|
83
|
+
height: 0
|
|
84
|
+
});
|
|
85
|
+
const radiusTokenName = SKELETON_TOKEN.radius[kind];
|
|
86
|
+
const radius = getVariableByName(radiusTokenName, modes ?? {}) ?? SKELETON_FALLBACK.radius[kind];
|
|
87
|
+
const backgroundColor = getVariableByName(SKELETON_TOKEN.background, modes ?? {}) ?? SKELETON_FALLBACK.backgroundColor;
|
|
88
|
+
const spec = reducedMotion ? SHIMMER.reduced : SHIMMER.normal;
|
|
89
|
+
// Convert the per-item stagger into a phase offset in [0, 1) (relative to
|
|
90
|
+
// one cycle). The sawtooth clock means a phase shift simply slides the
|
|
91
|
+
// start point of each skeleton's sweep, producing the cascade.
|
|
92
|
+
const phaseOffset = spec.durationMs > 0 ? staggerDelayMs(index, spec) / spec.durationMs : 0;
|
|
93
|
+
const baseStyle = useMemo(() => ({
|
|
94
|
+
backgroundColor,
|
|
95
|
+
borderRadius: radius,
|
|
96
|
+
overflow: 'hidden',
|
|
97
|
+
...(width !== undefined ? {
|
|
98
|
+
width
|
|
99
|
+
} : {}),
|
|
100
|
+
...(height !== undefined ? {
|
|
101
|
+
height
|
|
102
|
+
} : {})
|
|
103
|
+
}), [backgroundColor, radius, width, height]);
|
|
104
|
+
|
|
105
|
+
// --- Moving-shimmer geometry --------------------------------------------
|
|
106
|
+
//
|
|
107
|
+
// Overlay is 2× the box diagonal so the gradient still covers the clipped
|
|
108
|
+
// area at extreme translations. Overshoot (travel beyond each corner) is
|
|
109
|
+
// derived from the gradient stop layout via `computeShimmerMotion`.
|
|
110
|
+
const W = layout.width;
|
|
111
|
+
const H = layout.height;
|
|
112
|
+
const diag = Math.sqrt(W * W + H * H);
|
|
113
|
+
const overlaySize = diag * 2;
|
|
114
|
+
const motion = useMemo(() => computeShimmerMotion(W, H, overlaySize, SHIMMER_GRADIENT_STOPS), [W, H, overlaySize]);
|
|
115
|
+
const padX = motion?.padX ?? 0;
|
|
116
|
+
const padY = motion?.padY ?? 0;
|
|
117
|
+
const kStart = motion?.kStart ?? 0;
|
|
118
|
+
const kEnd = motion?.kEnd ?? 0;
|
|
119
|
+
const kTravel = kEnd - kStart;
|
|
120
|
+
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
|
121
|
+
const t = (clock.value + phaseOffset) % 1;
|
|
122
|
+
if (spec.gradient) {
|
|
123
|
+
if (kTravel === 0) {
|
|
124
|
+
// Pre-layout: keep the overlay invisible so we don't flash a
|
|
125
|
+
// mis-sized gradient before onLayout fires.
|
|
126
|
+
return {
|
|
127
|
+
transform: [{
|
|
128
|
+
translateX: 0
|
|
129
|
+
}, {
|
|
130
|
+
translateY: 0
|
|
131
|
+
}],
|
|
132
|
+
opacity: 0
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
// Linear sawtooth: t = 0 → fully before entry, t = 1 → fully after exit.
|
|
136
|
+
// Equivalent to normalised phase p = −O + t·(1 + 2O) where O is the
|
|
137
|
+
// gradient-derived overshoot fraction from `computeShimmerMotion`.
|
|
138
|
+
const k = kStart + t * kTravel;
|
|
139
|
+
return {
|
|
140
|
+
transform: [{
|
|
141
|
+
translateX: k
|
|
142
|
+
}, {
|
|
143
|
+
translateY: k
|
|
144
|
+
}],
|
|
145
|
+
opacity: 1
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
// Reduced motion: stationary solid overlay, opacity triangle wave.
|
|
149
|
+
const wave = t < 0.5 ? t * 2 : (1 - t) * 2;
|
|
150
|
+
const [lo, hi] = spec.opacityRange;
|
|
151
|
+
return {
|
|
152
|
+
transform: [{
|
|
153
|
+
translateX: 0
|
|
154
|
+
}, {
|
|
155
|
+
translateY: 0
|
|
156
|
+
}],
|
|
157
|
+
opacity: interpolate(wave, [0, 1], [lo, hi])
|
|
158
|
+
};
|
|
159
|
+
}, [phaseOffset, kStart, kTravel, spec.gradient, spec.opacityRange[0], spec.opacityRange[1]]);
|
|
160
|
+
const gradientOverlayContainerStyle = useMemo(() => ({
|
|
161
|
+
position: 'absolute',
|
|
162
|
+
left: -padX,
|
|
163
|
+
top: -padY,
|
|
164
|
+
width: overlaySize,
|
|
165
|
+
height: overlaySize,
|
|
166
|
+
pointerEvents: 'none'
|
|
167
|
+
}), [padX, padY, overlaySize]);
|
|
168
|
+
if (!active) {
|
|
169
|
+
// Render nothing when the group is inactive. This lets users sprinkle
|
|
170
|
+
// <Skeleton /> blocks unconditionally inside their loaded UI without
|
|
171
|
+
// having to manually gate them — only the active group flips them on.
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
const onLayout = e => {
|
|
175
|
+
const {
|
|
176
|
+
width: w,
|
|
177
|
+
height: h
|
|
178
|
+
} = e.nativeEvent.layout;
|
|
179
|
+
if (w !== layout.width || h !== layout.height) {
|
|
180
|
+
setLayout({
|
|
181
|
+
width: w,
|
|
182
|
+
height: h
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
return /*#__PURE__*/_jsx(View, {
|
|
187
|
+
style: [baseStyle, style],
|
|
188
|
+
accessibilityElementsHidden: true,
|
|
189
|
+
importantForAccessibility: "no-hide-descendants"
|
|
190
|
+
// Screen readers announce a loading state once via the group's owner;
|
|
191
|
+
// each individual skeleton is purely decorative.
|
|
192
|
+
,
|
|
193
|
+
accessibilityRole: "none",
|
|
194
|
+
onLayout: onLayout,
|
|
195
|
+
children: spec.gradient ? layout.width > 0 ? /*#__PURE__*/_jsx(Animated.View, {
|
|
196
|
+
style: [gradientOverlayContainerStyle, overlayAnimatedStyle],
|
|
197
|
+
children: /*#__PURE__*/_jsxs(Svg, {
|
|
198
|
+
width: overlaySize,
|
|
199
|
+
height: overlaySize,
|
|
200
|
+
children: [/*#__PURE__*/_jsx(Defs, {
|
|
201
|
+
children: /*#__PURE__*/_jsx(SvgLinearGradient, {
|
|
202
|
+
id: gradientId,
|
|
203
|
+
x1: 0,
|
|
204
|
+
y1: 0,
|
|
205
|
+
x2: overlaySize,
|
|
206
|
+
y2: overlaySize,
|
|
207
|
+
gradientUnits: "userSpaceOnUse",
|
|
208
|
+
children: SHIMMER_GRADIENT_STOPS.map((stop, i) => /*#__PURE__*/_jsx(Stop, {
|
|
209
|
+
offset: String(stop.offset),
|
|
210
|
+
stopColor: "#ffffff",
|
|
211
|
+
stopOpacity: stop.opacity
|
|
212
|
+
}, `${stop.offset}-${i}`))
|
|
213
|
+
})
|
|
214
|
+
}), /*#__PURE__*/_jsx(Rect, {
|
|
215
|
+
width: overlaySize,
|
|
216
|
+
height: overlaySize,
|
|
217
|
+
fill: `url(#${gradientId})`
|
|
218
|
+
})]
|
|
219
|
+
})
|
|
220
|
+
}) : null : /*#__PURE__*/_jsx(Animated.View, {
|
|
221
|
+
style: [absoluteFillStyle, overlayAnimatedStyle],
|
|
222
|
+
children: /*#__PURE__*/_jsx(View, {
|
|
223
|
+
style: solidOverlayStyle
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const Skeleton = /*#__PURE__*/React.memo(SkeletonImpl);
|
|
229
|
+
export default Skeleton;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react';
|
|
4
|
+
import { cancelAnimation, Easing, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated';
|
|
5
|
+
import { SHIMMER } from './shimmer-tokens';
|
|
6
|
+
import { useReducedMotion as useSystemReducedMotion } from './useReducedMotion';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shape of the value carried by the SkeletonContext.
|
|
10
|
+
*
|
|
11
|
+
* The provider owns ONE Reanimated shared value per group; every child
|
|
12
|
+
* `<Skeleton>` reads it on the UI thread and computes its own opacity from
|
|
13
|
+
* `(clock + perItemPhaseOffset)`. That keeps per-skeleton work bounded to a
|
|
14
|
+
* single `useAnimatedStyle` and avoids per-block JS timers.
|
|
15
|
+
*/
|
|
16
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
17
|
+
const SkeletonContext = /*#__PURE__*/createContext(null);
|
|
18
|
+
/**
|
|
19
|
+
* Provider that flips an entire subtree into "loading" mode and supplies the
|
|
20
|
+
* shared shimmer clock + stagger index source to every descendant skeleton.
|
|
21
|
+
*
|
|
22
|
+
* Usage:
|
|
23
|
+
* ```tsx
|
|
24
|
+
* <SkeletonGroup loading={!data}>
|
|
25
|
+
* <Card>...JFS primitives auto-skeletonize...</Card>
|
|
26
|
+
* </SkeletonGroup>
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* Nesting is supported — an inner `<SkeletonGroup>` fully overrides the
|
|
30
|
+
* outer one for its subtree (its own clock, its own index counter).
|
|
31
|
+
*/
|
|
32
|
+
export function SkeletonGroup({
|
|
33
|
+
loading,
|
|
34
|
+
reducedMotion: reducedMotionOverride,
|
|
35
|
+
children
|
|
36
|
+
}) {
|
|
37
|
+
const systemReducedMotion = useSystemReducedMotion();
|
|
38
|
+
const reducedMotion = reducedMotionOverride ?? systemReducedMotion;
|
|
39
|
+
const clock = useSharedValue(0);
|
|
40
|
+
|
|
41
|
+
// The duration depends on whether we're in reduced-motion mode; we
|
|
42
|
+
// intentionally restart the loop when either `loading` or `reducedMotion`
|
|
43
|
+
// changes so the new timing takes effect immediately.
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!loading) {
|
|
46
|
+
cancelAnimation(clock);
|
|
47
|
+
clock.value = 0;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const spec = reducedMotion ? SHIMMER.reduced : SHIMMER.normal;
|
|
51
|
+
const easing = reducedMotion ? Easing.inOut(Easing.ease) : Easing.linear;
|
|
52
|
+
clock.value = 0;
|
|
53
|
+
// Sawtooth loop (third arg = false). One-way ramp 0 -> 1, reset, repeat.
|
|
54
|
+
// The moving shimmer band traverses the box on each 0 -> 1 ramp; with the
|
|
55
|
+
// band's gradient fully faded at both extremes of the sweep, the reset is
|
|
56
|
+
// visually imperceptible.
|
|
57
|
+
clock.value = withRepeat(withTiming(1, {
|
|
58
|
+
duration: spec.durationMs,
|
|
59
|
+
easing
|
|
60
|
+
}), -1, false);
|
|
61
|
+
return () => {
|
|
62
|
+
cancelAnimation(clock);
|
|
63
|
+
};
|
|
64
|
+
}, [loading, reducedMotion, clock]);
|
|
65
|
+
|
|
66
|
+
// Stagger index counter — reset on each (re)mount so reorders and toggles
|
|
67
|
+
// get a fresh, deterministic ordering. The counter lives in a ref so
|
|
68
|
+
// React's commit phase doesn't shuffle it.
|
|
69
|
+
const indexRef = useRef(0);
|
|
70
|
+
// Reset whenever loading flips on, so a freshly-displayed group cascades
|
|
71
|
+
// from index 0 again rather than picking up stale numbers.
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (loading) indexRef.current = 0;
|
|
74
|
+
}, [loading]);
|
|
75
|
+
const value = useMemo(() => ({
|
|
76
|
+
active: loading,
|
|
77
|
+
reducedMotion,
|
|
78
|
+
clock,
|
|
79
|
+
nextIndex: () => {
|
|
80
|
+
const i = indexRef.current;
|
|
81
|
+
indexRef.current = i + 1;
|
|
82
|
+
return i;
|
|
83
|
+
}
|
|
84
|
+
}), [loading, reducedMotion, clock]);
|
|
85
|
+
return /*#__PURE__*/_jsx(SkeletonContext.Provider, {
|
|
86
|
+
value: value,
|
|
87
|
+
children: children
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Internal hook used by skeleton-aware primitives (and by `<Skeleton>`
|
|
93
|
+
* itself) to read the surrounding context.
|
|
94
|
+
*
|
|
95
|
+
* Always returns a non-null value: when called outside any provider, the
|
|
96
|
+
* returned object has `active: false` so the consumer falls through to its
|
|
97
|
+
* normal render path. The fallback clock is a no-op shared value so any
|
|
98
|
+
* direct `<Skeleton>` usage outside a group still has something safe to read.
|
|
99
|
+
*/
|
|
100
|
+
export function useSkeleton() {
|
|
101
|
+
const ctx = useContext(SkeletonContext);
|
|
102
|
+
// Hook order must be stable — always allocate the fallback even when ctx
|
|
103
|
+
// exists; React only reads from `ctx` when it's non-null below.
|
|
104
|
+
const fallbackClock = useSharedValue(0);
|
|
105
|
+
const fallbackIndexRef = useRef(0);
|
|
106
|
+
const fallback = useMemo(() => ({
|
|
107
|
+
active: false,
|
|
108
|
+
reducedMotion: false,
|
|
109
|
+
clock: fallbackClock,
|
|
110
|
+
nextIndex: () => {
|
|
111
|
+
const i = fallbackIndexRef.current;
|
|
112
|
+
fallbackIndexRef.current = i + 1;
|
|
113
|
+
return i;
|
|
114
|
+
}
|
|
115
|
+
}), [fallbackClock]);
|
|
116
|
+
return ctx ?? fallback;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Convenience wrapper around `useSkeleton().nextIndex()` that pins the
|
|
121
|
+
* returned value across re-renders. Each `<Skeleton>` calls this once on
|
|
122
|
+
* mount; subsequent renders re-use the same index from the closure.
|
|
123
|
+
*/
|
|
124
|
+
export function useStaggerIndex() {
|
|
125
|
+
const {
|
|
126
|
+
nextIndex
|
|
127
|
+
} = useSkeleton();
|
|
128
|
+
const indexRef = useRef(null);
|
|
129
|
+
if (indexRef.current === null) {
|
|
130
|
+
indexRef.current = nextIndex();
|
|
131
|
+
}
|
|
132
|
+
return indexRef.current;
|
|
133
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
export { default as Skeleton } from './Skeleton';
|
|
4
|
+
export { SkeletonGroup, useSkeleton, useStaggerIndex } from './SkeletonGroup';
|
|
5
|
+
export { useReducedMotion } from './useReducedMotion';
|
|
6
|
+
export { SHIMMER, SKELETON_TOKEN, SKELETON_FALLBACK } from './shimmer-tokens';
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Single source of truth for the skeleton shimmer behaviour spec.
|
|
5
|
+
*
|
|
6
|
+
* The two modes mirror the documented design behaviour:
|
|
7
|
+
*
|
|
8
|
+
* Normal:
|
|
9
|
+
* - 1.5s linear sawtooth cycle (band sweeps end-to-end then resets)
|
|
10
|
+
* - 135° gradient (top-left -> bottom-right), fixed angle on any aspect
|
|
11
|
+
* ratio via SVG userSpaceOnUse coordinates
|
|
12
|
+
* - Band alpha animates 33% -> 100% -> 33% across the band core;
|
|
13
|
+
* transparent fades at the gradient edges hide the sawtooth reset
|
|
14
|
+
* - 50–100ms per-item stagger
|
|
15
|
+
*
|
|
16
|
+
* Reduced motion (system-level OS toggle):
|
|
17
|
+
* - 3.0s ease-in-out cycle
|
|
18
|
+
* - No translation — solid white overlay opacity pulses 33% -> 100% in
|
|
19
|
+
* place (`opacityRange` below is consumed in this path)
|
|
20
|
+
* - No gradient
|
|
21
|
+
* - No stagger
|
|
22
|
+
*
|
|
23
|
+
* Centralising the spec here keeps `Skeleton.tsx` purely presentational and
|
|
24
|
+
* lets us tune the behaviour in one place if design ever updates it.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const SHIMMER = {
|
|
28
|
+
normal: {
|
|
29
|
+
durationMs: 1500,
|
|
30
|
+
staggerMsRange: [50, 100],
|
|
31
|
+
gradient: true,
|
|
32
|
+
opacityRange: [0.33, 1.0]
|
|
33
|
+
},
|
|
34
|
+
reduced: {
|
|
35
|
+
durationMs: 3000,
|
|
36
|
+
staggerMsRange: [0, 0],
|
|
37
|
+
gradient: false,
|
|
38
|
+
opacityRange: [0.33, 1.0]
|
|
39
|
+
},
|
|
40
|
+
/**
|
|
41
|
+
* Gradient angle in degrees, measured the CSS way: 0deg points "up", 90deg
|
|
42
|
+
* "right", 135deg therefore points down-right (top-left corner to
|
|
43
|
+
* bottom-right corner).
|
|
44
|
+
*/
|
|
45
|
+
gradientAngleDeg: 135,
|
|
46
|
+
/**
|
|
47
|
+
* Hard cap on the cumulative stagger delay so very long lists don't drift
|
|
48
|
+
* out forever; the wave wraps after this many ms.
|
|
49
|
+
*/
|
|
50
|
+
maxStaggerMs: 600
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Token names — referenced via `getVariableByName(...)` so the existing
|
|
55
|
+
* design-token resolver does its job (caching, mode resolution, etc.).
|
|
56
|
+
*
|
|
57
|
+
* The four tokens already live in the Figma export at
|
|
58
|
+
* `src/design-tokens/Coin Variables-variables-full.json`:
|
|
59
|
+
*
|
|
60
|
+
* - `bg/defaultSkeleton` -> base color for every skeleton block
|
|
61
|
+
* - `cornerRadius/defaultSkeleton` -> text & "other" (pill: 9999)
|
|
62
|
+
* - `cornerRadius/imageSkeleton` -> images (10)
|
|
63
|
+
* - `cornerRadius/badgeSkeleton` -> badges (4)
|
|
64
|
+
*/
|
|
65
|
+
export const SKELETON_TOKEN = {
|
|
66
|
+
background: 'bg/defaultSkeleton',
|
|
67
|
+
radius: {
|
|
68
|
+
text: 'cornerRadius/defaultSkeleton',
|
|
69
|
+
image: 'cornerRadius/imageSkeleton',
|
|
70
|
+
badge: 'cornerRadius/badgeSkeleton',
|
|
71
|
+
other: 'cornerRadius/defaultSkeleton'
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Fallback constants used when the token resolver is unavailable (tests,
|
|
77
|
+
* SSR, etc.). Match the current Figma values.
|
|
78
|
+
*/
|
|
79
|
+
export const SKELETON_FALLBACK = {
|
|
80
|
+
backgroundColor: 'rgb(245, 245, 246)',
|
|
81
|
+
radius: {
|
|
82
|
+
text: 9999,
|
|
83
|
+
image: 10,
|
|
84
|
+
badge: 4,
|
|
85
|
+
other: 9999
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Compute a stable per-item delay from a 0-based registration index.
|
|
91
|
+
*
|
|
92
|
+
* We pick the midpoint of the documented range (75ms) so the cascade is
|
|
93
|
+
* deterministic for snapshot tests and visually identical between renders.
|
|
94
|
+
* The total delay is then wrapped at `maxStaggerMs` so deep lists don't
|
|
95
|
+
* drift past the cap.
|
|
96
|
+
*/
|
|
97
|
+
export function staggerDelayMs(index, mode) {
|
|
98
|
+
const [min, max] = mode.staggerMsRange;
|
|
99
|
+
if (max <= 0) return 0;
|
|
100
|
+
const step = (min + max) / 2;
|
|
101
|
+
return index * step % SHIMMER.maxStaggerMs;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** One stop on the moving shimmer gradient (offset 0–1 along the 135° axis). */
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Gradient stops for the normal-mode moving band. The peak sits at 0.5; fully
|
|
108
|
+
* transparent tails at 0 and 1. The 0.30 / 0.70 stops mark where the band
|
|
109
|
+
* reaches the documented 33 % alpha.
|
|
110
|
+
*/
|
|
111
|
+
export const SHIMMER_GRADIENT_STOPS = [{
|
|
112
|
+
offset: 0,
|
|
113
|
+
opacity: 0
|
|
114
|
+
}, {
|
|
115
|
+
offset: 0.30,
|
|
116
|
+
opacity: 0.33
|
|
117
|
+
}, {
|
|
118
|
+
offset: 0.50,
|
|
119
|
+
opacity: 1
|
|
120
|
+
}, {
|
|
121
|
+
offset: 0.70,
|
|
122
|
+
opacity: 0.33
|
|
123
|
+
}, {
|
|
124
|
+
offset: 1.0,
|
|
125
|
+
opacity: 0
|
|
126
|
+
}];
|
|
127
|
+
|
|
128
|
+
/** Offset (0–1) of the brightest point on the gradient line. */
|
|
129
|
+
export function gradientPeakOffset(stops = SHIMMER_GRADIENT_STOPS) {
|
|
130
|
+
const peak = stops.find(s => s.opacity === 1);
|
|
131
|
+
return peak?.offset ?? 0.5;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* How far the gradient extends from the peak to a fully transparent stop,
|
|
136
|
+
* expressed as a fraction of the gradient line (0–1). With the default stops
|
|
137
|
+
* this is 0.5 (peak at 0.5, transparent at 0 and 1).
|
|
138
|
+
*
|
|
139
|
+
* This value drives the overshoot: the band must travel this fraction of a
|
|
140
|
+
* full corner-to-corner sweep *beyond* each corner so the soft transparent
|
|
141
|
+
* tails fully clear the box before the sawtooth reset.
|
|
142
|
+
*/
|
|
143
|
+
export function gradientTransparentExtent(stops = SHIMMER_GRADIENT_STOPS) {
|
|
144
|
+
const peak = gradientPeakOffset(stops);
|
|
145
|
+
const transparent = stops.filter(s => s.opacity === 0);
|
|
146
|
+
if (transparent.length === 0) return 0.5;
|
|
147
|
+
return Math.max(...transparent.map(s => Math.abs(s.offset - peak)));
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Derive the moving-shimmer geometry from box size, overlay size, and the
|
|
151
|
+
* gradient stop layout.
|
|
152
|
+
*
|
|
153
|
+
* Coordinate model (135° / down-right):
|
|
154
|
+
* - Peak stripe world diagonal sum: (W + H) / 2 + 2k
|
|
155
|
+
* - Gradient offset 0 → 1 spans 2 × overlaySize in diagonal-sum units
|
|
156
|
+
* - Transparent tail beyond peak: fadeExtent × 2 × overlaySize / 2
|
|
157
|
+
* = fadeExtent × overlaySize in k units
|
|
158
|
+
*
|
|
159
|
+
* The sawtooth clock maps linearly kStart → kEnd so t = 0 and t = 1 both
|
|
160
|
+
* land with the entire gradient outside the box — no jitter on reset.
|
|
161
|
+
*/
|
|
162
|
+
export function computeShimmerMotion(width, height, overlaySize, stops = SHIMMER_GRADIENT_STOPS) {
|
|
163
|
+
if (width <= 0 || height <= 0 || overlaySize <= 0) return null;
|
|
164
|
+
const fadeExtent = gradientTransparentExtent(stops);
|
|
165
|
+
const cornerTravelK = (width + height) / 2;
|
|
166
|
+
const baseSweepHalfK = cornerTravelK / 2;
|
|
167
|
+
const fadeBeyondPeakK = fadeExtent * overlaySize;
|
|
168
|
+
const kStart = -baseSweepHalfK - fadeBeyondPeakK;
|
|
169
|
+
const kEnd = baseSweepHalfK + fadeBeyondPeakK;
|
|
170
|
+
const overshootFraction = cornerTravelK > 0 ? fadeBeyondPeakK / cornerTravelK : 0;
|
|
171
|
+
return {
|
|
172
|
+
overlaySize,
|
|
173
|
+
padX: (overlaySize - width) / 2,
|
|
174
|
+
padY: (overlaySize - height) / 2,
|
|
175
|
+
kStart,
|
|
176
|
+
kEnd,
|
|
177
|
+
overshootFraction,
|
|
178
|
+
cornerTravelK,
|
|
179
|
+
fadeBeyondPeakK
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { AccessibilityInfo, Platform } from 'react-native';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cross-platform "prefers reduced motion" hook.
|
|
8
|
+
*
|
|
9
|
+
* - Native: reads `AccessibilityInfo.isReduceMotionEnabled()` and subscribes
|
|
10
|
+
* to `reduceMotionChanged` events so the value stays live as the user
|
|
11
|
+
* toggles the OS setting.
|
|
12
|
+
* - Web: uses `window.matchMedia('(prefers-reduced-motion: reduce)')`,
|
|
13
|
+
* subscribing to its `change` event.
|
|
14
|
+
* - Anywhere either API is missing: returns `false` (no reduction).
|
|
15
|
+
*
|
|
16
|
+
* The hook never throws — every native API access is defensively guarded so
|
|
17
|
+
* the skeleton system stays safe in tests, SSR, and constrained sandboxes.
|
|
18
|
+
*/
|
|
19
|
+
export function useReducedMotion() {
|
|
20
|
+
const [reduced, setReduced] = useState(false);
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
let cancelled = false;
|
|
23
|
+
if (Platform.OS === 'web') {
|
|
24
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
28
|
+
const update = matches => {
|
|
29
|
+
if (!cancelled) setReduced(matches);
|
|
30
|
+
};
|
|
31
|
+
update(mql.matches);
|
|
32
|
+
const listener = e => update(e.matches);
|
|
33
|
+
if (typeof mql.addEventListener === 'function') {
|
|
34
|
+
mql.addEventListener('change', listener);
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
mql.removeEventListener('change', listener);
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const legacyMql = mql;
|
|
41
|
+
legacyMql.addListener?.(listener);
|
|
42
|
+
return () => {
|
|
43
|
+
cancelled = true;
|
|
44
|
+
legacyMql.removeListener?.(listener);
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (typeof AccessibilityInfo?.isReduceMotionEnabled === 'function') {
|
|
48
|
+
AccessibilityInfo.isReduceMotionEnabled().then(value => {
|
|
49
|
+
if (!cancelled) setReduced(!!value);
|
|
50
|
+
}).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
const sub = typeof AccessibilityInfo?.addEventListener === 'function' ? AccessibilityInfo.addEventListener('reduceMotionChanged', value => {
|
|
53
|
+
if (!cancelled) setReduced(!!value);
|
|
54
|
+
}) : null;
|
|
55
|
+
return () => {
|
|
56
|
+
cancelled = true;
|
|
57
|
+
sub?.remove?.();
|
|
58
|
+
};
|
|
59
|
+
}, []);
|
|
60
|
+
return reduced;
|
|
61
|
+
}
|