@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.
- package/README.md +32 -1
- package/lib/commonjs/bottom-sheet/index.web.js +10 -10
- package/lib/commonjs/bottom-sheet/index.web.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.js +2 -3
- package/lib/commonjs/dialog/Dialog.js.map +1 -1
- package/lib/commonjs/dialog/Dialog.web.js +9 -19
- package/lib/commonjs/dialog/Dialog.web.js.map +1 -1
- package/lib/commonjs/dialog/DialogHeader.js +4 -6
- package/lib/commonjs/dialog/DialogHeader.js.map +1 -1
- package/lib/commonjs/overlay/index.js +108 -0
- package/lib/commonjs/overlay/index.js.map +1 -0
- package/lib/commonjs/portal/index.js +4 -1
- package/lib/commonjs/portal/index.js.map +1 -1
- package/lib/commonjs/provider/index.js +67 -0
- package/lib/commonjs/provider/index.js.map +1 -0
- package/lib/commonjs/provider/scroll-provider.js +13 -0
- package/lib/commonjs/provider/scroll-provider.js.map +1 -0
- package/lib/commonjs/provider/scroll-provider.web.js +13 -0
- package/lib/commonjs/provider/scroll-provider.web.js.map +1 -0
- package/lib/commonjs/tab-bar/TabBarBase.js +10 -6
- package/lib/commonjs/tab-bar/TabBarBase.js.map +1 -1
- package/lib/commonjs/toast/ToastHost.js +13 -5
- package/lib/commonjs/toast/ToastHost.js.map +1 -1
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js +189 -196
- package/lib/commonjs/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/module/bottom-sheet/index.web.js +10 -10
- package/lib/module/bottom-sheet/index.web.js.map +1 -1
- package/lib/module/dialog/Dialog.js +2 -3
- package/lib/module/dialog/Dialog.js.map +1 -1
- package/lib/module/dialog/Dialog.web.js +10 -20
- package/lib/module/dialog/Dialog.web.js.map +1 -1
- package/lib/module/dialog/DialogHeader.js +4 -6
- package/lib/module/dialog/DialogHeader.js.map +1 -1
- package/lib/module/overlay/index.js +102 -0
- package/lib/module/overlay/index.js.map +1 -0
- package/lib/module/portal/index.js +4 -1
- package/lib/module/portal/index.js.map +1 -1
- package/lib/module/provider/index.js +63 -0
- package/lib/module/provider/index.js.map +1 -0
- package/lib/module/provider/scroll-provider.js +16 -0
- package/lib/module/provider/scroll-provider.js.map +1 -0
- package/lib/module/provider/scroll-provider.web.js +5 -0
- package/lib/module/provider/scroll-provider.web.js.map +1 -0
- package/lib/module/tab-bar/TabBarBase.js +10 -6
- package/lib/module/tab-bar/TabBarBase.js.map +1 -1
- package/lib/module/toast/ToastHost.js +13 -5
- package/lib/module/toast/ToastHost.js.map +1 -1
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js +178 -185
- package/lib/module/zoomable-image-gallery/ZoomableImageGallery.js.map +1 -1
- package/lib/typescript/commonjs/bottom-sheet/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/overlay/index.d.ts +67 -0
- package/lib/typescript/commonjs/overlay/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/provider/index.d.ts +45 -0
- package/lib/typescript/commonjs/provider/index.d.ts.map +1 -0
- package/lib/typescript/commonjs/provider/scroll-provider.d.ts +14 -0
- package/lib/typescript/commonjs/provider/scroll-provider.d.ts.map +1 -0
- package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts +3 -0
- package/lib/typescript/commonjs/provider/scroll-provider.web.d.ts.map +1 -0
- package/lib/typescript/commonjs/toast/ToastHost.d.ts.map +1 -1
- package/lib/typescript/commonjs/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/lib/typescript/module/bottom-sheet/index.web.d.ts.map +1 -1
- package/lib/typescript/module/dialog/Dialog.web.d.ts.map +1 -1
- package/lib/typescript/module/overlay/index.d.ts +67 -0
- package/lib/typescript/module/overlay/index.d.ts.map +1 -0
- package/lib/typescript/module/provider/index.d.ts +45 -0
- package/lib/typescript/module/provider/index.d.ts.map +1 -0
- package/lib/typescript/module/provider/scroll-provider.d.ts +14 -0
- package/lib/typescript/module/provider/scroll-provider.d.ts.map +1 -0
- package/lib/typescript/module/provider/scroll-provider.web.d.ts +3 -0
- package/lib/typescript/module/provider/scroll-provider.web.d.ts.map +1 -0
- package/lib/typescript/module/toast/ToastHost.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/package.json +23 -1
- package/src/__tests__/BloomProvider.web.test.tsx +114 -0
- package/src/__tests__/TabBarBlur.test.tsx +6 -1
- package/src/__tests__/overlay-pointer-events.test.tsx +124 -0
- package/src/__tests__/pointer-events-style-form.test.ts +58 -0
- package/src/bottom-sheet/index.web.tsx +13 -8
- package/src/dialog/Dialog.tsx +1 -1
- package/src/dialog/Dialog.web.tsx +14 -16
- package/src/dialog/DialogHeader.tsx +2 -2
- package/src/overlay/index.tsx +115 -0
- package/src/portal/index.tsx +4 -2
- package/src/provider/index.tsx +68 -0
- package/src/provider/scroll-provider.ts +13 -0
- package/src/provider/scroll-provider.web.ts +2 -0
- package/src/tab-bar/TabBarBase.tsx +8 -6
- package/src/toast/ToastHost.tsx +10 -5
- package/src/zoomable-image-gallery/ZoomableImageGallery.tsx +15 -20
|
@@ -0,0 +1,67 @@
|
|
|
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 { type ReactNode } from 'react';
|
|
33
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
34
|
+
export interface OverlayRootProps {
|
|
35
|
+
children?: ReactNode;
|
|
36
|
+
style?: StyleProp<ViewStyle>;
|
|
37
|
+
testID?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Outermost node of a portaled surface. Fills the viewport and re-enables
|
|
41
|
+
* pointer events for its own children while empty gaps stay click-through
|
|
42
|
+
* (`box-none`), so a surface that only covers part of the screen never steals
|
|
43
|
+
* clicks from the app behind it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function OverlayRoot({ children, style, testID }: OverlayRootProps): import("react/jsx-runtime").JSX.Element;
|
|
46
|
+
export declare namespace OverlayRoot {
|
|
47
|
+
var displayName: string;
|
|
48
|
+
}
|
|
49
|
+
export interface BackdropProps {
|
|
50
|
+
/** Dismiss handler. Omit (or pass `disabled`) for a backdrop that only dims. */
|
|
51
|
+
onPress?: () => void;
|
|
52
|
+
/** `true` keeps the dim but makes it inert — a blocking dialog, a busy state. */
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
/** Extra style (dim colour, animated opacity). Rendered over `absoluteFill`. */
|
|
55
|
+
style?: StyleProp<ViewStyle>;
|
|
56
|
+
/** Rendered inside the backdrop — e.g. a blur layer that must dim with it. */
|
|
57
|
+
children?: ReactNode;
|
|
58
|
+
accessibilityLabel?: string;
|
|
59
|
+
testID?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Full-bleed dimming layer that dismisses the surface when pressed. Always
|
|
63
|
+
* takes pointer events (that is its whole job), so it must render UNDER the
|
|
64
|
+
* surface's panel in the tree, never over it.
|
|
65
|
+
*/
|
|
66
|
+
export declare const Backdrop: import("react").NamedExoticComponent<BackdropProps>;
|
|
67
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/overlay/index.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,EAAQ,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAyC,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAIrG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,gBAAgB,2CAMxE;yBANe,WAAW;;;AAU3B,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,iFAAiF;IACjF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,qDA0BnB,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
import { type ImageResolver } from '../image-resolver';
|
|
29
|
+
import { type BloomThemeProviderProps } from '../theme';
|
|
30
|
+
export interface BloomProviderProps extends Omit<BloomThemeProviderProps, 'children'> {
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Resolves bare media identifiers (Oxy file ids) to loadable URLs for every
|
|
34
|
+
* Bloom surface that takes a `source` — `<Avatar>`, image galleries, cards.
|
|
35
|
+
* Typically `(id, variant) => oxyServices.getFileDownloadUrl(id, variant)`.
|
|
36
|
+
*/
|
|
37
|
+
imageResolver?: ImageResolver;
|
|
38
|
+
/** `false` disables haptic feedback app-wide (honored by every `useHaptics()` call). */
|
|
39
|
+
haptics?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare function BloomProvider({ children, imageResolver, haptics, ...themeProps }: BloomProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
42
|
+
export declare namespace BloomProvider {
|
|
43
|
+
var displayName: string;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/provider/index.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAG9E,OAAO,EAAsB,KAAK,uBAAuB,EAAE,MAAM,UAAU,CAAC;AAG5E,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,uBAAuB,EAAE,UAAU,CAAC;IACnF,QAAQ,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,wFAAwF;IACxF,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,aAAa,EACb,OAAc,EACd,GAAG,UAAU,EACd,EAAE,kBAAkB,2CAcpB;yBAnBe,aAAa"}
|
|
@@ -0,0 +1,14 @@
|
|
|
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';
|
|
14
|
+
//# sourceMappingURL=scroll-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-provider.d.ts","sourceRoot":"","sources":["../../../../src/provider/scroll-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-provider.web.d.ts","sourceRoot":"","sources":["../../../../src/provider/scroll-provider.web.ts"],"names":[],"mappings":"AAAA,wGAAwG;AACxG,OAAO,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ToastHost.d.ts","sourceRoot":"","sources":["../../../../src/toast/ToastHost.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ToastHost.d.ts","sourceRoot":"","sources":["../../../../src/toast/ToastHost.tsx"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,wBAAgB,SAAS,CAAC,EAAE,QAAQ,EAAE,qBAAqB,EAAE,EAAE,cAAc,2CAqB5E;yBArBe,SAAS"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ZoomableImageGallery.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/ZoomableImageGallery.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"ZoomableImageGallery.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/ZoomableImageGallery.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AAmEjF,OAAO,KAAK,EAGV,0BAA0B,EAC1B,yBAAyB,EAC1B,MAAM,SAAS,CAAC;AAu4BjB,eAAO,MAAM,oBAAoB,8GAA4B,CAAC;AAyK9D,eAAe,oBAAoB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.web.d.ts","sourceRoot":"","sources":["../../../../src/bottom-sheet/index.web.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"index.web.d.ts","sourceRoot":"","sources":["../../../../src/bottom-sheet/index.web.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAQ/B,OAAO,EAEH,KAAK,gBAAgB,EACrB,KAAK,cAAc,EAEtB,MAAM,mBAAmB,CAAC;AAoC3B,QAAA,MAAM,WAAW,yFAEf,CAAC;AAkBH,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,CAAC;AACjD,eAAe,WAAW,CAAC;AAC3B,OAAO,EAAE,WAAW,EAAE,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Dialog.web.d.ts","sourceRoot":"","sources":["../../../../src/dialog/Dialog.web.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"Dialog.web.d.ts","sourceRoot":"","sources":["../../../../src/dialog/Dialog.web.tsx"],"names":[],"mappings":"AAuCA,OAAO,EAOL,4BAA4B,EAM7B,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EACV,YAAY,EAGZ,WAAW,EACZ,MAAM,SAAS,CAAC;AAsDjB;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,MAAM,CAAC,EAAE,SAAS,EAAE,GAAG,IAAI,EAAE,EAAE,WAAW,2CAazD;AA4ZD,OAAO,EAAE,4BAA4B,EAAE,CAAC;AAiQxC;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,EAChC,KAAK,EACL,WAAW,EACX,OAAO,EACP,SAAS,GACV,EAAE;IACD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,YAAY,EAAE,CAAC;IACxB,SAAS,EAAE,MAAM,IAAI,CAAC;CACvB,2CAgBA;AAiCD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,gBAAgB,mZAK5B,CAAC"}
|
|
@@ -0,0 +1,67 @@
|
|
|
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 { type ReactNode } from 'react';
|
|
33
|
+
import { type StyleProp, type ViewStyle } from 'react-native';
|
|
34
|
+
export interface OverlayRootProps {
|
|
35
|
+
children?: ReactNode;
|
|
36
|
+
style?: StyleProp<ViewStyle>;
|
|
37
|
+
testID?: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Outermost node of a portaled surface. Fills the viewport and re-enables
|
|
41
|
+
* pointer events for its own children while empty gaps stay click-through
|
|
42
|
+
* (`box-none`), so a surface that only covers part of the screen never steals
|
|
43
|
+
* clicks from the app behind it.
|
|
44
|
+
*/
|
|
45
|
+
export declare function OverlayRoot({ children, style, testID }: OverlayRootProps): import("react/jsx-runtime").JSX.Element;
|
|
46
|
+
export declare namespace OverlayRoot {
|
|
47
|
+
var displayName: string;
|
|
48
|
+
}
|
|
49
|
+
export interface BackdropProps {
|
|
50
|
+
/** Dismiss handler. Omit (or pass `disabled`) for a backdrop that only dims. */
|
|
51
|
+
onPress?: () => void;
|
|
52
|
+
/** `true` keeps the dim but makes it inert — a blocking dialog, a busy state. */
|
|
53
|
+
disabled?: boolean;
|
|
54
|
+
/** Extra style (dim colour, animated opacity). Rendered over `absoluteFill`. */
|
|
55
|
+
style?: StyleProp<ViewStyle>;
|
|
56
|
+
/** Rendered inside the backdrop — e.g. a blur layer that must dim with it. */
|
|
57
|
+
children?: ReactNode;
|
|
58
|
+
accessibilityLabel?: string;
|
|
59
|
+
testID?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Full-bleed dimming layer that dismisses the surface when pressed. Always
|
|
63
|
+
* takes pointer events (that is its whole job), so it must render UNDER the
|
|
64
|
+
* surface's panel in the tree, never over it.
|
|
65
|
+
*/
|
|
66
|
+
export declare const Backdrop: import("react").NamedExoticComponent<BackdropProps>;
|
|
67
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/overlay/index.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,EAAQ,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAC7C,OAAO,EAAyC,KAAK,SAAS,EAAE,KAAK,SAAS,EAAE,MAAM,cAAc,CAAC;AAIrG,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,gBAAgB,2CAMxE;yBANe,WAAW;;;AAU3B,MAAM,WAAW,aAAa;IAC5B,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,iFAAiF;IACjF,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,gFAAgF;IAChF,KAAK,CAAC,EAAE,SAAS,CAAC,SAAS,CAAC,CAAC;IAC7B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;GAIG;AACH,eAAO,MAAM,QAAQ,qDA0BnB,CAAC"}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
import { type ImageResolver } from '../image-resolver';
|
|
29
|
+
import { type BloomThemeProviderProps } from '../theme';
|
|
30
|
+
export interface BloomProviderProps extends Omit<BloomThemeProviderProps, 'children'> {
|
|
31
|
+
children: ReactNode;
|
|
32
|
+
/**
|
|
33
|
+
* Resolves bare media identifiers (Oxy file ids) to loadable URLs for every
|
|
34
|
+
* Bloom surface that takes a `source` — `<Avatar>`, image galleries, cards.
|
|
35
|
+
* Typically `(id, variant) => oxyServices.getFileDownloadUrl(id, variant)`.
|
|
36
|
+
*/
|
|
37
|
+
imageResolver?: ImageResolver;
|
|
38
|
+
/** `false` disables haptic feedback app-wide (honored by every `useHaptics()` call). */
|
|
39
|
+
haptics?: boolean;
|
|
40
|
+
}
|
|
41
|
+
export declare function BloomProvider({ children, imageResolver, haptics, ...themeProps }: BloomProviderProps): import("react/jsx-runtime").JSX.Element;
|
|
42
|
+
export declare namespace BloomProvider {
|
|
43
|
+
var displayName: string;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../../src/provider/index.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC,OAAO,EAAyB,KAAK,aAAa,EAAE,MAAM,mBAAmB,CAAC;AAG9E,OAAO,EAAsB,KAAK,uBAAuB,EAAE,MAAM,UAAU,CAAC;AAG5E,MAAM,WAAW,kBAAmB,SAAQ,IAAI,CAAC,uBAAuB,EAAE,UAAU,CAAC;IACnF,QAAQ,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,aAAa,CAAC,EAAE,aAAa,CAAC;IAC9B,wFAAwF;IACxF,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,aAAa,EACb,OAAc,EACd,GAAG,UAAU,EACd,EAAE,kBAAkB,2CAcpB;yBAnBe,aAAa"}
|
|
@@ -0,0 +1,14 @@
|
|
|
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';
|
|
14
|
+
//# sourceMappingURL=scroll-provider.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-provider.d.ts","sourceRoot":"","sources":["../../../../src/provider/scroll-provider.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AACH,OAAO,EAAE,yBAAyB,EAAE,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-provider.web.d.ts","sourceRoot":"","sources":["../../../../src/provider/scroll-provider.web.ts"],"names":[],"mappings":"AAAA,wGAAwG;AACxG,OAAO,EAAE,yBAAyB,EAAE,MAAM,qBAAqB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ToastHost.d.ts","sourceRoot":"","sources":["../../../../src/toast/ToastHost.tsx"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"ToastHost.d.ts","sourceRoot":"","sources":["../../../../src/toast/ToastHost.tsx"],"names":[],"mappings":"AAgCA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC;AAE9C,wBAAgB,SAAS,CAAC,EAAE,QAAQ,EAAE,qBAAqB,EAAE,EAAE,cAAc,2CAqB5E;yBArBe,SAAS"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ZoomableImageGallery.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/ZoomableImageGallery.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"ZoomableImageGallery.d.ts","sourceRoot":"","sources":["../../../../src/zoomable-image-gallery/ZoomableImageGallery.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA4D,MAAM,OAAO,CAAC;AAmEjF,OAAO,KAAK,EAGV,0BAA0B,EAC1B,yBAAyB,EAC1B,MAAM,SAAS,CAAC;AAu4BjB,eAAO,MAAM,oBAAoB,8GAA4B,CAAC;AAyK9D,eAAe,oBAAoB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oxyhq/bloom",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"description": "Bloom UI — Oxy ecosystem component library for React Native + Expo + Web",
|
|
5
5
|
"main": "lib/commonjs/index.js",
|
|
6
6
|
"module": "lib/module/index.js",
|
|
@@ -28,6 +28,17 @@
|
|
|
28
28
|
"default": "./lib/commonjs/index.js"
|
|
29
29
|
}
|
|
30
30
|
},
|
|
31
|
+
"./provider": {
|
|
32
|
+
"react-native": "./src/provider/index.tsx",
|
|
33
|
+
"import": {
|
|
34
|
+
"types": "./lib/typescript/module/provider/index.d.ts",
|
|
35
|
+
"default": "./lib/module/provider/index.js"
|
|
36
|
+
},
|
|
37
|
+
"require": {
|
|
38
|
+
"types": "./lib/typescript/commonjs/provider/index.d.ts",
|
|
39
|
+
"default": "./lib/commonjs/provider/index.js"
|
|
40
|
+
}
|
|
41
|
+
},
|
|
31
42
|
"./surfaces": {
|
|
32
43
|
"react-native": "./src/surfaces/index.ts",
|
|
33
44
|
"browser": {
|
|
@@ -137,6 +148,17 @@
|
|
|
137
148
|
"default": "./lib/commonjs/portal/index.js"
|
|
138
149
|
}
|
|
139
150
|
},
|
|
151
|
+
"./overlay": {
|
|
152
|
+
"react-native": "./src/overlay/index.tsx",
|
|
153
|
+
"import": {
|
|
154
|
+
"types": "./lib/typescript/module/overlay/index.d.ts",
|
|
155
|
+
"default": "./lib/module/overlay/index.js"
|
|
156
|
+
},
|
|
157
|
+
"require": {
|
|
158
|
+
"types": "./lib/typescript/commonjs/overlay/index.d.ts",
|
|
159
|
+
"default": "./lib/commonjs/overlay/index.js"
|
|
160
|
+
}
|
|
161
|
+
},
|
|
140
162
|
"./dialog": {
|
|
141
163
|
"react-native": "./src/dialog/index.ts",
|
|
142
164
|
"browser": {
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// `BloomProvider` exists so an app mounts ONE Bloom root instead of five, and
|
|
6
|
+
// the concrete bug that motivated it is web-only: `useScrollRestoration()`
|
|
7
|
+
// THROWS outside `ScrollRestorationProvider`, so anything scrollable that an
|
|
8
|
+
// app renders beside — rather than under — that provider crashes the screen.
|
|
9
|
+
//
|
|
10
|
+
// These tests therefore assert the WEB binding: `provider/scroll-provider` is
|
|
11
|
+
// mapped to the web implementation the same way a web bundler resolves the
|
|
12
|
+
// `.web` sibling (jest has no platform-extension resolution of its own, so
|
|
13
|
+
// without this mapping the suite would silently exercise the native no-op and
|
|
14
|
+
// pass no matter what).
|
|
15
|
+
|
|
16
|
+
import { createElement, type ReactNode } from 'react';
|
|
17
|
+
import { act } from 'react';
|
|
18
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
19
|
+
|
|
20
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
|
21
|
+
true;
|
|
22
|
+
|
|
23
|
+
// Only the two navigation hooks `scroll/index.web` consumes; `virtual` keeps
|
|
24
|
+
// the real (native-heavy) expo-router off the resolver.
|
|
25
|
+
jest.mock(
|
|
26
|
+
'expo-router',
|
|
27
|
+
() => {
|
|
28
|
+
const react = jest.requireActual<typeof import('react')>('react');
|
|
29
|
+
return {
|
|
30
|
+
useFocusEffect: (effect: () => undefined | (() => void)) => {
|
|
31
|
+
react.useEffect(effect, [effect]);
|
|
32
|
+
},
|
|
33
|
+
useRoute: () => ({ key: 'route-test', name: 'Test', params: {} }),
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
{ virtual: true },
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// What a web bundler does with `provider/scroll-provider.web.ts`.
|
|
40
|
+
jest.mock('../provider/scroll-provider', () => ({
|
|
41
|
+
ScrollRestorationProvider:
|
|
42
|
+
jest.requireActual<typeof import('../scroll/index.web')>('../scroll/index.web')
|
|
43
|
+
.ScrollRestorationProvider,
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
// Imported AFTER the mocks are registered.
|
|
47
|
+
import { BloomProvider } from '../provider';
|
|
48
|
+
import { useImageResolver } from '../image-resolver';
|
|
49
|
+
import { useScrollRestoration } from '../scroll/index.web';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Stands in for any Bloom-consuming scrollable an app renders under the root —
|
|
53
|
+
* a feed, a right-rail replies list, an overlay.
|
|
54
|
+
*/
|
|
55
|
+
function Scrollable({ onResolve }: { onResolve?: (url: string | undefined) => void }): ReactNode {
|
|
56
|
+
useScrollRestoration('window');
|
|
57
|
+
const resolver = useImageResolver();
|
|
58
|
+
onResolve?.(resolver?.('file-id', 'avatar'));
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function render(node: ReactNode): { root: Root; container: HTMLElement } {
|
|
63
|
+
const container = document.createElement('div');
|
|
64
|
+
document.body.appendChild(container);
|
|
65
|
+
const root = createRoot(container);
|
|
66
|
+
act(() => {
|
|
67
|
+
root.render(node);
|
|
68
|
+
});
|
|
69
|
+
return { root, container };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function unmount({ root, container }: { root: Root; container: HTMLElement }): void {
|
|
73
|
+
act(() => {
|
|
74
|
+
root.unmount();
|
|
75
|
+
});
|
|
76
|
+
container.remove();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe('BloomProvider (web)', () => {
|
|
80
|
+
it('provides scroll restoration to everything below it', () => {
|
|
81
|
+
let resolved: string | undefined;
|
|
82
|
+
let mounted: { root: Root; container: HTMLElement } | undefined;
|
|
83
|
+
|
|
84
|
+
expect(() => {
|
|
85
|
+
mounted = render(
|
|
86
|
+
createElement(BloomProvider, {
|
|
87
|
+
fonts: false,
|
|
88
|
+
imageResolver: (id: string, variant?: string) => `${id}:${variant}`,
|
|
89
|
+
children: createElement(Scrollable, { onResolve: (url) => { resolved = url; } }),
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
}).not.toThrow();
|
|
93
|
+
|
|
94
|
+
// The image resolver reaches the same subtree — one root, every context.
|
|
95
|
+
expect(resolved).toBe('file-id:avatar');
|
|
96
|
+
|
|
97
|
+
if (mounted) unmount(mounted);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Vacuity guard: proves the assertion above is actually load-bearing. If the
|
|
101
|
+
// provider ever stops mounting the WEB scroll provider (a bad platform
|
|
102
|
+
// binding, a dropped nesting level) this control case is what makes the test
|
|
103
|
+
// above meaningful — remove the provider and the very same child throws.
|
|
104
|
+
it('is what keeps the hook from throwing — the same child crashes without it', () => {
|
|
105
|
+
const consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
106
|
+
try {
|
|
107
|
+
expect(() => render(createElement(Scrollable, {}))).toThrow(
|
|
108
|
+
/useScrollRestoration must be used within a <ScrollRestorationProvider>/,
|
|
109
|
+
);
|
|
110
|
+
} finally {
|
|
111
|
+
consoleError.mockRestore();
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -76,11 +76,16 @@ function flattenStyle(style: unknown): Record<string, unknown> {
|
|
|
76
76
|
/**
|
|
77
77
|
* The bar's outermost view — `styles.root`, the absolutely-positioned
|
|
78
78
|
* `box-none` layer the blur and the pill wrap are the two children of.
|
|
79
|
+
*
|
|
80
|
+
* `box-none` is matched on the PROP, not on the flattened style: react-native-web
|
|
81
|
+
* only resolves that RN-only value from the prop path, so this bar (a full-width
|
|
82
|
+
* band pinned to the bottom of every page) would silently swallow clicks if it
|
|
83
|
+
* ever moved back into the style object. See `pointer-events-style-form.test.ts`.
|
|
79
84
|
*/
|
|
80
85
|
function barRoot(tree: RenderedTree): ReactTestRendererJSON {
|
|
81
86
|
const found = hostNodes(tree).find((node) => {
|
|
82
87
|
const style = flattenStyle(node.props.style);
|
|
83
|
-
return style.position === 'absolute' &&
|
|
88
|
+
return style.position === 'absolute' && node.props.pointerEvents === 'box-none';
|
|
84
89
|
});
|
|
85
90
|
if (!found) throw new Error('The bar rendered no absolute box-none root');
|
|
86
91
|
return found;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// The bug these tests exist for: Bloom's web `Portal` root is
|
|
6
|
+
// `pointer-events: none`, so every portaled surface has to opt back in. The
|
|
7
|
+
// surfaces did that with a `style={{ pointerEvents: 'box-none' }}` entry, which
|
|
8
|
+
// never reached the DOM — react-native-web resolves the `pointerEvents` PROP
|
|
9
|
+
// into its class pair, and the style-object form was dropped on the way
|
|
10
|
+
// through. The result on production web: dialogs, bottom sheets, the image
|
|
11
|
+
// viewer and toasts all rendered perfectly and were completely click-through.
|
|
12
|
+
// Backdrops didn't dismiss, panels didn't press, and clicks landed on the app
|
|
13
|
+
// behind the overlay.
|
|
14
|
+
//
|
|
15
|
+
// `OverlayRoot` / `Backdrop` encode the working contract; these tests pin it.
|
|
16
|
+
|
|
17
|
+
import { createElement } from 'react';
|
|
18
|
+
import { act } from 'react';
|
|
19
|
+
import { createRoot, type Root } from 'react-dom/client';
|
|
20
|
+
|
|
21
|
+
// The whole point is what react-native-web puts in the DOM, so this suite runs
|
|
22
|
+
// against the REAL RNW — not the repo-wide `react-native` mock, which never
|
|
23
|
+
// emits a class and would make every assertion below vacuous.
|
|
24
|
+
jest.mock('react-native', () => jest.requireActual('react-native-web'));
|
|
25
|
+
|
|
26
|
+
import { Backdrop, OverlayRoot } from '../overlay';
|
|
27
|
+
|
|
28
|
+
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
|
|
29
|
+
true;
|
|
30
|
+
|
|
31
|
+
/** Every `pointer-events` declaration react-native-web injected for `className`. */
|
|
32
|
+
function pointerEventsRulesFor(className: string): string[] {
|
|
33
|
+
const classes = className.split(/\s+/).filter(Boolean);
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
36
|
+
let rules: CSSRuleList;
|
|
37
|
+
try {
|
|
38
|
+
rules = sheet.cssRules;
|
|
39
|
+
} catch {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
for (const rule of Array.from(rules)) {
|
|
43
|
+
const text = rule.cssText ?? '';
|
|
44
|
+
if (!text.includes('pointer-events')) continue;
|
|
45
|
+
if (classes.some((c) => text.includes(`.${c}`))) out.push(text);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function render(node: ReturnType<typeof createElement>): {
|
|
52
|
+
root: Root;
|
|
53
|
+
container: HTMLElement;
|
|
54
|
+
} {
|
|
55
|
+
const container = document.createElement('div');
|
|
56
|
+
document.body.appendChild(container);
|
|
57
|
+
const root = createRoot(container);
|
|
58
|
+
act(() => {
|
|
59
|
+
root.render(node);
|
|
60
|
+
});
|
|
61
|
+
return { root, container };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe('overlay pointer-events contract (web)', () => {
|
|
65
|
+
it('OverlayRoot emits box-none: itself inert, its children interactive', () => {
|
|
66
|
+
const { root, container } = render(
|
|
67
|
+
createElement(OverlayRoot, { testID: 'root', children: null }),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const el = container.querySelector('[data-testid="root"]');
|
|
71
|
+
expect(el).not.toBeNull();
|
|
72
|
+
const rules = pointerEventsRulesFor(el?.className ?? '');
|
|
73
|
+
|
|
74
|
+
// The two halves of react-native-web's `box-none`: the box itself does not
|
|
75
|
+
// take events (empty gaps stay click-through), direct children do.
|
|
76
|
+
expect(rules.some((r) => /pointer-events:\s*none/.test(r) && !/>\s*\*/.test(r))).toBe(true);
|
|
77
|
+
expect(rules.some((r) => />\s*\*/.test(r) && /pointer-events:\s*auto/.test(r))).toBe(true);
|
|
78
|
+
|
|
79
|
+
act(() => root.unmount());
|
|
80
|
+
container.remove();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('Backdrop takes pointer events and dismisses on press', () => {
|
|
84
|
+
const onPress = jest.fn();
|
|
85
|
+
const { root, container } = render(
|
|
86
|
+
createElement(Backdrop, { onPress, testID: 'backdrop' }),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
const el = container.querySelector('[data-testid="backdrop"]') as HTMLElement | null;
|
|
90
|
+
expect(el).not.toBeNull();
|
|
91
|
+
|
|
92
|
+
// Without this the element inherits the portal root's `none` and the press
|
|
93
|
+
// below can never happen in a real browser.
|
|
94
|
+
expect(
|
|
95
|
+
pointerEventsRulesFor(el?.className ?? '').some((r) =>
|
|
96
|
+
/pointer-events:\s*auto/.test(r),
|
|
97
|
+
),
|
|
98
|
+
).toBe(true);
|
|
99
|
+
|
|
100
|
+
act(() => {
|
|
101
|
+
el?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
102
|
+
});
|
|
103
|
+
expect(onPress).toHaveBeenCalledTimes(1);
|
|
104
|
+
|
|
105
|
+
act(() => root.unmount());
|
|
106
|
+
container.remove();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('a disabled Backdrop dims without dismissing', () => {
|
|
110
|
+
const onPress = jest.fn();
|
|
111
|
+
const { root, container } = render(
|
|
112
|
+
createElement(Backdrop, { onPress, disabled: true, testID: 'backdrop' }),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const el = container.querySelector('[data-testid="backdrop"]') as HTMLElement | null;
|
|
116
|
+
act(() => {
|
|
117
|
+
el?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
118
|
+
});
|
|
119
|
+
expect(onPress).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
act(() => root.unmount());
|
|
122
|
+
container.remove();
|
|
123
|
+
});
|
|
124
|
+
});
|