@oxyhq/bloom 0.54.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +32 -1
  2. package/lib/commonjs/provider/index.js +67 -0
  3. package/lib/commonjs/provider/index.js.map +1 -0
  4. package/lib/commonjs/provider/scroll-provider.js +13 -0
  5. package/lib/commonjs/provider/scroll-provider.js.map +1 -0
  6. package/lib/commonjs/provider/scroll-provider.web.js +13 -0
  7. package/lib/commonjs/provider/scroll-provider.web.js.map +1 -0
  8. package/lib/commonjs/toast/ToastRow.js +35 -10
  9. package/lib/commonjs/toast/ToastRow.js.map +1 -1
  10. package/lib/commonjs/toast/ToastSwipeHandler.js +15 -5
  11. package/lib/commonjs/toast/ToastSwipeHandler.js.map +1 -1
  12. package/lib/commonjs/toast/constants.js +18 -1
  13. package/lib/commonjs/toast/constants.js.map +1 -1
  14. package/lib/commonjs/toast/toast-store.js +39 -1
  15. package/lib/commonjs/toast/toast-store.js.map +1 -1
  16. package/lib/commonjs/toast/use-stack-hover.js +175 -0
  17. package/lib/commonjs/toast/use-stack-hover.js.map +1 -0
  18. package/lib/commonjs/toast/use-stack-hover.native.js +42 -0
  19. package/lib/commonjs/toast/use-stack-hover.native.js.map +1 -0
  20. package/lib/module/provider/index.js +63 -0
  21. package/lib/module/provider/index.js.map +1 -0
  22. package/lib/module/provider/scroll-provider.js +16 -0
  23. package/lib/module/provider/scroll-provider.js.map +1 -0
  24. package/lib/module/provider/scroll-provider.web.js +5 -0
  25. package/lib/module/provider/scroll-provider.web.js.map +1 -0
  26. package/lib/module/toast/ToastRow.js +35 -10
  27. package/lib/module/toast/ToastRow.js.map +1 -1
  28. package/lib/module/toast/ToastSwipeHandler.js +16 -4
  29. package/lib/module/toast/ToastSwipeHandler.js.map +1 -1
  30. package/lib/module/toast/constants.js +18 -1
  31. package/lib/module/toast/constants.js.map +1 -1
  32. package/lib/module/toast/toast-store.js +39 -1
  33. package/lib/module/toast/toast-store.js.map +1 -1
  34. package/lib/module/toast/use-stack-hover.js +168 -0
  35. package/lib/module/toast/use-stack-hover.js.map +1 -0
  36. package/lib/module/toast/use-stack-hover.native.js +35 -0
  37. package/lib/module/toast/use-stack-hover.native.js.map +1 -0
  38. package/lib/typescript/commonjs/provider/index.d.ts +45 -0
  39. package/lib/typescript/commonjs/provider/index.d.ts.map +1 -0
  40. package/lib/typescript/commonjs/provider/scroll-provider.d.ts +14 -0
  41. package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +1 -0
  42. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +3 -0
  43. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +1 -0
  44. package/lib/typescript/commonjs/toast/ToastRow.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts +4 -0
  46. package/lib/typescript/commonjs/toast/ToastSwipeHandler.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/toast/constants.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/toast/toast-store.d.ts +15 -0
  49. package/lib/typescript/commonjs/toast/toast-store.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/toast/use-stack-hover.d.ts +55 -0
  51. package/lib/typescript/commonjs/toast/use-stack-hover.d.ts.map +1 -0
  52. package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts +26 -0
  53. package/lib/typescript/commonjs/toast/use-stack-hover.native.d.ts.map +1 -0
  54. package/lib/typescript/module/provider/index.d.ts +45 -0
  55. package/lib/typescript/module/provider/index.d.ts.map +1 -0
  56. package/lib/typescript/module/provider/scroll-provider.d.ts +14 -0
  57. package/lib/typescript/module/provider/scroll-provider.d.ts.map +1 -0
  58. package/lib/typescript/module/provider/scroll-provider.web.d.ts +3 -0
  59. package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +1 -0
  60. package/lib/typescript/module/toast/ToastRow.d.ts.map +1 -1
  61. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts +4 -0
  62. package/lib/typescript/module/toast/ToastSwipeHandler.d.ts.map +1 -1
  63. package/lib/typescript/module/toast/constants.d.ts.map +1 -1
  64. package/lib/typescript/module/toast/toast-store.d.ts +15 -0
  65. package/lib/typescript/module/toast/toast-store.d.ts.map +1 -1
  66. package/lib/typescript/module/toast/use-stack-hover.d.ts +55 -0
  67. package/lib/typescript/module/toast/use-stack-hover.d.ts.map +1 -0
  68. package/lib/typescript/module/toast/use-stack-hover.native.d.ts +26 -0
  69. package/lib/typescript/module/toast/use-stack-hover.native.d.ts.map +1 -0
  70. package/package.json +12 -1
  71. package/src/__tests__/BloomProvider.web.test.tsx +114 -0
  72. package/src/__tests__/Toaster.test.tsx +554 -0
  73. package/src/__tests__/toast-position-utils.test.ts +28 -0
  74. package/src/__tests__/toast-stack-hover.test.ts +92 -0
  75. package/src/__tests__/toast-store.test.ts +72 -0
  76. package/src/provider/index.tsx +68 -0
  77. package/src/provider/scroll-provider.ts +13 -0
  78. package/src/provider/scroll-provider.web.ts +2 -0
  79. package/src/toast/Toast.stories.tsx +10 -0
  80. package/src/toast/ToastRow.tsx +33 -10
  81. package/src/toast/ToastSwipeHandler.tsx +14 -2
  82. package/src/toast/constants.ts +18 -1
  83. package/src/toast/toast-store.ts +39 -1
  84. package/src/toast/use-stack-hover.native.ts +34 -0
  85. 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', () => {
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `BloomProvider` — the ONE Bloom root an app mounts.
3
+ *
4
+ * Bloom's app-wide state used to be a handful of separate providers that every
5
+ * consumer wired by hand (theme, haptics, image resolution, scroll restoration,
6
+ * tab-bar minimize progress). Mounting them separately means each one can end
7
+ * up at a different depth, and a provider mounted too low fails in ways that
8
+ * are hard to trace:
9
+ *
10
+ * - `useScrollRestoration()` THROWS on web outside `ScrollRestorationProvider`,
11
+ * so any scrollable rendered beside the provider (a right rail, an overlay)
12
+ * crashes the screen.
13
+ * - `useMinimizeState()` silently hands each caller a private fallback, so a
14
+ * tab bar below the provider just never minimizes — no error anywhere.
15
+ *
16
+ * Mounting this single provider at the app root makes both classes of mistake
17
+ * impossible: everything Bloom renders is under all of them, at the same depth.
18
+ * Nesting extra contexts costs nothing at runtime — the win is that scope is no
19
+ * longer a per-app decision.
20
+ *
21
+ * NOT included, on purpose — these are OUTLETS, not state, and their placement
22
+ * in the tree is a real app decision (z-order, safe areas, and mounting a
23
+ * second one duplicates every surface it renders):
24
+ * `<ToastOutlet>`, `<Portal.Provider>`/`<Portal.Outlet>`, `<SurfaceHost>`,
25
+ * `<BloomDialogProvider>`, `<AlertDialogHost>`.
26
+ */
27
+ import type { ReactNode } from 'react';
28
+
29
+ import { ImageResolverProvider, type ImageResolver } from '../image-resolver';
30
+ import { BloomHapticsProvider } from '../hooks/useHaptics';
31
+ import { TabBarMinimizeProvider } from '../tab-bar/minimize-context';
32
+ import { BloomThemeProvider, type BloomThemeProviderProps } from '../theme';
33
+ import { ScrollRestorationProvider } from './scroll-provider';
34
+
35
+ export interface BloomProviderProps extends Omit<BloomThemeProviderProps, 'children'> {
36
+ children: ReactNode;
37
+ /**
38
+ * Resolves bare media identifiers (Oxy file ids) to loadable URLs for every
39
+ * Bloom surface that takes a `source` — `<Avatar>`, image galleries, cards.
40
+ * Typically `(id, variant) => oxyServices.getFileDownloadUrl(id, variant)`.
41
+ */
42
+ imageResolver?: ImageResolver;
43
+ /** `false` disables haptic feedback app-wide (honored by every `useHaptics()` call). */
44
+ haptics?: boolean;
45
+ }
46
+
47
+ export function BloomProvider({
48
+ children,
49
+ imageResolver,
50
+ haptics = true,
51
+ ...themeProps
52
+ }: BloomProviderProps) {
53
+ return (
54
+ // `value` is passed unconditionally (null when unset) so toggling a resolver
55
+ // never changes the tree shape and remounts everything below it.
56
+ <ImageResolverProvider value={imageResolver ?? null}>
57
+ <BloomThemeProvider {...themeProps}>
58
+ <ScrollRestorationProvider>
59
+ <BloomHapticsProvider enabled={haptics}>
60
+ <TabBarMinimizeProvider>{children}</TabBarMinimizeProvider>
61
+ </BloomHapticsProvider>
62
+ </ScrollRestorationProvider>
63
+ </BloomThemeProvider>
64
+ </ImageResolverProvider>
65
+ );
66
+ }
67
+
68
+ BloomProvider.displayName = 'BloomProvider';
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Native/default binding for the scroll-restoration provider used by
3
+ * {@link BloomProvider}.
4
+ *
5
+ * `@oxyhq/bloom/scroll` is web-forked, so its `browser` export condition hands
6
+ * web consumers the real implementation while native gets the no-op. A compiled
7
+ * `lib/module/provider/index.js` cannot benefit from that condition (it imports
8
+ * a relative path, not the package subpath), so the platform choice is made
9
+ * here by FILENAME instead: Metro picks `scroll-provider.web.ts` on web, and
10
+ * every web bundler that resolves `.web.js` picks the compiled sibling. Same
11
+ * mechanism the toast engine uses for `ToastHost.native.tsx`.
12
+ */
13
+ export { ScrollRestorationProvider } from '../scroll';
@@ -0,0 +1,2 @@
1
+ /** Web binding for {@link BloomProvider}'s scroll-restoration provider — see `./scroll-provider.ts`. */
2
+ export { ScrollRestorationProvider } from '../scroll/index.web';
@@ -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
  }
