Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions example/src/screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
} from '../sheets';
import { colors, sharedStyles } from '../styles/theme';
import { CloseInterceptionDemo } from '../sheets/CloseInterceptionSheets';
import { PartialCloseDemo } from '../sheets/PartialCloseSheets';

export function HomeScreen() {
const { top } = useSafeAreaInsets();
Expand Down Expand Up @@ -167,6 +168,14 @@ export function HomeScreen() {
persistentWithPortalControl.open({ scaleBackground: true })
}
/>
<DemoCard
title="Partial Close"
description="closeTo / closeDepth / closeAbove and every edge case"
color={colors.cyan}
onPress={() =>
open(<PartialCloseDemo />, { scaleBackground: true })
}
/>
<DemoCard
title="Close Interception"
description="onBeforeClose interceptors and closeAll() with cascade animation"
Expand Down
158 changes: 158 additions & 0 deletions example/src/sheets/PartialCloseSheets.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type { BottomSheetMethods } from '@gorhom/bottom-sheet/lib/typescript/types';
import { forwardRef, useState } from 'react';
import { StyleSheet, Switch, Text, View } from 'react-native';
import {
useBottomSheetContext,
useBottomSheetManager,
useOnBeforeClose,
type CloseAllResult,
} from 'react-native-bottom-sheet-stack';

import { Badge, Button, SecondaryButton, Sheet } from '../components';
import { colors, sharedStyles } from '../styles/theme';

/**
* Every bounded-cascade case in one stack.
*
* Each level knows its own depth and the ID of the bottom one, so an outcome is
* readable off the screen: close down to level 1 and everything above it goes
* while level 1 stays. The result of the last call is printed at the level that
* made it — the point is to see `completed` and `stoppedAt`, not just the
* animation.
*/

function formatResult(result: CloseAllResult): string {
const closed = result.closed.length ? result.closed.join(', ') : '(none)';
const stopped = result.stoppedAt ? `\nstoppedAt: ${result.stoppedAt}` : '';
return `completed: ${result.completed}\nclosed: [${closed}]${stopped}`;
}

export const PartialCloseDemo = forwardRef<BottomSheetMethods>((_, ref) => (
<PartialCloseLevel ref={ref} level={1} />
));

interface LevelProps {
level: number;
/** ID of level 1, threaded down so deeper levels can aim `closeTo` at it. */
rootId?: string;
}

const PartialCloseLevel = forwardRef<BottomSheetMethods, LevelProps>(
({ level, rootId }, ref) => {
const { open, closeAll, closeTo, closeDepth } = useBottomSheetManager();
const { id, close, closeAbove } = useBottomSheetContext();

const [lastResult, setLastResult] = useState('no call yet');
const [inclusive, setInclusive] = useState(false);
const [blocking, setBlocking] = useState(false);

// Lets a level refuse, so a cascade can be stopped mid-way and `stoppedAt`
// observed rather than taken on faith.
useOnBeforeClose(({ onCancel, onConfirm }) =>
blocking ? onCancel() : onConfirm()
);

const target = rootId ?? id;

const report = async (call: Promise<CloseAllResult>) =>
setLastResult(formatResult(await call));

const pushLevel = () =>
open(<PartialCloseLevel level={level + 1} rootId={target} />, {
mode: 'push',
scaleBackground: true,
});

return (
<Sheet ref={ref}>
<View style={styles.badges}>
<Badge label={`level ${level}`} color={colors.primary} />
{blocking ? <Badge label="blocking" color={colors.warning} /> : null}
</View>

<Text style={sharedStyles.h1}>Partial close · level {level}</Text>
<Text style={sharedStyles.text}>
Push a few levels, then close part of the stack.
</Text>

<View style={sharedStyles.contextBox}>
<Text style={sharedStyles.contextTitle}>LAST RESULT</Text>
<Text style={sharedStyles.contextValue}>{lastResult}</Text>
</View>

<View style={styles.toggle}>
<Text style={sharedStyles.text}>Refuse to close (interceptor)</Text>
<Switch value={blocking} onValueChange={setBlocking} />
</View>
<View style={styles.toggle}>
<Text style={sharedStyles.text}>inclusive</Text>
<Switch value={inclusive} onValueChange={setInclusive} />
</View>

<Button title="Push next level" onPress={pushLevel} />

<Text style={sharedStyles.contextTitle}>FROM INSIDE THIS SHEET</Text>
<Button
title="closeAbove() — everything above me"
onPress={() => report(closeAbove({ stagger: 120, inclusive }))}
/>

<Text style={sharedStyles.contextTitle}>BOUNDED BY ID</Text>
<Button
title="closeTo(level 1)"
onPress={() => report(closeTo(target, { stagger: 120, inclusive }))}
/>
<Button
title="closeTo('missing') — unknown id closes nothing"
onPress={() => report(closeTo('missing', { stagger: 120 }))}
/>

<Text style={sharedStyles.contextTitle}>BOUNDED BY COUNT</Text>
<Button
title="closeDepth(1)"
onPress={() => report(closeDepth(1, { stagger: 120 }))}
/>
<Button
title="closeDepth(3)"
onPress={() => report(closeDepth(3, { stagger: 120 }))}
/>
<Button
title="closeDepth(0) — closes nothing"
onPress={() => report(closeDepth(0, { stagger: 120 }))}
/>
<Button
title="closeDepth(99) — clamps to the stack"
onPress={() => report(closeDepth(99, { stagger: 120 }))}
/>

<Text style={sharedStyles.contextTitle}>BOTH BOUNDS</Text>
<Button
title="until level 1 + depth 1 — narrower wins"
onPress={() =>
report(closeAll({ stagger: 120, until: target, depth: 1 }))
}
/>

<SecondaryButton
title="closeAll()"
onPress={() => report(closeAll({ stagger: 120 }))}
/>
<SecondaryButton title="Close this one" onPress={close} />
</Sheet>
);
}
);

const styles = StyleSheet.create({
badges: {
flexDirection: 'row',
gap: 8,
marginBottom: 8,
},
toggle: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: 6,
},
});
1 change: 1 addition & 0 deletions example/src/sheets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ export {
FormSheet,
ReadOnlySheet,
} from './CloseInterceptionSheets';
export { PartialCloseDemo } from './PartialCloseSheets';
141 changes: 137 additions & 4 deletions src/__tests__/coordinator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ describe('closeAllAnimated', () => {

const result = await closeAllAnimated('g1', { stagger: 0 });

expect(result).toEqual({ closedAll: true, closed: ['c', 'b', 'a'] });
expect(result).toEqual({ completed: true, closed: ['c', 'b', 'a'] });
expect(statusOf('a')).toBe('closing');
expect(statusOf('c')).toBe('closing');
});
Expand All @@ -118,7 +118,7 @@ describe('closeAllAnimated', () => {
const result = await closeAllAnimated('g1', { stagger: 0 });

expect(result).toEqual({
closedAll: false,
completed: false,
closed: ['c'],
stoppedAt: 'b',
});
Expand All @@ -127,6 +127,139 @@ describe('closeAllAnimated', () => {
expect(statusOf('b')).toBe('open');
});

describe('bounded cascades', () => {
it('closes down to `until`, leaving it and everything below open', async () => {
openAndSettle('a');
openAndSettle('b');
openAndSettle('c');

const result = await closeAllAnimated('g1', { stagger: 0, until: 'b' });

expect(result).toEqual({ completed: true, closed: ['c'] });
expect(statusOf('b')).toBe('open');
expect(statusOf('a')).toBe('open');
});

it('closes `until` itself when inclusive', async () => {
openAndSettle('a');
openAndSettle('b');
openAndSettle('c');

const result = await closeAllAnimated('g1', {
stagger: 0,
until: 'b',
inclusive: true,
});

expect(result).toEqual({ completed: true, closed: ['c', 'b'] });
expect(statusOf('a')).toBe('open');
});

it('closes nothing when `until` is the topmost sheet', async () => {
openAndSettle('a');
openAndSettle('b');

const result = await closeAllAnimated('g1', { stagger: 0, until: 'b' });

expect(result).toEqual({ completed: true, closed: [] });
expect(statusOf('b')).toBe('open');
});

// A bounded call that silently emptied the group would be the opposite of
// what it asked for — far worse than doing nothing.
it('closes nothing when `until` is not on the stack', async () => {
openAndSettle('a');
openAndSettle('b');

const result = await closeAllAnimated('g1', {
stagger: 0,
until: 'nope',
});

expect(result).toEqual({ completed: true, closed: [] });
expect(statusOf('a')).toBe('open');
expect(statusOf('b')).toBe('open');
});

it('closes `depth` sheets from the top', async () => {
openAndSettle('a');
openAndSettle('b');
openAndSettle('c');

const result = await closeAllAnimated('g1', { stagger: 0, depth: 2 });

expect(result).toEqual({ completed: true, closed: ['c', 'b'] });
expect(statusOf('a')).toBe('open');
});

it('empties the group when `depth` exceeds the stack', async () => {
openAndSettle('a');
openAndSettle('b');

const result = await closeAllAnimated('g1', { stagger: 0, depth: 99 });

expect(result).toEqual({ completed: true, closed: ['b', 'a'] });
});

it.each([0, -1])('closes nothing at depth %i', async (depth) => {
openAndSettle('a');
openAndSettle('b');

const result = await closeAllAnimated('g1', { stagger: 0, depth });

expect(result).toEqual({ completed: true, closed: [] });
expect(statusOf('b')).toBe('open');
});

// Neither bound may widen the other, so the narrower one has to win in
// both directions.
it('takes the narrower bound when depth is the tighter one', async () => {
openAndSettle('a');
openAndSettle('b');
openAndSettle('c');

const result = await closeAllAnimated('g1', {
stagger: 0,
until: 'a',
depth: 1,
});

expect(result).toEqual({ completed: true, closed: ['c'] });
expect(statusOf('b')).toBe('open');
});

it('takes the narrower bound when until is the tighter one', async () => {
openAndSettle('a');
openAndSettle('b');
openAndSettle('c');

const result = await closeAllAnimated('g1', {
stagger: 0,
until: 'b',
depth: 99,
});

expect(result).toEqual({ completed: true, closed: ['c'] });
expect(statusOf('b')).toBe('open');
});

it('reports the interceptor that stopped a bounded cascade', async () => {
openAndSettle('a');
openAndSettle('b');
openAndSettle('c');
setOnBeforeClose('c', ({ onCancel }) => onCancel());

const result = await closeAllAnimated('g1', { stagger: 0, depth: 2 });

expect(result).toEqual({
completed: false,
closed: [],
stoppedAt: 'c',
});
expect(statusOf('b')).toBe('open');
});
});

// Regression: 'nothing to close' used to break the loop like a refusal,
// stranding every sheet underneath one that had already settled.
it('keeps going past a sheet that has nothing to close', async () => {
Expand All @@ -141,7 +274,7 @@ describe('closeAllAnimated', () => {

const result = await closeAllAnimated('g1', { stagger: 0 });

expect(result.closedAll).toBe(true);
expect(result.completed).toBe(true);
// 'b' is skipped rather than treated as a refusal, so 'a' below it still
// gets closed.
expect(result.closed).toEqual(['c', 'a']);
Expand All @@ -160,7 +293,7 @@ describe('closeAllAnimated', () => {

it('is a no-op for an empty group', async () => {
await expect(closeAllAnimated('nothing', { stagger: 0 })).resolves.toEqual({
closedAll: true,
completed: true,
closed: [],
});
});
Expand Down
Loading
Loading