@lattice-ui/tabs 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,11 @@
1
+ # @lattice-ui/tabs
2
+
3
+ Behavior-only Tabs primitives for Roblox UI, aligned with the lattice compound pattern.
4
+
5
+ ## Exports
6
+
7
+ - `Tabs` (`TabsRoot` alias)
8
+ - `TabsRoot`
9
+ - `TabsList`
10
+ - `TabsTrigger`
11
+ - `TabsContent`
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { TabsContentProps } from "./types";
3
+ export declare function TabsContent(props: TabsContentProps): React.JSX.Element | undefined;
@@ -0,0 +1,56 @@
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 Presence = TS.import(script, TS.getModule(script, "@lattice-ui", "layer").out).Presence
7
+ local useTabsContext = TS.import(script, script.Parent, "context").useTabsContext
8
+ local createTabsContentName = TS.import(script, script.Parent, "internals", "ids").createTabsContentName
9
+ local function TabsContentImpl(props)
10
+ local contentName = createTabsContentName(props.value)
11
+ if props.asChild then
12
+ local child = props.children
13
+ if not React.isValidElement(child) then
14
+ error("[TabsContent] `asChild` requires a child element.")
15
+ end
16
+ return React.createElement(Slot, {
17
+ Name = contentName,
18
+ Visible = props.visible,
19
+ }, child)
20
+ end
21
+ return React.createElement("frame", {
22
+ BackgroundTransparency = 1,
23
+ BorderSizePixel = 0,
24
+ Size = UDim2.fromOffset(0, 0),
25
+ Visible = props.visible,
26
+ }, props.children)
27
+ end
28
+ local function TabsContent(props)
29
+ local tabsContext = useTabsContext()
30
+ local selected = tabsContext.value == props.value
31
+ local forceMount = props.forceMount == true
32
+ if not selected and not forceMount then
33
+ return nil
34
+ end
35
+ if forceMount then
36
+ return React.createElement(TabsContentImpl, {
37
+ asChild = props.asChild,
38
+ value = props.value,
39
+ visible = selected,
40
+ }, props.children)
41
+ end
42
+ return React.createElement(Presence, {
43
+ exitFallbackMs = 0,
44
+ present = selected,
45
+ render = function(state)
46
+ return React.createElement(TabsContentImpl, {
47
+ asChild = props.asChild,
48
+ value = props.value,
49
+ visible = state.isPresent,
50
+ }, props.children)
51
+ end,
52
+ })
53
+ end
54
+ return {
55
+ TabsContent = TabsContent,
56
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { TabsListProps } from "./types";
3
+ export declare function TabsList(props: TabsListProps): React.JSX.Element;
@@ -0,0 +1,30 @@
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 RovingFocusGroup = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out).RovingFocusGroup
7
+ local useTabsContext = TS.import(script, script.Parent, "context").useTabsContext
8
+ local function TabsList(props)
9
+ local tabsContext = useTabsContext()
10
+ local listNode = if props.asChild then ((function()
11
+ local child = props.children
12
+ if not React.isValidElement(child) then
13
+ error("[TabsList] `asChild` requires a child element.")
14
+ end
15
+ return React.createElement(Slot, nil, child)
16
+ end)()) else (React.createElement("frame", {
17
+ BackgroundTransparency = 1,
18
+ BorderSizePixel = 0,
19
+ Size = UDim2.fromOffset(0, 0),
20
+ }, props.children))
21
+ return React.createElement(RovingFocusGroup, {
22
+ active = true,
23
+ autoFocus = "none",
24
+ loop = true,
25
+ orientation = tabsContext.orientation,
26
+ }, listNode)
27
+ end
28
+ return {
29
+ TabsList = TabsList,
30
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { TabsProps } from "./types";
3
+ export declare function TabsRoot(props: TabsProps): React.JSX.Element;
@@ -0,0 +1,212 @@
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 useControllableState = _core.useControllableState
6
+ local TabsContextProvider = TS.import(script, script.Parent, "context").TabsContextProvider
7
+ local function getOrderedTriggers(triggers)
8
+ local _array = {}
9
+ local _length = #_array
10
+ table.move(triggers, 1, #triggers, _length + 1, _array)
11
+ local ordered = _array
12
+ table.sort(ordered, function(a, b)
13
+ return a.order < b.order
14
+ end)
15
+ return ordered
16
+ end
17
+ local function resolveNextValue(currentValue, orderedTriggers, fallbackOrder)
18
+ -- ▼ ReadonlyArray.filter ▼
19
+ local _newValue = {}
20
+ local _callback = function(trigger)
21
+ return not trigger.disabled
22
+ end
23
+ local _length = 0
24
+ for _k, _v in orderedTriggers do
25
+ if _callback(_v, _k - 1, orderedTriggers) == true then
26
+ _length += 1
27
+ _newValue[_length] = _v
28
+ end
29
+ end
30
+ -- ▲ ReadonlyArray.filter ▲
31
+ local enabled = _newValue
32
+ if #enabled == 0 then
33
+ return nil
34
+ end
35
+ if currentValue == nil then
36
+ local _result = enabled[1]
37
+ if _result ~= nil then
38
+ _result = _result.value
39
+ end
40
+ return _result
41
+ end
42
+ -- ▼ ReadonlyArray.find ▼
43
+ local _callback_1 = function(trigger)
44
+ return trigger.value == currentValue
45
+ end
46
+ local _result
47
+ for _i, _v in enabled do
48
+ if _callback_1(_v, _i - 1, enabled) == true then
49
+ _result = _v
50
+ break
51
+ end
52
+ end
53
+ -- ▲ ReadonlyArray.find ▲
54
+ local selectedEnabled = _result
55
+ if selectedEnabled then
56
+ return selectedEnabled.value
57
+ end
58
+ -- ▼ ReadonlyArray.find ▼
59
+ local _callback_2 = function(trigger)
60
+ return trigger.value == currentValue
61
+ end
62
+ local _result_1
63
+ for _i, _v in orderedTriggers do
64
+ if _callback_2(_v, _i - 1, orderedTriggers) == true then
65
+ _result_1 = _v
66
+ break
67
+ end
68
+ end
69
+ -- ▲ ReadonlyArray.find ▲
70
+ local selected = _result_1
71
+ local _result_2 = selected
72
+ if _result_2 ~= nil then
73
+ _result_2 = _result_2.order
74
+ end
75
+ local _condition = _result_2
76
+ if _condition == nil then
77
+ _condition = fallbackOrder
78
+ end
79
+ local anchorOrder = _condition
80
+ if anchorOrder ~= nil then
81
+ -- ▼ ReadonlyArray.find ▼
82
+ local _callback_3 = function(trigger)
83
+ return trigger.order > anchorOrder
84
+ end
85
+ local _result_3
86
+ for _i, _v in enabled do
87
+ if _callback_3(_v, _i - 1, enabled) == true then
88
+ _result_3 = _v
89
+ break
90
+ end
91
+ end
92
+ -- ▲ ReadonlyArray.find ▲
93
+ local after = _result_3
94
+ if after then
95
+ return after.value
96
+ end
97
+ end
98
+ local _result_3 = enabled[1]
99
+ if _result_3 ~= nil then
100
+ _result_3 = _result_3.value
101
+ end
102
+ return _result_3
103
+ end
104
+ local function TabsRoot(props)
105
+ local _binding = useControllableState({
106
+ value = props.value,
107
+ defaultValue = props.defaultValue,
108
+ onChange = function(nextValue)
109
+ if nextValue ~= nil then
110
+ local _result = props.onValueChange
111
+ if _result ~= nil then
112
+ _result(nextValue)
113
+ end
114
+ end
115
+ end,
116
+ })
117
+ local value = _binding[1]
118
+ local setValueState = _binding[2]
119
+ local orientation = props.orientation or "horizontal"
120
+ local activationMode = props.activationMode or "automatic"
121
+ local triggerRegistryRef = React.useRef({})
122
+ local lastSelectedOrderRef = React.useRef()
123
+ local registryRevision, setRegistryRevision = React.useState(0)
124
+ local registerTrigger = React.useCallback(function(trigger)
125
+ local _current = triggerRegistryRef.current
126
+ local _trigger = trigger
127
+ table.insert(_current, _trigger)
128
+ setRegistryRevision(function(revision)
129
+ return revision + 1
130
+ end)
131
+ return function()
132
+ local _exp = triggerRegistryRef.current
133
+ -- ▼ ReadonlyArray.findIndex ▼
134
+ local _callback = function(entry)
135
+ return entry.id == trigger.id
136
+ end
137
+ local _result = -1
138
+ for _i, _v in _exp do
139
+ if _callback(_v, _i - 1, _exp) == true then
140
+ _result = _i - 1
141
+ break
142
+ end
143
+ end
144
+ -- ▲ ReadonlyArray.findIndex ▲
145
+ local index = _result
146
+ if index >= 0 then
147
+ table.remove(triggerRegistryRef.current, index + 1)
148
+ setRegistryRevision(function(revision)
149
+ return revision + 1
150
+ end)
151
+ end
152
+ end
153
+ end, {})
154
+ local setValue = React.useCallback(function(nextValue)
155
+ local orderedTriggers = getOrderedTriggers(triggerRegistryRef.current)
156
+ -- ▼ ReadonlyArray.find ▼
157
+ local _callback = function(trigger)
158
+ return trigger.value == nextValue and not trigger.disabled
159
+ end
160
+ local _result
161
+ for _i, _v in orderedTriggers do
162
+ if _callback(_v, _i - 1, orderedTriggers) == true then
163
+ _result = _v
164
+ break
165
+ end
166
+ end
167
+ -- ▲ ReadonlyArray.find ▲
168
+ local selected = _result
169
+ if selected then
170
+ lastSelectedOrderRef.current = selected.order
171
+ end
172
+ setValueState(nextValue)
173
+ end, { setValueState })
174
+ React.useEffect(function()
175
+ local orderedTriggers = getOrderedTriggers(triggerRegistryRef.current)
176
+ -- ▼ ReadonlyArray.find ▼
177
+ local _callback = function(trigger)
178
+ return trigger.value == value and not trigger.disabled
179
+ end
180
+ local _result
181
+ for _i, _v in orderedTriggers do
182
+ if _callback(_v, _i - 1, orderedTriggers) == true then
183
+ _result = _v
184
+ break
185
+ end
186
+ end
187
+ -- ▲ ReadonlyArray.find ▲
188
+ local selected = _result
189
+ if selected then
190
+ lastSelectedOrderRef.current = selected.order
191
+ end
192
+ local nextValue = resolveNextValue(value, orderedTriggers, lastSelectedOrderRef.current)
193
+ if nextValue ~= value then
194
+ setValueState(nextValue)
195
+ end
196
+ end, { registryRevision, setValueState, value })
197
+ local contextValue = React.useMemo(function()
198
+ return {
199
+ value = value,
200
+ setValue = setValue,
201
+ orientation = orientation,
202
+ activationMode = activationMode,
203
+ registerTrigger = registerTrigger,
204
+ }
205
+ end, { activationMode, orientation, registerTrigger, setValue, value })
206
+ return React.createElement(TabsContextProvider, {
207
+ value = contextValue,
208
+ }, props.children)
209
+ end
210
+ return {
211
+ TabsRoot = TabsRoot,
212
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { TabsTriggerProps } from "./types";
3
+ export declare function TabsTrigger(props: TabsTriggerProps): React.JSX.Element;
@@ -0,0 +1,109 @@
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 RovingFocusItem = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out).RovingFocusItem
7
+ local useTabsContext = TS.import(script, script.Parent, "context").useTabsContext
8
+ local createTabsTriggerName = TS.import(script, script.Parent, "internals", "ids").createTabsTriggerName
9
+ local nextTriggerId = 0
10
+ local nextTriggerOrder = 0
11
+ local function toGuiObject(instance)
12
+ if not instance or not instance:IsA("GuiObject") then
13
+ return nil
14
+ end
15
+ return instance
16
+ end
17
+ local function TabsTrigger(props)
18
+ local tabsContext = useTabsContext()
19
+ local triggerRef = React.useRef()
20
+ local selected = tabsContext.value == props.value
21
+ local disabled = props.disabled == true
22
+ local triggerIdRef = React.useRef(0)
23
+ if triggerIdRef.current == 0 then
24
+ nextTriggerId += 1
25
+ triggerIdRef.current = nextTriggerId
26
+ end
27
+ local triggerOrderRef = React.useRef(0)
28
+ if triggerOrderRef.current == 0 then
29
+ nextTriggerOrder += 1
30
+ triggerOrderRef.current = nextTriggerOrder
31
+ end
32
+ React.useEffect(function()
33
+ return tabsContext.registerTrigger({
34
+ id = triggerIdRef.current,
35
+ value = props.value,
36
+ disabled = disabled,
37
+ ref = triggerRef,
38
+ order = triggerOrderRef.current,
39
+ })
40
+ end, { disabled, props.value, tabsContext })
41
+ local setTriggerRef = React.useCallback(function(instance)
42
+ triggerRef.current = toGuiObject(instance)
43
+ end, {})
44
+ local handleActivated = React.useCallback(function()
45
+ if disabled then
46
+ return nil
47
+ end
48
+ tabsContext.setValue(props.value)
49
+ end, { disabled, props.value, tabsContext })
50
+ local handleSelectionGained = React.useCallback(function()
51
+ if disabled or tabsContext.activationMode ~= "automatic" then
52
+ return nil
53
+ end
54
+ tabsContext.setValue(props.value)
55
+ end, { disabled, props.value, tabsContext })
56
+ local handleInputBegan = React.useCallback(function(_rbx, inputObject)
57
+ if disabled or tabsContext.activationMode ~= "manual" then
58
+ return nil
59
+ end
60
+ local keyCode = inputObject.KeyCode
61
+ if keyCode ~= Enum.KeyCode.Return and keyCode ~= Enum.KeyCode.Space then
62
+ return nil
63
+ end
64
+ tabsContext.setValue(props.value)
65
+ end, { disabled, props.value, tabsContext })
66
+ local eventHandlers = React.useMemo(function()
67
+ return {
68
+ Activated = handleActivated,
69
+ SelectionGained = handleSelectionGained,
70
+ InputBegan = handleInputBegan,
71
+ }
72
+ end, { handleActivated, handleInputBegan, handleSelectionGained })
73
+ local triggerName = React.useMemo(function()
74
+ return createTabsTriggerName(props.value)
75
+ end, { props.value })
76
+ if props.asChild then
77
+ local child = props.children
78
+ if not child then
79
+ error("[TabsTrigger] `asChild` requires a child element.")
80
+ end
81
+ return React.createElement(RovingFocusItem, {
82
+ asChild = true,
83
+ disabled = disabled,
84
+ }, React.createElement(Slot, {
85
+ Event = eventHandlers,
86
+ Name = triggerName,
87
+ ref = setTriggerRef,
88
+ }, child))
89
+ end
90
+ return React.createElement(RovingFocusItem, {
91
+ asChild = true,
92
+ disabled = disabled,
93
+ }, React.createElement("textbutton", {
94
+ Active = not disabled,
95
+ AutoButtonColor = false,
96
+ BackgroundColor3 = if selected then Color3.fromRGB(86, 137, 245) else Color3.fromRGB(47, 53, 68),
97
+ BorderSizePixel = 0,
98
+ Event = eventHandlers,
99
+ Selectable = not disabled,
100
+ Size = UDim2.fromOffset(132, 34),
101
+ Text = props.value,
102
+ TextColor3 = if disabled then Color3.fromRGB(136, 144, 159) else Color3.fromRGB(235, 240, 248),
103
+ TextSize = 15,
104
+ ref = setTriggerRef,
105
+ }, props.children))
106
+ end
107
+ return {
108
+ TabsTrigger = TabsTrigger,
109
+ }
@@ -0,0 +1,3 @@
1
+ import type { TabsContextValue } from "./types";
2
+ declare const TabsContextProvider: import("@rbxts/react").Provider<TabsContextValue | undefined>, useTabsContext: () => TabsContextValue;
3
+ export { TabsContextProvider, useTabsContext };
@@ -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("Tabs")
5
+ local TabsContextProvider = _binding[1]
6
+ local useTabsContext = _binding[2]
7
+ return {
8
+ TabsContextProvider = TabsContextProvider,
9
+ useTabsContext = useTabsContext,
10
+ }
@@ -0,0 +1,2 @@
1
+ export declare function createTabsTriggerName(value: string): string;
2
+ export declare function createTabsContentName(value: string): string;
@@ -0,0 +1,16 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local function sanitizeValue(value)
3
+ local lowered = string.lower(value)
4
+ local sanitized = (string.gsub(lowered, "[^%w_%-]", "-"))
5
+ return if #sanitized > 0 then sanitized else "tab"
6
+ end
7
+ local function createTabsTriggerName(value)
8
+ return `TabsTrigger:{sanitizeValue(value)}`
9
+ end
10
+ local function createTabsContentName(value)
11
+ return `TabsContent:{sanitizeValue(value)}`
12
+ end
13
+ return {
14
+ createTabsTriggerName = createTabsTriggerName,
15
+ createTabsContentName = createTabsContentName,
16
+ }
@@ -0,0 +1,42 @@
1
+ import type React from "@rbxts/react";
2
+ export type TabsOrientation = "horizontal" | "vertical";
3
+ export type TabsActivationMode = "automatic" | "manual";
4
+ export type TabsSetValue = (value: string) => void;
5
+ export type TabsTriggerRegistration = {
6
+ id: number;
7
+ value: string;
8
+ disabled: boolean;
9
+ ref: React.MutableRefObject<GuiObject | undefined>;
10
+ order: number;
11
+ };
12
+ export type TabsContextValue = {
13
+ value?: string;
14
+ setValue: TabsSetValue;
15
+ orientation: TabsOrientation;
16
+ activationMode: TabsActivationMode;
17
+ registerTrigger: (trigger: TabsTriggerRegistration) => () => void;
18
+ };
19
+ export type TabsProps = {
20
+ value?: string;
21
+ defaultValue?: string;
22
+ onValueChange?: (value: string) => void;
23
+ orientation?: TabsOrientation;
24
+ activationMode?: TabsActivationMode;
25
+ children?: React.ReactNode;
26
+ };
27
+ export type TabsListProps = {
28
+ asChild?: boolean;
29
+ children?: React.ReactNode;
30
+ };
31
+ export type TabsTriggerProps = {
32
+ value: string;
33
+ asChild?: boolean;
34
+ disabled?: boolean;
35
+ children?: React.ReactElement;
36
+ };
37
+ export type TabsContentProps = {
38
+ value: string;
39
+ asChild?: boolean;
40
+ forceMount?: boolean;
41
+ children?: React.ReactNode;
42
+ };
@@ -0,0 +1,2 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ return nil
package/out/index.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { TabsContent } from "./Tabs/TabsContent";
2
+ export { TabsList } from "./Tabs/TabsList";
3
+ export { TabsRoot, TabsRoot as Tabs } from "./Tabs/TabsRoot";
4
+ export { TabsTrigger } from "./Tabs/TabsTrigger";
5
+ export type { TabsActivationMode, TabsContentProps, TabsContextValue, TabsListProps, TabsOrientation, TabsProps, TabsTriggerProps, } from "./Tabs/types";
package/out/init.luau ADDED
@@ -0,0 +1,10 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local exports = {}
4
+ exports.TabsContent = TS.import(script, script, "Tabs", "TabsContent").TabsContent
5
+ exports.TabsList = TS.import(script, script, "Tabs", "TabsList").TabsList
6
+ local _TabsRoot = TS.import(script, script, "Tabs", "TabsRoot")
7
+ exports.TabsRoot = _TabsRoot.TabsRoot
8
+ exports.Tabs = _TabsRoot.TabsRoot
9
+ exports.TabsTrigger = TS.import(script, script, "Tabs", "TabsTrigger").TabsTrigger
10
+ return exports
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@lattice-ui/tabs",
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
+ "@lattice-ui/focus": "0.1.1",
10
+ "@lattice-ui/layer": "0.1.1"
11
+ },
12
+ "devDependencies": {
13
+ "@rbxts/react": "17.3.7-ts.1",
14
+ "@rbxts/react-roblox": "17.3.7-ts.1"
15
+ },
16
+ "peerDependencies": {
17
+ "@rbxts/react": "^17",
18
+ "@rbxts/react-roblox": "^17"
19
+ },
20
+ "scripts": {
21
+ "build": "rbxtsc -p tsconfig.json",
22
+ "watch": "rbxtsc -p tsconfig.json -w",
23
+ "typecheck": "tsc -p tsconfig.typecheck.json"
24
+ }
25
+ }
@@ -0,0 +1,65 @@
1
+ import { React, Slot } from "@lattice-ui/core";
2
+ import { Presence } from "@lattice-ui/layer";
3
+ import { useTabsContext } from "./context";
4
+ import { createTabsContentName } from "./internals/ids";
5
+ import type { TabsContentProps } from "./types";
6
+
7
+ type TabsContentImplProps = {
8
+ visible: boolean;
9
+ value: string;
10
+ asChild?: boolean;
11
+ children?: React.ReactNode;
12
+ };
13
+
14
+ function TabsContentImpl(props: TabsContentImplProps) {
15
+ const contentName = createTabsContentName(props.value);
16
+
17
+ if (props.asChild) {
18
+ const child = props.children;
19
+ if (!React.isValidElement(child)) {
20
+ error("[TabsContent] `asChild` requires a child element.");
21
+ }
22
+
23
+ return (
24
+ <Slot Name={contentName} Visible={props.visible}>
25
+ {child}
26
+ </Slot>
27
+ );
28
+ }
29
+
30
+ return (
31
+ <frame BackgroundTransparency={1} BorderSizePixel={0} Size={UDim2.fromOffset(0, 0)} Visible={props.visible}>
32
+ {props.children}
33
+ </frame>
34
+ );
35
+ }
36
+
37
+ export function TabsContent(props: TabsContentProps) {
38
+ const tabsContext = useTabsContext();
39
+ const selected = tabsContext.value === props.value;
40
+ const forceMount = props.forceMount === true;
41
+
42
+ if (!selected && !forceMount) {
43
+ return undefined;
44
+ }
45
+
46
+ if (forceMount) {
47
+ return (
48
+ <TabsContentImpl asChild={props.asChild} value={props.value} visible={selected}>
49
+ {props.children}
50
+ </TabsContentImpl>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <Presence
56
+ exitFallbackMs={0}
57
+ present={selected}
58
+ render={(state) => (
59
+ <TabsContentImpl asChild={props.asChild} value={props.value} visible={state.isPresent}>
60
+ {props.children}
61
+ </TabsContentImpl>
62
+ )}
63
+ />
64
+ );
65
+ }
@@ -0,0 +1,29 @@
1
+ import { React, Slot } from "@lattice-ui/core";
2
+ import { RovingFocusGroup } from "@lattice-ui/focus";
3
+ import { useTabsContext } from "./context";
4
+ import type { TabsListProps } from "./types";
5
+
6
+ export function TabsList(props: TabsListProps) {
7
+ const tabsContext = useTabsContext();
8
+
9
+ const listNode = props.asChild ? (
10
+ (() => {
11
+ const child = props.children;
12
+ if (!React.isValidElement(child)) {
13
+ error("[TabsList] `asChild` requires a child element.");
14
+ }
15
+
16
+ return <Slot>{child}</Slot>;
17
+ })()
18
+ ) : (
19
+ <frame BackgroundTransparency={1} BorderSizePixel={0} Size={UDim2.fromOffset(0, 0)}>
20
+ {props.children}
21
+ </frame>
22
+ );
23
+
24
+ return (
25
+ <RovingFocusGroup active autoFocus="none" loop orientation={tabsContext.orientation}>
26
+ {listNode}
27
+ </RovingFocusGroup>
28
+ );
29
+ }
@@ -0,0 +1,111 @@
1
+ import { React, useControllableState } from "@lattice-ui/core";
2
+ import { TabsContextProvider } from "./context";
3
+ import type { TabsProps, TabsTriggerRegistration } from "./types";
4
+
5
+ function getOrderedTriggers(triggers: Array<TabsTriggerRegistration>) {
6
+ const ordered = [...triggers];
7
+ ordered.sort((a, b) => a.order < b.order);
8
+ return ordered;
9
+ }
10
+
11
+ function resolveNextValue(
12
+ currentValue: string | undefined,
13
+ orderedTriggers: Array<TabsTriggerRegistration>,
14
+ fallbackOrder: number | undefined,
15
+ ) {
16
+ const enabled = orderedTriggers.filter((trigger) => !trigger.disabled);
17
+ if (enabled.size() === 0) {
18
+ return undefined;
19
+ }
20
+
21
+ if (currentValue === undefined) {
22
+ return enabled[0]?.value;
23
+ }
24
+
25
+ const selectedEnabled = enabled.find((trigger) => trigger.value === currentValue);
26
+ if (selectedEnabled) {
27
+ return selectedEnabled.value;
28
+ }
29
+
30
+ const selected = orderedTriggers.find((trigger) => trigger.value === currentValue);
31
+ const anchorOrder = selected?.order ?? fallbackOrder;
32
+ if (anchorOrder !== undefined) {
33
+ const after = enabled.find((trigger) => trigger.order > anchorOrder);
34
+ if (after) {
35
+ return after.value;
36
+ }
37
+ }
38
+
39
+ return enabled[0]?.value;
40
+ }
41
+
42
+ export function TabsRoot(props: TabsProps) {
43
+ const [value, setValueState] = useControllableState<string | undefined>({
44
+ value: props.value,
45
+ defaultValue: props.defaultValue,
46
+ onChange: (nextValue) => {
47
+ if (nextValue !== undefined) {
48
+ props.onValueChange?.(nextValue);
49
+ }
50
+ },
51
+ });
52
+
53
+ const orientation = props.orientation ?? "horizontal";
54
+ const activationMode = props.activationMode ?? "automatic";
55
+
56
+ const triggerRegistryRef = React.useRef<Array<TabsTriggerRegistration>>([]);
57
+ const lastSelectedOrderRef = React.useRef<number>();
58
+ const [registryRevision, setRegistryRevision] = React.useState(0);
59
+
60
+ const registerTrigger = React.useCallback((trigger: TabsTriggerRegistration) => {
61
+ triggerRegistryRef.current.push(trigger);
62
+ setRegistryRevision((revision) => revision + 1);
63
+
64
+ return () => {
65
+ const index = triggerRegistryRef.current.findIndex((entry) => entry.id === trigger.id);
66
+ if (index >= 0) {
67
+ triggerRegistryRef.current.remove(index);
68
+ setRegistryRevision((revision) => revision + 1);
69
+ }
70
+ };
71
+ }, []);
72
+
73
+ const setValue = React.useCallback(
74
+ (nextValue: string) => {
75
+ const orderedTriggers = getOrderedTriggers(triggerRegistryRef.current);
76
+ const selected = orderedTriggers.find((trigger) => trigger.value === nextValue && !trigger.disabled);
77
+ if (selected) {
78
+ lastSelectedOrderRef.current = selected.order;
79
+ }
80
+
81
+ setValueState(nextValue);
82
+ },
83
+ [setValueState],
84
+ );
85
+
86
+ React.useEffect(() => {
87
+ const orderedTriggers = getOrderedTriggers(triggerRegistryRef.current);
88
+ const selected = orderedTriggers.find((trigger) => trigger.value === value && !trigger.disabled);
89
+ if (selected) {
90
+ lastSelectedOrderRef.current = selected.order;
91
+ }
92
+
93
+ const nextValue = resolveNextValue(value, orderedTriggers, lastSelectedOrderRef.current);
94
+ if (nextValue !== value) {
95
+ setValueState(nextValue);
96
+ }
97
+ }, [registryRevision, setValueState, value]);
98
+
99
+ const contextValue = React.useMemo(
100
+ () => ({
101
+ value,
102
+ setValue,
103
+ orientation,
104
+ activationMode,
105
+ registerTrigger,
106
+ }),
107
+ [activationMode, orientation, registerTrigger, setValue, value],
108
+ );
109
+
110
+ return <TabsContextProvider value={contextValue}>{props.children}</TabsContextProvider>;
111
+ }
@@ -0,0 +1,127 @@
1
+ import { React, Slot } from "@lattice-ui/core";
2
+ import { RovingFocusItem } from "@lattice-ui/focus";
3
+ import { useTabsContext } from "./context";
4
+ import { createTabsTriggerName } from "./internals/ids";
5
+ import type { TabsTriggerProps } from "./types";
6
+
7
+ let nextTriggerId = 0;
8
+ let nextTriggerOrder = 0;
9
+
10
+ function toGuiObject(instance: Instance | undefined) {
11
+ if (!instance || !instance.IsA("GuiObject")) {
12
+ return undefined;
13
+ }
14
+
15
+ return instance;
16
+ }
17
+
18
+ export function TabsTrigger(props: TabsTriggerProps) {
19
+ const tabsContext = useTabsContext();
20
+ const triggerRef = React.useRef<GuiObject>();
21
+ const selected = tabsContext.value === props.value;
22
+ const disabled = props.disabled === true;
23
+
24
+ const triggerIdRef = React.useRef(0);
25
+ if (triggerIdRef.current === 0) {
26
+ nextTriggerId += 1;
27
+ triggerIdRef.current = nextTriggerId;
28
+ }
29
+
30
+ const triggerOrderRef = React.useRef(0);
31
+ if (triggerOrderRef.current === 0) {
32
+ nextTriggerOrder += 1;
33
+ triggerOrderRef.current = nextTriggerOrder;
34
+ }
35
+
36
+ React.useEffect(() => {
37
+ return tabsContext.registerTrigger({
38
+ id: triggerIdRef.current,
39
+ value: props.value,
40
+ disabled,
41
+ ref: triggerRef,
42
+ order: triggerOrderRef.current,
43
+ });
44
+ }, [disabled, props.value, tabsContext]);
45
+
46
+ const setTriggerRef = React.useCallback((instance: Instance | undefined) => {
47
+ triggerRef.current = toGuiObject(instance);
48
+ }, []);
49
+
50
+ const handleActivated = React.useCallback(() => {
51
+ if (disabled) {
52
+ return;
53
+ }
54
+
55
+ tabsContext.setValue(props.value);
56
+ }, [disabled, props.value, tabsContext]);
57
+
58
+ const handleSelectionGained = React.useCallback(() => {
59
+ if (disabled || tabsContext.activationMode !== "automatic") {
60
+ return;
61
+ }
62
+
63
+ tabsContext.setValue(props.value);
64
+ }, [disabled, props.value, tabsContext]);
65
+
66
+ const handleInputBegan = React.useCallback(
67
+ (_rbx: TextButton, inputObject: InputObject) => {
68
+ if (disabled || tabsContext.activationMode !== "manual") {
69
+ return;
70
+ }
71
+
72
+ const keyCode = inputObject.KeyCode;
73
+ if (keyCode !== Enum.KeyCode.Return && keyCode !== Enum.KeyCode.Space) {
74
+ return;
75
+ }
76
+
77
+ tabsContext.setValue(props.value);
78
+ },
79
+ [disabled, props.value, tabsContext],
80
+ );
81
+
82
+ const eventHandlers = React.useMemo(
83
+ () => ({
84
+ Activated: handleActivated,
85
+ SelectionGained: handleSelectionGained,
86
+ InputBegan: handleInputBegan,
87
+ }),
88
+ [handleActivated, handleInputBegan, handleSelectionGained],
89
+ );
90
+
91
+ const triggerName = React.useMemo(() => createTabsTriggerName(props.value), [props.value]);
92
+
93
+ if (props.asChild) {
94
+ const child = props.children;
95
+ if (!child) {
96
+ error("[TabsTrigger] `asChild` requires a child element.");
97
+ }
98
+
99
+ return (
100
+ <RovingFocusItem asChild disabled={disabled}>
101
+ <Slot Event={eventHandlers} Name={triggerName} ref={setTriggerRef}>
102
+ {child}
103
+ </Slot>
104
+ </RovingFocusItem>
105
+ );
106
+ }
107
+
108
+ return (
109
+ <RovingFocusItem asChild disabled={disabled}>
110
+ <textbutton
111
+ Active={!disabled}
112
+ AutoButtonColor={false}
113
+ BackgroundColor3={selected ? Color3.fromRGB(86, 137, 245) : Color3.fromRGB(47, 53, 68)}
114
+ BorderSizePixel={0}
115
+ Event={eventHandlers}
116
+ Selectable={!disabled}
117
+ Size={UDim2.fromOffset(132, 34)}
118
+ Text={props.value}
119
+ TextColor3={disabled ? Color3.fromRGB(136, 144, 159) : Color3.fromRGB(235, 240, 248)}
120
+ TextSize={15}
121
+ ref={setTriggerRef}
122
+ >
123
+ {props.children}
124
+ </textbutton>
125
+ </RovingFocusItem>
126
+ );
127
+ }
@@ -0,0 +1,6 @@
1
+ import { createStrictContext } from "@lattice-ui/core";
2
+ import type { TabsContextValue } from "./types";
3
+
4
+ const [TabsContextProvider, useTabsContext] = createStrictContext<TabsContextValue>("Tabs");
5
+
6
+ export { TabsContextProvider, useTabsContext };
@@ -0,0 +1,13 @@
1
+ function sanitizeValue(value: string) {
2
+ const lowered = string.lower(value);
3
+ const sanitized = string.gsub(lowered, "[^%w_%-]", "-")[0];
4
+ return sanitized.size() > 0 ? sanitized : "tab";
5
+ }
6
+
7
+ export function createTabsTriggerName(value: string) {
8
+ return `TabsTrigger:${sanitizeValue(value)}`;
9
+ }
10
+
11
+ export function createTabsContentName(value: string) {
12
+ return `TabsContent:${sanitizeValue(value)}`;
13
+ }
@@ -0,0 +1,50 @@
1
+ import type React from "@rbxts/react";
2
+
3
+ export type TabsOrientation = "horizontal" | "vertical";
4
+ export type TabsActivationMode = "automatic" | "manual";
5
+
6
+ export type TabsSetValue = (value: string) => void;
7
+
8
+ export type TabsTriggerRegistration = {
9
+ id: number;
10
+ value: string;
11
+ disabled: boolean;
12
+ ref: React.MutableRefObject<GuiObject | undefined>;
13
+ order: number;
14
+ };
15
+
16
+ export type TabsContextValue = {
17
+ value?: string;
18
+ setValue: TabsSetValue;
19
+ orientation: TabsOrientation;
20
+ activationMode: TabsActivationMode;
21
+ registerTrigger: (trigger: TabsTriggerRegistration) => () => void;
22
+ };
23
+
24
+ export type TabsProps = {
25
+ value?: string;
26
+ defaultValue?: string;
27
+ onValueChange?: (value: string) => void;
28
+ orientation?: TabsOrientation;
29
+ activationMode?: TabsActivationMode;
30
+ children?: React.ReactNode;
31
+ };
32
+
33
+ export type TabsListProps = {
34
+ asChild?: boolean;
35
+ children?: React.ReactNode;
36
+ };
37
+
38
+ export type TabsTriggerProps = {
39
+ value: string;
40
+ asChild?: boolean;
41
+ disabled?: boolean;
42
+ children?: React.ReactElement;
43
+ };
44
+
45
+ export type TabsContentProps = {
46
+ value: string;
47
+ asChild?: boolean;
48
+ forceMount?: boolean;
49
+ children?: React.ReactNode;
50
+ };
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { TabsContent } from "./Tabs/TabsContent";
2
+ export { TabsList } from "./Tabs/TabsList";
3
+ export { TabsRoot, TabsRoot as Tabs } from "./Tabs/TabsRoot";
4
+ export { TabsTrigger } from "./Tabs/TabsTrigger";
5
+ export type {
6
+ TabsActivationMode,
7
+ TabsContentProps,
8
+ TabsContextValue,
9
+ TabsListProps,
10
+ TabsOrientation,
11
+ TabsProps,
12
+ TabsTriggerProps,
13
+ } from "./Tabs/types";
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
+ }