@rootnative/inertia 0.0.0-alpha.5 → 0.0.0-alpha.6
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 +6 -0
- package/dist/index.d.mts +59 -2
- package/dist/index.d.ts +59 -2
- package/dist/index.js +150 -0
- package/dist/index.mjs +150 -0
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/internal/boxShadow.ts +204 -0
- package/src/values/index.ts +1 -0
- package/src/values/useShadow.ts +76 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,12 @@ All notable changes to `@rootnative/inertia` are documented here. The format fol
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`useShadow` now interpolates CSS `boxShadow`.** The classic `shadow*`/`elevation` keys never reach the web renderer, so a `useShadow` elevation crossfade silently dropped its shadow on web — the platform where hover, its most common driver, matters most (gap surfaced by the RootNative UI Card migration). `ShadowConfig` gains `boxShadow?: string | BoxShadowLayer[]`: pass the CSS string form design systems store elevation tokens in (px lengths only; parsed once on the JS thread, `cubicBezier`-style — malformed tokens throw at setup rather than warning) or structured layers mirroring RN's `BoxShadowValue`. Multi-layer shadows interpolate per layer with CSS-transition padding semantics (shorter side padded with invisible layers; a genuine `inset` mismatch throws); blur clamps at 0 under springy overshoot. Emitted as a `boxShadow` style string — passed through as CSS by react-native-web and rendered natively on RN 0.76+ new architecture; keep `shadow*`/`elevation` alongside it for old-arch native. New `BoxShadowLayer` type exported from the root.
|
|
10
|
+
|
|
11
|
+
## [0.0.0-alpha.5] - 2026-07-21
|
|
12
|
+
|
|
7
13
|
### Changed
|
|
8
14
|
|
|
9
15
|
- **Non-worklet transformers and easings now dev-warn.** `useTransform`'s transformer overload and custom `timing.easing` functions must be worklets (`'worklet'` directive as the first statement). The previous "plain functions are auto-wrapped" promise was unfulfillable: the directive-wrapped fallback closes over the opaque function reference, not the shared values read inside it, so Reanimated cannot extract dependencies (a `useTransform` derived value silently only refreshed on React re-renders — found via a frozen TextField label float in the UI library) and native builds reject the plain function when the closure is serialized to the UI thread. The fallback wrapper remains as a web-only best effort, but both sites now `console.warn` once in dev (suppressed under Jest, where the shared stubs report every function as non-worklet). Docs (`transitions.md`, `layout.md`, `api/hooks.md`) and docstrings corrected to state the real contract.
|
package/dist/index.d.mts
CHANGED
|
@@ -555,11 +555,42 @@ interface UseScrollResult {
|
|
|
555
555
|
*/
|
|
556
556
|
declare function useScroll(): UseScrollResult;
|
|
557
557
|
|
|
558
|
+
/**
|
|
559
|
+
* CSS `box-shadow` parsing + pairing for `useShadow`.
|
|
560
|
+
*
|
|
561
|
+
* Design systems store web elevation tokens as CSS `box-shadow` strings
|
|
562
|
+
* (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`).
|
|
563
|
+
* Like `cubicBezier`, this module makes those tokens directly consumable:
|
|
564
|
+
* strings are parsed once on the JS thread into flat layer records the
|
|
565
|
+
* `useShadow` worklet can interpolate without any frame-time string work.
|
|
566
|
+
*
|
|
567
|
+
* Invalid input **throws** rather than warning — shadow tokens are
|
|
568
|
+
* constructed at theme/module setup, and a malformed token should fail
|
|
569
|
+
* loudly there, not silently render the wrong elevation.
|
|
570
|
+
*/
|
|
571
|
+
/**
|
|
572
|
+
* One layer of a `box-shadow`, structurally mirroring React Native's
|
|
573
|
+
* `BoxShadowValue` (RN 0.76+ `boxShadow` style). Lengths are px numbers.
|
|
574
|
+
*/
|
|
575
|
+
interface BoxShadowLayer {
|
|
576
|
+
offsetX: number;
|
|
577
|
+
offsetY: number;
|
|
578
|
+
/** Must be >= 0, per CSS. @default 0 */
|
|
579
|
+
blurRadius?: number;
|
|
580
|
+
/** @default 0 */
|
|
581
|
+
spreadDistance?: number;
|
|
582
|
+
/** Any color string Reanimated's `interpolateColor` accepts. @default 'black' */
|
|
583
|
+
color?: string;
|
|
584
|
+
/** @default false */
|
|
585
|
+
inset?: boolean;
|
|
586
|
+
}
|
|
587
|
+
|
|
558
588
|
/**
|
|
559
589
|
* Shape accepted on either end of a `useShadow` tween. Every field is
|
|
560
590
|
* optional — only keys present on at least one side participate in the
|
|
561
591
|
* output style. Mirrors the flat shadow keys on `Motion.View`'s `animate`
|
|
562
|
-
* surface, plus the nested `shadowOffset` source
|
|
592
|
+
* surface, plus the nested `shadowOffset` source and the CSS `boxShadow`
|
|
593
|
+
* surface.
|
|
563
594
|
*/
|
|
564
595
|
interface ShadowConfig {
|
|
565
596
|
shadowOpacity?: number;
|
|
@@ -571,6 +602,19 @@ interface ShadowConfig {
|
|
|
571
602
|
/** Android elevation. iOS shadow consumers can leave this off. */
|
|
572
603
|
elevation?: number;
|
|
573
604
|
shadowColor?: string;
|
|
605
|
+
/**
|
|
606
|
+
* CSS `box-shadow` — the shadow surface on web (react-native-web passes
|
|
607
|
+
* it through as CSS) and on React Native 0.76+ new-architecture native.
|
|
608
|
+
* Accepts the CSS string form design systems store elevation tokens in
|
|
609
|
+
* (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`;
|
|
610
|
+
* px lengths only) or structured layers. Multi-layer shadows interpolate
|
|
611
|
+
* per layer; when one side has fewer layers, it is padded with invisible
|
|
612
|
+
* layers, CSS-transition style. A malformed string **throws** at render
|
|
613
|
+
* (like `cubicBezier` — token mistakes should fail loudly at setup).
|
|
614
|
+
* The classic `shadow*`/`elevation` keys don't reach the web renderer —
|
|
615
|
+
* provide `boxShadow` alongside them when the tween must show up there.
|
|
616
|
+
*/
|
|
617
|
+
boxShadow?: string | readonly BoxShadowLayer[];
|
|
574
618
|
}
|
|
575
619
|
interface UseShadowOptions {
|
|
576
620
|
/** Shadow state at `progress === 0`. */
|
|
@@ -607,6 +651,19 @@ interface UseShadowOptions {
|
|
|
607
651
|
* `shadowColor`, `{ width: 0, height: 0 }` for `shadowOffset`). This is a
|
|
608
652
|
* pure interpolator — to "animate" the shadow, drive `progress` with a
|
|
609
653
|
* spring, timing, or gesture upstream.
|
|
654
|
+
*
|
|
655
|
+
* The classic `shadow*`/`elevation` keys don't render on web. When the
|
|
656
|
+
* tween must show up there (or on RN 0.76+ new-arch native via the CSS
|
|
657
|
+
* shadow model), provide `boxShadow` on both ends — CSS string tokens or
|
|
658
|
+
* structured layers; multi-layer shadows interpolate per layer:
|
|
659
|
+
*
|
|
660
|
+
* ```tsx
|
|
661
|
+
* const shadowStyle = useShadow({
|
|
662
|
+
* from: { boxShadow: theme.elevation.level1 }, // '0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'
|
|
663
|
+
* to: { boxShadow: theme.elevation.level2 },
|
|
664
|
+
* progress,
|
|
665
|
+
* })
|
|
666
|
+
* ```
|
|
610
667
|
*/
|
|
611
668
|
declare function useShadow({ from, to, progress, }: UseShadowOptions): ReturnType<typeof useAnimatedStyle>;
|
|
612
669
|
|
|
@@ -623,4 +680,4 @@ declare function useShadow({ from, to, progress, }: UseShadowOptions): ReturnTyp
|
|
|
623
680
|
*/
|
|
624
681
|
declare function useVariants<V extends Readonly<Record<string, object>>>(variants: V, initial?: keyof V & string): VariantController<keyof V & string>;
|
|
625
682
|
|
|
626
|
-
export { AnimatableValue, type ColorStyleKey, EasingInput, type ExtrapolationMode, Motion, MotionComponent, MotionConfig, type MotionConfigProps, type MotionConfigValue, NamedTransitions, Presence, type PresenceContextValue, type ReducedMotion, type ShadowConfig, SpringTransition, TransitionConfig, TransitionInput, TransitionName, type UseColorTransitionOptions, type UseScrollResult, type UseShadowOptions, type UseTransformOptions, VariantController, buildReleaseAnimation, createMotionComponent, cubicBezier, ensureWorkletEasing, resolveAnimatableValue, resolveNamedTransition, resolveTransition, useAnimation, useBooleanSpring, useColorTransition, useMotionConfig, useMotionValue, useNamedTransitions, usePresence, useScroll, useShadow, useShouldReduceMotion, useSpring, useTransform, useVariants };
|
|
683
|
+
export { AnimatableValue, type BoxShadowLayer, type ColorStyleKey, EasingInput, type ExtrapolationMode, Motion, MotionComponent, MotionConfig, type MotionConfigProps, type MotionConfigValue, NamedTransitions, Presence, type PresenceContextValue, type ReducedMotion, type ShadowConfig, SpringTransition, TransitionConfig, TransitionInput, TransitionName, type UseColorTransitionOptions, type UseScrollResult, type UseShadowOptions, type UseTransformOptions, VariantController, buildReleaseAnimation, createMotionComponent, cubicBezier, ensureWorkletEasing, resolveAnimatableValue, resolveNamedTransition, resolveTransition, useAnimation, useBooleanSpring, useColorTransition, useMotionConfig, useMotionValue, useNamedTransitions, usePresence, useScroll, useShadow, useShouldReduceMotion, useSpring, useTransform, useVariants };
|
package/dist/index.d.ts
CHANGED
|
@@ -555,11 +555,42 @@ interface UseScrollResult {
|
|
|
555
555
|
*/
|
|
556
556
|
declare function useScroll(): UseScrollResult;
|
|
557
557
|
|
|
558
|
+
/**
|
|
559
|
+
* CSS `box-shadow` parsing + pairing for `useShadow`.
|
|
560
|
+
*
|
|
561
|
+
* Design systems store web elevation tokens as CSS `box-shadow` strings
|
|
562
|
+
* (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`).
|
|
563
|
+
* Like `cubicBezier`, this module makes those tokens directly consumable:
|
|
564
|
+
* strings are parsed once on the JS thread into flat layer records the
|
|
565
|
+
* `useShadow` worklet can interpolate without any frame-time string work.
|
|
566
|
+
*
|
|
567
|
+
* Invalid input **throws** rather than warning — shadow tokens are
|
|
568
|
+
* constructed at theme/module setup, and a malformed token should fail
|
|
569
|
+
* loudly there, not silently render the wrong elevation.
|
|
570
|
+
*/
|
|
571
|
+
/**
|
|
572
|
+
* One layer of a `box-shadow`, structurally mirroring React Native's
|
|
573
|
+
* `BoxShadowValue` (RN 0.76+ `boxShadow` style). Lengths are px numbers.
|
|
574
|
+
*/
|
|
575
|
+
interface BoxShadowLayer {
|
|
576
|
+
offsetX: number;
|
|
577
|
+
offsetY: number;
|
|
578
|
+
/** Must be >= 0, per CSS. @default 0 */
|
|
579
|
+
blurRadius?: number;
|
|
580
|
+
/** @default 0 */
|
|
581
|
+
spreadDistance?: number;
|
|
582
|
+
/** Any color string Reanimated's `interpolateColor` accepts. @default 'black' */
|
|
583
|
+
color?: string;
|
|
584
|
+
/** @default false */
|
|
585
|
+
inset?: boolean;
|
|
586
|
+
}
|
|
587
|
+
|
|
558
588
|
/**
|
|
559
589
|
* Shape accepted on either end of a `useShadow` tween. Every field is
|
|
560
590
|
* optional — only keys present on at least one side participate in the
|
|
561
591
|
* output style. Mirrors the flat shadow keys on `Motion.View`'s `animate`
|
|
562
|
-
* surface, plus the nested `shadowOffset` source
|
|
592
|
+
* surface, plus the nested `shadowOffset` source and the CSS `boxShadow`
|
|
593
|
+
* surface.
|
|
563
594
|
*/
|
|
564
595
|
interface ShadowConfig {
|
|
565
596
|
shadowOpacity?: number;
|
|
@@ -571,6 +602,19 @@ interface ShadowConfig {
|
|
|
571
602
|
/** Android elevation. iOS shadow consumers can leave this off. */
|
|
572
603
|
elevation?: number;
|
|
573
604
|
shadowColor?: string;
|
|
605
|
+
/**
|
|
606
|
+
* CSS `box-shadow` — the shadow surface on web (react-native-web passes
|
|
607
|
+
* it through as CSS) and on React Native 0.76+ new-architecture native.
|
|
608
|
+
* Accepts the CSS string form design systems store elevation tokens in
|
|
609
|
+
* (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`;
|
|
610
|
+
* px lengths only) or structured layers. Multi-layer shadows interpolate
|
|
611
|
+
* per layer; when one side has fewer layers, it is padded with invisible
|
|
612
|
+
* layers, CSS-transition style. A malformed string **throws** at render
|
|
613
|
+
* (like `cubicBezier` — token mistakes should fail loudly at setup).
|
|
614
|
+
* The classic `shadow*`/`elevation` keys don't reach the web renderer —
|
|
615
|
+
* provide `boxShadow` alongside them when the tween must show up there.
|
|
616
|
+
*/
|
|
617
|
+
boxShadow?: string | readonly BoxShadowLayer[];
|
|
574
618
|
}
|
|
575
619
|
interface UseShadowOptions {
|
|
576
620
|
/** Shadow state at `progress === 0`. */
|
|
@@ -607,6 +651,19 @@ interface UseShadowOptions {
|
|
|
607
651
|
* `shadowColor`, `{ width: 0, height: 0 }` for `shadowOffset`). This is a
|
|
608
652
|
* pure interpolator — to "animate" the shadow, drive `progress` with a
|
|
609
653
|
* spring, timing, or gesture upstream.
|
|
654
|
+
*
|
|
655
|
+
* The classic `shadow*`/`elevation` keys don't render on web. When the
|
|
656
|
+
* tween must show up there (or on RN 0.76+ new-arch native via the CSS
|
|
657
|
+
* shadow model), provide `boxShadow` on both ends — CSS string tokens or
|
|
658
|
+
* structured layers; multi-layer shadows interpolate per layer:
|
|
659
|
+
*
|
|
660
|
+
* ```tsx
|
|
661
|
+
* const shadowStyle = useShadow({
|
|
662
|
+
* from: { boxShadow: theme.elevation.level1 }, // '0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'
|
|
663
|
+
* to: { boxShadow: theme.elevation.level2 },
|
|
664
|
+
* progress,
|
|
665
|
+
* })
|
|
666
|
+
* ```
|
|
610
667
|
*/
|
|
611
668
|
declare function useShadow({ from, to, progress, }: UseShadowOptions): ReturnType<typeof useAnimatedStyle>;
|
|
612
669
|
|
|
@@ -623,4 +680,4 @@ declare function useShadow({ from, to, progress, }: UseShadowOptions): ReturnTyp
|
|
|
623
680
|
*/
|
|
624
681
|
declare function useVariants<V extends Readonly<Record<string, object>>>(variants: V, initial?: keyof V & string): VariantController<keyof V & string>;
|
|
625
682
|
|
|
626
|
-
export { AnimatableValue, type ColorStyleKey, EasingInput, type ExtrapolationMode, Motion, MotionComponent, MotionConfig, type MotionConfigProps, type MotionConfigValue, NamedTransitions, Presence, type PresenceContextValue, type ReducedMotion, type ShadowConfig, SpringTransition, TransitionConfig, TransitionInput, TransitionName, type UseColorTransitionOptions, type UseScrollResult, type UseShadowOptions, type UseTransformOptions, VariantController, buildReleaseAnimation, createMotionComponent, cubicBezier, ensureWorkletEasing, resolveAnimatableValue, resolveNamedTransition, resolveTransition, useAnimation, useBooleanSpring, useColorTransition, useMotionConfig, useMotionValue, useNamedTransitions, usePresence, useScroll, useShadow, useShouldReduceMotion, useSpring, useTransform, useVariants };
|
|
683
|
+
export { AnimatableValue, type BoxShadowLayer, type ColorStyleKey, EasingInput, type ExtrapolationMode, Motion, MotionComponent, MotionConfig, type MotionConfigProps, type MotionConfigValue, NamedTransitions, Presence, type PresenceContextValue, type ReducedMotion, type ShadowConfig, SpringTransition, TransitionConfig, TransitionInput, TransitionName, type UseColorTransitionOptions, type UseScrollResult, type UseShadowOptions, type UseTransformOptions, VariantController, buildReleaseAnimation, createMotionComponent, cubicBezier, ensureWorkletEasing, resolveAnimatableValue, resolveNamedTransition, resolveTransition, useAnimation, useBooleanSpring, useColorTransition, useMotionConfig, useMotionValue, useNamedTransitions, usePresence, useScroll, useShadow, useShouldReduceMotion, useSpring, useTransform, useVariants };
|
package/dist/index.js
CHANGED
|
@@ -166,6 +166,127 @@ function useScroll() {
|
|
|
166
166
|
onScroll: handler
|
|
167
167
|
};
|
|
168
168
|
}
|
|
169
|
+
|
|
170
|
+
// src/internal/boxShadow.ts
|
|
171
|
+
var LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px)?$/i;
|
|
172
|
+
var UNIT_LIKE = /^[+-]?(\d+\.?\d*|\.\d+)[a-z%]+$/i;
|
|
173
|
+
function invalid(input, reason) {
|
|
174
|
+
return new Error(
|
|
175
|
+
`[inertia] parseBoxShadow: ${reason} in ${JSON.stringify(input)}. Expected CSS box-shadow syntax with px lengths: '[inset] <offset-x> <offset-y> [blur] [spread] [color], ...'`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
function splitLayers(input) {
|
|
179
|
+
const layers = [];
|
|
180
|
+
let depth = 0;
|
|
181
|
+
let start = 0;
|
|
182
|
+
for (let i = 0; i < input.length; i++) {
|
|
183
|
+
const ch = input[i];
|
|
184
|
+
if (ch === "(") depth++;
|
|
185
|
+
else if (ch === ")") depth--;
|
|
186
|
+
else if (ch === "," && depth === 0) {
|
|
187
|
+
layers.push(input.slice(start, i));
|
|
188
|
+
start = i + 1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
layers.push(input.slice(start));
|
|
192
|
+
return layers;
|
|
193
|
+
}
|
|
194
|
+
function tokenize(layer) {
|
|
195
|
+
const tokens = [];
|
|
196
|
+
let depth = 0;
|
|
197
|
+
let current = "";
|
|
198
|
+
for (const ch of layer) {
|
|
199
|
+
if (ch === "(") depth++;
|
|
200
|
+
else if (ch === ")") depth--;
|
|
201
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
202
|
+
if (current) tokens.push(current);
|
|
203
|
+
current = "";
|
|
204
|
+
} else {
|
|
205
|
+
current += ch;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (current) tokens.push(current);
|
|
209
|
+
return tokens;
|
|
210
|
+
}
|
|
211
|
+
function parseBoxShadow(input) {
|
|
212
|
+
const trimmed = input.trim();
|
|
213
|
+
if (trimmed === "") throw invalid(input, "empty string");
|
|
214
|
+
if (trimmed.toLowerCase() === "none") return [];
|
|
215
|
+
return splitLayers(trimmed).map((layerString) => {
|
|
216
|
+
const tokens = tokenize(layerString.trim());
|
|
217
|
+
if (tokens.length === 0) throw invalid(input, "empty layer");
|
|
218
|
+
const lengths = [];
|
|
219
|
+
let color;
|
|
220
|
+
let inset = false;
|
|
221
|
+
for (const token of tokens) {
|
|
222
|
+
if (token.toLowerCase() === "inset") {
|
|
223
|
+
if (inset) throw invalid(input, "duplicate 'inset'");
|
|
224
|
+
inset = true;
|
|
225
|
+
} else if (LENGTH.test(token)) {
|
|
226
|
+
lengths.push(parseFloat(token));
|
|
227
|
+
} else if (UNIT_LIKE.test(token)) {
|
|
228
|
+
throw invalid(input, `unsupported unit in ${JSON.stringify(token)}`);
|
|
229
|
+
} else {
|
|
230
|
+
if (color !== void 0) throw invalid(input, "multiple colors");
|
|
231
|
+
color = token;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (lengths.length < 2 || lengths.length > 4) {
|
|
235
|
+
throw invalid(input, `expected 2-4 lengths, got ${lengths.length}`);
|
|
236
|
+
}
|
|
237
|
+
const [offsetX = 0, offsetY = 0, blurRadius = 0, spreadDistance = 0] = lengths;
|
|
238
|
+
if (blurRadius < 0) throw invalid(input, "negative blur radius");
|
|
239
|
+
return {
|
|
240
|
+
offsetX,
|
|
241
|
+
offsetY,
|
|
242
|
+
blurRadius,
|
|
243
|
+
spreadDistance,
|
|
244
|
+
color: color ?? "black",
|
|
245
|
+
inset
|
|
246
|
+
};
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function resolveBoxShadowInput(input) {
|
|
250
|
+
if (input === void 0) return [];
|
|
251
|
+
if (typeof input === "string") return parseBoxShadow(input);
|
|
252
|
+
return input.map((layer) => ({
|
|
253
|
+
offsetX: layer.offsetX,
|
|
254
|
+
offsetY: layer.offsetY,
|
|
255
|
+
blurRadius: layer.blurRadius ?? 0,
|
|
256
|
+
spreadDistance: layer.spreadDistance ?? 0,
|
|
257
|
+
color: layer.color ?? "black",
|
|
258
|
+
inset: layer.inset ?? false
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
function pairBoxShadowLayers(from, to) {
|
|
262
|
+
const count = Math.max(from.length, to.length);
|
|
263
|
+
const pairs = [];
|
|
264
|
+
for (let i = 0; i < count; i++) {
|
|
265
|
+
const a = from[i];
|
|
266
|
+
const b = to[i];
|
|
267
|
+
const fromLayer = a ?? invisibleLayer(b.inset);
|
|
268
|
+
const toLayer = b ?? invisibleLayer(a.inset);
|
|
269
|
+
if (fromLayer.inset !== toLayer.inset) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`[inertia] useShadow: boxShadow layer ${i} is 'inset' on one side but not the other \u2014 inset cannot be interpolated. Give both sides the same inset-ness (pad with a transparent layer if needed).`
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
pairs.push({ from: fromLayer, to: toLayer });
|
|
275
|
+
}
|
|
276
|
+
return pairs;
|
|
277
|
+
}
|
|
278
|
+
function invisibleLayer(inset) {
|
|
279
|
+
return {
|
|
280
|
+
offsetX: 0,
|
|
281
|
+
offsetY: 0,
|
|
282
|
+
blurRadius: 0,
|
|
283
|
+
spreadDistance: 0,
|
|
284
|
+
color: "transparent",
|
|
285
|
+
inset
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/values/useShadow.ts
|
|
169
290
|
function useShadow({
|
|
170
291
|
from,
|
|
171
292
|
to,
|
|
@@ -176,6 +297,10 @@ function useShadow({
|
|
|
176
297
|
const hasElevation = from.elevation !== void 0 || to.elevation !== void 0;
|
|
177
298
|
const hasColor = from.shadowColor !== void 0 || to.shadowColor !== void 0;
|
|
178
299
|
const hasOffset = from.shadowOffset !== void 0 || to.shadowOffset !== void 0;
|
|
300
|
+
const boxShadowPairs = from.boxShadow !== void 0 || to.boxShadow !== void 0 ? pairBoxShadowLayers(
|
|
301
|
+
resolveBoxShadowInput(from.boxShadow),
|
|
302
|
+
resolveBoxShadowInput(to.boxShadow)
|
|
303
|
+
) : [];
|
|
179
304
|
const opacityFrom = from.shadowOpacity ?? 0;
|
|
180
305
|
const opacityTo = to.shadowOpacity ?? 0;
|
|
181
306
|
const radiusFrom = from.shadowRadius ?? 0;
|
|
@@ -210,6 +335,31 @@ function useShadow({
|
|
|
210
335
|
height: reactNativeReanimated.interpolate(t, [0, 1], [offsetHFrom, offsetHTo])
|
|
211
336
|
};
|
|
212
337
|
}
|
|
338
|
+
if (boxShadowPairs.length > 0) {
|
|
339
|
+
let css = "";
|
|
340
|
+
let first = true;
|
|
341
|
+
for (const pair of boxShadowPairs) {
|
|
342
|
+
const x = reactNativeReanimated.interpolate(t, [0, 1], [pair.from.offsetX, pair.to.offsetX]);
|
|
343
|
+
const y = reactNativeReanimated.interpolate(t, [0, 1], [pair.from.offsetY, pair.to.offsetY]);
|
|
344
|
+
const blur = Math.max(
|
|
345
|
+
0,
|
|
346
|
+
reactNativeReanimated.interpolate(t, [0, 1], [pair.from.blurRadius, pair.to.blurRadius])
|
|
347
|
+
);
|
|
348
|
+
const spread = reactNativeReanimated.interpolate(
|
|
349
|
+
t,
|
|
350
|
+
[0, 1],
|
|
351
|
+
[pair.from.spreadDistance, pair.to.spreadDistance]
|
|
352
|
+
);
|
|
353
|
+
const color = reactNativeReanimated.interpolateColor(
|
|
354
|
+
t,
|
|
355
|
+
[0, 1],
|
|
356
|
+
[pair.from.color, pair.to.color]
|
|
357
|
+
);
|
|
358
|
+
css += (first ? "" : ", ") + (pair.from.inset ? "inset " : "") + `${x}px ${y}px ${blur}px ${spread}px ${color}`;
|
|
359
|
+
first = false;
|
|
360
|
+
}
|
|
361
|
+
out.boxShadow = css;
|
|
362
|
+
}
|
|
213
363
|
return out;
|
|
214
364
|
});
|
|
215
365
|
}
|
package/dist/index.mjs
CHANGED
|
@@ -171,6 +171,127 @@ function useScroll() {
|
|
|
171
171
|
onScroll: handler
|
|
172
172
|
};
|
|
173
173
|
}
|
|
174
|
+
|
|
175
|
+
// src/internal/boxShadow.ts
|
|
176
|
+
var LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px)?$/i;
|
|
177
|
+
var UNIT_LIKE = /^[+-]?(\d+\.?\d*|\.\d+)[a-z%]+$/i;
|
|
178
|
+
function invalid(input, reason) {
|
|
179
|
+
return new Error(
|
|
180
|
+
`[inertia] parseBoxShadow: ${reason} in ${JSON.stringify(input)}. Expected CSS box-shadow syntax with px lengths: '[inset] <offset-x> <offset-y> [blur] [spread] [color], ...'`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
function splitLayers(input) {
|
|
184
|
+
const layers = [];
|
|
185
|
+
let depth = 0;
|
|
186
|
+
let start = 0;
|
|
187
|
+
for (let i = 0; i < input.length; i++) {
|
|
188
|
+
const ch = input[i];
|
|
189
|
+
if (ch === "(") depth++;
|
|
190
|
+
else if (ch === ")") depth--;
|
|
191
|
+
else if (ch === "," && depth === 0) {
|
|
192
|
+
layers.push(input.slice(start, i));
|
|
193
|
+
start = i + 1;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
layers.push(input.slice(start));
|
|
197
|
+
return layers;
|
|
198
|
+
}
|
|
199
|
+
function tokenize(layer) {
|
|
200
|
+
const tokens = [];
|
|
201
|
+
let depth = 0;
|
|
202
|
+
let current = "";
|
|
203
|
+
for (const ch of layer) {
|
|
204
|
+
if (ch === "(") depth++;
|
|
205
|
+
else if (ch === ")") depth--;
|
|
206
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
207
|
+
if (current) tokens.push(current);
|
|
208
|
+
current = "";
|
|
209
|
+
} else {
|
|
210
|
+
current += ch;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (current) tokens.push(current);
|
|
214
|
+
return tokens;
|
|
215
|
+
}
|
|
216
|
+
function parseBoxShadow(input) {
|
|
217
|
+
const trimmed = input.trim();
|
|
218
|
+
if (trimmed === "") throw invalid(input, "empty string");
|
|
219
|
+
if (trimmed.toLowerCase() === "none") return [];
|
|
220
|
+
return splitLayers(trimmed).map((layerString) => {
|
|
221
|
+
const tokens = tokenize(layerString.trim());
|
|
222
|
+
if (tokens.length === 0) throw invalid(input, "empty layer");
|
|
223
|
+
const lengths = [];
|
|
224
|
+
let color;
|
|
225
|
+
let inset = false;
|
|
226
|
+
for (const token of tokens) {
|
|
227
|
+
if (token.toLowerCase() === "inset") {
|
|
228
|
+
if (inset) throw invalid(input, "duplicate 'inset'");
|
|
229
|
+
inset = true;
|
|
230
|
+
} else if (LENGTH.test(token)) {
|
|
231
|
+
lengths.push(parseFloat(token));
|
|
232
|
+
} else if (UNIT_LIKE.test(token)) {
|
|
233
|
+
throw invalid(input, `unsupported unit in ${JSON.stringify(token)}`);
|
|
234
|
+
} else {
|
|
235
|
+
if (color !== void 0) throw invalid(input, "multiple colors");
|
|
236
|
+
color = token;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (lengths.length < 2 || lengths.length > 4) {
|
|
240
|
+
throw invalid(input, `expected 2-4 lengths, got ${lengths.length}`);
|
|
241
|
+
}
|
|
242
|
+
const [offsetX = 0, offsetY = 0, blurRadius = 0, spreadDistance = 0] = lengths;
|
|
243
|
+
if (blurRadius < 0) throw invalid(input, "negative blur radius");
|
|
244
|
+
return {
|
|
245
|
+
offsetX,
|
|
246
|
+
offsetY,
|
|
247
|
+
blurRadius,
|
|
248
|
+
spreadDistance,
|
|
249
|
+
color: color ?? "black",
|
|
250
|
+
inset
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
function resolveBoxShadowInput(input) {
|
|
255
|
+
if (input === void 0) return [];
|
|
256
|
+
if (typeof input === "string") return parseBoxShadow(input);
|
|
257
|
+
return input.map((layer) => ({
|
|
258
|
+
offsetX: layer.offsetX,
|
|
259
|
+
offsetY: layer.offsetY,
|
|
260
|
+
blurRadius: layer.blurRadius ?? 0,
|
|
261
|
+
spreadDistance: layer.spreadDistance ?? 0,
|
|
262
|
+
color: layer.color ?? "black",
|
|
263
|
+
inset: layer.inset ?? false
|
|
264
|
+
}));
|
|
265
|
+
}
|
|
266
|
+
function pairBoxShadowLayers(from, to) {
|
|
267
|
+
const count = Math.max(from.length, to.length);
|
|
268
|
+
const pairs = [];
|
|
269
|
+
for (let i = 0; i < count; i++) {
|
|
270
|
+
const a = from[i];
|
|
271
|
+
const b = to[i];
|
|
272
|
+
const fromLayer = a ?? invisibleLayer(b.inset);
|
|
273
|
+
const toLayer = b ?? invisibleLayer(a.inset);
|
|
274
|
+
if (fromLayer.inset !== toLayer.inset) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`[inertia] useShadow: boxShadow layer ${i} is 'inset' on one side but not the other \u2014 inset cannot be interpolated. Give both sides the same inset-ness (pad with a transparent layer if needed).`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
pairs.push({ from: fromLayer, to: toLayer });
|
|
280
|
+
}
|
|
281
|
+
return pairs;
|
|
282
|
+
}
|
|
283
|
+
function invisibleLayer(inset) {
|
|
284
|
+
return {
|
|
285
|
+
offsetX: 0,
|
|
286
|
+
offsetY: 0,
|
|
287
|
+
blurRadius: 0,
|
|
288
|
+
spreadDistance: 0,
|
|
289
|
+
color: "transparent",
|
|
290
|
+
inset
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/values/useShadow.ts
|
|
174
295
|
function useShadow({
|
|
175
296
|
from,
|
|
176
297
|
to,
|
|
@@ -181,6 +302,10 @@ function useShadow({
|
|
|
181
302
|
const hasElevation = from.elevation !== void 0 || to.elevation !== void 0;
|
|
182
303
|
const hasColor = from.shadowColor !== void 0 || to.shadowColor !== void 0;
|
|
183
304
|
const hasOffset = from.shadowOffset !== void 0 || to.shadowOffset !== void 0;
|
|
305
|
+
const boxShadowPairs = from.boxShadow !== void 0 || to.boxShadow !== void 0 ? pairBoxShadowLayers(
|
|
306
|
+
resolveBoxShadowInput(from.boxShadow),
|
|
307
|
+
resolveBoxShadowInput(to.boxShadow)
|
|
308
|
+
) : [];
|
|
184
309
|
const opacityFrom = from.shadowOpacity ?? 0;
|
|
185
310
|
const opacityTo = to.shadowOpacity ?? 0;
|
|
186
311
|
const radiusFrom = from.shadowRadius ?? 0;
|
|
@@ -215,6 +340,31 @@ function useShadow({
|
|
|
215
340
|
height: interpolate(t, [0, 1], [offsetHFrom, offsetHTo])
|
|
216
341
|
};
|
|
217
342
|
}
|
|
343
|
+
if (boxShadowPairs.length > 0) {
|
|
344
|
+
let css = "";
|
|
345
|
+
let first = true;
|
|
346
|
+
for (const pair of boxShadowPairs) {
|
|
347
|
+
const x = interpolate(t, [0, 1], [pair.from.offsetX, pair.to.offsetX]);
|
|
348
|
+
const y = interpolate(t, [0, 1], [pair.from.offsetY, pair.to.offsetY]);
|
|
349
|
+
const blur = Math.max(
|
|
350
|
+
0,
|
|
351
|
+
interpolate(t, [0, 1], [pair.from.blurRadius, pair.to.blurRadius])
|
|
352
|
+
);
|
|
353
|
+
const spread = interpolate(
|
|
354
|
+
t,
|
|
355
|
+
[0, 1],
|
|
356
|
+
[pair.from.spreadDistance, pair.to.spreadDistance]
|
|
357
|
+
);
|
|
358
|
+
const color = interpolateColor(
|
|
359
|
+
t,
|
|
360
|
+
[0, 1],
|
|
361
|
+
[pair.from.color, pair.to.color]
|
|
362
|
+
);
|
|
363
|
+
css += (first ? "" : ", ") + (pair.from.inset ? "inset " : "") + `${x}px ${y}px ${blur}px ${spread}px ${color}`;
|
|
364
|
+
first = false;
|
|
365
|
+
}
|
|
366
|
+
out.boxShadow = css;
|
|
367
|
+
}
|
|
218
368
|
return out;
|
|
219
369
|
});
|
|
220
370
|
}
|
package/llms.txt
CHANGED
|
@@ -51,7 +51,7 @@ import { MotionScrollView } from '@rootnative/inertia/scroll-view'
|
|
|
51
51
|
- `useSpring(target, config?)` — spring-only shorthand. Animates a `SharedValue<number>` toward `target` with react-spring vocab (or a spring-typed registered `TransitionName`; non-spring names warn and fall back to the default spring). `target` may be a plain number (effect-driven) or a `SharedValue<number>` (UI-thread reaction); the latter is the gesture-smoothing path.
|
|
52
52
|
- `useBooleanSpring(active, config?)` — sugar over `useSpring` for the recurring "spring 0↔1 progress from a boolean" shape (checkbox checks, accordion expansions, drawer open/closed). Identical mechanics to `useSpring(active ? 1 : 0, config)` — the named form so the call site reads as the boolean it represents.
|
|
53
53
|
- `useTransform(value, inputRange, outputRange, options?)` / `useTransform(transformer)` — interpolate a numeric shared value onto a number or color range, or derive any value from any number of shared values via a worklet. Non-worklet transformers are auto-wrapped.
|
|
54
|
-
- `useShadow({ from, to, progress })` — pure value-layer interpolator between two `ShadowConfig`s (`shadowOpacity` / `shadowRadius` / `shadowOffset` / `elevation` / `shadowColor`) driven by a `SharedValue<number>` (0→1). Returns an animated style fragment to spread onto `style`; only emits keys present on either side, absent sides default to natural zero. The hook does not animate on its own — drive `progress` with a spring, a scroll-derived `useTransform`, or any other shared value source.
|
|
54
|
+
- `useShadow({ from, to, progress })` — pure value-layer interpolator between two `ShadowConfig`s (`shadowOpacity` / `shadowRadius` / `shadowOffset` / `elevation` / `shadowColor`, plus `boxShadow` as a CSS string or `BoxShadowLayer[]` — the shadow surface on web and RN 0.76+ new-arch; multi-layer, CSS-transition padding semantics, malformed strings throw at setup) driven by a `SharedValue<number>` (0→1). Returns an animated style fragment to spread onto `style`; only emits keys present on either side, absent sides default to natural zero. The hook does not animate on its own — drive `progress` with a spring, a scroll-derived `useTransform`, or any other shared value source.
|
|
55
55
|
- `useColorTransition(progress, [from, to], options?)` — pure value-layer interpolator for a single color channel, driven by a `SharedValue<number>` (0→1). Returns an animated style fragment with one color key (default `backgroundColor`; configurable via `options.key` to `color` / `borderColor` / `tintColor` / `shadowColor` / per-side border colors). For raw `SharedValue<string>` output, use `useTransform(progress, [0, 1], [from, to])` instead.
|
|
56
56
|
- `useScroll()` — returns `{ scrollX, scrollY, onScroll }` for use with `Motion.ScrollView`. Scroll events fire on the UI thread.
|
|
57
57
|
- `createMotionComponent<C>(C)` — wrap any component with the same Motion prop surface, inferring style from `C`.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS `box-shadow` parsing + pairing for `useShadow`.
|
|
3
|
+
*
|
|
4
|
+
* Design systems store web elevation tokens as CSS `box-shadow` strings
|
|
5
|
+
* (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`).
|
|
6
|
+
* Like `cubicBezier`, this module makes those tokens directly consumable:
|
|
7
|
+
* strings are parsed once on the JS thread into flat layer records the
|
|
8
|
+
* `useShadow` worklet can interpolate without any frame-time string work.
|
|
9
|
+
*
|
|
10
|
+
* Invalid input **throws** rather than warning — shadow tokens are
|
|
11
|
+
* constructed at theme/module setup, and a malformed token should fail
|
|
12
|
+
* loudly there, not silently render the wrong elevation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One layer of a `box-shadow`, structurally mirroring React Native's
|
|
17
|
+
* `BoxShadowValue` (RN 0.76+ `boxShadow` style). Lengths are px numbers.
|
|
18
|
+
*/
|
|
19
|
+
export interface BoxShadowLayer {
|
|
20
|
+
offsetX: number
|
|
21
|
+
offsetY: number
|
|
22
|
+
/** Must be >= 0, per CSS. @default 0 */
|
|
23
|
+
blurRadius?: number
|
|
24
|
+
/** @default 0 */
|
|
25
|
+
spreadDistance?: number
|
|
26
|
+
/** Any color string Reanimated's `interpolateColor` accepts. @default 'black' */
|
|
27
|
+
color?: string
|
|
28
|
+
/** @default false */
|
|
29
|
+
inset?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A layer with every field resolved to a concrete value. */
|
|
33
|
+
export interface ResolvedBoxShadowLayer {
|
|
34
|
+
offsetX: number
|
|
35
|
+
offsetY: number
|
|
36
|
+
blurRadius: number
|
|
37
|
+
spreadDistance: number
|
|
38
|
+
color: string
|
|
39
|
+
inset: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const LENGTH = /^[+-]?(\d+\.?\d*|\.\d+)(px)?$/i
|
|
43
|
+
const UNIT_LIKE = /^[+-]?(\d+\.?\d*|\.\d+)[a-z%]+$/i
|
|
44
|
+
|
|
45
|
+
function invalid(input: string, reason: string): Error {
|
|
46
|
+
return new Error(
|
|
47
|
+
`[inertia] parseBoxShadow: ${reason} in ${JSON.stringify(input)}. ` +
|
|
48
|
+
'Expected CSS box-shadow syntax with px lengths: ' +
|
|
49
|
+
"'[inset] <offset-x> <offset-y> [blur] [spread] [color], ...'",
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Split a box-shadow string into layer strings on top-level commas —
|
|
55
|
+
* commas inside color functions (`rgba(0, 0, 0, 0.3)`) don't split.
|
|
56
|
+
*/
|
|
57
|
+
function splitLayers(input: string): string[] {
|
|
58
|
+
const layers: string[] = []
|
|
59
|
+
let depth = 0
|
|
60
|
+
let start = 0
|
|
61
|
+
for (let i = 0; i < input.length; i++) {
|
|
62
|
+
const ch = input[i]
|
|
63
|
+
if (ch === '(') depth++
|
|
64
|
+
else if (ch === ')') depth--
|
|
65
|
+
else if (ch === ',' && depth === 0) {
|
|
66
|
+
layers.push(input.slice(start, i))
|
|
67
|
+
start = i + 1
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
layers.push(input.slice(start))
|
|
71
|
+
return layers
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Split one layer into whitespace-separated tokens, keeping color
|
|
76
|
+
* functions (which may contain spaces: `rgb(0 0 0 / 40%)`) as one token.
|
|
77
|
+
*/
|
|
78
|
+
function tokenize(layer: string): string[] {
|
|
79
|
+
const tokens: string[] = []
|
|
80
|
+
let depth = 0
|
|
81
|
+
let current = ''
|
|
82
|
+
for (const ch of layer) {
|
|
83
|
+
if (ch === '(') depth++
|
|
84
|
+
else if (ch === ')') depth--
|
|
85
|
+
if (depth === 0 && /\s/.test(ch)) {
|
|
86
|
+
if (current) tokens.push(current)
|
|
87
|
+
current = ''
|
|
88
|
+
} else {
|
|
89
|
+
current += ch
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (current) tokens.push(current)
|
|
93
|
+
return tokens
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Parse a CSS `box-shadow` string into resolved layers. `'none'` parses to
|
|
98
|
+
* an empty list. Only px (and unitless) lengths are supported — other units
|
|
99
|
+
* depend on font/viewport context that a style value can't resolve.
|
|
100
|
+
*/
|
|
101
|
+
export function parseBoxShadow(input: string): ResolvedBoxShadowLayer[] {
|
|
102
|
+
const trimmed = input.trim()
|
|
103
|
+
if (trimmed === '') throw invalid(input, 'empty string')
|
|
104
|
+
if (trimmed.toLowerCase() === 'none') return []
|
|
105
|
+
|
|
106
|
+
return splitLayers(trimmed).map((layerString) => {
|
|
107
|
+
const tokens = tokenize(layerString.trim())
|
|
108
|
+
if (tokens.length === 0) throw invalid(input, 'empty layer')
|
|
109
|
+
|
|
110
|
+
const lengths: number[] = []
|
|
111
|
+
let color: string | undefined
|
|
112
|
+
let inset = false
|
|
113
|
+
for (const token of tokens) {
|
|
114
|
+
if (token.toLowerCase() === 'inset') {
|
|
115
|
+
if (inset) throw invalid(input, "duplicate 'inset'")
|
|
116
|
+
inset = true
|
|
117
|
+
} else if (LENGTH.test(token)) {
|
|
118
|
+
lengths.push(parseFloat(token))
|
|
119
|
+
} else if (UNIT_LIKE.test(token)) {
|
|
120
|
+
throw invalid(input, `unsupported unit in ${JSON.stringify(token)}`)
|
|
121
|
+
} else {
|
|
122
|
+
if (color !== undefined) throw invalid(input, 'multiple colors')
|
|
123
|
+
color = token
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (lengths.length < 2 || lengths.length > 4) {
|
|
128
|
+
throw invalid(input, `expected 2-4 lengths, got ${lengths.length}`)
|
|
129
|
+
}
|
|
130
|
+
// The `= 0` on the offsets never fires (length >= 2 is validated
|
|
131
|
+
// above); it's here for noUncheckedIndexedAccess.
|
|
132
|
+
const [offsetX = 0, offsetY = 0, blurRadius = 0, spreadDistance = 0] =
|
|
133
|
+
lengths
|
|
134
|
+
if (blurRadius < 0) throw invalid(input, 'negative blur radius')
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
offsetX,
|
|
138
|
+
offsetY,
|
|
139
|
+
blurRadius,
|
|
140
|
+
spreadDistance,
|
|
141
|
+
color: color ?? 'black',
|
|
142
|
+
inset,
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve either input form (CSS string or structured layers) to layers. */
|
|
148
|
+
export function resolveBoxShadowInput(
|
|
149
|
+
input: string | readonly BoxShadowLayer[] | undefined,
|
|
150
|
+
): ResolvedBoxShadowLayer[] {
|
|
151
|
+
if (input === undefined) return []
|
|
152
|
+
if (typeof input === 'string') return parseBoxShadow(input)
|
|
153
|
+
return input.map((layer) => ({
|
|
154
|
+
offsetX: layer.offsetX,
|
|
155
|
+
offsetY: layer.offsetY,
|
|
156
|
+
blurRadius: layer.blurRadius ?? 0,
|
|
157
|
+
spreadDistance: layer.spreadDistance ?? 0,
|
|
158
|
+
color: layer.color ?? 'black',
|
|
159
|
+
inset: layer.inset ?? false,
|
|
160
|
+
}))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Pair up `from`/`to` layer lists for interpolation, CSS-transition style:
|
|
165
|
+
* the shorter list is padded with an invisible layer (all lengths 0,
|
|
166
|
+
* transparent color) matching the counterpart's `inset` flag. A genuine
|
|
167
|
+
* `inset` mismatch between paired layers is not interpolable and throws.
|
|
168
|
+
*/
|
|
169
|
+
export function pairBoxShadowLayers(
|
|
170
|
+
from: ResolvedBoxShadowLayer[],
|
|
171
|
+
to: ResolvedBoxShadowLayer[],
|
|
172
|
+
): Array<{ from: ResolvedBoxShadowLayer; to: ResolvedBoxShadowLayer }> {
|
|
173
|
+
const count = Math.max(from.length, to.length)
|
|
174
|
+
const pairs: Array<{
|
|
175
|
+
from: ResolvedBoxShadowLayer
|
|
176
|
+
to: ResolvedBoxShadowLayer
|
|
177
|
+
}> = []
|
|
178
|
+
for (let i = 0; i < count; i++) {
|
|
179
|
+
const a = from[i]
|
|
180
|
+
const b = to[i]
|
|
181
|
+
const fromLayer = a ?? invisibleLayer(b!.inset)
|
|
182
|
+
const toLayer = b ?? invisibleLayer(a!.inset)
|
|
183
|
+
if (fromLayer.inset !== toLayer.inset) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`[inertia] useShadow: boxShadow layer ${i} is 'inset' on one side ` +
|
|
186
|
+
'but not the other — inset cannot be interpolated. Give both ' +
|
|
187
|
+
'sides the same inset-ness (pad with a transparent layer if needed).',
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
pairs.push({ from: fromLayer, to: toLayer })
|
|
191
|
+
}
|
|
192
|
+
return pairs
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function invisibleLayer(inset: boolean): ResolvedBoxShadowLayer {
|
|
196
|
+
return {
|
|
197
|
+
offsetX: 0,
|
|
198
|
+
offsetY: 0,
|
|
199
|
+
blurRadius: 0,
|
|
200
|
+
spreadDistance: 0,
|
|
201
|
+
color: 'transparent',
|
|
202
|
+
inset,
|
|
203
|
+
}
|
|
204
|
+
}
|
package/src/values/index.ts
CHANGED
package/src/values/useShadow.ts
CHANGED
|
@@ -4,12 +4,20 @@ import {
|
|
|
4
4
|
useAnimatedStyle,
|
|
5
5
|
type SharedValue,
|
|
6
6
|
} from 'react-native-reanimated'
|
|
7
|
+
import {
|
|
8
|
+
pairBoxShadowLayers,
|
|
9
|
+
resolveBoxShadowInput,
|
|
10
|
+
type BoxShadowLayer,
|
|
11
|
+
} from '../internal/boxShadow'
|
|
12
|
+
|
|
13
|
+
export type { BoxShadowLayer }
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* Shape accepted on either end of a `useShadow` tween. Every field is
|
|
10
17
|
* optional — only keys present on at least one side participate in the
|
|
11
18
|
* output style. Mirrors the flat shadow keys on `Motion.View`'s `animate`
|
|
12
|
-
* surface, plus the nested `shadowOffset` source
|
|
19
|
+
* surface, plus the nested `shadowOffset` source and the CSS `boxShadow`
|
|
20
|
+
* surface.
|
|
13
21
|
*/
|
|
14
22
|
export interface ShadowConfig {
|
|
15
23
|
shadowOpacity?: number
|
|
@@ -18,6 +26,19 @@ export interface ShadowConfig {
|
|
|
18
26
|
/** Android elevation. iOS shadow consumers can leave this off. */
|
|
19
27
|
elevation?: number
|
|
20
28
|
shadowColor?: string
|
|
29
|
+
/**
|
|
30
|
+
* CSS `box-shadow` — the shadow surface on web (react-native-web passes
|
|
31
|
+
* it through as CSS) and on React Native 0.76+ new-architecture native.
|
|
32
|
+
* Accepts the CSS string form design systems store elevation tokens in
|
|
33
|
+
* (`'0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'`;
|
|
34
|
+
* px lengths only) or structured layers. Multi-layer shadows interpolate
|
|
35
|
+
* per layer; when one side has fewer layers, it is padded with invisible
|
|
36
|
+
* layers, CSS-transition style. A malformed string **throws** at render
|
|
37
|
+
* (like `cubicBezier` — token mistakes should fail loudly at setup).
|
|
38
|
+
* The classic `shadow*`/`elevation` keys don't reach the web renderer —
|
|
39
|
+
* provide `boxShadow` alongside them when the tween must show up there.
|
|
40
|
+
*/
|
|
41
|
+
boxShadow?: string | readonly BoxShadowLayer[]
|
|
21
42
|
}
|
|
22
43
|
|
|
23
44
|
export interface UseShadowOptions {
|
|
@@ -56,6 +77,19 @@ export interface UseShadowOptions {
|
|
|
56
77
|
* `shadowColor`, `{ width: 0, height: 0 }` for `shadowOffset`). This is a
|
|
57
78
|
* pure interpolator — to "animate" the shadow, drive `progress` with a
|
|
58
79
|
* spring, timing, or gesture upstream.
|
|
80
|
+
*
|
|
81
|
+
* The classic `shadow*`/`elevation` keys don't render on web. When the
|
|
82
|
+
* tween must show up there (or on RN 0.76+ new-arch native via the CSS
|
|
83
|
+
* shadow model), provide `boxShadow` on both ends — CSS string tokens or
|
|
84
|
+
* structured layers; multi-layer shadows interpolate per layer:
|
|
85
|
+
*
|
|
86
|
+
* ```tsx
|
|
87
|
+
* const shadowStyle = useShadow({
|
|
88
|
+
* from: { boxShadow: theme.elevation.level1 }, // '0px 1px 2px rgba(0,0,0,0.3), 0px 1px 3px 1px rgba(0,0,0,0.15)'
|
|
89
|
+
* to: { boxShadow: theme.elevation.level2 },
|
|
90
|
+
* progress,
|
|
91
|
+
* })
|
|
92
|
+
* ```
|
|
59
93
|
*/
|
|
60
94
|
export function useShadow({
|
|
61
95
|
from,
|
|
@@ -76,6 +110,17 @@ export function useShadow({
|
|
|
76
110
|
const hasOffset =
|
|
77
111
|
from.shadowOffset !== undefined || to.shadowOffset !== undefined
|
|
78
112
|
|
|
113
|
+
// boxShadow layers: parse/pair once on the JS thread into flat records so
|
|
114
|
+
// the worklet only interpolates numbers/colors and concatenates — no
|
|
115
|
+
// frame-time parsing. `[]` when neither side provides the key.
|
|
116
|
+
const boxShadowPairs =
|
|
117
|
+
from.boxShadow !== undefined || to.boxShadow !== undefined
|
|
118
|
+
? pairBoxShadowLayers(
|
|
119
|
+
resolveBoxShadowInput(from.boxShadow),
|
|
120
|
+
resolveBoxShadowInput(to.boxShadow),
|
|
121
|
+
)
|
|
122
|
+
: []
|
|
123
|
+
|
|
79
124
|
const opacityFrom = from.shadowOpacity ?? 0
|
|
80
125
|
const opacityTo = to.shadowOpacity ?? 0
|
|
81
126
|
const radiusFrom = from.shadowRadius ?? 0
|
|
@@ -111,6 +156,36 @@ export function useShadow({
|
|
|
111
156
|
height: interpolate(t, [0, 1], [offsetHFrom, offsetHTo]),
|
|
112
157
|
}
|
|
113
158
|
}
|
|
159
|
+
if (boxShadowPairs.length > 0) {
|
|
160
|
+
let css = ''
|
|
161
|
+
let first = true
|
|
162
|
+
for (const pair of boxShadowPairs) {
|
|
163
|
+
const x = interpolate(t, [0, 1], [pair.from.offsetX, pair.to.offsetX])
|
|
164
|
+
const y = interpolate(t, [0, 1], [pair.from.offsetY, pair.to.offsetY])
|
|
165
|
+
// Blur can't go negative (invalid CSS) even if a springy driver
|
|
166
|
+
// overshoots below 0.
|
|
167
|
+
const blur = Math.max(
|
|
168
|
+
0,
|
|
169
|
+
interpolate(t, [0, 1], [pair.from.blurRadius, pair.to.blurRadius]),
|
|
170
|
+
)
|
|
171
|
+
const spread = interpolate(
|
|
172
|
+
t,
|
|
173
|
+
[0, 1],
|
|
174
|
+
[pair.from.spreadDistance, pair.to.spreadDistance],
|
|
175
|
+
)
|
|
176
|
+
const color = interpolateColor(
|
|
177
|
+
t,
|
|
178
|
+
[0, 1],
|
|
179
|
+
[pair.from.color, pair.to.color],
|
|
180
|
+
)
|
|
181
|
+
css +=
|
|
182
|
+
(first ? '' : ', ') +
|
|
183
|
+
(pair.from.inset ? 'inset ' : '') +
|
|
184
|
+
`${x}px ${y}px ${blur}px ${spread}px ${color}`
|
|
185
|
+
first = false
|
|
186
|
+
}
|
|
187
|
+
out.boxShadow = css
|
|
188
|
+
}
|
|
114
189
|
return out
|
|
115
190
|
})
|
|
116
191
|
}
|