@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 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
- This package is intentionally a skeleton in the current phase.
3
+ Focus and directional navigation primitives for Roblox UI.
4
4
 
5
5
  ## Current status
6
6
 
7
- - `FocusScope` and `RovingFocusGroup` are no-op placeholders.
8
- - Public API is kept stable while layer/popper hardening lands first.
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
- ## Next implementation targets
10
+ ## FocusScope behavior
11
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.
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 type React from "@rbxts/react";
1
+ import { React } from "@lattice-ui/core";
2
2
  import type { FocusScopeProps } from "./types";
3
- export declare function FocusScope(props: FocusScopeProps): React.ReactNode;
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
- return props.children
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,3 @@
1
+ export declare function registerTrappedScope(scopeId: number): void;
2
+ export declare function unregisterTrappedScope(scopeId: number): void;
3
+ export declare function isTopTrappedScope(scopeId: number): boolean;
@@ -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
+ }
@@ -1,5 +1,7 @@
1
1
  import type React from "@rbxts/react";
2
2
  export type FocusScopeProps = {
3
+ active?: boolean;
4
+ asChild?: boolean;
3
5
  trapped?: boolean;
4
6
  restoreFocus?: boolean;
5
7
  children?: React.ReactNode;
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@lattice-ui/focus",
3
- "version": "0.1.1",
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.1.1"
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
- "watch": "rbxtsc -p tsconfig.json -w",
21
- "typecheck": "tsc -p tsconfig.typecheck.json"
24
+ "typecheck": "tsc -p tsconfig.typecheck.json",
25
+ "watch": "rbxtsc -p tsconfig.json -w"
22
26
  }
23
27
  }
@@ -1,6 +0,0 @@
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
- }
@@ -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
- }
@@ -1,7 +0,0 @@
1
- import type React from "@rbxts/react";
2
-
3
- export type FocusScopeProps = {
4
- trapped?: boolean;
5
- restoreFocus?: boolean;
6
- children?: React.ReactNode;
7
- };
@@ -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
- }
@@ -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,3 +0,0 @@
1
- export * from "./FocusScope/FocusScope";
2
- export * from "./RovingFocus/RovingFocusGroup";
3
- export * from "./RovingFocus/RovingFocusItem";
@@ -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
- }
@@ -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
- }