@oxyhq/bloom 0.53.0 → 0.54.1
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/lib/commonjs/tab-bar/TabBarBase.js +145 -20
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/tab-bar/shared.js +15 -1
- package/lib/commonjs/tab-bar/shared.js.map +1 -1
- package/lib/module/tab-bar/TabBarBase.js +147 -22
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/tab-bar/shared.js +14 -0
- package/lib/module/tab-bar/shared.js.map +1 -1
- package/lib/typescript/commonjs/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/shared.d.ts +13 -0
- package/lib/typescript/commonjs/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/commonjs/tab-bar/types.d.ts +52 -0
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/TabBarBase.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/shared.d.ts +13 -0
- package/lib/typescript/module/tab-bar/shared.d.ts.map +1 -1
- package/lib/typescript/module/tab-bar/types.d.ts +52 -0
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/TabBarBlur.test.tsx +154 -0
- package/src/__tests__/TabBarMaxWidth.test.tsx +413 -0
- package/src/__tests__/TabBarNoSelection.test.tsx +458 -0
- package/src/__tests__/TabBarReanimatedDeps.test.ts +28 -4
- package/src/__tests__/animated-style-deps.test.ts +30 -6
- package/src/tab-bar/TabBar.stories.tsx +52 -1
- package/src/tab-bar/TabBarBase.tsx +178 -46
- package/src/tab-bar/shared.ts +12 -0
- package/src/tab-bar/types.ts +50 -0
|
@@ -75,6 +75,18 @@ export type TabBarProps = ViewProps & {
|
|
|
75
75
|
* highlight and springs it here whenever this changes. Use this when there is
|
|
76
76
|
* no router in play; the router adapter uses the per-button `isFocused` path
|
|
77
77
|
* instead (see {@link TabBarButtonProps.isFocused}) and must NOT pass this.
|
|
78
|
+
*
|
|
79
|
+
* An index that names NO tab — negative, past the last tab, or fractional —
|
|
80
|
+
* means no selection: the highlight fades out where it stands and no tab is
|
|
81
|
+
* left tinted. Pass one whenever the current screen is not a tab; an index
|
|
82
|
+
* derived from the route (`TABS.findIndex(…)`) is `-1` on every such screen
|
|
83
|
+
* and is correct as-is. Returning to a real index fades the highlight back in
|
|
84
|
+
* AT that tab rather than sliding to it — while it was invisible there was no
|
|
85
|
+
* position to travel from — and scrubbing still arms it under the finger.
|
|
86
|
+
*
|
|
87
|
+
* OMITTING this prop is a different thing entirely and does not hide
|
|
88
|
+
* anything: that is the focus-driven path, where each button drives the
|
|
89
|
+
* highlight from its own `isFocused`.
|
|
78
90
|
*/
|
|
79
91
|
activeIndex?: number;
|
|
80
92
|
/**
|
|
@@ -104,6 +116,46 @@ export type TabBarProps = ViewProps & {
|
|
|
104
116
|
* optional `expo-haptics` peer is absent.
|
|
105
117
|
*/
|
|
106
118
|
haptics?: boolean;
|
|
119
|
+
/**
|
|
120
|
+
* The progressive blur rising from the bottom edge of the screen behind the
|
|
121
|
+
* pill. Defaults to `true`; `{ intensity }` tunes its strength.
|
|
122
|
+
*
|
|
123
|
+
* Pass `false` to remove it entirely — no blur, and no node left behind. The
|
|
124
|
+
* band is tall (the bar's own bottom gap plus its expanded height plus the
|
|
125
|
+
* bleed above it, 114pt at a zero bottom inset) and full-bleed, so anything a
|
|
126
|
+
* screen floats near the bottom edge — a video scrubber, a FAB — sits INSIDE
|
|
127
|
+
* it and is blurred. That cannot be undone from the outside: `zIndex` only
|
|
128
|
+
* orders siblings within one stacking context, and a screen's FAB is inside
|
|
129
|
+
* an earlier sibling of the bar's host, so no z-order a consumer can write
|
|
130
|
+
* lifts it above this band. Turning the blur off is the only fix that does
|
|
131
|
+
* not also change where the FAB paints relative to everything else.
|
|
132
|
+
*
|
|
133
|
+
* On native the band is ten stacked `expo-blur` layers plus a tail gradient;
|
|
134
|
+
* on web it is one masked `backdrop-filter`. `false` skips both.
|
|
135
|
+
*/
|
|
136
|
+
blur?: boolean | {
|
|
137
|
+
intensity?: number;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* Maximum width of the pill, in points. No default — omit it and the bar
|
|
141
|
+
* spans the window exactly as it always has.
|
|
142
|
+
*
|
|
143
|
+
* A CEILING, never a floor: on a window narrower than this the bar keeps its
|
|
144
|
+
* full-bleed width, so a phone layout is untouched by any value big enough to
|
|
145
|
+
* matter on a tablet. When it binds, the pill is constrained to this width and
|
|
146
|
+
* CENTRED, and the ITEM geometry follows it — the item width, the sliding
|
|
147
|
+
* highlight's position and the tap/scrub hit-testing all derive from the same
|
|
148
|
+
* constrained width, so a tap still lands on the tab it is visibly over.
|
|
149
|
+
*
|
|
150
|
+
* That last part is why this cannot be done from the outside. Narrowing the
|
|
151
|
+
* bar with a `style` override moves the pixels only: the highlight and the
|
|
152
|
+
* scrub worklet would still divide the WINDOW width by the tab count, leaving
|
|
153
|
+
* the highlight sized and positioned for a bar that is no longer there. On an
|
|
154
|
+
* iPad the unconstrained bar is the reason to reach for this at all — 810pt at
|
|
155
|
+
* 11" portrait, 1342pt in landscape, leaving 21pt glyphs adrift in cells
|
|
156
|
+
* hundreds of points wide.
|
|
157
|
+
*/
|
|
158
|
+
maxWidth?: number;
|
|
107
159
|
};
|
|
108
160
|
export type TabBarButtonProps = PressableProps & {
|
|
109
161
|
item: TabBarItem;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9D,MAAM,MAAM,UAAU,GAAG;IACvB,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;;OAUG;IACH,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,0DAA0D;IAC1D,UAAU,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG;IACpC
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../../../src/tab-bar/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AACvC,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9D,MAAM,MAAM,UAAU,GAAG;IACvB,yEAAyE;IACzE,IAAI,EAAE,MAAM,CAAC;IACb,4EAA4E;IAC5E,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;;OAUG;IACH,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;;;;;;;;;;;;;OAmBG;IACH,UAAU,CAAC,EAAE,SAAS,CAAC;IACvB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,0DAA0D;IAC1D,UAAU,EAAE,MAAM,CAAC;IACnB,4CAA4C;IAC5C,YAAY,EAAE,MAAM,CAAC;IACrB,oCAAoC;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,2DAA2D;IAC3D,SAAS,EAAE,MAAM,CAAC;IAClB,oEAAoE;IACpE,aAAa,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG;IACpC;;;;;;;;;;;;;;;;;OAiBG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;OAGG;IACH,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACxC;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,wEAAwE;IACxE,KAAK,CAAC,EAAE,OAAO,CAAC,WAAW,CAAC,CAAC;IAC7B;;;;OAIG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB;;;;;;;;;;;;;;;;OAgBG;IACH,IAAI,CAAC,EAAE,OAAO,GAAG;QAAE,SAAS,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC;IACxC;;;;;;;;;;;;;;;;;;OAkBG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG,cAAc,GAAG;IAC/C,IAAI,EAAE,UAAU,CAAC;IACjB,gFAAgF;IAChF,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;;;OASG;IACH,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,CAAC"}
|
package/package.json
CHANGED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render } from '@testing-library/react-native';
|
|
3
|
+
import type { ReactTestRendererJSON, ReactTestRendererNode } from 'react-test-renderer';
|
|
4
|
+
|
|
5
|
+
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
6
|
+
import { TabBar, TabBarButton } from '../tab-bar';
|
|
7
|
+
import { BLUR_BLEED, EXPANDED_HEIGHT, tabBarBottomGap } from '../tab-bar/shared';
|
|
8
|
+
import type { TabBarItem, TabBarProps } from '../tab-bar/types';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `blur` — turning the bar's bottom band off, and tuning it.
|
|
12
|
+
*
|
|
13
|
+
* The band is full-bleed and 114pt tall at a zero bottom inset (the bar's own
|
|
14
|
+
* bottom gap + its expanded height + the bleed above it), so ANY chrome a screen
|
|
15
|
+
* floats near the bottom edge sits inside it and is blurred: a video scrubber, a
|
|
16
|
+
* compose FAB, a QR-scan button. No consumer can escape it from the outside —
|
|
17
|
+
* `zIndex` orders siblings within one stacking context, and a screen's FAB lives
|
|
18
|
+
* inside an earlier sibling of the bar's host — so the prop is the only fix that
|
|
19
|
+
* does not also change where that FAB paints relative to everything else.
|
|
20
|
+
*
|
|
21
|
+
* Asserted end to end through the REAL `TabBar` (jest has no platform-extension
|
|
22
|
+
* resolution, so `../tab-bar` binds the neutral surface and the NATIVE
|
|
23
|
+
* `ProgressiveBlur`, whose ten `BlurView`s are what the `expo-blur` mock
|
|
24
|
+
* renders). That makes these assertions about the shipped composition rather
|
|
25
|
+
* than about a stand-in wired up by the test.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const ITEMS: TabBarItem[] = [
|
|
29
|
+
{ name: 'home', label: 'Home', icon: null },
|
|
30
|
+
{ name: 'search', label: 'Search', icon: null },
|
|
31
|
+
{ name: 'you', label: 'You', icon: null },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** Insets are all zero under the safe-area mock, so this is the real 114. */
|
|
35
|
+
const BAND_HEIGHT = tabBarBottomGap(0) + EXPANDED_HEIGHT + BLUR_BLEED;
|
|
36
|
+
|
|
37
|
+
/** The fork's own default, applied when nothing overrides it. */
|
|
38
|
+
const DEFAULT_INTENSITY = 5;
|
|
39
|
+
|
|
40
|
+
function renderBar(blur?: TabBarProps['blur']) {
|
|
41
|
+
return render(
|
|
42
|
+
<BloomThemeProvider mode="light" colorPreset="teal">
|
|
43
|
+
<TabBar activeIndex={0} blur={blur}>
|
|
44
|
+
{ITEMS.map((item, index) => (
|
|
45
|
+
<TabBarButton key={item.name} item={item} index={index} />
|
|
46
|
+
))}
|
|
47
|
+
</TabBar>
|
|
48
|
+
</BloomThemeProvider>,
|
|
49
|
+
).toJSON();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What `toJSON()` hands back — a node, several, or nothing rendered at all. */
|
|
53
|
+
type RenderedTree = ReactTestRendererNode | ReactTestRendererNode[] | null;
|
|
54
|
+
|
|
55
|
+
function isJsonNode(node: ReactTestRendererNode): node is ReactTestRendererJSON {
|
|
56
|
+
return typeof node !== 'string';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Every HOST node in the rendered tree — composites collapse to what they render. */
|
|
60
|
+
function hostNodes(tree: RenderedTree): ReactTestRendererJSON[] {
|
|
61
|
+
if (tree === null) return [];
|
|
62
|
+
const roots = (Array.isArray(tree) ? tree : [tree]).filter(isJsonNode);
|
|
63
|
+
return roots.flatMap((node) => [node, ...hostNodes(node.children ?? [])]);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function flattenStyle(style: unknown): Record<string, unknown> {
|
|
67
|
+
const out: Record<string, unknown> = {};
|
|
68
|
+
const visit = (value: unknown): void => {
|
|
69
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
70
|
+
else if (value && typeof value === 'object') Object.assign(out, value);
|
|
71
|
+
};
|
|
72
|
+
visit(style);
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The bar's outermost view — `styles.root`, the absolutely-positioned
|
|
78
|
+
* `box-none` layer the blur and the pill wrap are the two children of.
|
|
79
|
+
*/
|
|
80
|
+
function barRoot(tree: RenderedTree): ReactTestRendererJSON {
|
|
81
|
+
const found = hostNodes(tree).find((node) => {
|
|
82
|
+
const style = flattenStyle(node.props.style);
|
|
83
|
+
return style.position === 'absolute' && style.pointerEvents === 'box-none';
|
|
84
|
+
});
|
|
85
|
+
if (!found) throw new Error('The bar rendered no absolute box-none root');
|
|
86
|
+
return found;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function children(node: ReactTestRendererJSON): ReactTestRendererJSON[] {
|
|
90
|
+
return (node.children ?? []).filter(isJsonNode);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function blurLayers(tree: RenderedTree): ReactTestRendererJSON[] {
|
|
94
|
+
return hostNodes(tree).filter((node) => node.type === 'BlurView');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function intensities(tree: RenderedTree): unknown[] {
|
|
98
|
+
return [...new Set(blurLayers(tree).map((layer) => layer.props.intensity))];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
describe('TabBar blur', () => {
|
|
102
|
+
it('renders the band by default, behind the pill', () => {
|
|
103
|
+
const tree = renderBar();
|
|
104
|
+
expect(blurLayers(tree).length).toBeGreaterThan(0);
|
|
105
|
+
|
|
106
|
+
// FIRST child, so it paints behind the pill rather than over it.
|
|
107
|
+
const band = children(barRoot(tree))[0];
|
|
108
|
+
const style = flattenStyle(band?.props.style);
|
|
109
|
+
expect(style.position).toBe('absolute');
|
|
110
|
+
expect(style.bottom).toBe(0);
|
|
111
|
+
// Full-bleed, and tall enough to reach anything a screen floats down here.
|
|
112
|
+
expect(style.left).toBe(0);
|
|
113
|
+
expect(style.right).toBe(0);
|
|
114
|
+
expect(style.height).toBe(BAND_HEIGHT);
|
|
115
|
+
expect(BAND_HEIGHT).toBe(114);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('treats blur={true} as exactly the default', () => {
|
|
119
|
+
// The prop defaults to `true`, so an app that never heard of it and one that
|
|
120
|
+
// asks for the blur explicitly must render the identical tree.
|
|
121
|
+
expect(JSON.stringify(renderBar(true))).toBe(JSON.stringify(renderBar()));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('renders no blur at all with blur={false}', () => {
|
|
125
|
+
expect(blurLayers(renderBar(false))).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('leaves no empty node behind with blur={false}', () => {
|
|
129
|
+
// Not merely "invisible": an absolutely-positioned 114pt view that renders
|
|
130
|
+
// nothing is still a node, still a rect, and still something to reason about
|
|
131
|
+
// when debugging why a FAB is not reachable.
|
|
132
|
+
expect(children(barRoot(renderBar())).length).toBe(2);
|
|
133
|
+
expect(children(barRoot(renderBar(false))).length).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('keeps the bar itself when the blur is off', () => {
|
|
137
|
+
// Guards the obvious over-reach: `false` removes the band, not the pill.
|
|
138
|
+
const tabs = hostNodes(renderBar(false)).filter(
|
|
139
|
+
(node) => node.props.accessibilityRole === 'tab',
|
|
140
|
+
);
|
|
141
|
+
expect(tabs).toHaveLength(ITEMS.length);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('tunes the strength with the object form', () => {
|
|
145
|
+
expect(intensities(renderBar({ intensity: 12 }))).toEqual([12]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('keeps the fork default for an object with no intensity', () => {
|
|
149
|
+
// `{}` says "blurred, default strength" — it must not resolve to 0, which
|
|
150
|
+
// would read as "off" while still paying for ten composited layers.
|
|
151
|
+
expect(intensities(renderBar({}))).toEqual([DEFAULT_INTENSITY]);
|
|
152
|
+
expect(intensities(renderBar())).toEqual([DEFAULT_INTENSITY]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { act, render } from '@testing-library/react-native';
|
|
3
|
+
import type { ReactTestInstance } from 'react-test-renderer';
|
|
4
|
+
|
|
5
|
+
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
6
|
+
import { TabBar, TabBarButton, TabBarMinimizeProvider, useMinimizeState } from '../tab-bar';
|
|
7
|
+
import type { MinimizeState } from '../tab-bar/minimize-context';
|
|
8
|
+
import { BAR_MARGIN, MINIMIZED_INSET, ROW_PAD_H } from '../tab-bar/shared';
|
|
9
|
+
import type { TabBarItem } from '../tab-bar/types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* `maxWidth` — constraining the pill on a tablet, and keeping every piece of
|
|
13
|
+
* geometry on the SAME width while doing it.
|
|
14
|
+
*
|
|
15
|
+
* Unconstrained, the bar spans the window: 810pt at iPad 11" portrait, 1342pt
|
|
16
|
+
* in landscape, 21pt glyphs adrift in cells hundreds of points wide. A consumer
|
|
17
|
+
* cannot fix that from the outside, and the reason is what this file is really
|
|
18
|
+
* about: narrowing the bar with a `style` override moves the PIXELS only, while
|
|
19
|
+
* the highlight's width, its `translateX` and the tap/scrub hit-testing all keep
|
|
20
|
+
* dividing the WINDOW width by the tab count. The highlight then sits under one
|
|
21
|
+
* tab and a tap at the same point selects another.
|
|
22
|
+
*
|
|
23
|
+
* So the load-bearing test here is not "the bar got narrower" — it is that a tap
|
|
24
|
+
* at the centre of the slot the RENDERED highlight occupies selects that slot's
|
|
25
|
+
* tab, with the item width read back out of the rendered highlight rather than
|
|
26
|
+
* recomputed by the test. Layout and hit-testing are asserted against each
|
|
27
|
+
* other; nothing but agreement between the two can make it pass.
|
|
28
|
+
*
|
|
29
|
+
* Two pieces of the environment are replaced to get there:
|
|
30
|
+
*
|
|
31
|
+
* 1. `useWindowDimensions`, so the window is a tablet's rather than the 375pt
|
|
32
|
+
* the react-native mock reports — with a 375pt window every plausible
|
|
33
|
+
* `maxWidth` is inert and the prop could never be exercised.
|
|
34
|
+
* 2. `react-native-gesture-handler`, with the same RECORDING mock
|
|
35
|
+
* `TabBarLongPress.test.tsx` uses: the shared mock discards every registered
|
|
36
|
+
* callback, and the bar's gesture detector is the only path a real tap takes
|
|
37
|
+
* (it consumes the touches, which is what makes scrubbing possible).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** iPad 11" portrait, the window that produced the report. */
|
|
41
|
+
const TABLET_WIDTH = 1024;
|
|
42
|
+
/** Must be `mock`-prefixed to be readable from the hoisted factory below. */
|
|
43
|
+
let mockWindowWidth = TABLET_WIDTH;
|
|
44
|
+
|
|
45
|
+
jest.mock('react-native', () => {
|
|
46
|
+
const actual = jest.requireActual('react-native');
|
|
47
|
+
return {
|
|
48
|
+
...actual,
|
|
49
|
+
useWindowDimensions: () => ({
|
|
50
|
+
width: mockWindowWidth,
|
|
51
|
+
height: 1366,
|
|
52
|
+
scale: 2,
|
|
53
|
+
fontScale: 1,
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
interface MockGestureEvent {
|
|
59
|
+
x: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface MockGesture {
|
|
63
|
+
kind: string;
|
|
64
|
+
start?: (event: MockGestureEvent) => void;
|
|
65
|
+
update?: (event: MockGestureEvent) => void;
|
|
66
|
+
end?: (event: MockGestureEvent, success: boolean) => void;
|
|
67
|
+
finalize?: () => void;
|
|
68
|
+
/** Members of a composed `Gesture.Race(…)`; empty for a leaf gesture. */
|
|
69
|
+
members: MockGesture[];
|
|
70
|
+
activeOffsetX: (range: number[]) => MockGesture;
|
|
71
|
+
failOffsetY: (range: number[]) => MockGesture;
|
|
72
|
+
maxDistance: (px: number) => MockGesture;
|
|
73
|
+
maxDuration: (ms: number) => MockGesture;
|
|
74
|
+
minDuration: (ms: number) => MockGesture;
|
|
75
|
+
onStart: (worklet: (event: MockGestureEvent) => void) => MockGesture;
|
|
76
|
+
onUpdate: (worklet: (event: MockGestureEvent) => void) => MockGesture;
|
|
77
|
+
onEnd: (worklet: (event: MockGestureEvent, success: boolean) => void) => MockGesture;
|
|
78
|
+
onFinalize: (worklet: () => void) => MockGesture;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const mockGestures: MockGesture[] = [];
|
|
82
|
+
|
|
83
|
+
jest.mock('react-native-gesture-handler', () => {
|
|
84
|
+
const build = (kind: string): MockGesture => {
|
|
85
|
+
const gesture: MockGesture = {
|
|
86
|
+
kind,
|
|
87
|
+
members: [],
|
|
88
|
+
activeOffsetX: () => gesture,
|
|
89
|
+
failOffsetY: () => gesture,
|
|
90
|
+
maxDistance: () => gesture,
|
|
91
|
+
maxDuration: () => gesture,
|
|
92
|
+
minDuration: () => gesture,
|
|
93
|
+
onStart: (worklet) => {
|
|
94
|
+
gesture.start = worklet;
|
|
95
|
+
return gesture;
|
|
96
|
+
},
|
|
97
|
+
onUpdate: (worklet) => {
|
|
98
|
+
gesture.update = worklet;
|
|
99
|
+
return gesture;
|
|
100
|
+
},
|
|
101
|
+
onEnd: (worklet) => {
|
|
102
|
+
gesture.end = worklet;
|
|
103
|
+
return gesture;
|
|
104
|
+
},
|
|
105
|
+
onFinalize: (worklet) => {
|
|
106
|
+
gesture.finalize = worklet;
|
|
107
|
+
return gesture;
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
mockGestures.push(gesture);
|
|
111
|
+
return gesture;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
Gesture: {
|
|
116
|
+
Pan: () => build('Pan'),
|
|
117
|
+
Tap: () => build('Tap'),
|
|
118
|
+
LongPress: () => build('LongPress'),
|
|
119
|
+
Race: (...members: MockGesture[]) => {
|
|
120
|
+
const race = build('Race');
|
|
121
|
+
race.members.push(...members);
|
|
122
|
+
return race;
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
GestureDetector: ({ children }: { children: React.ReactNode }) => children,
|
|
126
|
+
GestureHandlerRootView: ({ children }: { children: React.ReactNode }) => children,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const ITEMS: TabBarItem[] = [
|
|
131
|
+
{ name: 'home', label: 'Home', icon: null },
|
|
132
|
+
{ name: 'search', label: 'Search', icon: null },
|
|
133
|
+
{ name: 'you', label: 'You', icon: null },
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
/** Comfortably narrower than the tablet window, so the constraint binds. */
|
|
137
|
+
const MAX_WIDTH = 480;
|
|
138
|
+
|
|
139
|
+
/** Captures the shared minimize progress so a test can drive the bar minimized. */
|
|
140
|
+
function MinimizeProbe({ onState }: { onState: (state: MinimizeState) => void }) {
|
|
141
|
+
onState(useMinimizeState());
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function Tree({
|
|
146
|
+
maxWidth,
|
|
147
|
+
onIndexChange,
|
|
148
|
+
onState,
|
|
149
|
+
}: {
|
|
150
|
+
maxWidth?: number;
|
|
151
|
+
onIndexChange?: (index: number) => void;
|
|
152
|
+
onState?: (state: MinimizeState) => void;
|
|
153
|
+
}) {
|
|
154
|
+
return (
|
|
155
|
+
<BloomThemeProvider mode="light" colorPreset="teal">
|
|
156
|
+
<TabBarMinimizeProvider>
|
|
157
|
+
{onState ? <MinimizeProbe onState={onState} /> : null}
|
|
158
|
+
<TabBar activeIndex={0} maxWidth={maxWidth} onIndexChange={onIndexChange}>
|
|
159
|
+
{ITEMS.map((item, index) => (
|
|
160
|
+
<TabBarButton key={item.name} item={item} index={index} />
|
|
161
|
+
))}
|
|
162
|
+
</TabBar>
|
|
163
|
+
</TabBarMinimizeProvider>
|
|
164
|
+
</BloomThemeProvider>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function renderBar(props: {
|
|
169
|
+
maxWidth?: number;
|
|
170
|
+
onIndexChange?: (index: number) => void;
|
|
171
|
+
onState?: (state: MinimizeState) => void;
|
|
172
|
+
}) {
|
|
173
|
+
const utils = render(<Tree {...props} />);
|
|
174
|
+
return { ...utils, resettle: () => utils.rerender(<Tree {...props} />) };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function flattenStyle(style: unknown): Record<string, unknown> {
|
|
178
|
+
const out: Record<string, unknown> = {};
|
|
179
|
+
const visit = (value: unknown): void => {
|
|
180
|
+
if (Array.isArray(value)) value.forEach(visit);
|
|
181
|
+
else if (value && typeof value === 'object') Object.assign(out, value);
|
|
182
|
+
};
|
|
183
|
+
visit(style);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Flattened style of the one node matching `match`, which must be unique. */
|
|
188
|
+
function styleOf(
|
|
189
|
+
root: ReactTestInstance,
|
|
190
|
+
match: (style: Record<string, unknown>) => boolean,
|
|
191
|
+
what: string,
|
|
192
|
+
): Record<string, unknown> {
|
|
193
|
+
const found = root
|
|
194
|
+
.findAll((node) => typeof node.type === 'string')
|
|
195
|
+
.map((node) => flattenStyle(node.props.style))
|
|
196
|
+
.filter(match);
|
|
197
|
+
if (found.length !== 1) throw new Error(`Expected exactly one ${what}, found ${found.length}`);
|
|
198
|
+
return found[0] as Record<string, unknown>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** `styles.barWrap` — the box the pill's animated margins are applied inside. */
|
|
202
|
+
function wrapStyle(root: ReactTestInstance): Record<string, unknown> {
|
|
203
|
+
return styleOf(root, (style) => style.marginHorizontal === BAR_MARGIN, 'bar wrap');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The sliding highlight. The capsule surface also carries
|
|
208
|
+
* `borderCurve: 'continuous'`, but it is an `absoluteFill` with no width of its
|
|
209
|
+
* own — the width IS the highlight's geometry, which is what this reads.
|
|
210
|
+
*/
|
|
211
|
+
function highlightStyle(root: ReactTestInstance): Record<string, unknown> {
|
|
212
|
+
return styleOf(
|
|
213
|
+
root,
|
|
214
|
+
(style) => style.borderCurve === 'continuous' && typeof style.width === 'number',
|
|
215
|
+
'highlight',
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The item width the bar ACTUALLY rendered, read back off the highlight. */
|
|
220
|
+
function renderedItemWidth(root: ReactTestInstance): number {
|
|
221
|
+
const width = highlightStyle(root).width;
|
|
222
|
+
if (typeof width !== 'number') throw new Error('The highlight rendered no numeric width');
|
|
223
|
+
return width;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Centre of tab `index`, in the pill's own coordinates (what `event.x` uses). */
|
|
227
|
+
function centreOf(index: number, itemWidth: number): number {
|
|
228
|
+
return ROW_PAD_H + (index + 0.5) * itemWidth;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The gesture the detector was given — the last race constructed. */
|
|
232
|
+
function member(kind: string): MockGesture {
|
|
233
|
+
const race = mockGestures.filter((gesture) => gesture.kind === 'Race').pop();
|
|
234
|
+
if (!race) throw new Error('The bar composed no gesture race');
|
|
235
|
+
const found = race.members.find((gesture) => gesture.kind === kind);
|
|
236
|
+
if (!found) throw new Error(`No ${kind} gesture in the bar's race`);
|
|
237
|
+
return found;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A tap that succeeded at `x`, measured from the pill's left edge. */
|
|
241
|
+
function tapAt(x: number) {
|
|
242
|
+
act(() => {
|
|
243
|
+
member('Tap').end?.({ x }, true);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Scrub to `x` and release there — the other path through `indexAtX`. */
|
|
248
|
+
function scrubTo(x: number) {
|
|
249
|
+
act(() => {
|
|
250
|
+
member('Pan').start?.({ x });
|
|
251
|
+
member('Pan').update?.({ x });
|
|
252
|
+
member('Pan').finalize?.();
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
mockGestures.length = 0;
|
|
258
|
+
mockWindowWidth = TABLET_WIDTH;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe('TabBar maxWidth', () => {
|
|
262
|
+
describe('layout', () => {
|
|
263
|
+
it('leaves the wrap unconstrained when the prop is omitted', () => {
|
|
264
|
+
// No default: an existing consumer's bar must be the same bar, so the wrap
|
|
265
|
+
// keeps stretching to the full window rather than acquiring a width.
|
|
266
|
+
const style = wrapStyle(renderBar({}).UNSAFE_root);
|
|
267
|
+
expect(style.width).toBeUndefined();
|
|
268
|
+
expect(style.alignSelf).toBeUndefined();
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('constrains the wrap and centres it', () => {
|
|
272
|
+
const style = wrapStyle(renderBar({ maxWidth: MAX_WIDTH }).UNSAFE_root);
|
|
273
|
+
expect(style.width).toBe(MAX_WIDTH);
|
|
274
|
+
// Centring is static and belongs to the WRAP; the minimize inset stays an
|
|
275
|
+
// animated pair of equal margins on the pill inside it.
|
|
276
|
+
expect(style.alignSelf).toBe('center');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('is a ceiling, never a floor', () => {
|
|
280
|
+
// A phone narrower than the value keeps the full-bleed bar, so one value
|
|
281
|
+
// can be passed unconditionally from a shared tab layout.
|
|
282
|
+
mockWindowWidth = 375;
|
|
283
|
+
const style = wrapStyle(renderBar({ maxWidth: MAX_WIDTH }).UNSAFE_root);
|
|
284
|
+
expect(style.width).toBe(375 - BAR_MARGIN * 2);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('never lets the bar exceed the window', () => {
|
|
288
|
+
mockWindowWidth = 900;
|
|
289
|
+
const style = wrapStyle(renderBar({ maxWidth: 4000 }).UNSAFE_root);
|
|
290
|
+
expect(style.width).toBe(900 - BAR_MARGIN * 2);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('geometry', () => {
|
|
295
|
+
it('sizes the highlight from the constrained width, not the window', () => {
|
|
296
|
+
const { UNSAFE_root } = renderBar({ maxWidth: MAX_WIDTH });
|
|
297
|
+
expect(renderedItemWidth(UNSAFE_root)).toBeCloseTo((MAX_WIDTH - ROW_PAD_H * 2) / 3, 5);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('sizes it from the window when the prop is omitted', () => {
|
|
301
|
+
const { UNSAFE_root } = renderBar({});
|
|
302
|
+
const available = TABLET_WIDTH - BAR_MARGIN * 2;
|
|
303
|
+
expect(renderedItemWidth(UNSAFE_root)).toBeCloseTo((available - ROW_PAD_H * 2) / 3, 5);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('a tap lands on the tab it is visibly over', () => {
|
|
307
|
+
// THE test this prop exists for. The item width comes from the highlight
|
|
308
|
+
// the bar actually rendered, so this asserts layout and hit-testing
|
|
309
|
+
// against each other: derive either one from a different width and the
|
|
310
|
+
// centre of a rendered slot resolves to a different tab.
|
|
311
|
+
const onIndexChange = jest.fn();
|
|
312
|
+
const { UNSAFE_root } = renderBar({ maxWidth: MAX_WIDTH, onIndexChange });
|
|
313
|
+
const itemWidth = renderedItemWidth(UNSAFE_root);
|
|
314
|
+
|
|
315
|
+
for (const index of ITEMS.map((_item, i) => i)) tapAt(centreOf(index, itemWidth));
|
|
316
|
+
|
|
317
|
+
expect(onIndexChange.mock.calls).toEqual([[0], [1], [2]]);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('resolves the far right of the constrained pill to the LAST tab', () => {
|
|
321
|
+
// The same failure stated as a single number, so what regresses is legible
|
|
322
|
+
// in the diff: at 1024pt the unconstrained geometry makes each tab 330.7pt
|
|
323
|
+
// wide, and 479 is only 1.4 of those from the left — the middle tab.
|
|
324
|
+
const onIndexChange = jest.fn();
|
|
325
|
+
renderBar({ maxWidth: MAX_WIDTH, onIndexChange });
|
|
326
|
+
|
|
327
|
+
tapAt(MAX_WIDTH - 1);
|
|
328
|
+
|
|
329
|
+
expect(onIndexChange).toHaveBeenCalledWith(ITEMS.length - 1);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('resolves the far left to the FIRST tab', () => {
|
|
333
|
+
const onIndexChange = jest.fn();
|
|
334
|
+
renderBar({ maxWidth: MAX_WIDTH, onIndexChange });
|
|
335
|
+
|
|
336
|
+
tapAt(1);
|
|
337
|
+
|
|
338
|
+
expect(onIndexChange).toHaveBeenCalledWith(0);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('scrubs to the same tabs a tap would', () => {
|
|
342
|
+
// Pan, tap and long press share one `indexAtX` worklet; the scrub is the
|
|
343
|
+
// path that would drag the highlight visibly past the end of a
|
|
344
|
+
// window-width bar.
|
|
345
|
+
const onIndexChange = jest.fn();
|
|
346
|
+
const { UNSAFE_root } = renderBar({ maxWidth: MAX_WIDTH, onIndexChange });
|
|
347
|
+
const itemWidth = renderedItemWidth(UNSAFE_root);
|
|
348
|
+
|
|
349
|
+
for (const index of ITEMS.map((_item, i) => i)) scrubTo(centreOf(index, itemWidth));
|
|
350
|
+
|
|
351
|
+
expect(onIndexChange.mock.calls).toEqual([[0], [1], [2]]);
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe('composed with the minimize inset', () => {
|
|
356
|
+
/** Render constrained, drive the bar fully minimized, settle the mappers. */
|
|
357
|
+
function renderMinimized() {
|
|
358
|
+
let state: MinimizeState | undefined;
|
|
359
|
+
const onIndexChange = jest.fn();
|
|
360
|
+
const utils = renderBar({
|
|
361
|
+
maxWidth: MAX_WIDTH,
|
|
362
|
+
onIndexChange,
|
|
363
|
+
onState: (value) => {
|
|
364
|
+
state = value;
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
if (!state) throw new Error('MinimizeProbe never ran');
|
|
368
|
+
// The reanimated mock's shared values are plain boxes: a write is only
|
|
369
|
+
// visible to `useAnimatedStyle` on the NEXT render.
|
|
370
|
+
const progress = state.progress;
|
|
371
|
+
act(() => {
|
|
372
|
+
progress.value = 1;
|
|
373
|
+
});
|
|
374
|
+
utils.resettle();
|
|
375
|
+
return { ...utils, onIndexChange };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
it('shrinks the pill by the inset on BOTH sides of the constrained width', () => {
|
|
379
|
+
const { UNSAFE_root } = renderMinimized();
|
|
380
|
+
const barWidth = MAX_WIDTH - MINIMIZED_INSET * 2;
|
|
381
|
+
expect(renderedItemWidth(UNSAFE_root)).toBeCloseTo((barWidth - ROW_PAD_H * 2) / 3, 5);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it('still lands a tap on the tab it is visibly over', () => {
|
|
385
|
+
// Centring (static, on the wrap) and the inset (animated, on the pill)
|
|
386
|
+
// compose: equal margins keep the pill centred inside the constrained
|
|
387
|
+
// wrap, and `event.x` stays relative to the pill, so the same geometry
|
|
388
|
+
// holds at both ends of the minimize animation.
|
|
389
|
+
const { UNSAFE_root, onIndexChange } = renderMinimized();
|
|
390
|
+
const itemWidth = renderedItemWidth(UNSAFE_root);
|
|
391
|
+
|
|
392
|
+
for (const index of ITEMS.map((_item, i) => i)) tapAt(centreOf(index, itemWidth));
|
|
393
|
+
|
|
394
|
+
expect(onIndexChange.mock.calls).toEqual([[0], [1], [2]]);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('leaves the blur band full-bleed', () => {
|
|
399
|
+
// The band is the screen-edge scrim content dissolves into, not part of the
|
|
400
|
+
// pill — constraining it would leave a floating blurred rectangle.
|
|
401
|
+
const { UNSAFE_root } = renderBar({ maxWidth: MAX_WIDTH });
|
|
402
|
+
// The band's own root, not one of the ten layers inside it: only the root
|
|
403
|
+
// is the `pointerEvents: 'none'` box that the bar sizes.
|
|
404
|
+
const band = styleOf(
|
|
405
|
+
UNSAFE_root,
|
|
406
|
+
(style) => style.pointerEvents === 'none' && typeof style.height === 'number',
|
|
407
|
+
'blur band',
|
|
408
|
+
);
|
|
409
|
+
expect(band.left).toBe(0);
|
|
410
|
+
expect(band.right).toBe(0);
|
|
411
|
+
expect(band.width).toBeUndefined();
|
|
412
|
+
});
|
|
413
|
+
});
|