@@ -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
- // On Android the RNGH tap wins the race against the nested Pressable,
361
- // so a press in the close-button strip has to dismiss explicitly.
362
- // `x` is relative to the row, which is capped — measuring the strip
363
- // from the window edge would put it outside the row on a wide viewport.
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,
@@ -14,6 +14,10 @@
14
14
  * covers `toast.custom` JSX and `unstyled` rows too, which never reach that
15
15
  * card. Every distance the gesture measures is a fraction of the capped
16
16
  * `rowWidth`, not of the window — see the constant.
17
+ *
18
+ * W13 — being THE row box also makes it the hover target: `useStackHover`'s
19
+ * handlers go here so a pointer over ANY row counts as a pointer over the stack.
20
+ * The handlers are empty on native.
17
21
  */
18
22
  import * as React from 'react';
19
23
  import {
@@ -37,6 +41,7 @@ import { easeInOutCircFn } from './animations';
37
41
  import { TOAST_MAX_ROW_WIDTH } from './constants';
38
42
  import { useToastContext } from './context';
39
43
  import type { ToastPosition, ToastProps } from './types';
44
+ import { useStackHover } from './use-stack-hover';
40
45
 
41
46
  /** Fraction of the row a horizontal swipe must cross to dismiss. */
42
47
  const HORIZONTAL_DISMISS_FRACTION = 0.25;
@@ -76,10 +81,16 @@ export const ToastSwipeHandler: React.FC<
76
81
  // The row is `width: '100%'` up to the cap, so this is its real width.
77
82
  const rowWidth = Math.min(windowWidth, TOAST_MAX_ROW_WIDTH);
78
83
  const translate = useSharedValue(0);
79
- const { swipeToDismissDirection: direction, position: positionCtx } =
80
- useToastContext();
84
+ const {
85
+ swipeToDismissDirection: direction,
86
+ position: positionCtx,
87
+ enableStacking,
88
+ } = useToastContext();
81
89
  const position = positionProps ?? positionCtx;
82
90
  const isAndroid = Platform.OS === 'android';
91
+ // W13 — hover lives on THE ROW BOX, so hovering any row counts as hovering the
92
+ // stack. Empty on native; see `use-stack-hover.native.ts`.
93
+ const hoverProps = useStackHover({ enableStacking });
83
94
 
84
95
  const pan = Gesture.Pan()
85
96
  .onBegin(() => {
@@ -199,6 +210,7 @@ export const ToastSwipeHandler: React.FC<
199
210
  styles.rowBox,
200
211
  style,
201
212
  ]}
213
+ {...hoverProps}
202
214
  // W9 — on web `LinearTransition`'s easing degrades to CSS `ease` because
203
215
  // a `bezierFn` result carries no easing-name symbol for the CSS mapper.
204
216
  // Accepted: the transition still runs, only its curve differs.
@@ -98,7 +98,24 @@ export const toastDefaults: {
98
98
  theme: 'system',
99
99
  autoWiggleOnUpdate: 'never',
100
100
  richColors: false,
101
- enableStacking: false,
101
+ /**
102
+ * ON, unlike upstream — the same class of correction as `TOAST_MAX_ROW_WIDTH`.
103
+ *
104
+ * sonner-NATIVE defaults this off; sonner (web) has no switch at all, because a
105
+ * collapsed stack — front row full size, the ones behind scaled and offset,
106
+ * expanding on interaction — IS its default presentation. The port inherited the
107
+ * mobile default onto every desktop consumer, where an unstacked run of toasts
108
+ * is a column of full cards rather than a stack.
109
+ *
110
+ * Bloom already deviates deliberately elsewhere (`position: 'bottom-center'` vs
111
+ * upstream's `top-center`, `duration: 3000` vs 4000, `gap: 8` vs 14), so this is
112
+ * in keeping rather than a break with the port.
113
+ *
114
+ * Turning it OFF (`<ToastOutlet enableStacking={false} />`) restores the flat
115
+ * column, which is still the right choice for a surface that must show several
116
+ * toasts at once without an interaction.
117
+ */
118
+ enableStacking: true,
102
119
  stackGap: 8,
103
120
  allowFontScaling: true,
104
121
  };
@@ -42,6 +42,7 @@ class ToastStore {
42
42
  private pendingPromises = new Set<string | number>();
43
43
  private collapseCooldown = false;
44
44
  private collapseCooldownTimeout: ReturnType<typeof setTimeout> | null = null;
45
+ private expansionHold: (() => boolean) | null = null;
45
46
 
46
47
  subscribe = (callback: Subscriber) => {
47
48
  this.subscribers.add(callback);
@@ -59,6 +60,24 @@ class ToastStore {
59
60
  this.config = config;
60
61
  };
61
62
 
63
+ /**
64
+ * Something outside the store can HOLD an expanded stack open — on web, a pointer
65
+ * resting on it. Registered by the platform trigger (`use-stack-hover`) so this
66
+ * module stays pure JS with no DOM and no platform branch; native registers
67
+ * nothing and the predicate stays `null`.
68
+ *
69
+ * It is asked at DECISION time rather than tracked as state on purpose: a row can
70
+ * stop being under the pointer with no event at all — rows unmount under the
71
+ * cursor and move under a stationary one, and neither fires `pointerleave` —
72
+ * so any cached boolean goes stale. Nothing renders from this, so it is
73
+ * deliberately not part of the snapshot.
74
+ */
75
+ setExpansionHold = (hold: (() => boolean) | null) => {
76
+ this.expansionHold = hold;
77
+ };
78
+
79
+ private isHeldOpen = (): boolean => this.expansionHold?.() === true;
80
+
62
81
  private notify = () => {
63
82
  this.subscribers.forEach((callback) => callback());
64
83
  };
@@ -305,8 +324,27 @@ class ToastStore {
305
324
  const updatedRefs = { ...this.state.toastRefs };
306
325
  delete updatedRefs[id];
307
326
 
327
+ /**
328
+ * An expanded stack collapses itself once a single row is left, because
329
+ * "expanded" then means nothing visually — a lone row resolves to the SAME
330
+ * offset and scale either way (`toast-position-utils.test.ts` pins that) — while
331
+ * `isExpanded` keeps every timer paused, so staying expanded would leave that
332
+ * last toast on screen forever with no visible reason. Inherited from
333
+ * sonner-native and kept deliberately: on native it is invisible and it is the
334
+ * only thing that stops the last row hanging.
335
+ *
336
+ * UNLESS something is holding the stack open. On web that is a pointer resting
337
+ * on it, and collapsing under the cursor would resume the timers hover had just
338
+ * paused — the toast would then expire while the user is reading it, which is
339
+ * exactly what hover exists to prevent.
340
+ *
341
+ * An EMPTY stack always resets, hold or no hold: it no longer exists to be held,
342
+ * and once the rows unmount no `pointerleave` will ever arrive to release it.
343
+ */
344
+ const isEmpty = filteredToasts.length === 0;
345
+ const heldOpen = !isEmpty && this.isHeldOpen();
308
346
  const shouldAutoCollapse =
309
- filteredToasts.length <= 1 && this.state.isExpanded;
347
+ filteredToasts.length <= 1 && this.state.isExpanded && !heldOpen;
310
348
 
311
349
  const updatedIndex = this.cloneIndex();
312
350
  updatedIndex.delete(id);
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Bloom-original — NATIVE. Metro resolves this over `use-stack-hover.ts` on iOS and
3
+ * Android, the same filename split `ToastHost.native.tsx` uses.
4
+ *
5
+ * Native keeps PRESS as the only way to expand a stack, so this hands back no
6
+ * handlers at all. Two reasons it is a fork rather than a runtime `Platform` check:
7
+ * the web file's module-level hover state and its `setTimeout` have no business
8
+ * existing in a native bundle, and `onPointerEnter`/`onPointerLeave` DO fire on
9
+ * native for touch (RN's pointer events unify touch and mouse), which would make
10
+ * every tap expand-then-collapse while the press toggle fired as well.
11
+ *
12
+ * Keep the shape identical to the web file — `ToastSwipeHandler` spreads whatever
13
+ * this returns onto the row box, and `ToastRow` calls `isStackHovered()`
14
+ * unconditionally.
15
+ */
16
+ export type StackHoverProps = Record<string, never>;
17
+
18
+ /** Parity with the web file's export; nothing here waits for anything. */
19
+ export const HOVER_LEAVE_GRACE = 0;
20
+
21
+ /** Nothing hovers on a touch screen. */
22
+ export function isStackHovered(): boolean {
23
+ return false;
24
+ }
25
+
26
+ /** One frozen object, so spreading it never changes the row box's props. */
27
+ const NO_HOVER_PROPS: StackHoverProps = Object.freeze({});
28
+
29
+ export function useStackHover(_options: { enableStacking: boolean }): StackHoverProps {
30
+ return NO_HOVER_PROPS;
31
+ }
32
+
33
+ /** Parity with the web file so the shared suite can call it on either platform. */
34
+ export function resetStackHoverForTests(): void {}