onejs-react 0.1.19 → 0.1.21
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 +1 -1
- package/src/__tests__/hooks.test.tsx +433 -1
- package/src/__tests__/host-config.test.ts +70 -0
- package/src/__tests__/mocks.ts +7 -0
- package/src/hooks.ts +110 -0
- package/src/host-config.ts +43 -4
- package/src/index.ts +3 -1
- package/src/style-parser.ts +14 -1
- package/src/types.ts +77 -2
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
|
12
12
|
import React from "react"
|
|
13
|
-
import { useFrameSync, useFrameSyncWith, toArray } from "../hooks"
|
|
13
|
+
import { useFrameSync, useFrameSyncWith, useEventSync, toArray } from "../hooks"
|
|
14
14
|
import { render, unmount } from "../renderer"
|
|
15
15
|
import { createMockContainer, flushMicrotasks } from "./mocks"
|
|
16
16
|
|
|
@@ -579,3 +579,435 @@ describe("useFrameSyncWith (deprecated)", () => {
|
|
|
579
579
|
expect(capturedValue.x).toBe(10)
|
|
580
580
|
})
|
|
581
581
|
})
|
|
582
|
+
|
|
583
|
+
// ===========================================================================
|
|
584
|
+
// useEventSync
|
|
585
|
+
// ===========================================================================
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Creates a mock C# object with event subscription support.
|
|
589
|
+
* Simulates the bootstrap's add_EventName / remove_EventName proxy mechanism.
|
|
590
|
+
*/
|
|
591
|
+
function createMockCSharpObject(initialValues: Record<string, unknown>) {
|
|
592
|
+
const listeners = new Map<string, Set<Function>>()
|
|
593
|
+
const values = { ...initialValues }
|
|
594
|
+
|
|
595
|
+
const proxy = new Proxy(values, {
|
|
596
|
+
get(target, prop) {
|
|
597
|
+
const propName = String(prop)
|
|
598
|
+
if (propName.startsWith("add_")) {
|
|
599
|
+
return (handler: Function) => {
|
|
600
|
+
const eventName = propName.slice(4)
|
|
601
|
+
if (!listeners.has(eventName)) listeners.set(eventName, new Set())
|
|
602
|
+
listeners.get(eventName)!.add(handler)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
if (propName.startsWith("remove_")) {
|
|
606
|
+
return (handler: Function) => {
|
|
607
|
+
const eventName = propName.slice(7)
|
|
608
|
+
listeners.get(eventName)?.delete(handler)
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
return target[propName]
|
|
612
|
+
},
|
|
613
|
+
})
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
proxy,
|
|
617
|
+
fire(eventName: string) {
|
|
618
|
+
listeners.get(eventName)?.forEach(h => (h as any)())
|
|
619
|
+
},
|
|
620
|
+
set(prop: string, value: unknown) {
|
|
621
|
+
(values as any)[prop] = value
|
|
622
|
+
},
|
|
623
|
+
listenerCount(eventName: string) {
|
|
624
|
+
return listeners.get(eventName)?.size ?? 0
|
|
625
|
+
},
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
describe("useEventSync", () => {
|
|
630
|
+
// -- Convention form --
|
|
631
|
+
|
|
632
|
+
describe("convention form", () => {
|
|
633
|
+
it("reads initial value on mount", async () => {
|
|
634
|
+
const obj = createMockCSharpObject({ Health: 100 })
|
|
635
|
+
let capturedValue: unknown
|
|
636
|
+
|
|
637
|
+
function TestComponent() {
|
|
638
|
+
capturedValue = useEventSync(obj.proxy, "Health")
|
|
639
|
+
return null
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const container = createMockContainer()
|
|
643
|
+
render(React.createElement(TestComponent), container)
|
|
644
|
+
await flushMicrotasks()
|
|
645
|
+
|
|
646
|
+
expect(capturedValue).toBe(100)
|
|
647
|
+
})
|
|
648
|
+
|
|
649
|
+
it("subscribes to OnPropertyChanged event", async () => {
|
|
650
|
+
const obj = createMockCSharpObject({ Health: 100 })
|
|
651
|
+
|
|
652
|
+
function TestComponent() {
|
|
653
|
+
useEventSync(obj.proxy, "Health")
|
|
654
|
+
return null
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const container = createMockContainer()
|
|
658
|
+
render(React.createElement(TestComponent), container)
|
|
659
|
+
await flushMicrotasks()
|
|
660
|
+
|
|
661
|
+
expect(obj.listenerCount("OnHealthChanged")).toBe(1)
|
|
662
|
+
})
|
|
663
|
+
|
|
664
|
+
it("updates when event fires", async () => {
|
|
665
|
+
const obj = createMockCSharpObject({ Health: 100 })
|
|
666
|
+
let capturedValue: unknown
|
|
667
|
+
|
|
668
|
+
function TestComponent() {
|
|
669
|
+
capturedValue = useEventSync(obj.proxy, "Health")
|
|
670
|
+
return null
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const container = createMockContainer()
|
|
674
|
+
render(React.createElement(TestComponent), container)
|
|
675
|
+
await flushMicrotasks()
|
|
676
|
+
expect(capturedValue).toBe(100)
|
|
677
|
+
|
|
678
|
+
obj.set("Health", 75)
|
|
679
|
+
obj.fire("OnHealthChanged")
|
|
680
|
+
await flushMicrotasks()
|
|
681
|
+
expect(capturedValue).toBe(75)
|
|
682
|
+
})
|
|
683
|
+
|
|
684
|
+
it("unsubscribes on unmount", async () => {
|
|
685
|
+
const obj = createMockCSharpObject({ Health: 100 })
|
|
686
|
+
|
|
687
|
+
function TestComponent() {
|
|
688
|
+
useEventSync(obj.proxy, "Health")
|
|
689
|
+
return null
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const container = createMockContainer()
|
|
693
|
+
render(React.createElement(TestComponent), container)
|
|
694
|
+
await flushMicrotasks()
|
|
695
|
+
expect(obj.listenerCount("OnHealthChanged")).toBe(1)
|
|
696
|
+
|
|
697
|
+
unmount(container)
|
|
698
|
+
await flushMicrotasks()
|
|
699
|
+
expect(obj.listenerCount("OnHealthChanged")).toBe(0)
|
|
700
|
+
})
|
|
701
|
+
|
|
702
|
+
it("does not poll via RAF", async () => {
|
|
703
|
+
const obj = createMockCSharpObject({ Health: 100 })
|
|
704
|
+
|
|
705
|
+
function TestComponent() {
|
|
706
|
+
useEventSync(obj.proxy, "Health")
|
|
707
|
+
return null
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const container = createMockContainer()
|
|
711
|
+
render(React.createElement(TestComponent), container)
|
|
712
|
+
await flushMicrotasks()
|
|
713
|
+
|
|
714
|
+
// RAF should not have been called by useEventSync
|
|
715
|
+
// (useFrameSync calls it, useEventSync should not)
|
|
716
|
+
const rafCallCount = (globalThis as any).requestAnimationFrame.mock.calls.length
|
|
717
|
+
// Advance several frames — count should not grow from useEventSync
|
|
718
|
+
await advanceFrame()
|
|
719
|
+
await advanceFrame()
|
|
720
|
+
await advanceFrame()
|
|
721
|
+
const rafCallCountAfter = (globalThis as any).requestAnimationFrame.mock.calls.length
|
|
722
|
+
expect(rafCallCountAfter).toBe(rafCallCount)
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
it("handles null source without crashing", async () => {
|
|
726
|
+
let capturedValue: unknown
|
|
727
|
+
|
|
728
|
+
function TestComponent() {
|
|
729
|
+
capturedValue = useEventSync(null as any, "Health")
|
|
730
|
+
return null
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
const container = createMockContainer()
|
|
734
|
+
render(React.createElement(TestComponent), container)
|
|
735
|
+
await flushMicrotasks()
|
|
736
|
+
|
|
737
|
+
expect(capturedValue).toBeUndefined()
|
|
738
|
+
})
|
|
739
|
+
|
|
740
|
+
it("re-subscribes when deps change", async () => {
|
|
741
|
+
const obj1 = createMockCSharpObject({ Health: 100 })
|
|
742
|
+
const obj2 = createMockCSharpObject({ Health: 200 })
|
|
743
|
+
let capturedValue: unknown
|
|
744
|
+
let currentSource = obj1
|
|
745
|
+
|
|
746
|
+
function TestComponent({ source }: { source: any }) {
|
|
747
|
+
capturedValue = useEventSync(source.proxy, "Health", [source.proxy])
|
|
748
|
+
return null
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const container = createMockContainer()
|
|
752
|
+
render(React.createElement(TestComponent, { source: currentSource }), container)
|
|
753
|
+
await flushMicrotasks()
|
|
754
|
+
expect(capturedValue).toBe(100)
|
|
755
|
+
expect(obj1.listenerCount("OnHealthChanged")).toBe(1)
|
|
756
|
+
|
|
757
|
+
// Switch source
|
|
758
|
+
currentSource = obj2
|
|
759
|
+
render(React.createElement(TestComponent, { source: currentSource }), container)
|
|
760
|
+
await flushMicrotasks()
|
|
761
|
+
expect(capturedValue).toBe(200)
|
|
762
|
+
expect(obj1.listenerCount("OnHealthChanged")).toBe(0)
|
|
763
|
+
expect(obj2.listenerCount("OnHealthChanged")).toBe(1)
|
|
764
|
+
})
|
|
765
|
+
|
|
766
|
+
it("handles multiple rapid events correctly", async () => {
|
|
767
|
+
const obj = createMockCSharpObject({ Score: 0 })
|
|
768
|
+
let capturedValue: unknown
|
|
769
|
+
|
|
770
|
+
function TestComponent() {
|
|
771
|
+
capturedValue = useEventSync(obj.proxy, "Score")
|
|
772
|
+
return null
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const container = createMockContainer()
|
|
776
|
+
render(React.createElement(TestComponent), container)
|
|
777
|
+
await flushMicrotasks()
|
|
778
|
+
|
|
779
|
+
obj.set("Score", 10)
|
|
780
|
+
obj.fire("OnScoreChanged")
|
|
781
|
+
obj.set("Score", 20)
|
|
782
|
+
obj.fire("OnScoreChanged")
|
|
783
|
+
obj.set("Score", 30)
|
|
784
|
+
obj.fire("OnScoreChanged")
|
|
785
|
+
await flushMicrotasks()
|
|
786
|
+
|
|
787
|
+
expect(capturedValue).toBe(30)
|
|
788
|
+
})
|
|
789
|
+
})
|
|
790
|
+
|
|
791
|
+
// -- Explicit form --
|
|
792
|
+
|
|
793
|
+
describe("explicit form", () => {
|
|
794
|
+
it("reads initial value from custom getter", async () => {
|
|
795
|
+
const obj = createMockCSharpObject({ Items: { Count: 5 } })
|
|
796
|
+
let capturedValue: unknown
|
|
797
|
+
|
|
798
|
+
function TestComponent() {
|
|
799
|
+
capturedValue = useEventSync(
|
|
800
|
+
() => (obj.proxy as any).Items.Count,
|
|
801
|
+
[[obj.proxy, "OnItemsChanged"]]
|
|
802
|
+
)
|
|
803
|
+
return null
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
const container = createMockContainer()
|
|
807
|
+
render(React.createElement(TestComponent), container)
|
|
808
|
+
await flushMicrotasks()
|
|
809
|
+
|
|
810
|
+
expect(capturedValue).toBe(5)
|
|
811
|
+
})
|
|
812
|
+
|
|
813
|
+
it("subscribes to multiple events", async () => {
|
|
814
|
+
const obj = createMockCSharpObject({ Count: 0 })
|
|
815
|
+
|
|
816
|
+
function TestComponent() {
|
|
817
|
+
useEventSync(
|
|
818
|
+
() => (obj.proxy as any).Count,
|
|
819
|
+
[[obj.proxy, "OnItemAdded"], [obj.proxy, "OnItemRemoved"]]
|
|
820
|
+
)
|
|
821
|
+
return null
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const container = createMockContainer()
|
|
825
|
+
render(React.createElement(TestComponent), container)
|
|
826
|
+
await flushMicrotasks()
|
|
827
|
+
|
|
828
|
+
expect(obj.listenerCount("OnItemAdded")).toBe(1)
|
|
829
|
+
expect(obj.listenerCount("OnItemRemoved")).toBe(1)
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
it("updates on any subscribed event", async () => {
|
|
833
|
+
const obj = createMockCSharpObject({ Count: 0 })
|
|
834
|
+
let capturedValue: unknown
|
|
835
|
+
|
|
836
|
+
function TestComponent() {
|
|
837
|
+
capturedValue = useEventSync(
|
|
838
|
+
() => (obj.proxy as any).Count,
|
|
839
|
+
[[obj.proxy, "OnItemAdded"], [obj.proxy, "OnItemRemoved"]]
|
|
840
|
+
)
|
|
841
|
+
return null
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const container = createMockContainer()
|
|
845
|
+
render(React.createElement(TestComponent), container)
|
|
846
|
+
await flushMicrotasks()
|
|
847
|
+
expect(capturedValue).toBe(0)
|
|
848
|
+
|
|
849
|
+
obj.set("Count", 3)
|
|
850
|
+
obj.fire("OnItemAdded")
|
|
851
|
+
await flushMicrotasks()
|
|
852
|
+
expect(capturedValue).toBe(3)
|
|
853
|
+
|
|
854
|
+
obj.set("Count", 2)
|
|
855
|
+
obj.fire("OnItemRemoved")
|
|
856
|
+
await flushMicrotasks()
|
|
857
|
+
expect(capturedValue).toBe(2)
|
|
858
|
+
})
|
|
859
|
+
|
|
860
|
+
it("unsubscribes all events on unmount", async () => {
|
|
861
|
+
const obj = createMockCSharpObject({ Count: 0 })
|
|
862
|
+
|
|
863
|
+
function TestComponent() {
|
|
864
|
+
useEventSync(
|
|
865
|
+
() => (obj.proxy as any).Count,
|
|
866
|
+
[[obj.proxy, "OnItemAdded"], [obj.proxy, "OnItemRemoved"]]
|
|
867
|
+
)
|
|
868
|
+
return null
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const container = createMockContainer()
|
|
872
|
+
render(React.createElement(TestComponent), container)
|
|
873
|
+
await flushMicrotasks()
|
|
874
|
+
expect(obj.listenerCount("OnItemAdded")).toBe(1)
|
|
875
|
+
expect(obj.listenerCount("OnItemRemoved")).toBe(1)
|
|
876
|
+
|
|
877
|
+
unmount(container)
|
|
878
|
+
await flushMicrotasks()
|
|
879
|
+
expect(obj.listenerCount("OnItemAdded")).toBe(0)
|
|
880
|
+
expect(obj.listenerCount("OnItemRemoved")).toBe(0)
|
|
881
|
+
})
|
|
882
|
+
|
|
883
|
+
it("supports events from multiple sources", async () => {
|
|
884
|
+
const inventory = createMockCSharpObject({ Count: 5 })
|
|
885
|
+
const player = createMockCSharpObject({ Level: 1 })
|
|
886
|
+
let capturedValue: unknown
|
|
887
|
+
|
|
888
|
+
function TestComponent() {
|
|
889
|
+
capturedValue = useEventSync(
|
|
890
|
+
() => (inventory.proxy as any).Count * (player.proxy as any).Level,
|
|
891
|
+
[[inventory.proxy, "OnChanged"], [player.proxy, "OnLevelUp"]]
|
|
892
|
+
)
|
|
893
|
+
return null
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const container = createMockContainer()
|
|
897
|
+
render(React.createElement(TestComponent), container)
|
|
898
|
+
await flushMicrotasks()
|
|
899
|
+
expect(capturedValue).toBe(5)
|
|
900
|
+
|
|
901
|
+
player.set("Level", 2)
|
|
902
|
+
player.fire("OnLevelUp")
|
|
903
|
+
await flushMicrotasks()
|
|
904
|
+
expect(capturedValue).toBe(10)
|
|
905
|
+
|
|
906
|
+
inventory.set("Count", 10)
|
|
907
|
+
inventory.fire("OnChanged")
|
|
908
|
+
await flushMicrotasks()
|
|
909
|
+
expect(capturedValue).toBe(20)
|
|
910
|
+
})
|
|
911
|
+
|
|
912
|
+
it("handles getter that throws", async () => {
|
|
913
|
+
let shouldThrow = false
|
|
914
|
+
let capturedValue: unknown
|
|
915
|
+
|
|
916
|
+
const obj = createMockCSharpObject({})
|
|
917
|
+
|
|
918
|
+
function TestComponent() {
|
|
919
|
+
capturedValue = useEventSync(
|
|
920
|
+
() => {
|
|
921
|
+
if (shouldThrow) throw new Error("destroyed")
|
|
922
|
+
return 42
|
|
923
|
+
},
|
|
924
|
+
[[obj.proxy, "OnChanged"]]
|
|
925
|
+
)
|
|
926
|
+
return null
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
const container = createMockContainer()
|
|
930
|
+
render(React.createElement(TestComponent), container)
|
|
931
|
+
await flushMicrotasks()
|
|
932
|
+
expect(capturedValue).toBe(42)
|
|
933
|
+
|
|
934
|
+
shouldThrow = true
|
|
935
|
+
obj.fire("OnChanged")
|
|
936
|
+
await flushMicrotasks()
|
|
937
|
+
// Should not crash — value stays at last good value
|
|
938
|
+
expect(capturedValue).toBe(42)
|
|
939
|
+
})
|
|
940
|
+
|
|
941
|
+
it("works with static-like event sources", async () => {
|
|
942
|
+
const staticClass = createMockCSharpObject({ Score: 999 })
|
|
943
|
+
let capturedValue: unknown
|
|
944
|
+
|
|
945
|
+
function TestComponent() {
|
|
946
|
+
capturedValue = useEventSync(
|
|
947
|
+
() => (staticClass.proxy as any).Score,
|
|
948
|
+
[[staticClass.proxy, "OnScoreChanged"]]
|
|
949
|
+
)
|
|
950
|
+
return null
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const container = createMockContainer()
|
|
954
|
+
render(React.createElement(TestComponent), container)
|
|
955
|
+
await flushMicrotasks()
|
|
956
|
+
expect(capturedValue).toBe(999)
|
|
957
|
+
|
|
958
|
+
staticClass.set("Score", 1500)
|
|
959
|
+
staticClass.fire("OnScoreChanged")
|
|
960
|
+
await flushMicrotasks()
|
|
961
|
+
expect(capturedValue).toBe(1500)
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
it("handler still fires when event passes arguments (ignored by design)", async () => {
|
|
965
|
+
const obj = createMockCSharpObject({ Health: 100 })
|
|
966
|
+
let capturedValue: unknown
|
|
967
|
+
|
|
968
|
+
function TestComponent() {
|
|
969
|
+
capturedValue = useEventSync(obj.proxy, "Health")
|
|
970
|
+
return null
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const container = createMockContainer()
|
|
974
|
+
render(React.createElement(TestComponent), container)
|
|
975
|
+
await flushMicrotasks()
|
|
976
|
+
expect(capturedValue).toBe(100)
|
|
977
|
+
|
|
978
|
+
// C# event fires — handler re-reads getter, ignoring event args
|
|
979
|
+
obj.set("Health", 80)
|
|
980
|
+
obj.fire("OnHealthChanged")
|
|
981
|
+
await flushMicrotasks()
|
|
982
|
+
expect(capturedValue).toBe(80)
|
|
983
|
+
})
|
|
984
|
+
|
|
985
|
+
it("multiple components subscribe to the same event independently", async () => {
|
|
986
|
+
const obj = createMockCSharpObject({ Health: 100 })
|
|
987
|
+
let value1: unknown, value2: unknown
|
|
988
|
+
|
|
989
|
+
function Component1() { value1 = useEventSync(() => (obj.proxy as any).Health, [[obj.proxy, "OnHealthChanged"]]); return null }
|
|
990
|
+
function Component2() { value2 = useEventSync(() => (obj.proxy as any).Health, [[obj.proxy, "OnHealthChanged"]]); return null }
|
|
991
|
+
|
|
992
|
+
function Parent() {
|
|
993
|
+
return React.createElement(React.Fragment, null,
|
|
994
|
+
React.createElement(Component1),
|
|
995
|
+
React.createElement(Component2)
|
|
996
|
+
)
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
const container = createMockContainer()
|
|
1000
|
+
render(React.createElement(Parent), container)
|
|
1001
|
+
await flushMicrotasks()
|
|
1002
|
+
expect(value1).toBe(100)
|
|
1003
|
+
expect(value2).toBe(100)
|
|
1004
|
+
expect(obj.listenerCount("OnHealthChanged")).toBe(2)
|
|
1005
|
+
|
|
1006
|
+
obj.set("Health", 50)
|
|
1007
|
+
obj.fire("OnHealthChanged")
|
|
1008
|
+
await flushMicrotasks()
|
|
1009
|
+
expect(value1).toBe(50)
|
|
1010
|
+
expect(value2).toBe(50)
|
|
1011
|
+
})
|
|
1012
|
+
})
|
|
1013
|
+
})
|
|
@@ -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', () => {
|
package/src/__tests__/mocks.ts
CHANGED
|
@@ -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);
|
|
@@ -262,6 +268,7 @@ export function createMockCS() {
|
|
|
262
268
|
Path: {
|
|
263
269
|
Combine: (...parts: string[]) => parts.join("/"),
|
|
264
270
|
GetDirectoryName: (p: string) => p.substring(0, p.lastIndexOf("/")),
|
|
271
|
+
IsPathRooted: (p: string) => p.startsWith("/"),
|
|
265
272
|
},
|
|
266
273
|
File: {
|
|
267
274
|
Exists: (path: string) => mockFileSystem.has(path),
|
package/src/hooks.ts
CHANGED
|
@@ -376,3 +376,113 @@ export function toArray<T = unknown>(collection: unknown): T[] {
|
|
|
376
376
|
}
|
|
377
377
|
return result
|
|
378
378
|
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Event source descriptor for useEventSync: [sourceObject, eventName].
|
|
382
|
+
* The eventName should NOT include the "add_" / "remove_" prefix.
|
|
383
|
+
*/
|
|
384
|
+
export type EventSource = [source: object, eventName: string]
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Syncs a value from C# to React state via event subscription instead of polling.
|
|
388
|
+
* Zero work when nothing changes — the getter is only called when an event fires.
|
|
389
|
+
*
|
|
390
|
+
* Use this instead of `useFrameSync` when C# fires events on state change.
|
|
391
|
+
* `useFrameSync` polls every frame (causing GC pressure); `useEventSync` does
|
|
392
|
+
* zero work between events.
|
|
393
|
+
*
|
|
394
|
+
* **Convention form**: Derives the getter and event name from a property name.
|
|
395
|
+
* `useEventSync(source, "Health")` subscribes to `source.add_OnHealthChanged`
|
|
396
|
+
* and reads `source.Health`. The C# side must have an event named `On{Prop}Changed`.
|
|
397
|
+
*
|
|
398
|
+
* **Explicit form**: User-provided getter and event descriptors.
|
|
399
|
+
* Supports multiple event sources, static events, and derived state.
|
|
400
|
+
*
|
|
401
|
+
* If the source object can be null or change over time, pass it in deps:
|
|
402
|
+
* `useEventSync(player, "Health", [player])`
|
|
403
|
+
*
|
|
404
|
+
* Events must fire on Unity's main thread (the normal case for MonoBehaviour methods).
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* // Convention: subscribes to player.add_OnHealthChanged, reads player.Health
|
|
408
|
+
* const health = useEventSync(player, "Health")
|
|
409
|
+
*
|
|
410
|
+
* @example
|
|
411
|
+
* // Convention with deps (required if source can change or start null)
|
|
412
|
+
* const health = useEventSync(currentPlayer, "Health", [currentPlayer])
|
|
413
|
+
*
|
|
414
|
+
* @example
|
|
415
|
+
* // Explicit: custom getter, multiple events
|
|
416
|
+
* const itemCount = useEventSync(
|
|
417
|
+
* () => inventory.Items.Count,
|
|
418
|
+
* [[inventory, "OnItemAdded"], [inventory, "OnItemRemoved"]]
|
|
419
|
+
* )
|
|
420
|
+
*
|
|
421
|
+
* @example
|
|
422
|
+
* // Explicit: static events
|
|
423
|
+
* const state = useEventSync(
|
|
424
|
+
* () => CS.GameManager.Score,
|
|
425
|
+
* [[CS.GameManager, "OnScoreChanged"]]
|
|
426
|
+
* )
|
|
427
|
+
*/
|
|
428
|
+
export function useEventSync<T>(getter: () => T, events: EventSource[], deps?: readonly unknown[]): T
|
|
429
|
+
export function useEventSync(source: object, propertyName: string, deps?: readonly unknown[]): unknown
|
|
430
|
+
export function useEventSync<T>(
|
|
431
|
+
sourceOrGetter: object | (() => T),
|
|
432
|
+
propOrEvents: string | EventSource[],
|
|
433
|
+
depsOrNothing?: readonly unknown[]
|
|
434
|
+
): T {
|
|
435
|
+
if (typeof sourceOrGetter === "function") {
|
|
436
|
+
return useEventSyncImpl(
|
|
437
|
+
sourceOrGetter as () => T,
|
|
438
|
+
propOrEvents as EventSource[],
|
|
439
|
+
depsOrNothing ?? []
|
|
440
|
+
)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const source = sourceOrGetter
|
|
444
|
+
const propName = propOrEvents as string
|
|
445
|
+
return useEventSyncImpl(
|
|
446
|
+
() => source ? (source as any)[propName] : undefined,
|
|
447
|
+
source ? [[source, `On${propName}Changed`]] : [],
|
|
448
|
+
depsOrNothing ?? []
|
|
449
|
+
)
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function useEventSyncImpl<T>(
|
|
453
|
+
getter: () => T,
|
|
454
|
+
events: EventSource[],
|
|
455
|
+
deps: readonly unknown[]
|
|
456
|
+
): T {
|
|
457
|
+
const [value, setValue] = useState<T>(() => {
|
|
458
|
+
try { return getter() } catch { return undefined as T }
|
|
459
|
+
})
|
|
460
|
+
const getterRef = useRef(getter)
|
|
461
|
+
getterRef.current = getter
|
|
462
|
+
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
try { setValue(getterRef.current()) } catch {}
|
|
465
|
+
|
|
466
|
+
const handler = () => {
|
|
467
|
+
try { setValue(getterRef.current()) } catch {}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for (const [source, eventName] of events) {
|
|
471
|
+
try {
|
|
472
|
+
const addFn = (source as any)[`add_${eventName}`]
|
|
473
|
+
if (typeof addFn === "function") addFn(handler)
|
|
474
|
+
} catch {}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return () => {
|
|
478
|
+
for (const [source, eventName] of events) {
|
|
479
|
+
try {
|
|
480
|
+
const removeFn = (source as any)[`remove_${eventName}`]
|
|
481
|
+
if (typeof removeFn === "function") removeFn(handler)
|
|
482
|
+
} catch {}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}, deps)
|
|
486
|
+
|
|
487
|
+
return value
|
|
488
|
+
}
|
package/src/host-config.ts
CHANGED
|
@@ -303,16 +303,21 @@ const CLASS_ESCAPE_MAP: [string, string][] = [
|
|
|
303
303
|
];
|
|
304
304
|
|
|
305
305
|
/**
|
|
306
|
-
* Escape special characters in a class name for USS compatibility
|
|
306
|
+
* Escape special characters in a class name for USS compatibility.
|
|
307
307
|
* Tailwind class names like "hover:bg-red-500" become "hover_c_bg-red-500"
|
|
308
|
+
* Results are cached since the same class names are used repeatedly across renders.
|
|
308
309
|
*/
|
|
310
|
+
const _escapeCache = new Map<string, string>();
|
|
309
311
|
function escapeClassName(name: string): string {
|
|
312
|
+
const cached = _escapeCache.get(name);
|
|
313
|
+
if (cached !== undefined) return cached;
|
|
310
314
|
let escaped = name;
|
|
311
315
|
for (const [char, replacement] of CLASS_ESCAPE_MAP) {
|
|
312
316
|
if (escaped.includes(char)) {
|
|
313
317
|
escaped = escaped.split(char).join(replacement);
|
|
314
318
|
}
|
|
315
319
|
}
|
|
320
|
+
_escapeCache.set(name, escaped);
|
|
316
321
|
return escaped;
|
|
317
322
|
}
|
|
318
323
|
|
|
@@ -437,14 +442,20 @@ function clearRemovedStyles(element: CSObject, oldKeys: Set<string>, newKeys: Se
|
|
|
437
442
|
}
|
|
438
443
|
}
|
|
439
444
|
|
|
440
|
-
// Parse className string into a Set of escaped class names
|
|
445
|
+
// Parse className string into a Set of escaped class names.
|
|
446
|
+
// Results are cached since the same className strings recur across renders.
|
|
447
|
+
const _parseCache = new Map<string, Set<string>>();
|
|
441
448
|
function parseClassNames(className: string | undefined): Set<string> {
|
|
442
449
|
if (!className) return new Set();
|
|
443
|
-
|
|
450
|
+
const cached = _parseCache.get(className);
|
|
451
|
+
if (cached !== undefined) return cached;
|
|
452
|
+
const result = new Set(
|
|
444
453
|
className.split(/\s+/)
|
|
445
454
|
.filter(Boolean)
|
|
446
455
|
.map(escapeClassName)
|
|
447
456
|
);
|
|
457
|
+
_parseCache.set(className, result);
|
|
458
|
+
return result;
|
|
448
459
|
}
|
|
449
460
|
|
|
450
461
|
// Apply className(s) to element (with escaping for Tailwind/USS compatibility)
|
|
@@ -775,7 +786,7 @@ function applyListViewProps(element: CSListView, props: Record<string, unknown>,
|
|
|
775
786
|
|
|
776
787
|
// Props handled by the reconciler infrastructure - not forwarded to C# elements
|
|
777
788
|
const RESERVED_PROPS = new Set([
|
|
778
|
-
'children', 'key', 'ref', 'style', 'className', 'pickingMode',
|
|
789
|
+
'children', 'key', 'ref', 'style', 'className', 'pickingMode', 'focusable', 'disabled',
|
|
779
790
|
'onGenerateVisualContent',
|
|
780
791
|
...Object.keys(EVENT_PROPS),
|
|
781
792
|
]);
|
|
@@ -851,6 +862,16 @@ function createInstance(type: string, props: BaseProps): Instance {
|
|
|
851
862
|
element.pickingMode = CS.UnityEngine.UIElements.PickingMode[props.pickingMode];
|
|
852
863
|
}
|
|
853
864
|
|
|
865
|
+
// Apply focusable
|
|
866
|
+
if (props.focusable !== undefined) {
|
|
867
|
+
element.focusable = props.focusable;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
// Apply disabled (inverted — SetEnabled(true) means "not disabled")
|
|
871
|
+
if (props.disabled !== undefined) {
|
|
872
|
+
element.SetEnabled(!props.disabled);
|
|
873
|
+
}
|
|
874
|
+
|
|
854
875
|
return instance;
|
|
855
876
|
}
|
|
856
877
|
|
|
@@ -890,6 +911,24 @@ function updateInstance(instance: Instance, oldProps: BaseProps, newProps: BaseP
|
|
|
890
911
|
}
|
|
891
912
|
}
|
|
892
913
|
|
|
914
|
+
// Update focusable - only set if explicitly provided, do not override
|
|
915
|
+
// element-specific defaults (Button is focusable by default, View is not)
|
|
916
|
+
// when the prop is removed.
|
|
917
|
+
if (oldProps.focusable !== newProps.focusable && newProps.focusable !== undefined) {
|
|
918
|
+
element.focusable = newProps.focusable;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
// Update disabled - unlike focusable, every VisualElement starts enabled
|
|
922
|
+
// by default, so removing the prop after a previous `disabled={true}` is
|
|
923
|
+
// expected to restore the enabled state.
|
|
924
|
+
if (oldProps.disabled !== newProps.disabled) {
|
|
925
|
+
if (newProps.disabled !== undefined) {
|
|
926
|
+
element.SetEnabled(!newProps.disabled);
|
|
927
|
+
} else {
|
|
928
|
+
element.SetEnabled(true);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
893
932
|
instance.props = newProps;
|
|
894
933
|
}
|
|
895
934
|
|
package/src/index.ts
CHANGED
|
@@ -45,7 +45,8 @@ export type {
|
|
|
45
45
|
export { Transform2D, useVectorContent } from './vector';
|
|
46
46
|
|
|
47
47
|
// Sync Hooks & C# Interop Utilities
|
|
48
|
-
export { useFrameSync, useFrameSyncWith, useThrottledSync, toArray } from './hooks';
|
|
48
|
+
export { useFrameSync, useFrameSyncWith, useThrottledSync, useEventSync, toArray } from './hooks';
|
|
49
|
+
export type { EventSource } from './hooks';
|
|
49
50
|
|
|
50
51
|
// Types
|
|
51
52
|
export type {
|
|
@@ -60,6 +61,7 @@ export type {
|
|
|
60
61
|
DragEventData,
|
|
61
62
|
GeometryEventData,
|
|
62
63
|
NavigationEventData,
|
|
64
|
+
NavigationDirection,
|
|
63
65
|
TransitionEventData,
|
|
64
66
|
// Event handler types
|
|
65
67
|
PointerEventHandler,
|
package/src/style-parser.ts
CHANGED
|
@@ -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
|
-
|
|
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?:
|
|
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;
|