onejs-react 0.1.24 → 0.1.26
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 +22 -1
- package/package.json +1 -1
- package/src/__tests__/host-config.test.ts +26 -0
- package/src/__tests__/mocks.ts +1 -0
- package/src/__tests__/renderer.test.tsx +105 -1
- package/src/host-config.ts +14 -1
- package/src/index.ts +1 -1
- package/src/renderer.ts +34 -1
- package/src/types.ts +15 -0
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
|
@@ -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', () => {
|
package/src/__tests__/mocks.ts
CHANGED
|
@@ -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();
|
package/src/host-config.ts
CHANGED
|
@@ -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
|
@@ -409,6 +409,21 @@ export interface BaseProps {
|
|
|
409
409
|
style?: ViewStyle;
|
|
410
410
|
className?: string;
|
|
411
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
|
+
|
|
412
427
|
// Click
|
|
413
428
|
onClick?: PointerEventHandler;
|
|
414
429
|
|