@seed-design/stackflow 1.1.20 → 1.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/lib/_virtual/_rolldown/runtime.cjs +34 -0
  2. package/lib/_virtual/_rolldown/runtime.js +14 -0
  3. package/lib/components/AppBar/AppBar.cjs +86 -102
  4. package/lib/components/AppBar/AppBar.js +84 -98
  5. package/lib/components/AppBar/AppBar.namespace.cjs +20 -16
  6. package/lib/components/AppBar/AppBar.namespace.js +15 -1
  7. package/lib/components/AppBar/index.cjs +17 -18
  8. package/lib/components/AppBar/index.js +3 -3
  9. package/lib/components/AppScreen/AppScreen.cjs +46 -61
  10. package/lib/components/AppScreen/AppScreen.js +45 -57
  11. package/lib/components/AppScreen/AppScreen.namespace.cjs +17 -13
  12. package/lib/components/AppScreen/AppScreen.namespace.js +12 -1
  13. package/lib/components/AppScreen/index.cjs +14 -15
  14. package/lib/components/AppScreen/index.js +3 -3
  15. package/lib/components/index.cjs +30 -26
  16. package/lib/components/index.js +5 -6
  17. package/lib/hooks/index.cjs +3 -9
  18. package/lib/hooks/index.js +2 -1
  19. package/lib/hooks/useActivityZIndexBase.cjs +12 -11
  20. package/lib/hooks/useActivityZIndexBase.js +11 -7
  21. package/lib/index.cjs +41 -32
  22. package/lib/index.js +8 -10
  23. package/lib/plugin.cjs +16 -16
  24. package/lib/plugin.js +14 -12
  25. package/lib/primitive/AppBar/AppBar.cjs +41 -33
  26. package/lib/primitive/AppBar/AppBar.js +40 -29
  27. package/lib/primitive/AppBar/AppBar.namespace.cjs +16 -12
  28. package/lib/primitive/AppBar/AppBar.namespace.js +11 -1
  29. package/lib/primitive/AppBar/anatomy.cjs +18 -0
  30. package/lib/primitive/AppBar/anatomy.d.ts +15 -0
  31. package/lib/primitive/AppBar/anatomy.js +18 -0
  32. package/lib/primitive/AppBar/index.cjs +13 -14
  33. package/lib/primitive/AppBar/index.js +3 -3
  34. package/lib/primitive/AppBar/useAppBar.cjs +29 -36
  35. package/lib/primitive/AppBar/useAppBar.js +28 -32
  36. package/lib/primitive/AppBar/useAppBarContext.cjs +11 -18
  37. package/lib/primitive/AppBar/useAppBarContext.js +10 -14
  38. package/lib/primitive/AppScreen/AppScreen.cjs +49 -52
  39. package/lib/primitive/AppScreen/AppScreen.js +48 -48
  40. package/lib/primitive/AppScreen/AppScreen.namespace.cjs +16 -12
  41. package/lib/primitive/AppScreen/AppScreen.namespace.js +11 -1
  42. package/lib/primitive/AppScreen/anatomy.cjs +17 -0
  43. package/lib/primitive/AppScreen/anatomy.d.ts +14 -0
  44. package/lib/primitive/AppScreen/anatomy.js +17 -0
  45. package/lib/primitive/AppScreen/index.cjs +15 -16
  46. package/lib/primitive/AppScreen/index.js +4 -4
  47. package/lib/primitive/AppScreen/useAppScreen.cjs +60 -63
  48. package/lib/primitive/AppScreen/useAppScreen.js +59 -59
  49. package/lib/primitive/AppScreen/useAppScreenContext.cjs +11 -18
  50. package/lib/primitive/AppScreen/useAppScreenContext.js +10 -14
  51. package/lib/primitive/GlobalInteraction/GlobalInteraction.cjs +21 -26
  52. package/lib/primitive/GlobalInteraction/GlobalInteraction.js +20 -22
  53. package/lib/primitive/GlobalInteraction/animation.cjs +303 -0
  54. package/lib/primitive/GlobalInteraction/animation.d.ts +16 -0
  55. package/lib/primitive/GlobalInteraction/animation.js +299 -0
  56. package/lib/primitive/GlobalInteraction/constants.cjs +9 -0
  57. package/lib/primitive/GlobalInteraction/constants.d.ts +19 -0
  58. package/lib/primitive/GlobalInteraction/constants.js +8 -0
  59. package/lib/primitive/GlobalInteraction/dom.cjs +147 -0
  60. package/lib/primitive/GlobalInteraction/dom.d.ts +44 -0
  61. package/lib/primitive/GlobalInteraction/dom.js +140 -0
  62. package/lib/primitive/GlobalInteraction/index.cjs +5 -11
  63. package/lib/primitive/GlobalInteraction/index.js +3 -2
  64. package/lib/primitive/GlobalInteraction/useGlobalInteraction.cjs +220 -133
  65. package/lib/primitive/GlobalInteraction/useGlobalInteraction.d.ts +10 -26
  66. package/lib/primitive/GlobalInteraction/useGlobalInteraction.js +219 -129
  67. package/lib/primitive/GlobalInteraction/useGlobalInteractionContext.cjs +11 -18
  68. package/lib/primitive/GlobalInteraction/useGlobalInteractionContext.js +10 -14
  69. package/lib/primitive/GlobalInteraction/useSwipeBack.cjs +71 -55
  70. package/lib/primitive/GlobalInteraction/useSwipeBack.d.ts +19 -2
  71. package/lib/primitive/GlobalInteraction/useSwipeBack.js +70 -51
  72. package/lib/primitive/index.cjs +55 -27
  73. package/lib/primitive/index.js +26 -9
  74. package/lib/primitive/private/useElementOffset.cjs +29 -51
  75. package/lib/primitive/private/useElementOffset.js +27 -28
  76. package/lib/primitive/private/useTopActivity.cjs +22 -31
  77. package/lib/primitive/private/useTopActivity.js +21 -27
  78. package/lib/utils/createStyleContext.cjs +91 -74
  79. package/lib/utils/createStyleContext.js +89 -70
  80. package/lib/utils/styled.cjs +13 -20
  81. package/lib/utils/styled.js +12 -16
  82. package/package.json +4 -4
  83. package/src/components/AppBar/AppBar.tsx +9 -3
  84. package/src/primitive/AppBar/AppBar.tsx +4 -1
  85. package/src/primitive/AppBar/anatomy.ts +16 -0
  86. package/src/primitive/AppBar/useAppBar.tsx +2 -1
  87. package/src/primitive/AppScreen/anatomy.ts +15 -0
  88. package/src/primitive/AppScreen/useAppScreen.tsx +5 -4
  89. package/src/primitive/GlobalInteraction/animation.ts +447 -0
  90. package/src/primitive/GlobalInteraction/constants.ts +24 -0
  91. package/src/primitive/GlobalInteraction/dom.ts +211 -0
  92. package/src/primitive/GlobalInteraction/useGlobalInteraction.ts +302 -129
  93. package/src/primitive/GlobalInteraction/useSwipeBack.ts +64 -26
