@ankhorage/surface 9.4.19 → 9.5.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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # @ankhorage/surface
2
2
 
3
+ ## 9.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 68db7bb: Add a browser-only Popover entrypoint that does not load React Native modules.
8
+
3
9
  ## 9.4.19
4
10
 
5
11
  ### Patch Changes
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # @ankhorage/surface
5
5
 
6
- ![license: MIT](././paradox/badges/license.svg) ![npm: v9.4.19](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![paradox: warnings](././paradox/badges/docs.svg)
6
+ ![license: MIT](././paradox/badges/license.svg) ![npm: v9.5.0](././paradox/badges/npm.svg) ![runtime: bun](././paradox/badges/runtime.svg) ![typescript: strict](././paradox/badges/typescript.svg) ![eslint: checked](././paradox/badges/eslint.svg) ![prettier: checked](././paradox/badges/prettier.svg) ![build: checked](././paradox/badges/build.svg) ![tests: checked](././paradox/badges/tests.svg) ![paradox: warnings](././paradox/badges/docs.svg)
7
7
 
8
8
  Cross-platform UI foundation for React Native and React Native Web. Provides theming, layout primitives, and core building blocks for modern apps.
9
9
 
@@ -0,0 +1,5 @@
1
+ import React from 'react';
2
+ import type { PopoverProps } from '../../../../../types/popover';
3
+ /*** Renders the browser-only Popover without loading React Native implementation modules. */
4
+ export declare function BrowserPopover(props: PopoverProps): React.JSX.Element;
5
+ //# sourceMappingURL=BrowserPopover.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrowserPopover.d.ts","sourceRoot":"","sources":["../../../../../../src/features/popover/adapters/inbound/browser/BrowserPopover.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,MAAM,OAAO,CAAC;AAI1B,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,8BAA8B,CAAC;AAEjE,6FAA6F;AAC7F,wBAAgB,cAAc,CAAC,KAAK,EAAE,YAAY,qBAgCjD"}
@@ -0,0 +1,108 @@
1
+ import React from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import { useControllableState } from '../../../../../internal/useControllableState';
4
+ /*** Renders the browser-only Popover without loading React Native implementation modules. */
5
+ export function BrowserPopover(props) {
6
+ const anchorRef = React.useRef(null);
7
+ const [contentElement, setContentElement] = React.useState(null);
8
+ const [open, setOpen] = useControllableState({
9
+ defaultValue: props.defaultOpen ?? false,
10
+ onChange: props.onOpenChange,
11
+ value: props.open,
12
+ });
13
+ const passive = props.interactionPolicy === 'passive';
14
+ const close = React.useCallback(() => {
15
+ if (!passive)
16
+ setOpen(false);
17
+ }, [passive, setOpen]);
18
+ const toggle = React.useCallback(() => {
19
+ if (!passive)
20
+ setOpen(!open);
21
+ }, [open, passive, setOpen]);
22
+ useBrowserPopoverDismissal(anchorRef, close, contentElement, open, props.closeOnOutsidePress);
23
+ const anchorRect = useBrowserPopoverAnchorRect(anchorRef, open);
24
+ const content = renderBrowserPopoverContent(props, anchorRect, setContentElement, open);
25
+ return (<>
26
+ <div ref={anchorRef} style={anchorStyle} data-testid={props.testID ? `${props.testID}-anchor` : undefined}>
27
+ {props.anchor({ close, open, toggle })}
28
+ </div>
29
+ {content === null || typeof document === 'undefined'
30
+ ? null
31
+ : createPortal(content, document.body)}
32
+ </>);
33
+ }
34
+ /*** Closes the open browser overlay for a true outside pointer press or Escape. */
35
+ function useBrowserPopoverDismissal(anchorRef, close, contentElement, open, closeOnOutsidePress) {
36
+ React.useEffect(() => {
37
+ if (!open)
38
+ return;
39
+ const closeOnPointerDown = (event) => {
40
+ const { target } = event;
41
+ if (!(target instanceof Node))
42
+ return;
43
+ if (anchorRef.current?.contains(target) || contentElement?.contains(target))
44
+ return;
45
+ if (closeOnOutsidePress !== false)
46
+ close();
47
+ };
48
+ const closeOnEscape = (event) => {
49
+ if (event.key === 'Escape')
50
+ close();
51
+ };
52
+ document.addEventListener('pointerdown', closeOnPointerDown);
53
+ document.addEventListener('keydown', closeOnEscape);
54
+ return () => {
55
+ document.removeEventListener('pointerdown', closeOnPointerDown);
56
+ document.removeEventListener('keydown', closeOnEscape);
57
+ };
58
+ }, [anchorRef, close, closeOnOutsidePress, contentElement, open]);
59
+ }
60
+ /*** Create portal content only after the browser anchor has viewport coordinates. */
61
+ function renderBrowserPopoverContent(props, anchorRect, setContentElement, open) {
62
+ if (!open || anchorRect === null)
63
+ return null;
64
+ return (<div ref={setContentElement} data-testid={props.testID ? `${props.testID}-content` : undefined} role="dialog" style={contentStyle(props, anchorRect)}>
65
+ {props.children}
66
+ </div>);
67
+ }
68
+ /*** Tracks the anchor in viewport coordinates while the browser overlay is visible. */
69
+ function useBrowserPopoverAnchorRect(anchorRef, open) {
70
+ const [anchorRect, setAnchorRect] = React.useState(null);
71
+ React.useLayoutEffect(() => {
72
+ if (!open)
73
+ return;
74
+ const anchor = anchorRef.current;
75
+ if (anchor === null)
76
+ return;
77
+ const updateAnchorRect = () => setAnchorRect(anchor.getBoundingClientRect());
78
+ updateAnchorRect();
79
+ window.addEventListener('resize', updateAnchorRect);
80
+ window.addEventListener('scroll', updateAnchorRect, true);
81
+ return () => {
82
+ window.removeEventListener('resize', updateAnchorRect);
83
+ window.removeEventListener('scroll', updateAnchorRect, true);
84
+ };
85
+ }, [anchorRef, open]);
86
+ return anchorRect;
87
+ }
88
+ /*** Position browser content in the document overlay using the shared Popover placement contract. */
89
+ function contentStyle(props, anchorRect) {
90
+ const offset = props.offset ?? 8;
91
+ const placement = props.placement ?? 'bottom-start';
92
+ const verticalPosition = placement.startsWith('top')
93
+ ? { bottom: window.innerHeight - anchorRect.top + offset }
94
+ : { top: anchorRect.bottom + offset };
95
+ const horizontalPosition = placement.endsWith('end')
96
+ ? { left: anchorRect.right, transform: 'translateX(-100%)' }
97
+ : placement.endsWith('start')
98
+ ? { left: anchorRect.left }
99
+ : { left: anchorRect.left + anchorRect.width / 2, transform: 'translateX(-50%)' };
100
+ return { ...baseContentStyle, ...verticalPosition, ...horizontalPosition };
101
+ }
102
+ const anchorStyle = { display: 'inline-block' };
103
+ const baseContentStyle = {
104
+ pointerEvents: 'auto',
105
+ position: 'fixed',
106
+ zIndex: 1000,
107
+ };
108
+ //# sourceMappingURL=BrowserPopover.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"BrowserPopover.js","sourceRoot":"","sources":["../../../../../../src/features/popover/adapters/inbound/browser/BrowserPopover.tsx"],"names":[],"mappings":"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","sourcesContent":["import React from 'react';\nimport { createPortal } from 'react-dom';\n\nimport { useControllableState } from '../../../../../internal/useControllableState';\nimport type { PopoverProps } from '../../../../../types/popover';\n\n/*** Renders the browser-only Popover without loading React Native implementation modules. */\nexport function BrowserPopover(props: PopoverProps) {\n const anchorRef = React.useRef<HTMLDivElement>(null);\n const [contentElement, setContentElement] = React.useState<HTMLDivElement | null>(null);\n const [open, setOpen] = useControllableState<boolean>({\n defaultValue: props.defaultOpen ?? false,\n onChange: props.onOpenChange,\n value: props.open,\n });\n const passive = props.interactionPolicy === 'passive';\n const close = React.useCallback(() => {\n if (!passive) setOpen(false);\n }, [passive, setOpen]);\n const toggle = React.useCallback(() => {\n if (!passive) setOpen(!open);\n }, [open, passive, setOpen]);\n useBrowserPopoverDismissal(anchorRef, close, contentElement, open, props.closeOnOutsidePress);\n const anchorRect = useBrowserPopoverAnchorRect(anchorRef, open);\n const content = renderBrowserPopoverContent(props, anchorRect, setContentElement, open);\n return (\n <>\n <div\n ref={anchorRef}\n style={anchorStyle}\n data-testid={props.testID ? `${props.testID}-anchor` : undefined}\n >\n {props.anchor({ close, open, toggle })}\n </div>\n {content === null || typeof document === 'undefined'\n ? null\n : createPortal(content, document.body)}\n </>\n );\n}\n\n/*** Closes the open browser overlay for a true outside pointer press or Escape. */\nfunction useBrowserPopoverDismissal(\n anchorRef: React.RefObject<HTMLDivElement | null>,\n close: () => void,\n contentElement: HTMLDivElement | null,\n open: boolean,\n closeOnOutsidePress: boolean | undefined,\n): void {\n React.useEffect(() => {\n if (!open) return;\n const closeOnPointerDown = (event: PointerEvent) => {\n const { target } = event;\n if (!(target instanceof Node)) return;\n if (anchorRef.current?.contains(target) || contentElement?.contains(target)) return;\n if (closeOnOutsidePress !== false) close();\n };\n const closeOnEscape = (event: KeyboardEvent) => {\n if (event.key === 'Escape') close();\n };\n document.addEventListener('pointerdown', closeOnPointerDown);\n document.addEventListener('keydown', closeOnEscape);\n return () => {\n document.removeEventListener('pointerdown', closeOnPointerDown);\n document.removeEventListener('keydown', closeOnEscape);\n };\n }, [anchorRef, close, closeOnOutsidePress, contentElement, open]);\n}\n\n/*** Create portal content only after the browser anchor has viewport coordinates. */\nfunction renderBrowserPopoverContent(\n props: PopoverProps,\n anchorRect: DOMRect | null,\n setContentElement: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>,\n open: boolean,\n): React.ReactNode {\n if (!open || anchorRect === null) return null;\n return (\n <div\n ref={setContentElement}\n data-testid={props.testID ? `${props.testID}-content` : undefined}\n role=\"dialog\"\n style={contentStyle(props, anchorRect)}\n >\n {props.children}\n </div>\n );\n}\n\n/*** Tracks the anchor in viewport coordinates while the browser overlay is visible. */\nfunction useBrowserPopoverAnchorRect(\n anchorRef: React.RefObject<HTMLDivElement | null>,\n open: boolean,\n): DOMRect | null {\n const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);\n React.useLayoutEffect(() => {\n if (!open) return;\n const anchor = anchorRef.current;\n if (anchor === null) return;\n const updateAnchorRect = () => setAnchorRect(anchor.getBoundingClientRect());\n updateAnchorRect();\n window.addEventListener('resize', updateAnchorRect);\n window.addEventListener('scroll', updateAnchorRect, true);\n return () => {\n window.removeEventListener('resize', updateAnchorRect);\n window.removeEventListener('scroll', updateAnchorRect, true);\n };\n }, [anchorRef, open]);\n return anchorRect;\n}\n\n/*** Position browser content in the document overlay using the shared Popover placement contract. */\nfunction contentStyle(props: PopoverProps, anchorRect: DOMRect): React.CSSProperties {\n const offset = props.offset ?? 8;\n const placement = props.placement ?? 'bottom-start';\n const verticalPosition = placement.startsWith('top')\n ? { bottom: window.innerHeight - anchorRect.top + offset }\n : { top: anchorRect.bottom + offset };\n const horizontalPosition = placement.endsWith('end')\n ? { left: anchorRect.right, transform: 'translateX(-100%)' }\n : placement.endsWith('start')\n ? { left: anchorRect.left }\n : { left: anchorRect.left + anchorRect.width / 2, transform: 'translateX(-50%)' };\n return { ...baseContentStyle, ...verticalPosition, ...horizontalPosition };\n}\n\nconst anchorStyle: React.CSSProperties = { display: 'inline-block' };\nconst baseContentStyle: React.CSSProperties = {\n pointerEvents: 'auto',\n position: 'fixed',\n zIndex: 1000,\n};\n"]}
@@ -0,0 +1,3 @@
1
+ export type { PopoverAnchorRenderProps, PopoverPlacement, PopoverProps } from '../../types/popover';
2
+ export { BrowserPopover as Popover } from './adapters/inbound/browser/BrowserPopover';
3
+ //# sourceMappingURL=browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../../../src/features/popover/browser.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,wBAAwB,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACpG,OAAO,EAAE,cAAc,IAAI,OAAO,EAAE,MAAM,2CAA2C,CAAC"}
@@ -0,0 +1,2 @@
1
+ export { BrowserPopover as Popover } from './adapters/inbound/browser/BrowserPopover';
2
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"browser.js","sourceRoot":"","sources":["../../../src/features/popover/browser.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,IAAI,OAAO,EAAE,MAAM,2CAA2C,CAAC","sourcesContent":["export type { PopoverAnchorRenderProps, PopoverPlacement, PopoverProps } from '../../types/popover';\nexport { BrowserPopover as Popover } from './adapters/inbound/browser/BrowserPopover';\n"]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ankhorage/surface",
3
3
  "type": "module",
4
- "version": "9.4.19",
4
+ "version": "9.5.0",
5
5
  "description": "Stable UI foundation for React Native and React Native Web.",
6
6
  "homepage": "https://github.com/ankhorage/surface#readme",
7
7
  "bugs": {
@@ -34,6 +34,7 @@
34
34
  "@react-native-vector-icons/ionicons": "^13.1.3",
35
35
  "@react-native-vector-icons/material-design-icons": "^13.1.3",
36
36
  "react": "19.2.3 || 19.3.0",
37
+ "react-dom": "19.2.3 || 19.3.0",
37
38
  "react-native": "0.86.x || 0.87.x",
38
39
  "react-native-gesture-handler": "~2.32.0 || ~3.3.0",
39
40
  "react-native-reanimated": "4.5.1 || 4.7.0 || 4.7",
@@ -70,6 +71,11 @@
70
71
  "import": "./dist/features/bottom-sheet/public.js",
71
72
  "default": "./dist/features/bottom-sheet/public.js"
72
73
  },
74
+ "./browser": {
75
+ "types": "./dist/features/popover/browser.d.ts",
76
+ "import": "./dist/features/popover/browser.js",
77
+ "default": "./dist/features/popover/browser.js"
78
+ },
73
79
  "./color": {
74
80
  "bun": "./src/constants/surfaceColor.ts",
75
81
  "react-native": "./src/constants/surfaceColor.ts",
@@ -108,6 +114,7 @@
108
114
  "format:check": "ankhorage-prettier --check .",
109
115
  "prepack": "bun run build",
110
116
  "test": "bun test src",
117
+ "test:browser": "bun test ./test/browser/browserPopover.ts",
111
118
  "typecheck": "bun x tsc --noEmit -p tsconfig.json",
112
119
  "version-packages": "ankhorage-changeset version"
113
120
  },
@@ -135,6 +142,7 @@
135
142
  "react-native-svg": "15.15.5",
136
143
  "react-native-web": "~0.21.4",
137
144
  "react-native-worklets": "0.13.0",
145
+ "playwright-core": "^1.58.2",
138
146
  "typescript": "~6.0.3"
139
147
  },
140
148
  "packageManager": "bun@1.4.2",
@@ -0,0 +1,111 @@
1
+ import { expect, test } from 'bun:test';
2
+ import { Window } from 'happy-dom';
3
+ import React, { act } from 'react';
4
+
5
+ import { BrowserPopover } from './BrowserPopover';
6
+
7
+ test('BrowserPopover opens, uses browser placement, and closes on outside press', async () => {
8
+ const { browserWindow, container, root } = await renderBrowserPopover();
9
+ try {
10
+ const button = container.querySelector('button');
11
+ if (button === null) throw new Error('BrowserPopover anchor did not render.');
12
+ await act(async () => {
13
+ button.click();
14
+ await Promise.resolve();
15
+ });
16
+
17
+ const content = browserWindow.document.querySelector(
18
+ '[data-testid="popover-content"]',
19
+ ) as HTMLElement | null;
20
+ expect(content?.textContent).toBe('Content');
21
+ expect(content?.style.position).toBe('fixed');
22
+ expect(Number.parseInt(content?.style.bottom ?? '', 10)).toBeGreaterThan(0);
23
+ expect(content?.style.left).toBe('0px');
24
+ expect(content?.style.transform).toBe('translateX(-100%)');
25
+
26
+ await act(async () => {
27
+ browserWindow.document.body.dispatchEvent(
28
+ new browserWindow.PointerEvent('pointerdown', { bubbles: true }),
29
+ );
30
+ await Promise.resolve();
31
+ });
32
+ expect(browserWindow.document.querySelector('[data-testid="popover-content"]')).toBeNull();
33
+ act(() => root.unmount());
34
+ } finally {
35
+ resetBrowserGlobals(browserWindow);
36
+ }
37
+ });
38
+
39
+ test('BrowserPopover closes on Escape and removes browser listeners after unmount', async () => {
40
+ const { browserWindow, container, root } = await renderBrowserPopover();
41
+ try {
42
+ const button = container.querySelector('button');
43
+ if (button === null) throw new Error('BrowserPopover anchor did not render.');
44
+ await act(async () => {
45
+ button.click();
46
+ await Promise.resolve();
47
+ });
48
+
49
+ await act(async () => {
50
+ browserWindow.document.dispatchEvent(
51
+ new browserWindow.KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }),
52
+ );
53
+ await Promise.resolve();
54
+ });
55
+ expect(browserWindow.document.querySelector('[data-testid="popover-content"]')).toBeNull();
56
+
57
+ act(() => root.unmount());
58
+ browserWindow.dispatchEvent(new browserWindow.Event('resize'));
59
+ browserWindow.document.body.dispatchEvent(
60
+ new browserWindow.PointerEvent('pointerdown', { bubbles: true }),
61
+ );
62
+ expect(browserWindow.document.querySelector('[data-testid="popover-content"]')).toBeNull();
63
+ } finally {
64
+ resetBrowserGlobals(browserWindow);
65
+ }
66
+ });
67
+
68
+ /*** Mount the browser-only Popover in a DOM isolated from the Bun test process. */
69
+ async function renderBrowserPopover() {
70
+ const browserWindow = new Window();
71
+ Object.assign(globalThis, {
72
+ IS_REACT_ACT_ENVIRONMENT: true,
73
+ document: browserWindow.document,
74
+ Node: browserWindow.Node,
75
+ PointerEvent: browserWindow.PointerEvent,
76
+ window: browserWindow,
77
+ });
78
+ try {
79
+ const container = browserWindow.document.createElement('div');
80
+ browserWindow.document.body.append(container);
81
+ const { createRoot } = await import('react-dom/client');
82
+ const root = createRoot(container as unknown as Element);
83
+
84
+ act(() => {
85
+ root.render(
86
+ <BrowserPopover
87
+ closeOnOutsidePress
88
+ placement="top-end"
89
+ testID="popover"
90
+ anchor={({ toggle }) => <button onClick={toggle}>Open</button>}
91
+ >
92
+ <span>Content</span>
93
+ </BrowserPopover>,
94
+ );
95
+ });
96
+ return { browserWindow, container, root };
97
+ } catch (error) {
98
+ resetBrowserGlobals(browserWindow);
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ /*** Remove the temporary DOM globals so other tests retain their own environment. */
104
+ function resetBrowserGlobals(browserWindow: Window): void {
105
+ browserWindow.close();
106
+ Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
107
+ Reflect.deleteProperty(globalThis, 'Node');
108
+ Reflect.deleteProperty(globalThis, 'PointerEvent');
109
+ Reflect.deleteProperty(globalThis, 'document');
110
+ Reflect.deleteProperty(globalThis, 'window');
111
+ }
@@ -0,0 +1,132 @@
1
+ import React from 'react';
2
+ import { createPortal } from 'react-dom';
3
+
4
+ import { useControllableState } from '../../../../../internal/useControllableState';
5
+ import type { PopoverProps } from '../../../../../types/popover';
6
+
7
+ /*** Renders the browser-only Popover without loading React Native implementation modules. */
8
+ export function BrowserPopover(props: PopoverProps) {
9
+ const anchorRef = React.useRef<HTMLDivElement>(null);
10
+ const [contentElement, setContentElement] = React.useState<HTMLDivElement | null>(null);
11
+ const [open, setOpen] = useControllableState<boolean>({
12
+ defaultValue: props.defaultOpen ?? false,
13
+ onChange: props.onOpenChange,
14
+ value: props.open,
15
+ });
16
+ const passive = props.interactionPolicy === 'passive';
17
+ const close = React.useCallback(() => {
18
+ if (!passive) setOpen(false);
19
+ }, [passive, setOpen]);
20
+ const toggle = React.useCallback(() => {
21
+ if (!passive) setOpen(!open);
22
+ }, [open, passive, setOpen]);
23
+ useBrowserPopoverDismissal(anchorRef, close, contentElement, open, props.closeOnOutsidePress);
24
+ const anchorRect = useBrowserPopoverAnchorRect(anchorRef, open);
25
+ const content = renderBrowserPopoverContent(props, anchorRect, setContentElement, open);
26
+ return (
27
+ <>
28
+ <div
29
+ ref={anchorRef}
30
+ style={anchorStyle}
31
+ data-testid={props.testID ? `${props.testID}-anchor` : undefined}
32
+ >
33
+ {props.anchor({ close, open, toggle })}
34
+ </div>
35
+ {content === null || typeof document === 'undefined'
36
+ ? null
37
+ : createPortal(content, document.body)}
38
+ </>
39
+ );
40
+ }
41
+
42
+ /*** Closes the open browser overlay for a true outside pointer press or Escape. */
43
+ function useBrowserPopoverDismissal(
44
+ anchorRef: React.RefObject<HTMLDivElement | null>,
45
+ close: () => void,
46
+ contentElement: HTMLDivElement | null,
47
+ open: boolean,
48
+ closeOnOutsidePress: boolean | undefined,
49
+ ): void {
50
+ React.useEffect(() => {
51
+ if (!open) return;
52
+ const closeOnPointerDown = (event: PointerEvent) => {
53
+ const { target } = event;
54
+ if (!(target instanceof Node)) return;
55
+ if (anchorRef.current?.contains(target) || contentElement?.contains(target)) return;
56
+ if (closeOnOutsidePress !== false) close();
57
+ };
58
+ const closeOnEscape = (event: KeyboardEvent) => {
59
+ if (event.key === 'Escape') close();
60
+ };
61
+ document.addEventListener('pointerdown', closeOnPointerDown);
62
+ document.addEventListener('keydown', closeOnEscape);
63
+ return () => {
64
+ document.removeEventListener('pointerdown', closeOnPointerDown);
65
+ document.removeEventListener('keydown', closeOnEscape);
66
+ };
67
+ }, [anchorRef, close, closeOnOutsidePress, contentElement, open]);
68
+ }
69
+
70
+ /*** Create portal content only after the browser anchor has viewport coordinates. */
71
+ function renderBrowserPopoverContent(
72
+ props: PopoverProps,
73
+ anchorRect: DOMRect | null,
74
+ setContentElement: React.Dispatch<React.SetStateAction<HTMLDivElement | null>>,
75
+ open: boolean,
76
+ ): React.ReactNode {
77
+ if (!open || anchorRect === null) return null;
78
+ return (
79
+ <div
80
+ ref={setContentElement}
81
+ data-testid={props.testID ? `${props.testID}-content` : undefined}
82
+ role="dialog"
83
+ style={contentStyle(props, anchorRect)}
84
+ >
85
+ {props.children}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ /*** Tracks the anchor in viewport coordinates while the browser overlay is visible. */
91
+ function useBrowserPopoverAnchorRect(
92
+ anchorRef: React.RefObject<HTMLDivElement | null>,
93
+ open: boolean,
94
+ ): DOMRect | null {
95
+ const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
96
+ React.useLayoutEffect(() => {
97
+ if (!open) return;
98
+ const anchor = anchorRef.current;
99
+ if (anchor === null) return;
100
+ const updateAnchorRect = () => setAnchorRect(anchor.getBoundingClientRect());
101
+ updateAnchorRect();
102
+ window.addEventListener('resize', updateAnchorRect);
103
+ window.addEventListener('scroll', updateAnchorRect, true);
104
+ return () => {
105
+ window.removeEventListener('resize', updateAnchorRect);
106
+ window.removeEventListener('scroll', updateAnchorRect, true);
107
+ };
108
+ }, [anchorRef, open]);
109
+ return anchorRect;
110
+ }
111
+
112
+ /*** Position browser content in the document overlay using the shared Popover placement contract. */
113
+ function contentStyle(props: PopoverProps, anchorRect: DOMRect): React.CSSProperties {
114
+ const offset = props.offset ?? 8;
115
+ const placement = props.placement ?? 'bottom-start';
116
+ const verticalPosition = placement.startsWith('top')
117
+ ? { bottom: window.innerHeight - anchorRect.top + offset }
118
+ : { top: anchorRect.bottom + offset };
119
+ const horizontalPosition = placement.endsWith('end')
120
+ ? { left: anchorRect.right, transform: 'translateX(-100%)' }
121
+ : placement.endsWith('start')
122
+ ? { left: anchorRect.left }
123
+ : { left: anchorRect.left + anchorRect.width / 2, transform: 'translateX(-50%)' };
124
+ return { ...baseContentStyle, ...verticalPosition, ...horizontalPosition };
125
+ }
126
+
127
+ const anchorStyle: React.CSSProperties = { display: 'inline-block' };
128
+ const baseContentStyle: React.CSSProperties = {
129
+ pointerEvents: 'auto',
130
+ position: 'fixed',
131
+ zIndex: 1000,
132
+ };
@@ -0,0 +1,2 @@
1
+ export type { PopoverAnchorRenderProps, PopoverPlacement, PopoverProps } from '../../types/popover';
2
+ export { BrowserPopover as Popover } from './adapters/inbound/browser/BrowserPopover';
package/src/index.test.ts CHANGED
@@ -137,6 +137,11 @@ describe('public package metadata contract', () => {
137
137
  import: './dist/features/bottom-sheet/public.js',
138
138
  types: './dist/features/bottom-sheet/public.d.ts',
139
139
  },
140
+ './browser': {
141
+ default: './dist/features/popover/browser.js',
142
+ import: './dist/features/popover/browser.js',
143
+ types: './dist/features/popover/browser.d.ts',
144
+ },
140
145
  './color': {
141
146
  bun: './src/constants/surfaceColor.ts',
142
147
  'react-native': './src/constants/surfaceColor.ts',
@@ -162,6 +167,7 @@ describe('public package metadata contract', () => {
162
167
  describe('native peer compatibility contract', () => {
163
168
  it('preserves the independently tested Expo 57 and current native peer tuples', () => {
164
169
  expect(packageJson.peerDependencies.react).toBe('19.2.3 || 19.3.0');
170
+ expect(packageJson.peerDependencies['react-dom']).toBe('19.2.3 || 19.3.0');
165
171
  expect(packageJson.peerDependencies['react-native']).toBe('0.86.x || 0.87.x');
166
172
  expect(packageJson.peerDependencies['react-native-web']).toBe('~0.21.0');
167
173
  expect(packageJson.peerDependencies['react-native-safe-area-context']).toBe(