@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.
- package/README.md +98 -0
- package/dist/cjs/SheetModal/index.cjs +22 -3
- package/dist/cjs/SheetModal/index.native.js +50 -10
- package/dist/cjs/SheetModal/index.native.js.map +1 -1
- package/dist/cjs/Tabs/index.cjs +33 -12
- package/dist/cjs/Tabs/index.native.js +37 -12
- package/dist/cjs/Tabs/index.native.js.map +1 -1
- package/dist/cjs/layouts/tags.native.js.map +1 -1
- package/dist/cjs/views/TreeView.cjs +175 -43
- package/dist/cjs/views/TreeView.native.js +181 -46
- package/dist/cjs/views/TreeView.native.js.map +1 -1
- package/dist/esm/SheetModal/index.mjs +22 -3
- package/dist/esm/SheetModal/index.mjs.map +1 -1
- package/dist/esm/SheetModal/index.native.js +51 -11
- package/dist/esm/SheetModal/index.native.js.map +1 -1
- package/dist/esm/Tabs/index.mjs +33 -12
- package/dist/esm/Tabs/index.mjs.map +1 -1
- package/dist/esm/Tabs/index.native.js +37 -12
- package/dist/esm/Tabs/index.native.js.map +1 -1
- package/dist/esm/layouts/tags.mjs.map +1 -1
- package/dist/esm/layouts/tags.native.js.map +1 -1
- package/dist/esm/views/TreeView.mjs +175 -43
- package/dist/esm/views/TreeView.mjs.map +1 -1
- package/dist/esm/views/TreeView.native.js +181 -46
- package/dist/esm/views/TreeView.native.js.map +1 -1
- package/dist/jsx/SheetModal/index.mjs +22 -3
- package/dist/jsx/SheetModal/index.mjs.map +1 -1
- package/dist/jsx/SheetModal/index.native.js +50 -10
- package/dist/jsx/SheetModal/index.native.js.map +1 -1
- package/dist/jsx/Tabs/index.mjs +33 -12
- package/dist/jsx/Tabs/index.mjs.map +1 -1
- package/dist/jsx/Tabs/index.native.js +37 -12
- package/dist/jsx/Tabs/index.native.js.map +1 -1
- package/dist/jsx/layouts/tags.mjs.map +1 -1
- package/dist/jsx/layouts/tags.native.js.map +1 -1
- package/dist/jsx/views/TreeView.mjs +175 -43
- package/dist/jsx/views/TreeView.mjs.map +1 -1
- package/dist/jsx/views/TreeView.native.js +181 -46
- package/dist/jsx/views/TreeView.native.js.map +1 -1
- package/package.json +11 -11
- package/src/Accordion/index.tsx +1 -1
- package/src/SheetModal/SheetModal.fill.spec.tsx +147 -0
- package/src/SheetModal/SheetModal.ownedBody.spec.tsx +200 -0
- package/src/SheetModal/SheetModal.stories.spec.tsx +23 -2
- package/src/SheetModal/SheetModal.stories.tsx +275 -2
- package/src/SheetModal/index.native.tsx +50 -6
- package/src/SheetModal/index.tsx +40 -3
- package/src/Tabs/Tabs.lifecycle.spec.tsx +123 -0
- package/src/Tabs/Tabs.measurement.spec.tsx +186 -0
- package/src/Tabs/Tabs.stories.tsx +43 -0
- package/src/Tabs/index.tsx +42 -13
- package/src/layouts/tags.tsx +2 -1
- package/src/views/TreeView.reveal.spec.tsx +7 -0
- package/src/views/TreeView.reveal.stories.tsx +134 -0
- package/src/views/TreeView.scroll-index.spec.tsx +246 -0
- package/src/views/TreeView.scroll.spec.tsx +711 -50
- package/src/views/TreeView.tsx +230 -49
- package/types/SheetModal/index.d.ts +8 -1
- package/types/SheetModal/index.d.ts.map +1 -1
- package/types/SheetModal/index.native.d.ts +1 -1
- package/types/SheetModal/index.native.d.ts.map +1 -1
- package/types/Tabs/index.d.ts.map +1 -1
- package/types/layouts/tags.d.ts.map +1 -1
- package/types/views/TreeView.d.ts +10 -1
- 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
|
+
};
|
package/src/Tabs/index.tsx
CHANGED
|
@@ -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>(
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
720
|
-
updateFades();
|
|
750
|
+
updateScrollSize("viewport", e.nativeEvent.layout.width);
|
|
721
751
|
}}
|
|
722
752
|
onContentSizeChange={(w) => {
|
|
723
|
-
|
|
724
|
-
updateFades();
|
|
753
|
+
updateScrollSize("content", w);
|
|
725
754
|
}}
|
|
726
755
|
>
|
|
727
756
|
{tabList}
|
package/src/layouts/tags.tsx
CHANGED
|
@@ -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
|
|
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
|
+
};
|