@oxyhq/bloom 0.54.1 → 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/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/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/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/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__/Toaster.test.tsx +554 -0
- 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/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
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
|
+
import * as ReactNative from 'react-native';
|
|
2
3
|
import { AppState, Text } from 'react-native';
|
|
3
4
|
import { act, fireEvent, render } from '@testing-library/react-native';
|
|
4
5
|
|
|
5
6
|
import { space } from '../styles/tokens';
|
|
6
7
|
import { BloomThemeProvider } from '../theme/BloomThemeProvider';
|
|
7
8
|
import {
|
|
9
|
+
CLOSE_BUTTON_HIT_AREA,
|
|
8
10
|
ENTERING_ANIMATION_DURATION,
|
|
11
|
+
toastDefaults,
|
|
9
12
|
TOAST_MAX_ROW_WIDTH,
|
|
10
13
|
} from '../toast/constants';
|
|
11
14
|
import { toast, ToastOutlet } from '../toast';
|
|
12
15
|
import { toastStore } from '../toast/toast-store';
|
|
16
|
+
import {
|
|
17
|
+
HOVER_LEAVE_GRACE,
|
|
18
|
+
resetStackHoverForTests,
|
|
19
|
+
} from '../toast/use-stack-hover';
|
|
13
20
|
import type { ToasterProps, ToastPosition } from '../toast/types';
|
|
14
21
|
|
|
15
22
|
/**
|
|
@@ -151,6 +158,61 @@ const rowBoxOf = ({ UNSAFE_root }: ReturnType<typeof renderOutlet>) =>
|
|
|
151
158
|
hostName(node) === 'Animated.View' && node.props['aria-live'] !== undefined,
|
|
152
159
|
);
|
|
153
160
|
|
|
161
|
+
/** Every mounted row box — one per rendered toast, whatever its geometry. */
|
|
162
|
+
const rowBoxesOf = ({ UNSAFE_root }: ReturnType<typeof renderOutlet>) =>
|
|
163
|
+
UNSAFE_root.findAll(
|
|
164
|
+
(node) =>
|
|
165
|
+
hostName(node) === 'Animated.View' && node.props['aria-live'] !== undefined,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The shape `__mocks__/react-native-gesture-handler.ts` records its callbacks in,
|
|
170
|
+
* so a suite can fire the row's REAL tap with a real row-relative x rather than
|
|
171
|
+
* reaching inside the component for the policy.
|
|
172
|
+
*/
|
|
173
|
+
type MockGesture = {
|
|
174
|
+
__handlers: { onEnd?: (event: { x: number; y: number }) => void };
|
|
175
|
+
__members: MockGesture[];
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const isMockGesture = (value: unknown): value is MockGesture =>
|
|
179
|
+
typeof value === 'object' &&
|
|
180
|
+
value !== null &&
|
|
181
|
+
'__handlers' in value &&
|
|
182
|
+
'__members' in value;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Fires the tap gesture of one row at a ROW-RELATIVE x, in render order (so the
|
|
186
|
+
* last one is the front of a bottom-anchored stack — the row a real tap lands on).
|
|
187
|
+
* The pan gesture registers `onBegin`/`onChange`/`onFinalize` and no `onEnd`, so
|
|
188
|
+
* `onEnd` identifies the tap unambiguously.
|
|
189
|
+
*/
|
|
190
|
+
const tapRow = (
|
|
191
|
+
rendered: ReturnType<typeof renderOutlet>,
|
|
192
|
+
{ x, row = 'front' }: { x: number; row?: 'front' | number },
|
|
193
|
+
) => {
|
|
194
|
+
const gestures = rendered.UNSAFE_root
|
|
195
|
+
.findAll((node) => isMockGesture(node.props.gesture))
|
|
196
|
+
.flatMap((node) => {
|
|
197
|
+
const gesture: unknown = node.props.gesture;
|
|
198
|
+
return isMockGesture(gesture) ? [gesture] : [];
|
|
199
|
+
});
|
|
200
|
+
const gesture = row === 'front' ? gestures[gestures.length - 1] : gestures[row];
|
|
201
|
+
const onEnd = gesture?.__members.find(
|
|
202
|
+
(member) => typeof member.__handlers.onEnd === 'function',
|
|
203
|
+
)?.__handlers.onEnd;
|
|
204
|
+
if (!onEnd) {
|
|
205
|
+
throw new Error(`no tap gesture found for row ${String(row)}`);
|
|
206
|
+
}
|
|
207
|
+
act(() => {
|
|
208
|
+
onEnd({ x, y: 20 });
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** The mocked window is 375 wide, so the row is uncapped and the strip starts here. */
|
|
213
|
+
const STRIP_X = 375 - CLOSE_BUTTON_HIT_AREA + 10;
|
|
214
|
+
const ROW_CENTRE_X = 100;
|
|
215
|
+
|
|
154
216
|
describe('ToastOutlet', () => {
|
|
155
217
|
beforeEach(() => {
|
|
156
218
|
jest.useFakeTimers();
|
|
@@ -158,6 +220,9 @@ describe('ToastOutlet', () => {
|
|
|
158
220
|
});
|
|
159
221
|
|
|
160
222
|
afterEach(() => {
|
|
223
|
+
// Hover state is module-level (one stack per document), so it outlives the
|
|
224
|
+
// render tree and has to be cleared between tests.
|
|
225
|
+
resetStackHoverForTests();
|
|
161
226
|
act(() => {
|
|
162
227
|
toastStore.dismissToast(undefined);
|
|
163
228
|
jest.runOnlyPendingTimers();
|
|
@@ -522,6 +587,495 @@ describe('ToastOutlet', () => {
|
|
|
522
587
|
});
|
|
523
588
|
});
|
|
524
589
|
|
|
590
|
+
/**
|
|
591
|
+
* HOVER-TO-EXPAND (web). jest resolves `use-stack-hover.ts`, the web file, so
|
|
592
|
+
* these run the real trigger; `use-stack-hover.native.ts` returns no handlers at
|
|
593
|
+
* all and is covered by `toast-stack-hover.test.ts`.
|
|
594
|
+
*
|
|
595
|
+
* The pointer geometry itself — that hover reaches a row through the portal
|
|
596
|
+
* root's `pointer-events: none`, and the transforms on hover-in and hover-out —
|
|
597
|
+
* is only observable in a browser and is measured there.
|
|
598
|
+
*/
|
|
599
|
+
describe('stack hover', () => {
|
|
600
|
+
const mouse = { nativeEvent: { pointerType: 'mouse' } };
|
|
601
|
+
const touch = { nativeEvent: { pointerType: 'touch' } };
|
|
602
|
+
|
|
603
|
+
const hoverRow = (rendered: ReturnType<typeof renderOutlet>, index = 0) => {
|
|
604
|
+
const row = rowBoxesOf(rendered)[index];
|
|
605
|
+
if (!row) {
|
|
606
|
+
throw new Error(`no row box at index ${index}`);
|
|
607
|
+
}
|
|
608
|
+
return row;
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
it('expands the stack when a mouse enters a row', () => {
|
|
612
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
613
|
+
show(() => {
|
|
614
|
+
toast('first');
|
|
615
|
+
toast('second');
|
|
616
|
+
});
|
|
617
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
618
|
+
|
|
619
|
+
act(() => {
|
|
620
|
+
fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('ignores a touch pointer, so a tap does not expand-then-collapse', () => {
|
|
627
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
628
|
+
show(() => {
|
|
629
|
+
toast('first');
|
|
630
|
+
toast('second');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
act(() => {
|
|
634
|
+
fireEvent(hoverRow(rendered), 'pointerEnter', touch);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it('collapses on leave, but only after the grace period', () => {
|
|
641
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
642
|
+
show(() => {
|
|
643
|
+
toast('first');
|
|
644
|
+
toast('second');
|
|
645
|
+
});
|
|
646
|
+
act(() => {
|
|
647
|
+
fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
act(() => {
|
|
651
|
+
fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
|
|
652
|
+
jest.advanceTimersByTime(HOVER_LEAVE_GRACE - 1);
|
|
653
|
+
});
|
|
654
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
655
|
+
|
|
656
|
+
act(() => {
|
|
657
|
+
jest.advanceTimersByTime(1);
|
|
658
|
+
});
|
|
659
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('does not collapse when the pointer crosses from one row to the next', () => {
|
|
663
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
664
|
+
show(() => {
|
|
665
|
+
toast('first');
|
|
666
|
+
toast('second');
|
|
667
|
+
});
|
|
668
|
+
act(() => {
|
|
669
|
+
fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
// The gap between two expanded rows: leave one, enter the next a tick later.
|
|
673
|
+
act(() => {
|
|
674
|
+
fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
|
|
675
|
+
jest.advanceTimersByTime(HOVER_LEAVE_GRACE - 20);
|
|
676
|
+
fireEvent(hoverRow(rendered, 1), 'pointerEnter', mouse);
|
|
677
|
+
jest.advanceTimersByTime(HOVER_LEAVE_GRACE * 3);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('does not let a hovered toast auto-close under the cursor', () => {
|
|
684
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
685
|
+
show(() => {
|
|
686
|
+
toast('first');
|
|
687
|
+
toast('second');
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
act(() => {
|
|
691
|
+
fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
|
|
692
|
+
// Well past both rows' deadlines.
|
|
693
|
+
jest.advanceTimersByTime(
|
|
694
|
+
(toastDefaults.duration + ENTERING_ANIMATION_DURATION) * 3,
|
|
695
|
+
);
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
699
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
700
|
+
|
|
701
|
+
// Leaving restarts the clock, so they still go on their own.
|
|
702
|
+
act(() => {
|
|
703
|
+
fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
|
|
704
|
+
jest.advanceTimersByTime(
|
|
705
|
+
HOVER_LEAVE_GRACE + toastDefaults.duration + ENTERING_ANIMATION_DURATION,
|
|
706
|
+
);
|
|
707
|
+
});
|
|
708
|
+
expect(rendered.queryByText('first')).toBeNull();
|
|
709
|
+
expect(rendered.queryByText('second')).toBeNull();
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it('pauses a hovered toast with stacking off too, without expanding anything', () => {
|
|
713
|
+
const rendered = renderOutlet({ enableStacking: false });
|
|
714
|
+
show(() => {
|
|
715
|
+
toast('first');
|
|
716
|
+
toast('second');
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
act(() => {
|
|
720
|
+
fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
|
|
721
|
+
jest.advanceTimersByTime(
|
|
722
|
+
(toastDefaults.duration + ENTERING_ANIMATION_DURATION) * 3,
|
|
723
|
+
);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
727
|
+
// Nothing to expand in a flat column — the pointer only pauses.
|
|
728
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
729
|
+
|
|
730
|
+
act(() => {
|
|
731
|
+
fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
|
|
732
|
+
jest.advanceTimersByTime(
|
|
733
|
+
HOVER_LEAVE_GRACE + toastDefaults.duration + ENTERING_ANIMATION_DURATION,
|
|
734
|
+
);
|
|
735
|
+
});
|
|
736
|
+
expect(rendered.queryByText('first')).toBeNull();
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('keeps a hovered stack expanded when a row is dismissed out of it', () => {
|
|
740
|
+
// The store auto-collapses down to a single row so it cannot hang, but doing
|
|
741
|
+
// that under the cursor would resume the timers hover had just paused — the
|
|
742
|
+
// remaining toast would then expire while the user is reading it.
|
|
743
|
+
const rendered = renderOutlet({ enableStacking: true, closeButton: true });
|
|
744
|
+
show(() => {
|
|
745
|
+
toast('first');
|
|
746
|
+
toast('second');
|
|
747
|
+
});
|
|
748
|
+
act(() => {
|
|
749
|
+
fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
|
|
750
|
+
});
|
|
751
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
752
|
+
|
|
753
|
+
// Dismiss the front row from the close strip: 2 rows -> 1.
|
|
754
|
+
tapRow(rendered, { x: STRIP_X });
|
|
755
|
+
expect(rendered.queryByText('second')).toBeNull();
|
|
756
|
+
|
|
757
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
758
|
+
act(() => {
|
|
759
|
+
jest.advanceTimersByTime(
|
|
760
|
+
(toastDefaults.duration + ENTERING_ANIMATION_DURATION) * 3,
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
764
|
+
|
|
765
|
+
// Leaving releases the hold: the survivor collapses and resumes.
|
|
766
|
+
act(() => {
|
|
767
|
+
fireEvent(hoverRow(rendered), 'pointerLeave', mouse);
|
|
768
|
+
jest.advanceTimersByTime(HOVER_LEAVE_GRACE);
|
|
769
|
+
});
|
|
770
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
771
|
+
act(() => {
|
|
772
|
+
jest.advanceTimersByTime(
|
|
773
|
+
toastDefaults.duration + ENTERING_ANIMATION_DURATION,
|
|
774
|
+
);
|
|
775
|
+
});
|
|
776
|
+
expect(rendered.queryByText('first')).toBeNull();
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
it('leaves the expansion alone on a press while hovering, and still runs onPress', () => {
|
|
780
|
+
const onPress = jest.fn();
|
|
781
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
782
|
+
show(() => {
|
|
783
|
+
toast('first');
|
|
784
|
+
toast('second', { onPress });
|
|
785
|
+
});
|
|
786
|
+
act(() => {
|
|
787
|
+
fireEvent(hoverRow(rendered), 'pointerEnter', mouse);
|
|
788
|
+
});
|
|
789
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
790
|
+
|
|
791
|
+
// A desktop click on the row: hover owns expansion, so this must not toggle
|
|
792
|
+
// it shut (which would resume the timers hover just paused).
|
|
793
|
+
tapRow(rendered, { x: ROW_CENTRE_X });
|
|
794
|
+
|
|
795
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
796
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
797
|
+
});
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* TAPS ON A STACKED ROW — INVARIANT: NOT ONE OF THEM IS INERT.
|
|
802
|
+
*
|
|
803
|
+
* Every case here fires the row's real tap callback with a real row-relative x,
|
|
804
|
+
* because the defect this covers lived INSIDE that callback and a test that only
|
|
805
|
+
* taps row centres passes against it: the close-button strip used to require
|
|
806
|
+
* `isExpanded` to dismiss and did nothing whatsoever otherwise, so at default
|
|
807
|
+
* config (`closeButton: false`, dismiss branch unreachable) the rightmost 60dp of
|
|
808
|
+
* every stacked row was dead. Stacking is now the default, so that was the
|
|
809
|
+
* default too.
|
|
810
|
+
*
|
|
811
|
+
* The strip's x is measured from the ROW's capped width, never the window's —
|
|
812
|
+
* `measures the strip from the row, not the window` below is the guard on that.
|
|
813
|
+
*/
|
|
814
|
+
describe('stacked row taps', () => {
|
|
815
|
+
it('expands a collapsed stack from the close strip instead of doing nothing', () => {
|
|
816
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
817
|
+
show(() => {
|
|
818
|
+
toast('first');
|
|
819
|
+
toast('second');
|
|
820
|
+
});
|
|
821
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
822
|
+
|
|
823
|
+
tapRow(rendered, { x: STRIP_X });
|
|
824
|
+
|
|
825
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
826
|
+
// Nothing was dismissed: there is no ✕ to aim at at default config.
|
|
827
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
828
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('collapses an expanded stack from the close strip when there is no close button', () => {
|
|
832
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
833
|
+
show(() => {
|
|
834
|
+
toast('first');
|
|
835
|
+
toast('second');
|
|
836
|
+
});
|
|
837
|
+
tapRow(rendered, { x: ROW_CENTRE_X });
|
|
838
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
839
|
+
|
|
840
|
+
tapRow(rendered, { x: STRIP_X });
|
|
841
|
+
|
|
842
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
843
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
it('dismisses from the close strip of a COLLAPSED stack — the ✕ is visible there', () => {
|
|
847
|
+
const rendered = renderOutlet({ enableStacking: true, closeButton: true });
|
|
848
|
+
show(() => {
|
|
849
|
+
toast('first');
|
|
850
|
+
toast('second');
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
tapRow(rendered, { x: STRIP_X });
|
|
854
|
+
|
|
855
|
+
// The front row goes; the stack does not expand instead.
|
|
856
|
+
expect(rendered.queryByText('second')).toBeNull();
|
|
857
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
858
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
it('dismisses from the close strip of an expanded stack', () => {
|
|
862
|
+
const rendered = renderOutlet({ enableStacking: true, closeButton: true });
|
|
863
|
+
show(() => {
|
|
864
|
+
toast('first');
|
|
865
|
+
toast('second');
|
|
866
|
+
});
|
|
867
|
+
tapRow(rendered, { x: ROW_CENTRE_X });
|
|
868
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
869
|
+
|
|
870
|
+
tapRow(rendered, { x: STRIP_X });
|
|
871
|
+
|
|
872
|
+
expect(rendered.queryByText('second')).toBeNull();
|
|
873
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
it('never dismisses from the strip when the toast is not dismissible', () => {
|
|
877
|
+
const rendered = renderOutlet({ enableStacking: true, closeButton: true });
|
|
878
|
+
show(() => {
|
|
879
|
+
toast('first');
|
|
880
|
+
toast('second', { dismissible: false });
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
tapRow(rendered, { x: STRIP_X });
|
|
884
|
+
|
|
885
|
+
// No ✕ renders on a non-dismissible row, so the strip expands instead.
|
|
886
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
887
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
it('treats the strip boundary itself as row, not strip', () => {
|
|
891
|
+
const rendered = renderOutlet({ enableStacking: true, closeButton: true });
|
|
892
|
+
show(() => {
|
|
893
|
+
toast('first');
|
|
894
|
+
toast('second');
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
// `x > rowWidth - CLOSE_BUTTON_HIT_AREA` — the boundary pixel is outside.
|
|
898
|
+
tapRow(rendered, { x: 375 - CLOSE_BUTTON_HIT_AREA });
|
|
899
|
+
|
|
900
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
901
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(true);
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
it('measures the strip from the row, not the window', () => {
|
|
905
|
+
// A 1280px viewport caps the row at TOAST_MAX_ROW_WIDTH, so the strip starts
|
|
906
|
+
// at 328. Measured from the WINDOW it would start at 1220 and this tap would
|
|
907
|
+
// land in the row body — the failure mode the row-relative math exists for.
|
|
908
|
+
jest
|
|
909
|
+
.spyOn(ReactNative, 'useWindowDimensions')
|
|
910
|
+
.mockReturnValue({ width: 1280, height: 900, scale: 1, fontScale: 1 });
|
|
911
|
+
|
|
912
|
+
const rendered = renderOutlet({ enableStacking: true, closeButton: true });
|
|
913
|
+
show(() => {
|
|
914
|
+
toast('first');
|
|
915
|
+
toast('second');
|
|
916
|
+
});
|
|
917
|
+
|
|
918
|
+
tapRow(rendered, { x: TOAST_MAX_ROW_WIDTH - CLOSE_BUTTON_HIT_AREA + 10 });
|
|
919
|
+
|
|
920
|
+
expect(rendered.queryByText('second')).toBeNull();
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it('still runs the toast onPress from inside the strip', () => {
|
|
924
|
+
const onPress = jest.fn();
|
|
925
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
926
|
+
show(() => {
|
|
927
|
+
toast('first');
|
|
928
|
+
toast('second', { onPress });
|
|
929
|
+
});
|
|
930
|
+
|
|
931
|
+
tapRow(rendered, { x: STRIP_X });
|
|
932
|
+
|
|
933
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('leaves a lone toast unexpanded — there is no stack to open', () => {
|
|
937
|
+
const rendered = renderOutlet({ enableStacking: true });
|
|
938
|
+
show(() => toast('only'));
|
|
939
|
+
|
|
940
|
+
tapRow(rendered, { x: ROW_CENTRE_X });
|
|
941
|
+
|
|
942
|
+
expect(toastStore.getSnapshot().isExpanded).toBe(false);
|
|
943
|
+
expect(rendered.queryByText('only')).toBeTruthy();
|
|
944
|
+
});
|
|
945
|
+
});
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The stacked presentation itself is geometry, so it is verified where geometry
|
|
949
|
+
* is verifiable: `toast-position-utils.test.ts` for the offsets and the `scaleX`
|
|
950
|
+
* depth cue, and the `Sequential*` / `Stacking` stories in a real browser for
|
|
951
|
+
* the composed result. What jest can pin — and what the two `describe.each`
|
|
952
|
+
* blocks below deliberately do NOT pin, since they run both values — is which
|
|
953
|
+
* one an outlet with no props gets.
|
|
954
|
+
*
|
|
955
|
+
* `useAnimatedTarget` drives the stack transform from a layout effect, so the
|
|
956
|
+
* rendered style still carries the previous frame's numbers; asserting them from
|
|
957
|
+
* the tree would read a stale value rather than the stack.
|
|
958
|
+
*/
|
|
959
|
+
it('stacks by default — the deliberate deviation from sonner-native', () => {
|
|
960
|
+
expect(toastDefaults.enableStacking).toBe(true);
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* SEQUENTIAL MULTI-TOAST AT THE DEFAULT DURATION.
|
|
965
|
+
*
|
|
966
|
+
* Every earlier sequential check — the `Sequential*` stories and the browser
|
|
967
|
+
* probes driving them — fires with `duration: Infinity`, which starts no
|
|
968
|
+
* auto-close timer at all. That made the whole timer path invisible to them:
|
|
969
|
+
* a defect that cut a settled row's life short when the next toast arrived
|
|
970
|
+
* would have left every one of those results green. These tests therefore use
|
|
971
|
+
* the OUTLET DEFAULT duration and assert lifetimes, not just presence.
|
|
972
|
+
*
|
|
973
|
+
* Both `enableStacking` values run: the flat and collapsed-stack paths resolve
|
|
974
|
+
* row positions through different branches of `calculateToastPosition`, and a
|
|
975
|
+
* row dropped from the render set in one but not the other would otherwise be
|
|
976
|
+
* a coin flip on the default.
|
|
977
|
+
*
|
|
978
|
+
* These are RENDER-SET assertions. A row that is mounted but visually pinned or
|
|
979
|
+
* collapsed by reanimated's web layout-animation cleanup still passes here —
|
|
980
|
+
* that class of bug is only observable in a real browser, which is what the
|
|
981
|
+
* `Sequential*` stories and `animations.ts`'s `TOAST_ENTER_DRIVER` note cover.
|
|
982
|
+
*/
|
|
983
|
+
describe.each([false, true])(
|
|
984
|
+
'sequential toasts at the default duration (enableStacking=%s)',
|
|
985
|
+
(enableStacking) => {
|
|
986
|
+
/** The window the consumer report measured the first row's disappearance in. */
|
|
987
|
+
const REPORT_WINDOW = 150;
|
|
988
|
+
const GAP = 1000;
|
|
989
|
+
|
|
990
|
+
it('keeps both rows mounted when the second arrives while the first is at rest', () => {
|
|
991
|
+
const rendered = renderOutlet({ enableStacking });
|
|
992
|
+
show(() => toast('first'));
|
|
993
|
+
act(() => {
|
|
994
|
+
jest.advanceTimersByTime(GAP);
|
|
995
|
+
});
|
|
996
|
+
show(() => toast('second'));
|
|
997
|
+
act(() => {
|
|
998
|
+
jest.advanceTimersByTime(REPORT_WINDOW);
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
1002
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
1003
|
+
expect(rowBoxesOf(rendered)).toHaveLength(2);
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
it('does not shorten the first row: it lives its own full duration, no less', () => {
|
|
1007
|
+
const rendered = renderOutlet({ enableStacking });
|
|
1008
|
+
show(() => toast('first'));
|
|
1009
|
+
act(() => {
|
|
1010
|
+
jest.advanceTimersByTime(GAP);
|
|
1011
|
+
});
|
|
1012
|
+
show(() => toast('second'));
|
|
1013
|
+
|
|
1014
|
+
// One tick short of the first row's own deadline (its duration plus the
|
|
1015
|
+
// enter animation's head start), measured from ITS fire, not the second's.
|
|
1016
|
+
act(() => {
|
|
1017
|
+
jest.advanceTimersByTime(
|
|
1018
|
+
toastDefaults.duration + ENTERING_ANIMATION_DURATION - GAP - 1,
|
|
1019
|
+
);
|
|
1020
|
+
});
|
|
1021
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
1022
|
+
expect(rowBoxesOf(rendered)).toHaveLength(2);
|
|
1023
|
+
|
|
1024
|
+
act(() => {
|
|
1025
|
+
jest.advanceTimersByTime(1);
|
|
1026
|
+
});
|
|
1027
|
+
expect(rendered.queryByText('first')).toBeNull();
|
|
1028
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
1029
|
+
expect(rowBoxesOf(rendered)).toHaveLength(1);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('does not extend the first row either, nor cut the second short', () => {
|
|
1033
|
+
const rendered = renderOutlet({ enableStacking });
|
|
1034
|
+
show(() => toast('first'));
|
|
1035
|
+
act(() => {
|
|
1036
|
+
jest.advanceTimersByTime(GAP);
|
|
1037
|
+
});
|
|
1038
|
+
show(() => toast('second'));
|
|
1039
|
+
|
|
1040
|
+
// The second row's own deadline is a full GAP later than the first's.
|
|
1041
|
+
act(() => {
|
|
1042
|
+
jest.advanceTimersByTime(
|
|
1043
|
+
toastDefaults.duration + ENTERING_ANIMATION_DURATION - 1,
|
|
1044
|
+
);
|
|
1045
|
+
});
|
|
1046
|
+
expect(rendered.queryByText('first')).toBeNull();
|
|
1047
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
1048
|
+
|
|
1049
|
+
act(() => {
|
|
1050
|
+
jest.advanceTimersByTime(1);
|
|
1051
|
+
});
|
|
1052
|
+
expect(rendered.queryByText('second')).toBeNull();
|
|
1053
|
+
expect(rowBoxesOf(rendered)).toHaveLength(0);
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
it('holds every row of a three-toast run, one arriving each second', () => {
|
|
1057
|
+
const rendered = renderOutlet({ enableStacking });
|
|
1058
|
+
show(() => toast('first'));
|
|
1059
|
+
act(() => {
|
|
1060
|
+
jest.advanceTimersByTime(GAP);
|
|
1061
|
+
});
|
|
1062
|
+
show(() => toast('second'));
|
|
1063
|
+
act(() => {
|
|
1064
|
+
jest.advanceTimersByTime(GAP);
|
|
1065
|
+
});
|
|
1066
|
+
show(() => toast('third'));
|
|
1067
|
+
act(() => {
|
|
1068
|
+
jest.advanceTimersByTime(REPORT_WINDOW);
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
expect(rowBoxesOf(rendered)).toHaveLength(3);
|
|
1072
|
+
expect(rendered.queryByText('first')).toBeTruthy();
|
|
1073
|
+
expect(rendered.queryByText('second')).toBeTruthy();
|
|
1074
|
+
expect(rendered.queryByText('third')).toBeTruthy();
|
|
1075
|
+
});
|
|
1076
|
+
},
|
|
1077
|
+
);
|
|
1078
|
+
|
|
525
1079
|
describe('app-state pausing', () => {
|
|
526
1080
|
it('subscribes exactly once for the whole stack, not once per row', () => {
|
|
527
1081
|
const addEventListener = jest.spyOn(AppState, 'addEventListener');
|
|
@@ -196,6 +196,34 @@ describe('calculateToastPosition', () => {
|
|
|
196
196
|
}),
|
|
197
197
|
).toBe(54);
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* WHY THE STORE MAY AUTO-COLLAPSE A STACK DOWN TO ITS LAST ROW.
|
|
202
|
+
*
|
|
203
|
+
* `ToastStore.dismissToast` collapses an expanded stack once one row is left, so
|
|
204
|
+
* that row's paused timer resumes and it cannot hang on screen forever. That is
|
|
205
|
+
* only acceptable because the collapse is INVISIBLE at one row: both branches of
|
|
206
|
+
* `calculateToastPosition` resolve to the same offset, and `calculateStackScaleX`
|
|
207
|
+
* returns 1 either way. If this ever stops holding, the store's rule needs a
|
|
208
|
+
* rethink rather than this test an update.
|
|
209
|
+
*/
|
|
210
|
+
it.each<ToastPosition>(['bottom-center', 'top-center', 'center'])(
|
|
211
|
+
'a single row is unmoved by expansion (%s)',
|
|
212
|
+
(position) => {
|
|
213
|
+
const lone = {
|
|
214
|
+
index: 0,
|
|
215
|
+
position,
|
|
216
|
+
enableStacking: true,
|
|
217
|
+
numberOfToasts: 1,
|
|
218
|
+
orderedToastIds: ['a'],
|
|
219
|
+
allToastHeights: { a: 50 },
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
expect(positionAt({ ...lone, isExpanded: true })).toBe(
|
|
223
|
+
positionAt({ ...lone, isExpanded: false }),
|
|
224
|
+
);
|
|
225
|
+
},
|
|
226
|
+
);
|
|
199
227
|
});
|
|
200
228
|
});
|
|
201
229
|
|