@multiplatform.one/components 7.10.0 → 7.11.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.
Files changed (65) hide show
  1. package/README.md +98 -0
  2. package/dist/cjs/SheetModal/index.cjs +22 -3
  3. package/dist/cjs/SheetModal/index.native.js +50 -10
  4. package/dist/cjs/SheetModal/index.native.js.map +1 -1
  5. package/dist/cjs/Tabs/index.cjs +33 -12
  6. package/dist/cjs/Tabs/index.native.js +37 -12
  7. package/dist/cjs/Tabs/index.native.js.map +1 -1
  8. package/dist/cjs/layouts/tags.native.js.map +1 -1
  9. package/dist/cjs/views/TreeView.cjs +175 -43
  10. package/dist/cjs/views/TreeView.native.js +181 -46
  11. package/dist/cjs/views/TreeView.native.js.map +1 -1
  12. package/dist/esm/SheetModal/index.mjs +22 -3
  13. package/dist/esm/SheetModal/index.mjs.map +1 -1
  14. package/dist/esm/SheetModal/index.native.js +51 -11
  15. package/dist/esm/SheetModal/index.native.js.map +1 -1
  16. package/dist/esm/Tabs/index.mjs +33 -12
  17. package/dist/esm/Tabs/index.mjs.map +1 -1
  18. package/dist/esm/Tabs/index.native.js +37 -12
  19. package/dist/esm/Tabs/index.native.js.map +1 -1
  20. package/dist/esm/layouts/tags.mjs.map +1 -1
  21. package/dist/esm/layouts/tags.native.js.map +1 -1
  22. package/dist/esm/views/TreeView.mjs +175 -43
  23. package/dist/esm/views/TreeView.mjs.map +1 -1
  24. package/dist/esm/views/TreeView.native.js +181 -46
  25. package/dist/esm/views/TreeView.native.js.map +1 -1
  26. package/dist/jsx/SheetModal/index.mjs +22 -3
  27. package/dist/jsx/SheetModal/index.mjs.map +1 -1
  28. package/dist/jsx/SheetModal/index.native.js +50 -10
  29. package/dist/jsx/SheetModal/index.native.js.map +1 -1
  30. package/dist/jsx/Tabs/index.mjs +33 -12
  31. package/dist/jsx/Tabs/index.mjs.map +1 -1
  32. package/dist/jsx/Tabs/index.native.js +37 -12
  33. package/dist/jsx/Tabs/index.native.js.map +1 -1
  34. package/dist/jsx/layouts/tags.mjs.map +1 -1
  35. package/dist/jsx/layouts/tags.native.js.map +1 -1
  36. package/dist/jsx/views/TreeView.mjs +175 -43
  37. package/dist/jsx/views/TreeView.mjs.map +1 -1
  38. package/dist/jsx/views/TreeView.native.js +181 -46
  39. package/dist/jsx/views/TreeView.native.js.map +1 -1
  40. package/package.json +11 -11
  41. package/src/Accordion/index.tsx +1 -1
  42. package/src/SheetModal/SheetModal.fill.spec.tsx +147 -0
  43. package/src/SheetModal/SheetModal.ownedBody.spec.tsx +200 -0
  44. package/src/SheetModal/SheetModal.stories.spec.tsx +23 -2
  45. package/src/SheetModal/SheetModal.stories.tsx +275 -2
  46. package/src/SheetModal/index.native.tsx +50 -6
  47. package/src/SheetModal/index.tsx +40 -3
  48. package/src/Tabs/Tabs.lifecycle.spec.tsx +123 -0
  49. package/src/Tabs/Tabs.measurement.spec.tsx +186 -0
  50. package/src/Tabs/Tabs.stories.tsx +43 -0
  51. package/src/Tabs/index.tsx +42 -13
  52. package/src/layouts/tags.tsx +2 -1
  53. package/src/views/TreeView.reveal.spec.tsx +7 -0
  54. package/src/views/TreeView.reveal.stories.tsx +134 -0
  55. package/src/views/TreeView.scroll-index.spec.tsx +246 -0
  56. package/src/views/TreeView.scroll.spec.tsx +711 -50
  57. package/src/views/TreeView.tsx +230 -49
  58. package/types/SheetModal/index.d.ts +8 -1
  59. package/types/SheetModal/index.d.ts.map +1 -1
  60. package/types/SheetModal/index.native.d.ts +1 -1
  61. package/types/SheetModal/index.native.d.ts.map +1 -1
  62. package/types/Tabs/index.d.ts.map +1 -1
  63. package/types/layouts/tags.d.ts.map +1 -1
  64. package/types/views/TreeView.d.ts +10 -1
  65. package/types/views/TreeView.d.ts.map +1 -1
