onejs-react 0.1.23 → 0.1.25

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
@@ -112,6 +112,27 @@ RenderContainer (minimal: __csHandle, __csType)
112
112
  └── ScrollViewElement (+ scrollOffset, ScrollTo)
113
113
  ```
114
114
 
115
+ ### Portals
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.
120
+
121
+ ```tsx
122
+ import { createPortal, View } from "onejs-react"
123
+
124
+ 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
130
+ )
131
+ }
132
+ ```
133
+
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.
135
+
115
136
  ## Key Concepts
116
137
 
117
138
  - **Element types**: Use `ojs-` prefix internally (e.g., `ojs-view`, `ojs-button`) to avoid conflicts with HTML types
@@ -136,7 +157,7 @@ Test suite uses Vitest with mocked Unity CS globals. Tests are in `src/__tests__
136
157
  | File | Coverage |
137
158
  |------|----------|
138
159
  | `host-config.test.ts` | Instance creation, style/className management, events, children |
139
- | `renderer.test.tsx` | Integration tests: render(), unmount(), React state, effects |
160
+ | `renderer.test.tsx` | Integration tests: render(), unmount(), createPortal(), React state, effects |
140
161
  | `components.test.tsx` | Component wrappers, prop passing, event mapping |
141
162
  | `mocks.ts` | Mock implementations of Unity UI Toolkit classes |
142
163
  | `setup.ts` | Global test setup for CS, __eventAPI |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-react",
3
- "version": "0.1.23",
3
+ "version": "0.1.25",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -138,6 +138,16 @@ describe('host-config', () => {
138
138
  const instance = createInstance('ojs-view', {});
139
139
  expect(getMockElement(instance).enabledSelf).toBe(true);
140
140
  });
141
+
142
+ it('forwards the name prop to the element (USS #id selectors / Debugger)', () => {
143
+ const instance = createInstance('ojs-view', { name: 'my-panel' } as TestProps);
144
+ expect(getMockElement(instance).name).toBe('my-panel');
145
+ });
146
+
147
+ it('leaves name empty by default when the prop is omitted', () => {
148
+ const instance = createInstance('ojs-view', {});
149
+ expect(getMockElement(instance).name).toBe('');
150
+ });
141
151
  });
142
152
 
143
153
  describe('style application', () => {
@@ -372,6 +382,22 @@ describe('host-config', () => {
372
382
  commitUpdate(instance, 'ojs-button', { disabled: true } as TestProps, {} as TestProps);
373
383
  expect(getMockElement(instance).enabledSelf).toBe(true);
374
384
  });
385
+
386
+ it('updates name when the prop changes', () => {
387
+ const instance = createInstance('ojs-view', { name: 'first' } as TestProps);
388
+ expect(getMockElement(instance).name).toBe('first');
389
+
390
+ commitUpdate(instance, 'ojs-view', { name: 'first' } as TestProps, { name: 'second' } as TestProps);
391
+ expect(getMockElement(instance).name).toBe('second');
392
+ });
393
+
394
+ it('resets name to empty string when the prop is removed', () => {
395
+ const instance = createInstance('ojs-view', { name: 'first' } as TestProps);
396
+ expect(getMockElement(instance).name).toBe('first');
397
+
398
+ commitUpdate(instance, 'ojs-view', { name: 'first' } as TestProps, {} as TestProps);
399
+ expect(getMockElement(instance).name).toBe('');
400
+ });
375
401
  });
376
402
 
