@seed-design/stackflow 1.1.24 → 1.1.26

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.
@@ -13,7 +13,9 @@ function useAppBar(_props) {
13
13
  const [right, rightRef] = (0, react.useState)(null);
14
14
  const leftOffset = require_useElementOffset.useElementOffset(left);
15
15
  const rightOffset = require_useElementOffset.useElementOffset(right);
16
- const centeredTitlePaddingX = root ? `${Math.max(leftOffset?.fromLeft ?? 0, rightOffset?.fromRight ?? 0)}px` : "initial";
16
+ const extents = [leftOffset?.fromLeft, rightOffset?.fromRight].filter((extent) => extent !== void 0);
17
+ const areaExtent = extents.length > 0 ? `${Math.max(...extents)}px` : "initial";
18
+ const centeredTitlePaddingX = root ? `${Math.max(leftOffset?.fromParentLeft ?? 0, rightOffset?.fromParentRight ?? 0)}px` : "initial";
17
19
  return (0, react.useMemo)(() => ({
18
20
  refs: {
19
21
  root: rootRef,
@@ -24,9 +26,16 @@ function useAppBar(_props) {
24
26
  rootProps: (0, _seed_design_dom_utils.elementProps)({
25
27
  "data-part": require_anatomy.appBarAnatomy.root,
26
28
  ...stateProps,
27
- style: { "--centered-title-padding-x": centeredTitlePaddingX }
29
+ style: {
30
+ "--app-bar-area-extent": areaExtent,
31
+ "--centered-title-padding-x": centeredTitlePaddingX
32
+ }
28
33
  })
29
- }), [stateProps, centeredTitlePaddingX]);
34
+ }), [
35
+ stateProps,
36
+ areaExtent,
37
+ centeredTitlePaddingX
38
+ ]);
30
39
  }
31
40
  //#endregion
32
41
  exports.useAppBar = useAppBar;
@@ -12,7 +12,9 @@ function useAppBar(_props) {
12
12
  const [right, rightRef] = useState(null);
13
13
  const leftOffset = useElementOffset(left);
14
14
  const rightOffset = useElementOffset(right);
15
- const centeredTitlePaddingX = root ? `${Math.max(leftOffset?.fromLeft ?? 0, rightOffset?.fromRight ?? 0)}px` : "initial";
15
+ const extents = [leftOffset?.fromLeft, rightOffset?.fromRight].filter((extent) => extent !== void 0);
16
+ const areaExtent = extents.length > 0 ? `${Math.max(...extents)}px` : "initial";
17
+ const centeredTitlePaddingX = root ? `${Math.max(leftOffset?.fromParentLeft ?? 0, rightOffset?.fromParentRight ?? 0)}px` : "initial";
16
18
  return useMemo(() => ({
17
19
  refs: {
18
20
  root: rootRef,
@@ -23,9 +25,16 @@ function useAppBar(_props) {
23
25
  rootProps: elementProps({
24
26
  "data-part": appBarAnatomy.root,
25
27
  ...stateProps,
26
- style: { "--centered-title-padding-x": centeredTitlePaddingX }
28
+ style: {
29
+ "--app-bar-area-extent": areaExtent,
30
+ "--centered-title-padding-x": centeredTitlePaddingX
31
+ }
27
32
  })
28
- }), [stateProps, centeredTitlePaddingX]);
33
+ }), [
34
+ stateProps,
35
+ areaExtent,
36
+ centeredTitlePaddingX
37
+ ]);
29
38
  }
30
39
  //#endregion
31
40
  export { useAppBar };
@@ -185,11 +185,12 @@ function useGlobalInteraction() {
185
185
  stopRunningAnims,
186
186
  cancelPendingPushRAF
187
187
  ]);
