@oxyhq/bloom 0.54.0 → 0.55.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/lib/commonjs/tab-bar/TabBarBase.js +101 -16
- 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/commonjs/toast/ToastRow.js +35 -10
- package/lib/commonjs/toast/ToastRow.js.map +1 -1
- package/lib/commonjs/toast/ToastSwipeHandler.js +15 -5
- package/lib/commonjs/toast/ToastSwipeHandler.js.map +1 -1
- package/lib/commonjs/toast/constants.js +18 -1
- package/lib/commonjs/toast/constants.js.map +1 -1
- package/lib/commonjs/toast/toast-store.js +39 -1
- package/lib/commonjs/toast/toast-store.js.map +1 -1
- package/lib/commonjs/toast/use-stack-hover.js +175 -0
- package/lib/commonjs/toast/use-stack-hover.js.map +1 -0
- package/lib/commonjs/toast/use-stack-hover.native.js +42 -0
- package/lib/commonjs/toast/use-stack-hover.native.js.map +1 -0
- package/lib/module/tab-bar/TabBarBase.js +103 -18
- 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/module/toast/ToastRow.js +35 -10
- package/lib/module/toast/ToastRow.js.map +1 -1
- package/lib/module/toast/ToastSwipeHandler.js +16 -4
- package/lib/module/toast/ToastSwipeHandler.js.map +1 -1
- package/lib/module/toast/constants.js +18 -1
- package/lib/module/toast/constants.js.map +1 -1
- package/lib/module/toast/toast-store.js +39 -1
- package/lib/module/toast/toast-store.js.map +1 -1
- package/lib/module/toast/use-stack-hover.js +168 -0
- package/lib/module/toast/use-stack-hover.js.map +1 -0
- package/lib/module/toast/use-stack-hover.native.js +35 -0
- package/lib/module/toast/use-stack-hover.native.js.map +1 -0
- 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 +12 -0
- package/lib/typescript/commonjs/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/ToastRow.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts +4 -0
- package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/constants.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/toast-store.d.ts +15 -0
- package/lib/typescript/commonjs/toast/toast-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/toast/use-stack-hover.d.ts +55 -0
- package/lib/typescript/commonjs/toast/use-stack-hover.d.ts.map +1 -0
- package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts +26 -0
- package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts.map +1 -0
- 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 +12 -0
- package/lib/typescript/module/tab-bar/types.d.ts.map +1 -1
- package/lib/typescript/module/toast/ToastRow.d.ts.map +1 -1
- package/lib/typescript/module/toast/ToastSwipeHandler.d.ts +4 -0
- package/lib/typescript/module/toast/ToastSwipeHandler.d.ts.map +1 -1
- package/lib/typescript/module/toast/constants.d.ts.map +1 -1
- package/lib/typescript/module/toast/toast-store.d.ts +15 -0
- package/lib/typescript/module/toast/toast-store.d.ts.map +1 -1
- package/lib/typescript/module/toast/use-stack-hover.d.ts +55 -0
- package/lib/typescript/module/toast/use-stack-hover.d.ts.map +1 -0
- package/lib/typescript/module/toast/use-stack-hover.native.d.ts +26 -0
- package/lib/typescript/module/toast/use-stack-hover.native.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/TabBarNoSelection.test.tsx +458 -0
- package/src/__tests__/TabBarReanimatedDeps.test.ts +28 -4
- package/src/__tests__/Toaster.test.tsx +554 -0
- package/src/__tests__/animated-style-deps.test.ts +30 -6
- package/src/__tests__/toast-position-utils.test.ts +28 -0
- package/src/__tests__/toast-stack-hover.test.ts +92 -0
- package/src/__tests__/toast-store.test.ts +72 -0
- package/src/tab-bar/TabBar.stories.tsx +52 -1
- package/src/tab-bar/TabBarBase.tsx +113 -31
- package/src/tab-bar/shared.ts +12 -0
- package/src/tab-bar/types.ts +12 -0
- package/src/toast/Toast.stories.tsx +10 -0
- package/src/toast/ToastRow.tsx +33 -10
- package/src/toast/ToastSwipeHandler.tsx +14 -2
- package/src/toast/constants.ts +18 -1
- package/src/toast/toast-store.ts +39 -1
- package/src/toast/use-stack-hover.native.ts +34 -0
- package/src/toast/use-stack-hover.ts +182 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { renderHook } from '@testing-library/react-native';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
isStackHovered as isStackHoveredNative,
|
|
7
|
+
useStackHover as useStackHoverNative,
|
|
8
|
+
} from '../toast/use-stack-hover.native';
|
|
9
|
+
import { isStackHovered, useStackHover } from '../toast/use-stack-hover';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The hover trigger's platform split. jest resolves the WEB file by default (as
|
|
13
|
+
* every non-Metro bundler and a consumer's `tsc` do), so `Toaster.test.tsx` covers
|
|
14
|
+
* the web behaviour through real rows; this file covers the NATIVE fork, which has
|
|
15
|
+
* no behaviour to exercise, plus the source properties that keep the split honest.
|
|
16
|
+
*/
|
|
17
|
+
const SRC = join(__dirname, '..');
|
|
18
|
+
const read = (rel: string) => readFileSync(join(SRC, rel), 'utf8');
|
|
19
|
+
const code = (rel: string) =>
|
|
20
|
+
read(rel)
|
|
21
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
22
|
+
.replace(/^\s*\/\/.*$/gm, '');
|
|
23
|
+
|
|
24
|
+
describe('stack hover platform split', () => {
|
|
25
|
+
it('native hands back no handlers at all, for either stacking value', () => {
|
|
26
|
+
const stacked = renderHook(() => useStackHoverNative({ enableStacking: true }));
|
|
27
|
+
const flat = renderHook(() => useStackHoverNative({ enableStacking: false }));
|
|
28
|
+
|
|
29
|
+
expect(Object.keys(stacked.result.current)).toEqual([]);
|
|
30
|
+
expect(Object.keys(flat.result.current)).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('native returns ONE object identity, so the row box props never churn', () => {
|
|
34
|
+
const { result, rerender } = renderHook(() =>
|
|
35
|
+
useStackHoverNative({ enableStacking: true }),
|
|
36
|
+
);
|
|
37
|
+
const first = result.current;
|
|
38
|
+
rerender(undefined);
|
|
39
|
+
|
|
40
|
+
expect(result.current).toBe(first);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('nothing ever hovers on native', () => {
|
|
44
|
+
expect(isStackHoveredNative()).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('web hands back both pointer handlers', () => {
|
|
48
|
+
const { result } = renderHook(() => useStackHover({ enableStacking: true }));
|
|
49
|
+
|
|
50
|
+
expect(typeof result.current.onPointerEnter).toBe('function');
|
|
51
|
+
expect(typeof result.current.onPointerLeave).toBe('function');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('web is not hovered until a pointer says so', () => {
|
|
55
|
+
expect(isStackHovered()).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('the native fork carries no hover state, no timer and no store import', () => {
|
|
59
|
+
const native = code('toast/use-stack-hover.native.ts');
|
|
60
|
+
// A native bundle must not ship the web file's module-level latch or its
|
|
61
|
+
// pending-leave timeout, which is the whole reason this is a file split rather
|
|
62
|
+
// than a `Platform.OS` branch.
|
|
63
|
+
expect(native).not.toMatch(/setTimeout/);
|
|
64
|
+
expect(native).not.toMatch(/from '\.\/toast-store'/);
|
|
65
|
+
expect(native).not.toMatch(/pointerType/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('the web fork gates on a mouse pointer', () => {
|
|
69
|
+
const web = code('toast/use-stack-hover.ts');
|
|
70
|
+
// Touch fires enter before pointerdown and leave after pointerup, so without
|
|
71
|
+
// this every tap on a touch-capable web device would expand then collapse.
|
|
72
|
+
expect(web).toMatch(/pointerType === 'mouse'/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('the web fork drives the store rather than a second expansion mechanism', () => {
|
|
76
|
+
const web = code('toast/use-stack-hover.ts');
|
|
77
|
+
expect(web).toMatch(/toastStore\.expand\(\)/);
|
|
78
|
+
expect(web).toMatch(/toastStore\.collapse\(\)/);
|
|
79
|
+
// With stacking off there is nothing to expand, so the pointer pauses directly.
|
|
80
|
+
expect(web).toMatch(/toastStore\.pauseAllTimers\(\)/);
|
|
81
|
+
expect(web).toMatch(/toastStore\.resumeAllTimers\(\)/);
|
|
82
|
+
// No parallel state: expansion lives in the store and nowhere else.
|
|
83
|
+
expect(web).not.toMatch(/useState/);
|
|
84
|
+
expect(web).not.toMatch(/isExpanded\s*=/);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('the row box is the hover target and the press toggle stands down for it', () => {
|
|
88
|
+
expect(code('toast/ToastSwipeHandler.tsx')).toMatch(/\{\.\.\.hoverProps\}/);
|
|
89
|
+
// Hover owns expansion on web; a click must not toggle it shut again.
|
|
90
|
+
expect(code('toast/ToastRow.tsx')).toMatch(/else if \(!isStackHovered\(\)\)/);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -17,6 +17,9 @@ describe('toastStore', () => {
|
|
|
17
17
|
});
|
|
18
18
|
|
|
19
19
|
afterEach(() => {
|
|
20
|
+
// The expansion hold is registered by whoever owns hovering, so a test that
|
|
21
|
+
// installs one must not leak it into the next.
|
|
22
|
+
toastStore.setExpansionHold(null);
|
|
20
23
|
toastStore.dismissToast(undefined);
|
|
21
24
|
// Drains the hide-overlay timeout so the next test starts from a clean state.
|
|
22
25
|
jest.runOnlyPendingTimers();
|
|
@@ -573,6 +576,14 @@ describe('toastStore', () => {
|
|
|
573
576
|
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
574
577
|
});
|
|
575
578
|
|
|
579
|
+
/**
|
|
580
|
+
* The auto-collapse rule. A lone row renders identically expanded or collapsed
|
|
581
|
+
* (`toast-position-utils.test.ts`: "a single row is unmoved by expansion"), while
|
|
582
|
+
* `isExpanded` keeps every timer paused — so collapsing at one row is invisible
|
|
583
|
+
* and is the only thing that stops that last toast hanging on screen forever.
|
|
584
|
+
* The exception is a stack something is HOLDING open, which on web is a pointer
|
|
585
|
+
* resting on it.
|
|
586
|
+
*/
|
|
576
587
|
it('auto-collapses once only one toast is left', () => {
|
|
577
588
|
const first = toastStore.addToast({ title: 'a' });
|
|
578
589
|
toastStore.addToast({ title: 'b' });
|
|
@@ -581,6 +592,67 @@ describe('toastStore', () => {
|
|
|
581
592
|
toastStore.dismissToast(first, 'onDismiss');
|
|
582
593
|
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
583
594
|
});
|
|
595
|
+
|
|
596
|
+
it('resumes the surviving toast on that auto-collapse, so it cannot hang', () => {
|
|
597
|
+
const first = toastStore.addToast({ title: 'a', duration: 1000 });
|
|
598
|
+
toastStore.addToast({ title: 'b', duration: 1000 });
|
|
599
|
+
toastStore.expand();
|
|
600
|
+
|
|
601
|
+
toastStore.dismissToast(first, 'onDismiss');
|
|
602
|
+
jest.advanceTimersByTime(ENTERING_ANIMATION_DURATION + 1000);
|
|
603
|
+
|
|
604
|
+
expect(toastStore.getSnapshot().toasts).toHaveLength(0);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it('stays expanded while more than one toast is left', () => {
|
|
608
|
+
const first = toastStore.addToast({ title: 'a' });
|
|
609
|
+
toastStore.addToast({ title: 'b' });
|
|
610
|
+
toastStore.addToast({ title: 'c' });
|
|
611
|
+
toastStore.expand();
|
|
612
|
+
|
|
613
|
+
toastStore.dismissToast(first, 'onDismiss');
|
|
614
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
it('does not auto-collapse a stack something is holding open', () => {
|
|
618
|
+
toastStore.setExpansionHold(() => true);
|
|
619
|
+
const first = toastStore.addToast({ title: 'a', duration: 1000 });
|
|
620
|
+
toastStore.addToast({ title: 'b', duration: 1000 });
|
|
621
|
+
toastStore.expand();
|
|
622
|
+
|
|
623
|
+
toastStore.dismissToast(first, 'onDismiss');
|
|
624
|
+
|
|
625
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
626
|
+
// Still paused: resuming here is what would let a hovered toast expire under
|
|
627
|
+
// the cursor.
|
|
628
|
+
jest.advanceTimersByTime(1_000_000);
|
|
629
|
+
expect(toastStore.getSnapshot().toasts).toHaveLength(1);
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
it('resets an EMPTY stack even while held', () => {
|
|
633
|
+
// Nothing can hold open a stack that no longer exists, and once the rows
|
|
634
|
+
// unmount no `pointerleave` ever arrives to release the hold — so this must
|
|
635
|
+
// not depend on it.
|
|
636
|
+
toastStore.setExpansionHold(() => true);
|
|
637
|
+
const only = toastStore.addToast({ title: 'a' });
|
|
638
|
+
toastStore.expand();
|
|
639
|
+
|
|
640
|
+
toastStore.dismissToast(only, 'onDismiss');
|
|
641
|
+
|
|
642
|
+
expect(toastStore.getSnapshot().toasts).toHaveLength(0);
|
|
643
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
it('stops consulting a hold once it is cleared', () => {
|
|
647
|
+
toastStore.setExpansionHold(() => true);
|
|
648
|
+
toastStore.setExpansionHold(null);
|
|
649
|
+
const first = toastStore.addToast({ title: 'a' });
|
|
650
|
+
toastStore.addToast({ title: 'b' });
|
|
651
|
+
toastStore.expand();
|
|
652
|
+
|
|
653
|
+
toastStore.dismissToast(first, 'onDismiss');
|
|
654
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
655
|
+
});
|
|
584
656
|
});
|
|
585
657
|
|
|
586
658
|
describe('subscription', () => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React, { useState, type PropsWithChildren } from 'react';
|
|
2
|
-
import { ScrollView, Text, View } from 'react-native';
|
|
2
|
+
import { Pressable, ScrollView, Text, View } from 'react-native';
|
|
3
3
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|
4
4
|
import Animated from 'react-native-reanimated';
|
|
5
5
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
|
@@ -177,6 +177,52 @@ function MinimizeScreen() {
|
|
|
177
177
|
);
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Body of {@link NoSelection} — the consumer shape that exposed the bug.
|
|
182
|
+
*
|
|
183
|
+
* An index derived from the current route is `-1` on every screen outside the
|
|
184
|
+
* tab set, and the capsule used to slide one item-width to the LEFT of the first
|
|
185
|
+
* tab and poke out of the pill. What to watch for, none of which jest can see:
|
|
186
|
+
* it fades out WHERE IT STANDS rather than travelling off, no glyph or label
|
|
187
|
+
* stays lit while it is gone, and picking a tab again fades it back in AT that
|
|
188
|
+
* tab instead of sliding across the bar to reach it. Scrubbing from the
|
|
189
|
+
* off-tab state arms it under the finger.
|
|
190
|
+
*/
|
|
191
|
+
function NoSelectionScreen() {
|
|
192
|
+
const { colors } = useTheme();
|
|
193
|
+
const [activeIndex, setActiveIndex] = useState(0);
|
|
194
|
+
const offTab = activeIndex < 0;
|
|
195
|
+
return (
|
|
196
|
+
<Screen>
|
|
197
|
+
<ScrollView contentContainerStyle={CONTENT_STYLE}>
|
|
198
|
+
<Pressable
|
|
199
|
+
onPress={() => setActiveIndex(offTab ? 0 : -1)}
|
|
200
|
+
style={{
|
|
201
|
+
padding: 16,
|
|
202
|
+
borderRadius: 16,
|
|
203
|
+
borderWidth: 1,
|
|
204
|
+
borderColor: colors.border,
|
|
205
|
+
backgroundColor: colors.backgroundSecondary,
|
|
206
|
+
}}
|
|
207
|
+
>
|
|
208
|
+
<Text style={{ color: colors.text, fontSize: 15, fontWeight: '600' }}>
|
|
209
|
+
{offTab ? 'Back to the Home tab' : 'Open a screen that is not a tab (index -1)'}
|
|
210
|
+
</Text>
|
|
211
|
+
</Pressable>
|
|
212
|
+
<Feed
|
|
213
|
+
heading={offTab ? 'Not a tab' : (ITEMS[activeIndex]?.label ?? 'Feed')}
|
|
214
|
+
caption={
|
|
215
|
+
offTab
|
|
216
|
+
? 'No tab is selected: the highlight has faded out where it stood and nothing is tinted. Tap a tab — it fades back in there rather than sliding across — or drag across the bar to arm it under the finger.'
|
|
217
|
+
: 'Open the screen above to leave the tab set, the way a post detail or a settings route does.'
|
|
218
|
+
}
|
|
219
|
+
/>
|
|
220
|
+
</ScrollView>
|
|
221
|
+
<ControlledBar activeIndex={activeIndex} onIndexChange={setActiveIndex} />
|
|
222
|
+
</Screen>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
180
226
|
function ThemeOverrideScreen() {
|
|
181
227
|
const { colors } = useTheme();
|
|
182
228
|
const [activeIndex, setActiveIndex] = useState(1);
|
|
@@ -211,6 +257,11 @@ export const MinimizeOnScroll: Story = {
|
|
|
211
257
|
),
|
|
212
258
|
};
|
|
213
259
|
|
|
260
|
+
/** No tab selected (`activeIndex` naming no tab): the highlight fades out in place. */
|
|
261
|
+
export const NoSelection: Story = {
|
|
262
|
+
render: () => <NoSelectionScreen />,
|
|
263
|
+
};
|
|
264
|
+
|
|
214
265
|
/** A partial `theme` override on top of the token-derived defaults. */
|
|
215
266
|
export const ThemeOverride: Story = {
|
|
216
267
|
render: () => <ThemeOverrideScreen />,
|
|
@@ -32,6 +32,7 @@ import Animated, {
|
|
|
32
32
|
useAnimatedStyle,
|
|
33
33
|
useSharedValue,
|
|
34
34
|
withSpring,
|
|
35
|
+
withTiming,
|
|
35
36
|
type SharedValue,
|
|
36
37
|
} from 'react-native-reanimated';
|
|
37
38
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
BLUR_BLEED,
|
|
45
46
|
EXPANDED_HEIGHT,
|
|
46
47
|
HIGHLIGHT_EXPANDED,
|
|
48
|
+
HIGHLIGHT_FADE,
|
|
47
49
|
HIGHLIGHT_MINIMIZED,
|
|
48
50
|
ICON_SIZE,
|
|
49
51
|
ITEM_GAP,
|
|
@@ -69,6 +71,12 @@ import type { TabBarButtonProps, TabBarProps, TabBarTheme } from './types';
|
|
|
69
71
|
type BarContextValue = {
|
|
70
72
|
/** Live highlight position, in tab units. Fractional while scrubbing. */
|
|
71
73
|
slideIndex: SharedValue<number>;
|
|
74
|
+
/**
|
|
75
|
+
* Highlight visibility, 0–1. Below 1 only when the bar has NO selection (an
|
|
76
|
+
* `activeIndex` that names no tab). Buttons fold it into their own active
|
|
77
|
+
* tint so a faded-out capsule leaves no glyph and no label lit.
|
|
78
|
+
*/
|
|
79
|
+
highlightOpacity: SharedValue<number>;
|
|
72
80
|
/** True while a scrub is in progress — the finger owns the highlight. */
|
|
73
81
|
isDragging: SharedValue<boolean>;
|
|
74
82
|
theme: TabBarTheme;
|
|
@@ -107,10 +115,34 @@ function TabBarBody({
|
|
|
107
115
|
const { width: windowWidth } = useWindowDimensions();
|
|
108
116
|
const minimized = useMinimizeState();
|
|
109
117
|
const progress = minimized.progress;
|
|
118
|
+
const tabCount = Math.max(Children.count(children), 1);
|
|
119
|
+
|
|
120
|
+
// Is a tab selected at all?
|
|
121
|
+
//
|
|
122
|
+
// The two states this distinguishes are NOT the same thing, and collapsing
|
|
123
|
+
// them would silently kill the highlight for every router consumer:
|
|
124
|
+
//
|
|
125
|
+
// - `activeIndex === undefined` is the FOCUS-DRIVEN path. The bar is not
|
|
126
|
+
// the writer at all — each `TabBarButton` supplies `isFocused` and drives
|
|
127
|
+
// the highlight itself (that is how the adapter keeps it correct through
|
|
128
|
+
// deep links and back gestures), so from the bar's side there is always a
|
|
129
|
+
// selection and the highlight is always visible.
|
|
130
|
+
// - NO SELECTION is `activeIndex` being a number that names no tab:
|
|
131
|
+
// negative, past the last tab, or fractional. Every consumer whose route
|
|
132
|
+
// set is larger than its tab set produces one — a `usePathname()`-derived
|
|
133
|
+
// index is -1 on every screen that is not a tab — and the highlight must
|
|
134
|
+
// then be gone rather than parked outside the pill.
|
|
135
|
+
const hasSelection =
|
|
136
|
+
activeIndex === undefined ||
|
|
137
|
+
(Number.isInteger(activeIndex) && activeIndex >= 0 && activeIndex < tabCount);
|
|
138
|
+
|
|
110
139
|
const slideIndex = useSharedValue(0);
|
|
140
|
+
// Seeded from the CURRENT state, not from 0: a bar that mounts with a
|
|
141
|
+
// selection must show its highlight on the first frame exactly as it always
|
|
142
|
+
// has, and one that mounts without a selection must never flash it.
|
|
143
|
+
const highlightOpacity = useSharedValue(hasSelection ? 1 : 0);
|
|
111
144
|
const isDragging = useSharedValue(false);
|
|
112
145
|
const lastTicked = useSharedValue(-1);
|
|
113
|
-
const tabCount = Math.max(Children.count(children), 1);
|
|
114
146
|
const theme = useTabBarTheme(themeOverrides);
|
|
115
147
|
const impact = useHaptics();
|
|
116
148
|
|
|
@@ -160,8 +192,26 @@ function TabBarBody({
|
|
|
160
192
|
if (activeIndex === undefined) return;
|
|
161
193
|
// While scrubbing the finger owns the highlight; never fight it.
|
|
162
194
|
if (isDragging.value) return;
|
|
163
|
-
|
|
164
|
-
|
|
195
|
+
if (!hasSelection) {
|
|
196
|
+
// Fade out where it stands. `slideIndex` is deliberately left alone: it
|
|
197
|
+
// is the highlight's POSITION, and there is no position that means
|
|
198
|
+
// "nowhere" — springing it to a sentinel would drag the capsule across
|
|
199
|
+
// the bar on its way out, dragging the active tint over every tab it
|
|
200
|
+
// passed. Visibility is the thing that changed, so visibility is the only
|
|
201
|
+
// thing that animates.
|
|
202
|
+
highlightOpacity.value = withTiming(0, HIGHLIGHT_FADE);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
// Coming back from fully hidden the capsule APPEARS at the new tab instead
|
|
206
|
+
// of travelling to it: a slide says "the selection moved from here to
|
|
207
|
+
// there", and while it was invisible there was no "here" — sliding from the
|
|
208
|
+
// stale index would animate out of a position the user never saw, and would
|
|
209
|
+
// light up every tab in between on the way. Interrupted mid-fade it is
|
|
210
|
+
// still on screen, so from there it slides as it always does.
|
|
211
|
+
slideIndex.value =
|
|
212
|
+
highlightOpacity.value === 0 ? activeIndex : withSpring(activeIndex, SLIDE_SPRING);
|
|
213
|
+
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
214
|
+
}, [activeIndex, hasSelection, slideIndex, highlightOpacity, isDragging]);
|
|
165
215
|
|
|
166
216
|
// Scrubbing: the highlight tracks the finger 1:1 while dragging (no spring —
|
|
167
217
|
// it must feel attached), haptic ticks fire on boundary crossings, and
|
|
@@ -192,8 +242,17 @@ function TabBarBody({
|
|
|
192
242
|
const pan = Gesture.Pan()
|
|
193
243
|
.activeOffsetX([-6, 6])
|
|
194
244
|
.failOffsetY([-14, 14])
|
|
195
|
-
.onStart(() => {
|
|
245
|
+
.onStart((event) => {
|
|
196
246
|
isDragging.value = true;
|
|
247
|
+
// Arming from the no-selection state: a hidden capsule has no position
|
|
248
|
+
// the finger can pick up, so it starts under the finger rather than at
|
|
249
|
+
// the index it faded out on — which would also make the first boundary
|
|
250
|
+
// tick fire against a phantom position. Mid-fade it is still visible,
|
|
251
|
+
// so it keeps the position it is at, exactly as it always has.
|
|
252
|
+
if (highlightOpacity.value === 0) {
|
|
253
|
+
slideIndex.value = indexAtX(event.x, progress.value);
|
|
254
|
+
}
|
|
255
|
+
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
197
256
|
lastTicked.value = Math.round(slideIndex.value);
|
|
198
257
|
// Scrubbing is a deliberate bar interaction — surface the labels.
|
|
199
258
|
setMinimized(minimized, 0);
|
|
@@ -230,7 +289,11 @@ function TabBarBody({
|
|
|
230
289
|
return;
|
|
231
290
|
}
|
|
232
291
|
const index = Math.round(indexAtX(event.x, progress.value));
|
|
233
|
-
|
|
292
|
+
// Same rule as the controlled path above: appear at the tapped tab when
|
|
293
|
+
// hidden, slide to it when already on screen.
|
|
294
|
+
slideIndex.value =
|
|
295
|
+
highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
296
|
+
highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
234
297
|
setMinimized(minimized, 0);
|
|
235
298
|
runOnJS(selectIndex)(index);
|
|
236
299
|
});
|
|
@@ -263,6 +326,7 @@ function TabBarBody({
|
|
|
263
326
|
isDragging,
|
|
264
327
|
lastTicked,
|
|
265
328
|
slideIndex,
|
|
329
|
+
highlightOpacity,
|
|
266
330
|
minimized,
|
|
267
331
|
progress,
|
|
268
332
|
]);
|
|
@@ -344,9 +408,14 @@ function TabBarBody({
|
|
|
344
408
|
width: itemWidth,
|
|
345
409
|
borderRadius: height / 2,
|
|
346
410
|
top: (barHeight - height) / 2,
|
|
411
|
+
// With no selection there is nothing to highlight, and the capsule has to
|
|
412
|
+
// stop being drawn: `slideIndex` is a position in tab units, so an
|
|
413
|
+
// out-of-range one is a real place — one item-width to the LEFT of the
|
|
414
|
+
// first tab, i.e. half outside the pill — not an absence.
|
|
415
|
+
opacity: highlightOpacity.value,
|
|
347
416
|
transform: [{ translateX: ROW_PAD_H + itemWidth * slideIndex.value }],
|
|
348
417
|
};
|
|
349
|
-
}, [progress, slideIndex, barOuterWidth, tabCount]);
|
|
418
|
+
}, [progress, slideIndex, highlightOpacity, barOuterWidth, tabCount]);
|
|
350
419
|
|
|
351
420
|
// Shared with `useTabBarFootprint`, so a consumer accounting for the bar in
|
|
352
421
|
// its own layout can never drift from where the bar actually sits.
|
|
@@ -366,8 +435,8 @@ function TabBarBody({
|
|
|
366
435
|
const constrainedWrapStyle: ViewStyle | null =
|
|
367
436
|
maxWidth === undefined ? null : { width: barOuterWidth, alignSelf: 'center' };
|
|
368
437
|
const barContext = useMemo(
|
|
369
|
-
() => ({ slideIndex, isDragging, theme, activeIndex, selectIndex }),
|
|
370
|
-
[slideIndex, isDragging, theme, activeIndex, selectIndex],
|
|
438
|
+
() => ({ slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex }),
|
|
439
|
+
[slideIndex, highlightOpacity, isDragging, theme, activeIndex, selectIndex],
|
|
371
440
|
);
|
|
372
441
|
|
|
373
442
|
return (
|
|
@@ -431,6 +500,7 @@ function TabBarButtonBody({
|
|
|
431
500
|
const standaloneTheme = useTabBarTheme();
|
|
432
501
|
const theme = bar?.theme ?? standaloneTheme;
|
|
433
502
|
const slideIndex = bar?.slideIndex;
|
|
503
|
+
const highlightOpacity = bar?.highlightOpacity;
|
|
434
504
|
// The two paths meet here: an explicit `isFocused` (router adapter) wins;
|
|
435
505
|
// otherwise focus comes from the bar's controlled `activeIndex`.
|
|
436
506
|
const focused = isFocused ?? (bar?.activeIndex === index);
|
|
@@ -449,29 +519,35 @@ function TabBarButtonBody({
|
|
|
449
519
|
// Tint follows the sliding highlight, not navigation focus: whatever the pill
|
|
450
520
|
// is over lights up — live while scrubbing, traveling on taps. Without a bar
|
|
451
521
|
// there is nothing to follow, so it falls back to plain focus.
|
|
522
|
+
//
|
|
523
|
+
// Scaled by the highlight's own visibility, so a bar with NO selection leaves
|
|
524
|
+
// nothing lit: distance alone would keep the tab the capsule faded out on
|
|
525
|
+
// fully tinted, which is the same bug as a stray capsule wearing a different
|
|
526
|
+
// hat — a tab that looks selected while nothing is.
|
|
452
527
|
// (Deps: see the CRITICAL note in `TabBarBody`.)
|
|
453
|
-
const activeGlyphStyle = useAnimatedStyle(
|
|
454
|
-
()
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
()
|
|
462
|
-
opacity:
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
528
|
+
const activeGlyphStyle = useAnimatedStyle(() => {
|
|
529
|
+
if (!slideIndex || !highlightOpacity) return { opacity: focused ? 1 : 0 };
|
|
530
|
+
const proximity = 1 - Math.min(Math.abs(slideIndex.value - index), 1);
|
|
531
|
+
return { opacity: highlightOpacity.value * proximity };
|
|
532
|
+
}, [slideIndex, highlightOpacity, index, focused]);
|
|
533
|
+
|
|
534
|
+
const labelStyle = useAnimatedStyle(() => {
|
|
535
|
+
const opacity = interpolate(progress.value, [0, 0.4], [1, 0], Extrapolation.CLAMP);
|
|
536
|
+
if (!slideIndex || !highlightOpacity) {
|
|
537
|
+
return { opacity, color: focused ? theme.activeTint : theme.inactiveTint };
|
|
538
|
+
}
|
|
539
|
+
// Same quantity as the glyph crossfade above, so a label can never disagree
|
|
540
|
+
// with the icon it sits under.
|
|
541
|
+
const proximity = 1 - Math.min(Math.abs(slideIndex.value - index), 1);
|
|
542
|
+
return {
|
|
543
|
+
opacity,
|
|
544
|
+
color: interpolateColor(
|
|
545
|
+
highlightOpacity.value * proximity,
|
|
546
|
+
[0, 1],
|
|
547
|
+
[theme.inactiveTint, theme.activeTint],
|
|
548
|
+
),
|
|
549
|
+
};
|
|
550
|
+
}, [progress, slideIndex, highlightOpacity, index, focused, theme]);
|
|
475
551
|
|
|
476
552
|
// Height is animated EXPLICITLY (not derived from children) so the icon stays
|
|
477
553
|
// perfectly centered every frame — layout-driven sizing lags a frame behind
|
|
@@ -499,7 +575,13 @@ function TabBarButtonBody({
|
|
|
499
575
|
onPress={(event) => {
|
|
500
576
|
// The bar's GestureDetector normally consumes touches; this still fires
|
|
501
577
|
// for assistive-technology activation (VoiceOver) and keyboard focus.
|
|
502
|
-
if (bar)
|
|
578
|
+
if (bar) {
|
|
579
|
+
// Appear at the tab when hidden, slide to it when visible — the same
|
|
580
|
+
// rule the tap gesture and the controlled path follow.
|
|
581
|
+
bar.slideIndex.value =
|
|
582
|
+
bar.highlightOpacity.value === 0 ? index : withSpring(index, SLIDE_SPRING);
|
|
583
|
+
bar.highlightOpacity.value = withTiming(1, HIGHLIGHT_FADE);
|
|
584
|
+
}
|
|
503
585
|
setMinimized(minimized, 0);
|
|
504
586
|
// Controlled path only. On the focus-driven path the trigger's own
|
|
505
587
|
// `onPress` below performs the navigation, so reporting the selection
|
package/src/tab-bar/shared.ts
CHANGED
|
@@ -79,6 +79,18 @@ export function tabBarBottomGap(bottomInset: number): number {
|
|
|
79
79
|
*/
|
|
80
80
|
export const SLIDE_SPRING = { duration: 420, dampingRatio: 0.82 };
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Fade for the highlight coming and going when the selection does — an
|
|
84
|
+
* `activeIndex` that names no tab (see `TabBarProps.activeIndex`).
|
|
85
|
+
*
|
|
86
|
+
* A timing, not a spring, and deliberately shorter than {@link SLIDE_SPRING}:
|
|
87
|
+
* appearing and disappearing is a change of STATE, not a movement, so it must
|
|
88
|
+
* not read as a second animation racing the slide. The capsule fades where it
|
|
89
|
+
* stands — moving it out of view instead would drag it the length of the bar on
|
|
90
|
+
* its way out and sweep the active tint across every tab it passed.
|
|
91
|
+
*/
|
|
92
|
+
export const HIGHLIGHT_FADE = { duration: 160 };
|
|
93
|
+
|
|
82
94
|
/**
|
|
83
95
|
* How long a press must be held before `onIndexLongPress` fires.
|
|
84
96
|
*
|
package/src/tab-bar/types.ts
CHANGED
|
@@ -78,6 +78,18 @@ export type TabBarProps = ViewProps & {
|
|
|
78
78
|
* highlight and springs it here whenever this changes. Use this when there is
|
|
79
79
|
* no router in play; the router adapter uses the per-button `isFocused` path
|
|
80
80
|
* instead (see {@link TabBarButtonProps.isFocused}) and must NOT pass this.
|
|
81
|
+
*
|
|
82
|
+
* An index that names NO tab — negative, past the last tab, or fractional —
|
|
83
|
+
* means no selection: the highlight fades out where it stands and no tab is
|
|
84
|
+
* left tinted. Pass one whenever the current screen is not a tab; an index
|
|
85
|
+
* derived from the route (`TABS.findIndex(…)`) is `-1` on every such screen
|
|
86
|
+
* and is correct as-is. Returning to a real index fades the highlight back in
|
|
87
|
+
* AT that tab rather than sliding to it — while it was invisible there was no
|
|
88
|
+
* position to travel from — and scrubbing still arms it under the finger.
|
|
89
|
+
*
|
|
90
|
+
* OMITTING this prop is a different thing entirely and does not hide
|
|
91
|
+
* anything: that is the focus-driven path, where each button drives the
|
|
92
|
+
* highlight from its own `isFocused`.
|
|
81
93
|
*/
|
|
82
94
|
activeIndex?: number;
|
|
83
95
|
/**
|
|
@@ -26,6 +26,14 @@ import type { ToasterProps } from './types';
|
|
|
26
26
|
* unstacked they must be ~54px apart (46px measured row + 8px gap), collapsed-
|
|
27
27
|
* stack ~8px apart. Equal `y` values mean the rows are drawn on top of each
|
|
28
28
|
* other — the failure this shape exists to catch.
|
|
29
|
+
*
|
|
30
|
+
* `Sequential*` gives you BOTH durations for a reason. The `One`/`Two` buttons use
|
|
31
|
+
* `duration: Infinity` so a row stays put while you inspect it — but that starts no
|
|
32
|
+
* auto-close timer at all, so nothing driven from those buttons can see the timer
|
|
33
|
+
* path. `One 3s`/`Two 3s` fire at the outlet's real duration; drive those when the
|
|
34
|
+
* question is a row's LIFETIME rather than its position. (`Toaster.test.tsx`'s
|
|
35
|
+
* "sequential toasts at the default duration" block covers the render set there;
|
|
36
|
+
* only a browser can judge the geometry.)
|
|
29
37
|
*/
|
|
30
38
|
const meta: Meta = {
|
|
31
39
|
title: 'Components/Toast',
|
|
@@ -249,6 +257,8 @@ function SequentialDemo({ outlet }: { outlet?: ToasterProps }) {
|
|
|
249
257
|
<Demo outlet={outlet}>
|
|
250
258
|
<Button onPress={() => toast('Row one', { duration: Infinity })}>One</Button>
|
|
251
259
|
<Button onPress={() => toast('Row two', { duration: Infinity })}>Two</Button>
|
|
260
|
+
<Button onPress={() => toast('Row one')}>One 3s</Button>
|
|
261
|
+
<Button onPress={() => toast('Row two')}>Two 3s</Button>
|
|
252
262
|
</Demo>
|
|
253
263
|
);
|
|
254
264
|
}
|
package/src/toast/ToastRow.tsx
CHANGED
|
@@ -60,6 +60,7 @@ import {
|
|
|
60
60
|
} from './constants';
|
|
61
61
|
import { useDynamicToastContext, useToastContext } from './context';
|
|
62
62
|
import { calculateStackScaleX } from './position-utils';
|
|
63
|
+
import { isStackHovered } from './use-stack-hover';
|
|
63
64
|
import { useAnimatedTarget } from './use-animated-target';
|
|
64
65
|
import { ToastContent } from './ToastContent';
|
|
65
66
|
import { ToastSwipeHandler } from './ToastSwipeHandler';
|
|
@@ -354,18 +355,41 @@ export const ToastRow = React.forwardRef<ToastRef, ToastRowProps>(
|
|
|
354
355
|
}
|
|
355
356
|
}, [id, isExpanded]);
|
|
356
357
|
|
|
358
|
+
/**
|
|
359
|
+
* INVARIANT: NO TAP ON A STACKED ROW IS INERT. Every one of them dismisses,
|
|
360
|
+
* expands or collapses.
|
|
361
|
+
*
|
|
362
|
+
* The close-button strip dismisses only when a ✕ is actually THERE to aim at,
|
|
363
|
+
* which is exactly `closeButton && dismissible` — the same condition
|
|
364
|
+
* `ToastContent` renders it under, and nothing to do with expansion. Anything
|
|
365
|
+
* else in the strip falls through to the row's own behaviour rather than being
|
|
366
|
+
* swallowed: on a collapsed stack it expands, on an expanded one it collapses.
|
|
367
|
+
*
|
|
368
|
+
* Guarding the strip on `isExpanded` (as this did until now) made two taps do
|
|
369
|
+
* nothing at all — the whole strip at default config, where `closeButton` is
|
|
370
|
+
* `false` so the dismiss branch is unreachable, and the VISIBLE ✕ on a
|
|
371
|
+
* collapsed stack's front row. On Android that second one has no fallback:
|
|
372
|
+
* the RNGH tap wins the race against `ToastContent`'s nested close Pressable,
|
|
373
|
+
* so the ✕ never got its own press either.
|
|
374
|
+
*
|
|
375
|
+
* `x` is relative to the row, which is CAPPED at `TOAST_MAX_ROW_WIDTH`.
|
|
376
|
+
* Measuring the strip from the window edge would put it outside the row
|
|
377
|
+
* entirely on a wide viewport — do not "simplify" this back to `screenWidth`.
|
|
378
|
+
*
|
|
379
|
+
* W13 — the expansion TOGGLE stands down while a mouse is hovering the stack,
|
|
380
|
+
* because hover already owns expansion there (`use-stack-hover`). Without this
|
|
381
|
+
* a desktop click would collapse the stack under the cursor and resume the
|
|
382
|
+
* timers hover had just paused. Dismissing is unaffected, `onPress` always
|
|
383
|
+
* runs, and `isStackHovered()` is always false on native and on any touch web
|
|
384
|
+
* device — so press remains the only trigger wherever there is no pointer.
|
|
385
|
+
*/
|
|
357
386
|
const onSwipePress = React.useCallback(
|
|
358
387
|
({ x }: { x: number; y: number }) => {
|
|
359
388
|
if (enableStacking && numberOfToasts > 1 && position !== 'center') {
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
if (x > rowWidth - CLOSE_BUTTON_HIT_AREA) {
|
|
365
|
-
if (isExpanded && closeButton && dismissible) {
|
|
366
|
-
onDismiss(id);
|
|
367
|
-
}
|
|
368
|
-
} else {
|
|
389
|
+
const inCloseStrip = x > rowWidth - CLOSE_BUTTON_HIT_AREA;
|
|
390
|
+
if (inCloseStrip && closeButton && dismissible) {
|
|
391
|
+
onDismiss(id);
|
|
392
|
+
} else if (!isStackHovered()) {
|
|
369
393
|
toggleExpand();
|
|
370
394
|
}
|
|
371
395
|
}
|
|
@@ -376,7 +400,6 @@ export const ToastRow = React.forwardRef<ToastRef, ToastRowProps>(
|
|
|
376
400
|
numberOfToasts,
|
|
377
401
|
position,
|
|
378
402
|
rowWidth,
|
|
379
|
-
isExpanded,
|
|
380
403
|
closeButton,
|
|
381
404
|
dismissible,
|
|
382
405
|
onDismiss,
|