@@ -0,0 +1,186 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { act, cleanup, screen } from "@testing-library/react";
3
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
4
+ import { Tabs, tabsScrollIntoViewX } from "./index";
5
+
6
+ const observed = vi.hoisted(() => ({ scroll: null as any, interactions: new Map<string, any>() }));
7
+
8
+ // The actual component and primitives render. Only public measurement events
9
+ // and the imperative scroll boundary are controlled; this is not browser proof.
10
+ vi.mock("tamagui", async (importOriginal) => {
11
+ const actual = await importOriginal<any>();
12
+ const React = await import("react");
13
+ return {
14
+ ...actual,
15
+ ScrollView: React.forwardRef((props: any, ref) => {
16
+ observed.scroll = props;
17
+ return React.createElement(actual.ScrollView, { ...props, ref });
18
+ }),
19
+ styled: (...args: any[]) => {
20
+ const Component = actual.styled(...args);
21
+ if (args[0] !== actual.Tabs.Tab) return Component;
22
+ return React.forwardRef((props: any, ref) => {
23
+ observed.interactions.set(props.value, props.onInteraction);
24
+ return React.createElement(Component, { ...props, ref });
25
+ });
26
+ },
27
+ };
28
+ });
29
+
30
+ const items = Array.from({ length: 6 }, (_, index) => ({
31
+ value: `tab-${index}`,
32
+ label: `Tab ${index + 1}`,
33
+ }));
34
+ const last = { x: 500, y: 0, width: 100, height: 40 };
35
+
36
+ afterEach(() => {
37
+ cleanup();
38
+ vi.restoreAllMocks();
39
+ observed.scroll = null;
40
+ observed.interactions.clear();
41
+ document.documentElement.removeAttribute("dir");
42
+ });
43
+
44
+ function fixture(mode: "value" | "defaultValue" = "value") {
45
+ const rendered = renderWithProviders(<Tabs items={items} {...{ [mode]: "tab-5" }} />);
46
+ expect(screen.getByRole("tab", { name: "Tab 6" })).toHaveAttribute("aria-selected", "true");
47
+ const host = rendered.container.querySelector('[data-tabs-scroll="true"]') as any;
48
+ expect(typeof host.scrollTo).toBe("function");
49
+ const scroll = vi.spyOn(host, "scrollTo").mockImplementation(() => {});
50
+ const viewport = (width: number) =>
51
+ act(() => observed.scroll.onLayout({ nativeEvent: { layout: { width } } }));
52
+ const content = (width: number) => act(() => observed.scroll.onContentSizeChange(width, 40));
53
+ const active = (layout = last) => act(() => observed.interactions.get("tab-5")("select", layout));
54
+ const manualScroll = (x: number) =>
55
+ act(() => observed.scroll.onScroll({ nativeEvent: { contentOffset: { x } } }));
56
+ return { ...rendered, scroll, viewport, content, active, manualScroll };
57
+ }
58
+
59
+ describe.each(["value", "defaultValue"] as const)(
60
+ "Tabs public measurement ordering (%s)",
61
+ (mode) => {
62
+ it("reveals initial last tab when metrics arrive before its layout (control)", () => {
63
+ const tree = fixture(mode);
64
+ tree.viewport(390);
65
+ tree.content(600);
66
+ tree.active();
67
+ expect(tree.scroll).toHaveBeenCalledWith({ x: 210, animated: true });
68
+ });
69
+
70
+ it("reveals initial last tab when content arrives after its layout", () => {
71
+ const tree = fixture(mode);
72
+ tree.viewport(390);
73
+ tree.active();
74
+ expect(tree.scroll).not.toHaveBeenCalled();
75
+ tree.content(600);
76
+ expect(tree.scroll).toHaveBeenCalled();
77
+ });
78
+
79
+ it("corrects after a viewport shrink with unchanged active-tab bounds", () => {
80
+ const tree = fixture(mode);
81
+ tree.viewport(800);
82
+ tree.content(600);
83
+ tree.active();
84
+ expect(tree.scroll).not.toHaveBeenCalled();
85
+ tree.viewport(390);
86
+ expect(tree.scroll).toHaveBeenCalled();
87
+ });
88
+
89
+ it("does not force a manual scroll back after the active tab was revealed", () => {
90
+ const tree = fixture(mode);
91
+ tree.viewport(390);
92
+ tree.content(600);
93
+ tree.active();
94
+ expect(tree.scroll).toHaveBeenCalledWith({ x: 210, animated: true });
95
+ tree.scroll.mockClear();
96
+ tree.manualScroll(0);
97
+ expect(tree.scroll).not.toHaveBeenCalled();
98
+ });
99
+ },
100
+ );
101
+
102
+ it.each(["value", "defaultValue"] as const)(
103
+ "reveals after late viewport measurement (%s)",
104
+ (mode) => {
105
+ const tree = fixture(mode);
106
+ tree.content(600);
107
+ tree.active();
108
+ expect(tree.scroll).not.toHaveBeenCalled();
109
+ tree.viewport(390);
110
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ x: 210, animated: true });
111
+ },
112
+ );
113
+
114
+ it("retries after a hidden viewport becomes usable and ignores identical measurements", () => {
115
+ const tree = fixture();
116
+ tree.viewport(0);
117
+ tree.content(600);
118
+ tree.active();
119
+ expect(tree.scroll).not.toHaveBeenCalled();
120
+ tree.viewport(390);
121
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ x: 210, animated: true });
122
+ tree.manualScroll(210);
123
+ tree.scroll.mockClear();
124
+ tree.viewport(390);
125
+ tree.content(600);
126
+ tree.active();
127
+ expect(tree.scroll).not.toHaveBeenCalled();
128
+ tree.viewport(800);
129
+ expect(tree.scroll).not.toHaveBeenCalled();
130
+ });
131
+
132
+ it("rechecks changed content width without fighting subsequent manual scroll", () => {
133
+ const tree = fixture();
134
+ tree.viewport(390);
135
+ tree.content(390);
136
+ tree.active();
137
+ expect(tree.scroll).not.toHaveBeenCalled();
138
+ tree.content(600);
139
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ x: 210, animated: true });
140
+ tree.manualScroll(210);
141
+ tree.scroll.mockClear();
142
+ tree.content(620);
143
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ x: 230, animated: true });
144
+ tree.manualScroll(230);
145
+ tree.scroll.mockClear();
146
+ tree.manualScroll(0);
147
+ tree.active();
148
+ tree.viewport(390);
149
+ tree.content(620);
150
+ expect(tree.scroll).not.toHaveBeenCalled();
151
+ tree.content(650);
152
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ x: 242, animated: true });
153
+ });
154
+
155
+ it("waits for the latest selected tab's layout when dimensions were pending", () => {
156
+ const tree = fixture();
157
+ tree.viewport(390);
158
+ tree.active();
159
+ tree.rerender(<Tabs items={items} value="tab-0" />);
160
+ tree.content(600);
161
+ expect(tree.scroll).not.toHaveBeenCalled();
162
+ act(() => observed.interactions.get("tab-0")("select", { ...last, x: 0 }));
163
+ expect(tree.scroll).not.toHaveBeenCalled();
164
+ expect(screen.getByRole("tab", { name: "Tab 1" })).toHaveAttribute("aria-selected", "true");
165
+ });
166
+
167
+ it("uses the negative RTL offset range after delayed dimensions", () => {
168
+ document.documentElement.setAttribute("dir", "rtl");
169
+ const tree = fixture();
170
+ tree.viewport(390);
171
+ tree.active({ ...last, x: 0 });
172
+ tree.content(600);
173
+ expect(tree.scroll).toHaveBeenCalledExactlyOnceWith({ x: -210, animated: true });
174
+ tree.manualScroll(-210);
175
+ tree.scroll.mockClear();
176
+ tree.manualScroll(0);
177
+ expect(tree.scroll).not.toHaveBeenCalled();
178
+ });
179
+
180
+ it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
181
+ "does not scroll with unusable width %s",
182
+ (width) => {
183
+ expect(tabsScrollIntoViewX(last, 0, width, 600, "ltr")).toBeUndefined();
184
+ expect(tabsScrollIntoViewX({ ...last, width }, 0, 390, 600, "ltr")).toBeUndefined();
185
+ },
186
+ );
@@ -362,3 +362,46 @@ export const EqualSmall: Story = {
362
362
  </YStack>
363
363
  ),
