@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
@@ -1,6 +1,15 @@
1
- import { Button } from "@multiplatform.one/forms";
1
+ import {
2
+ Paragraph as FixtureText,
3
+ ScrollView,
4
+ SheetModal as PublicSheetModal,
5
+ XStack,
6
+ YStack as FixtureStack,
7
+ type TamaguiElement,
8
+ } from "@multiplatform.one/components";
9
+ import { Button, Input } from "@multiplatform.one/forms";
2
10
  import type { Meta, StoryObj } from "@storybook/react-native-web-vite";
3
- import { useState } from "react";
11
+ import { useRef, useState } from "react";
12
+ import { Platform, useWindowDimensions, type LayoutChangeEvent } from "react-native";
4
13
  import { Paragraph, SizableText, YStack } from "tamagui";
5
14
  import { ActionBar } from "../actions/ActionBar";
6
15
  import { SheetModal } from "./index";
@@ -107,3 +116,267 @@ export const OpenOnMount: Story = {
107
116
  name: "Open",
108
117
  render: () => <Harness startOpen />,
109
118
  };
119
+
120
+ type Bounds = { x: number; y: number; width: number; height: number };
121
+ type Reading<T> = T | "unmeasured" | "unavailable" | "invalid";
122
+ type FixtureMode = "subject" | "control-240";
123
+ type FixtureReadings = {
124
+ frameLayout: Reading<Bounds>;
125
+ toolbarLayout: Reading<Bounds>;
126
+ viewportLayout: Reading<Bounds>;
127
+ scrollLayout: Reading<Bounds>;
128
+ frameWindow: Reading<Bounds>;
129
+ viewportWindow: Reading<Bounds>;
130
+ scrollWindow: Reading<Bounds>;
131
+ tailWindow: Reading<Bounds>;
132
+ content: Reading<{ width: number; height: number }>;
133
+ offset: Reading<{ x: number; y: number }>;
134
+ sampleWindow: ReturnType<typeof useWindowDimensions> | "unmeasured";
135
+ sample: number;
136
+ tailPresses: number;
137
+ };
138
+
139
+ function emptyReadings(): FixtureReadings {
140
+ return {
141
+ frameLayout: "unmeasured",
142
+ toolbarLayout: "unmeasured",
143
+ viewportLayout: "unmeasured",
144
+ scrollLayout: "unmeasured",
145
+ frameWindow: "unmeasured",
146
+ viewportWindow: "unmeasured",
147
+ scrollWindow: "unmeasured",
148
+ tailWindow: "unmeasured",
149
+ content: "unmeasured",
150
+ offset: "unmeasured",
151
+ sampleWindow: "unmeasured",
152
+ sample: 0,
153
+ tailPresses: 0,
154
+ };
155
+ }
156
+
157
+ function bounds(x: number, y: number, width: number, height: number): Reading<Bounds> {
158
+ return [x, y, width, height].every(Number.isFinite) && width >= 0 && height >= 0
159
+ ? { x, y, width, height }
160
+ : "invalid";
161
+ }
162
+
163
+ function OwnedBodyFixture({ measured }: { measured: boolean }) {
164
+ const window = useWindowDimensions();
165
+ const [open, setOpen] = useState(false);
166
+ const [mode, setMode] = useState<FixtureMode>("subject");
167
+ const [fill, setFill] = useState(false);
168
+ const [readings, setReadings] = useState(emptyReadings);
169
+ const active = useRef(false);
170
+ const generation = useRef(0);
171
+ const sample = useRef(0);
172
+ const frameRef = useRef<TamaguiElement>(null);
173
+ const viewportRef = useRef<TamaguiElement>(null);
174
+ const scrollRef = useRef<React.ComponentRef<typeof ScrollView>>(null);
175
+ const tailRef = useRef<TamaguiElement>(null);
176
+ const presentation = generation.current;
177
+ const kind = measured ? "measured" : "scroll";
178
+
179
+ function present(nextMode: FixtureMode, nextFill = false) {
180
+ generation.current += 1;
181
+ sample.current = 0;
182
+ active.current = true;
183
+ setMode(nextMode);
184
+ setFill(nextFill);
185
+ setReadings(emptyReadings());
186
+ setOpen(true);
187
+ }
188
+
189
+ function close() {
190
+ active.current = false;
191
+ setOpen(false);
192
+ }
193
+
194
+ function record(patch: Partial<FixtureReadings>) {
195
+ if (!active.current || generation.current !== presentation) return;
196
+ setReadings((previous) => {
197
+ const next = { ...previous, ...patch };
198
+ return JSON.stringify(next) === JSON.stringify(previous) ? previous : next;
199
+ });
200
+ }
201
+
202
+ function onLayout(key: "frameLayout" | "toolbarLayout" | "viewportLayout" | "scrollLayout") {
203
+ return ({ nativeEvent: { layout } }: LayoutChangeEvent) =>
204
+ record({ [key]: bounds(layout.x, layout.y, layout.width, layout.height) });
205
+ }
206
+
207
+ function measureBounds() {
208
+ const currentSample = ++sample.current;
209
+ record({
210
+ sample: currentSample,
211
+ frameWindow: "unmeasured",
212
+ viewportWindow: "unmeasured",
213
+ scrollWindow: "unmeasured",
214
+ tailWindow: "unmeasured",
215
+ sampleWindow: { ...window },
216
+ });
217
+ for (const [key, node] of [
218
+ ["frameWindow", frameRef.current],
219
+ ["viewportWindow", viewportRef.current],
220
+ ["scrollWindow", scrollRef.current?.getNativeScrollRef?.()],
221
+ ["tailWindow", tailRef.current],
222
+ ] as const) {
223
+ if (!node || typeof node.measureInWindow !== "function") {
224
+ record({ [key]: "unavailable" });
225
+ continue;
226
+ }
227
+ node.measureInWindow((x, y, width, height) => {
228
+ if (sample.current === currentSample) record({ [key]: bounds(x, y, width, height) });
229
+ });
230
+ }
231
+ }
232
+
233
+ const viewport = readings.viewportLayout;
234
+ const showRows = !measured || (typeof viewport === "object" && viewport.height > 0);
235
+ const height = (value: Reading<Bounds>) => (typeof value === "object" ? value.height : value);
236
+ const diagnostics = {
237
+ kind,
238
+ mode,
239
+ fill,
240
+ open,
241
+ presentation,
242
+ platform: Platform.OS,
243
+ window,
244
+ sheetFrameWindow: "unavailable: SheetModal has no public frame ref",
245
+ ...readings,
246
+ };
247
+
248
+ return (
249
+ <FixtureStack gap="$3" padding="$3">
250
+ <FixtureText>{measured ? "Owned measured viewport" : "Owned ScrollView"}</FixtureText>
251
+ <Button onPress={() => present("subject")}>Open flex subject</Button>
252
+ <Button onPress={() => present("subject", true)}>Open flex subject (fill)</Button>
253
+ <Button onPress={() => present("control-240")}>Open 240pt control</Button>
254
+ <FixtureText>
255
+ Measure after the sheet settles. Close it to read the captured bounds below. Repeat after
256
+ dragging, scrolling, rotating or using the keyboard. A declared size is not a measurement.
257
+ </FixtureText>
258
+ <FixtureText testID="owned-sheet-readings" selectable>
259
+ {JSON.stringify(diagnostics, null, 2)}
260
+ </FixtureText>
261
+ <PublicSheetModal
262
+ open={open}
263
+ onOpenChange={(next) => {
264
+ if (!next) close();
265
+ }}
266
+ snapPoint={88}
267
+ scrollable={false}
268
+ fill={fill}
269
+ header={
270
+ <FixtureStack gap="$1" flexShrink={0}>
271
+ <FixtureText numberOfLines={1}>
272
+ {kind} / {mode} / fill {fill ? "on" : "off"}
273
+ </FixtureText>
274
+ <FixtureText numberOfLines={1}>
275
+ Body {height(readings.frameLayout)} / toolbar {height(readings.toolbarLayout)} /
276
+ viewport {height(viewport)}
277
+ </FixtureText>
278
+ <FixtureText numberOfLines={1}>
279
+ Offset {typeof readings.offset === "object" ? readings.offset.y : readings.offset} /
280
+ tail presses {readings.tailPresses}
281
+ </FixtureText>
282
+ <XStack gap="$2">
283
+ <Button flex={1} onPress={measureBounds}>
284
+ Measure bounds
285
+ </Button>
286
+ <Button flex={1} onPress={close}>
287
+ Close fixture
288
+ </Button>
289
+ </XStack>
290
+ </FixtureStack>
291
+ }
292
+ >
293
+ <FixtureStack
294
+ key={presentation}
295
+ ref={frameRef}
296
+ testID="owned-sheet-frame"
297
+ collapsable={false}
298
+ minHeight={0}
299
+ minWidth={0}
300
+ {...(mode === "control-240" ? { height: 240, flexGrow: 0, flexShrink: 0 } : { flex: 1 })}
301
+ onLayout={onLayout("frameLayout")}
302
+ >
303
+ <FixtureStack
304
+ testID="owned-sheet-toolbar"
305
+ collapsable={false}
306
+ flexShrink={0}
307
+ onLayout={onLayout("toolbarLayout")}
308
+ >
309
+ <FixtureText>Records toolbar</FixtureText>
310
+ </FixtureStack>
311
+ <FixtureStack
312
+ ref={viewportRef}
313
+ testID="owned-sheet-viewport"
314
+ collapsable={false}
315
+ flex={1}
316
+ minHeight={0}
317
+ minWidth={0}
318
+ onLayout={onLayout("viewportLayout")}
319
+ >
320
+ {showRows && (
321
+ <ScrollView
322
+ ref={scrollRef}
323
+ testID="owned-sheet-scroll"
324
+ flex={1}
325
+ minHeight={0}
326
+ keyboardShouldPersistTaps="handled"
327
+ scrollEventThrottle={32}
328
+ onLayout={onLayout("scrollLayout")}
329
+ onContentSizeChange={(width, height) =>
330
+ record({
331
+ content:
332
+ [width, height].every(Number.isFinite) && width >= 0 && height >= 0
333
+ ? { width, height }
334
+ : "invalid",
335
+ })
336
+ }
337
+ onScroll={({ nativeEvent: { contentOffset } }) =>
338
+ record({
339
+ offset: [contentOffset.x, contentOffset.y].every(Number.isFinite)
340
+ ? { x: contentOffset.x, y: contentOffset.y }
341
+ : "invalid",
342
+ })
343
+ }
344
+ >
345
+ <FixtureStack gap="$2">
346
+ <Input label="Keyboard probe" placeholder="Generic text only" />
347
+ {Array.from({ length: 60 }, (_, index) => (
348
+ <FixtureStack key={index} gap="$1" paddingVertical="$2">
349
+ <FixtureText>Record {index + 1}</FixtureText>
350
+ {Array.from({ length: 1 + (index % 4) }, (_, line) => (
351
+ <FixtureText key={line}>
352
+ Generic detail {line + 1} for this record.
353
+ </FixtureText>
354
+ ))}
355
+ </FixtureStack>
356
+ ))}
357
+ <FixtureStack ref={tailRef} testID="owned-sheet-tail" collapsable={false}>
358
+ <Button onPress={() => record({ tailPresses: readings.tailPresses + 1 })}>
359
+ Activate Record 60
360
+ </Button>
361
+ </FixtureStack>
362
+ </FixtureStack>
363
+ </ScrollView>
364
+ )}
365
+ </FixtureStack>
366
+ </FixtureStack>
367
+ </PublicSheetModal>
368
+ </FixtureStack>
369
+ );
370
+ }
371
+
372
+ export const OwnedMeasuredViewport: Story = {
373
+ name: "Owned measured viewport",
374
+ argTypes: { snapPoint: { control: false }, scrollable: { control: false } },
375
+ render: () => <OwnedBodyFixture measured />,
376
+ };
377
+
378
+ export const OwnedScrollView: Story = {
379
+ name: "Owned ScrollView",
380
+ argTypes: { snapPoint: { control: false }, scrollable: { control: false } },
381
+ render: () => <OwnedBodyFixture measured={false} />,
382
+ };
@@ -40,6 +40,14 @@ const backdropStrip = 48;
40
40
  // Released more than this far below the smallest detent, the sheet dismisses.
