@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 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, StyleProp, ViewStyle, PressableProps } from 'react-native';
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 (only clipped by `overflow: hidden`), so
142
- * their state is preserved across open/close and screen readers still reach them.
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
- export { Collapse, type CollapseProps, DEFAULT_FADE_TRANSLATE_Y, DEFAULT_PRESSED_SCALE, DEFAULT_SKELETON_DURATION_MS, FadeIn, type FadeInProps, MOTION_TEST_IDS, type MotionEasingName, PressableScale, type PressableScaleProps, Skeleton, type SkeletonProps, type UseEnterExitOptions, type UseEnterExitResult, collapseTargetHeight, motionDurations, motionEasingTokens, motionEasings, resolveDuration, resolveScale, shimmerRange, shouldUseNativeDriver, useEnterExit };
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, StyleProp, ViewStyle, PressableProps } from 'react-native';
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 (only clipped by `overflow: hidden`), so
142
- * their state is preserved across open/close and screen readers still reach them.
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
- export { Collapse, type CollapseProps, DEFAULT_FADE_TRANSLATE_Y, DEFAULT_PRESSED_SCALE, DEFAULT_SKELETON_DURATION_MS, FadeIn, type FadeInProps, MOTION_TEST_IDS, type MotionEasingName, PressableScale, type PressableScaleProps, Skeleton, type SkeletonProps, type UseEnterExitOptions, type UseEnterExitResult, collapseTargetHeight, motionDurations, motionEasingTokens, motionEasings, resolveDuration, resolveScale, shimmerRange, shouldUseNativeDriver, useEnterExit };
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) return void 0;
158
- if (!initialized.current) {
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(reactNative.Animated.View, { style: [styles.outer, { height: outerHeight }, style], testID, children: /* @__PURE__ */ jsxRuntime.jsx(reactNative.View, { onLayout: onContentLayout, children }) });
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