@@ -0,0 +1,447 @@
1
+ /**
2
+ * WAAPI-based transition animations for AppScreen.
3
+ *
4
+ * Animations use fill:"forwards" so the final keyframe persists until
5
+ * inline styles are explicitly updated on finish (see dom.ts).
6
+ */
7
+
8
+ import type { TransitionTargets, TransitionStyle } from "./dom";
9
+ import { setTransform, setOpacity } from "./dom";
10
+ import {
11
+ BEHIND_OFFSET_PERCENT,
12
+ BEHIND_PARALLAX,
13
+ OPACITY_FADE_MULTIPLIER,
14
+ TITLE_OFFSET_PERCENT,
15
+ TITLE_TRANSLATE_RATIO,
16
+ } from "./constants";
17
+
18
+ // ─── Constants ──────────────────────────────────────────────────────────────
19
+
20
+ const IOS_EASING = "cubic-bezier(0.2, 0.1, 0.21, 0.99)";
21
+ const IOS_DURATION = 350;
22
+
23
+ const ANDROID_ENTER_EASING = "cubic-bezier(0.23, 0.1, 0.32, 1)";
24
+ const ANDROID_ENTER_DURATION = 300;
25
+ const ANDROID_EXIT_EASING = "linear";
26
+ const ANDROID_EXIT_DURATION = 150;
27
+
28
+ const FADE_IN_ENTER_EASING = "ease-out";
29
+ const FADE_IN_ENTER_DURATION = 300;
30
+ const FADE_IN_EXIT_EASING = "ease-in";
31
+ const FADE_IN_EXIT_DURATION = 150;
32
+
33
+ // Extra margin added to duration for the setTimeout race fallback.
34
+ const FINISHED_TIMEOUT_MARGIN = 100;
35
+
36
+ // ─── Utilities ──────────────────────────────────────────────────────────────
37
+
38
+ function safeAnimate(
39
+ el: HTMLElement | null,
40
+ keyframes: Keyframe[],
41
+ options: KeyframeAnimationOptions,
42
+ ): Animation | null {
43
+ if (!el) return null;
44
+ return el.animate(keyframes, options);
45
+ }
46
+
47
+ /**
48
+ * Snap the app-bar background element transform during an interactive gesture
49
+ * (e.g. swipe-back). Reuses a single Animation by swapping its keyframes each
50
+ * call — recreating on every touchmove would cancel-then-recreate and cause a
51
+ * visible flicker.
52
+ */
53
+ export function scrubAppBarBackground(
54
+ el: HTMLElement | null,
55
+ transform: string,
56
+ prev: Animation | null,
57
+ ): Animation | null {
58
+ if (prev && prev.effect instanceof KeyframeEffect) {
59
+ try {
60
+ prev.effect.setKeyframes([{ transform }]);
61
+ return prev;
62
+ } catch {
63
+ prev.cancel();
64
+ }
65
+ }
66
+ return safeAnimate(el, [{ transform }], { duration: 1, fill: "forwards" });
67
+ }
68
+
69
+ export function cancelAll(animations: (Animation | null)[]) {
70
+ for (const a of animations) {
71
+ try {
72
+ a?.cancel();
73
+ } catch {
74
+ /* ignore */
75
+ }
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Resolve when the animation finishes or `timeoutMs` elapses, whichever
81
+ * comes first. Uses `Animation.finished` when available (Chrome 84+,
82
+ * Safari 13.1+, Firefox 110+) and falls back to `onfinish` / `oncancel`
83
+ * listeners for older browsers (e.g. Chrome 77). Both the timer and the
84
+ * listeners are always cleaned up on the losing side of the race, so a
85
+ * late-firing animation cannot invoke a stale callback or leak via
86
+ * closure-retained Animation references.
87
+ */
88
+ function waitOne(a: Animation, timeoutMs: number): Promise<void> {
89
+ const maybeFinished = (a as { finished?: Promise<Animation> }).finished;
90
+
91
+ if (maybeFinished && typeof maybeFinished.then === "function") {
92
+ return new Promise<void>((resolve) => {
93
+ let settled = false;
94
+ const finish = () => {
95
+ if (settled) return;
96
+ settled = true;
97
+ clearTimeout(timer);
98
+ resolve();
99
+ };
100
+ const timer = setTimeout(finish, timeoutMs);
101
+ maybeFinished.then(finish, finish);
102
+ });
103
+ }
104
+
105
+ return new Promise<void>((resolve) => {
106
+ let settled = false;
107
+ const finish = () => {
108
+ if (settled) return;
109
+ settled = true;
110
+ try {
111
+ a.onfinish = null;
112
+ a.oncancel = null;
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ clearTimeout(timer);
117
+ resolve();
118
+ };
119
+ a.onfinish = finish;
120
+ a.oncancel = finish;
121
+ const timer = setTimeout(finish, timeoutMs);
122
+ });
123
+ }
124
+
125
+ function waitAll(animations: (Animation | null)[], durationMs: number): Promise<void> {
126
+ const valid = animations.filter((a): a is Animation => a !== null);
127
+ if (valid.length === 0) return Promise.resolve();
128
+ const timeout = durationMs + FINISHED_TIMEOUT_MARGIN;
129
+ return Promise.all(valid.map((a) => waitOne(a, timeout))).then(() => {});
130
+ }
131
+
132
+ export type AnimationResult = { animations: Animation[]; finished: Promise<void> };
133
+
134
+ function collectAnimations(anims: (Animation | null)[], durationMs: number): AnimationResult {
135
+ const animations = anims.filter((a): a is Animation => a !== null);
136
+ return { animations, finished: waitAll(animations, durationMs) };
137
+ }
138
+
139
+ // ─── iOS Slide ──────────────────────────────────────────────────────────────
140
+
141
+ interface TitleKeyframe {
142
+ opacity: string;
143
+ transform: string;
144
+ }
145
+
146
+ interface IosPositions {
147
+ topLayer: string;
148
+ behindLayer: string;
149
+ dim: string;
150
+ topTitle: TitleKeyframe;
151
+ behindTitle: TitleKeyframe;
152
+ topIconOpacity: string;
153
+ behindIconOpacity: string;
154
+ appBarBackground: string;
155
+ }
156
+
157
+ const IOS_ONSCREEN: IosPositions = {
158
+ topLayer: "translate3d(0, 0, 0)",
159
+ behindLayer: `translate3d(${BEHIND_OFFSET_PERCENT}%, 0, 0)`,
160
+ dim: "1",
161
+ topTitle: { opacity: "1", transform: "translate3d(0, 0, 0)" },
162
+ behindTitle: { opacity: "0", transform: `translate3d(${-TITLE_OFFSET_PERCENT}%, 0, 0)` },
163
+ topIconOpacity: "1",
164
+ behindIconOpacity: "0",
165
+ appBarBackground: "translate3d(0, 0, 0)",
166
+ };
167
+
168
+ const IOS_OFFSCREEN: IosPositions = {
169
+ topLayer: "translate3d(100%, 0, 0)",
170
+ behindLayer: "translate3d(0, 0, 0)",
171
+ dim: "0",
172
+ topTitle: { opacity: "0", transform: `translate3d(${TITLE_OFFSET_PERCENT}%, 0, 0)` },
173
+ behindTitle: { opacity: "1", transform: "translate3d(0, 0, 0)" },
174
+ topIconOpacity: "0",
175
+ behindIconOpacity: "1",
176
+ appBarBackground: "translate3d(100%, 0, 0)",
177
+ };
178
+
179
+ function iosAnimate(t: TransitionTargets, from: IosPositions, to: IosPositions): AnimationResult {
180
+ const opts: KeyframeAnimationOptions = {
181
+ duration: IOS_DURATION,
182
+ easing: IOS_EASING,
183
+ fill: "forwards",
184
+ };
185
+ const anims: (Animation | null)[] = [];
186
+
187
+ anims.push(
188
+ safeAnimate(t.topLayer, [{ transform: from.topLayer }, { transform: to.topLayer }], opts),
189
+ );
190
+ anims.push(
191
+ safeAnimate(
192
+ t.behindLayer,
193
+ [{ transform: from.behindLayer }, { transform: to.behindLayer }],
194
+ opts,
195
+ ),
196
+ );
197
+ anims.push(safeAnimate(t.topDim, [{ opacity: from.dim }, { opacity: to.dim }], opts));
198
+ anims.push(safeAnimate(t.topTitle, [from.topTitle, to.topTitle], opts));
199
+ anims.push(safeAnimate(t.behindTitle, [from.behindTitle, to.behindTitle], opts));
200
+ for (const icon of t.topIcons) {
201
+ anims.push(
202
+ safeAnimate(
203
+ icon,
204
+ [
205
+ { opacity: from.topIconOpacity, transform: from.topTitle.transform },
206
+ { opacity: to.topIconOpacity, transform: to.topTitle.transform },
207
+ ],
208
+ opts,
209
+ ),
210
+ );
211
+ }
212
+ for (const icon of t.behindIcons) {
213
+ anims.push(
214
+ safeAnimate(
215
+ icon,
216
+ [{ opacity: from.behindIconOpacity }, { opacity: to.behindIconOpacity }],
217
+ opts,
218
+ ),
219
+ );
220
+ }
221
+ anims.push(
222
+ safeAnimate(
223
+ t.topAppBarBackground,
224
+ [{ transform: from.appBarBackground }, { transform: to.appBarBackground }],
225
+ opts,
226
+ ),
227
+ );
228
+
229
+ return collectAnimations(anims, IOS_DURATION);
230
+ }
231
+
232
+ /**
233
+ * Pin every iOS transition target to the given position with inline styles
234
+ * before the animation starts. Prevents a one-frame flash at the final
235
+ * position when the browser paints between React commit and animation
236
+ * commit, and guarantees a known start state when the previous transition's
237
+ * finish handler did not run (e.g. in browsers without Animation.finished
238
+ * support, where the polyfill could time out).
239
+ */
240
+ function pinIosInlineStyles(t: TransitionTargets, pos: IosPositions) {
241
+ setTransform(t.topLayer, pos.topLayer);
242
+ setTransform(t.behindLayer, pos.behindLayer);
243
+ setOpacity(t.topDim, pos.dim);
244
+ setTransform(t.topAppBarBackground, pos.appBarBackground);
245
+
246
+ setOpacity(t.topTitle, pos.topTitle.opacity);
247
+ setTransform(t.topTitle, pos.topTitle.transform);
248
+ setOpacity(t.behindTitle, pos.behindTitle.opacity);
249
+ setTransform(t.behindTitle, pos.behindTitle.transform);
250
+
251
+ for (const icon of t.topIcons) {
252
+ setOpacity(icon, pos.topIconOpacity);
253
+ setTransform(icon, pos.topTitle.transform);
254
+ }
255
+ for (const icon of t.behindIcons) setOpacity(icon, pos.behindIconOpacity);
256
+ }
257
+
258
+ function iosAnimatePush(t: TransitionTargets): AnimationResult {
259
+ pinIosInlineStyles(t, IOS_OFFSCREEN);
260
+ return iosAnimate(t, IOS_OFFSCREEN, IOS_ONSCREEN);
261
+ }
262
+
263
+ function iosAnimatePop(t: TransitionTargets): AnimationResult {
264
+ // No inline pinning here — top is already onscreen (no flash risk) and
265
+ // pinning `-30%` on the behind layer caused it to stick there when
266
+ // cleanup got skipped for any reason.
267
+ return iosAnimate(t, IOS_ONSCREEN, IOS_OFFSCREEN);
268
+ }
269
+
270
+ // ─── Android / FadeIn ───────────────────────────────────────────────────────
271
+
272
+ function androidAnimate(t: TransitionTargets, direction: "push" | "pop"): AnimationResult {
273
+ const isPush = direction === "push";
274
+ const duration = isPush ? ANDROID_ENTER_DURATION : ANDROID_EXIT_DURATION;
275
+ const opts: KeyframeAnimationOptions = {
276
+ duration,
277
+ easing: isPush ? ANDROID_ENTER_EASING : ANDROID_EXIT_EASING,
278
+ fill: "forwards",
279
+ };
280
+
281
+ if (isPush) {
282
+ // Prevent flash: set start positions before animation
283
+ setOpacity(t.topLayer, "0");
284
+ setTransform(t.topLayer, "translate3d(0, 8vh, 0)");
285
+ setOpacity(t.topDim, "0");
286
+ if (t.topAppBarRoot) {
287
+ setOpacity(t.topAppBarRoot, "0");
288
+ setTransform(t.topAppBarRoot, "translate3d(0, 8vh, 0)");
289
+ }
290
+ } else {
291
+ // Pin onscreen positions before pop starts (mirror push setup)
292
+ setOpacity(t.topLayer, "1");
293
+ setTransform(t.topLayer, "translate3d(0, 0, 0)");
294
+ if (t.topAppBarRoot) {
295
+ setOpacity(t.topAppBarRoot, "1");
296
+ setTransform(t.topAppBarRoot, "translate3d(0, 0, 0)");
297
+ }
298
+ // Restore behind appBar before pop starts (was hidden during idle)
299
+ if (t.behindAppBarRoot) t.behindAppBarRoot.style.opacity = "";
300
+ }
301
+
302
+ const [fromOpacity, toOpacity] = isPush ? ["0", "1"] : ["1", "0"];
303
+ const [fromY, toY] = isPush ? ["8vh", "0"] : ["0", "8vh"];
304
+ const shared: [Keyframe, Keyframe] = [
305
+ { opacity: fromOpacity, transform: `translate3d(0, ${fromY}, 0)` },
306
+ { opacity: toOpacity, transform: `translate3d(0, ${toY}, 0)` },
307
+ ];
308
+ const dimFrames: [Keyframe, Keyframe] = [
309
+ { opacity: fromOpacity, transform: `translate3d(0, ${isPush ? "-8vh" : "0"}, 0)` },
310
+ { opacity: toOpacity, transform: `translate3d(0, ${isPush ? "0" : "-8vh"}, 0)` },
311
+ ];
312
+
313
+ return collectAnimations(
314
+ [
315
+ safeAnimate(t.topLayer, shared, opts),
316
+ safeAnimate(t.topDim, dimFrames, opts),
317
+ safeAnimate(t.topAppBarRoot, shared, opts),
318
+ ],
319
+ duration,
320
+ );
321
+ }
322
+
323
+ function fadeInAnimate(t: TransitionTargets, direction: "push" | "pop"): AnimationResult {
324
+ const isPush = direction === "push";
325
+ const duration = isPush ? FADE_IN_ENTER_DURATION : FADE_IN_EXIT_DURATION;
326
+ const opts: KeyframeAnimationOptions = {
327
+ duration,
328
+ easing: isPush ? FADE_IN_ENTER_EASING : FADE_IN_EXIT_EASING,
329
+ fill: "forwards",
330
+ };
331
+
332
+ if (isPush) {
333
+ // Prevent flash: set start positions before animation
334
+ setOpacity(t.topLayer, "0");
335
+ if (t.topAppBarRoot) setOpacity(t.topAppBarRoot, "0");
336
+ } else {
337
+ // Pin onscreen positions before pop starts (mirror push setup)
338
+ setOpacity(t.topLayer, "1");
339
+ if (t.topAppBarRoot) setOpacity(t.topAppBarRoot, "1");
340
+ // Restore behind appBar before pop starts (was hidden during idle)
341
+ if (t.behindAppBarRoot) t.behindAppBarRoot.style.opacity = "";
342
+ }
343
+
344
+ const frames: [Keyframe, Keyframe] = [
345
+ { opacity: isPush ? "0" : "1" },
346
+ { opacity: isPush ? "1" : "0" },
347
+ ];
348
+
349
+ return collectAnimations(
350
+ [safeAnimate(t.topLayer, frames, opts), safeAnimate(t.topAppBarRoot, frames, opts)],
351
+ duration,
352
+ );
353
+ }
354
+
355
+ // ─── Swipe ──────────────────────────────────────────────────────────────────
356
+
357
+ function animateSwipe(
358
+ t: TransitionTargets,
359
+ displacement: number,
360
+ duration: number,
361
+ end: IosPositions,
362
+ ): AnimationResult {
363
+ const ratio = displacement / window.innerWidth;
364
+ const topFade = Math.max(0, 1 - ratio * OPACITY_FADE_MULTIPLIER);
365
+ const opts: KeyframeAnimationOptions = { duration, easing: IOS_EASING, fill: "forwards" };
366
+
367
+ const currentBehind = `translate3d(calc(${BEHIND_OFFSET_PERCENT}% + ${displacement * BEHIND_PARALLAX}px), 0, 0)`;
368
+ const currentTitleOffset = `translate3d(${displacement * TITLE_TRANSLATE_RATIO}px, 0, 0)`;
369
+ const currentBehindTitle = `translate3d(calc(${-TITLE_OFFSET_PERCENT}% + ${displacement * TITLE_TRANSLATE_RATIO}px), 0, 0)`;
370
+
371
+ const anims: (Animation | null)[] = [
372
+ safeAnimate(
373
+ t.topLayer,
374
+ [{ transform: `translate3d(${displacement}px, 0, 0)` }, { transform: end.topLayer }],
375
+ opts,
376
+ ),
377
+ safeAnimate(
378
+ t.behindLayer,
379
+ [{ transform: currentBehind }, { transform: end.behindLayer }],
380
+ opts,
381
+ ),
382
+ safeAnimate(t.topDim, [{ opacity: `${1 - ratio}` }, { opacity: end.dim }], opts),
383
+ safeAnimate(
384
+ t.topTitle,
385
+ [{ opacity: `${topFade}`, transform: currentTitleOffset }, end.topTitle],
386
+ opts,
387
+ ),
388
+ safeAnimate(
389
+ t.behindTitle,
390
+ [{ opacity: `${ratio}`, transform: currentBehindTitle }, end.behindTitle],
391
+ opts,
392
+ ),
393
+ ];
394
+ for (const icon of t.topIcons) {
395
+ anims.push(
396
+ safeAnimate(
397
+ icon,
398
+ [
399
+ { opacity: `${topFade}`, transform: currentTitleOffset },
400
+ { opacity: end.topIconOpacity, transform: end.topTitle.transform },
401
+ ],
402
+ opts,
403
+ ),
404
+ );
405
+ }
406
+ for (const icon of t.behindIcons) {
407
+ anims.push(
408
+ safeAnimate(icon, [{ opacity: `${ratio}` }, { opacity: end.behindIconOpacity }], opts),
409
+ );
410
+ }
411
+ anims.push(
412
+ safeAnimate(
413
+ t.topAppBarBackground,
414
+ [{ transform: `translate3d(${displacement}px, 0, 0)` }, { transform: end.appBarBackground }],
415
+ opts,
416
+ ),
417
+ );
418
+
419
+ return collectAnimations(anims, duration);
420
+ }
421
+
422
+ // ─── Public API ─────────────────────────────────────────────────────────────
423
+
424
+ export function animateTransition(
425
+ targets: TransitionTargets,
426
+ direction: "push" | "pop",
427
+ style: TransitionStyle,
428
+ ): AnimationResult {
429
+ switch (style) {
430
+ case "slideFromRightIOS":
431
+ return direction === "push" ? iosAnimatePush(targets) : iosAnimatePop(targets);
432
+ case "fadeFromBottomAndroid":
433
+ return androidAnimate(targets, direction);
434
+ case "fadeIn":
435
+ return fadeInAnimate(targets, direction);
436
+ default:
437
+ return { animations: [], finished: Promise.resolve() };
438
+ }
439
+ }
440
+
441
+ export function animateSwipeComplete(t: TransitionTargets, displacement: number): AnimationResult {
442
+ return animateSwipe(t, displacement, IOS_DURATION, IOS_OFFSCREEN);
443
+ }
444
+
445
+ export function animateSwipeCancel(t: TransitionTargets, displacement: number): AnimationResult {
446
+ return animateSwipe(t, displacement, IOS_DURATION, IOS_ONSCREEN);
447
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Shared numeric constants for WAAPI animations and inline style math.
3
+ *
4
+ * Consumers:
5
+ * - animation.ts (WAAPI keyframes)
6
+ * - dom.ts (swipe-time inline styles)
7
+ *
8
+ * Keeping these in one place prevents drift between the two consumers.
9
+ */
10
+
11
+ /** Behind activity's baseline X offset during iOS slide (percent). */
12
+ export const BEHIND_OFFSET_PERCENT = -30;
13
+
14
+ /** Behind activity's parallax multiplier applied to swipe displacement. */
15
+ export const BEHIND_PARALLAX = 0.3;
16
+
17
+ /** Title translate-X multiplier applied to swipe displacement (px). */
18
+ export const TITLE_TRANSLATE_RATIO = 0.15;
19
+
20
+ /** Top title/icon opacity fade rate during swipe (ratio multiplier). */
21
+ export const OPACITY_FADE_MULTIPLIER = 3;
22
+
23
+ /** Title X offset at onscreen/offscreen endpoints (percent). */
24
+ export const TITLE_OFFSET_PERCENT = 25;
@@ -0,0 +1,211 @@
1
+ /**
2
+ * DOM discovery and inline style management for AppScreen transitions.
3
+ *
4
+ * Elements are found via data-part attributes. Inline styles are the
5
+ * source of truth for element positions between animations.
6
+ */
7
+
8
+ import { appBarAnatomy } from "../AppBar/anatomy";
9
+ import { appScreenAnatomy } from "../AppScreen/anatomy";
10
+ import {
11
+ BEHIND_OFFSET_PERCENT,
12
+ BEHIND_PARALLAX,
13
+ OPACITY_FADE_MULTIPLIER,
14
+ TITLE_OFFSET_PERCENT,
15
+ TITLE_TRANSLATE_RATIO,
16
+ } from "./constants";
17
+
18
+ // ─── Types ──────────────────────────────────────────────────────────────────
19
+
20
+ export interface TransitionTargets {
21
+ topLayer: HTMLElement | null;
22
+ topDim: HTMLElement | null;
23
+ behindLayer: HTMLElement | null;
24
+ behindDim: HTMLElement | null;
25
+ topTitle: HTMLElement | null;
26
+ behindTitle: HTMLElement | null;
27
+ topIcons: HTMLElement[];
28
+ behindIcons: HTMLElement[];
29
+ topAppBarRoot: HTMLElement | null;
30
+ behindAppBarRoot: HTMLElement | null;
31
+ topAppBarBackground: HTMLElement | null;
32
+ behindAppBarBackground: HTMLElement | null;
33
+ }
34
+
35
+ export type TransitionStyle = "slideFromRightIOS" | "fadeFromBottomAndroid" | "fadeIn";
36
+
37
+ // ─── DOM Discovery ──────────────────────────────────────────────────────────
38
+
39
+ function queryParts(activity: HTMLElement | null, part: string): HTMLElement | null {
40
+ return activity?.querySelector<HTMLElement>(`[data-part='${part}']`) ?? null;
41
+ }
42
+
43
+ function queryAllParts(activity: HTMLElement | null, ...parts: string[]): HTMLElement[] {
44
+ if (!activity) return [];
45
+ const selector = parts.map((p) => `[data-part='${p}']`).join(", ");
46
+ return Array.from(activity.querySelectorAll<HTMLElement>(selector));
47
+ }
48
+
49
+ /** Read transition style directly from the top activity DOM element. */
50
+ export function readTransitionStyle(stackEl: HTMLElement): TransitionStyle {
51
+ const topActivity = stackEl.querySelector<HTMLElement>("[data-activity-is-top]");
52
+ return (topActivity?.dataset["transitionStyle"] as TransitionStyle) ?? "slideFromRightIOS";
53
+ }
54
+
55
+ /** Find all transition target elements. Call once per gesture/transition. */
56
+ export function findTransitionTargets(stackEl: HTMLElement): TransitionTargets {
57
+ const topActivity = stackEl.querySelector<HTMLElement>("[data-activity-is-top]");
58
+
59
+ let behindActivity: HTMLElement | null = null;
60
+ if (topActivity) {
61
+ const all = Array.from(
62
+ stackEl.querySelectorAll<HTMLElement>(`[data-part='${appScreenAnatomy.activity}']`),
63
+ );
64
+ const topId = topActivity.dataset["activityId"];
65
+ const topIdx = all.findIndex((el) => el.dataset["activityId"] === topId);
66
+ for (let i = topIdx - 1; i >= 0; i--) {
67
+ if (all[i].dataset["activityId"]) {
68
+ behindActivity = all[i];
69
+ break;
70
+ }
71
+ }
72
+ }
73
+
74
+ return {
75
+ topLayer: queryParts(topActivity, appScreenAnatomy.layer),
76
+ topDim: queryParts(topActivity, appScreenAnatomy.dim),
77
+ behindLayer: queryParts(behindActivity, appScreenAnatomy.layer),
78
+ behindDim: queryParts(behindActivity, appScreenAnatomy.dim),
79
+ topTitle: queryParts(topActivity, appBarAnatomy.main),
80
+ behindTitle: queryParts(behindActivity, appBarAnatomy.main),
81
+ topIcons: queryAllParts(topActivity, appBarAnatomy.icon, appBarAnatomy.custom),
82
+ behindIcons: queryAllParts(behindActivity, appBarAnatomy.icon, appBarAnatomy.custom),
83
+ topAppBarRoot: queryParts(topActivity, appBarAnatomy.root),
84
+ behindAppBarRoot: queryParts(behindActivity, appBarAnatomy.root),
85
+ topAppBarBackground: queryParts(topActivity, appBarAnatomy.background),
86
+ behindAppBarBackground: queryParts(behindActivity, appBarAnatomy.background),
87
+ };
88
+ }
89
+
90
+ // ─── Inline Style Helpers ───────────────────────────────────────────────────
91
+
92
+ export function setTransform(el: HTMLElement | null, value: string) {
93
+ if (el) el.style.transform = value;
94
+ }
95
+
96
+ export function setOpacity(el: HTMLElement | null, value: string) {
97
+ if (el) el.style.opacity = value;
98
+ }
99
+
100
+ function clearStyles(el: HTMLElement | null) {
101
+ if (!el) return;
102
+ el.style.transform = "";
103
+ el.style.opacity = "";
104
+ }
105
+
106
+ // ─── Position Management ────────────────────────────────────────────────────
107
+
108
+ /**
109
+ * Clear ALL inline styles from every target, including appBar roots.
110
+ * This is the foundation — call before setting any specific positions.
111
+ */
112
+ export function clearAllStyles(t: TransitionTargets) {
113
+ const all = [
114
+ t.topLayer,
115
+ t.topDim,
116
+ t.behindLayer,
117
+ t.behindDim,
118
+ t.topTitle,
119
+ t.behindTitle,
120
+ t.topAppBarRoot,
121
+ t.behindAppBarRoot,
122
+ t.topAppBarBackground,
123
+ t.behindAppBarBackground,
124
+ ...t.topIcons,
125
+ ...t.behindIcons,
126
+ ];
127
+ for (const el of all) clearStyles(el);
128
+ }
129
+
130
+ /**
131
+ * Set idle positions after push completes.
132
+ * Pattern: clear everything first, then set only non-default positions.
133
+ */
134
+ export function setIdlePositions(
135
+ t: TransitionTargets,
136
+ style: TransitionStyle = "slideFromRightIOS",
137
+ ) {
138
+ clearAllStyles(t);
139
+
140
+ if (style === "slideFromRightIOS") {
141
+ // Behind layer parallax offset
142
+ setTransform(t.behindLayer, `translate3d(${BEHIND_OFFSET_PERCENT}%, 0, 0)`);
143
+ setOpacity(t.behindTitle, "0");
144
+ setTransform(t.behindTitle, `translate3d(${-TITLE_OFFSET_PERCENT}%, 0, 0)`);
145
+ for (const icon of t.behindIcons) setOpacity(icon, "0");
146
+ } else {
147
+ // Android/fadeIn: behind is at same position, hide its appBar to prevent bleed-through
148
+ setOpacity(t.behindAppBarRoot, "0");
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Set positions after pop/swipe-complete.
154
+ * Pattern: clear everything first, then pin top in exit position.
155
+ */
156
+ export function setPostExitPositions(
157
+ t: TransitionTargets,
158
+ style: TransitionStyle = "slideFromRightIOS",
159
+ ) {
160
+ clearAllStyles(t);
161
+
162
+ // Pin top activity off-screen (stackflow will remove it from DOM later)
163
+ setOpacity(t.topDim, "0");
164
+ setOpacity(t.topAppBarRoot, "0");
165
+
166
+ if (style === "slideFromRightIOS") {
167
+ setTransform(t.topLayer, "translate3d(100%, 0, 0)");
168
+ setOpacity(t.topTitle, "0");
169
+ setTransform(t.topTitle, `translate3d(${TITLE_OFFSET_PERCENT}%, 0, 0)`);
170
+ for (const icon of t.topIcons) {
171
+ setOpacity(icon, "0");
172
+ setTransform(icon, `translate3d(${TITLE_OFFSET_PERCENT}%, 0, 0)`);
173
+ }
174
+ } else if (style === "fadeFromBottomAndroid") {
175
+ setOpacity(t.topLayer, "0");
176
+ setTransform(t.topLayer, "translate3d(0, 8vh, 0)");
177
+ setTransform(t.topAppBarRoot, "translate3d(0, 8vh, 0)");
178
+ } else {
179
+ setOpacity(t.topLayer, "0");
180
+ }
181
+ }
182
+
183
+ /** Apply inline styles during swiping (touchmove). */
184
+ export function applySwipeStyles(t: TransitionTargets, displacement: number, ratio: number) {
185
+ setTransform(t.topLayer, `translate3d(${displacement}px, 0, 0)`);
186
+ setTransform(
187
+ t.behindLayer,
188
+ `translate3d(calc(${BEHIND_OFFSET_PERCENT}% + ${displacement * BEHIND_PARALLAX}px), 0, 0)`,
189
+ );
190
+ setOpacity(t.topDim, `${1 - ratio}`);
191
+
192
+ const topFade = Math.max(0, 1 - ratio * OPACITY_FADE_MULTIPLIER);
193
+ if (t.topTitle) {
194
+ setOpacity(t.topTitle, `${topFade}`);
195
+ setTransform(t.topTitle, `translate3d(${displacement * TITLE_TRANSLATE_RATIO}px, 0, 0)`);
196
+ }
197
+ if (t.behindTitle) {
198
+ setOpacity(t.behindTitle, `${ratio}`);
199
+ setTransform(
200
+ t.behindTitle,
201
+ `translate3d(calc(${-TITLE_OFFSET_PERCENT}% + ${displacement * TITLE_TRANSLATE_RATIO}px), 0, 0)`,
202
+ );
203
+ }
204
+ for (const icon of t.topIcons) {
205
+ setOpacity(icon, `${topFade}`);
206
+ setTransform(icon, `translate3d(${displacement * TITLE_TRANSLATE_RATIO}px, 0, 0)`);
207
+ }
208
+ for (const icon of t.behindIcons) {
209
+ setOpacity(icon, `${ratio}`);
210
+ }
211
+ }