@oxyhq/bloom 0.56.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/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/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/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/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/toast/ToastHost.d.ts.map +1 -1
- package/lib/typescript/module/zoomable-image-gallery/ZoomableImageGallery.d.ts.map +1 -1
- package/package.json +12 -1
- 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/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"}
|
|
@@ -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"}
|
|
@@ -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",
|
|
@@ -148,6 +148,17 @@
|
|
|
148
148
|
"default": "./lib/commonjs/portal/index.js"
|
|
149
149
|
}
|
|
150
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
|
+
},
|
|
151
162
|
"./dialog": {
|
|
152
163
|
"react-native": "./src/dialog/index.ts",
|
|
153
164
|
"browser": {
|
|
@@ -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
|
+
});
|
|
@@ -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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
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,
|
package/src/dialog/Dialog.tsx
CHANGED
|
@@ -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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
</
|
|
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
|
-
<
|
|
689
|
-
style={[sheetStyles.root, { zIndex: backdropZIndex }, containerStyle
|
|
687
|
+
<OverlayRoot
|
|
688
|
+
style={[sheetStyles.root, { zIndex: backdropZIndex }, containerStyle]}
|
|
690
689
|
{...(containerClassName ? ({ className: containerClassName } as Record<string, string>) : {})}
|
|
691
690
|
>
|
|
692
|
-
<
|
|
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
|
-
</
|
|
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
|
|
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={
|
|
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"
|