@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 +15 -0
- package/out/FocusScope/FocusScope.d.ts +3 -0
- package/out/FocusScope/FocusScope.luau +7 -0
- package/out/FocusScope/focusManager.d.ts +4 -0
- package/out/FocusScope/focusManager.luau +13 -0
- package/out/FocusScope/types.d.ts +6 -0
- package/out/FocusScope/types.luau +2 -0
- package/out/RovingFocus/RovingFocusGroup.d.ts +3 -0
- package/out/RovingFocus/RovingFocusGroup.luau +196 -0
- package/out/RovingFocus/RovingFocusItem.d.ts +3 -0
- package/out/RovingFocus/RovingFocusItem.luau +66 -0
- package/out/RovingFocus/context.d.ts +3 -0
- package/out/RovingFocus/context.luau +10 -0
- package/out/RovingFocus/roving.d.ts +4 -0
- package/out/RovingFocus/roving.luau +85 -0
- package/out/RovingFocus/types.d.ts +25 -0
- package/out/RovingFocus/types.luau +2 -0
- package/out/index.d.ts +3 -0
- package/out/init.luau +13 -0
- package/out/internals/guiSelection.d.ts +3 -0
- package/out/internals/guiSelection.luau +15 -0
- package/package.json +23 -0
- package/src/FocusScope/FocusScope.tsx +6 -0
- package/src/FocusScope/focusManager.ts +11 -0
- package/src/FocusScope/types.ts +7 -0
- package/src/RovingFocus/RovingFocusGroup.tsx +174 -0
- package/src/RovingFocus/RovingFocusItem.tsx +71 -0
- package/src/RovingFocus/context.ts +6 -0
- package/src/RovingFocus/roving.ts +62 -0
- package/src/RovingFocus/types.ts +30 -0
- package/src/index.ts +3 -0
- package/src/internals/guiSelection.ts +11 -0
- package/tsconfig.json +16 -0
- package/tsconfig.typecheck.json +25 -0
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,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,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,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,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
|
+
};
|
package/out/index.d.ts
ADDED
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,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,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,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,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
|
+
}
|