188
+ const topActivityId = stack?.activities.find((activity) => activity.isTop)?.id;
188
189
  (0, react.useLayoutEffect)(() => {
189
190
  if (stack?.globalTransitionState !== "idle") return;
190
191
  const stackEl = stackRef.current;
191
192
  if (stackEl) require_dom.clearTopActivityStyles(stackEl);
192
- }, [stack?.globalTransitionState]);
193
+ }, [stack?.globalTransitionState, topActivityId]);
193
194
  (0, react.useEffect)(() => {
194
195
  return () => {
195
196
  cancelPendingPushRAF();
@@ -184,11 +184,12 @@ function useGlobalInteraction() {
184
184
  stopRunningAnims,
185
185
  cancelPendingPushRAF
186
186
  ]);
187
+ const topActivityId = stack?.activities.find((activity) => activity.isTop)?.id;
187
188
  useLayoutEffect(() => {
188
189
  if (stack?.globalTransitionState !== "idle") return;
189
190
  const stackEl = stackRef.current;
190
191
  if (stackEl) clearTopActivityStyles(stackEl);
191
- }, [stack?.globalTransitionState]);
192
+ }, [stack?.globalTransitionState, topActivityId]);
192
193
  useEffect(() => {
193
194
  return () => {
194
195
  cancelPendingPushRAF();
@@ -4,6 +4,18 @@ let react = require("react");
4
4
  react = require_runtime.__toESM(react);
5
5
  //#region src/primitive/private/useElementOffset.ts
6
6
  var useLayoutEffect = typeof window !== "undefined" ? react.useLayoutEffect : react.useEffect;
7
+ function measure(element) {
8
+ const parent = element.offsetParent ?? document.body;
9
+ const { paddingLeft, paddingRight } = getComputedStyle(parent);
10
+ const fromParentLeft = element.offsetLeft + element.offsetWidth;
11
+ const fromParentRight = parent.clientWidth - element.offsetLeft;
12
+ return {
13
+ fromLeft: fromParentLeft - Number.parseFloat(paddingLeft),
14
+ fromRight: fromParentRight - Number.parseFloat(paddingRight),
15
+ fromParentLeft,
16
+ fromParentRight
17
+ };
18
+ }
7
19
  function useElementOffset(element) {
8
20
  const [offset, setOffset] = react.useState(void 0);
9
21
  useLayoutEffect(() => {
@@ -11,20 +23,10 @@ function useElementOffset(element) {
11
23
  setOffset(void 0);
12
24
  return;
13
25
  }
14
- if (element) {
15
- setOffset({
16
- fromLeft: element.offsetLeft + element.offsetWidth,
17
- fromRight: (element.offsetParent ?? document.body).clientWidth - element.offsetLeft
18
- });
19
- const resizeObserver = new ResizeObserver(() => {
20
- setOffset({
21
- fromLeft: element.offsetLeft + element.offsetWidth,
22
- fromRight: (element.offsetParent ?? document.body).clientWidth - element.offsetLeft
23
- });
24
- });
25
- resizeObserver.observe(element);
26
- return () => resizeObserver.unobserve(element);
27
- }
26
+ setOffset(measure(element));
27
+ const resizeObserver = new ResizeObserver(() => setOffset(measure(element)));
28
+ resizeObserver.observe(element);
29
+ return () => resizeObserver.disconnect();
28
30
  }, [element]);
29
31
  return offset;
30
32
  }
@@ -1,4 +1,6 @@
1
1
  export declare function useElementOffset(element: HTMLElement | null): {
2
2
  fromLeft: number;
3
3
  fromRight: number;
4
+ fromParentLeft: number;
5
+ fromParentRight: number;
4
6
  } | undefined;
@@ -2,6 +2,18 @@
2
2
  import * as React from "react";
3
3
  //#region src/primitive/private/useElementOffset.ts
4
4
  var useLayoutEffect$1 = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
5
+ function measure(element) {
6
+ const parent = element.offsetParent ?? document.body;
7
+ const { paddingLeft, paddingRight } = getComputedStyle(parent);
8
+ const fromParentLeft = element.offsetLeft + element.offsetWidth;
9
+ const fromParentRight = parent.clientWidth - element.offsetLeft;
10
+ return {
11
+ fromLeft: fromParentLeft - Number.parseFloat(paddingLeft),
12
+ fromRight: fromParentRight - Number.parseFloat(paddingRight),
13
+ fromParentLeft,
14
+ fromParentRight
15
+ };
16
+ }
5
17
  function useElementOffset(element) {
6
18
  const [offset, setOffset] = React.useState(void 0);
7
19
  useLayoutEffect$1(() => {
@@ -9,20 +21,10 @@ function useElementOffset(element) {
9
21
  setOffset(void 0);
10
22
  return;
11
23
  }
12
- if (element) {
13
- setOffset({
14
- fromLeft: element.offsetLeft + element.offsetWidth,
15
- fromRight: (element.offsetParent ?? document.body).clientWidth - element.offsetLeft
16
- });
17
- const resizeObserver = new ResizeObserver(() => {
18
- setOffset({
19
- fromLeft: element.offsetLeft + element.offsetWidth,
20
- fromRight: (element.offsetParent ?? document.body).clientWidth - element.offsetLeft
21
- });
22
- });
23
- resizeObserver.observe(element);
24
- return () => resizeObserver.unobserve(element);
25
- }
24
+ setOffset(measure(element));
25
+ const resizeObserver = new ResizeObserver(() => setOffset(measure(element)));
26
+ resizeObserver.observe(element);
27
+ return () => resizeObserver.disconnect();
26
28
  }, [element]);
27
29
  return offset;
28
30
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-design/stackflow",
3
- "version": "1.1.24",
3
+ "version": "1.1.26",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
@@ -36,7 +36,7 @@
36
36
  "clsx": "^2.1.1"
37
37
  },
38
38
  "devDependencies": {
39
- "@seed-design/css": "1.2.16",
39
+ "@seed-design/css": "1.2.20",
40
40
  "@stackflow/core": "^1.2.0",
41
41
  "@stackflow/react": "^1.5.1",
42
42
  "@types/react": "^19.2.2",
@@ -18,8 +18,14 @@ export function useAppBar(_props: UseAppBarProps) {
18
18
 
19
19
  const leftOffset = useElementOffset(left);
20
20
  const rightOffset = useElementOffset(right);
21
+ const extents = [leftOffset?.fromLeft, rightOffset?.fromRight].filter(
22
+ (extent) => extent !== undefined,
23
+ );
24
+ // `initial` leaves the variable unset, which the recipe reads as having no area to clear.
25
+ const areaExtent = extents.length > 0 ? `${Math.max(...extents)}px` : "initial";
26
+ // Read by `@seed-design/css` <= 1.2.19 in place of `--app-bar-area-extent`.
21
27
  const centeredTitlePaddingX = root
22
- ? `${Math.max(leftOffset?.fromLeft ?? 0, rightOffset?.fromRight ?? 0)}px`
28
+ ? `${Math.max(leftOffset?.fromParentLeft ?? 0, rightOffset?.fromParentRight ?? 0)}px`
23
29
  : "initial";
24
30
 
25
31
  return useMemo(
@@ -34,10 +40,11 @@ export function useAppBar(_props: UseAppBarProps) {
34
40
  "data-part": appBarAnatomy.root,
35
41
  ...stateProps,
36
42
  style: {
43
+ "--app-bar-area-extent": areaExtent,
37
44
  "--centered-title-padding-x": centeredTitlePaddingX,
38
45
  } as React.CSSProperties,
39
46
  }),
40
47
  }),
41
- [stateProps, centeredTitlePaddingX],
48
+ [stateProps, areaExtent, centeredTitlePaddingX],
42
49
  );
43
50
  }
@@ -0,0 +1,159 @@
1
+ import { aggregate, makeEvent, type DomainEvent, type Stack } from "@stackflow/core";
2
+ import { render } from "@testing-library/react";
3
+ import { beforeEach, describe, expect, it, mock } from "bun:test";
4
+ import { appBarAnatomy } from "../AppBar/anatomy";
5
+ import { appScreenAnatomy } from "../AppScreen/anatomy";
6
+
7
+ // `useGlobalInteraction` (and the `useTopActivity` it calls) only consume
8
+ // `useStack` from @stackflow/react. Mocking just that lets the test feed real
9
+ // Stack snapshots — built by @stackflow/core's own reducer — without pulling in
10
+ // a renderer plugin. Registered before the dynamic import below so the hook
11
+ // picks it up.
12
+ let currentStack: Stack;
13
+ mock.module("@stackflow/react", () => ({ useStack: () => currentStack }));
14
+
15
+ const { useGlobalInteraction } = await import("./useGlobalInteraction");
16
+ const { findTransitionTargets, setIdlePositions } = await import("./dom");
17
+
18
+ // happy-dom ships no WAAPI. A `finished` that never settles models an
19
+ // animation that is still in flight, which is all these tests need.
20
+ Element.prototype.animate = (() => ({
21
+ finished: new Promise<void>(() => {}),
22
+ cancel() {},
23
+ })) as Element["animate"];
24
+
25
+ // ─── Real stack snapshots ───────────────────────────────────────────────────
26
+
27
+ const T0 = 1_000_000;
28
+ const TRANSITION_DURATION = 350;
29
+
30
+ /** Zero-padded so the reducer's lexicographic sort by id matches insertion order. */
31
+ function evt<T extends DomainEvent["name"]>(
32
+ seq: number,
33
+ name: T,
34
+ params: Omit<Extract<DomainEvent, { name: T }>, "id" | "name" | "eventDate">,
35
+ ) {
36
+ return makeEvent(name, {
37
+ ...params,
38
+ id: String(seq).padStart(4, "0"),
39
+ eventDate: T0 + seq,
40
+ });
41
+ }
42
+
43
+ const BASE_EVENTS: DomainEvent[] = [
44
+ evt(1, "Initialized", { transitionDuration: TRANSITION_DURATION }),
45
+ evt(2, "ActivityRegistered", { activityName: "Screen" }),
46
+ evt(3, "Pushed", { activityId: "a1", activityName: "Screen", activityParams: {} }),
47
+ evt(4, "Pushed", { activityId: "a2", activityName: "Screen", activityParams: {} }),
48
+ ];
49
+
50
+ /** Far enough past every eventDate that both pushes have settled. */
51
+ const SETTLED_AT = T0 + TRANSITION_DURATION * 10;
52
+
53
+ const POP_SEQ = 5;
54
+
55
+ function stackAfter(...extraEvents: DomainEvent[]): Stack {
56
+ return aggregate([...BASE_EVENTS, ...extraEvents], SETTLED_AT);
57
+ }
58
+
59
+ /** `pop({ animate: false })` — stackflow maps it to `skipExitActiveState`. */
60
+ const POP_WITHOUT_ANIMATION = evt(POP_SEQ, "Popped", { skipExitActiveState: true });
61
+
62
+ /** A plain animated `pop()`, still mid-flight at `SETTLED_AT`. */
63
+ const POP_WITH_ANIMATION = makeEvent("Popped", {
64
+ id: String(POP_SEQ).padStart(4, "0"),
65
+ eventDate: SETTLED_AT,
66
+ });
67
+
68
+ // ─── Harness ────────────────────────────────────────────────────────────────
69
+
70
+ function ActivityMarkup({ id, isTop }: { id: string; isTop: boolean }) {
71
+ return (
72
+ <section
73
+ data-part={appScreenAnatomy.activity}
74
+ data-activity-id={id}
75
+ data-transition-style="slideFromRightIOS"
76
+ {...(isTop ? { "data-activity-is-top": "" } : {})}
77
+ >
78
+ <div data-part={appScreenAnatomy.layer} data-testid={`${id}-layer`} />
79
+ <div data-part={appScreenAnatomy.dim} />
80
+ <div data-part={appBarAnatomy.root}>
81
+ <div data-part={appBarAnatomy.background} />
82
+ <div data-part={appBarAnatomy.main} data-testid={`${id}-title`} />
83
+ <div data-part={appBarAnatomy.icon} data-testid={`${id}-icon`} />
84
+ </div>
85
+ </section>
86
+ );
87
+ }
88
+
89
+ function Harness() {
90
+ const { stackRef } = useGlobalInteraction();
91
+
92
+ // Mirrors the core's `visibleActivities` filter — exit-done is unmounted.
93
+ const visible = currentStack.activities.filter((a) => a.transitionState !== "exit-done");
94
+
95
+ return (
96
+ <div ref={stackRef} data-testid="stack">
97
+ {visible.map((activity) => (
98
+ <ActivityMarkup key={activity.id} id={activity.id} isTop={activity.isTop} />
99
+ ))}
100
+ </div>
101
+ );
102
+ }
103
+
104
+ // ─── Tests ──────────────────────────────────────────────────────────────────
105
+
106
+ describe("useGlobalInteraction — settle safety-net", () => {
107
+ beforeEach(() => {
108
+ currentStack = stackAfter();
109
+ });
110
+
111
+ it("leaves the behind layer pinned at its idle offset while a2 is on top", () => {
112
+ // Premise check: a settled push is idle, with a2 on top.
113
+ expect(currentStack.globalTransitionState).toBe("idle");
114
+ expect(currentStack.activities.find((a) => a.isTop)?.id).toBe("a2");
115
+
116
+ const { getByTestId, rerender } = render(<Harness />);
117
+
118
+ // Exactly what the push's finished handler leaves behind.
119
+ setIdlePositions(findTransitionTargets(getByTestId("stack")), "slideFromRightIOS");
120
+ rerender(<Harness />);
121
+
122
+ expect(getByTestId("a1-layer").style.transform).toBe("translate3d(-30%, 0, 0)");
123
+ });
124
+
125
+ it("clears the landing activity when pop({ animate: false }) skips exit-active", () => {
126
+ const { getByTestId, rerender } = render(<Harness />);
127
+ setIdlePositions(findTransitionTargets(getByTestId("stack")), "slideFromRightIOS");
128
+
129
+ currentStack = stackAfter(POP_WITHOUT_ANIMATION);
130
+
131
+ // Premise: the transition state machine never moves — a1 just becomes top.
132
+ expect(currentStack.globalTransitionState).toBe("idle");
133
+ expect(currentStack.activities.find((a) => a.isTop)?.id).toBe("a1");
134
+
135
+ rerender(<Harness />);
136
+
137
+ expect(getByTestId("a1-layer").style.transform).toBe("");
138
+ expect(getByTestId("a1-title").style.transform).toBe("");
139
+ expect(getByTestId("a1-title").style.opacity).toBe("");
140
+ expect(getByTestId("a1-icon").style.opacity).toBe("");
141
+ });
142
+
143
+ it("does not touch inline styles while a transition is still in flight", () => {
144
+ const { getByTestId, rerender } = render(<Harness />);
145
+ setIdlePositions(findTransitionTargets(getByTestId("stack")), "slideFromRightIOS");
146
+
147
+ currentStack = stackAfter(POP_WITH_ANIMATION);
148
+
149
+ // Premise: an animated pop parks a2 in exit-active, so the stack is loading.
150
+ expect(currentStack.globalTransitionState).toBe("loading");
151
+ expect(currentStack.activities.find((a) => a.id === "a2")?.transitionState).toBe("exit-active");
152
+
153
+ rerender(<Harness />);
154
+
155
+ // The WAAPI pop animation owns the unwind from here — the safety-net must
156
+ // stay out of the way until everything settles.
157
+ expect(getByTestId("a1-layer").style.transform).toBe("translate3d(-30%, 0, 0)");
158
+ });
159
+ });
@@ -341,13 +341,22 @@ export function useGlobalInteraction() {
341
341
  // top must always be in a clean default state. Wipe all leftover inline
342
342
  // styles from the top activity — the behind is left untouched (stays at
343
343
  // -30%), this only runs at idle, and it's a no-op if already clean.
344
+ //
345
+ // Keyed on the top activity too, not just globalTransitionState: an
346
+ // `animate: false` navigation makes the core skip the enter-active /
347
+ // exit-active phase entirely, so globalTransitionState never leaves "idle"
348
+ // and a state-only dependency would never re-run. That is how a behind
349
+ // layer became the top while still pinned at -30%.
350
+ const topActivityId = stack?.activities.find((activity) => activity.isTop)?.id;
351
+
352
+ // biome-ignore lint/correctness/useExhaustiveDependencies: topActivityId is a trigger, not a value read here — clearTopActivityStyles re-queries the DOM
344
353
  useLayoutEffect(() => {
345
354
  if (stack?.globalTransitionState !== "idle") return;
346
355
  const stackEl = stackRef.current;
347
356
  if (stackEl) {
348
357
  clearTopActivityStyles(stackEl);
349
358
  }
350
- }, [stack?.globalTransitionState]);
359
+ }, [stack?.globalTransitionState, topActivityId]);
351
360
 
