onejs-react 0.1.25 → 0.1.27
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 +13 -10
- package/package.json +1 -1
- package/src/__tests__/mocks.ts +1 -0
- package/src/__tests__/portal.test.tsx +85 -0
- package/src/host-config.ts +32 -0
- package/src/index.ts +4 -0
- package/src/portal.tsx +51 -0
package/README.md
CHANGED
|
@@ -114,24 +114,27 @@ RenderContainer (minimal: __csHandle, __csType)
|
|
|
114
114
|
|
|
115
115
|
### Portals
|
|
116
116
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
UI Toolkit has no `z-index`: paint order follows the element hierarchy and a parent with `overflow: hidden` clips its children. Portaling overlays (modals, tooltips, dropdowns) into a top-level container like `__root` lets them escape clipping and paint above the rest of the UI.
|
|
117
|
+
`<Portal>` renders children above the rest of the UI (modals, tooltips, dropdowns). UI Toolkit has no `z-index` and `overflow: hidden` clips children, so a deeply-nested overlay gets clipped and stuck below later siblings. `<Portal>` mounts into a shared overlay layer that onejs-react keeps as the last child of `__root`, so overlays always paint on top. Zero setup.
|
|
120
118
|
|
|
121
119
|
```tsx
|
|
122
|
-
import {
|
|
120
|
+
import { Portal, View } from "onejs-react"
|
|
123
121
|
|
|
124
122
|
function Modal({ children }) {
|
|
125
|
-
return
|
|
126
|
-
<
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
123
|
+
return (
|
|
124
|
+
<Portal>
|
|
125
|
+
<View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}>
|
|
126
|
+
{children}
|
|
127
|
+
</View>
|
|
128
|
+
</Portal>
|
|
130
129
|
)
|
|
131
130
|
}
|
|
132
131
|
```
|
|
133
132
|
|
|
134
|
-
|
|
133
|
+
The layer ignores picking when empty, so a closed overlay never blocks the app.
|
|
134
|
+
|
|
135
|
+
`<Portal>` is built on `createPortal(children, container, key?)` (the OneJS equivalent of `react-dom`'s `createPortal`), exported for when you need a specific target. With a custom target you own draw order, so prefer `<Portal>` for overlays.
|
|
136
|
+
|
|
137
|
+
> Use the exports from `onejs-react`, not `react-dom` - the latter targets the browser DOM and will not work here.
|
|
135
138
|
|
|
136
139
|
## Key Concepts
|
|
137
140
|
|
package/package.json
CHANGED
package/src/__tests__/mocks.ts
CHANGED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the <Portal> component.
|
|
3
|
+
*
|
|
4
|
+
* Portal renders into a shared overlay layer that OneJS keeps as the last child
|
|
5
|
+
* of __root, so overlays paint on top of the app without per-call BringToFront.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import { render } from '../renderer';
|
|
11
|
+
import { Portal } from '../portal';
|
|
12
|
+
import { View, Label, Button } from '../components';
|
|
13
|
+
import { MockVisualElement, createMockContainer, flushMicrotasks } from './mocks';
|
|
14
|
+
|
|
15
|
+
describe('Portal', () => {
|
|
16
|
+
it('mounts children into a shared overlay layer appended last under __root', async () => {
|
|
17
|
+
const root = createMockContainer();
|
|
18
|
+
(globalThis as any).__root = root;
|
|
19
|
+
|
|
20
|
+
render(<View><Portal><Label text="overlay" /></Portal></View>, root as any);
|
|
21
|
+
await flushMicrotasks();
|
|
22
|
+
|
|
23
|
+
// root children: [0] app view, [1] portal layer (added in an effect → last)
|
|
24
|
+
expect(root.childCount).toBe(2);
|
|
25
|
+
const layer = root.children[1] as MockVisualElement;
|
|
26
|
+
expect(layer.name).toBe('onejs-portal-root');
|
|
27
|
+
expect(layer.pickingMode).toBe((globalThis as any).CS.UnityEngine.UIElements.PickingMode.Ignore);
|
|
28
|
+
|
|
29
|
+
// portal content lives inside the layer, not the app view
|
|
30
|
+
expect(layer.childCount).toBe(1);
|
|
31
|
+
expect(layer.children[0].__csType).toBe('UnityEngine.UIElements.Label');
|
|
32
|
+
expect((root.children[0] as MockVisualElement).childCount).toBe(0);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('shares one layer across multiple Portals', async () => {
|
|
36
|
+
const root = createMockContainer();
|
|
37
|
+
(globalThis as any).__root = root;
|
|
38
|
+
|
|
39
|
+
render(
|
|
40
|
+
<View>
|
|
41
|
+
<Portal><Label text="a" /></Portal>
|
|
42
|
+
<Portal><Button text="b" /></Portal>
|
|
43
|
+
</View>,
|
|
44
|
+
root as any
|
|
45
|
+
);
|
|
46
|
+
await flushMicrotasks();
|
|
47
|
+
|
|
48
|
+
// app + exactly one shared layer
|
|
49
|
+
expect(root.childCount).toBe(2);
|
|
50
|
+
const layer = root.children[1] as MockVisualElement;
|
|
51
|
+
expect(layer.childCount).toBe(2);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('targets an explicit container without creating the shared layer', async () => {
|
|
55
|
+
const root = createMockContainer();
|
|
56
|
+
(globalThis as any).__root = root;
|
|
57
|
+
const target = createMockContainer();
|
|
58
|
+
|
|
59
|
+
render(<View><Portal container={target as any}><Label text="x" /></Portal></View>, root as any);
|
|
60
|
+
await flushMicrotasks();
|
|
61
|
+
|
|
62
|
+
expect(target.childCount).toBe(1);
|
|
63
|
+
expect(target.children[0].__csType).toBe('UnityEngine.UIElements.Label');
|
|
64
|
+
// shared layer not created: root has only the app view
|
|
65
|
+
expect(root.childCount).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('removes portal content when unmounted', async () => {
|
|
69
|
+
const root = createMockContainer();
|
|
70
|
+
(globalThis as any).__root = root;
|
|
71
|
+
|
|
72
|
+
function App({ show }: { show: boolean }) {
|
|
73
|
+
return <View>{show && <Portal><Label text="m" /></Portal>}</View>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
render(<App show={true} />, root as any);
|
|
77
|
+
await flushMicrotasks();
|
|
78
|
+
const layer = root.children[1] as MockVisualElement;
|
|
79
|
+
expect(layer.childCount).toBe(1);
|
|
80
|
+
|
|
81
|
+
render(<App show={false} />, root as any);
|
|
82
|
+
await flushMicrotasks();
|
|
83
|
+
expect(layer.childCount).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/src/host-config.ts
CHANGED
|
@@ -86,6 +86,10 @@ declare const __eventAPI: {
|
|
|
86
86
|
removeParent: (childHandle: number) => void;
|
|
87
87
|
};
|
|
88
88
|
|
|
89
|
+
// Panel root element, provided by the runtime. This is also the container apps
|
|
90
|
+
// pass to render(), so it is the natural home for a shared overlay layer.
|
|
91
|
+
declare const __root: CSObject;
|
|
92
|
+
|
|
89
93
|
interface CSStyle {
|
|
90
94
|
[key: string]: unknown;
|
|
91
95
|
}
|
|
@@ -437,6 +441,34 @@ function applyStyle(element: CSObject, style: ViewStyle | undefined): Set<string
|
|
|
437
441
|
return appliedKeys;
|
|
438
442
|
}
|
|
439
443
|
|
|
444
|
+
// Shared overlay layer for <Portal>. A full-screen, click-through element kept as
|
|
445
|
+
// the LAST child of __root, so portaled content (modals, tooltips, dropdowns)
|
|
446
|
+
// always paints above the app and escapes any `overflow: hidden` ancestor.
|
|
447
|
+
//
|
|
448
|
+
// It must be appended AFTER the app's root view exists, otherwise it lands before
|
|
449
|
+
// the app and renders behind it. Callers create it from an effect (post-mount), by
|
|
450
|
+
// which point the app root is already in __root, so Add() puts the layer last.
|
|
451
|
+
//
|
|
452
|
+
// Keyed by __root so a fresh root (e.g. after a domain reload) gets its own layer.
|
|
453
|
+
const _portalLayers = new WeakMap<object, VisualElement>();
|
|
454
|
+
|
|
455
|
+
export function getPortalLayer(): VisualElement {
|
|
456
|
+
const root = __root as unknown as object;
|
|
457
|
+
let layer = _portalLayers.get(root);
|
|
458
|
+
if (!layer) {
|
|
459
|
+
const el: CSObject = new CS.UnityEngine.UIElements.VisualElement();
|
|
460
|
+
el.name = "onejs-portal-root";
|
|
461
|
+
applyStyle(el, { position: "absolute", top: 0, left: 0, right: 0, bottom: 0 });
|
|
462
|
+
// The layer itself ignores picking so empty regions stay click-through; its
|
|
463
|
+
// children (modals, etc.) still receive input normally.
|
|
464
|
+
el.pickingMode = CS.UnityEngine.UIElements.PickingMode.Ignore;
|
|
465
|
+
__root.Add(el);
|
|
466
|
+
layer = el;
|
|
467
|
+
_portalLayers.set(root, layer);
|
|
468
|
+
}
|
|
469
|
+
return layer;
|
|
470
|
+
}
|
|
471
|
+
|
|
440
472
|
// Force-resolve CS path proxies (e.g. CS.UnityEngine.UIElements.Justify.Center)
|
|
441
473
|
// to their underlying int value. parseEnumValue and parseLength's StyleKeyword
|
|
442
474
|
// cases return path proxies whose .valueOf() reads the int via GetField. The
|
package/src/index.ts
CHANGED
|
@@ -21,6 +21,10 @@ export { registerElement } from './host-config';
|
|
|
21
21
|
// Renderer
|
|
22
22
|
export { render, unmount, createPortal, flushSync, batchedUpdates, getDebugInfo } from './renderer';
|
|
23
23
|
|
|
24
|
+
// Portals
|
|
25
|
+
export { Portal } from './portal';
|
|
26
|
+
export type { PortalProps } from './portal';
|
|
27
|
+
|
|
24
28
|
// Error Handling
|
|
25
29
|
export { ErrorBoundary, formatError } from './error-boundary';
|
|
26
30
|
export type { ErrorBoundaryProps } from './error-boundary';
|
package/src/portal.tsx
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useState, useEffect, type ReactNode } from "react"
|
|
2
|
+
import { createPortal } from "./renderer"
|
|
3
|
+
import { getPortalLayer } from "./host-config"
|
|
4
|
+
import type { RenderContainer } from "./types"
|
|
5
|
+
|
|
6
|
+
export interface PortalProps {
|
|
7
|
+
children: ReactNode
|
|
8
|
+
/** Target element. Defaults to OneJS's shared overlay layer (last child of `__root`). */
|
|
9
|
+
container?: RenderContainer
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render `children` above the rest of the UI, outside the normal hierarchy.
|
|
14
|
+
*
|
|
15
|
+
* Zero setup: portals into a shared overlay layer that OneJS keeps as the last
|
|
16
|
+
* child of `__root`, so modals, tooltips, and dropdowns always paint on top and
|
|
17
|
+
* escape any `overflow: hidden` ancestor. Pass `container` to target a specific
|
|
18
|
+
* element instead.
|
|
19
|
+
*
|
|
20
|
+
* Built on {@link createPortal}; reach for that directly only when you need a
|
|
21
|
+
* custom target and want to manage draw order yourself.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* function Modal({ onClose, children }) {
|
|
25
|
+
* return (
|
|
26
|
+
* <Portal>
|
|
27
|
+
* <View
|
|
28
|
+
* style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0,
|
|
29
|
+
* backgroundColor: "rgba(0,0,0,0.6)", alignItems: "center", justifyContent: "center" }}
|
|
30
|
+
* onClick={onClose}
|
|
31
|
+
* >
|
|
32
|
+
* <View onClick={(e) => e.stopPropagation()}>{children}</View>
|
|
33
|
+
* </View>
|
|
34
|
+
* </Portal>
|
|
35
|
+
* )
|
|
36
|
+
* }
|
|
37
|
+
*/
|
|
38
|
+
export function Portal({ children, container }: PortalProps) {
|
|
39
|
+
// The shared layer is appended to __root in an effect (after the app mounts) so
|
|
40
|
+
// it lands last. It is therefore unavailable on the first render; we resolve it
|
|
41
|
+
// post-mount, which adds one frame before the overlay appears. An explicit
|
|
42
|
+
// container is used immediately, with no delay.
|
|
43
|
+
const [layer, setLayer] = useState<RenderContainer | null>(container ?? null)
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!container) setLayer(getPortalLayer())
|
|
47
|
+
}, [container])
|
|
48
|
+
|
|
49
|
+
const target = container ?? layer
|
|
50
|
+
return target ? createPortal(children, target) : null
|
|
51
|
+
}
|