onejs-react 0.1.20 → 0.1.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "onejs-react",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "React 19 renderer for OneJS (Unity UI Toolkit)",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -108,6 +108,36 @@ describe('host-config', () => {
108
108
  createInstance('unknown-type', {});
109
109
  }).toThrow('Unknown element type: unknown-type');
110
110
  });
111
+
112
+ it('forwards the focusable prop to the element', () => {
113
+ const instance = createInstance('ojs-view', { focusable: true } as TestProps);
114
+ expect((instance.element as unknown as { focusable: boolean }).focusable).toBe(true);
115
+ });
116
+
117
+ it('forwards focusable=false explicitly', () => {
118
+ const instance = createInstance('ojs-button', { focusable: false } as TestProps);
119
+ expect((instance.element as unknown as { focusable: boolean }).focusable).toBe(false);
120
+ });
121
+
122
+ it('does not set focusable when prop is omitted', () => {
123
+ const instance = createInstance('ojs-view', {});
124
+ expect((instance.element as unknown as { focusable?: boolean }).focusable).toBeUndefined();
125
+ });
126
+
127
+ it('applies disabled=true by calling SetEnabled(false)', () => {
128
+ const instance = createInstance('ojs-button', { disabled: true } as TestProps);
129
+ expect(getMockElement(instance).enabledSelf).toBe(false);
130
+ });
131
+
132
+ it('applies disabled=false by calling SetEnabled(true)', () => {
133
+ const instance = createInstance('ojs-button', { disabled: false } as TestProps);
134
+ expect(getMockElement(instance).enabledSelf).toBe(true);
135
+ });
136
+
137
+ it('leaves element enabled by default when disabled prop is omitted', () => {
138
+ const instance = createInstance('ojs-view', {});
139
+ expect(getMockElement(instance).enabledSelf).toBe(true);
140
+ });
111
141
  });
112
142
 
113
143
  describe('style application', () => {
@@ -302,6 +332,46 @@ describe('host-config', () => {
302
332
  expect(instance.appliedStyleKeys.has('width')).toBe(false);
303
333
  expect(instance.appliedStyleKeys.has('height')).toBe(true);
304
334
  });
335
+
336
+ it('updates focusable when the prop changes', () => {
337
+ const instance = createInstance('ojs-view', { focusable: false } as TestProps);
338
+ expect((instance.element as unknown as { focusable: boolean }).focusable).toBe(false);
339
+
340
+ commitUpdate(instance, 'ojs-view', { focusable: false } as TestProps, { focusable: true } as TestProps);
341
+ expect((instance.element as unknown as { focusable: boolean }).focusable).toBe(true);
342
+ });
343
+
344
+ it('does not clobber focusable when the prop becomes undefined', () => {
345
+ // Removing the prop should leave the element's current state alone,
346
+ // so element-specific defaults (Button etc.) remain intact.
347
+ const instance = createInstance('ojs-button', { focusable: true } as TestProps);
348
+ expect((instance.element as unknown as { focusable: boolean }).focusable).toBe(true);
349
+
350
+ commitUpdate(instance, 'ojs-button', { focusable: true } as TestProps, {} as TestProps);
351
+ expect((instance.element as unknown as { focusable: boolean }).focusable).toBe(true);
352
+ });
353
+
354
+ it('updates disabled when the prop changes', () => {
355
+ const instance = createInstance('ojs-button', { disabled: false } as TestProps);
356
+ expect(getMockElement(instance).enabledSelf).toBe(true);
357
+
358
+ commitUpdate(instance, 'ojs-button', { disabled: false } as TestProps, { disabled: true } as TestProps);
359
+ expect(getMockElement(instance).enabledSelf).toBe(false);
360
+
361
+ commitUpdate(instance, 'ojs-button', { disabled: true } as TestProps, { disabled: false } as TestProps);
362
+ expect(getMockElement(instance).enabledSelf).toBe(true);
363
+ });
364
+
365
+ it('restores disabled to enabled when prop is removed', () => {
366
+ // Unlike focusable, every VisualElement starts enabled by default,
367
+ // so removing `disabled={true}` must call SetEnabled(true) to
368
+ // restore the element rather than leaving it stuck disabled.
369
+ const instance = createInstance('ojs-button', { disabled: true } as TestProps);
370
+ expect(getMockElement(instance).enabledSelf).toBe(false);
371
+
372
+ commitUpdate(instance, 'ojs-button', { disabled: true } as TestProps, {} as TestProps);
373
+ expect(getMockElement(instance).enabledSelf).toBe(true);
374
+ });
305
375
  });
