@oxyhq/bloom 0.55.0 → 0.57.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 (90) hide show
  1. package/README.md +32 -1
  2. package/lib/commonjs/bottom-sheet/index.web.js +10 -10
  3. package/lib/commonjs/bottom-sheet/index.web.js.map +1 -1
  4. package/lib/commonjs/dialog/Dialog.js +2 -3
  5. package/lib/commonjs/dialog/Dialog.js.map +1 -1
  6. package/lib/commonjs/dialog/Dialog.web.js +9 -19
  7. package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
  8. package/lib/commonjs/dialog/DialogHeader.js +4 -6
  9. package/lib/commonjs/dialog/DialogHeader.js.map +1 -1
  10. package/lib/commonjs/overlay/index.js +108 -0
  11. package/lib/commonjs/overlay/index.js.map +1 -0
  12. package/lib/commonjs/portal/index.js +4 -1
  13. package/lib/commonjs/portal/index.js.map +1 -1
  14. package/lib/commonjs/provider/index.js +67 -0
  15. package/lib/commonjs/provider/index.js.map +1 -0
  16. package/lib/commonjs/provider/scroll-provider.js +13 -0
  17. package/lib/commonjs/provider/scroll-provider.js.map +1 -0
  18. package/lib/commonjs/provider/scroll-provider.web.js +13 -0
  19. package/lib/commonjs/provider/scroll-provider.web.js.map +1 -0
  20. package/lib/commonjs/tab-bar/TabBarBase.js +10 -6
  21. package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
  22. package/lib/commonjs/toast/ToastHost.js +13 -5
  23. package/lib/commonjs/toast/ToastHost.js.map +1 -1
  24. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +189 -196
  25. package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  26. package/lib/module/bottom-sheet/index.web.js +10 -10
  27. package/lib/module/bottom-sheet/index.web.js.map +1 -1
  28. package/lib/module/dialog/Dialog.js +2 -3
  29. package/lib/module/dialog/Dialog.js.map +1 -1
  30. package/lib/module/dialog/Dialog.web.js +10 -20
  31. package/lib/module/dialog/Dialog.web.js.map +1 -1
  32. package/lib/module/dialog/DialogHeader.js +4 -6
  33. package/lib/module/dialog/DialogHeader.js.map +1 -1
  34. package/lib/module/overlay/index.js +102 -0
  35. package/lib/module/overlay/index.js.map +1 -0
  36. package/lib/module/portal/index.js +4 -1
  37. package/lib/module/portal/index.js.map +1 -1
  38. package/lib/module/provider/index.js +63 -0
  39. package/lib/module/provider/index.js.map +1 -0
  40. package/lib/module/provider/scroll-provider.js +16 -0
  41. package/lib/module/provider/scroll-provider.js.map +1 -0
  42. package/lib/module/provider/scroll-provider.web.js +5 -0
  43. package/lib/module/provider/scroll-provider.web.js.map +1 -0
  44. package/lib/module/tab-bar/TabBarBase.js +10 -6
  45. package/lib/module/tab-bar/TabBarBase.js.map +1 -1
  46. package/lib/module/toast/ToastHost.js +13 -5
  47. package/lib/module/toast/ToastHost.js.map +1 -1
  48. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +178 -185
  49. package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
  50. package/lib/typescript/commonjs/bottom-sheet/index.web.d.ts.map +1 -1
  51. package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/overlay/index.d.ts +67 -0
  53. package/lib/typescript/commonjs/overlay/index.d.ts.map +1 -0
  54. package/lib/typescript/commonjs/provider/index.d.ts +45 -0
  55. package/lib/typescript/commonjs/provider/index.d.ts.map +1 -0
  56. package/lib/typescript/commonjs/provider/scroll-provider.d.ts +14 -0
  57. package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +1 -0
  58. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +3 -0
  59. package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +1 -0
  60. package/lib/typescript/commonjs/toast/ToastHost.d.ts.map +1 -1
  61. package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  62. package/lib/typescript/module/bottom-sheet/index.web.d.ts.map +1 -1
  63. package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
  64. package/lib/typescript/module/overlay/index.d.ts +67 -0
  65. package/lib/typescript/module/overlay/index.d.ts.map +1 -0
  66. package/lib/typescript/module/provider/index.d.ts +45 -0
  67. package/lib/typescript/module/provider/index.d.ts.map +1 -0
  68. package/lib/typescript/module/provider/scroll-provider.d.ts +14 -0
  69. package/lib/typescript/module/provider/scroll-provider.d.ts.map +1 -0
  70. package/lib/typescript/module/provider/scroll-provider.web.d.ts +3 -0
  71. package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +1 -0
  72. package/lib/typescript/module/toast/ToastHost.d.ts.map +1 -1
  73. package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
  74. package/package.json +23 -1
  75. package/src/__tests__/BloomProvider.web.test.tsx +114 -0
  76. package/src/__tests__/TabBarBlur.test.tsx +6 -1
  77. package/src/__tests__/overlay-pointer-events.test.tsx +124 -0
  78. package/src/__tests__/pointer-events-style-form.test.ts +58 -0
  79. package/src/bottom-sheet/index.web.tsx +13 -8
  80. package/src/dialog/Dialog.tsx +1 -1
  81. package/src/dialog/Dialog.web.tsx +14 -16
  82. package/src/dialog/DialogHeader.tsx +2 -2
  83. package/src/overlay/index.tsx +115 -0
  84. package/src/portal/index.tsx +4 -2
  85. package/src/provider/index.tsx +68 -0
  86. package/src/provider/scroll-provider.ts +13 -0
  87. package/src/provider/scroll-provider.web.ts +2 -0
  88. package/src/tab-bar/TabBarBase.tsx +8 -6
  89. package/src/toast/ToastHost.tsx +10 -5
  90. package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +15 -20
