@lattice-ui/focus 0.1.1

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 ADDED
@@ -0,0 +1,15 @@
1
+ # @lattice-ui/focus
2
+
3
+ This package is intentionally a skeleton in the current phase.
4
+
5
+ ## Current status
6
+
7
+ - `FocusScope` and `RovingFocusGroup` are no-op placeholders.
8
+ - Public API is kept stable while layer/popper hardening lands first.
9
+
10
+ ## Next implementation targets
11
+
12
+ - `FocusScope`: trap + restore behavior for dialog-like surfaces.
13
+ - `RovingFocusGroup`: keyboard/gamepad directional navigation.
14
+ - `GuiService.SelectedObject` + `NextSelection*` graph management.
15
+ - Focus restore flow to trigger elements when scope closes.
@@ -0,0 +1,3 @@
1
+ import type React from "@rbxts/react";
2
+ import type { FocusScopeProps } from "./types";
3
+ export declare function FocusScope(props: FocusScopeProps): React.ReactNode;
@@ -0,0 +1,7 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local function FocusScope(props)
3
+ return props.children
4
+ end
5
+ return {
6
+ FocusScope = FocusScope,
7
+ }
@@ -0,0 +1,4 @@
1
+ export declare const GuiService: GuiService;
2
+ export type FocusSnapshot = GuiObject | undefined;
3
+ export declare function captureFocus(): FocusSnapshot;
4
+ export declare function restoreFocus(snapshot: FocusSnapshot): void;
@@ -0,0 +1,13 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local GuiService = game:GetService("GuiService")
3
+ local function captureFocus()
4
+ return GuiService.SelectedObject
5
+ end
6
+ local function restoreFocus(snapshot)
7
+ GuiService.SelectedObject = snapshot
8
+ end
9
+ return {
10
+ captureFocus = captureFocus,
11
+ restoreFocus = restoreFocus,
12
+ GuiService = GuiService,
13
+ }
@@ -0,0 +1,6 @@
1
+ import type React from "@rbxts/react";
2
+ export type FocusScopeProps = {
3
+ trapped?: boolean;
4
+ restoreFocus?: boolean;
5
+ children?: React.ReactNode;
6
+ };
@@ -0,0 +1,2 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ return nil
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { RovingFocusGroupProps } from "./types";
3
+ export declare function RovingFocusGroup(props: RovingFocusGroupProps): React.JSX.Element;
@@ -0,0 +1,196 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local React = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out).React
4
+ local _guiSelection = TS.import(script, script.Parent.Parent, "internals", "guiSelection")
5
+ local getSelectedGuiObject = _guiSelection.getSelectedGuiObject
6
+ local setSelectedGuiObject = _guiSelection.setSelectedGuiObject
7
+ local UserInputService = _guiSelection.UserInputService
8
+ local RovingFocusProvider = TS.import(script, script.Parent, "context").RovingFocusProvider
9
+ local _roving = TS.import(script, script.Parent, "roving")
10
+ local getFirstEnabledRovingIndex = _roving.getFirstEnabledRovingIndex
11
+ local getLastEnabledRovingIndex = _roving.getLastEnabledRovingIndex
12
+ local getNextRovingIndex = _roving.getNextRovingIndex
13
+ local function findCurrentIndex(items, selectedObject)
14
+ if not selectedObject then
15
+ return -1
16
+ end
17
+ -- ▼ ReadonlyArray.findIndex ▼
18
+ local _callback = function(item)
19
+ local node = item.getNode()
20
+ if not node then
21
+ return false
22
+ end
23
+ return selectedObject == node or selectedObject:IsDescendantOf(node)
24
+ end
25
+ local _result = -1
26
+ for _i, _v in items do
27
+ if _callback(_v, _i - 1, items) == true then
28
+ _result = _i - 1
29
+ break
30
+ end
31
+ end
32
+ -- ▲ ReadonlyArray.findIndex ▲
33
+ return _result
34
+ end
35
+ local function isItemDisabled(items, index)
36
+ local item = items[index + 1]
37
+ if not item then
38
+ return true
39
+ end
40
+ return item.getDisabled()
41
+ end
42
+ local function focusItem(items, index)
43
+ local item = items[index + 1]
44
+ if not item then
45
+ return nil
46
+ end
47
+ if item.getDisabled() then
48
+ return nil
49
+ end
50
+ local node = item.getNode()
51
+ if not node or not node.Selectable then
52
+ return nil
53
+ end
54
+ setSelectedGuiObject(node)
55
+ end
56
+ local function resolveArrowDirection(keyCode, orientation)
57
+ if (orientation == "vertical" or orientation == "both") and keyCode == Enum.KeyCode.Up then
58
+ return "prev"
59
+ end
60
+ if (orientation == "vertical" or orientation == "both") and keyCode == Enum.KeyCode.Down then
61
+ return "next"
62
+ end
63
+ if (orientation == "horizontal" or orientation == "both") and keyCode == Enum.KeyCode.Left then
64
+ return "prev"
65
+ end
66
+ if (orientation == "horizontal" or orientation == "both") and keyCode == Enum.KeyCode.Right then
67
+ return "next"
68
+ end
69
+ return nil
70
+ end
71
+ local function RovingFocusGroup(props)
72
+ local _condition = props.loop
73
+ if _condition == nil then
74
+ _condition = true
75
+ end
76
+ local loop = _condition
77
+ local orientation = props.orientation or "both"
78
+ local _condition_1 = props.active
79
+ if _condition_1 == nil then
80
+ _condition_1 = true
81
+ end
82
+ local active = _condition_1
83
+ local autoFocus = props.autoFocus or "none"
84
+ local itemEntriesRef = React.useRef({})
85
+ local revision, setRevision = React.useState(0)
86
+ local registerItem = React.useCallback(function(item)
87
+ local _current = itemEntriesRef.current
88
+ local _item = item
89
+ table.insert(_current, _item)
90
+ setRevision(function(value)
91
+ return value + 1
92
+ end)
93
+ return function()
94
+ local _exp = itemEntriesRef.current
95
+ -- ▼ ReadonlyArray.findIndex ▼
96
+ local _callback = function(entry)
97
+ return entry.id == item.id
98
+ end
99
+ local _result = -1
100
+ for _i, _v in _exp do
101
+ if _callback(_v, _i - 1, _exp) == true then
102
+ _result = _i - 1
103
+ break
104
+ end
105
+ end
106
+ -- ▲ ReadonlyArray.findIndex ▲
107
+ local index = _result
108
+ if index >= 0 then
109
+ table.remove(itemEntriesRef.current, index + 1)
110
+ setRevision(function(value)
111
+ return value + 1
112
+ end)
113
+ end
114
+ end
115
+ end, {})
116
+ React.useEffect(function()
117
+ if not active or autoFocus ~= "first" then
118
+ return nil
119
+ end
120
+ local items = itemEntriesRef.current
121
+ local firstEnabledIndex = getFirstEnabledRovingIndex(#items, function(index)
122
+ return isItemDisabled(items, index)
123
+ end)
124
+ if firstEnabledIndex >= 0 then
125
+ focusItem(items, firstEnabledIndex)
126
+ end
127
+ end, { active, autoFocus, revision })
128
+ React.useEffect(function()
129
+ if not active then
130
+ return nil
131
+ end
132
+ local connection = UserInputService.InputBegan:Connect(function(inputObject, gameProcessedEvent)
133
+ if gameProcessedEvent then
134
+ return nil
135
+ end
136
+ local keyCode = inputObject.KeyCode
137
+ local isHomeKey = keyCode == Enum.KeyCode.Home
138
+ local isEndKey = keyCode == Enum.KeyCode.End
139
+ local direction = resolveArrowDirection(keyCode, orientation)
140
+ if not isHomeKey and not isEndKey and not direction then
141
+ return nil
142
+ end
143
+ local items = itemEntriesRef.current
144
+ local itemCount = #items
145
+ if itemCount <= 0 then
146
+ return nil
147
+ end
148
+ local selectedObject = getSelectedGuiObject()
149
+ local currentIndex = findCurrentIndex(items, selectedObject)
150
+ if currentIndex < 0 then
151
+ return nil
152
+ end
153
+ if isHomeKey then
154
+ local firstEnabledIndex = getFirstEnabledRovingIndex(itemCount, function(index)
155
+ return isItemDisabled(items, index)
156
+ end)
157
+ if firstEnabledIndex >= 0 then
158
+ focusItem(items, firstEnabledIndex)
159
+ end
160
+ return nil
161
+ end
162
+ if isEndKey then
163
+ local lastEnabledIndex = getLastEnabledRovingIndex(itemCount, function(index)
164
+ return isItemDisabled(items, index)
165
+ end)
166
+ if lastEnabledIndex >= 0 then
167
+ focusItem(items, lastEnabledIndex)
168
+ end
169
+ return nil
170
+ end
171
+ if not direction then
172
+ return nil
173
+ end
174
+ local nextIndex = getNextRovingIndex(currentIndex, itemCount, direction, loop, function(index)
175
+ return isItemDisabled(items, index)
176
+ end)
177
+ if nextIndex >= 0 then
178
+ focusItem(items, nextIndex)
179
+ end
180
+ end)
181
+ return function()
182
+ connection:Disconnect()
183
+ end
184
+ end, { active, loop, orientation })
185
+ local contextValue = React.useMemo(function()
186
+ return {
187
+ registerItem = registerItem,
188
+ }
189
+ end, { registerItem })
190
+ return React.createElement(RovingFocusProvider, {
191
+ value = contextValue,
192
+ }, props.children)
193
+ end
194
+ return {
195
+ RovingFocusGroup = RovingFocusGroup,
196
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { RovingFocusItemProps } from "./types";
3
+ export declare function RovingFocusItem(props: RovingFocusItemProps): React.JSX.Element;
@@ -0,0 +1,66 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
4
+ local React = _core.React
5
+ local Slot = _core.Slot
6
+ local useRovingFocusContext = TS.import(script, script.Parent, "context").useRovingFocusContext
7
+ local nextRovingItemId = 0
8
+ local function toGuiObject(instance)
9
+ if not instance or not instance:IsA("GuiObject") then
10
+ return nil
11
+ end
12
+ return instance
13
+ end
14
+ local function RovingFocusItem(props)
15
+ local rovingFocusContext = useRovingFocusContext()
16
+ local itemRef = React.useRef()
17
+ local disabledRef = React.useRef(props.disabled == true)
18
+ React.useEffect(function()
19
+ disabledRef.current = props.disabled == true
20
+ end, { props.disabled })
21
+ local itemIdRef = React.useRef(0)
22
+ if itemIdRef.current == 0 then
23
+ nextRovingItemId += 1
24
+ itemIdRef.current = nextRovingItemId
25
+ end
26
+ React.useEffect(function()
27
+ return rovingFocusContext.registerItem({
28
+ id = itemIdRef.current,
29
+ getNode = function()
30
+ return itemRef.current
31
+ end,
32
+ getDisabled = function()
33
+ return disabledRef.current
34
+ end,
35
+ })
36
+ end, { rovingFocusContext })
37
+ local setItemRef = React.useCallback(function(instance)
38
+ itemRef.current = toGuiObject(instance)
39
+ end, {})
40
+ if props.asChild then
41
+ local child = props.children
42
+ if not child then
43
+ error("[RovingFocusItem] `asChild` requires a child element.")
44
+ end
45
+ return React.createElement(Slot, {
46
+ Active = props.disabled ~= true,
47
+ Selectable = props.disabled ~= true,
48
+ ref = setItemRef,
49
+ }, child)
50
+ end
51
+ return React.createElement("textbutton", {
52
+ Active = props.disabled ~= true,
53
+ AutoButtonColor = false,
54
+ BackgroundTransparency = 1,
55
+ BorderSizePixel = 0,
56
+ Selectable = props.disabled ~= true,
57
+ Size = UDim2.fromOffset(140, 30),
58
+ Text = "Item",
59
+ TextColor3 = Color3.fromRGB(240, 244, 250),
60
+ TextSize = 15,
61
+ ref = setItemRef,
62
+ }, props.children)
63
+ end
64
+ return {
65
+ RovingFocusItem = RovingFocusItem,
66
+ }
@@ -0,0 +1,3 @@
1
+ import type { RovingFocusContextValue } from "./types";
2
+ declare const RovingFocusProvider: import("@rbxts/react").Provider<RovingFocusContextValue | undefined>, useRovingFocusContext: () => RovingFocusContextValue;
3
+ export { RovingFocusProvider, useRovingFocusContext };
@@ -0,0 +1,10 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local createStrictContext = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out).createStrictContext
4
+ local _binding = createStrictContext("RovingFocusGroup")
5
+ local RovingFocusProvider = _binding[1]
6
+ local useRovingFocusContext = _binding[2]
7
+ return {
8
+ RovingFocusProvider = RovingFocusProvider,
9
+ useRovingFocusContext = useRovingFocusContext,
10
+ }
@@ -0,0 +1,4 @@
1
+ import type { RovingDirection } from "./types";
2
+ export declare function getNextRovingIndex(currentIndex: number, itemCount: number, direction: RovingDirection, loop: boolean, isDisabled?: (index: number) => boolean): number;
3
+ export declare function getFirstEnabledRovingIndex(itemCount: number, isDisabled?: (index: number) => boolean): number;
4
+ export declare function getLastEnabledRovingIndex(itemCount: number, isDisabled?: (index: number) => boolean): number;
@@ -0,0 +1,85 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local function getDirectionDelta(direction)
3
+ return if direction == "next" then 1 else -1
4
+ end
5
+ local function getNextRovingIndex(currentIndex, itemCount, direction, loop, isDisabled)
6
+ if itemCount <= 0 then
7
+ return -1
8
+ end
9
+ -- Keep focus unchanged when the current index is outside this group.
10
+ if currentIndex < 0 or currentIndex >= itemCount then
11
+ return currentIndex
12
+ end
13
+ local delta = getDirectionDelta(direction)
14
+ local candidate = currentIndex
15
+ do
16
+ local attempts = 0
17
+ local _shouldIncrement = false
18
+ while true do
19
+ if _shouldIncrement then
20
+ attempts += 1
21
+ else
22
+ _shouldIncrement = true
23
+ end
24
+ if not (attempts < itemCount) then
25
+ break
26
+ end
27
+ candidate += delta
28
+ if candidate < 0 or candidate >= itemCount then
29
+ if not loop then
30
+ return currentIndex
31
+ end
32
+ candidate = if direction == "next" then 0 else itemCount - 1
33
+ end
34
+ if not isDisabled or not isDisabled(candidate) then
35
+ return candidate
36
+ end
37
+ end
38
+ end
39
+ return currentIndex
40
+ end
41
+ local function getFirstEnabledRovingIndex(itemCount, isDisabled)
42
+ do
43
+ local index = 0
44
+ local _shouldIncrement = false
45
+ while true do
46
+ if _shouldIncrement then
47
+ index += 1
48
+ else
49
+ _shouldIncrement = true
50
+ end
51
+ if not (index < itemCount) then
52
+ break
53
+ end
54
+ if not isDisabled or not isDisabled(index) then
55
+ return index
56
+ end
57
+ end
58
+ end
59
+ return -1
60
+ end
61
+ local function getLastEnabledRovingIndex(itemCount, isDisabled)
62
+ do
63
+ local index = itemCount - 1
64
+ local _shouldIncrement = false
65
+ while true do
66
+ if _shouldIncrement then
67
+ index -= 1
68
+ else
69
+ _shouldIncrement = true
70
+ end
71
+ if not (index >= 0) then
72
+ break
73
+ end
74
+ if not isDisabled or not isDisabled(index) then
75
+ return index
76
+ end
77
+ end
78
+ end
79
+ return -1
80
+ end
81
+ return {
82
+ getNextRovingIndex = getNextRovingIndex,
83
+ getFirstEnabledRovingIndex = getFirstEnabledRovingIndex,
84
+ getLastEnabledRovingIndex = getLastEnabledRovingIndex,
85
+ }
@@ -0,0 +1,25 @@
1
+ import type React from "@rbxts/react";
2
+ export type RovingDirection = "next" | "prev";
3
+ export type RovingOrientation = "horizontal" | "vertical" | "both";
4
+ export type RovingAutoFocus = "none" | "first";
5
+ export type RovingFocusGroupProps = {
6
+ loop?: boolean;
7
+ orientation?: RovingOrientation;
8
+ active?: boolean;
9
+ autoFocus?: RovingAutoFocus;
10
+ children?: React.ReactNode;
11
+ };
12
+ export type RovingFocusItemProps = {
13
+ asChild?: boolean;
14
+ disabled?: boolean;
15
+ children?: React.ReactElement;
16
+ };
17
+ export type RovingItemRegistration = {
18
+ id: number;
19
+ getNode: () => GuiObject | undefined;
20
+ getDisabled: () => boolean;
21
+ };
22
+ export type RovingFocusContextValue = {
23
+ registerItem: (item: RovingItemRegistration) => () => void;
24
+ children?: React.ReactNode;
25
+ };
@@ -0,0 +1,2 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ return nil
package/out/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./FocusScope/FocusScope";
2
+ export * from "./RovingFocus/RovingFocusGroup";
3
+ export * from "./RovingFocus/RovingFocusItem";
package/out/init.luau ADDED
@@ -0,0 +1,13 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local exports = {}
4
+ for _k, _v in TS.import(script, script, "FocusScope", "FocusScope") or {} do
5
+ exports[_k] = _v
6
+ end
7
+ for _k, _v in TS.import(script, script, "RovingFocus", "RovingFocusGroup") or {} do
8
+ exports[_k] = _v
9
+ end
10
+ for _k, _v in TS.import(script, script, "RovingFocus", "RovingFocusItem") or {} do
11
+ exports[_k] = _v
12
+ end
13
+ return exports
@@ -0,0 +1,3 @@
1
+ export declare const UserInputService: UserInputService;
2
+ export declare function getSelectedGuiObject(): GuiObject | undefined;
3
+ export declare function setSelectedGuiObject(guiObject: GuiObject | undefined): void;
@@ -0,0 +1,15 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local GuiService = TS.import(script, script.Parent.Parent, "FocusScope", "focusManager").GuiService
4
+ local UserInputService = game:GetService("UserInputService")
5
+ local function getSelectedGuiObject()
6
+ return GuiService.SelectedObject
7
+ end
8
+ local function setSelectedGuiObject(guiObject)
9
+ GuiService.SelectedObject = guiObject
10
+ end
11
+ return {
12
+ getSelectedGuiObject = getSelectedGuiObject,
13
+ setSelectedGuiObject = setSelectedGuiObject,
14
+ UserInputService = UserInputService,
15
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@lattice-ui/focus",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "main": "out/init.luau",
6
+ "types": "out/index.d.ts",
7
+ "dependencies": {
8
+ "@lattice-ui/core": "0.1.1"
9
+ },
10
+ "devDependencies": {
11
+ "@rbxts/react": "17.3.7-ts.1",
12
+ "@rbxts/react-roblox": "17.3.7-ts.1"
13
+ },
14
+ "peerDependencies": {
15
+ "@rbxts/react": "^17",
16
+ "@rbxts/react-roblox": "^17"
17
+ },
18
+ "scripts": {
19
+ "build": "rbxtsc -p tsconfig.json",
20
+ "watch": "rbxtsc -p tsconfig.json -w",
21
+ "typecheck": "tsc -p tsconfig.typecheck.json"
22
+ }
23
+ }
@@ -0,0 +1,6 @@
1
+ import type React from "@rbxts/react";
2
+ import type { FocusScopeProps } from "./types";
3
+
4
+ export function FocusScope(props: FocusScopeProps): React.ReactNode {
5
+ return props.children;
6
+ }
@@ -0,0 +1,11 @@
1
+ export const GuiService = game.GetService("GuiService");
2
+
3
+ export type FocusSnapshot = GuiObject | undefined;
4
+
5
+ export function captureFocus(): FocusSnapshot {
6
+ return GuiService.SelectedObject;
7
+ }
8
+
9
+ export function restoreFocus(snapshot: FocusSnapshot) {
10
+ GuiService.SelectedObject = snapshot;
11
+ }
@@ -0,0 +1,7 @@
1
+ import type React from "@rbxts/react";
2
+
3
+ export type FocusScopeProps = {
4
+ trapped?: boolean;
5
+ restoreFocus?: boolean;
6
+ children?: React.ReactNode;
7
+ };
@@ -0,0 +1,174 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import { getSelectedGuiObject, setSelectedGuiObject, UserInputService } from "../internals/guiSelection";
3
+ import { RovingFocusProvider } from "./context";
4
+ import { getFirstEnabledRovingIndex, getLastEnabledRovingIndex, getNextRovingIndex } from "./roving";
5
+ import type { RovingDirection, RovingFocusGroupProps, RovingItemRegistration, RovingOrientation } from "./types";
6
+
7
+ function findCurrentIndex(items: Array<RovingItemRegistration>, selectedObject: GuiObject | undefined) {
8
+ if (!selectedObject) {
9
+ return -1;
10
+ }
11
+
12
+ return items.findIndex((item) => {
13
+ const node = item.getNode();
14
+ if (!node) {
15
+ return false;
16
+ }
17
+
18
+ return selectedObject === node || selectedObject.IsDescendantOf(node);
19
+ });
20
+ }
21
+
22
+ function isItemDisabled(items: Array<RovingItemRegistration>, index: number) {
23
+ const item = items[index];
24
+ if (!item) {
25
+ return true;
26
+ }
27
+
28
+ return item.getDisabled();
29
+ }
30
+
31
+ function focusItem(items: Array<RovingItemRegistration>, index: number) {
32
+ const item = items[index];
33
+ if (!item) {
34
+ return;
35
+ }
36
+
37
+ if (item.getDisabled()) {
38
+ return;
39
+ }
40
+
41
+ const node = item.getNode();
42
+ if (!node || !node.Selectable) {
43
+ return;
44
+ }
45
+
46
+ setSelectedGuiObject(node);
47
+ }
48
+
49
+ function resolveArrowDirection(keyCode: Enum.KeyCode, orientation: RovingOrientation): RovingDirection | undefined {
50
+ if ((orientation === "vertical" || orientation === "both") && keyCode === Enum.KeyCode.Up) {
51
+ return "prev";
52
+ }
53
+
54
+ if ((orientation === "vertical" || orientation === "both") && keyCode === Enum.KeyCode.Down) {
55
+ return "next";
56
+ }
57
+
58
+ if ((orientation === "horizontal" || orientation === "both") && keyCode === Enum.KeyCode.Left) {
59
+ return "prev";
60
+ }
61
+
62
+ if ((orientation === "horizontal" || orientation === "both") && keyCode === Enum.KeyCode.Right) {
63
+ return "next";
64
+ }
65
+
66
+ return undefined;
67
+ }
68
+
69
+ export function RovingFocusGroup(props: RovingFocusGroupProps) {
70
+ const loop = props.loop ?? true;
71
+ const orientation = props.orientation ?? "both";
72
+ const active = props.active ?? true;
73
+ const autoFocus = props.autoFocus ?? "none";
74
+
75
+ const itemEntriesRef = React.useRef<Array<RovingItemRegistration>>([]);
76
+ const [revision, setRevision] = React.useState(0);
77
+
78
+ const registerItem = React.useCallback((item: RovingItemRegistration) => {
79
+ itemEntriesRef.current.push(item);
80
+ setRevision((value) => value + 1);
81
+
82
+ return () => {
83
+ const index = itemEntriesRef.current.findIndex((entry) => entry.id === item.id);
84
+ if (index >= 0) {
85
+ itemEntriesRef.current.remove(index);
86
+ setRevision((value) => value + 1);
87
+ }
88
+ };
89
+ }, []);
90
+
91
+ React.useEffect(() => {
92
+ if (!active || autoFocus !== "first") {
93
+ return;
94
+ }
95
+
96
+ const items = itemEntriesRef.current;
97
+ const firstEnabledIndex = getFirstEnabledRovingIndex(items.size(), (index) => isItemDisabled(items, index));
98
+ if (firstEnabledIndex >= 0) {
99
+ focusItem(items, firstEnabledIndex);
100
+ }
101
+ }, [active, autoFocus, revision]);
102
+
103
+ React.useEffect(() => {
104
+ if (!active) {
105
+ return;
106
+ }
107
+
108
+ const connection = UserInputService.InputBegan.Connect((inputObject, gameProcessedEvent) => {
109
+ if (gameProcessedEvent) {
110
+ return;
111
+ }
112
+
113
+ const keyCode = inputObject.KeyCode;
114
+ const isHomeKey = keyCode === Enum.KeyCode.Home;
115
+ const isEndKey = keyCode === Enum.KeyCode.End;
116
+ const direction = resolveArrowDirection(keyCode, orientation);
117
+ if (!isHomeKey && !isEndKey && !direction) {
118
+ return;
119
+ }
120
+
121
+ const items = itemEntriesRef.current;
122
+ const itemCount = items.size();
123
+ if (itemCount <= 0) {
124
+ return;
125
+ }
126
+
127
+ const selectedObject = getSelectedGuiObject();
128
+ const currentIndex = findCurrentIndex(items, selectedObject);
129
+ if (currentIndex < 0) {
130
+ return;
131
+ }
132
+
133
+ if (isHomeKey) {
134
+ const firstEnabledIndex = getFirstEnabledRovingIndex(itemCount, (index) => isItemDisabled(items, index));
135
+ if (firstEnabledIndex >= 0) {
136
+ focusItem(items, firstEnabledIndex);
137
+ }
138
+ return;
139
+ }
140
+
141
+ if (isEndKey) {
142
+ const lastEnabledIndex = getLastEnabledRovingIndex(itemCount, (index) => isItemDisabled(items, index));
143
+ if (lastEnabledIndex >= 0) {
144
+ focusItem(items, lastEnabledIndex);
145
+ }
146
+ return;
147
+ }
148
+
149
+ if (!direction) {
150
+ return;
151
+ }
152
+
153
+ const nextIndex = getNextRovingIndex(currentIndex, itemCount, direction, loop, (index) =>
154
+ isItemDisabled(items, index),
155
+ );
156
+ if (nextIndex >= 0) {
157
+ focusItem(items, nextIndex);
158
+ }
159
+ });
160
+
161
+ return () => {
162
+ connection.Disconnect();
163
+ };
164
+ }, [active, loop, orientation]);
165
+
166
+ const contextValue = React.useMemo(
167
+ () => ({
168
+ registerItem,
169
+ }),
170
+ [registerItem],
171
+ );
172
+
173
+ return <RovingFocusProvider value={contextValue}>{props.children}</RovingFocusProvider>;
174
+ }
@@ -0,0 +1,71 @@
1
+ import { React, Slot } from "@lattice-ui/core";
2
+ import { useRovingFocusContext } from "./context";
3
+ import type { RovingFocusItemProps } from "./types";
4
+
5
+ let nextRovingItemId = 0;
6
+
7
+ function toGuiObject(instance: Instance | undefined) {
8
+ if (!instance || !instance.IsA("GuiObject")) {
9
+ return undefined;
10
+ }
11
+
12
+ return instance;
13
+ }
14
+
15
+ export function RovingFocusItem(props: RovingFocusItemProps) {
16
+ const rovingFocusContext = useRovingFocusContext();
17
+ const itemRef = React.useRef<GuiObject>();
18
+ const disabledRef = React.useRef(props.disabled === true);
19
+
20
+ React.useEffect(() => {
21
+ disabledRef.current = props.disabled === true;
22
+ }, [props.disabled]);
23
+
24
+ const itemIdRef = React.useRef(0);
25
+ if (itemIdRef.current === 0) {
26
+ nextRovingItemId += 1;
27
+ itemIdRef.current = nextRovingItemId;
28
+ }
29
+
30
+ React.useEffect(() => {
31
+ return rovingFocusContext.registerItem({
32
+ id: itemIdRef.current,
33
+ getNode: () => itemRef.current,
34
+ getDisabled: () => disabledRef.current,
35
+ });
36
+ }, [rovingFocusContext]);
37
+
38
+ const setItemRef = React.useCallback((instance: Instance | undefined) => {
39
+ itemRef.current = toGuiObject(instance);
40
+ }, []);
41
+
42
+ if (props.asChild) {
43
+ const child = props.children;
44
+ if (!child) {
45
+ error("[RovingFocusItem] `asChild` requires a child element.");
46
+ }
47
+
48
+ return (
49
+ <Slot Active={props.disabled !== true} Selectable={props.disabled !== true} ref={setItemRef}>
50
+ {child}
51
+ </Slot>
52
+ );
53
+ }
54
+
55
+ return (
56
+ <textbutton
57
+ Active={props.disabled !== true}
58
+ AutoButtonColor={false}
59
+ BackgroundTransparency={1}
60
+ BorderSizePixel={0}
61
+ Selectable={props.disabled !== true}
62
+ Size={UDim2.fromOffset(140, 30)}
63
+ Text="Item"
64
+ TextColor3={Color3.fromRGB(240, 244, 250)}
65
+ TextSize={15}
66
+ ref={setItemRef}
67
+ >
68
+ {props.children}
69
+ </textbutton>
70
+ );
71
+ }
@@ -0,0 +1,6 @@
1
+ import { createStrictContext } from "@lattice-ui/core";
2
+ import type { RovingFocusContextValue } from "./types";
3
+
4
+ const [RovingFocusProvider, useRovingFocusContext] = createStrictContext<RovingFocusContextValue>("RovingFocusGroup");
5
+
6
+ export { RovingFocusProvider, useRovingFocusContext };
@@ -0,0 +1,62 @@
1
+ import type { RovingDirection } from "./types";
2
+
3
+ function getDirectionDelta(direction: RovingDirection) {
4
+ return direction === "next" ? 1 : -1;
5
+ }
6
+
7
+ export function getNextRovingIndex(
8
+ currentIndex: number,
9
+ itemCount: number,
10
+ direction: RovingDirection,
11
+ loop: boolean,
12
+ isDisabled?: (index: number) => boolean,
13
+ ) {
14
+ if (itemCount <= 0) {
15
+ return -1;
16
+ }
17
+
18
+ // Keep focus unchanged when the current index is outside this group.
19
+ if (currentIndex < 0 || currentIndex >= itemCount) {
20
+ return currentIndex;
21
+ }
22
+
23
+ const delta = getDirectionDelta(direction);
24
+ let candidate = currentIndex;
25
+
26
+ for (let attempts = 0; attempts < itemCount; attempts++) {
27
+ candidate += delta;
28
+
29
+ if (candidate < 0 || candidate >= itemCount) {
30
+ if (!loop) {
31
+ return currentIndex;
32
+ }
33
+ candidate = direction === "next" ? 0 : itemCount - 1;
34
+ }
35
+
36
+ if (!isDisabled || !isDisabled(candidate)) {
37
+ return candidate;
38
+ }
39
+ }
40
+
41
+ return currentIndex;
42
+ }
43
+
44
+ export function getFirstEnabledRovingIndex(itemCount: number, isDisabled?: (index: number) => boolean) {
45
+ for (let index = 0; index < itemCount; index++) {
46
+ if (!isDisabled || !isDisabled(index)) {
47
+ return index;
48
+ }
49
+ }
50
+
51
+ return -1;
52
+ }
53
+
54
+ export function getLastEnabledRovingIndex(itemCount: number, isDisabled?: (index: number) => boolean) {
55
+ for (let index = itemCount - 1; index >= 0; index--) {
56
+ if (!isDisabled || !isDisabled(index)) {
57
+ return index;
58
+ }
59
+ }
60
+
61
+ return -1;
62
+ }
@@ -0,0 +1,30 @@
1
+ import type React from "@rbxts/react";
2
+
3
+ export type RovingDirection = "next" | "prev";
4
+ export type RovingOrientation = "horizontal" | "vertical" | "both";
5
+ export type RovingAutoFocus = "none" | "first";
6
+
7
+ export type RovingFocusGroupProps = {
8
+ loop?: boolean;
9
+ orientation?: RovingOrientation;
10
+ active?: boolean;
11
+ autoFocus?: RovingAutoFocus;
12
+ children?: React.ReactNode;
13
+ };
14
+
15
+ export type RovingFocusItemProps = {
16
+ asChild?: boolean;
17
+ disabled?: boolean;
18
+ children?: React.ReactElement;
19
+ };
20
+
21
+ export type RovingItemRegistration = {
22
+ id: number;
23
+ getNode: () => GuiObject | undefined;
24
+ getDisabled: () => boolean;
25
+ };
26
+
27
+ export type RovingFocusContextValue = {
28
+ registerItem: (item: RovingItemRegistration) => () => void;
29
+ children?: React.ReactNode;
30
+ };
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./FocusScope/FocusScope";
2
+ export * from "./RovingFocus/RovingFocusGroup";
3
+ export * from "./RovingFocus/RovingFocusItem";
@@ -0,0 +1,11 @@
1
+ import { GuiService } from "../FocusScope/focusManager";
2
+
3
+ export const UserInputService = game.GetService("UserInputService");
4
+
5
+ export function getSelectedGuiObject() {
6
+ return GuiService.SelectedObject;
7
+ }
8
+
9
+ export function setSelectedGuiObject(guiObject: GuiObject | undefined) {
10
+ GuiService.SelectedObject = guiObject;
11
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "out",
6
+ "declaration": true,
7
+ "typeRoots": [
8
+ "./node_modules/@rbxts",
9
+ "../../node_modules/@rbxts",
10
+ "./node_modules/@lattice-ui",
11
+ "../../node_modules/@lattice-ui"
12
+ ],
13
+ "types": ["types", "compiler-types"]
14
+ },
15
+ "include": ["src"]
16
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "baseUrl": "..",
6
+ "rootDir": "..",
7
+ "paths": {
8
+ "@lattice-ui/checkbox": ["checkbox/src/index.ts"],
9
+ "@lattice-ui/core": ["core/src/index.ts"],
10
+ "@lattice-ui/dialog": ["dialog/src/index.ts"],
11
+ "@lattice-ui/focus": ["focus/src/index.ts"],
12
+ "@lattice-ui/layer": ["layer/src/index.ts"],
13
+ "@lattice-ui/menu": ["menu/src/index.ts"],
14
+ "@lattice-ui/popover": ["popover/src/index.ts"],
15
+ "@lattice-ui/popper": ["popper/src/index.ts"],
16
+ "@lattice-ui/radio-group": ["radio-group/src/index.ts"],
17
+ "@lattice-ui/style": ["style/src/index.ts"],
18
+ "@lattice-ui/switch": ["switch/src/index.ts"],
19
+ "@lattice-ui/system": ["system/src/index.ts"],
20
+ "@lattice-ui/tabs": ["tabs/src/index.ts"],
21
+ "@lattice-ui/toggle-group": ["toggle-group/src/index.ts"],
22
+ "@lattice-ui/tooltip": ["tooltip/src/index.ts"]
23
+ }
24
+ }
25
+ }