306
376
 
307
377
  describe('className management', () => {
@@ -71,6 +71,7 @@ export class MockVisualElement {
71
71
  text = '';
72
72
  value: unknown = undefined;
73
73
  label = '';
74
+ enabledSelf = true;
74
75
 
75
76
  constructor(csType = 'UnityEngine.UIElements.VisualElement') {
76
77
  this.__csHandle = Math.floor(Math.random() * 1000000);
@@ -117,6 +118,11 @@ export class MockVisualElement {
117
118
  this._children = [];
118
119
  }
119
120
 
121
+ // Enabled state
122
+ SetEnabled(value: boolean): void {
123
+ this.enabledSelf = value;
124
+ }
125
+
120
126
  // Class list methods
121
127
  AddToClassList(className: string): void {
122
128
  this._classList.add(className);
@@ -71,6 +71,10 @@ declare const CS: {
71
71
  };
72
72
  FrostedGlassElement: new () => CSObject;
73
73
  };
74
+ StyleBridge: {
75
+ ApplyStyles: (element: CSObject, styles: Record<string, unknown>) => void;
76
+ AddClassesBatch: (element: CSObject, classes: string[]) => void;
77
+ };
74
78
  };
75
79
  };
76
80
 
@@ -385,47 +389,73 @@ function getRenderTextureHandle(value: RenderTextureRef): number {
385
389
  return value.__rtHandle ?? value.__handle ?? -1;
386
390
  }
387
391
 
388
- // Apply style properties to element, returns the set of applied keys
392
+ // Apply style properties to element, returns the set of applied keys.
393
+ //
394
+ // Batched path: parsed style values are collected into a single dict and sent
395
+ // to CS.OneJS.StyleBridge.ApplyStyles in one __cs.invoke crossing instead of
396
+ // one per property. On WebGL each crossing is ~3ms (JSON marshal + reflection),
397
+ // so the difference is ~N invokes vs 1 invoke per element. backgroundImage
398
+ // stays on its individual GPU-bridge path since it's not a plain IStyle setter.
389
399
  function applyStyle(element: CSObject, style: ViewStyle | undefined): Set<string> {
390
400
  const appliedKeys = new Set<string>();
391
401
  if (!style) return appliedKeys;
392
402
 
393
- const s = element.style;
403
+ const batched: Record<string, unknown> = {}
404
+
394
405
  for (const [key, value] of Object.entries(style)) {
395
406
  if (value === undefined) continue;
396
407
 
397
- // Handle shorthand properties
398
408
  const expanded = STYLE_SHORTHANDS[key];
399
409
  if (expanded) {
400
- // Parse the value once, apply to all expanded properties
401
- const parsed = parseStyleValue(expanded[0], value);
410
+ const parsed = resolveForBatch(parseStyleValue(expanded[0], value));
402
411
  for (const prop of expanded) {
403
- s[prop] = parsed;
412
+ batched[prop] = parsed;
404
413
  appliedKeys.add(prop);
405
414
  }
406
415
  } else if (key === "backgroundImage") {
407
416
  if (value == null) {
408
417
  CS.OneJS.GPU.GPUBridge.ClearElementBackgroundImage(element);
409
418
  } else if (isRenderTextureHandle(value)) {
410
- // GPU compute RenderTexture handles (via rt.getUnityObject())
411
419
  const handle = getRenderTextureHandle(value);
412
420
  if (handle >= 0) {
413
421
  CS.OneJS.GPU.GPUBridge.SetElementBackgroundImage(element, handle);
414
422
  }
415
423
  } else if (typeof value === "object" && "__csHandle" in value) {
416
- // C# objects: Texture2D, Sprite, VectorImage, RenderTexture
417
424
  CS.OneJS.GPU.GPUBridge.SetElementBackgroundFromObject(element, value);
418
425
  }
419
426
  appliedKeys.add(key);
420
427
  } else {
421
- // Parse and apply individual property
422
- s[key] = parseStyleValue(key, value);
428
+ batched[key] = resolveForBatch(parseStyleValue(key, value));
423
429
  appliedKeys.add(key);
424
430
  }
425
431
  }
432
+
433
+ if (Object.keys(batched).length > 0) {
434
+ CS.OneJS.StyleBridge.ApplyStyles(element, batched);
435
+ }
436
+
426
437
  return appliedKeys;
427
438
  }
428
439
 
440
+ // Force-resolve CS path proxies (e.g. CS.UnityEngine.UIElements.Justify.Center)
441
+ // to their underlying int value. parseEnumValue and parseLength's StyleKeyword
442
+ // cases return path proxies whose .valueOf() reads the int via GetField. The
443
+ // non-batched __cs.invoke path resolved these implicitly via __resolveValue;
444
+ // the batched path JSON.stringifies the whole dict, so path proxies serialize
445
+ // via toJSON to {__csTypeRef:...} which C# can't interpret as an enum value.
446
+ // CS object proxies (with __csHandle) keep their toJSON shape — only path
447
+ // proxies need coercion.
448
+ function resolveForBatch(value: unknown): unknown {
449
+ // Path proxies (e.g. CS.UnityEngine.UIElements.Justify.Center) have a
450
+ // function as their underlying Proxy target so they can also be invoked
451
+ // as constructors — so typeof is "function", not "object". Just check the
452
+ // __csPathProxy sentinel and rely on truthy/property semantics.
453
+ if (value && (value as any).__csPathProxy) {
454
+ return Number(value)
455
+ }
456
+ return value
457
+ }
458
+
429
459
  // Clear style properties that are no longer in the new style
430
460
  function clearRemovedStyles(element: CSObject, oldKeys: Set<string>, newKeys: Set<string>) {
431
461
  const s = element.style;
@@ -458,13 +488,18 @@ function parseClassNames(className: string | undefined): Set<string> {
458
488
  return result;
459
489
  }
460
490
 
461
- // Apply className(s) to element (with escaping for Tailwind/USS compatibility)
491
+ // Apply className(s) to element (with escaping for Tailwind/USS compatibility).
492
+ // Routes through StyleBridge.AddClassesBatch so a multi-class string is one
493
+ // __cs.invoke crossing instead of one per class.
462
494
  function applyClassName(element: CSObject, className: string | undefined) {
463
495
  if (!className) return;
464
496
 
465
- const classes = className.split(/\s+/).filter(Boolean);
466
- for (const cls of classes) {
467
- element.AddToClassList(escapeClassName(cls));
497
+ const classes: string[] = [];
498
+ for (const cls of className.split(/\s+/)) {
499
+ if (cls) classes.push(escapeClassName(cls));
500
+ }
501
+ if (classes.length > 0) {
502
+ CS.OneJS.StyleBridge.AddClassesBatch(element, classes);
468
503
  }
469
504
  }
470
505
 
@@ -786,7 +821,7 @@ function applyListViewProps(element: CSListView, props: Record<string, unknown>,
786
821
 
787
822
  // Props handled by the reconciler infrastructure - not forwarded to C# elements
788
823
  const RESERVED_PROPS = new Set([
789
- 'children', 'key', 'ref', 'style', 'className', 'pickingMode',
824
+ 'children', 'key', 'ref', 'style', 'className', 'pickingMode', 'focusable', 'disabled',
790
825
  'onGenerateVisualContent',
791
826
  ...Object.keys(EVENT_PROPS),
792
827
  ]);
@@ -862,6 +897,16 @@ function createInstance(type: string, props: BaseProps): Instance {
862
897
  element.pickingMode = CS.UnityEngine.UIElements.PickingMode[props.pickingMode];
863
898
  }
864
899
 
900
+ // Apply focusable
901
+ if (props.focusable !== undefined) {
902
+ element.focusable = props.focusable;
903
+ }
904
+
905
+ // Apply disabled (inverted — SetEnabled(true) means "not disabled")
906
+ if (props.disabled !== undefined) {
907
+ element.SetEnabled(!props.disabled);
908
+ }
909
+
865
910
  return instance;
866
911
  }
867
912
 
@@ -901,6 +946,24 @@ function updateInstance(instance: Instance, oldProps: BaseProps, newProps: BaseP
901
946
  }
902
947
  }
903
948
 
949
+ // Update focusable - only set if explicitly provided, do not override
950
+ // element-specific defaults (Button is focusable by default, View is not)
951
+ // when the prop is removed.
952
+ if (oldProps.focusable !== newProps.focusable && newProps.focusable !== undefined) {
953
+ element.focusable = newProps.focusable;
954
+ }
955
+
956
+ // Update disabled - unlike focusable, every VisualElement starts enabled
957
+ // by default, so removing the prop after a previous `disabled={true}` is
958
+ // expected to restore the enabled state.
959
+ if (oldProps.disabled !== newProps.disabled) {
960
+ if (newProps.disabled !== undefined) {
961
+ element.SetEnabled(!newProps.disabled);
962
+ } else {
963
+ element.SetEnabled(true);
964
+ }
965
+ }
966
+
904
967
  instance.props = newProps;
905
968
  }
906
969
 
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ export type {
61
61
  DragEventData,
62
62
  GeometryEventData,
63
63
  NavigationEventData,
64
+ NavigationDirection,
64
65
  TransitionEventData,
65
66
  // Event handler types
66
67
  PointerEventHandler,
@@ -14,7 +14,8 @@ declare const CS: {
14
14
  UIElements: {
15
15
  Length: new (value: number, unit?: number) => CSLength;
16
16
  LengthUnit: { Pixel: number; Percent: number };
17
- StyleKeyword: { Auto: number; None: number; Initial: number };
17
+ StyleKeyword: { Auto: number; None: number; Initial: number; Null: number };
18
+ StyleMaterialDefinition: new (v: unknown) => unknown;
18
19
  Angle: { Degrees: (v: number) => any; Radians: (v: number) => any; Turns: (v: number) => any; Gradians: (v: number) => any };
19
20
  Translate: new (x: any, y: any) => any;
20
21
  Rotate: new (angle: any) => any;
@@ -499,6 +500,18 @@ function parseTransformOriginStyle(value: unknown): unknown {
499
500
  * @returns Parsed value suitable for Unity UI Toolkit
500
501
  */
501
502
  export function parseStyleValue(key: string, value: unknown): unknown {
503
+ // `unityMaterial` is special: it can legitimately be `null` (meaning
504
+ // "reset to the panel default") so we can't short-circuit like the
505
+ // length/color paths below. Wrap in StyleMaterialDefinition either way.
506
+ if (key === "unityMaterial") {
507
+ if (value === undefined) return undefined
508
+ const SMD = CS.UnityEngine.UIElements.StyleMaterialDefinition
509
+ if (value === null) {
510
+ return new SMD(CS.UnityEngine.UIElements.StyleKeyword.Null)
511
+ }
512
+ return new SMD(value as any)
513
+ }
514
+
502
515
  if (value === undefined || value === null) return value
503
516
 
504
517
  // Length properties
package/src/types.ts CHANGED
@@ -163,6 +163,17 @@ export interface ViewStyle {
163
163
  /** Tint color applied to the background image */
164
164
  unityBackgroundImageTintColor?: StyleColor;
165
165
 
166
+ /**
167
+ * Override the shader / material used to render the element. Accepts a
168
+ * Unity `Material` (typical) or a `MaterialDefinition`; `null` clears the
169
+ * inline override so the element falls back to the panel default.
170
+ *
171
+ * Typed loosely as `object | null` because the React layer has no reason
172
+ * to pin the caller to a specific C# shape — the style parser constructs
173
+ * a `StyleMaterialDefinition` at assignment time.
174
+ */
175
+ unityMaterial?: object | null;
176
+
166
177
  // Slicing
167
178
  /** 9-slice top inset */
168
179
  unitySliceTop?: number;
@@ -262,9 +273,32 @@ export interface ChangeEventData<T = unknown> {
262
273
  value: T;
263
274
  }
264
275
 
276
+ /**
277
+ * Shape of the synthetic event object passed to focus / blur handlers at
278
+ * runtime. Matches `QuickJSBootstrap.__dispatchEvent` (see
279
+ * `OneJS/Resources/OneJS/QuickJSBootstrap.js.txt:980-1031`) — every synthetic
280
+ * event carries `target` / `currentTarget` as integer C# handles plus the
281
+ * propagation-control surface, and the C# bridge's `OnFocusIn` / `OnFocusOut`
282
+ * dispatch an empty data object, so focus events add no fields beyond the
283
+ * base.
284
+ *
285
+ * `relatedTarget` is intentionally absent: the C# bridge serializes `{}` for
286
+ * focus events and never includes it. Any code that previously depended on
287
+ * `e.relatedTarget` at runtime has been silently receiving `undefined`.
288
+ *
289
+ * `target` / `currentTarget` are raw C# handles, not `VisualElement` proxies.
290
+ * Resolve them via `CS.QuickJSNative.GetObjectByHandle(handle)` when an
291
+ * element reference is needed, or compare them directly against
292
+ * `ref.current?.__csHandle` to check element identity.
293
+ */
265
294
  export interface FocusEventData {
266
295
  type: string;
267
- relatedTarget?: unknown;
296
+ target: number;
297
+ currentTarget: number;
298
+ preventDefault(): void;
299
+ stopPropagation(): void;
300
+ defaultPrevented: boolean;
301
+ propagationStopped: boolean;
268
302
  }
269
303
 
270
304
  export interface DragEventData {
@@ -281,9 +315,26 @@ export interface GeometryEventData {
281
315
  newRect: { x: number; y: number; width: number; height: number };
282
316
  }
283
317
 
318
+ /**
319
+ * Direction values dispatched by `NavigationMoveEvent`. Mirrors
320
+ * `UnityEngine.UIElements.NavigationMoveEvent.Direction`, serialized to
321
+ * lowercase strings by the C# bridge (see `QuickJSUIBridge.OnNavigationMove`
322
+ * and `QuickJSBootstrap.__NAV_DIRECTION_NAMES`). `NavigationSubmitEvent` /
323
+ * `NavigationCancelEvent` do not carry a direction — the field is only
324
+ * populated for navigation-move.
325
+ */
326
+ export type NavigationDirection =
327
+ | 'none'
328
+ | 'left'
329
+ | 'up'
330
+ | 'right'
331
+ | 'down'
332
+ | 'next'
333
+ | 'previous';
334
+
284
335
  export interface NavigationEventData {
285
336
  type: string;
286
- direction?: string;
337
+ direction?: NavigationDirection;
287
338
  modifiers?: number;
288
339
  }
289
340
 
@@ -430,6 +481,29 @@ export interface BaseProps {
430
481
  */
431
482
  pickingMode?: 'Position' | 'Ignore';
432
483
 
484
+ // Focus
485
+ /**
486
+ * Whether this element can receive focus.
487
+ * Maps directly to `VisualElement.focusable` in Unity UI Toolkit.
488
+ *
489
+ * Default is element-specific (e.g. Button is focusable by default, View is not).
490
+ * Setting this to `true` makes the element a focus target for keyboard/gamepad
491
+ * navigation and enables `NavigationMoveEvent`/`NavigationSubmitEvent` routing.
492
+ */
493
+ focusable?: boolean;
494
+
495
+ // Enabled state
496
+ /**
497
+ * Whether this element is disabled. When `true`, the reconciler calls
498
+ * `VisualElement.SetEnabled(false)` on the underlying C# element, which
499
+ * applies the `:disabled` USS pseudo-class, blocks pointer events, and
500
+ * prevents the element and its descendants from receiving focus.
501
+ *
502
+ * Removing the prop (or setting it to `false`) restores the element to
503
+ * `SetEnabled(true)`. Every VisualElement starts enabled by default.
504
+ */
505
+ disabled?: boolean;
506
+
433
507
  // Vector drawing
434
508
  /**
435
509
  * Callback for custom vector drawing via Unity's generateVisualContent.
@@ -572,6 +646,7 @@ export interface VisualElement extends RenderContainer {
572
646
  visible: boolean;
573
647
  enabledSelf: boolean;
574
648
  enabledInHierarchy: boolean;
649
+ SetEnabled: (value: boolean) => void;
575
650
 
576
651
  // Text content (for TextElement-derived types)
577
652
  text?: string;