@@ -0,0 +1,58 @@
1
+ // A source gate for the one mistake that took every Bloom overlay down on web:
2
+ // writing `pointerEvents: 'box-none'` (or `'box-only'`) inside a STYLE object.
3
+ //
4
+ // react-native-web resolves those two RN-only values in `createDOMProps`, from
5
+ // the `pointerEvents` PROP — it maps the prop onto its own class pair
6
+ // (`self { none !important }` + `> * { auto }`). As a style-object value it is
7
+ // not valid CSS and is dropped, so the element silently keeps whatever it
8
+ // inherits. Inside the web Portal (root: `pointer-events: none`) that makes the
9
+ // entire surface click-through; outside it, a full-bleed `box-none` container
10
+ // becomes click-CATCHING and eats presses meant for the app under it. Both
11
+ // failure modes render perfectly and log nothing.
12
+ //
13
+ // `pointerEvents: 'auto' | 'none'` ARE valid CSS and stay allowed as styles —
14
+ // only the two RN-only values are banned.
15
+ //
16
+ // The runtime half of this contract is asserted in `overlay-pointer-events.test.tsx`.
17
+
18
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
19
+ import { join } from 'node:path';
20
+
21
+ const SRC = join(__dirname, '..');
22
+ const BANNED = /pointerEvents:\s*['"](box-none|box-only)['"]/;
23
+
24
+ function sourceFiles(dir: string, out: string[] = []): string[] {
25
+ for (const entry of readdirSync(dir)) {
26
+ if (entry === '__tests__' || entry === 'node_modules') continue;
27
+ const full = join(dir, entry);
28
+ if (statSync(full).isDirectory()) {
29
+ sourceFiles(full, out);
30
+ } else if (/\.tsx?$/.test(entry)) {
31
+ out.push(full);
32
+ }
33
+ }
34
+ return out;
35
+ }
36
+
37
+ describe('pointerEvents style-object form', () => {
38
+ const files = sourceFiles(SRC);
39
+
40
+ // Vacuity floor: a broken traversal would otherwise report a clean sweep.
41
+ it('scans the whole source tree', () => {
42
+ expect(files.length).toBeGreaterThan(200);
43
+ expect(files.some((f) => f.endsWith(join('overlay', 'index.tsx')))).toBe(true);
44
+ });
45
+
46
+ it('never uses the RN-only values as styles — pass them as the prop', () => {
47
+ const offenders = files
48
+ .flatMap((file) =>
49
+ readFileSync(file, 'utf8')
50
+ .split('\n')
51
+ .map((line, i) => ({ file: file.slice(SRC.length + 1), line: i + 1, text: line.trim() }))
52
+ .filter(({ text }) => BANNED.test(text) && !text.startsWith('*') && !text.startsWith('//')),
53
+ )
54
+ .map(({ file, line, text }) => `${file}:${line} ${text}`);
55
+
56
+ expect(offenders).toEqual([]);
57
+ });
58
+ });
@@ -2,6 +2,7 @@ import type React from 'react';
2
2
  import { forwardRef } from 'react';
3
3
  import { StyleSheet } from 'react-native';
4
4
  import { GestureHandlerRootView } from 'react-native-gesture-handler';
5
+ import { OverlayRoot } from '../overlay';
5
6
  import { Portal } from '../portal/index.web';
6
7
  import { Z_INDEX } from '../styles/z-index';
7
8
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
@@ -33,9 +34,15 @@ import {
33
34
  function WebShell({ children }: BottomSheetShellProps) {
34
35
  return (
35
36
  <Portal>
36
- <GestureHandlerRootView style={[webStyles.rootView, { pointerEvents: 'box-none' }]}>
37
- {children}
38
- </GestureHandlerRootView>
37
+ {/* `OverlayRoot` owns the portal-root pointer-events opt-in (see
38
+ `src/overlay`). It used to ride in the style array below as an
39
+ RN-only box-none value, which never reached the DOM — the whole
40
+ sheet, backdrop included, was click-through on web. */}
41
+ <OverlayRoot style={webStyles.rootView}>
42
+ <GestureHandlerRootView style={StyleSheet.absoluteFill}>
43
+ {children}
44
+ </GestureHandlerRootView>
45
+ </OverlayRoot>
39
46
  </Portal>
40
47
  );
41
48
  }
@@ -48,11 +55,9 @@ BottomSheet.displayName = 'BottomSheet';
48
55
 
49
56
  const webStyles = StyleSheet.create({
50
57
  rootView: {
51
- // The bloom Portal root is `position: fixed; inset: 0; pointer-events:
52
- // none`; this fixed, full-viewport root re-enables pointer events for
53
- // the sheet's own interactive descendants (backdrop, sheet) while empty
54
- // gaps stay click-through (`box-none`). `StyleSheet.absoluteFill` from
55
- // the sheet body then anchors to this box.
58
+ // Fixed, full-viewport box the sheet's `StyleSheet.absoluteFill` body
59
+ // anchors to. The pointer-events opt-in lives in `OverlayRoot`; this
60
+ // only adds the stacking context.
56
61
  position: WEB_POSITION_FIXED,
57
62
  top: 0,
58
63
  left: 0,
@@ -523,13 +523,13 @@ function SideSheet({
523
523
 
524
524
  return (
525
525
  <View
526
+ pointerEvents="box-none"
526
527
  style={[
527
528
  sideStyles.root,
528
529
  // Per-layer offset so a side dialog stacked on top of another (surface
529
530
  // stack) paints above it. Layer 0 → offset 0 → unchanged.
530
531
  { zIndex: Z_INDEX.fullscreen + Z_INDEX_LAYER_STEP * (layer ?? 0) },
531
532
  containerStyle,
532
- { pointerEvents: 'box-none' },
533
533
  ]}
534
534
  {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
535
535
  >
@@ -19,6 +19,7 @@ import {
19
19
  import Animated from 'react-native-reanimated';
20
20
  import { RemoveScrollBar } from 'react-remove-scroll-bar';
21
21
 
22
+ import { Backdrop, OverlayRoot } from '../overlay';
22
23
  import { Portal } from '../portal/index.web';
23
24
  import { createOverlayZIndex } from '../styles/z-index';
24
25
  import { WEB_POSITION_FIXED, type WebCssStyle } from '../styles/web-view-style';
@@ -315,23 +316,21 @@ function CenterOrSideDialog({
315
316
  <Context.Provider value={context}>
316
317
  <ClosingContext.Provider value={isClosing}>
317
318
  <RemoveScrollBar />
318
- <Pressable
319
- onPress={dismissOnBackdrop ? () => close() : undefined}
319
+ {/* The press target IS the full-viewport box, so it uses
320
+ `Backdrop` (which opts back in from the Portal root's
321
+ `pointer-events: none` via the `pointerEvents` PROP — the style
322
+ form is dropped before it reaches the DOM, see `src/overlay`)
323
+ and lays the panel out inside itself. */}
324
+ <Backdrop
325
+ onPress={() => close()}
320
326
  disabled={!dismissOnBackdrop}
321
- // `pointerEvents: 'auto'` opts back in from the Portal root's
322
- // `pointer-events: none`, which is set so the idle portal
323
- // doesn't intercept clicks on the underlying app.
327
+ accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
324
328
  style={{
325
329
  position: WEB_POSITION_FIXED,
326
- top: 0,
327
- left: 0,
328
- right: 0,
329
- bottom: 0,
330
330
  zIndex: dialogZIndex.backdrop,
331
331
  alignItems: 'center',
332
332
  justifyContent: 'center',
333
333
  paddingHorizontal: 20,
334
- pointerEvents: 'auto',
335
334
  }}
336
335
  >
337
336
  <DialogBackdrop isClosing={isClosing} />
@@ -353,7 +352,7 @@ function CenterOrSideDialog({
353
352
  >
354
353
  {children}
355
354
  </DialogPanel>
356
- </Pressable>
355
+ </Backdrop>
357
356
  </ClosingContext.Provider>
358
357
  </Context.Provider>
359
358
  </Portal>
@@ -685,13 +684,12 @@ function SheetSurface({
685
684
  }, [dismissOnBackdrop, onDismiss]);
686
685
 
687
686
  return (
688
- <View
689
- style={[sheetStyles.root, { zIndex: backdropZIndex }, containerStyle, { pointerEvents: 'box-none' }]}
687
+ <OverlayRoot
688
+ style={[sheetStyles.root, { zIndex: backdropZIndex }, containerStyle]}
690
689
  {...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
691
690
  >
692
- <Pressable
691
+ <Backdrop
693
692
  testID={testID ? `${testID}-backdrop` : DIALOG_SHEET_BACKDROP_TESTID}
694
- accessibilityRole="button"
695
693
  accessibilityLabel={label ? `Dismiss ${label}` : 'Dismiss dialog'}
696
694
  onPress={handleBackdropPress}
697
695
  disabled={!dismissOnBackdrop}
@@ -776,7 +774,7 @@ function SheetSurface({
776
774
  </View>
777
775
  )}
778
776
  </View>
779
- </View>
777
+ </OverlayRoot>
780
778
  );
781
779
  }
782
780
 
@@ -534,7 +534,7 @@ export const DialogNavHeader = memo(function DialogNavHeader({
534
534
  ) : null);
535
535
 
536
536
  return (
537
- <View style={[styles.overlay, style, { pointerEvents: 'box-none' }]}>
537
+ <View pointerEvents="box-none" style={[styles.overlay, style]}>
538
538
  {/* Opaque (surface bg) at the top → transparent at the bottom, so scrolled
539
539
  content fades out under the bar. `onImage` swaps to a dark scrim so the
540
540
  chrome reads over media. Pure NativeWind — no SVG dependency. */}
@@ -546,7 +546,7 @@ export const DialogNavHeader = memo(function DialogNavHeader({
546
546
  }
547
547
  style={[StyleSheet.absoluteFill, { height: DIALOG_HEADER_OVERLAY_HEIGHT, pointerEvents: 'none' }]}
548
548
  />
549
- <View style={[styles.navRow, { pointerEvents: 'box-none' }]}>
549
+ <View pointerEvents="box-none" style={styles.navRow}>
550
550
  <View style={styles.side}>{left}</View>
551
551
  <Animated.View
552
552
  testID="dialog-nav-title"
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The shared plumbing every portaled surface needs: an interactive ROOT and a
3
+ * press-to-dismiss BACKDROP. Dialog, BottomSheet, the image gallery, menus and
4
+ * toasts each used to hand-roll both, and each one got the web contract subtly
5
+ * wrong in its own way.
6
+ *
7
+ * ## The contract these two components encode
8
+ *
9
+ * Bloom's web `Portal` renders into `#bloom-portal-root`, which is
10
+ * `position: fixed; inset: 0; pointer-events: none` — an idle portal must let
11
+ * clicks reach the app underneath. `pointer-events` INHERITS in CSS, so every
12
+ * portaled descendant starts out click-through and has to opt back in.
13
+ *
14
+ * The opt-in only works through the `pointerEvents` **prop**. Passing it inside
15
+ * a `style` object (`style={{ pointerEvents: 'box-none' }}`) does NOT reach the
16
+ * DOM: react-native-web resolves the RN-only `box-none`/`box-only` values in
17
+ * `createDOMProps`, mapping the PROP onto its own class pair
18
+ * (`self { none !important }` + `> * { auto }`), while a style-object value is
19
+ * not valid CSS and is dropped. `pointerEvents: 'auto'`/`'none'` do survive as
20
+ * styles, which is what makes this so easy to get wrong — the two RN-only
21
+ * values silently do nothing. The symptom is brutal and silent: the whole
22
+ * surface — backdrop AND panel — renders perfectly and is completely
23
+ * click-through. Backdrop taps don't dismiss, buttons don't press, and the
24
+ * clicks land on whatever is behind the overlay (so a tap "through" a viewer
25
+ * navigates the page underneath). Only keyboard paths (Escape) keep working,
26
+ * which is what makes it look like a dismissal bug rather than a hit-testing
27
+ * one.
28
+ *
29
+ * Use `<OverlayRoot>` for the surface's outermost node and `<Backdrop>` for its
30
+ * dimming layer; do not re-implement either with raw `View`s.
31
+ */
32
+ import { memo, type ReactNode } from 'react';
33
+ import { Platform, Pressable, StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native';
34
+
35
+ import { WEB_POSITION_FIXED } from '../styles/web-view-style';
36
+
37
+ export interface OverlayRootProps {
38
+ children?: ReactNode;
39
+ style?: StyleProp<ViewStyle>;
40
+ testID?: string;
41
+ }
42
+
43
+ /**
44
+ * Outermost node of a portaled surface. Fills the viewport and re-enables
45
+ * pointer events for its own children while empty gaps stay click-through
46
+ * (`box-none`), so a surface that only covers part of the screen never steals
47
+ * clicks from the app behind it.
48
+ */
49
+ export function OverlayRoot({ children, style, testID }: OverlayRootProps) {
50
+ return (
51
+ <View pointerEvents="box-none" style={[styles.root, style]} testID={testID}>
52
+ {children}
53
+ </View>
54
+ );
55
+ }
56
+
57
+ OverlayRoot.displayName = 'OverlayRoot';
58
+
59
+ export interface BackdropProps {
60
+ /** Dismiss handler. Omit (or pass `disabled`) for a backdrop that only dims. */
61
+ onPress?: () => void;
62
+ /** `true` keeps the dim but makes it inert — a blocking dialog, a busy state. */
63
+ disabled?: boolean;
64
+ /** Extra style (dim colour, animated opacity). Rendered over `absoluteFill`. */
65
+ style?: StyleProp<ViewStyle>;
66
+ /** Rendered inside the backdrop — e.g. a blur layer that must dim with it. */
67
+ children?: ReactNode;
68
+ accessibilityLabel?: string;
69
+ testID?: string;
70
+ }
71
+
72
+ /**
73
+ * Full-bleed dimming layer that dismisses the surface when pressed. Always
74
+ * takes pointer events (that is its whole job), so it must render UNDER the
75
+ * surface's panel in the tree, never over it.
76
+ */
77
+ export const Backdrop = memo(function Backdrop({
78
+ onPress,
79
+ disabled = false,
80
+ style,
81
+ children,
82
+ accessibilityLabel = 'Dismiss',
83
+ testID,
84
+ }: BackdropProps) {
85
+ const inert = disabled || !onPress;
86
+ return (
87
+ <Pressable
88
+ pointerEvents="auto"
89
+ onPress={inert ? undefined : onPress}
90
+ disabled={inert}
91
+ // A dimming layer is not a focus stop on web: Escape and the panel's own
92
+ // controls are the keyboard paths out. It stays labelled for screen
93
+ // readers that surface it as the dismiss affordance.
94
+ focusable={false}
95
+ accessibilityRole={inert ? undefined : 'button'}
96
+ accessibilityLabel={inert ? undefined : accessibilityLabel}
97
+ testID={testID}
98
+ style={[StyleSheet.absoluteFill, style]}
99
+ >
100
+ {children}
101
+ </Pressable>
102
+ );
103
+ });
104
+
105
+ const styles = StyleSheet.create({
106
+ root: {
107
+ // Web: the portal root is `fixed; inset: 0`, but a plain absolute child of
108
+ // it anchors to the document flow in some browsers once the page scrolls —
109
+ // pin this box to the viewport itself. Native: absolute fill inside the
110
+ // Outlet, which is already full-screen.
111
+ ...(Platform.OS === 'web'
112
+ ? { position: WEB_POSITION_FIXED, top: 0, left: 0, right: 0, bottom: 0 }
113
+ : StyleSheet.absoluteFillObject),
114
+ },
115
+ });
@@ -63,7 +63,7 @@ function createPortalGroup() {
63
63
  function Outlet() {
64
64
  const ctx = useContext(Context);
65
65
  if (Platform.OS === 'web') {
66
- return <View style={styles.portalOutlet}>{ctx.outlet}</View>;
66
+ return <View pointerEvents="box-none" style={styles.portalOutlet}>{ctx.outlet}</View>;
67
67
  }
68
68
  return ctx.outlet;
69
69
  }
@@ -123,7 +123,9 @@ const styles = StyleSheet.create({
123
123
  left: 0,
124
124
  right: 0,
125
125
  bottom: 0,
126
- pointerEvents: 'box-none',
126
+ // `box-none` rides on the PROP above — react-native-web only resolves that
127
+ // RN-only value from the prop path, so as a style entry it is silently
128
+ // inert and this full-viewport outlet swallows every click in the app.
127
129
  zIndex: Z_INDEX.portalRoot,
128
130
  },
129
131
  });
@@ -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';
@@ -440,7 +440,7 @@ function TabBarBody({
440
440
  );
441
441
 
442
442
  return (
443
- <View {...viewProps} style={[styles.root, style]}>
443
+ <View {...viewProps} pointerEvents="box-none" style={[styles.root, style]}>
444
444
  {/* Progressive blur rising from the screen's bottom edge behind the pill.
445
445
  Rendered CONDITIONALLY, and as nothing at all when off: the band is
446
446
  full-bleed and 114pt tall at a zero bottom inset, so it blurs whatever
@@ -462,7 +462,7 @@ function TabBarBody({
462
462
  }}
463
463
  />
464
464
  )}
465
- <View style={[styles.barWrap, { marginBottom: bottomOffset }, constrainedWrapStyle]}>
465
+ <View pointerEvents="box-none" style={[styles.barWrap, { marginBottom: bottomOffset }, constrainedWrapStyle]}>
466
466
  <GestureDetector gesture={gesture}>
467
467
  <Animated.View style={barStyle}>
468
468
  <Surface theme={theme} style={shapeStyle} />
@@ -642,17 +642,19 @@ export function createTabBarButton(Glyph: ComponentType<TabBarGlyphProps>) {
642
642
 
643
643
  const styles = StyleSheet.create({
644
644
  root: {
645
+ // `pointerEvents="box-none"` is passed as a PROP on the views using these
646
+ // styles, NOT as a style entry: react-native-web resolves the RN-only
647
+ // `box-none` value only from the prop path, so as a style it silently does
648
+ // nothing and this full-width bottom band swallows every click in it. RNW
649
+ // logs a deprecation notice for the prop exactly once per session — the
650
+ // trade against a dead strip across the bottom of every page is not close.
645
651
  position: 'absolute',
646
652
  left: 0,
647
653
  right: 0,
648
654
  bottom: 0,
649
- // In the style object, not as a prop: RN-Web deprecated the `pointerEvents`
650
- // prop and warns on every render when it is used.
651
- pointerEvents: 'box-none',
652
655
  },
653
656
  barWrap: {
654
657
  marginHorizontal: BAR_MARGIN,
655
- pointerEvents: 'box-none',
656
658
  },
657
659
  highlight: {
658
660
  position: 'absolute',
@@ -19,13 +19,14 @@
19
19
  * swipe-to-dismiss would never receive touches. This provides its own, exactly
20
20
  * as `bottom-sheet/index.web.tsx` does. Web consumers need no setup.
21
21
  *
22
- * `pointerEvents: 'box-none'` keeps the empty area click-through while the rows
22
+ * `OverlayRoot`'s `box-none` keeps the empty area click-through while the rows
23
23
  * themselves stay interactive.
24
24
  */
25
25
  import * as React from 'react';
26
26
  import { StyleSheet } from 'react-native';
27
27
  import { GestureHandlerRootView } from 'react-native-gesture-handler';
28
28
 
29
+ import { OverlayRoot } from '../overlay';
29
30
  import { Portal } from '../portal/index.web';
30
31
  import { WEB_POSITION_FIXED } from '../styles/web-view-style';
31
32
  import { Z_INDEX } from '../styles/z-index';
@@ -33,9 +34,14 @@ import type { ToastHostProps } from './types';
33
34
 
34
35
  export function ToastHost({ children, ToasterOverlayWrapper }: ToastHostProps) {
35
36
  const content = (
36
- <GestureHandlerRootView style={styles.host}>
37
- {children}
38
- </GestureHandlerRootView>
37
+ // `OverlayRoot` carries the pointer-events opt-in as a PROP; as a style
38
+ // entry it never reached the DOM, so the rows inherited the portal root's
39
+ // `pointer-events: none` and no toast could be pressed or swiped away.
40
+ <OverlayRoot style={styles.host}>
41
+ <GestureHandlerRootView style={StyleSheet.absoluteFill}>
42
+ {children}
43
+ </GestureHandlerRootView>
44
+ </OverlayRoot>
39
45
  );
40
46
 
41
47
  return (
@@ -62,6 +68,5 @@ const styles = StyleSheet.create({
62
68
  // Inside `#bloom-portal-root` (which owns the document layer at 999999),
63
69
  // this keeps toasts above Dialog's surface (60) and tooltips (70).
64
70
  zIndex: Z_INDEX.toast,
65
- pointerEvents: 'box-none',
66
71
  },
67
72
  });
@@ -28,6 +28,7 @@ import {
28
28
  GestureDetector,
29
29
  } from 'react-native-gesture-handler';
30
30
  import { useTheme } from '../theme/use-theme';
31
+ import { Backdrop, OverlayRoot } from '../overlay';
31
32
  import { Portal } from '../portal';
32
33
  import { PressableWithHover } from '../pressable-with-hover';
33
34
  import {
@@ -423,19 +424,6 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
423
424
 
424
425
  React.useImperativeHandle(ref, () => ({ open }), [open]);
425
426
 
426
- // Tapping the backdrop dismisses. Uses the same Gesture system as every
427
- // other interaction in this component (image tap, pinch, pan, double-tap)
428
- // instead of a plain RN Pressable, so the backdrop isn't the one interaction
429
- // mixing two different event-handling systems under the same
430
- // GestureHandlerRootView.
431
- const backdropTapGesture = useMemo(
432
- () =>
433
- Gesture.Tap().onEnd(() => {
434
- runOnJS(handleDismiss)();
435
- }),
436
- [handleDismiss]
437
- );
438
-
439
427
  const panGesture = useMemo(
440
428
  () =>
441
429
  Gesture.Pan()
@@ -756,9 +744,16 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
756
744
  }, [handleDismiss, isOpen, pageTo]);
757
745
 
758
746
  const renderContent = () => (
759
- <GestureHandlerRootView style={styles.modalContainer}>
760
- <GestureDetector gesture={backdropTapGesture}>
761
- <Animated.View style={StyleSheet.absoluteFill}>
747
+ <OverlayRoot style={styles.modalContainer}>
748
+ <GestureHandlerRootView style={StyleSheet.absoluteFill}>
749
+ {/* Shared `Backdrop` (a Pressable) rather than a `Gesture.Tap()`: the
750
+ viewer renders through the web Portal, whose root is
751
+ `pointer-events: none`, and a gesture handler on a node that never
752
+ receives pointer events simply never fires — tapping the backdrop
753
+ did nothing on web while Escape still closed. `Backdrop` opts back
754
+ in via the `pointerEvents` PROP, the only form that reaches the DOM
755
+ (see `src/overlay`). */}
756
+ <Backdrop onPress={handleDismiss} accessibilityLabel="Close image viewer">
762
757
  <AnimatedBlurView
763
758
  intensity={80}
764
759
  tint={theme.isDark ? 'dark' : 'light'}
@@ -769,8 +764,7 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
769
764
  style={[StyleSheet.absoluteFill, { backgroundColor: theme.colors.overlay }, backdropStyle]}
770
765
  />
771
766
  </AnimatedBlurView>
772
- </Animated.View>
773
- </GestureDetector>
767
+ </Backdrop>
774
768
 
775
769
  <GestureDetector gesture={panGesture}>
776
770
  <Animated.View
@@ -961,8 +955,9 @@ const ZoomableImageGalleryInner = React.forwardRef<ZoomableImageGalleryHandle, Z
961
955
  </Animated.View>
962
956
  ) : null}
963
957
  </Animated.View>
964
- </GestureDetector>
965
- </GestureHandlerRootView>
958
+ </GestureDetector>
959
+ </GestureHandlerRootView>
960
+ </OverlayRoot>
966
961
  );
967
962
 
968
963
  if (!isOpen) return null;