41
41
  const dismissThreshold = 56;
42
42
  const IN_DIALOG_REGION = { inDialogRegion: true } as const;
43
+ /*
44
+ * MPO-299 fill: an owned viewport bootstraps from zero unless EVERY link
45
+ * between the capped frame and the body gives way at once — frame height,
46
+ * surface, padded stack, body slot. Opening only the outer one keeps the
47
+ * measured-zero failure, so these are applied together or not at all.
48
+ */
49
+ const fillSlot = { flex: 1, minHeight: 0 } as const;
50
+ const nonShrinking = { flexShrink: 0 } as const;
43
51
 
44
52
  export function SheetModal({
45
53
  open,
@@ -48,6 +56,7 @@ export function SheetModal({
48
56
  header,
49
57
  snapPoint,
50
58
  scrollable,
59
+ fill = false,
51
60
  }: SheetModalProps) {
52
61
  const { t } = useTranslation();
53
62
  const { knobProps } = useResolvedKnobs();
@@ -281,22 +290,28 @@ export function SheetModal({
281
290
  const h = Math.round(e.nativeEvent.layout.height);
282
291
  if (h > 0 && h !== measuredHeight) setMeasuredHeight(h);
283
292
  }}
293
+ testID="sheet-modal-frame"
284
294
  style={{
285
295
  // MPO-64: flex children default to flexShrink 0, so without this
286
296
  // the sheet cannot give way to the keyboard.
287
297
  flexShrink: 1,
288
298
  minHeight: 0,
289
299
  maxHeight,
300
+ // The cap is already keyboard- and window-aware; fill only asks
301
+ // for it as a definite height rather than an upper bound.
302
+ ...(fill ? { height: maxHeight } : null),
290
303
  transform: [{ translateY }],
291
304
  }}
292
305
  >
293
306
  <YStack
307
+ testID="sheet-modal-surface"
294
308
  {...knobProps.surface}
295
309
  {...knobProps.borderRadius}
296
310
  borderBottomLeftRadius={0}
297
311
  borderBottomRightRadius={0}
298
312
  elevation={knobProps.elevation}
299
313
  maxHeight={maxHeight}
314
+ {...(fill ? { ...fillSlot, flexShrink: 1 } : null)}
300
315
  paddingTop="$2"
301
316
  paddingBottom={keyboardInset > 0 ? 8 : bottomInset}
302
317
  overflow="hidden"
@@ -316,11 +331,13 @@ export function SheetModal({
316
331
  {...panResponder.panHandlers}
317
332
  accessibilityRole="adjustable"
318
333
  accessibilityLabel={t("Drag to resize")}
334
+ testID="sheet-modal-grabber"
319
335
  style={{
320
336
  alignSelf: "stretch",
321
337
  alignItems: "center",
322
338
  paddingTop: 3,
323
339
  paddingBottom: 11,
340
+ ...(fill ? nonShrinking : null),
324
341
  }}
325
342
  >
326
343
  <View
@@ -334,17 +351,44 @@ export function SheetModal({
334
351
  </RNView>
335
352
  <DialogRegionContext.Provider value={IN_DIALOG_REGION}>
336
353
  <YStack
354
+ testID="sheet-modal-content"
337
355
  padding={knobProps.panelPadding.padding}
338
356
  gap={knobProps.gap.gap}
339
357
  flexShrink={1}
358
+ {...(fill ? fillSlot : null)}
340
359
  >
341
- {header}
342
- {scrollable ? (
343
- <RNScrollView style={{ flexGrow: 0 }} keyboardShouldPersistTaps="handled">
344
- {children}
345
- </RNScrollView>
360
+ {fill ? (
361
+ <>
362
+ {header ? <RNView style={nonShrinking}>{header}</RNView> : null}
363
+ <RNView testID="sheet-modal-body" style={fillSlot}>
364
+ {scrollable ? (
365
+ <RNScrollView
366
+ testID="sheet-modal-scroll"
367
+ style={fillSlot}
368
+ keyboardShouldPersistTaps="handled"
369
+ >
370
+ {children}
371
+ </RNScrollView>
372
+ ) : (
373
+ children
374
+ )}
375
+ </RNView>
376
+ </>
346
377
  ) : (
347
- children
378
+ <>
379
+ {header}
380
+ {scrollable ? (
381
+ <RNScrollView
382
+ testID="sheet-modal-scroll"
383
+ style={{ flexGrow: 0 }}
384
+ keyboardShouldPersistTaps="handled"
385
+ >
386
+ {children}
387
+ </RNScrollView>
388
+ ) : (
389
+ children
390
+ )}
391
+ </>
348
392
  )}
349
393
  </YStack>
350
394
  </DialogRegionContext.Provider>
@@ -25,6 +25,13 @@ export interface SheetModalProps {
25
25
  snapPoint?: number;
26
26
  /** Wrap children in the sheet's scroll container. */
27
27
  scrollable?: boolean;
28
+ /**
29
+ * Opt the body into the sheet's remaining height instead of sizing it to
30
+ * its content. A consumer-owned viewport (a virtualized list, a measured
31
+ * workspace) cannot bootstrap from an intrinsic host: it reports zero and
32
+ * then mounts nothing. Off by default, so existing sheets are unchanged.
33
+ */
34
+ fill?: boolean;
28
35
  }
29
36
 
30
37
  const FOCUSABLE =
@@ -32,6 +39,14 @@ const FOCUSABLE =
32
39
 
33
40
  const IN_DIALOG_REGION = { inDialogRegion: true } as const;
34
41
 
42
+ /*
43
+ * MPO-299 fill: the web frame already has a definite height from the Sheet's
44
+ * percent snap point, so fill only opens the body chain inside it. A flex
45
+ * basis on the frame itself would replace that height, not extend it.
46
+ */
47
+ const fillSlot = { flex: 1, minHeight: 0 } as const;
48
+ const nonShrinking = { flexShrink: 0 } as const;
49
+
35
50
  /** LC-71: the sheet frame is not a control. Auto-focus must not paint a ring. */
36
51
  const frameRingReset = {
37
52
  outlineWidth: 0,
@@ -140,6 +155,7 @@ export function SheetModal({
140
155
  header,
141
156
  snapPoint = 60,
142
157
  scrollable,
158
+ fill = false,
143
159
  }: SheetModalProps) {
144
160
  const { knobProps } = useResolvedKnobs();
145
161
  const transition = knobProps.transition;
@@ -190,9 +206,30 @@ export function SheetModal({
190
206
  {...frameRingReset}
191
207
  >
192
208
  <DialogRegionContext.Provider value={IN_DIALOG_REGION}>
193
- {header ? <View id={titleId}>{header}</View> : null}
194
- {scrollable ? <Sheet.ScrollView>{children}</Sheet.ScrollView> : children}
195
- <View style={{ height: "env(safe-area-inset-bottom)" }} />
209
+ {header ? (
210
+ <View id={titleId} {...(fill ? nonShrinking : null)}>
211
+ {header}
212
+ </View>
213
+ ) : null}
214
+ {fill ? (
215
+ <View testID="sheet-modal-body" {...fillSlot}>
216
+ {scrollable ? (
217
+ <Sheet.ScrollView testID="sheet-modal-scroll" {...fillSlot}>
218
+ {children}
219
+ </Sheet.ScrollView>
220
+ ) : (
221
+ children
222
+ )}
223
+ </View>
224
+ ) : scrollable ? (
225
+ <Sheet.ScrollView testID="sheet-modal-scroll">{children}</Sheet.ScrollView>
226
+ ) : (
227
+ children
228
+ )}
229
+ <View
230
+ style={{ height: "env(safe-area-inset-bottom)" }}
231
+ {...(fill ? nonShrinking : null)}
232
+ />
196
233
  </DialogRegionContext.Provider>
197
234
  </SheetFrame>
198
235
  </Sheet>
@@ -0,0 +1,123 @@
1
+ import { act, cleanup, screen } from "@testing-library/react";
2
+ import { renderWithProviders } from "@multiplatform.one/test-utils";
3
+ import { afterEach, beforeEach, expect, it, vi } from "vitest";
4
+ import { Tabs } from "./index";
5
+
6
+ const observed = vi.hoisted(() => ({ scroll: null as any }));
7
+ vi.mock("tamagui", async (original) => {
8
+ const actual = await original<any>();
9
+ const React = await import("react");
10
+ return {
11
+ ...actual,
12
+ ScrollView: React.forwardRef((props: any, ref) => {
13
+ observed.scroll = props;
14
+ return React.createElement(actual.ScrollView, { ...props, ref });
15
+ }),
16
+ };
17
+ });
18
+ const observers: ControlledResizeObserver[] = [];
19
+ class ControlledResizeObserver {
20
+ elements = new Set<Element>();
21
+ constructor(readonly callback: ResizeObserverCallback) {
22
+ observers.push(this);
23
+ }
24
+ observe(element: Element) {
25
+ this.elements.add(element);
26
+ }
27
+ unobserve(element: Element) {
28
+ this.elements.delete(element);
29
+ }
30
+ disconnect() {
31
+ this.elements.clear();
32
+ }
33
+ }
34
+ let precedingGrowth = 0;
35
+ const items = Array.from({ length: 6 }, (_, index) => ({
36
+ value: `tab-${index}`,
37
+ label: `Tab ${index + 1}`,
38
+ }));
39
+ function indexFor(element: HTMLElement) {
40
+ if (element.getAttribute("role") !== "tab") return -1;
41
+ return Number(element.textContent?.match(/Tab (\d)/)?.[1] ?? 0) - 1;
42
+ }
43
+ beforeEach(() => {
44
+ precedingGrowth = 0;
45
+ observers.length = 0;
46
+ vi.stubGlobal("ResizeObserver", ControlledResizeObserver);
47
+ vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(
48
+ function (this: HTMLElement) {
49
+ const index = indexFor(this);
50
+ return index < 0 ? 0 : 100 + (index === 0 ? precedingGrowth : 0);
51
+ },
52
+ );
53
+ vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
54
+ function (this: HTMLElement) {
55
+ return indexFor(this) < 0 ? 0 : 40;
56
+ },
57
+ );
58
+ vi.spyOn(HTMLElement.prototype, "offsetLeft", "get").mockImplementation(
59
+ function (this: HTMLElement) {
60
+ const index = indexFor(this);
61
+ return index < 0 ? 0 : index * 100 + (index > 0 ? precedingGrowth : 0);
62
+ },
63
+ );
64
+ });
65
+ afterEach(() => {
66
+ cleanup();
67
+ vi.restoreAllMocks();
68
+ vi.unstubAllGlobals();
69
+ });
70
+ function measured() {
71
+ const tree = renderWithProviders(<Tabs items={items} value="tab-5" />);
72
+ const host = tree.container.querySelector('[data-tabs-scroll="true"]') as any;
73
+ const scroll = vi.spyOn(host, "scrollTo").mockImplementation(() => {});
74
+ act(() => observed.scroll.onLayout({ nativeEvent: { layout: { width: 390 } } }));
75
+ act(() => observed.scroll.onContentSizeChange(600, 40));
76
+ expect(scroll).toHaveBeenCalledExactlyOnceWith({ x: 210, animated: true });
77
+ act(() => observed.scroll.onScroll({ nativeEvent: { contentOffset: { x: 210 } } }));
78
+ scroll.mockClear();
79
+ return { ...tree, scroll };
80
+ }
81
+ it("uses the primitive's real initial measurement and selection effect", () => {
82
+ measured();
83
+ });
84
+ it("refreshes selected position after a preceding label grows without resizing the selected trigger", () => {
85
+ const tree = measured();
86
+ precedingGrowth = 100;
87
+ tree.rerender(
88
+ <Tabs
89
+ value="tab-5"
90
+ items={items.map((item, index) =>
91
+ index === 0 ? { ...item, label: "Tab 1 with loaded details" } : item,
92
+ )}
93
+ />,
94
+ );
95
+ const first = screen.getByRole("tab", { name: "Tab 1 with loaded details" });
96
+ act(() => {
97
+ for (const observer of observers) {
98
+ if (observer.elements.has(first))
99
+ observer.callback(
100
+ [
101
+ {
102
+ target: first,
103
+ contentRect: first.getBoundingClientRect(),
104
+ borderBoxSize: [],
105
+ contentBoxSize: [],
106
+ devicePixelContentBoxSize: [],
107
+ },
108
+ ],
109
+ observer as unknown as ResizeObserver,
110
+ );
111
+ }
112
+ observed.scroll.onContentSizeChange(700, 40);
113
+ });
114
+ const selected = screen.getByRole("tab", { name: "Tab 6" });
115
+ expect(selected.offsetLeft).toBe(600);
116
+ const lastRequest = tree.scroll.mock.calls.at(-1)?.[0] as { x: number } | undefined;
117
+ expect(lastRequest?.x).toBeGreaterThanOrEqual(selected.offsetLeft + selected.offsetWidth - 390);
118
+ expect(lastRequest!.x).toBeLessThanOrEqual(selected.offsetLeft);
119
+ tree.scroll.mockClear();
120
+ act(() => observed.scroll.onScroll({ nativeEvent: { contentOffset: { x: 0 } } }));
121
+ act(() => observed.scroll.onContentSizeChange(700, 40));
122
+ expect(tree.scroll).not.toHaveBeenCalled();
123
+ });