352
361
  // Cancel any pending push rAF and running animations on unmount so
353
362
  // late-firing finished handlers can't run against a torn-down stack.
@@ -2,10 +2,27 @@ import * as React from "react";
2
2
 
3
3
  const useLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
4
4
 
5
+ // `fromLeft`/`fromRight` are measured from the offset parent's content edges: the parent's padding
6
+ // carries the safe-area insets, which can move to the other side (a 180° rotation) without resizing
7
+ // anything, so a value that included the padding would go stale with no resize to trigger a
8
+ // re-measure. The padding-edge pair is what `@seed-design/css` <= 1.2.19 reads, where the parent's
9
+ // padding holds no inset.
10
+ function measure(element: HTMLElement) {
11
+ const parent = element.offsetParent ?? document.body;
12
+ const { paddingLeft, paddingRight } = getComputedStyle(parent);
13
+ const fromParentLeft = element.offsetLeft + element.offsetWidth;
14
+ const fromParentRight = parent.clientWidth - element.offsetLeft;
15
+
16
+ return {
17
+ fromLeft: fromParentLeft - Number.parseFloat(paddingLeft),
18
+ fromRight: fromParentRight - Number.parseFloat(paddingRight),
19
+ fromParentLeft,
20
+ fromParentRight,
21
+ };
22
+ }
23
+
5
24
  export function useElementOffset(element: HTMLElement | null) {
6
- const [offset, setOffset] = React.useState<{ fromLeft: number; fromRight: number } | undefined>(
7
- undefined,
8
- );
25
+ const [offset, setOffset] = React.useState<ReturnType<typeof measure> | undefined>(undefined);
9
26
 
10
27
  useLayoutEffect(() => {
11
28
  if (!element) {
@@ -13,24 +30,13 @@ export function useElementOffset(element: HTMLElement | null) {
13
30
  return;
14
31
  }
15
32
 
16
- if (element) {
17
- // provide as early as possible
18
- setOffset({
19
- fromLeft: element.offsetLeft + element.offsetWidth,
20
- fromRight: (element.offsetParent ?? document.body).clientWidth - element.offsetLeft,
21
- });
22
-
23
- const resizeObserver = new ResizeObserver(() => {
24
- const fromLeft = element.offsetLeft + element.offsetWidth;
25
- const fromRight = (element.offsetParent ?? document.body).clientWidth - element.offsetLeft;
33
+ // provide as early as possible
34
+ setOffset(measure(element));
26
35
 
27
- setOffset({ fromLeft, fromRight });
28
- });
36
+ const resizeObserver = new ResizeObserver(() => setOffset(measure(element)));
37
+ resizeObserver.observe(element);
29
38
 
30
- resizeObserver.observe(element);
31
-
32
- return () => resizeObserver.unobserve(element);
33
- }
39
+ return () => resizeObserver.disconnect();
34
40
  }, [element]);
35
41
 
36
42
  return offset;