@dloizides/ui-motion 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/README.md +12 -0
- package/dist/index.d.mts +102 -4
- package/dist/index.d.ts +102 -4
- package/dist/index.js +209 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +204 -11
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,45 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`computeFlipDeltas(prev, next, { staggerMs, cap })`** — the pure FLIP invert step: for each
|
|
8
|
+
item that moved, the offset it must start from, staggered in new reading order (y, then x).
|
|
9
|
+
New and unmoved items are omitted; past `cap` the rest snap.
|
|
10
|
+
- **`<Reorder itemKeys spring staggerMs? cap? testID?>{(key) => node}</Reorder>`** — plays that
|
|
11
|
+
step on a keyed list when its order changes. Web reads slots synchronously from the DOM and
|
|
12
|
+
inverts in a layout effect (before paint; RN-web `onLayout` misses position-only moves); native
|
|
13
|
+
uses `onLayout`. Mid-flight items start from where they are drawn; moved items past `cap` snap.
|
|
14
|
+
Items get `${testID}-item-${key}`. No animation under reduced motion.
|
|
15
|
+
- **`useFocusLift(focused, spring, { scale?, lift? })`** + pure **`focusLiftTarget`** — a focused
|
|
16
|
+
tile lifts 8px and scales 1.06 on a retargetable spring; stays put under reduced motion.
|
|
17
|
+
|
|
18
|
+
## 1.0.2
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **`Collapse` — a closed region is now HIDDEN, not merely clipped.** `overflow: hidden` +
|
|
23
|
+
`height: 0` clips, but the clipped child keeps its own non-empty box: it stayed in the tab
|
|
24
|
+
order, was still announced by screen readers, and was still reported VISIBLE by Playwright —
|
|
25
|
+
while the owning header said `aria-expanded="false"`. Found by an aml-v2 E2E test that could
|
|
26
|
+
see a collapsed settings panel. A settled-closed region now sets `visibility: hidden` (web),
|
|
27
|
+
`aria-hidden`, `accessibilityElementsHidden`, `importantForAccessibility="no-hide-descendants"`
|
|
28
|
+
and `pointerEvents="none"`.
|
|
29
|
+
|
|
30
|
+
`visibility: hidden` rather than `display: none` deliberately: `display: none` would zero the
|
|
31
|
+
`onLayout` measurement the component animates back to, so reopening would animate 0 -> 0.
|
|
32
|
+
|
|
33
|
+
"Settled closed" is a state distinct from `!open` — it is set only once the collapse has
|
|
34
|
+
actually finished, so the content collapses instead of vanishing, and an interrupted collapse
|
|
35
|
+
(a reopen mid-animation, `finished: false`) never hides. It is also set immediately whenever
|
|
36
|
+
there is nothing to animate: before the first `onLayout` and under reduced motion.
|
|
37
|
+
|
|
38
|
+
- **`collapseSettlesClosed(open, finished)`** exported from `motionUtils` — the settle rule as a
|
|
39
|
+
pure function, because the animation callback it guards is unreachable from a jsdom test
|
|
40
|
+
(`onLayout` needs a `ResizeObserver`).
|
|
41
|
+
|
|
42
|
+
|
|
3
43
|
## 1.0.1
|
|
4
44
|
|
|
5
45
|
Initial release — the shared motion layer of the dloizides.com RN-web UI kit.
|
package/README.md
CHANGED
|
@@ -50,6 +50,12 @@ if (!mounted) return null;
|
|
|
50
50
|
return <Animated.View style={style}>{children}</Animated.View>;
|
|
51
51
|
```
|
|
52
52
|
|
|
53
|
+
### `useFocusLift(focused, spring, { scale?, lift? })`
|
|
54
|
+
|
|
55
|
+
Springs a tile to `scale` (default 1.06) and `-lift` px (default 8) while `focused`, back to rest
|
|
56
|
+
otherwise. A focus change retargets the running spring. Reduced motion: no movement (signal focus
|
|
57
|
+
with a ring/shadow instead). Spread `style` onto an `Animated.View`. Pure target: `focusLiftTarget`.
|
|
58
|
+
|
|
53
59
|
## Components
|
|
54
60
|
|
|
55
61
|
### `<FadeIn duration? delay? translateY? style? testID>`
|
|
@@ -91,6 +97,12 @@ loading signal than the opacity pulse in `ui-feedback`'s PageSkeleton.
|
|
|
91
97
|
<Skeleton width="60%" height={20} borderRadius={6} />
|
|
92
98
|
```
|
|
93
99
|
|
|
100
|
+
### `<Reorder itemKeys spring staggerMs? cap? testID>{(key) => node}</Reorder>`
|
|
101
|
+
|
|
102
|
+
FLIP re-rank for a keyed list: when `itemKeys` changes order, moved items spring from their old
|
|
103
|
+
slot to the new one, staggered (default 18ms), at most `cap` (default 8) animated. Under reduced
|
|
104
|
+
motion items jump. The invert maths is the pure `computeFlipDeltas`.
|
|
105
|
+
|
|
94
106
|
## Reduced motion
|
|
95
107
|
|
|
96
108
|
Every primitive honours the OS "reduce motion" setting: `FadeIn` shows content at rest,
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { prefersReducedMotion, useReducedMotion } from '@dloizides/rn-web-hooks';
|
|
2
|
-
import { EasingFunction, Animated,
|
|
2
|
+
import { EasingFunction, Animated, ViewStyle, StyleProp, PressableProps } from 'react-native';
|
|
3
3
|
import { MotionEasings, MotionDurations } from '@dloizides/design-tokens';
|
|
4
4
|
export { DEFAULT_MOTION, Motion, MotionDurations, MotionEasings } from '@dloizides/design-tokens';
|
|
5
5
|
import React from 'react';
|
|
@@ -69,6 +69,7 @@ declare const MOTION_TEST_IDS: {
|
|
|
69
69
|
readonly collapse: "collapse";
|
|
70
70
|
readonly pressableScale: "pressable-scale";
|
|
71
71
|
readonly skeleton: "skeleton";
|
|
72
|
+
readonly reorder: "reorder";
|
|
72
73
|
};
|
|
73
74
|
/** Default press-in scale for {@link PressableScale}. */
|
|
74
75
|
declare const DEFAULT_PRESSED_SCALE = 0.96;
|
|
@@ -76,6 +77,14 @@ declare const DEFAULT_PRESSED_SCALE = 0.96;
|
|
|
76
77
|
declare const DEFAULT_FADE_TRANSLATE_Y = 8;
|
|
77
78
|
/** Default shimmer sweep duration (ms) for {@link Skeleton}. */
|
|
78
79
|
declare const DEFAULT_SKELETON_DURATION_MS = 1200;
|
|
80
|
+
/** Default focused scale for {@link useFocusLift}. */
|
|
81
|
+
declare const DEFAULT_FOCUS_SCALE = 1.06;
|
|
82
|
+
/** Default focused lift (px, upward) for {@link useFocusLift}. */
|
|
83
|
+
declare const DEFAULT_FOCUS_LIFT_PX = 8;
|
|
84
|
+
/** Default per-item stagger (ms) for {@link Reorder}. */
|
|
85
|
+
declare const DEFAULT_FLIP_STAGGER_MS = 18;
|
|
86
|
+
/** Default maximum number of items {@link Reorder} animates; the rest snap. */
|
|
87
|
+
declare const DEFAULT_FLIP_CAP = 8;
|
|
79
88
|
|
|
80
89
|
interface UseEnterExitOptions {
|
|
81
90
|
/** Whether the element should be visible (entered). */
|
|
@@ -108,6 +117,57 @@ interface UseEnterExitResult {
|
|
|
108
117
|
}
|
|
109
118
|
declare function useEnterExit(options: UseEnterExitOptions): UseEnterExitResult;
|
|
110
119
|
|
|
120
|
+
interface FocusLiftSpring {
|
|
121
|
+
stiffness: number;
|
|
122
|
+
damping: number;
|
|
123
|
+
}
|
|
124
|
+
interface FocusLiftOptions {
|
|
125
|
+
/** Scale while focused. Default 1.06. */
|
|
126
|
+
scale?: number;
|
|
127
|
+
/** Upward lift (px) while focused. Default 8. */
|
|
128
|
+
lift?: number;
|
|
129
|
+
}
|
|
130
|
+
interface FocusLiftTarget {
|
|
131
|
+
scale: number;
|
|
132
|
+
translateY: number;
|
|
133
|
+
}
|
|
134
|
+
interface UseFocusLiftResult {
|
|
135
|
+
/** Animated style to spread onto an `Animated.View`. */
|
|
136
|
+
style: Animated.WithAnimatedObject<ViewStyle>;
|
|
137
|
+
}
|
|
138
|
+
/** The transform a tile should settle at. Pure — the hook only springs toward it. */
|
|
139
|
+
declare function focusLiftTarget(focused: boolean, reduced: boolean, opts: FocusLiftOptions): FocusLiftTarget;
|
|
140
|
+
declare function useFocusLift(focused: boolean, spring: FocusLiftSpring, opts?: FocusLiftOptions): UseFocusLiftResult;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* computeFlipDeltas — the FLIP "invert" step, kept pure so it can be unit-tested
|
|
144
|
+
* without driving RN `Animated` or a layout pass.
|
|
145
|
+
*
|
|
146
|
+
* Given every item's slot BEFORE and AFTER a reorder, it returns, for each item that
|
|
147
|
+
* moved, the offset it must start from so it visually sits in its old slot — the
|
|
148
|
+
* caller then springs that offset to 0 ("play"). Items are staggered in their NEW
|
|
149
|
+
* reading order (row-first: y, then x). New items (no previous slot) and items that
|
|
150
|
+
* did not move are omitted. Past `cap` animated items the rest are omitted too, so
|
|
151
|
+
* they snap straight to their new slot — a long list does not play a long wave.
|
|
152
|
+
*/
|
|
153
|
+
interface LayoutBox {
|
|
154
|
+
x: number;
|
|
155
|
+
y: number;
|
|
156
|
+
}
|
|
157
|
+
interface FlipDelta {
|
|
158
|
+
key: string;
|
|
159
|
+
dx: number;
|
|
160
|
+
dy: number;
|
|
161
|
+
delayMs: number;
|
|
162
|
+
}
|
|
163
|
+
interface FlipOptions {
|
|
164
|
+
/** Delay (ms) added per animated item, in new reading order. */
|
|
165
|
+
staggerMs: number;
|
|
166
|
+
/** Maximum number of items that animate; the rest snap. */
|
|
167
|
+
cap: number;
|
|
168
|
+
}
|
|
169
|
+
declare function computeFlipDeltas(prev: ReadonlyMap<string, LayoutBox>, next: ReadonlyMap<string, LayoutBox>, opts: FlipOptions): FlipDelta[];
|
|
170
|
+
|
|
111
171
|
/**
|
|
112
172
|
* FadeIn — fades its children in (with a small upward translate) once, on mount.
|
|
113
173
|
* The lightweight, mount-only sibling of {@link useEnterExit} (which handles the
|
|
@@ -138,8 +198,18 @@ declare const FadeIn: ({ children, duration, delay, translateY, style, testID, }
|
|
|
138
198
|
* web and native. Height is a layout prop, so the native driver can never be used
|
|
139
199
|
* here (`useNativeDriver: false`).
|
|
140
200
|
*
|
|
141
|
-
* The children stay MOUNTED at all times
|
|
142
|
-
*
|
|
201
|
+
* The children stay MOUNTED at all times, so their state survives open/close and a
|
|
202
|
+
* consumer's `aria-controls` always resolves to a real node. MOUNTED IS NOT EXPOSED,
|
|
203
|
+
* though: once the collapse has settled shut the content is hidden from the
|
|
204
|
+
* accessibility tree, the tab order and hit-testing.
|
|
205
|
+
*
|
|
206
|
+
* THAT DISTINCTION IS A BUG FIX, and this comment used to assert the bug ("screen
|
|
207
|
+
* readers still reach them"). `overflow: hidden` + `height: 0` CLIPS; it does not hide.
|
|
208
|
+
* The clipped child keeps its own non-empty box, so it stayed focusable by Tab,
|
|
209
|
+
* announced by screen readers, and reported VISIBLE by Playwright — while the owning
|
|
210
|
+
* header said `aria-expanded="false"`. Found via an aml-v2 E2E test that could see a
|
|
211
|
+
* collapsed settings panel.
|
|
212
|
+
*
|
|
143
213
|
* Reduced-motion snaps to the target height with no animation.
|
|
144
214
|
*/
|
|
145
215
|
|
|
@@ -203,4 +273,32 @@ interface SkeletonProps {
|
|
|
203
273
|
}
|
|
204
274
|
declare const Skeleton: ({ width, height, borderRadius, duration, backgroundColor, highlightColor, style, testID, }: SkeletonProps) => React.ReactElement;
|
|
205
275
|
|
|
206
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Reorder — a keyed list whose moved items spring from their old slot to the new one
|
|
278
|
+
* when `itemKeys` changes order (FLIP; maths in computeFlipDeltas, play in playFlip).
|
|
279
|
+
* - web: RN-web `onLayout` (ResizeObserver) misses position-only moves, so OLD slots are
|
|
280
|
+
* read from the DOM during the render that changes the order (DOM still holds the last
|
|
281
|
+
* commit) and NEW slots in `useLayoutEffect` — after the DOM mutation, before paint.
|
|
282
|
+
* - native: `onLayout` reports moves; the flip plays one frame after they arrive.
|
|
283
|
+
* Reduced motion: nothing animates (the caller crossfades). Transform only.
|
|
284
|
+
*/
|
|
285
|
+
|
|
286
|
+
interface ReorderProps {
|
|
287
|
+
/** Stable item keys, in display order. */
|
|
288
|
+
itemKeys: readonly string[];
|
|
289
|
+
/** Renders the content for one key. */
|
|
290
|
+
children: (key: string) => React.ReactNode;
|
|
291
|
+
spring: {
|
|
292
|
+
stiffness: number;
|
|
293
|
+
damping: number;
|
|
294
|
+
};
|
|
295
|
+
/** Per-item stagger (ms). Default 18. */
|
|
296
|
+
staggerMs?: number;
|
|
297
|
+
/** Maximum animated items; the rest snap. Default 8. */
|
|
298
|
+
cap?: number;
|
|
299
|
+
/** Container testID; each item gets `${testID}-item-${key}`. */
|
|
300
|
+
testID?: string;
|
|
301
|
+
}
|
|
302
|
+
declare function Reorder({ itemKeys, children, spring, staggerMs, cap, testID, }: ReorderProps): React.ReactElement;
|
|
303
|
+
|
|
304
|
+
export { Collapse, type CollapseProps, DEFAULT_FADE_TRANSLATE_Y, DEFAULT_FLIP_CAP, DEFAULT_FLIP_STAGGER_MS, DEFAULT_FOCUS_LIFT_PX, DEFAULT_FOCUS_SCALE, DEFAULT_PRESSED_SCALE, DEFAULT_SKELETON_DURATION_MS, FadeIn, type FadeInProps, type FlipDelta, type FlipOptions, type FocusLiftOptions, type FocusLiftSpring, type FocusLiftTarget, type LayoutBox, MOTION_TEST_IDS, type MotionEasingName, PressableScale, type PressableScaleProps, Reorder, type ReorderProps, Skeleton, type SkeletonProps, type UseEnterExitOptions, type UseEnterExitResult, type UseFocusLiftResult, collapseTargetHeight, computeFlipDeltas, focusLiftTarget, motionDurations, motionEasingTokens, motionEasings, resolveDuration, resolveScale, shimmerRange, shouldUseNativeDriver, useEnterExit, useFocusLift };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { prefersReducedMotion, useReducedMotion } from '@dloizides/rn-web-hooks';
|
|
2
|
-
import { EasingFunction, Animated,
|
|
2
|
+
import { EasingFunction, Animated, ViewStyle, StyleProp, PressableProps } from 'react-native';
|
|
3
3
|
import { MotionEasings, MotionDurations } from '@dloizides/design-tokens';
|
|
4
4
|
export { DEFAULT_MOTION, Motion, MotionDurations, MotionEasings } from '@dloizides/design-tokens';
|
|
5
5
|
import React from 'react';
|
|
@@ -69,6 +69,7 @@ declare const MOTION_TEST_IDS: {
|
|
|
69
69
|
readonly collapse: "collapse";
|
|
70
70
|
readonly pressableScale: "pressable-scale";
|
|
71
71
|
readonly skeleton: "skeleton";
|
|
72
|
+
readonly reorder: "reorder";
|
|
72
73
|
};
|
|
73
74
|
/** Default press-in scale for {@link PressableScale}. */
|
|
74
75
|
declare const DEFAULT_PRESSED_SCALE = 0.96;
|
|
@@ -76,6 +77,14 @@ declare const DEFAULT_PRESSED_SCALE = 0.96;
|
|
|
76
77
|
declare const DEFAULT_FADE_TRANSLATE_Y = 8;
|
|
77
78
|
/** Default shimmer sweep duration (ms) for {@link Skeleton}. */
|
|
78
79
|
declare const DEFAULT_SKELETON_DURATION_MS = 1200;
|
|
80
|
+
/** Default focused scale for {@link useFocusLift}. */
|
|
81
|
+
declare const DEFAULT_FOCUS_SCALE = 1.06;
|
|
82
|
+
/** Default focused lift (px, upward) for {@link useFocusLift}. */
|
|
83
|
+
declare const DEFAULT_FOCUS_LIFT_PX = 8;
|
|
84
|
+
/** Default per-item stagger (ms) for {@link Reorder}. */
|
|
85
|
+
declare const DEFAULT_FLIP_STAGGER_MS = 18;
|
|
86
|
+
/** Default maximum number of items {@link Reorder} animates; the rest snap. */
|
|
87
|
+
declare const DEFAULT_FLIP_CAP = 8;
|
|
79
88
|
|
|
80
89
|
interface UseEnterExitOptions {
|
|
81
90
|
/** Whether the element should be visible (entered). */
|
|
@@ -108,6 +117,57 @@ interface UseEnterExitResult {
|
|
|
108
117
|
}
|
|
109
118
|
declare function useEnterExit(options: UseEnterExitOptions): UseEnterExitResult;
|
|
110
119
|
|
|
120
|
+
interface FocusLiftSpring {
|
|
121
|
+
stiffness: number;
|
|
122
|
+
damping: number;
|
|
123
|
+
}
|
|
124
|
+
interface FocusLiftOptions {
|
|
125
|
+
/** Scale while focused. Default 1.06. */
|
|
126
|
+
scale?: number;
|
|
127
|
+
/** Upward lift (px) while focused. Default 8. */
|
|
128
|
+
lift?: number;
|
|
129
|
+
}
|
|
130
|
+
interface FocusLiftTarget {
|
|
131
|
+
scale: number;
|
|
132
|
+
translateY: number;
|
|
133
|
+
}
|
|
134
|
+
interface UseFocusLiftResult {
|
|
135
|
+
/** Animated style to spread onto an `Animated.View`. */
|
|
136
|
+
style: Animated.WithAnimatedObject<ViewStyle>;
|
|
137
|
+
}
|
|
138
|
+
/** The transform a tile should settle at. Pure — the hook only springs toward it. */
|
|
139
|
+
declare function focusLiftTarget(focused: boolean, reduced: boolean, opts: FocusLiftOptions): FocusLiftTarget;
|
|
140
|
+
declare function useFocusLift(focused: boolean, spring: FocusLiftSpring, opts?: FocusLiftOptions): UseFocusLiftResult;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* computeFlipDeltas — the FLIP "invert" step, kept pure so it can be unit-tested
|
|
144
|
+
* without driving RN `Animated` or a layout pass.
|
|
145
|
+
*
|
|
146
|
+
* Given every item's slot BEFORE and AFTER a reorder, it returns, for each item that
|
|
147
|
+
* moved, the offset it must start from so it visually sits in its old slot — the
|
|
148
|
+
* caller then springs that offset to 0 ("play"). Items are staggered in their NEW
|
|
149
|
+
* reading order (row-first: y, then x). New items (no previous slot) and items that
|
|
150
|
+
* did not move are omitted. Past `cap` animated items the rest are omitted too, so
|
|
151
|
+
* they snap straight to their new slot — a long list does not play a long wave.
|
|
152
|
+
*/
|
|
153
|
+
interface LayoutBox {
|
|
154
|
+
x: number;
|
|
155
|
+
y: number;
|
|
156
|
+
}
|
|
157
|
+
interface FlipDelta {
|
|
158
|
+
key: string;
|
|
159
|
+
dx: number;
|
|
160
|
+
dy: number;
|
|
161
|
+
delayMs: number;
|
|
162
|
+
}
|
|
163
|
+
interface FlipOptions {
|
|
164
|
+
/** Delay (ms) added per animated item, in new reading order. */
|
|
165
|
+
staggerMs: number;
|
|
166
|
+
/** Maximum number of items that animate; the rest snap. */
|
|
167
|
+
cap: number;
|
|
168
|
+
}
|
|
169
|
+
declare function computeFlipDeltas(prev: ReadonlyMap<string, LayoutBox>, next: ReadonlyMap<string, LayoutBox>, opts: FlipOptions): FlipDelta[];
|
|
170
|
+
|
|
111
171
|
/**
|
|
112
172
|
* FadeIn — fades its children in (with a small upward translate) once, on mount.
|
|
113
173
|
* The lightweight, mount-only sibling of {@link useEnterExit} (which handles the
|
|
@@ -138,8 +198,18 @@ declare const FadeIn: ({ children, duration, delay, translateY, style, testID, }
|
|
|
138
198
|
* web and native. Height is a layout prop, so the native driver can never be used
|
|
139
199
|
* here (`useNativeDriver: false`).
|
|
140
200
|
*
|
|
141
|
-
* The children stay MOUNTED at all times
|
|
142
|
-
*
|
|
201
|
+
* The children stay MOUNTED at all times, so their state survives open/close and a
|
|
202
|
+
* consumer's `aria-controls` always resolves to a real node. MOUNTED IS NOT EXPOSED,
|
|
203
|
+
* though: once the collapse has settled shut the content is hidden from the
|
|
204
|
+
* accessibility tree, the tab order and hit-testing.
|
|
205
|
+
*
|
|
206
|
+
* THAT DISTINCTION IS A BUG FIX, and this comment used to assert the bug ("screen
|
|
207
|
+
* readers still reach them"). `overflow: hidden` + `height: 0` CLIPS; it does not hide.
|
|
208
|
+
* The clipped child keeps its own non-empty box, so it stayed focusable by Tab,
|
|
209
|
+
* announced by screen readers, and reported VISIBLE by Playwright — while the owning
|
|
210
|
+
* header said `aria-expanded="false"`. Found via an aml-v2 E2E test that could see a
|
|
211
|
+
* collapsed settings panel.
|
|
212
|
+
*
|
|
143
213
|
* Reduced-motion snaps to the target height with no animation.
|
|
144
214
|
*/
|
|
145
215
|
|
|
@@ -203,4 +273,32 @@ interface SkeletonProps {
|
|
|
203
273
|
}
|
|
204
274
|
declare const Skeleton: ({ width, height, borderRadius, duration, backgroundColor, highlightColor, style, testID, }: SkeletonProps) => React.ReactElement;
|
|
205
275
|
|
|
206
|
-
|
|
276
|
+
/**
|
|
277
|
+
* Reorder — a keyed list whose moved items spring from their old slot to the new one
|
|
278
|
+
* when `itemKeys` changes order (FLIP; maths in computeFlipDeltas, play in playFlip).
|
|
279
|
+
* - web: RN-web `onLayout` (ResizeObserver) misses position-only moves, so OLD slots are
|
|
280
|
+
* read from the DOM during the render that changes the order (DOM still holds the last
|
|
281
|
+
* commit) and NEW slots in `useLayoutEffect` — after the DOM mutation, before paint.
|
|
282
|
+
* - native: `onLayout` reports moves; the flip plays one frame after they arrive.
|
|
283
|
+
* Reduced motion: nothing animates (the caller crossfades). Transform only.
|
|
284
|
+
*/
|
|
285
|
+
|
|
286
|
+
interface ReorderProps {
|
|
287
|
+
/** Stable item keys, in display order. */
|
|
288
|
+
itemKeys: readonly string[];
|
|
289
|
+
/** Renders the content for one key. */
|
|
290
|
+
children: (key: string) => React.ReactNode;
|
|
291
|
+
spring: {
|
|
292
|
+
stiffness: number;
|
|
293
|
+
damping: number;
|
|
294
|
+
};
|
|
295
|
+
/** Per-item stagger (ms). Default 18. */
|
|
296
|
+
staggerMs?: number;
|
|
297
|
+
/** Maximum animated items; the rest snap. Default 8. */
|
|
298
|
+
cap?: number;
|
|
299
|
+
/** Container testID; each item gets `${testID}-item-${key}`. */
|
|
300
|
+
testID?: string;
|
|
301
|
+
}
|
|
302
|
+
declare function Reorder({ itemKeys, children, spring, staggerMs, cap, testID, }: ReorderProps): React.ReactElement;
|
|
303
|
+
|
|
304
|
+
export { Collapse, type CollapseProps, DEFAULT_FADE_TRANSLATE_Y, DEFAULT_FLIP_CAP, DEFAULT_FLIP_STAGGER_MS, DEFAULT_FOCUS_LIFT_PX, DEFAULT_FOCUS_SCALE, DEFAULT_PRESSED_SCALE, DEFAULT_SKELETON_DURATION_MS, FadeIn, type FadeInProps, type FlipDelta, type FlipOptions, type FocusLiftOptions, type FocusLiftSpring, type FocusLiftTarget, type LayoutBox, MOTION_TEST_IDS, type MotionEasingName, PressableScale, type PressableScaleProps, Reorder, type ReorderProps, Skeleton, type SkeletonProps, type UseEnterExitOptions, type UseEnterExitResult, type UseFocusLiftResult, collapseTargetHeight, computeFlipDeltas, focusLiftTarget, motionDurations, motionEasingTokens, motionEasings, resolveDuration, resolveScale, shimmerRange, shouldUseNativeDriver, useEnterExit, useFocusLift };
|
package/dist/index.js
CHANGED
|
@@ -24,6 +24,9 @@ function collapseTargetHeight(open, measured) {
|
|
|
24
24
|
if (measured === null) return void 0;
|
|
25
25
|
return open ? measured : 0;
|
|
26
26
|
}
|
|
27
|
+
function collapseSettlesClosed(open, finished) {
|
|
28
|
+
return finished && !open;
|
|
29
|
+
}
|
|
27
30
|
function resolveScale(pressed, reduced, pressedScale) {
|
|
28
31
|
if (reduced) return 1;
|
|
29
32
|
return pressed ? pressedScale : 1;
|
|
@@ -37,11 +40,16 @@ var MOTION_TEST_IDS = {
|
|
|
37
40
|
fadeIn: "fade-in",
|
|
38
41
|
collapse: "collapse",
|
|
39
42
|
pressableScale: "pressable-scale",
|
|
40
|
-
skeleton: "skeleton"
|
|
43
|
+
skeleton: "skeleton",
|
|
44
|
+
reorder: "reorder"
|
|
41
45
|
};
|
|
42
46
|
var DEFAULT_PRESSED_SCALE = 0.96;
|
|
43
47
|
var DEFAULT_FADE_TRANSLATE_Y = 8;
|
|
44
48
|
var DEFAULT_SKELETON_DURATION_MS = 1200;
|
|
49
|
+
var DEFAULT_FOCUS_SCALE = 1.06;
|
|
50
|
+
var DEFAULT_FOCUS_LIFT_PX = 8;
|
|
51
|
+
var DEFAULT_FLIP_STAGGER_MS = 18;
|
|
52
|
+
var DEFAULT_FLIP_CAP = 8;
|
|
45
53
|
function useEnterExit(options) {
|
|
46
54
|
const { visible, duration = motionDurations.base, translateY = 0, fromScale = 1 } = options;
|
|
47
55
|
const reduced = rnWebHooks.useReducedMotion();
|
|
@@ -96,6 +104,55 @@ function useEnterExit(options) {
|
|
|
96
104
|
}, [progress, translateY, fromScale]);
|
|
97
105
|
return { style, mounted };
|
|
98
106
|
}
|
|
107
|
+
var REST = { scale: 1, translateY: 0 };
|
|
108
|
+
function focusLiftTarget(focused, reduced, opts) {
|
|
109
|
+
if (reduced || !focused) return REST;
|
|
110
|
+
return { scale: opts.scale ?? DEFAULT_FOCUS_SCALE, translateY: -(opts.lift ?? DEFAULT_FOCUS_LIFT_PX) };
|
|
111
|
+
}
|
|
112
|
+
function useFocusLift(focused, spring, opts = {}) {
|
|
113
|
+
const reduced = rnWebHooks.useReducedMotion();
|
|
114
|
+
const target = focusLiftTarget(focused, reduced, opts);
|
|
115
|
+
const scale = react.useRef(new reactNative.Animated.Value(target.scale)).current;
|
|
116
|
+
const translateY = react.useRef(new reactNative.Animated.Value(target.translateY)).current;
|
|
117
|
+
const { stiffness, damping } = spring;
|
|
118
|
+
react.useEffect(() => {
|
|
119
|
+
if (reduced) {
|
|
120
|
+
scale.stopAnimation();
|
|
121
|
+
translateY.stopAnimation();
|
|
122
|
+
scale.setValue(target.scale);
|
|
123
|
+
translateY.setValue(target.translateY);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const config = { stiffness, damping, mass: 1, useNativeDriver: shouldUseNativeDriver() };
|
|
127
|
+
reactNative.Animated.spring(scale, { ...config, toValue: target.scale }).start();
|
|
128
|
+
reactNative.Animated.spring(translateY, { ...config, toValue: target.translateY }).start();
|
|
129
|
+
}, [reduced, target.scale, target.translateY, stiffness, damping, scale, translateY]);
|
|
130
|
+
react.useEffect(
|
|
131
|
+
() => () => {
|
|
132
|
+
scale.stopAnimation();
|
|
133
|
+
translateY.stopAnimation();
|
|
134
|
+
},
|
|
135
|
+
[scale, translateY]
|
|
136
|
+
);
|
|
137
|
+
const style = react.useMemo(() => ({ transform: [{ translateY }, { scale }] }), [scale, translateY]);
|
|
138
|
+
return { style };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/flip/computeFlipDeltas.ts
|
|
142
|
+
function computeFlipDeltas(prev, next, opts) {
|
|
143
|
+
const ordered = [...next.entries()].sort(([, a], [, b]) => a.y - b.y || a.x - b.x);
|
|
144
|
+
const moved = [];
|
|
145
|
+
for (const [key, box] of ordered) {
|
|
146
|
+
if (moved.length >= opts.cap) break;
|
|
147
|
+
const before = prev.get(key);
|
|
148
|
+
if (before === void 0) continue;
|
|
149
|
+
const dx = before.x - box.x;
|
|
150
|
+
const dy = before.y - box.y;
|
|
151
|
+
if (dx === 0 && dy === 0) continue;
|
|
152
|
+
moved.push({ key, dx, dy, delayMs: moved.length * opts.staggerMs });
|
|
153
|
+
}
|
|
154
|
+
return moved;
|
|
155
|
+
}
|
|
99
156
|
var FadeIn = ({
|
|
100
157
|
children,
|
|
101
158
|
duration = motionDurations.base,
|
|
@@ -132,11 +189,13 @@ var FadeIn = ({
|
|
|
132
189
|
}, [progress, translateY]);
|
|
133
190
|
return /* @__PURE__ */ jsxRuntime.jsx(reactNative.Animated.View, { style: [animatedStyle, style], testID, children });
|
|
134
191
|
};
|
|
192
|
+
var IS_WEB = reactNative.Platform.OS === "web";
|
|
135
193
|
var styles = reactNative.StyleSheet.create({
|
|
136
194
|
outer: {
|
|
137
195
|
overflow: "hidden"
|
|
138
196
|
}
|
|
139
197
|
});
|
|
198
|
+
var HIDDEN_WHEN_SETTLED_CLOSED = { visibility: "hidden" };
|
|
140
199
|
var Collapse = ({
|
|
141
200
|
open,
|
|
142
201
|
children,
|
|
@@ -146,6 +205,7 @@ var Collapse = ({
|
|
|
146
205
|
}) => {
|
|
147
206
|
const reduced = rnWebHooks.useReducedMotion();
|
|
148
207
|
const [measured, setMeasured] = react.useState(null);
|
|
208
|
+
const [settledClosed, setSettledClosed] = react.useState(!open);
|
|
149
209
|
const heightAnim = react.useRef(new reactNative.Animated.Value(0)).current;
|
|
150
210
|
const initialized = react.useRef(false);
|
|
151
211
|
const onContentLayout = react.useCallback((event) => {
|
|
@@ -153,16 +213,17 @@ var Collapse = ({
|
|
|
153
213
|
setMeasured((previous) => previous === nextHeight ? previous : nextHeight);
|
|
154
214
|
}, []);
|
|
155
215
|
react.useEffect(() => {
|
|
216
|
+
if (open) setSettledClosed(false);
|
|
156
217
|
const target = collapseTargetHeight(open, measured);
|
|
157
|
-
if (target === void 0)
|
|
158
|
-
|
|
159
|
-
heightAnim.setValue(target);
|
|
160
|
-
initialized.current = true;
|
|
218
|
+
if (target === void 0) {
|
|
219
|
+
if (!open) setSettledClosed(true);
|
|
161
220
|
return void 0;
|
|
162
221
|
}
|
|
163
222
|
const effectiveDuration = resolveDuration(duration, reduced);
|
|
164
|
-
if (effectiveDuration === 0) {
|
|
223
|
+
if (!initialized.current || effectiveDuration === 0) {
|
|
165
224
|
heightAnim.setValue(target);
|
|
225
|
+
initialized.current = true;
|
|
226
|
+
if (!open) setSettledClosed(true);
|
|
166
227
|
return void 0;
|
|
167
228
|
}
|
|
168
229
|
const anim = reactNative.Animated.timing(heightAnim, {
|
|
@@ -172,11 +233,29 @@ var Collapse = ({
|
|
|
172
233
|
// Height is a layout prop — the native driver cannot animate it.
|
|
173
234
|
useNativeDriver: false
|
|
174
235
|
});
|
|
175
|
-
anim.start()
|
|
236
|
+
anim.start(({ finished }) => {
|
|
237
|
+
if (collapseSettlesClosed(open, finished)) setSettledClosed(true);
|
|
238
|
+
});
|
|
176
239
|
return () => anim.stop();
|
|
177
240
|
}, [open, measured, duration, reduced, heightAnim]);
|
|
178
241
|
const outerHeight = measured === null ? void 0 : heightAnim;
|
|
179
|
-
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
242
|
+
return /* @__PURE__ */ jsxRuntime.jsx(
|
|
243
|
+
reactNative.Animated.View,
|
|
244
|
+
{
|
|
245
|
+
accessibilityElementsHidden: settledClosed,
|
|
246
|
+
"aria-hidden": settledClosed ? true : void 0,
|
|
247
|
+
importantForAccessibility: settledClosed ? "no-hide-descendants" : "auto",
|
|
248
|
+
pointerEvents: settledClosed ? "none" : "auto",
|
|
249
|
+
style: [
|
|
250
|
+
styles.outer,
|
|
251
|
+
{ height: outerHeight },
|
|
252
|
+
style,
|
|
253
|
+
settledClosed && IS_WEB ? HIDDEN_WHEN_SETTLED_CLOSED : null
|
|
254
|
+
],
|
|
255
|
+
testID,
|
|
256
|
+
children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { onLayout: onContentLayout, children })
|
|
257
|
+
}
|
|
258
|
+
);
|
|
180
259
|
};
|
|
181
260
|
var PressableScale = ({
|
|
182
261
|
pressedScale = DEFAULT_PRESSED_SCALE,
|
|
@@ -286,6 +365,120 @@ var Skeleton = ({
|
|
|
286
365
|
}
|
|
287
366
|
);
|
|
288
367
|
};
|
|
368
|
+
var ORIGIN = { x: 0, y: 0 };
|
|
369
|
+
function measureSlots(nodes) {
|
|
370
|
+
const slots = /* @__PURE__ */ new Map();
|
|
371
|
+
for (const [key, node] of nodes) {
|
|
372
|
+
const el = node;
|
|
373
|
+
if (typeof el?.offsetLeft === "number" && typeof el.offsetTop === "number") {
|
|
374
|
+
slots.set(key, { x: el.offsetLeft, y: el.offsetTop });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return slots;
|
|
378
|
+
}
|
|
379
|
+
function playFlip(offsets, prev, next, opts) {
|
|
380
|
+
const moved = computeFlipDeltas(prev, next, { staggerMs: opts.staggerMs, cap: Number.POSITIVE_INFINITY });
|
|
381
|
+
moved.forEach(({ key, dx, dy, delayMs }, index) => {
|
|
382
|
+
const xy = offsets.get(key);
|
|
383
|
+
if (xy === void 0) return;
|
|
384
|
+
xy.stopAnimation((current) => {
|
|
385
|
+
if (index >= opts.cap) {
|
|
386
|
+
xy.setValue(ORIGIN);
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
xy.setValue({ x: current.x + dx, y: current.y + dy });
|
|
390
|
+
reactNative.Animated.spring(xy, {
|
|
391
|
+
toValue: ORIGIN,
|
|
392
|
+
stiffness: opts.spring.stiffness,
|
|
393
|
+
damping: opts.spring.damping,
|
|
394
|
+
mass: 1,
|
|
395
|
+
delay: delayMs,
|
|
396
|
+
useNativeDriver: shouldUseNativeDriver()
|
|
397
|
+
}).start();
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
function Reorder({
|
|
402
|
+
itemKeys,
|
|
403
|
+
children,
|
|
404
|
+
spring,
|
|
405
|
+
staggerMs = DEFAULT_FLIP_STAGGER_MS,
|
|
406
|
+
cap = DEFAULT_FLIP_CAP,
|
|
407
|
+
testID = MOTION_TEST_IDS.reorder
|
|
408
|
+
}) {
|
|
409
|
+
const reduced = rnWebHooks.useReducedMotion();
|
|
410
|
+
const web = reactNative.Platform.OS === "web";
|
|
411
|
+
const layouts = react.useRef(/* @__PURE__ */ new Map()).current;
|
|
412
|
+
const nodes = react.useRef(/* @__PURE__ */ new Map()).current;
|
|
413
|
+
const offsets = react.useRef(/* @__PURE__ */ new Map()).current;
|
|
414
|
+
const snapshot = react.useRef(null);
|
|
415
|
+
const measuredOrder = react.useRef(null);
|
|
416
|
+
const frame = react.useRef(null);
|
|
417
|
+
const order = JSON.stringify(itemKeys);
|
|
418
|
+
const opts = { staggerMs, cap, spring };
|
|
419
|
+
if (web && measuredOrder.current !== order) {
|
|
420
|
+
measuredOrder.current = order;
|
|
421
|
+
snapshot.current = measureSlots(nodes);
|
|
422
|
+
}
|
|
423
|
+
const offsetFor = (key) => {
|
|
424
|
+
if (!offsets.has(key)) offsets.set(key, new reactNative.Animated.ValueXY({ x: 0, y: 0 }));
|
|
425
|
+
return offsets.get(key);
|
|
426
|
+
};
|
|
427
|
+
const takeSnapshot = () => {
|
|
428
|
+
const prev = snapshot.current;
|
|
429
|
+
snapshot.current = null;
|
|
430
|
+
return prev;
|
|
431
|
+
};
|
|
432
|
+
react.useLayoutEffect(() => {
|
|
433
|
+
const live = new Set(itemKeys);
|
|
434
|
+
for (const key of [...offsets.keys()]) {
|
|
435
|
+
if (live.has(key)) continue;
|
|
436
|
+
layouts.delete(key);
|
|
437
|
+
offsets.delete(key);
|
|
438
|
+
}
|
|
439
|
+
if (!web) {
|
|
440
|
+
snapshot.current = reduced ? null : new Map(layouts);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
const prev = takeSnapshot();
|
|
444
|
+
if (prev !== null && !reduced) playFlip(offsets, prev, measureSlots(nodes), opts);
|
|
445
|
+
}, [order, reduced]);
|
|
446
|
+
react.useEffect(() => {
|
|
447
|
+
if (!reduced) return;
|
|
448
|
+
for (const xy of offsets.values()) {
|
|
449
|
+
xy.stopAnimation();
|
|
450
|
+
xy.setValue({ x: 0, y: 0 });
|
|
451
|
+
}
|
|
452
|
+
}, [reduced, offsets]);
|
|
453
|
+
react.useEffect(() => () => {
|
|
454
|
+
if (frame.current !== null) cancelAnimationFrame(frame.current);
|
|
455
|
+
}, []);
|
|
456
|
+
const playNative = () => {
|
|
457
|
+
frame.current = null;
|
|
458
|
+
const prev = takeSnapshot();
|
|
459
|
+
if (prev !== null) playFlip(offsets, prev, layouts, opts);
|
|
460
|
+
};
|
|
461
|
+
const onItemLayout = (key) => (event) => {
|
|
462
|
+
const { x, y } = event.nativeEvent.layout;
|
|
463
|
+
layouts.set(key, { x, y });
|
|
464
|
+
if (snapshot.current !== null && frame.current === null) frame.current = requestAnimationFrame(playNative);
|
|
465
|
+
};
|
|
466
|
+
const registerNode = (key) => (node) => {
|
|
467
|
+
if (node === null) nodes.delete(key);
|
|
468
|
+
else nodes.set(key, node);
|
|
469
|
+
};
|
|
470
|
+
return /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { testID, children: itemKeys.map((key) => /* @__PURE__ */ jsxRuntime.jsx(
|
|
471
|
+
reactNative.Animated.View,
|
|
472
|
+
{
|
|
473
|
+
ref: registerNode(key),
|
|
474
|
+
testID: `${testID}-item-${key}`,
|
|
475
|
+
onLayout: web ? void 0 : onItemLayout(key),
|
|
476
|
+
style: { transform: offsetFor(key).getTranslateTransform() },
|
|
477
|
+
children: children(key)
|
|
478
|
+
},
|
|
479
|
+
key
|
|
480
|
+
)) });
|
|
481
|
+
}
|
|
289
482
|
|
|
290
483
|
Object.defineProperty(exports, "prefersReducedMotion", {
|
|
291
484
|
enumerable: true,
|
|
@@ -301,13 +494,20 @@ Object.defineProperty(exports, "DEFAULT_MOTION", {
|
|
|
301
494
|
});
|
|
302
495
|
exports.Collapse = Collapse;
|
|
303
496
|
exports.DEFAULT_FADE_TRANSLATE_Y = DEFAULT_FADE_TRANSLATE_Y;
|
|
497
|
+
exports.DEFAULT_FLIP_CAP = DEFAULT_FLIP_CAP;
|
|
498
|
+
exports.DEFAULT_FLIP_STAGGER_MS = DEFAULT_FLIP_STAGGER_MS;
|
|
499
|
+
exports.DEFAULT_FOCUS_LIFT_PX = DEFAULT_FOCUS_LIFT_PX;
|
|
500
|
+
exports.DEFAULT_FOCUS_SCALE = DEFAULT_FOCUS_SCALE;
|
|
304
501
|
exports.DEFAULT_PRESSED_SCALE = DEFAULT_PRESSED_SCALE;
|
|
305
502
|
exports.DEFAULT_SKELETON_DURATION_MS = DEFAULT_SKELETON_DURATION_MS;
|
|
306
503
|
exports.FadeIn = FadeIn;
|
|
307
504
|
exports.MOTION_TEST_IDS = MOTION_TEST_IDS;
|
|
308
505
|
exports.PressableScale = PressableScale;
|
|
506
|
+
exports.Reorder = Reorder;
|
|
309
507
|
exports.Skeleton = Skeleton;
|
|
310
508
|
exports.collapseTargetHeight = collapseTargetHeight;
|
|
509
|
+
exports.computeFlipDeltas = computeFlipDeltas;
|
|
510
|
+
exports.focusLiftTarget = focusLiftTarget;
|
|
311
511
|
exports.motionDurations = motionDurations;
|
|
312
512
|
exports.motionEasingTokens = motionEasingTokens;
|
|
313
513
|
exports.motionEasings = motionEasings;
|
|
@@ -316,5 +516,6 @@ exports.resolveScale = resolveScale;
|
|
|
316
516
|
exports.shimmerRange = shimmerRange;
|
|
317
517
|
exports.shouldUseNativeDriver = shouldUseNativeDriver;
|
|
318
518
|
exports.useEnterExit = useEnterExit;
|
|
519
|
+
exports.useFocusLift = useFocusLift;
|
|
319
520
|
//# sourceMappingURL=index.js.map
|
|
320
521
|
//# sourceMappingURL=index.js.map
|