377
403
  describe('className management', () => {
@@ -68,6 +68,7 @@ export class MockVisualElement {
68
68
  private _classList: Set<string> = new Set();
69
69
 
70
70
  // Common properties
71
+ name = '';
71
72
  text = '';
72
73
  value: unknown = undefined;
73
74
  label = '';
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { describe, it, expect, vi, beforeEach } from 'vitest';
12
12
  import React, { useState, useEffect } from 'react';
13
- import { render, unmount, getRoot } from '../renderer';
13
+ import { render, unmount, createPortal, getRoot } from '../renderer';
14
14
  import { View, Label, Button } from '../components';
15
15
  import { MockVisualElement, MockLength, MockColor, createMockContainer, flushMicrotasks, getEventAPI } from './mocks';
16
16
 
@@ -140,6 +140,110 @@ describe('renderer', () => {
140
140
  });
141
141
  });
142
142
 
143
+ describe('createPortal()', () => {
144
+ it('renders portal children into the target, not the host container', async () => {
145
+ const host = createMockContainer();
146
+ const portalTarget = createMockContainer();
147
+
148
+ function App() {
149
+ return (
150
+ <ojs-view>
151
+ <ojs-label text="in-tree" />
152
+ {createPortal(<ojs-button text="in-portal" />, portalTarget as any)}
153
+ </ojs-view>
154
+ );
155
+ }
156
+
157
+ render(<App />, host as any);
158
+ await flushMicrotasks();
159
+
160
+ // Host tree contains only the view + its label child, not the portal child
161
+ const view = host.children[0] as MockVisualElement;
162
+ expect(view.childCount).toBe(1);
163
+ expect(view.children[0].__csType).toBe('UnityEngine.UIElements.Label');
164
+
165
+ // Portal target received the button
166
+ expect(portalTarget.childCount).toBe(1);
167
+ expect(portalTarget.children[0].__csType).toBe('UnityEngine.UIElements.Button');
168
+ });
169
+
170
+ it('appends into an existing target without clearing it', async () => {
171
+ const host = createMockContainer();
172
+ const portalTarget = createMockContainer();
173
+ const existing = new MockVisualElement();
174
+ portalTarget.Add(existing);
175
+
176
+ render(
177
+ <ojs-view>{createPortal(<ojs-label text="overlay" />, portalTarget as any)}</ojs-view>,
178
+ host as any
179
+ );
180
+ await flushMicrotasks();
181
+
182
+ expect(portalTarget.childCount).toBe(2);
183
+ expect(portalTarget.children).toContain(existing);
184
+ });
185
+
186
+ it('updates portal children when state changes', async () => {
187
+ const host = createMockContainer();
188
+ const portalTarget = createMockContainer();
189
+ let setText: (s: string) => void;
190
+
191
+ function App() {
192
+ const [text, _setText] = useState('a');
193
+ setText = _setText;
194
+ return <ojs-view>{createPortal(<ojs-label text={text} />, portalTarget as any)}</ojs-view>;
195
+ }
196
+
197
+ render(<App />, host as any);
198
+ await flushMicrotasks();
199
+ expect((portalTarget.children[0] as MockVisualElement).text).toBe('a');
200
+
201
+ setText!('b');
202
+ await flushMicrotasks();
203
+ expect((portalTarget.children[0] as MockVisualElement).text).toBe('b');
204
+ });
205
+
206
+ it('removes portal content from the target on unmount', async () => {
207
+ const host = createMockContainer();
208
+ const portalTarget = createMockContainer();
209
+
210
+ render(
211
+ <ojs-view>{createPortal(<ojs-label text="x" />, portalTarget as any)}</ojs-view>,
212
+ host as any
213
+ );
214
+ await flushMicrotasks();
215
+ expect(portalTarget.childCount).toBe(1);
216
+
217
+ unmount(host as any);
218
+ await flushMicrotasks();
219
+ expect(portalTarget.childCount).toBe(0);
220
+ });
221
+
222
+ it('removes portal content when conditionally unmounted', async () => {
223
+ const host = createMockContainer();
224
+ const portalTarget = createMockContainer();
225
+ let setShow: (b: boolean) => void;
226
+
227
+ function App() {
228
+ const [show, _setShow] = useState(true);
229
+ setShow = _setShow;
230
+ return (
231
+ <ojs-view>
232
+ {show && createPortal(<ojs-label text="modal" />, portalTarget as any)}
233
+ </ojs-view>
234
+ );
235
+ }
236
+
237
+ render(<App />, host as any);
238
+ await flushMicrotasks();
239
+ expect(portalTarget.childCount).toBe(1);
240
+
241
+ setShow!(false);
242
+ await flushMicrotasks();
243
+ expect(portalTarget.childCount).toBe(0);
244
+ });
245
+ });
246
+
143
247
  describe('component wrappers', () => {
144
248
  it('renders View component', async () => {
145
249
  const container = createMockContainer();
@@ -847,7 +847,7 @@ function applyListViewProps(element: CSListView, props: Record<string, unknown>,
847
847
 
848
848
  // Props handled by the reconciler infrastructure - not forwarded to C# elements
849
849
  const RESERVED_PROPS = new Set([
850
- 'children', 'key', 'ref', 'style', 'className', 'pickingMode', 'focusable', 'disabled',
850
+ 'children', 'key', 'ref', 'style', 'className', 'name', 'pickingMode', 'focusable', 'disabled',
851
851
  'onGenerateVisualContent',
852
852
  ...Object.keys(EVENT_PROPS),
853
853
  ]);
@@ -918,6 +918,13 @@ function createInstance(type: string, props: BaseProps): Instance {
918
918
  applyVisualContentCallback(instance, props);
919
919
  applyComponentProps(element, type, props as Record<string, unknown>);
920
920
 
921
+ // Apply name (VisualElement.name). Drives USS #id selectors and the label
922
+ // shown in the UI Toolkit Debugger. Universal to every VisualElement, so
923
+ // it lives here rather than in the per-type prop handlers.
924
+ if (props.name !== undefined) {
925
+ element.name = props.name;
926
+ }
927
+
921
928
  // Apply pickingMode
922
929
  if (props.pickingMode !== undefined) {
923
930
  element.pickingMode = CS.UnityEngine.UIElements.PickingMode[props.pickingMode];
@@ -962,6 +969,12 @@ function updateInstance(instance: Instance, oldProps: BaseProps, newProps: BaseP
962
969
  // Update component-specific props - pass oldProps to skip unchanged values
963
970
  applyComponentProps(element, instance.type, newProps as Record<string, unknown>, oldProps as Record<string, unknown>);
964
971
 
972
+ // Update name. Unity defaults VisualElement.name to "", so removing the prop
973
+ // resets it to empty rather than leaving the stale value behind.
974
+ if (oldProps.name !== newProps.name) {
975
+ element.name = newProps.name ?? '';
976
+ }
977
+
965
978
  // Update pickingMode
966
979
  if (oldProps.pickingMode !== newProps.pickingMode) {
967
980
  if (newProps.pickingMode !== undefined) {
package/src/index.ts CHANGED
@@ -19,7 +19,7 @@ export {
19
19
  export { registerElement } from './host-config';
20
20
 
21
21
  // Renderer
22
- export { render, unmount, flushSync, batchedUpdates, getDebugInfo } from './renderer';
22
+ export { render, unmount, createPortal, flushSync, batchedUpdates, getDebugInfo } from './renderer';
23
23
 
24
24
  // Error Handling
25
25
  export { ErrorBoundary, formatError } from './error-boundary';
package/src/renderer.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import Reconciler from 'react-reconciler';
2
- import type { ReactNode } from 'react';
2
+ import type { ReactNode, ReactPortal } from 'react';
3
3
  import { hostConfig, type Container } from './host-config';
4
4
  import type { RenderContainer, VisualElement } from './types';
5
5
 
@@ -58,6 +58,39 @@ export function unmount(container: RenderContainer): void {
58
58
  }
59
59
  }
60
60
 
61
+ /**
62
+ * Render `children` into a different container, outside the normal parent
63
+ * hierarchy. The returned node must be included in a render tree (i.e. returned
64
+ * from a component) to take effect.
65
+ *
66
+ * Unlike the DOM, UI Toolkit has no `z-index`: paint order follows the element
67
+ * hierarchy (later siblings draw on top) and a parent with `overflow: hidden`
68
+ * clips its descendants. Portaling a subtree into a top-level container such as
69
+ * `__root` lets modals, tooltips, and other overlays escape clipping and paint
70
+ * above the rest of the UI.
71
+ *
72
+ * @param children React nodes to render into `container`.
73
+ * @param container Target VisualElement, e.g. `__root` or a ref'd element.
74
+ * @param key Optional React key for the portal.
75
+ *
76
+ * @example
77
+ * function Modal({ children }: { children: ReactNode }) {
78
+ * return createPortal(
79
+ * <View style={{ position: "absolute", top: 0, left: 0, right: 0, bottom: 0 }}>
80
+ * {children}
81
+ * </View>,
82
+ * __root
83
+ * )
84
+ * }
85
+ */
86
+ export function createPortal(children: ReactNode, container: RenderContainer, key: string | null = null): ReactPortal {
87
+ // `react-reconciler`'s ReactPortal type ({ containerInfo, implementation, ... })
88
+ // is structurally different from `react`'s ReactPortal (extends ReactElement with
89
+ // type/props). The runtime value is a valid portal either way; bridge the known
90
+ // @types discrepancy so consumers get `react`'s ReactPortal, matching react-dom.
91
+ return reconciler.createPortal(children, container as Container, null, key) as unknown as ReactPortal;
92
+ }
93
+
61
94
  /**
62
95
  * Execute a callback synchronously, flushing all updates before returning.
63
96
  * Useful for tests or when you need immediate UI updates.
package/src/types.ts CHANGED
@@ -251,10 +251,8 @@ export interface MouseEventData {
251
251
 
252
252
  export interface WheelEventData {
253
253
  type: string;
254
- x: number;
255
- y: number;
256
- delta: { x: number; y: number };
257
- modifiers?: number;
254
+ deltaX: number;
255
+ deltaY: number;
258
256
  }
259
257
 
260
258
  export interface KeyEventData {
@@ -411,6 +409,21 @@ export interface BaseProps {
411
409
  style?: ViewStyle;
412
410
  className?: string;
413
411
 
412
+ /**
413
+ * Element name. Maps directly to `VisualElement.name` in Unity UI Toolkit.
414
+ *
415
+ * Useful for:
416
+ * - **USS `#id` selectors**: a rule like `#my-panel { ... }` targets the
417
+ * element whose `name` is `my-panel` (analogous to a CSS ID selector).
418
+ * - **Debugging**: the UI Toolkit Debugger window shows the name instead of
419
+ * the bare type (e.g. `VisualElement#my-panel`).
420
+ *
421
+ * Names need not be unique, but since `#` selectors match by name, prefer
422
+ * unique names for elements you intend to select on. Removing the prop resets
423
+ * the name to an empty string (Unity's default).
424
+ */
425
+ name?: string;
426
+
414
427
  // Click
415
428
  onClick?: PointerEventHandler;
416
429