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 CHANGED
@@ -114,24 +114,27 @@ RenderContainer (minimal: __csHandle, __csType)
114
114
 
115
115
  ### Portals
116
116
 
117
- `createPortal(children, container, key?)` renders a subtree into a different `VisualElement`, outside the normal parent hierarchy - the OneJS equivalent of `react-dom`'s `createPortal`.
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 { createPortal, View } from "onejs-react"
120
+ import { Portal, View } from "onejs-react"
123
121
 
124
122
  function Modal({ children }) {
125
- return createPortal(
126
- <View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}>
127
- {children}
128
- </View>,
129
- __root
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
- > Use the `createPortal` exported from `onejs-react`, not the one from `react-dom` - the latter targets the browser DOM and will not work here.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-react",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -73,6 +73,7 @@ export class MockVisualElement {
73
73
  value: unknown = undefined;
74
74
  label = '';
75
75
  enabledSelf = true;
76
+ pickingMode = 0;
76
77
 
77
78
  constructor(csType = 'UnityEngine.UIElements.VisualElement') {
78
79
  this.__csHandle = Math.floor(Math.random() * 1000000);
@@ -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
+ });
@@ -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
+ }