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/src/icons/Icon.tsx
CHANGED
|
@@ -3,6 +3,8 @@ import { View, AccessibilityProps, type StyleProp, type ViewStyle } from 'react-
|
|
|
3
3
|
import Svg, { Path } from 'react-native-svg';
|
|
4
4
|
import { getIcon, hasIcon } from './registry';
|
|
5
5
|
import MediaSource, { type UnifiedSource } from '../utils/MediaSource';
|
|
6
|
+
import Skeleton from '../skeleton/Skeleton';
|
|
7
|
+
import { useSkeleton } from '../skeleton/SkeletonGroup';
|
|
6
8
|
|
|
7
9
|
type IconProps = AccessibilityProps & {
|
|
8
10
|
/**
|
|
@@ -22,6 +24,12 @@ type IconProps = AccessibilityProps & {
|
|
|
22
24
|
size?: number;
|
|
23
25
|
color?: string;
|
|
24
26
|
style?: StyleProp<ViewStyle>;
|
|
27
|
+
/**
|
|
28
|
+
* Explicit per-instance loading override. When `true`, renders a square
|
|
29
|
+
* skeleton at the icon's size; when `false`, the surrounding
|
|
30
|
+
* `<SkeletonGroup>` is ignored. Defaults to inheriting from the group.
|
|
31
|
+
*/
|
|
32
|
+
loading?: boolean;
|
|
25
33
|
};
|
|
26
34
|
|
|
27
35
|
/**
|
|
@@ -54,8 +62,13 @@ function Icon({
|
|
|
54
62
|
size = 24,
|
|
55
63
|
color = '#141414',
|
|
56
64
|
style,
|
|
65
|
+
loading,
|
|
57
66
|
...rest
|
|
58
67
|
}: IconProps) {
|
|
68
|
+
// Skeleton context — always read; we short-circuit below.
|
|
69
|
+
const { active: groupActive } = useSkeleton();
|
|
70
|
+
const isLoading = loading ?? groupActive;
|
|
71
|
+
|
|
59
72
|
const containerStyle: StyleProp<ViewStyle> = [
|
|
60
73
|
{
|
|
61
74
|
width: size,
|
|
@@ -66,6 +79,10 @@ function Icon({
|
|
|
66
79
|
style,
|
|
67
80
|
];
|
|
68
81
|
|
|
82
|
+
if (isLoading) {
|
|
83
|
+
return <Skeleton kind="other" width={size} height={size} style={style as any} />;
|
|
84
|
+
}
|
|
85
|
+
|
|
69
86
|
const iconData = name && hasIcon(name) ? getIcon(name) : null;
|
|
70
87
|
|
|
71
88
|
if (iconData) {
|
package/src/icons/registry.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import React, { useId, useMemo, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
type LayoutChangeEvent,
|
|
4
|
+
StyleSheet,
|
|
5
|
+
View,
|
|
6
|
+
type DimensionValue,
|
|
7
|
+
type StyleProp,
|
|
8
|
+
type ViewStyle,
|
|
9
|
+
} from 'react-native'
|
|
10
|
+
import Animated, {
|
|
11
|
+
interpolate,
|
|
12
|
+
useAnimatedStyle,
|
|
13
|
+
} from 'react-native-reanimated'
|
|
14
|
+
import Svg, {
|
|
15
|
+
Defs,
|
|
16
|
+
LinearGradient as SvgLinearGradient,
|
|
17
|
+
Rect,
|
|
18
|
+
Stop,
|
|
19
|
+
} from 'react-native-svg'
|
|
20
|
+
import { getVariableByName } from '../design-tokens/figma-variables-resolver'
|
|
21
|
+
import {
|
|
22
|
+
SHIMMER,
|
|
23
|
+
SHIMMER_GRADIENT_STOPS,
|
|
24
|
+
SKELETON_FALLBACK,
|
|
25
|
+
SKELETON_TOKEN,
|
|
26
|
+
computeShimmerMotion,
|
|
27
|
+
staggerDelayMs,
|
|
28
|
+
type SkeletonKind,
|
|
29
|
+
} from './shimmer-tokens'
|
|
30
|
+
import { useSkeleton, useStaggerIndex } from './SkeletonGroup'
|
|
31
|
+
|
|
32
|
+
export interface SkeletonProps {
|
|
33
|
+
/**
|
|
34
|
+
* Visual category — controls only the corner-radius token. Sizes are still
|
|
35
|
+
* supplied via `width`/`height`. Defaults to `'other'`.
|
|
36
|
+
*/
|
|
37
|
+
kind?: SkeletonKind;
|
|
38
|
+
/** Width of the rectangular placeholder. Numbers are dp, strings are RN dimensions. */
|
|
39
|
+
width?: DimensionValue;
|
|
40
|
+
/** Height of the rectangular placeholder. */
|
|
41
|
+
height?: DimensionValue;
|
|
42
|
+
/**
|
|
43
|
+
* Optional style overrides merged onto the base layer (e.g. `marginTop`,
|
|
44
|
+
* `alignSelf`). Avoid `backgroundColor` and `borderRadius` here — use the
|
|
45
|
+
* design tokens instead so the system stays consistent.
|
|
46
|
+
*/
|
|
47
|
+
style?: StyleProp<ViewStyle>;
|
|
48
|
+
/**
|
|
49
|
+
* Modes for token resolution (forwarded to `getVariableByName`). Most
|
|
50
|
+
* consumers can omit this — defaults work for every standard mode.
|
|
51
|
+
*/
|
|
52
|
+
modes?: Record<string, any>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Solid-white overlay used by reduced-motion mode (also acts as the safe
|
|
56
|
+
// fallback when SVG can't render or when the box hasn't been measured yet).
|
|
57
|
+
// Allocated once at module scope so per-render allocation is zero.
|
|
58
|
+
const SOLID_OVERLAY_COLOR = 'rgba(255, 255, 255, 1)'
|
|
59
|
+
|
|
60
|
+
// `pointerEvents: 'none'` lives on the style (not the deprecated prop) so it
|
|
61
|
+
// works on both native and React Native Web without warnings.
|
|
62
|
+
const absoluteFillStyle: ViewStyle = {
|
|
63
|
+
...StyleSheet.absoluteFillObject,
|
|
64
|
+
overflow: 'hidden',
|
|
65
|
+
pointerEvents: 'none',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const solidOverlayStyle: ViewStyle = {
|
|
69
|
+
...StyleSheet.absoluteFillObject,
|
|
70
|
+
backgroundColor: SOLID_OVERLAY_COLOR,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Atomic skeleton placeholder. Renders a base rectangle (background colour +
|
|
75
|
+
* radius per kind) plus an animated overlay that produces the shimmer.
|
|
76
|
+
*
|
|
77
|
+
* Two visual modes share the same engine:
|
|
78
|
+
*
|
|
79
|
+
* - Normal mode (gradient: true): a soft 135° band translates diagonally
|
|
80
|
+
* across the box end-to-end. Its alpha inside the moving band varies
|
|
81
|
+
* 33% → 100% → 33% via gradient stops. The sawtooth clock travels from
|
|
82
|
+
* fully off-screen (−overshoot) to fully off-screen (+overshoot) so the
|
|
83
|
+
* transparent gradient tails clear the box before the loop resets.
|
|
84
|
+
*
|
|
85
|
+
* - Reduced-motion mode (gradient: false): a solid white overlay covers the
|
|
86
|
+
* box and pulses opacity 33% → 100% → 33% in place, with no translation,
|
|
87
|
+
* ease-in-out timing and no stagger.
|
|
88
|
+
*
|
|
89
|
+
* Critical for cross-platform consistency:
|
|
90
|
+
*
|
|
91
|
+
* - The SVG gradient uses `gradientUnits="userSpaceOnUse"` with absolute
|
|
92
|
+
* pixel coordinates that have Δx === Δy. This guarantees the visual angle
|
|
93
|
+
* is exactly 45° in screen pixels (= 135° CSS) on ANY aspect ratio. With
|
|
94
|
+
* the default `objectBoundingBox` units, the gradient line is normalised
|
|
95
|
+
* to the box and tilts off-angle on non-square boxes.
|
|
96
|
+
*
|
|
97
|
+
* - The whole animated overlay sits inside a clipping parent (`overflow:
|
|
98
|
+
* hidden` + `borderRadius`). Translating an `Animated.View` is GPU-cheap
|
|
99
|
+
* on both iOS/Android (Reanimated UI thread) and on web (CSS transforms).
|
|
100
|
+
*
|
|
101
|
+
* - The SVG `id` for the gradient is per-instance (`useId`) so multiple
|
|
102
|
+
* skeletons on a web page don't collide on `url(#…)` resolution.
|
|
103
|
+
*/
|
|
104
|
+
function SkeletonImpl({
|
|
105
|
+
kind = 'other',
|
|
106
|
+
width,
|
|
107
|
+
height,
|
|
108
|
+
style,
|
|
109
|
+
modes,
|
|
110
|
+
}: SkeletonProps) {
|
|
111
|
+
const { active, reducedMotion, clock } = useSkeleton()
|
|
112
|
+
const index = useStaggerIndex()
|
|
113
|
+
const reactId = useId()
|
|
114
|
+
// SVG ids must match `^[A-Za-z][A-Za-z0-9_.-]*$`; React's useId returns
|
|
115
|
+
// colons which are illegal in fragment refs, so strip them.
|
|
116
|
+
const gradientId = `jfsSkeletonGradient-${reactId.replace(/[^A-Za-z0-9_-]/g, '')}`
|
|
117
|
+
|
|
118
|
+
// Measured pixel size of the base box. The moving shimmer geometry depends
|
|
119
|
+
// on real layout so we capture it via onLayout. Before the first layout
|
|
120
|
+
// pass W and H are 0 and we render no overlay (the next render after layout
|
|
121
|
+
// fills it in).
|
|
122
|
+
const [layout, setLayout] = useState({ width: 0, height: 0 })
|
|
123
|
+
|
|
124
|
+
const radiusTokenName = SKELETON_TOKEN.radius[kind]
|
|
125
|
+
const radius =
|
|
126
|
+
(getVariableByName(radiusTokenName, modes ?? {}) as number | null) ??
|
|
127
|
+
SKELETON_FALLBACK.radius[kind]
|
|
128
|
+
|
|
129
|
+
const backgroundColor =
|
|
130
|
+
(getVariableByName(SKELETON_TOKEN.background, modes ?? {}) as
|
|
131
|
+
| string
|
|
132
|
+
| null) ?? SKELETON_FALLBACK.backgroundColor
|
|
133
|
+
|
|
134
|
+
const spec = reducedMotion ? SHIMMER.reduced : SHIMMER.normal
|
|
135
|
+
// Convert the per-item stagger into a phase offset in [0, 1) (relative to
|
|
136
|
+
// one cycle). The sawtooth clock means a phase shift simply slides the
|
|
137
|
+
// start point of each skeleton's sweep, producing the cascade.
|
|
138
|
+
const phaseOffset =
|
|
139
|
+
spec.durationMs > 0 ? staggerDelayMs(index, spec) / spec.durationMs : 0
|
|
140
|
+
|
|
141
|
+
const baseStyle = useMemo<ViewStyle>(
|
|
142
|
+
() => ({
|
|
143
|
+
backgroundColor,
|
|
144
|
+
borderRadius: radius,
|
|
145
|
+
overflow: 'hidden',
|
|
146
|
+
...(width !== undefined ? { width } : {}),
|
|
147
|
+
...(height !== undefined ? { height } : {}),
|
|
148
|
+
}),
|
|
149
|
+
[backgroundColor, radius, width, height],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// --- Moving-shimmer geometry --------------------------------------------
|
|
153
|
+
//
|
|
154
|
+
// Overlay is 2× the box diagonal so the gradient still covers the clipped
|
|
155
|
+
// area at extreme translations. Overshoot (travel beyond each corner) is
|
|
156
|
+
// derived from the gradient stop layout via `computeShimmerMotion`.
|
|
157
|
+
const W = layout.width
|
|
158
|
+
const H = layout.height
|
|
159
|
+
const diag = Math.sqrt(W * W + H * H)
|
|
160
|
+
const overlaySize = diag * 2
|
|
161
|
+
|
|
162
|
+
const motion = useMemo(
|
|
163
|
+
() => computeShimmerMotion(W, H, overlaySize, SHIMMER_GRADIENT_STOPS),
|
|
164
|
+
[W, H, overlaySize],
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const padX = motion?.padX ?? 0
|
|
168
|
+
const padY = motion?.padY ?? 0
|
|
169
|
+
const kStart = motion?.kStart ?? 0
|
|
170
|
+
const kEnd = motion?.kEnd ?? 0
|
|
171
|
+
const kTravel = kEnd - kStart
|
|
172
|
+
|
|
173
|
+
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
|
174
|
+
const t = (clock.value + phaseOffset) % 1
|
|
175
|
+
if (spec.gradient) {
|
|
176
|
+
if (kTravel === 0) {
|
|
177
|
+
// Pre-layout: keep the overlay invisible so we don't flash a
|
|
178
|
+
// mis-sized gradient before onLayout fires.
|
|
179
|
+
return {
|
|
180
|
+
transform: [
|
|
181
|
+
{ translateX: 0 },
|
|
182
|
+
{ translateY: 0 },
|
|
183
|
+
] as unknown as ViewStyle['transform'],
|
|
184
|
+
opacity: 0,
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Linear sawtooth: t = 0 → fully before entry, t = 1 → fully after exit.
|
|
188
|
+
// Equivalent to normalised phase p = −O + t·(1 + 2O) where O is the
|
|
189
|
+
// gradient-derived overshoot fraction from `computeShimmerMotion`.
|
|
190
|
+
const k = kStart + t * kTravel
|
|
191
|
+
return {
|
|
192
|
+
transform: [
|
|
193
|
+
{ translateX: k },
|
|
194
|
+
{ translateY: k },
|
|
195
|
+
] as unknown as ViewStyle['transform'],
|
|
196
|
+
opacity: 1,
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
// Reduced motion: stationary solid overlay, opacity triangle wave.
|
|
200
|
+
const wave = t < 0.5 ? t * 2 : (1 - t) * 2
|
|
201
|
+
const [lo, hi] = spec.opacityRange
|
|
202
|
+
return {
|
|
203
|
+
transform: [
|
|
204
|
+
{ translateX: 0 },
|
|
205
|
+
{ translateY: 0 },
|
|
206
|
+
] as unknown as ViewStyle['transform'],
|
|
207
|
+
opacity: interpolate(wave, [0, 1], [lo, hi]),
|
|
208
|
+
}
|
|
209
|
+
}, [
|
|
210
|
+
phaseOffset,
|
|
211
|
+
kStart,
|
|
212
|
+
kTravel,
|
|
213
|
+
spec.gradient,
|
|
214
|
+
spec.opacityRange[0],
|
|
215
|
+
spec.opacityRange[1],
|
|
216
|
+
])
|
|
217
|
+
|
|
218
|
+
const gradientOverlayContainerStyle = useMemo<ViewStyle>(
|
|
219
|
+
() => ({
|
|
220
|
+
position: 'absolute',
|
|
221
|
+
left: -padX,
|
|
222
|
+
top: -padY,
|
|
223
|
+
width: overlaySize,
|
|
224
|
+
height: overlaySize,
|
|
225
|
+
pointerEvents: 'none',
|
|
226
|
+
}),
|
|
227
|
+
[padX, padY, overlaySize],
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if (!active) {
|
|
231
|
+
// Render nothing when the group is inactive. This lets users sprinkle
|
|
232
|
+
// <Skeleton /> blocks unconditionally inside their loaded UI without
|
|
233
|
+
// having to manually gate them — only the active group flips them on.
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const onLayout = (e: LayoutChangeEvent) => {
|
|
238
|
+
const { width: w, height: h } = e.nativeEvent.layout
|
|
239
|
+
if (w !== layout.width || h !== layout.height) {
|
|
240
|
+
setLayout({ width: w, height: h })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return (
|
|
245
|
+
<View
|
|
246
|
+
style={[baseStyle, style]}
|
|
247
|
+
accessibilityElementsHidden
|
|
248
|
+
importantForAccessibility="no-hide-descendants"
|
|
249
|
+
// Screen readers announce a loading state once via the group's owner;
|
|
250
|
+
// each individual skeleton is purely decorative.
|
|
251
|
+
accessibilityRole="none"
|
|
252
|
+
onLayout={onLayout}
|
|
253
|
+
>
|
|
254
|
+
{spec.gradient ? (
|
|
255
|
+
layout.width > 0 ? (
|
|
256
|
+
<Animated.View
|
|
257
|
+
style={[gradientOverlayContainerStyle, overlayAnimatedStyle]}
|
|
258
|
+
>
|
|
259
|
+
<Svg width={overlaySize} height={overlaySize}>
|
|
260
|
+
<Defs>
|
|
261
|
+
<SvgLinearGradient
|
|
262
|
+
id={gradientId}
|
|
263
|
+
x1={0}
|
|
264
|
+
y1={0}
|
|
265
|
+
x2={overlaySize}
|
|
266
|
+
y2={overlaySize}
|
|
267
|
+
gradientUnits="userSpaceOnUse"
|
|
268
|
+
>
|
|
269
|
+
{SHIMMER_GRADIENT_STOPS.map((stop, i) => (
|
|
270
|
+
<Stop
|
|
271
|
+
key={`${stop.offset}-${i}`}
|
|
272
|
+
offset={String(stop.offset)}
|
|
273
|
+
stopColor="#ffffff"
|
|
274
|
+
stopOpacity={stop.opacity}
|
|
275
|
+
/>
|
|
276
|
+
))}
|
|
277
|
+
</SvgLinearGradient>
|
|
278
|
+
</Defs>
|
|
279
|
+
<Rect
|
|
280
|
+
width={overlaySize}
|
|
281
|
+
height={overlaySize}
|
|
282
|
+
fill={`url(#${gradientId})`}
|
|
283
|
+
/>
|
|
284
|
+
</Svg>
|
|
285
|
+
</Animated.View>
|
|
286
|
+
) : null
|
|
287
|
+
) : (
|
|
288
|
+
<Animated.View style={[absoluteFillStyle, overlayAnimatedStyle]}>
|
|
289
|
+
<View style={solidOverlayStyle} />
|
|
290
|
+
</Animated.View>
|
|
291
|
+
)}
|
|
292
|
+
</View>
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const Skeleton = React.memo(SkeletonImpl)
|
|
297
|
+
|
|
298
|
+
export default Skeleton
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from 'react'
|
|
9
|
+
import {
|
|
10
|
+
cancelAnimation,
|
|
11
|
+
Easing,
|
|
12
|
+
useSharedValue,
|
|
13
|
+
withRepeat,
|
|
14
|
+
withTiming,
|
|
15
|
+
type SharedValue,
|
|
16
|
+
} from 'react-native-reanimated'
|
|
17
|
+
import { SHIMMER } from './shimmer-tokens'
|
|
18
|
+
import { useReducedMotion as useSystemReducedMotion } from './useReducedMotion'
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Shape of the value carried by the SkeletonContext.
|
|
22
|
+
*
|
|
23
|
+
* The provider owns ONE Reanimated shared value per group; every child
|
|
24
|
+
* `<Skeleton>` reads it on the UI thread and computes its own opacity from
|
|
25
|
+
* `(clock + perItemPhaseOffset)`. That keeps per-skeleton work bounded to a
|
|
26
|
+
* single `useAnimatedStyle` and avoids per-block JS timers.
|
|
27
|
+
*/
|
|
28
|
+
export interface SkeletonContextValue {
|
|
29
|
+
/** Whether the surrounding group is currently in loading state. */
|
|
30
|
+
active: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Whether reduced-motion treatment should be applied. May be auto-detected
|
|
33
|
+
* from the OS or explicitly overridden via the `reducedMotion` prop on
|
|
34
|
+
* `<SkeletonGroup>`.
|
|
35
|
+
*/
|
|
36
|
+
reducedMotion: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Shared phase value driven on the UI thread. Sawtooth: ramps linearly
|
|
39
|
+
* (or eased) from 0 -> 1 over one `SHIMMER.*.durationMs` period, then
|
|
40
|
+
* jumps back to 0 and repeats. Sawtooth is required so that the moving
|
|
41
|
+
* shimmer band sweeps from end to end at a constant heading and resets
|
|
42
|
+
* cleanly; a ping-pong clock would produce a discontinuity in
|
|
43
|
+
* `(clock + phaseOffset) % 1` at the apex, which translates to a visible
|
|
44
|
+
* jump in the band's position when stagger offsets are non-zero.
|
|
45
|
+
*/
|
|
46
|
+
clock: SharedValue<number>;
|
|
47
|
+
/**
|
|
48
|
+
* Atomically registers a child skeleton and returns its registration index.
|
|
49
|
+
* The order is stable for the life of the parent group; the counter is held
|
|
50
|
+
* in a ref so React's render lifecycle doesn't reshuffle it.
|
|
51
|
+
*/
|
|
52
|
+
nextIndex(): number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const SkeletonContext = createContext<SkeletonContextValue | null>(null)
|
|
56
|
+
|
|
57
|
+
export interface SkeletonGroupProps {
|
|
58
|
+
/** When true, descendant skeleton-aware primitives render placeholders. */
|
|
59
|
+
loading: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Override the auto-detected reduced-motion preference. Pass `undefined` to
|
|
62
|
+
* use the system value (default). Pass `true`/`false` to force.
|
|
63
|
+
*/
|
|
64
|
+
reducedMotion?: boolean;
|
|
65
|
+
children?: ReactNode;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Provider that flips an entire subtree into "loading" mode and supplies the
|
|
70
|
+
* shared shimmer clock + stagger index source to every descendant skeleton.
|
|
71
|
+
*
|
|
72
|
+
* Usage:
|
|
73
|
+
* ```tsx
|
|
74
|
+
* <SkeletonGroup loading={!data}>
|
|
75
|
+
* <Card>...JFS primitives auto-skeletonize...</Card>
|
|
76
|
+
* </SkeletonGroup>
|
|
77
|
+
* ```
|
|
78
|
+
*
|
|
79
|
+
* Nesting is supported — an inner `<SkeletonGroup>` fully overrides the
|
|
80
|
+
* outer one for its subtree (its own clock, its own index counter).
|
|
81
|
+
*/
|
|
82
|
+
export function SkeletonGroup({
|
|
83
|
+
loading,
|
|
84
|
+
reducedMotion: reducedMotionOverride,
|
|
85
|
+
children,
|
|
86
|
+
}: SkeletonGroupProps) {
|
|
87
|
+
const systemReducedMotion = useSystemReducedMotion()
|
|
88
|
+
const reducedMotion = reducedMotionOverride ?? systemReducedMotion
|
|
89
|
+
const clock = useSharedValue(0)
|
|
90
|
+
|
|
91
|
+
// The duration depends on whether we're in reduced-motion mode; we
|
|
92
|
+
// intentionally restart the loop when either `loading` or `reducedMotion`
|
|
93
|
+
// changes so the new timing takes effect immediately.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!loading) {
|
|
96
|
+
cancelAnimation(clock)
|
|
97
|
+
clock.value = 0
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const spec = reducedMotion ? SHIMMER.reduced : SHIMMER.normal
|
|
101
|
+
const easing = reducedMotion
|
|
102
|
+
? Easing.inOut(Easing.ease)
|
|
103
|
+
: Easing.linear
|
|
104
|
+
clock.value = 0
|
|
105
|
+
// Sawtooth loop (third arg = false). One-way ramp 0 -> 1, reset, repeat.
|
|
106
|
+
// The moving shimmer band traverses the box on each 0 -> 1 ramp; with the
|
|
107
|
+
// band's gradient fully faded at both extremes of the sweep, the reset is
|
|
108
|
+
// visually imperceptible.
|
|
109
|
+
clock.value = withRepeat(
|
|
110
|
+
withTiming(1, { duration: spec.durationMs, easing }),
|
|
111
|
+
-1,
|
|
112
|
+
false,
|
|
113
|
+
)
|
|
114
|
+
return () => {
|
|
115
|
+
cancelAnimation(clock)
|
|
116
|
+
}
|
|
117
|
+
}, [loading, reducedMotion, clock])
|
|
118
|
+
|
|
119
|
+
// Stagger index counter — reset on each (re)mount so reorders and toggles
|
|
120
|
+
// get a fresh, deterministic ordering. The counter lives in a ref so
|
|
121
|
+
// React's commit phase doesn't shuffle it.
|
|
122
|
+
const indexRef = useRef(0)
|
|
123
|
+
// Reset whenever loading flips on, so a freshly-displayed group cascades
|
|
124
|
+
// from index 0 again rather than picking up stale numbers.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (loading) indexRef.current = 0
|
|
127
|
+
}, [loading])
|
|
128
|
+
|
|
129
|
+
const value = useMemo<SkeletonContextValue>(
|
|
130
|
+
() => ({
|
|
131
|
+
active: loading,
|
|
132
|
+
reducedMotion,
|
|
133
|
+
clock,
|
|
134
|
+
nextIndex: () => {
|
|
135
|
+
const i = indexRef.current
|
|
136
|
+
indexRef.current = i + 1
|
|
137
|
+
return i
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
[loading, reducedMotion, clock],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<SkeletonContext.Provider value={value}>
|
|
145
|
+
{children}
|
|
146
|
+
</SkeletonContext.Provider>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Internal hook used by skeleton-aware primitives (and by `<Skeleton>`
|
|
152
|
+
* itself) to read the surrounding context.
|
|
153
|
+
*
|
|
154
|
+
* Always returns a non-null value: when called outside any provider, the
|
|
155
|
+
* returned object has `active: false` so the consumer falls through to its
|
|
156
|
+
* normal render path. The fallback clock is a no-op shared value so any
|
|
157
|
+
* direct `<Skeleton>` usage outside a group still has something safe to read.
|
|
158
|
+
*/
|
|
159
|
+
export function useSkeleton(): SkeletonContextValue {
|
|
160
|
+
const ctx = useContext(SkeletonContext)
|
|
161
|
+
// Hook order must be stable — always allocate the fallback even when ctx
|
|
162
|
+
// exists; React only reads from `ctx` when it's non-null below.
|
|
163
|
+
const fallbackClock = useSharedValue(0)
|
|
164
|
+
const fallbackIndexRef = useRef(0)
|
|
165
|
+
const fallback = useMemo<SkeletonContextValue>(
|
|
166
|
+
() => ({
|
|
167
|
+
active: false,
|
|
168
|
+
reducedMotion: false,
|
|
169
|
+
clock: fallbackClock,
|
|
170
|
+
nextIndex: () => {
|
|
171
|
+
const i = fallbackIndexRef.current
|
|
172
|
+
fallbackIndexRef.current = i + 1
|
|
173
|
+
return i
|
|
174
|
+
},
|
|
175
|
+
}),
|
|
176
|
+
[fallbackClock],
|
|
177
|
+
)
|
|
178
|
+
return ctx ?? fallback
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Convenience wrapper around `useSkeleton().nextIndex()` that pins the
|
|
183
|
+
* returned value across re-renders. Each `<Skeleton>` calls this once on
|
|
184
|
+
* mount; subsequent renders re-use the same index from the closure.
|
|
185
|
+
*/
|
|
186
|
+
export function useStaggerIndex(): number {
|
|
187
|
+
const { nextIndex } = useSkeleton()
|
|
188
|
+
const indexRef = useRef<number | null>(null)
|
|
189
|
+
if (indexRef.current === null) {
|
|
190
|
+
indexRef.current = nextIndex()
|
|
191
|
+
}
|
|
192
|
+
return indexRef.current
|
|
193
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as Skeleton, type SkeletonProps } from './Skeleton'
|
|
2
|
+
export {
|
|
3
|
+
SkeletonGroup,
|
|
4
|
+
useSkeleton,
|
|
5
|
+
useStaggerIndex,
|
|
6
|
+
type SkeletonGroupProps,
|
|
7
|
+
type SkeletonContextValue,
|
|
8
|
+
} from './SkeletonGroup'
|
|
9
|
+
export { useReducedMotion } from './useReducedMotion'
|
|
10
|
+
export { SHIMMER, SKELETON_TOKEN, SKELETON_FALLBACK, type SkeletonKind } from './shimmer-tokens'
|