@lattice-ui/tabs 0.4.3 → 0.5.0-next.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/default.project.json +9 -0
- package/out/Tabs/TabsContent.d.ts +1 -1
- package/out/Tabs/TabsContent.luau +21 -5
- package/out/Tabs/TabsRoot.luau +5 -4
- package/out/Tabs/TabsTrigger.luau +19 -4
- package/out/Tabs/types.d.ts +2 -0
- package/package.json +14 -3
- package/src/Tabs/TabsContent.tsx +89 -0
- package/src/Tabs/TabsList.tsx +19 -0
- package/src/Tabs/TabsRoot.tsx +124 -0
- package/src/Tabs/TabsTrigger.tsx +171 -0
- package/src/Tabs/context.ts +6 -0
- package/src/Tabs/internals/ids.ts +13 -0
- package/src/Tabs/types.ts +49 -0
- package/src/index.ts +20 -0
|
@@ -4,10 +4,23 @@ local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
|
|
|
4
4
|
local React = _core.React
|
|
5
5
|
local Slot = _core.Slot
|
|
6
6
|
local Presence = TS.import(script, TS.getModule(script, "@lattice-ui", "layer").out).Presence
|
|
7
|
+
local _motion = TS.import(script, TS.getModule(script, "@lattice-ui", "motion").out)
|
|
8
|
+
local createSurfaceRevealRecipe = _motion.createSurfaceRevealRecipe
|
|
9
|
+
local usePresenceMotion = _motion.usePresenceMotion
|
|
7
10
|
local useTabsContext = TS.import(script, script.Parent, "context").useTabsContext
|
|
8
11
|
local createTabsContentName = TS.import(script, script.Parent, "internals", "ids").createTabsContentName
|
|
9
12
|
local function TabsContentImpl(props)
|
|
10
13
|
local contentName = createTabsContentName(props.value)
|
|
14
|
+
local defaultTransition = React.useMemo(function()
|
|
15
|
+
return createSurfaceRevealRecipe()
|
|
16
|
+
end, {})
|
|
17
|
+
local config = React.useMemo(function()
|
|
18
|
+
if not props.transition then
|
|
19
|
+
return defaultTransition
|
|
20
|
+
end
|
|
21
|
+
return props.transition
|
|
22
|
+
end, { defaultTransition, props.transition })
|
|
23
|
+
local motionRef = usePresenceMotion(props.motionPresent, config, props.onExitComplete)
|
|
11
24
|
if props.asChild then
|
|
12
25
|
local child = props.children
|
|
13
26
|
if not React.isValidElement(child) then
|
|
@@ -16,6 +29,7 @@ local function TabsContentImpl(props)
|
|
|
16
29
|
return React.createElement(Slot, {
|
|
17
30
|
Name = contentName,
|
|
18
31
|
Visible = props.visible,
|
|
32
|
+
ref = motionRef,
|
|
19
33
|
}, child)
|
|
20
34
|
end
|
|
21
35
|
return React.createElement("frame", {
|
|
@@ -23,30 +37,32 @@ local function TabsContentImpl(props)
|
|
|
23
37
|
BorderSizePixel = 0,
|
|
24
38
|
Size = UDim2.fromOffset(0, 0),
|
|
25
39
|
Visible = props.visible,
|
|
40
|
+
ref = motionRef,
|
|
26
41
|
}, props.children)
|
|
27
42
|
end
|
|
28
43
|
local function TabsContent(props)
|
|
29
44
|
local tabsContext = useTabsContext()
|
|
30
45
|
local selected = tabsContext.value == props.value
|
|
31
46
|
local forceMount = props.forceMount == true
|
|
32
|
-
if not selected and not forceMount then
|
|
33
|
-
return nil
|
|
34
|
-
end
|
|
35
47
|
if forceMount then
|
|
36
48
|
return React.createElement(TabsContentImpl, {
|
|
37
49
|
asChild = props.asChild,
|
|
50
|
+
motionPresent = selected,
|
|
51
|
+
transition = props.transition,
|
|
38
52
|
value = props.value,
|
|
39
53
|
visible = selected,
|
|
40
54
|
}, props.children)
|
|
41
55
|
end
|
|
42
56
|
return React.createElement(Presence, {
|
|
43
|
-
exitFallbackMs = 0,
|
|
44
57
|
present = selected,
|
|
45
58
|
render = function(state)
|
|
46
59
|
return React.createElement(TabsContentImpl, {
|
|
47
60
|
asChild = props.asChild,
|
|
61
|
+
motionPresent = state.isPresent,
|
|
62
|
+
onExitComplete = state.onExitComplete,
|
|
63
|
+
transition = props.transition,
|
|
48
64
|
value = props.value,
|
|
49
|
-
visible =
|
|
65
|
+
visible = true,
|
|
50
66
|
}, props.children)
|
|
51
67
|
end,
|
|
52
68
|
})
|
package/out/Tabs/TabsRoot.luau
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
-- Compiled with roblox-ts v3.0.0
|
|
2
2
|
local TS = _G[script]
|
|
3
3
|
local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
|
|
4
|
-
local findOrderedSelectionEntry = _core.findOrderedSelectionEntry
|
|
5
|
-
local focusOrderedSelectionEntry = _core.focusOrderedSelectionEntry
|
|
6
|
-
local getOrderedSelectionEntries = _core.getOrderedSelectionEntries
|
|
7
|
-
local getRelativeOrderedSelectionEntry = _core.getRelativeOrderedSelectionEntry
|
|
8
4
|
local React = _core.React
|
|
9
5
|
local useControllableState = _core.useControllableState
|
|
6
|
+
local _focus = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out)
|
|
7
|
+
local findOrderedSelectionEntry = _focus.findOrderedSelectionEntry
|
|
8
|
+
local focusOrderedSelectionEntry = _focus.focusOrderedSelectionEntry
|
|
9
|
+
local getOrderedSelectionEntries = _focus.getOrderedSelectionEntries
|
|
10
|
+
local getRelativeOrderedSelectionEntry = _focus.getRelativeOrderedSelectionEntry
|
|
10
11
|
local TabsContextProvider = TS.import(script, script.Parent, "context").TabsContextProvider
|
|
11
12
|
local function resolveNextValue(currentValue, orderedTriggers, fallbackOrder)
|
|
12
13
|
-- ▼ ReadonlyArray.filter ▼
|
|
@@ -3,7 +3,10 @@ local TS = _G[script]
|
|
|
3
3
|
local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
|
|
4
4
|
local React = _core.React
|
|
5
5
|
local Slot = _core.Slot
|
|
6
|
-
local useFocusNode =
|
|
6
|
+
local useFocusNode = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out).useFocusNode
|
|
7
|
+
local _motion = TS.import(script, TS.getModule(script, "@lattice-ui", "motion").out)
|
|
8
|
+
local createSelectionResponseRecipe = _motion.createSelectionResponseRecipe
|
|
9
|
+
local useResponseMotion = _motion.useResponseMotion
|
|
7
10
|
local useTabsContext = TS.import(script, script.Parent, "context").useTabsContext
|
|
8
11
|
local createTabsTriggerName = TS.import(script, script.Parent, "internals", "ids").createTabsTriggerName
|
|
9
12
|
local nextTriggerId = 0
|
|
@@ -50,9 +53,21 @@ local function TabsTrigger(props)
|
|
|
50
53
|
return disabledRef.current
|
|
51
54
|
end,
|
|
52
55
|
})
|
|
56
|
+
local motionRef = useResponseMotion(selected, {
|
|
57
|
+
active = {
|
|
58
|
+
BackgroundColor3 = Color3.fromRGB(86, 137, 245),
|
|
59
|
+
TextColor3 = Color3.fromRGB(235, 240, 248),
|
|
60
|
+
},
|
|
61
|
+
inactive = {
|
|
62
|
+
BackgroundColor3 = Color3.fromRGB(47, 53, 68),
|
|
63
|
+
TextColor3 = Color3.fromRGB(136, 144, 159),
|
|
64
|
+
},
|
|
65
|
+
}, createSelectionResponseRecipe())
|
|
53
66
|
local setTriggerRef = React.useCallback(function(instance)
|
|
54
|
-
|
|
55
|
-
|
|
67
|
+
local nextTrigger = toGuiObject(instance)
|
|
68
|
+
triggerRef.current = nextTrigger
|
|
69
|
+
motionRef.current = nextTrigger
|
|
70
|
+
end, { motionRef })
|
|
56
71
|
local handleActivated = React.useCallback(function()
|
|
57
72
|
if disabled then
|
|
58
73
|
return nil
|
|
@@ -112,7 +127,7 @@ local function TabsTrigger(props)
|
|
|
112
127
|
Selectable = not disabled,
|
|
113
128
|
Size = UDim2.fromOffset(132, 34),
|
|
114
129
|
Text = props.value,
|
|
115
|
-
TextColor3 = if disabled then Color3.fromRGB(136, 144, 159) else Color3.fromRGB(235, 240, 248),
|
|
130
|
+
TextColor3 = if selected then Color3.fromRGB(235, 240, 248) elseif disabled then Color3.fromRGB(136, 144, 159) else Color3.fromRGB(235, 240, 248),
|
|
116
131
|
TextSize = 15,
|
|
117
132
|
ref = setTriggerRef,
|
|
118
133
|
}, props.children)
|
package/out/Tabs/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PresenceMotionConfig } from "@lattice-ui/motion";
|
|
1
2
|
import type React from "@rbxts/react";
|
|
2
3
|
export type TabsSetValue = (value: string) => void;
|
|
3
4
|
export type TabsOrientation = "horizontal" | "vertical";
|
|
@@ -33,6 +34,7 @@ export type TabsTriggerProps = {
|
|
|
33
34
|
children?: React.ReactElement;
|
|
34
35
|
};
|
|
35
36
|
export type TabsContentProps = {
|
|
37
|
+
transition?: PresenceMotionConfig;
|
|
36
38
|
value: string;
|
|
37
39
|
asChild?: boolean;
|
|
38
40
|
forceMount?: boolean;
|
package/package.json
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lattice-ui/tabs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0-next.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "out/init.luau",
|
|
6
6
|
"types": "out/index.d.ts",
|
|
7
|
+
"source": "src/index.ts",
|
|
7
8
|
"files": [
|
|
9
|
+
"default.project.json",
|
|
8
10
|
"out",
|
|
11
|
+
"src",
|
|
9
12
|
"README.md"
|
|
10
13
|
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/astra-void/lattice-ui.git"
|
|
17
|
+
},
|
|
11
18
|
"dependencies": {
|
|
12
|
-
"@lattice-ui/core": "0.
|
|
13
|
-
"@lattice-ui/
|
|
19
|
+
"@lattice-ui/core": "0.5.0-next.1",
|
|
20
|
+
"@lattice-ui/focus": "0.5.0-next.1",
|
|
21
|
+
"@lattice-ui/layer": "0.5.0-next.1",
|
|
22
|
+
"@lattice-ui/motion": "0.5.0-next.1"
|
|
14
23
|
},
|
|
15
24
|
"devDependencies": {
|
|
16
25
|
"@rbxts/react": "17.3.7-ts.1",
|
|
@@ -22,6 +31,8 @@
|
|
|
22
31
|
},
|
|
23
32
|
"scripts": {
|
|
24
33
|
"build": "rbxtsc -p tsconfig.json",
|
|
34
|
+
"lint": "eslint .",
|
|
35
|
+
"lint:fix": "eslint . --fix",
|
|
25
36
|
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
26
37
|
"watch": "rbxtsc -p tsconfig.json -w"
|
|
27
38
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { React, Slot } from "@lattice-ui/core";
|
|
2
|
+
import { Presence } from "@lattice-ui/layer";
|
|
3
|
+
import { createSurfaceRevealRecipe, type PresenceMotionConfig, usePresenceMotion } from "@lattice-ui/motion";
|
|
4
|
+
import { useTabsContext } from "./context";
|
|
5
|
+
import { createTabsContentName } from "./internals/ids";
|
|
6
|
+
import type { TabsContentProps } from "./types";
|
|
7
|
+
|
|
8
|
+
function TabsContentImpl(props: {
|
|
9
|
+
motionPresent: boolean;
|
|
10
|
+
visible: boolean;
|
|
11
|
+
transition?: PresenceMotionConfig;
|
|
12
|
+
onExitComplete?: () => void;
|
|
13
|
+
value: string;
|
|
14
|
+
asChild?: boolean;
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
}) {
|
|
17
|
+
const contentName = createTabsContentName(props.value);
|
|
18
|
+
const defaultTransition = React.useMemo(() => createSurfaceRevealRecipe(), []);
|
|
19
|
+
|
|
20
|
+
const config = React.useMemo(() => {
|
|
21
|
+
if (!props.transition) return defaultTransition;
|
|
22
|
+
return props.transition;
|
|
23
|
+
}, [defaultTransition, props.transition]);
|
|
24
|
+
|
|
25
|
+
const motionRef = usePresenceMotion<Frame>(props.motionPresent, config, props.onExitComplete);
|
|
26
|
+
|
|
27
|
+
if (props.asChild) {
|
|
28
|
+
const child = props.children;
|
|
29
|
+
if (!React.isValidElement(child)) {
|
|
30
|
+
error("[TabsContent] `asChild` requires a child element.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<Slot Name={contentName} Visible={props.visible} ref={motionRef}>
|
|
35
|
+
{child}
|
|
36
|
+
</Slot>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<frame
|
|
42
|
+
BackgroundTransparency={1}
|
|
43
|
+
BorderSizePixel={0}
|
|
44
|
+
Size={UDim2.fromOffset(0, 0)}
|
|
45
|
+
Visible={props.visible}
|
|
46
|
+
ref={motionRef}
|
|
47
|
+
>
|
|
48
|
+
{props.children}
|
|
49
|
+
</frame>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function TabsContent(props: TabsContentProps) {
|
|
54
|
+
const tabsContext = useTabsContext();
|
|
55
|
+
const selected = tabsContext.value === props.value;
|
|
56
|
+
const forceMount = props.forceMount === true;
|
|
57
|
+
|
|
58
|
+
if (forceMount) {
|
|
59
|
+
return (
|
|
60
|
+
<TabsContentImpl
|
|
61
|
+
asChild={props.asChild}
|
|
62
|
+
motionPresent={selected}
|
|
63
|
+
transition={props.transition}
|
|
64
|
+
value={props.value}
|
|
65
|
+
visible={selected}
|
|
66
|
+
>
|
|
67
|
+
{props.children}
|
|
68
|
+
</TabsContentImpl>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Presence
|
|
74
|
+
present={selected}
|
|
75
|
+
render={(state) => (
|
|
76
|
+
<TabsContentImpl
|
|
77
|
+
asChild={props.asChild}
|
|
78
|
+
motionPresent={state.isPresent}
|
|
79
|
+
onExitComplete={state.onExitComplete}
|
|
80
|
+
transition={props.transition}
|
|
81
|
+
value={props.value}
|
|
82
|
+
visible={true}
|
|
83
|
+
>
|
|
84
|
+
{props.children}
|
|
85
|
+
</TabsContentImpl>
|
|
86
|
+
)}
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { React, Slot } from "@lattice-ui/core";
|
|
2
|
+
import type { TabsListProps } from "./types";
|
|
3
|
+
|
|
4
|
+
export function TabsList(props: TabsListProps) {
|
|
5
|
+
if (props.asChild) {
|
|
6
|
+
const child = props.children;
|
|
7
|
+
if (!React.isValidElement(child)) {
|
|
8
|
+
error("[TabsList] `asChild` requires a child element.");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
return <Slot>{child}</Slot>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<frame BackgroundTransparency={1} BorderSizePixel={0} Size={UDim2.fromOffset(0, 0)}>
|
|
16
|
+
{props.children}
|
|
17
|
+
</frame>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { React, useControllableState } from "@lattice-ui/core";
|
|
2
|
+
import {
|
|
3
|
+
findOrderedSelectionEntry,
|
|
4
|
+
focusOrderedSelectionEntry,
|
|
5
|
+
getOrderedSelectionEntries,
|
|
6
|
+
getRelativeOrderedSelectionEntry,
|
|
7
|
+
} from "@lattice-ui/focus";
|
|
8
|
+
import { TabsContextProvider } from "./context";
|
|
9
|
+
import type { TabsProps, TabsTriggerRegistration } from "./types";
|
|
10
|
+
|
|
11
|
+
function resolveNextValue(
|
|
12
|
+
currentValue: string | undefined,
|
|
13
|
+
orderedTriggers: Array<TabsTriggerRegistration>,
|
|
14
|
+
fallbackOrder: number | undefined,
|
|
15
|
+
) {
|
|
16
|
+
const enabled = orderedTriggers.filter((trigger) => !trigger.getDisabled());
|
|
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 orientation = props.orientation ?? "horizontal";
|
|
44
|
+
const [value, setValueState] = useControllableState<string | undefined>({
|
|
45
|
+
value: props.value,
|
|
46
|
+
defaultValue: props.defaultValue,
|
|
47
|
+
onChange: (nextValue) => {
|
|
48
|
+
if (nextValue !== undefined) {
|
|
49
|
+
props.onValueChange?.(nextValue);
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const triggerRegistryRef = React.useRef<Array<TabsTriggerRegistration>>([]);
|
|
55
|
+
const lastSelectedOrderRef = React.useRef<number>();
|
|
56
|
+
const [registryRevision, setRegistryRevision] = React.useState(0);
|
|
57
|
+
|
|
58
|
+
const registerTrigger = React.useCallback((trigger: TabsTriggerRegistration) => {
|
|
59
|
+
triggerRegistryRef.current.push(trigger);
|
|
60
|
+
setRegistryRevision((revision) => revision + 1);
|
|
61
|
+
|
|
62
|
+
return () => {
|
|
63
|
+
const index = triggerRegistryRef.current.findIndex((entry) => entry.id === trigger.id);
|
|
64
|
+
if (index >= 0) {
|
|
65
|
+
triggerRegistryRef.current.remove(index);
|
|
66
|
+
setRegistryRevision((revision) => revision + 1);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}, []);
|
|
70
|
+
|
|
71
|
+
const setValue = React.useCallback(
|
|
72
|
+
(nextValue: string) => {
|
|
73
|
+
const orderedTriggers = getOrderedSelectionEntries(triggerRegistryRef.current);
|
|
74
|
+
const selected = orderedTriggers.find((trigger) => trigger.value === nextValue && !trigger.getDisabled());
|
|
75
|
+
if (selected) {
|
|
76
|
+
lastSelectedOrderRef.current = selected.order;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
setValueState(nextValue);
|
|
80
|
+
},
|
|
81
|
+
[setValueState],
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
const moveSelection = React.useCallback(
|
|
85
|
+
(fromValue: string, direction: -1 | 1) => {
|
|
86
|
+
const currentTrigger =
|
|
87
|
+
findOrderedSelectionEntry(triggerRegistryRef.current, (trigger) => trigger.value === fromValue) ?? undefined;
|
|
88
|
+
const nextTrigger = getRelativeOrderedSelectionEntry(triggerRegistryRef.current, currentTrigger?.id, direction);
|
|
89
|
+
if (!nextTrigger) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
focusOrderedSelectionEntry(nextTrigger);
|
|
94
|
+
setValue(nextTrigger.value);
|
|
95
|
+
},
|
|
96
|
+
[setValue],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
const orderedTriggers = getOrderedSelectionEntries(triggerRegistryRef.current);
|
|
101
|
+
const selected = orderedTriggers.find((trigger) => trigger.value === value && !trigger.getDisabled());
|
|
102
|
+
if (selected) {
|
|
103
|
+
lastSelectedOrderRef.current = selected.order;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const nextValue = resolveNextValue(value, orderedTriggers, lastSelectedOrderRef.current);
|
|
107
|
+
if (nextValue !== value) {
|
|
108
|
+
setValueState(nextValue);
|
|
109
|
+
}
|
|
110
|
+
}, [registryRevision, setValueState, value]);
|
|
111
|
+
|
|
112
|
+
const contextValue = React.useMemo(
|
|
113
|
+
() => ({
|
|
114
|
+
value,
|
|
115
|
+
orientation,
|
|
116
|
+
setValue,
|
|
117
|
+
registerTrigger,
|
|
118
|
+
moveSelection,
|
|
119
|
+
}),
|
|
120
|
+
[moveSelection, orientation, registerTrigger, setValue, value],
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return <TabsContextProvider value={contextValue}>{props.children}</TabsContextProvider>;
|
|
124
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { React, Slot } from "@lattice-ui/core";
|
|
2
|
+
import { useFocusNode } from "@lattice-ui/focus";
|
|
3
|
+
import { createSelectionResponseRecipe, useResponseMotion } from "@lattice-ui/motion";
|
|
4
|
+
import { useTabsContext } from "./context";
|
|
5
|
+
import { createTabsTriggerName } from "./internals/ids";
|
|
6
|
+
import type { TabsTriggerProps } from "./types";
|
|
7
|
+
|
|
8
|
+
let nextTriggerId = 0;
|
|
9
|
+
let nextTriggerOrder = 0;
|
|
10
|
+
|
|
11
|
+
function toGuiObject(instance: Instance | undefined) {
|
|
12
|
+
if (!instance || !instance.IsA("GuiObject")) {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return instance;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function TabsTrigger(props: TabsTriggerProps) {
|
|
20
|
+
const tabsContext = useTabsContext();
|
|
21
|
+
const triggerRef = React.useRef<GuiObject>();
|
|
22
|
+
const selected = tabsContext.value === props.value;
|
|
23
|
+
const disabled = props.disabled === true;
|
|
24
|
+
const disabledRef = React.useRef(disabled);
|
|
25
|
+
|
|
26
|
+
React.useEffect(() => {
|
|
27
|
+
disabledRef.current = disabled;
|
|
28
|
+
}, [disabled]);
|
|
29
|
+
|
|
30
|
+
const triggerIdRef = React.useRef(0);
|
|
31
|
+
if (triggerIdRef.current === 0) {
|
|
32
|
+
nextTriggerId += 1;
|
|
33
|
+
triggerIdRef.current = nextTriggerId;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const triggerOrderRef = React.useRef(0);
|
|
37
|
+
if (triggerOrderRef.current === 0) {
|
|
38
|
+
nextTriggerOrder += 1;
|
|
39
|
+
triggerOrderRef.current = nextTriggerOrder;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
React.useEffect(() => {
|
|
43
|
+
return tabsContext.registerTrigger({
|
|
44
|
+
id: triggerIdRef.current,
|
|
45
|
+
value: props.value,
|
|
46
|
+
ref: triggerRef,
|
|
47
|
+
order: triggerOrderRef.current,
|
|
48
|
+
getDisabled: () => disabledRef.current,
|
|
49
|
+
});
|
|
50
|
+
}, [props.value, tabsContext]);
|
|
51
|
+
|
|
52
|
+
useFocusNode({
|
|
53
|
+
ref: triggerRef,
|
|
54
|
+
getDisabled: () => disabledRef.current,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const motionRef = useResponseMotion<GuiObject>(
|
|
58
|
+
selected,
|
|
59
|
+
{
|
|
60
|
+
active: { BackgroundColor3: Color3.fromRGB(86, 137, 245), TextColor3: Color3.fromRGB(235, 240, 248) },
|
|
61
|
+
inactive: { BackgroundColor3: Color3.fromRGB(47, 53, 68), TextColor3: Color3.fromRGB(136, 144, 159) },
|
|
62
|
+
},
|
|
63
|
+
createSelectionResponseRecipe(),
|
|
64
|
+
);
|
|
65
|
+
const setTriggerRef = React.useCallback(
|
|
66
|
+
(instance: Instance | undefined) => {
|
|
67
|
+
const nextTrigger = toGuiObject(instance);
|
|
68
|
+
triggerRef.current = nextTrigger;
|
|
69
|
+
motionRef.current = nextTrigger;
|
|
70
|
+
},
|
|
71
|
+
[motionRef],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const handleActivated = React.useCallback(() => {
|
|
75
|
+
if (disabled) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
tabsContext.setValue(props.value);
|
|
80
|
+
}, [disabled, props.value, tabsContext]);
|
|
81
|
+
|
|
82
|
+
const handleSelectionGained = React.useCallback(() => {
|
|
83
|
+
if (disabled) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
tabsContext.setValue(props.value);
|
|
88
|
+
}, [disabled, props.value, tabsContext]);
|
|
89
|
+
|
|
90
|
+
const handleInputBegan = React.useCallback(
|
|
91
|
+
(_rbx: TextButton, inputObject: InputObject) => {
|
|
92
|
+
if (disabled) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const keyCode = inputObject.KeyCode;
|
|
97
|
+
const direction =
|
|
98
|
+
tabsContext.orientation === "horizontal"
|
|
99
|
+
? keyCode === Enum.KeyCode.Left
|
|
100
|
+
? -1
|
|
101
|
+
: keyCode === Enum.KeyCode.Right
|
|
102
|
+
? 1
|
|
103
|
+
: undefined
|
|
104
|
+
: keyCode === Enum.KeyCode.Up
|
|
105
|
+
? -1
|
|
106
|
+
: keyCode === Enum.KeyCode.Down
|
|
107
|
+
? 1
|
|
108
|
+
: undefined;
|
|
109
|
+
|
|
110
|
+
if (direction !== undefined) {
|
|
111
|
+
tabsContext.moveSelection(props.value, direction);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (keyCode !== Enum.KeyCode.Return && keyCode !== Enum.KeyCode.Space) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
tabsContext.setValue(props.value);
|
|
120
|
+
},
|
|
121
|
+
[disabled, props.value, tabsContext],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const eventHandlers = React.useMemo(
|
|
125
|
+
() => ({
|
|
126
|
+
Activated: handleActivated,
|
|
127
|
+
InputBegan: handleInputBegan,
|
|
128
|
+
SelectionGained: handleSelectionGained,
|
|
129
|
+
}),
|
|
130
|
+
[handleActivated, handleInputBegan, handleSelectionGained],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const triggerName = React.useMemo(() => createTabsTriggerName(props.value), [props.value]);
|
|
134
|
+
|
|
135
|
+
if (props.asChild) {
|
|
136
|
+
const child = props.children;
|
|
137
|
+
if (!child) {
|
|
138
|
+
error("[TabsTrigger] `asChild` requires a child element.");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<Slot Active={!disabled} Event={eventHandlers} Name={triggerName} Selectable={!disabled} ref={setTriggerRef}>
|
|
143
|
+
{child}
|
|
144
|
+
</Slot>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<textbutton
|
|
150
|
+
Active={!disabled}
|
|
151
|
+
AutoButtonColor={false}
|
|
152
|
+
BackgroundColor3={selected ? Color3.fromRGB(86, 137, 245) : Color3.fromRGB(47, 53, 68)}
|
|
153
|
+
BorderSizePixel={0}
|
|
154
|
+
Event={eventHandlers}
|
|
155
|
+
Selectable={!disabled}
|
|
156
|
+
Size={UDim2.fromOffset(132, 34)}
|
|
157
|
+
Text={props.value}
|
|
158
|
+
TextColor3={
|
|
159
|
+
selected
|
|
160
|
+
? Color3.fromRGB(235, 240, 248)
|
|
161
|
+
: disabled
|
|
162
|
+
? Color3.fromRGB(136, 144, 159)
|
|
163
|
+
: Color3.fromRGB(235, 240, 248)
|
|
164
|
+
}
|
|
165
|
+
TextSize={15}
|
|
166
|
+
ref={setTriggerRef}
|
|
167
|
+
>
|
|
168
|
+
{props.children}
|
|
169
|
+
</textbutton>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -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,49 @@
|
|
|
1
|
+
import type { PresenceMotionConfig } from "@lattice-ui/motion";
|
|
2
|
+
import type React from "@rbxts/react";
|
|
3
|
+
|
|
4
|
+
export type TabsSetValue = (value: string) => void;
|
|
5
|
+
export type TabsOrientation = "horizontal" | "vertical";
|
|
6
|
+
|
|
7
|
+
export type TabsTriggerRegistration = {
|
|
8
|
+
id: number;
|
|
9
|
+
value: string;
|
|
10
|
+
ref: React.MutableRefObject<GuiObject | undefined>;
|
|
11
|
+
order: number;
|
|
12
|
+
getDisabled: () => boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type TabsContextValue = {
|
|
16
|
+
value?: string;
|
|
17
|
+
orientation: TabsOrientation;
|
|
18
|
+
setValue: TabsSetValue;
|
|
19
|
+
registerTrigger: (trigger: TabsTriggerRegistration) => () => void;
|
|
20
|
+
moveSelection: (fromValue: string, direction: -1 | 1) => void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type TabsProps = {
|
|
24
|
+
value?: string;
|
|
25
|
+
defaultValue?: string;
|
|
26
|
+
onValueChange?: (value: string) => void;
|
|
27
|
+
orientation?: TabsOrientation;
|
|
28
|
+
children?: React.ReactNode;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type TabsListProps = {
|
|
32
|
+
asChild?: boolean;
|
|
33
|
+
children?: React.ReactNode;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type TabsTriggerProps = {
|
|
37
|
+
value: string;
|
|
38
|
+
asChild?: boolean;
|
|
39
|
+
disabled?: boolean;
|
|
40
|
+
children?: React.ReactElement;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type TabsContentProps = {
|
|
44
|
+
transition?: PresenceMotionConfig;
|
|
45
|
+
value: string;
|
|
46
|
+
asChild?: boolean;
|
|
47
|
+
forceMount?: boolean;
|
|
48
|
+
children?: React.ReactNode;
|
|
49
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { TabsContent } from "./Tabs/TabsContent";
|
|
2
|
+
import { TabsList } from "./Tabs/TabsList";
|
|
3
|
+
import { TabsRoot } from "./Tabs/TabsRoot";
|
|
4
|
+
import { TabsTrigger } from "./Tabs/TabsTrigger";
|
|
5
|
+
|
|
6
|
+
export const Tabs = {
|
|
7
|
+
Root: TabsRoot,
|
|
8
|
+
List: TabsList,
|
|
9
|
+
Trigger: TabsTrigger,
|
|
10
|
+
Content: TabsContent,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
TabsContentProps,
|
|
15
|
+
TabsContextValue,
|
|
16
|
+
TabsListProps,
|
|
17
|
+
TabsOrientation,
|
|
18
|
+
TabsProps,
|
|
19
|
+
TabsTriggerProps,
|
|
20
|
+
} from "./Tabs/types";
|