@lattice-ui/focus 0.1.1 → 0.3.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/LICENSE +7 -0
- package/README.md +16 -8
- package/out/FocusScope/FocusScope.d.ts +2 -2
- package/out/FocusScope/FocusScope.luau +189 -1
- package/out/FocusScope/scopeStack.d.ts +3 -0
- package/out/FocusScope/scopeStack.luau +43 -0
- package/out/FocusScope/types.d.ts +2 -0
- package/package.json +8 -4
- package/src/FocusScope/FocusScope.tsx +0 -6
- package/src/FocusScope/focusManager.ts +0 -11
- package/src/FocusScope/types.ts +0 -7
- package/src/RovingFocus/RovingFocusGroup.tsx +0 -174
- package/src/RovingFocus/RovingFocusItem.tsx +0 -71
- package/src/RovingFocus/context.ts +0 -6
- package/src/RovingFocus/roving.ts +0 -62
- package/src/RovingFocus/types.ts +0 -30
- package/src/index.ts +0 -3
- package/src/internals/guiSelection.ts +0 -11
- package/tsconfig.json +0 -16
- package/tsconfig.typecheck.json +0 -25
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 astra-void
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
# @lattice-ui/focus
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Focus and directional navigation primitives for Roblox UI.
|
|
4
4
|
|
|
5
5
|
## Current status
|
|
6
6
|
|
|
7
|
-
- `
|
|
8
|
-
-
|
|
7
|
+
- `RovingFocusGroup` and `RovingFocusItem` provide arrow/Home/End navigation across registered selectable items.
|
|
8
|
+
- `FocusScope` can trap `GuiService.SelectedObject` and restore captured focus on scope teardown.
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## FocusScope behavior
|
|
11
11
|
|
|
12
|
-
- `
|
|
13
|
-
- `
|
|
14
|
-
- `
|
|
15
|
-
|
|
12
|
+
- `active` defaults to `true`.
|
|
13
|
+
- `asChild` keeps caller tree structure; without it, `FocusScope` renders a transparent full-size frame wrapper.
|
|
14
|
+
- When `trapped` is true, outside selections are redirected to:
|
|
15
|
+
1. last focused selectable object inside the scope, then
|
|
16
|
+
2. first selectable descendant inside the scope.
|
|
17
|
+
- Nested trapped scopes use stack order; only the top-most active trapped scope redirects focus.
|
|
18
|
+
- When `restoreFocus` is true, captured focus is restored on unmount/deactivation if the target is still valid.
|
|
19
|
+
|
|
20
|
+
## Known limits
|
|
21
|
+
|
|
22
|
+
- Trap and restore currently use `GuiService.SelectedObject` only.
|
|
23
|
+
- This phase does not manage `NextSelection*` graph rewrites.
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { React } from "@lattice-ui/core";
|
|
2
2
|
import type { FocusScopeProps } from "./types";
|
|
3
|
-
export declare function FocusScope(props: FocusScopeProps): React.
|
|
3
|
+
export declare function FocusScope(props: FocusScopeProps): React.JSX.Element;
|
|
@@ -1,6 +1,194 @@
|
|
|
1
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 _guiSelection = TS.import(script, script.Parent.Parent, "internals", "guiSelection")
|
|
7
|
+
local getSelectedGuiObject = _guiSelection.getSelectedGuiObject
|
|
8
|
+
local setSelectedGuiObject = _guiSelection.setSelectedGuiObject
|
|
9
|
+
local _focusManager = TS.import(script, script.Parent, "focusManager")
|
|
10
|
+
local captureFocus = _focusManager.captureFocus
|
|
11
|
+
local GuiService = _focusManager.GuiService
|
|
12
|
+
local restoreFocus = _focusManager.restoreFocus
|
|
13
|
+
local _scopeStack = TS.import(script, script.Parent, "scopeStack")
|
|
14
|
+
local isTopTrappedScope = _scopeStack.isTopTrappedScope
|
|
15
|
+
local registerTrappedScope = _scopeStack.registerTrappedScope
|
|
16
|
+
local unregisterTrappedScope = _scopeStack.unregisterTrappedScope
|
|
17
|
+
local nextScopeId = 0
|
|
18
|
+
local function toGuiObject(instance)
|
|
19
|
+
if not instance or not instance:IsA("GuiObject") then
|
|
20
|
+
return nil
|
|
21
|
+
end
|
|
22
|
+
return instance
|
|
23
|
+
end
|
|
24
|
+
local function isLiveGuiObject(guiObject)
|
|
25
|
+
return guiObject ~= nil and guiObject.Parent ~= nil
|
|
26
|
+
end
|
|
27
|
+
local function isFocusable(guiObject)
|
|
28
|
+
return isLiveGuiObject(guiObject) and guiObject.Selectable
|
|
29
|
+
end
|
|
30
|
+
local function isInsideScope(scopeRoot, guiObject)
|
|
31
|
+
if not isLiveGuiObject(scopeRoot) or not isLiveGuiObject(guiObject) then
|
|
32
|
+
return false
|
|
33
|
+
end
|
|
34
|
+
return guiObject == scopeRoot or guiObject:IsDescendantOf(scopeRoot)
|
|
35
|
+
end
|
|
36
|
+
local function findFirstFocusableInScope(scopeRoot)
|
|
37
|
+
if isFocusable(scopeRoot) then
|
|
38
|
+
return scopeRoot
|
|
39
|
+
end
|
|
40
|
+
for _, descendant in scopeRoot:GetDescendants() do
|
|
41
|
+
if descendant:IsA("GuiObject") and isFocusable(descendant) then
|
|
42
|
+
return descendant
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
local function getFocusableRestoreTarget(snapshot)
|
|
48
|
+
if not snapshot then
|
|
49
|
+
return nil
|
|
50
|
+
end
|
|
51
|
+
if not isFocusable(snapshot) then
|
|
52
|
+
return nil
|
|
53
|
+
end
|
|
54
|
+
return snapshot
|
|
55
|
+
end
|
|
2
56
|
local function FocusScope(props)
|
|
3
|
-
|
|
57
|
+
local _condition = props.active
|
|
58
|
+
if _condition == nil then
|
|
59
|
+
_condition = true
|
|
60
|
+
end
|
|
61
|
+
local active = _condition
|
|
62
|
+
local trapped = props.trapped == true
|
|
63
|
+
local shouldRestoreFocus = props.restoreFocus ~= false
|
|
64
|
+
local scopeIdRef = React.useRef(0)
|
|
65
|
+
if scopeIdRef.current == 0 then
|
|
66
|
+
nextScopeId += 1
|
|
67
|
+
scopeIdRef.current = nextScopeId
|
|
68
|
+
end
|
|
69
|
+
local scopeRootRef = React.useRef()
|
|
70
|
+
local lastFocusedInsideRef = React.useRef()
|
|
71
|
+
local restoreSnapshotRef = React.useRef()
|
|
72
|
+
local isRedirectingRef = React.useRef(false)
|
|
73
|
+
local shouldRestoreFocusRef = React.useRef(shouldRestoreFocus)
|
|
74
|
+
React.useEffect(function()
|
|
75
|
+
shouldRestoreFocusRef.current = shouldRestoreFocus
|
|
76
|
+
end, { shouldRestoreFocus })
|
|
77
|
+
local updateLastFocusedInside = React.useCallback(function()
|
|
78
|
+
local scopeRoot = scopeRootRef.current
|
|
79
|
+
if not scopeRoot then
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
local selectedObject = getSelectedGuiObject()
|
|
83
|
+
if not selectedObject or not isInsideScope(scopeRoot, selectedObject) then
|
|
84
|
+
return nil
|
|
85
|
+
end
|
|
86
|
+
if isFocusable(selectedObject) then
|
|
87
|
+
lastFocusedInsideRef.current = selectedObject
|
|
88
|
+
end
|
|
89
|
+
end, {})
|
|
90
|
+
local resolveFallbackTarget = React.useCallback(function()
|
|
91
|
+
local scopeRoot = scopeRootRef.current
|
|
92
|
+
if not isLiveGuiObject(scopeRoot) then
|
|
93
|
+
return nil
|
|
94
|
+
end
|
|
95
|
+
local previousFocus = lastFocusedInsideRef.current
|
|
96
|
+
if previousFocus and isInsideScope(scopeRoot, previousFocus) and isFocusable(previousFocus) then
|
|
97
|
+
return previousFocus
|
|
98
|
+
end
|
|
99
|
+
return findFirstFocusableInScope(scopeRoot)
|
|
100
|
+
end, {})
|
|
101
|
+
local enforceFocusTrap = React.useCallback(function()
|
|
102
|
+
if not active or not trapped then
|
|
103
|
+
return nil
|
|
104
|
+
end
|
|
105
|
+
if not isTopTrappedScope(scopeIdRef.current) then
|
|
106
|
+
return nil
|
|
107
|
+
end
|
|
108
|
+
local scopeRoot = scopeRootRef.current
|
|
109
|
+
if not isLiveGuiObject(scopeRoot) then
|
|
110
|
+
return nil
|
|
111
|
+
end
|
|
112
|
+
local selectedObject = getSelectedGuiObject()
|
|
113
|
+
if selectedObject and isInsideScope(scopeRoot, selectedObject) then
|
|
114
|
+
if isFocusable(selectedObject) then
|
|
115
|
+
lastFocusedInsideRef.current = selectedObject
|
|
116
|
+
end
|
|
117
|
+
return nil
|
|
118
|
+
end
|
|
119
|
+
local fallbackTarget = resolveFallbackTarget()
|
|
120
|
+
if not fallbackTarget or fallbackTarget == selectedObject then
|
|
121
|
+
return nil
|
|
122
|
+
end
|
|
123
|
+
isRedirectingRef.current = true
|
|
124
|
+
setSelectedGuiObject(fallbackTarget)
|
|
125
|
+
isRedirectingRef.current = false
|
|
126
|
+
end, { active, resolveFallbackTarget, trapped })
|
|
127
|
+
local setScopeRoot = React.useCallback(function(instance)
|
|
128
|
+
local scopeRoot = toGuiObject(instance)
|
|
129
|
+
scopeRootRef.current = scopeRoot
|
|
130
|
+
if not scopeRoot then
|
|
131
|
+
return nil
|
|
132
|
+
end
|
|
133
|
+
updateLastFocusedInside()
|
|
134
|
+
if active and trapped then
|
|
135
|
+
enforceFocusTrap()
|
|
136
|
+
end
|
|
137
|
+
end, { active, enforceFocusTrap, trapped, updateLastFocusedInside })
|
|
138
|
+
React.useEffect(function()
|
|
139
|
+
if not active then
|
|
140
|
+
restoreSnapshotRef.current = nil
|
|
141
|
+
return nil
|
|
142
|
+
end
|
|
143
|
+
if shouldRestoreFocusRef.current then
|
|
144
|
+
restoreSnapshotRef.current = captureFocus()
|
|
145
|
+
else
|
|
146
|
+
restoreSnapshotRef.current = nil
|
|
147
|
+
end
|
|
148
|
+
local currentScopeId = scopeIdRef.current
|
|
149
|
+
if trapped then
|
|
150
|
+
registerTrappedScope(currentScopeId)
|
|
151
|
+
end
|
|
152
|
+
local selectedObjectConnection = GuiService:GetPropertyChangedSignal("SelectedObject"):Connect(function()
|
|
153
|
+
if isRedirectingRef.current then
|
|
154
|
+
return nil
|
|
155
|
+
end
|
|
156
|
+
updateLastFocusedInside()
|
|
157
|
+
if trapped then
|
|
158
|
+
enforceFocusTrap()
|
|
159
|
+
end
|
|
160
|
+
end)
|
|
161
|
+
if trapped then
|
|
162
|
+
enforceFocusTrap()
|
|
163
|
+
end
|
|
164
|
+
return function()
|
|
165
|
+
selectedObjectConnection:Disconnect()
|
|
166
|
+
if trapped then
|
|
167
|
+
unregisterTrappedScope(currentScopeId)
|
|
168
|
+
end
|
|
169
|
+
local restoreTarget = getFocusableRestoreTarget(restoreSnapshotRef.current)
|
|
170
|
+
restoreSnapshotRef.current = nil
|
|
171
|
+
if restoreTarget and shouldRestoreFocusRef.current then
|
|
172
|
+
restoreFocus(restoreTarget)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end, { active, enforceFocusTrap, trapped, updateLastFocusedInside })
|
|
176
|
+
if props.asChild then
|
|
177
|
+
local child = props.children
|
|
178
|
+
if not React.isValidElement(child) then
|
|
179
|
+
error("[FocusScope] `asChild` requires a child element.")
|
|
180
|
+
end
|
|
181
|
+
return React.createElement(Slot, {
|
|
182
|
+
ref = setScopeRoot,
|
|
183
|
+
}, child)
|
|
184
|
+
end
|
|
185
|
+
return React.createElement("frame", {
|
|
186
|
+
BackgroundTransparency = 1,
|
|
187
|
+
BorderSizePixel = 0,
|
|
188
|
+
Position = UDim2.fromScale(0, 0),
|
|
189
|
+
Size = UDim2.fromScale(1, 1),
|
|
190
|
+
ref = setScopeRoot,
|
|
191
|
+
}, props.children)
|
|
4
192
|
end
|
|
5
193
|
return {
|
|
6
194
|
FocusScope = FocusScope,
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local trappedScopeStack = {}
|
|
3
|
+
local function findScopeIndex(scopeId)
|
|
4
|
+
-- ▼ ReadonlyArray.findIndex ▼
|
|
5
|
+
local _callback = function(entry)
|
|
6
|
+
return entry == scopeId
|
|
7
|
+
end
|
|
8
|
+
local _result = -1
|
|
9
|
+
for _i, _v in trappedScopeStack do
|
|
10
|
+
if _callback(_v, _i - 1, trappedScopeStack) == true then
|
|
11
|
+
_result = _i - 1
|
|
12
|
+
break
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
-- ▲ ReadonlyArray.findIndex ▲
|
|
16
|
+
return _result
|
|
17
|
+
end
|
|
18
|
+
local function registerTrappedScope(scopeId)
|
|
19
|
+
if findScopeIndex(scopeId) >= 0 then
|
|
20
|
+
return nil
|
|
21
|
+
end
|
|
22
|
+
local _scopeId = scopeId
|
|
23
|
+
table.insert(trappedScopeStack, _scopeId)
|
|
24
|
+
end
|
|
25
|
+
local function unregisterTrappedScope(scopeId)
|
|
26
|
+
local scopeIndex = findScopeIndex(scopeId)
|
|
27
|
+
if scopeIndex < 0 then
|
|
28
|
+
return nil
|
|
29
|
+
end
|
|
30
|
+
table.remove(trappedScopeStack, scopeIndex + 1)
|
|
31
|
+
end
|
|
32
|
+
local function isTopTrappedScope(scopeId)
|
|
33
|
+
if #trappedScopeStack <= 0 then
|
|
34
|
+
return false
|
|
35
|
+
end
|
|
36
|
+
local topScopeId = trappedScopeStack[#trappedScopeStack]
|
|
37
|
+
return topScopeId == scopeId
|
|
38
|
+
end
|
|
39
|
+
return {
|
|
40
|
+
registerTrappedScope = registerTrappedScope,
|
|
41
|
+
unregisterTrappedScope = unregisterTrappedScope,
|
|
42
|
+
isTopTrappedScope = isTopTrappedScope,
|
|
43
|
+
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lattice-ui/focus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "out/init.luau",
|
|
6
6
|
"types": "out/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"out",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
7
11
|
"dependencies": {
|
|
8
|
-
"@lattice-ui/core": "0.
|
|
12
|
+
"@lattice-ui/core": "0.3.1"
|
|
9
13
|
},
|
|
10
14
|
"devDependencies": {
|
|
11
15
|
"@rbxts/react": "17.3.7-ts.1",
|
|
@@ -17,7 +21,7 @@
|
|
|
17
21
|
},
|
|
18
22
|
"scripts": {
|
|
19
23
|
"build": "rbxtsc -p tsconfig.json",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
24
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
25
|
+
"watch": "rbxtsc -p tsconfig.json -w"
|
|
22
26
|
}
|
|
23
27
|
}
|
|
@@ -1,11 +0,0 @@
|
|
|
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
|
-
}
|
package/src/FocusScope/types.ts
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,71 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,6 +0,0 @@
|
|
|
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 };
|
|
@@ -1,62 +0,0 @@
|
|
|
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
|
-
}
|
package/src/RovingFocus/types.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,11 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
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
|
-
}
|
package/tsconfig.typecheck.json
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
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
|
-
}
|