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.
Files changed (134) hide show
  1. package/CHANGELOG.md +115 -6
  2. package/lib/commonjs/components/AccountCard/AccountCard.js +247 -0
  3. package/lib/commonjs/components/ActionFooter/ActionFooter.js +147 -82
  4. package/lib/commonjs/components/AppBar/AppBar.js +17 -11
  5. package/lib/commonjs/components/Avatar/Avatar.js +20 -0
  6. package/lib/commonjs/components/Badge/Badge.js +23 -0
  7. package/lib/commonjs/components/Button/Button.js +37 -0
  8. package/lib/commonjs/components/CardBankAccount/CardBankAccount.js +18 -2
  9. package/lib/commonjs/components/CheckboxItem/CheckboxItem.js +40 -25
  10. package/lib/commonjs/components/Dropdown/Dropdown.js +214 -0
  11. package/lib/commonjs/components/DropdownInput/DropdownInput.js +542 -0
  12. package/lib/commonjs/components/FormField/FormField.js +328 -178
  13. package/lib/commonjs/components/IconButton/IconButton.js +20 -0
  14. package/lib/commonjs/components/Image/Image.js +26 -1
  15. package/lib/commonjs/components/LottieIntroBlock/LottieIntroBlock.js +150 -0
  16. package/lib/commonjs/components/LottiePlayer/LottiePlayer.js +116 -0
  17. package/lib/commonjs/components/LottiePlayer/LottiePlayer.web.js +82 -0
  18. package/lib/commonjs/components/LottiePlayer/loadNativeLottieView.js +74 -0
  19. package/lib/commonjs/components/LottiePlayer/loadWebLottieView.js +50 -0
  20. package/lib/commonjs/components/PageHero/PageHero.js +189 -0
  21. package/lib/commonjs/components/PoweredByLabel/PoweredByLabel.js +135 -0
  22. package/lib/commonjs/components/PoweredByLabel/finvu.png +0 -0
  23. package/lib/commonjs/components/RechargeCard/RechargeCard.js +32 -17
  24. package/lib/commonjs/components/Text/Text.js +40 -3
  25. package/lib/commonjs/components/Tooltip/Tooltip.js +34 -27
  26. package/lib/commonjs/components/index.js +67 -0
  27. package/lib/commonjs/design-tokens/Coin Variables-variables-full.json +1 -1
  28. package/lib/commonjs/icons/Icon.js +16 -0
  29. package/lib/commonjs/icons/registry.js +1 -1
  30. package/lib/commonjs/index.js +12 -0
  31. package/lib/commonjs/skeleton/Skeleton.js +234 -0
  32. package/lib/commonjs/skeleton/SkeletonGroup.js +140 -0
  33. package/lib/commonjs/skeleton/index.js +58 -0
  34. package/lib/commonjs/skeleton/shimmer-tokens.js +189 -0
  35. package/lib/commonjs/skeleton/useReducedMotion.js +64 -0
  36. package/lib/module/components/AccountCard/AccountCard.js +241 -0
  37. package/lib/module/components/ActionFooter/ActionFooter.js +146 -82
  38. package/lib/module/components/AppBar/AppBar.js +17 -11
  39. package/lib/module/components/Avatar/Avatar.js +19 -0
  40. package/lib/module/components/Badge/Badge.js +23 -0
  41. package/lib/module/components/Button/Button.js +37 -0
  42. package/lib/module/components/CardBankAccount/CardBankAccount.js +17 -2
  43. package/lib/module/components/CheckboxItem/CheckboxItem.js +41 -26
  44. package/lib/module/components/Dropdown/Dropdown.js +206 -0
  45. package/lib/module/components/DropdownInput/DropdownInput.js +536 -0
  46. package/lib/module/components/FormField/FormField.js +330 -180
  47. package/lib/module/components/IconButton/IconButton.js +20 -0
  48. package/lib/module/components/Image/Image.js +25 -1
  49. package/lib/module/components/LottieIntroBlock/LottieIntroBlock.js +144 -0
  50. package/lib/module/components/LottiePlayer/LottiePlayer.js +111 -0
  51. package/lib/module/components/LottiePlayer/LottiePlayer.web.js +77 -0
  52. package/lib/module/components/LottiePlayer/loadNativeLottieView.js +69 -0
  53. package/lib/module/components/LottiePlayer/loadWebLottieView.js +45 -0
  54. package/lib/module/components/PageHero/PageHero.js +183 -0
  55. package/lib/module/components/PoweredByLabel/PoweredByLabel.js +130 -0
  56. package/lib/module/components/PoweredByLabel/finvu.png +0 -0
  57. package/lib/module/components/RechargeCard/RechargeCard.js +33 -17
  58. package/lib/module/components/Text/Text.js +40 -3
  59. package/lib/module/components/Tooltip/Tooltip.js +34 -27
  60. package/lib/module/components/index.js +8 -1
  61. package/lib/module/design-tokens/Coin Variables-variables-full.json +1 -1
  62. package/lib/module/icons/Icon.js +16 -0
  63. package/lib/module/icons/registry.js +1 -1
  64. package/lib/module/index.js +2 -1
  65. package/lib/module/skeleton/Skeleton.js +229 -0
  66. package/lib/module/skeleton/SkeletonGroup.js +133 -0
  67. package/lib/module/skeleton/index.js +6 -0
  68. package/lib/module/skeleton/shimmer-tokens.js +181 -0
  69. package/lib/module/skeleton/useReducedMotion.js +61 -0
  70. package/lib/typescript/src/components/AccountCard/AccountCard.d.ts +81 -0
  71. package/lib/typescript/src/components/ActionFooter/ActionFooter.d.ts +26 -21
  72. package/lib/typescript/src/components/Avatar/Avatar.d.ts +7 -1
  73. package/lib/typescript/src/components/Badge/Badge.d.ts +7 -1
  74. package/lib/typescript/src/components/Button/Button.d.ts +8 -1
  75. package/lib/typescript/src/components/CardBankAccount/CardBankAccount.d.ts +9 -2
  76. package/lib/typescript/src/components/CheckboxItem/CheckboxItem.d.ts +18 -2
  77. package/lib/typescript/src/components/Dropdown/Dropdown.d.ts +62 -0
  78. package/lib/typescript/src/components/DropdownInput/DropdownInput.d.ts +107 -0
  79. package/lib/typescript/src/components/FormField/FormField.d.ts +76 -19
  80. package/lib/typescript/src/components/IconButton/IconButton.d.ts +7 -1
  81. package/lib/typescript/src/components/Image/Image.d.ts +8 -1
  82. package/lib/typescript/src/components/LottieIntroBlock/LottieIntroBlock.d.ts +58 -0
  83. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.d.ts +85 -0
  84. package/lib/typescript/src/components/LottiePlayer/LottiePlayer.web.d.ts +28 -0
  85. package/lib/typescript/src/components/LottiePlayer/loadNativeLottieView.d.ts +11 -0
  86. package/lib/typescript/src/components/LottiePlayer/loadWebLottieView.d.ts +11 -0
  87. package/lib/typescript/src/components/PageHero/PageHero.d.ts +79 -0
  88. package/lib/typescript/src/components/PoweredByLabel/PoweredByLabel.d.ts +70 -0
  89. package/lib/typescript/src/components/Text/Text.d.ts +31 -2
  90. package/lib/typescript/src/components/Tooltip/Tooltip.d.ts +13 -2
  91. package/lib/typescript/src/components/index.d.ts +8 -1
  92. package/lib/typescript/src/icons/Icon.d.ts +7 -1
  93. package/lib/typescript/src/icons/registry.d.ts +1 -1
  94. package/lib/typescript/src/index.d.ts +1 -0
  95. package/lib/typescript/src/skeleton/Skeleton.d.ts +60 -0
  96. package/lib/typescript/src/skeleton/SkeletonGroup.d.ts +78 -0
  97. package/lib/typescript/src/skeleton/index.d.ts +5 -0
  98. package/lib/typescript/src/skeleton/shimmer-tokens.d.ts +160 -0
  99. package/lib/typescript/src/skeleton/useReducedMotion.d.ts +15 -0
  100. package/package.json +11 -3
  101. package/src/components/AccountCard/AccountCard.tsx +376 -0
  102. package/src/components/ActionFooter/ActionFooter.tsx +152 -86
  103. package/src/components/AppBar/AppBar.tsx +25 -14
  104. package/src/components/Avatar/Avatar.tsx +26 -0
  105. package/src/components/Badge/Badge.tsx +27 -0
  106. package/src/components/Button/Button.tsx +40 -0
  107. package/src/components/CardBankAccount/CardBankAccount.tsx +29 -3
  108. package/src/components/CheckboxItem/CheckboxItem.tsx +65 -30
  109. package/src/components/Dropdown/Dropdown.tsx +331 -0
  110. package/src/components/DropdownInput/DropdownInput.tsx +819 -0
  111. package/src/components/FormField/FormField.tsx +542 -215
  112. package/src/components/IconButton/IconButton.tsx +27 -0
  113. package/src/components/Image/Image.tsx +25 -0
  114. package/src/components/LottieIntroBlock/LottieIntroBlock.tsx +202 -0
  115. package/src/components/LottiePlayer/LottiePlayer.tsx +145 -0
  116. package/src/components/LottiePlayer/LottiePlayer.web.tsx +94 -0
  117. package/src/components/LottiePlayer/loadNativeLottieView.tsx +87 -0
  118. package/src/components/LottiePlayer/loadWebLottieView.tsx +64 -0
  119. package/src/components/PageHero/PageHero.tsx +257 -0
  120. package/src/components/PoweredByLabel/PoweredByLabel.tsx +221 -0
  121. package/src/components/PoweredByLabel/finvu.png +0 -0
  122. package/src/components/RechargeCard/RechargeCard.tsx +32 -24
  123. package/src/components/Text/Text.tsx +78 -3
  124. package/src/components/Tooltip/Tooltip.tsx +50 -25
  125. package/src/components/index.ts +16 -1
  126. package/src/design-tokens/Coin Variables-variables-full.json +1 -1
  127. package/src/icons/Icon.tsx +17 -0
  128. package/src/icons/registry.ts +1 -1
  129. package/src/index.ts +1 -0
  130. package/src/skeleton/Skeleton.tsx +298 -0
  131. package/src/skeleton/SkeletonGroup.tsx +193 -0
  132. package/src/skeleton/index.ts +10 -0
  133. package/src/skeleton/shimmer-tokens.ts +221 -0
  134. package/src/skeleton/useReducedMotion.ts +72 -0
@@ -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) {
@@ -4,7 +4,7 @@
4
4
  * Auto-generated from SVG files in src/icons/
5
5
  * DO NOT EDIT MANUALLY - Run "npm run icons:generate" to regenerate
6
6
  *
7
- * Generated: 2026-05-15T16:18:56.427Z
7
+ * Generated: 2026-05-25T10:18:38.037Z
8
8
  */
9
9
 
10
10
  // Icon name to SVG data mapping
package/src/index.ts CHANGED
@@ -3,3 +3,4 @@ export * as Icons from './icons';
3
3
  export * from './design-tokens';
4
4
  export * from './Containers';
5
5
  export * from './utils';
6
+ export * from './skeleton';
@@ -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'