364
364
  };
365
+
366
+ export const InitialSixthActive: Story = {
367
+ args: { defaultValue: "section-6", ariaLabel: "Example sections" },
368
+ render: (args) => {
369
+ const [wide, setWide] = React.useState(false);
370
+ const [fullLabels, setFullLabels] = React.useState(true);
371
+ const [hidden, setHidden] = React.useState(false);
372
+ const [mount, setMount] = React.useState(0);
373
+ const items: TabsItem[] = Array.from({ length: 6 }, (_, index) => ({
374
+ value: `section-${index + 1}`,
375
+ label: fullLabels ? `Section ${index + 1} overview` : `${index + 1}`,
376
+ content: (
377
+ <Panel
378
+ title={`Section ${index + 1}`}
379
+ body="The selected section stays visible when the strip is measured or resized."
380
+ />
381
+ ),
382
+ }));
383
+ return (
384
+ <YStack gap="$3">
385
+ <XStack gap="$2" flexWrap="wrap">
386
+ <Button onPress={() => setWide((value) => !value)}>
387
+ {wide ? "Set width to 390px" : "Set width to 800px"}
388
+ </Button>
389
+ <Button onPress={() => setFullLabels((value) => !value)}>
390
+ {fullLabels ? "Use short labels" : "Load full labels"}
391
+ </Button>
392
+ <Button onPress={() => setHidden((value) => !value)}>
393
+ {hidden ? "Show strip" : "Hide strip"}
394
+ </Button>
395
+ <Button onPress={() => setMount((value) => value + 1)}>Reset to sixth tab</Button>
396
+ </XStack>
397
+ <Text color="$color11">
398
+ Use short labels, then load full labels to change content width. Hide and show the strip
399
+ to defer usable measurements. Scroll manually after reveal to check that it stays put.
400
+ </Text>
401
+ <YStack width={wide ? 800 : 390} maxWidth="100%" display={hidden ? "none" : "flex"}>
402
+ <Tabs key={mount} {...args} items={items} />
403
+ </YStack>
404
+ </YStack>
405
+ );
406
+ },
407
+ };
@@ -174,6 +174,14 @@ export function tabsScrollIntoViewX(
174
174
  direction: TabsDirection,
175
175
  pad = 32,
176
176
  ): number | undefined {
177
+ if (
178
+ ![layout.x, layout.width, layout.height, offset, viewport, content].every(Number.isFinite) ||
179
+ layout.width <= 0 ||
180
+ layout.height <= 0 ||
181
+ viewport <= 0 ||
182
+ content <= 0
183
+ )
184
+ return undefined;
177
185
  const range = Math.max(content - viewport, 0);
178
186
  if (range <= 0) return undefined;
179
187
  const minScrollLeft = direction === "rtl" ? -range : 0;
@@ -366,23 +374,28 @@ function TabsRoot({
366
374
  };
367
375
 
368
376
  // Active tab layout (relative to the list) for the sliding indicator
369
- const [activeLayout, setActiveLayout] = React.useState<TabLayout | null>(null);
370
- const handleInteraction = (type: string, layout: TabLayout | null) => {
371
- if (type !== "select" || !layout) return;
377
+ const [activeLayout, setActiveLayout] = React.useState<(TabLayout & { value: string }) | null>(
378
+ null,
379
+ );
380
+ const handleInteraction = (tabValue: string, type: string, layout: TabLayout | null) => {
381
+ if (type !== "select" || !layout || tabValue !== currentValue) return;
372
382
  setActiveLayout((prev) =>
373
383
  prev &&
384
+ prev.value === tabValue &&
374
385
  prev.x === layout.x &&
375
386
  prev.y === layout.y &&
376
387
  prev.width === layout.width &&
377
388
  prev.height === layout.height
378
389
  ? prev
379
- : layout,
390
+ : { ...layout, value: tabValue },
380
391
  );
381
392
  };
382
393
 
383
394
  // Overflow tracking for edge fades + keep-active-visible scrolling
384
395
  const scrollRef = React.useRef<ScrollView>(null);
396
+ const tabRefs = React.useRef(new Map<string, HTMLElement>());
385
397
  const scrollMetrics = React.useRef({ offset: 0, viewport: 0, content: 0 });
398
+ const [sizeRevision, setSizeRevision] = React.useState(0);
386
399
  const [fades, setFades] = React.useState({ left: false, right: false });
387
400
  const updateFades = () => {
388
401
  const { offset, viewport, content } = scrollMetrics.current;
@@ -390,20 +403,38 @@ function TabsRoot({
390
403
  setFades((prev) => (prev.left === next.left && prev.right === next.right ? prev : next));
391
404
  };
392
405
 
406
+ const updateScrollSize = (dimension: "viewport" | "content", size: number) => {
407
+ if (!Object.is(scrollMetrics.current[dimension], size)) {
408
+ scrollMetrics.current[dimension] = size;
409
+ if (isWeb) {
410
+ const selected = tabRefs.current.get(currentValue);
411
+ if (selected && selected.offsetWidth > 0 && selected.offsetHeight > 0) {
412
+ handleInteraction(currentValue, "select", {
413
+ x: selected.offsetLeft,
414
+ y: selected.offsetTop,
415
+ width: selected.offsetWidth,
416
+ height: selected.offsetHeight,
417
+ });
418
+ }
419
+ }
420
+ setSizeRevision((current) => current + 1);
421
+ }
422
+ updateFades();
423
+ };
424
+
393
425
  // Scroll the newly active tab into view when it is clipped
394
426
  React.useEffect(() => {
395
- if (!activeLayout) return;
427
+ if (!activeLayout || activeLayout.value !== currentValue) return;
396
428
  const { offset, viewport, content } = scrollMetrics.current;
397
429
  const target = tabsScrollIntoViewX(activeLayout, offset, viewport, content, direction);
398
- if (target !== undefined) {
430
+ if (target !== undefined && Math.abs(target - offset) >= 1) {
399
431
  scrollRef.current?.scrollTo({ x: target, animated: true });
400
432
  }
401
- }, [activeLayout, direction]);
433
+ }, [activeLayout, currentValue, direction, sizeRevision]);
402
434
 
403
435
  const isUnderline = variant === "underline";
404
436
  const isBand = variant === "band";
405
437
  const equalDistribution = isBand || distribution === "equal";
406
- const tabRefs = React.useRef(new Map<string, HTMLElement>());
407
438
  const onTabKeyDown = (event: React.KeyboardEvent, value: string) => {
408
439
  if (!["Home", "End", "ArrowLeft", "ArrowRight"].includes(event.key)) return;
409
440
  const enabled = items.filter((item) => !disabled && !item.disabled);
@@ -576,7 +607,7 @@ function TabsRoot({
576
607
  accessibilityState: { selected: isActive, disabled: Boolean(isDisabled) },
577
608
  })}
578
609
  hitSlop={tabHitSlop}
579
- onInteraction={handleInteraction}
610
+ onInteraction={(type, layout) => handleInteraction(item.value, type, layout)}
580
611
  cursor={isDisabled ? "not-allowed" : "pointer"}
581
612
  backgroundColor={
582
613
  isBand
@@ -716,12 +747,10 @@ function TabsRoot({
716
747
  updateFades();
717
748
  }}
718
749
  onLayout={(e) => {
719
- scrollMetrics.current.viewport = e.nativeEvent.layout.width;
720
- updateFades();
750
+ updateScrollSize("viewport", e.nativeEvent.layout.width);
721
751
  }}
722
752
  onContentSizeChange={(w) => {
723
- scrollMetrics.current.content = w;
724
- updateFades();
753
+ updateScrollSize("content", w);
725
754
  }}
726
755
  >
727
756
  {tabList}
@@ -20,6 +20,7 @@ import {
20
20
  XStack,
21
21
  YStack,
22
22
  isWeb,
23
+ type InputProps,
23
24
  type YStackProps,
24
25
  } from "tamagui";
25
26
  import {
@@ -622,7 +623,7 @@ export function Tags({
622
623
  // sat off the chips' optical line.
623
624
  fontSize={chipTextSize(chipSize)}
624
625
  color={componentColors.text.primary}
625
- placeholderTextColor={knobProps.textAccentColor as any}
626
+ placeholderTextColor={knobProps.textAccentColor as InputProps["placeholderTextColor"]}
626
627
  autoComplete="off"
627
628
  role="combobox"
628
629
  aria-autocomplete="list"
@@ -27,6 +27,13 @@ describe("TreeView public expansion contract", () => {
27
27
  expectTypeOf<TreeViewProps["revealRequest"]>().toEqualTypeOf<
28
28
  { id: string; requestId: number } | undefined
29
29
  >();
30
+ expectTypeOf<TreeViewProps["onRevealComplete"]>().toEqualTypeOf<
31
+ ((request: { id: string; requestId: number }) => void) | undefined
32
+ >();
33
+ expectTypeOf<TreeViewProps["initialScrollOffset"]>().toEqualTypeOf<number | undefined>();
34
+ expectTypeOf<TreeViewProps["onScrollOffsetChange"]>().toEqualTypeOf<
35
+ ((offset: number) => void) | undefined
36
+ >();
30
37
  expectTypeOf<TreeViewProps["expandedIds"]>().toEqualTypeOf<string[] | undefined>();
31
38
  expectTypeOf<TreeViewProps["onExpandedIdsChange"]>().toEqualTypeOf<
32
39
  ((ids: string[]) => void) | undefined
@@ -36,6 +36,10 @@ function RevealFixture(args: TreeViewProps) {
36
36
  const [expandedIds, setExpandedIds] = useState<string[]>(["other"]);
37
37
  const [selectedId, setSelectedId] = useState<string>();
38
38
  const [revealRequest, setRevealRequest] = useState<TreeViewProps["revealRequest"]>();
39
+ const [completion, setCompletion] = useState<{
40
+ request: NonNullable<TreeViewProps["revealRequest"]>;
41
+ count: number;
42
+ }>();
39
43
  const [deferExpansion, setDeferExpansion] = useState(false);
40
44
  const [pendingExpansion, setPendingExpansion] = useState<string[]>();
41
45
  const [hidden, setHidden] = useState(false);
@@ -77,6 +81,11 @@ function RevealFixture(args: TreeViewProps) {
77
81
  <Text>
78
82
  Target: {revealRequest?.id ?? "none"}. Expanded: {expandedIds.join(", ")}
79
83
  </Text>
84
+ <Text>
85
+ Measured completion:{" "}
86
+ {completion ? `${completion.request.id} (${completion.request.requestId})` : "none"}.
87
+ Callback count: {completion?.count ?? 0}
88
+ </Text>
80
89
  <YStack display={hidden ? "none" : "flex"}>
81
90
  <TreeView
82
91
  {...args}
@@ -88,6 +97,10 @@ function RevealFixture(args: TreeViewProps) {
88
97
  }}
89
98
  selectedId={selectedId}
90
99
  revealRequest={revealRequest}
100
+ onRevealComplete={(request) => {
101
+ setCompletion((previous) => ({ request, count: (previous?.count ?? 0) + 1 }));
102
+ args.onRevealComplete?.(request);
103
+ }}
91
104
  onNodeSelect={(node) => setSelectedId(node.id)}
92
105
  renderNode={(node) => {
93
106
  const index = Number(node.id.replace("item-", ""));
@@ -113,3 +126,124 @@ export const ControlledReveal: Story = {
113
126
  name: "Controlled reveal with 500 variable-height items",
114
127
  render: (args) => <RevealFixture {...args} />,
115
128
  };
129
+
130
+ function RetainedScrollFixture(args: TreeViewProps) {
131
+ const [mounted, setMounted] = useState(true);
132
+ const [generation, setGeneration] = useState(0);
133
+ const [nodes, setNodes] = useState(folders);
134
+ const [hidden, setHidden] = useState(false);
135
+ const [taller, setTaller] = useState(false);
136
+ const [savedOffset, setSavedOffset] = useState<number>();
137
+ const [observations, setObservations] = useState(0);
138
+ const [expandedIds, setExpandedIds] = useState(["folders", "archive", "other"]);
139
+ const [revealRequest, setRevealRequest] = useState<TreeViewProps["revealRequest"]>();
140
+ const [completion, setCompletion] = useState<string>();
141
+ const sequence = useRef(0);
142
+ const revealLast = () => setRevealRequest({ id: "item-499", requestId: ++sequence.current });
143
+ return (
144
+ <YStack gap="$3" width="100%" maxWidth={700}>
145
+ <Text>
146
+ Scroll manually, unmount, then mount. The observed offset and expansion survive outside the
147
+ tree. Remount with delayed data or a hidden host to check deferred restoration.
148
+ </Text>
149
+ <XStack gap="$2" flexWrap="wrap">
150
+ <Button onPress={() => setMounted((value) => !value)}>
151
+ {mounted ? "Unmount tree" : "Mount tree"}
152
+ </Button>
153
+ <Button
154
+ onPress={() => {
155
+ setNodes([]);
156
+ setMounted(true);
157
+ setGeneration((value) => value + 1);
158
+ }}
159
+ >
160
+ Remount without data
161
+ </Button>
162
+ <Button onPress={() => setNodes(folders)}>Deliver data</Button>
163
+ <Button
164
+ onPress={() => {
165
+ setHidden(true);
166
+ setMounted(true);
167
+ setGeneration((value) => value + 1);
168
+ }}
169
+ >
170
+ Remount hidden
171
+ </Button>
172
+ <Button onPress={() => setHidden((value) => !value)}>
173
+ {hidden ? "Show tree" : "Hide tree"}
174
+ </Button>
175
+ </XStack>
176
+ <XStack gap="$2" flexWrap="wrap">
177
+ <Button onPress={revealLast}>Reveal last item</Button>
178
+ <Button
179
+ onPress={() => {
180
+ revealLast();
181
+ setMounted(true);
182
+ setGeneration((value) => value + 1);
183
+ }}
184
+ >
185
+ Remount with reveal
186
+ </Button>
187
+ <Button onPress={() => setTaller((value) => !value)}>
188
+ {taller ? "Use configured height" : "Double viewport height"}
189
+ </Button>
190
+ </XStack>
191
+ <Text>
192
+ Retained observed offset: {savedOffset ?? "none"}. Observations: {observations}.
193
+ </Text>
194
+ <Text>
195
+ Initial fallback: {args.initialScrollOffset ?? "none"}. Mounted: {String(mounted)}.
196
+ </Text>
197
+ <Text>
198
+ Pending reveal: {revealRequest?.requestId ?? "none"}. Measured completion:{" "}
199
+ {completion ?? "none"}.
200
+ </Text>
201
+ <YStack display={hidden ? "none" : "flex"}>
202
+ {mounted && (
203
+ <TreeView
204
+ {...args}
205
+ key={generation}
206
+ nodes={nodes}
207
+ height={taller ? (args.height ?? 240) * 2 : args.height}
208
+ expandedIds={expandedIds}
209
+ onExpandedIdsChange={setExpandedIds}
210
+ initialScrollOffset={savedOffset ?? args.initialScrollOffset}
211
+ onScrollOffsetChange={(offset) => {
212
+ setSavedOffset(offset);
213
+ setObservations((value) => value + 1);
214
+ args.onScrollOffsetChange?.(offset);
215
+ }}
216
+ revealRequest={revealRequest}
217
+ onRevealComplete={(request) => {
218
+ setCompletion(`${request.id} (${request.requestId})`);
219
+ setRevealRequest((pending) =>
220
+ pending?.requestId === request.requestId ? undefined : pending,
221
+ );
222
+ args.onRevealComplete?.(request);
223
+ }}
224
+ renderNode={(node) => {
225
+ const index = Number(node.id.replace("item-", ""));
226
+ return (
227
+ <YStack
228
+ minHeight={Number.isFinite(index) && index % 2 ? 80 : 32}
229
+ justifyContent="center"
230
+ >
231
+ <Text>{node.label}</Text>
232
+ {Number.isFinite(index) && index % 2 === 1 && (
233
+ <Text fontSize="$2">Additional detail</Text>
234
+ )}
235
+ </YStack>
236
+ );
237
+ }}
238
+ />
239
+ )}
240
+ </YStack>
241
+ </YStack>
242
+ );
243
+ }
244
+
245
+ export const RetainedScroll: Story = {
246
+ name: "Retained scroll across unmount and delayed layout",
247
+ args: { initialScrollOffset: 640.5, ariaLabel: "Retained scroll fixture" },
248
+ render: (args) => <RetainedScrollFixture {...args} />,
249
+ };