@lattice-ui/scroll-area 0.3.2 → 0.4.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/.turbo/turbo-build.log +4 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/README.md +1 -0
- package/out/ScrollArea/ScrollAreaRoot.luau +13 -1
- package/out/ScrollArea/ScrollAreaScrollbar.luau +45 -0
- package/out/ScrollArea/ScrollAreaThumb.luau +97 -0
- package/out/ScrollArea/scrollMath.d.ts +3 -0
- package/out/ScrollArea/scrollMath.luau +15 -0
- package/out/ScrollArea/types.d.ts +1 -0
- package/out/index.d.ts +1 -1
- package/out/init.luau +3 -0
- package/package.json +2 -2
- package/src/ScrollArea/ScrollAreaRoot.tsx +23 -0
- package/src/ScrollArea/ScrollAreaScrollbar.tsx +74 -1
- package/src/ScrollArea/ScrollAreaThumb.tsx +151 -1
- package/src/ScrollArea/scrollMath.ts +26 -0
- package/src/ScrollArea/types.ts +1 -0
- package/src/index.ts +8 -1
package/README.md
CHANGED
|
@@ -15,4 +15,5 @@ Headless scroll area primitives for Roblox UI with custom scrollbars and thumbs.
|
|
|
15
15
|
|
|
16
16
|
- Supports vertical and horizontal scrollbar primitives.
|
|
17
17
|
- `type` supports `auto`, `always`, and `scroll` visibility policies.
|
|
18
|
+
- Clicking a scrollbar track and dragging a thumb both update the viewport canvas position.
|
|
18
19
|
- Thumb/canvas math helpers are exported for unit testing.
|
|
@@ -47,6 +47,17 @@ local function ScrollAreaRoot(props)
|
|
|
47
47
|
setShowScrollbarsFromActivity(false)
|
|
48
48
|
end)
|
|
49
49
|
end, { scrollHideDelayMs, scrollType })
|
|
50
|
+
local setScrollPosition = React.useCallback(function(orientation, position)
|
|
51
|
+
local viewport = viewportRef.current
|
|
52
|
+
if not viewport then
|
|
53
|
+
return nil
|
|
54
|
+
end
|
|
55
|
+
local axisMetrics = if orientation == "vertical" then metrics.vertical else metrics.horizontal
|
|
56
|
+
local maxScroll = math.max(0, axisMetrics.contentSize - axisMetrics.viewportSize)
|
|
57
|
+
local nextPosition = math.clamp(position, 0, maxScroll)
|
|
58
|
+
viewport.CanvasPosition = if orientation == "vertical" then Vector2.new(viewport.CanvasPosition.X, nextPosition) else Vector2.new(nextPosition, viewport.CanvasPosition.Y)
|
|
59
|
+
notifyScrollActivity()
|
|
60
|
+
end, { metrics.horizontal, metrics.vertical, notifyScrollActivity })
|
|
50
61
|
local hasVerticalOverflow = metrics.vertical.contentSize > metrics.vertical.viewportSize + 1
|
|
51
62
|
local hasHorizontalOverflow = metrics.horizontal.contentSize > metrics.horizontal.viewportSize + 1
|
|
52
63
|
local showVerticalScrollbar = if scrollType == "always" then hasVerticalOverflow elseif scrollType == "scroll" then hasVerticalOverflow and showScrollbarsFromActivity else hasVerticalOverflow
|
|
@@ -60,11 +71,12 @@ local function ScrollAreaRoot(props)
|
|
|
60
71
|
vertical = metrics.vertical,
|
|
61
72
|
horizontal = metrics.horizontal,
|
|
62
73
|
setMetrics = setMetrics,
|
|
74
|
+
setScrollPosition = setScrollPosition,
|
|
63
75
|
notifyScrollActivity = notifyScrollActivity,
|
|
64
76
|
showVerticalScrollbar = showVerticalScrollbar,
|
|
65
77
|
showHorizontalScrollbar = showHorizontalScrollbar,
|
|
66
78
|
}
|
|
67
|
-
end, { metrics.horizontal, metrics.vertical, notifyScrollActivity, scrollHideDelayMs, setViewport, showHorizontalScrollbar, showVerticalScrollbar, scrollType })
|
|
79
|
+
end, { metrics.horizontal, metrics.vertical, notifyScrollActivity, scrollHideDelayMs, setScrollPosition, setViewport, showHorizontalScrollbar, showVerticalScrollbar, scrollType })
|
|
68
80
|
return React.createElement(ScrollAreaContextProvider, {
|
|
69
81
|
value = contextValue,
|
|
70
82
|
}, props.children)
|
|
@@ -4,25 +4,70 @@ 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 useScrollAreaContext = TS.import(script, script.Parent, "context").useScrollAreaContext
|
|
7
|
+
local _scrollMath = TS.import(script, script.Parent, "scrollMath")
|
|
8
|
+
local resolveCanvasPositionFromTrackPosition = _scrollMath.resolveCanvasPositionFromTrackPosition
|
|
9
|
+
local resolveThumbOffset = _scrollMath.resolveThumbOffset
|
|
10
|
+
local resolveThumbSize = _scrollMath.resolveThumbSize
|
|
11
|
+
local function isPointerInput(inputObject)
|
|
12
|
+
return inputObject.UserInputType == Enum.UserInputType.MouseButton1 or inputObject.UserInputType == Enum.UserInputType.Touch
|
|
13
|
+
end
|
|
14
|
+
local function toGuiObject(instance)
|
|
15
|
+
if not instance or not instance:IsA("GuiObject") then
|
|
16
|
+
return nil
|
|
17
|
+
end
|
|
18
|
+
return instance
|
|
19
|
+
end
|
|
7
20
|
local function ScrollAreaScrollbar(props)
|
|
8
21
|
local scrollAreaContext = useScrollAreaContext()
|
|
22
|
+
local scrollbarRef = React.useRef()
|
|
9
23
|
local vertical = props.orientation == "vertical"
|
|
10
24
|
local visible = if vertical then scrollAreaContext.showVerticalScrollbar else scrollAreaContext.showHorizontalScrollbar
|
|
25
|
+
local axisMetrics = if vertical then scrollAreaContext.vertical else scrollAreaContext.horizontal
|
|
26
|
+
local setScrollbarRef = React.useCallback(function(instance)
|
|
27
|
+
scrollbarRef.current = toGuiObject(instance)
|
|
28
|
+
end, {})
|
|
29
|
+
local handleInputBegan = React.useCallback(function(rbx, inputObject)
|
|
30
|
+
if not isPointerInput(inputObject) or axisMetrics.contentSize <= axisMetrics.viewportSize then
|
|
31
|
+
return nil
|
|
32
|
+
end
|
|
33
|
+
local track = scrollbarRef.current or rbx
|
|
34
|
+
local trackSize = math.max(1, if vertical then track.AbsoluteSize.Y else track.AbsoluteSize.X)
|
|
35
|
+
local trackStart = if vertical then track.AbsolutePosition.Y else track.AbsolutePosition.X
|
|
36
|
+
local pointerPosition = if vertical then inputObject.Position.Y else inputObject.Position.X
|
|
37
|
+
local trackPosition = pointerPosition - trackStart
|
|
38
|
+
local thumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, trackSize)
|
|
39
|
+
local thumbOffset = resolveThumbOffset(axisMetrics.scrollPosition, axisMetrics.viewportSize, axisMetrics.contentSize, trackSize, thumbSize)
|
|
40
|
+
if trackPosition >= thumbOffset and trackPosition <= thumbOffset + thumbSize then
|
|
41
|
+
return nil
|
|
42
|
+
end
|
|
43
|
+
local nextCanvasPosition = resolveCanvasPositionFromTrackPosition(trackPosition, axisMetrics.viewportSize, axisMetrics.contentSize, trackSize, thumbSize)
|
|
44
|
+
scrollAreaContext.setScrollPosition(props.orientation, nextCanvasPosition)
|
|
45
|
+
end, { axisMetrics.contentSize, axisMetrics.scrollPosition, axisMetrics.viewportSize, props.orientation, scrollAreaContext, vertical })
|
|
11
46
|
if props.asChild then
|
|
12
47
|
local child = props.children
|
|
13
48
|
if not child then
|
|
14
49
|
error("[ScrollAreaScrollbar] `asChild` requires a child element.")
|
|
15
50
|
end
|
|
16
51
|
return React.createElement(Slot, {
|
|
52
|
+
Active = visible,
|
|
53
|
+
Event = {
|
|
54
|
+
InputBegan = handleInputBegan,
|
|
55
|
+
},
|
|
17
56
|
Visible = visible,
|
|
57
|
+
ref = setScrollbarRef,
|
|
18
58
|
}, child)
|
|
19
59
|
end
|
|
20
60
|
return React.createElement("frame", {
|
|
61
|
+
Active = visible,
|
|
21
62
|
BackgroundColor3 = Color3.fromRGB(44, 52, 67),
|
|
22
63
|
BorderSizePixel = 0,
|
|
64
|
+
Event = {
|
|
65
|
+
InputBegan = handleInputBegan,
|
|
66
|
+
},
|
|
23
67
|
Position = if vertical then UDim2.fromScale(1, 0) else UDim2.fromScale(0, 1),
|
|
24
68
|
Size = if vertical then UDim2.fromOffset(8, 160) else UDim2.fromOffset(260, 8),
|
|
25
69
|
Visible = visible,
|
|
70
|
+
ref = setScrollbarRef,
|
|
26
71
|
}, props.children)
|
|
27
72
|
end
|
|
28
73
|
return {
|
|
@@ -5,32 +5,129 @@ local React = _core.React
|
|
|
5
5
|
local Slot = _core.Slot
|
|
6
6
|
local useScrollAreaContext = TS.import(script, script.Parent, "context").useScrollAreaContext
|
|
7
7
|
local _scrollMath = TS.import(script, script.Parent, "scrollMath")
|
|
8
|
+
local resolveCanvasPositionFromThumbOffset = _scrollMath.resolveCanvasPositionFromThumbOffset
|
|
8
9
|
local resolveThumbOffset = _scrollMath.resolveThumbOffset
|
|
10
|
+
local resolveThumbOffsetFromPointerDelta = _scrollMath.resolveThumbOffsetFromPointerDelta
|
|
9
11
|
local resolveThumbSize = _scrollMath.resolveThumbSize
|
|
12
|
+
local UserInputService = game:GetService("UserInputService")
|
|
13
|
+
local function isThumbDragStart(inputObject)
|
|
14
|
+
return inputObject.UserInputType == Enum.UserInputType.MouseButton1 or inputObject.UserInputType == Enum.UserInputType.Touch
|
|
15
|
+
end
|
|
16
|
+
local function toGuiObject(instance)
|
|
17
|
+
if not instance or not instance:IsA("GuiObject") then
|
|
18
|
+
return nil
|
|
19
|
+
end
|
|
20
|
+
return instance
|
|
21
|
+
end
|
|
10
22
|
local function ScrollAreaThumb(props)
|
|
11
23
|
local scrollAreaContext = useScrollAreaContext()
|
|
12
24
|
local vertical = props.orientation == "vertical"
|
|
25
|
+
local thumbRef = React.useRef()
|
|
26
|
+
local dragStateRef = React.useRef()
|
|
13
27
|
local axisMetrics = if vertical then scrollAreaContext.vertical else scrollAreaContext.horizontal
|
|
14
28
|
local trackSize = math.max(1, axisMetrics.viewportSize)
|
|
15
29
|
local thumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, trackSize)
|
|
16
30
|
local thumbOffset = resolveThumbOffset(axisMetrics.scrollPosition, axisMetrics.viewportSize, axisMetrics.contentSize, trackSize, thumbSize)
|
|
17
31
|
local sizeScale = if trackSize > 0 then thumbSize / trackSize else 1
|
|
18
32
|
local offsetScale = if trackSize > 0 then thumbOffset / trackSize else 0
|
|
33
|
+
local setThumbRef = React.useCallback(function(instance)
|
|
34
|
+
thumbRef.current = toGuiObject(instance)
|
|
35
|
+
end, {})
|
|
36
|
+
local getTrack = React.useCallback(function()
|
|
37
|
+
local thumb = thumbRef.current
|
|
38
|
+
local _parent = thumb
|
|
39
|
+
if _parent ~= nil then
|
|
40
|
+
_parent = _parent.Parent
|
|
41
|
+
end
|
|
42
|
+
local parent = _parent
|
|
43
|
+
if not parent or not parent:IsA("GuiObject") then
|
|
44
|
+
return nil
|
|
45
|
+
end
|
|
46
|
+
return parent
|
|
47
|
+
end, {})
|
|
48
|
+
local handleInputBegan = React.useCallback(function(rbx, inputObject)
|
|
49
|
+
if not isThumbDragStart(inputObject) or axisMetrics.contentSize <= axisMetrics.viewportSize then
|
|
50
|
+
return nil
|
|
51
|
+
end
|
|
52
|
+
local track = getTrack()
|
|
53
|
+
if not track then
|
|
54
|
+
return nil
|
|
55
|
+
end
|
|
56
|
+
local actualTrackSize = math.max(1, if vertical then track.AbsoluteSize.Y else track.AbsoluteSize.X)
|
|
57
|
+
local actualThumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, actualTrackSize)
|
|
58
|
+
local actualThumbOffset = resolveThumbOffset(axisMetrics.scrollPosition, axisMetrics.viewportSize, axisMetrics.contentSize, actualTrackSize, actualThumbSize)
|
|
59
|
+
dragStateRef.current = {
|
|
60
|
+
inputType = inputObject.UserInputType,
|
|
61
|
+
pointerStart = if vertical then inputObject.Position.Y else inputObject.Position.X,
|
|
62
|
+
thumbOffsetStart = actualThumbOffset,
|
|
63
|
+
}
|
|
64
|
+
if rbx:IsA("GuiButton") then
|
|
65
|
+
rbx.AutoButtonColor = false
|
|
66
|
+
end
|
|
67
|
+
end, { axisMetrics.contentSize, axisMetrics.scrollPosition, axisMetrics.viewportSize, getTrack, vertical })
|
|
68
|
+
React.useEffect(function()
|
|
69
|
+
local inputChangedConnection = UserInputService.InputChanged:Connect(function(inputObject)
|
|
70
|
+
local dragState = dragStateRef.current
|
|
71
|
+
if not dragState then
|
|
72
|
+
return nil
|
|
73
|
+
end
|
|
74
|
+
local isMatchingInput = if dragState.inputType == Enum.UserInputType.Touch then inputObject.UserInputType == Enum.UserInputType.Touch else inputObject.UserInputType == Enum.UserInputType.MouseMovement
|
|
75
|
+
if not isMatchingInput then
|
|
76
|
+
return nil
|
|
77
|
+
end
|
|
78
|
+
local track = getTrack()
|
|
79
|
+
if not track then
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
local actualTrackSize = math.max(1, if vertical then track.AbsoluteSize.Y else track.AbsoluteSize.X)
|
|
83
|
+
local actualThumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, actualTrackSize)
|
|
84
|
+
local pointerPosition = if vertical then inputObject.Position.Y else inputObject.Position.X
|
|
85
|
+
local thumbOffset = resolveThumbOffsetFromPointerDelta(dragState.thumbOffsetStart, pointerPosition - dragState.pointerStart, actualTrackSize, actualThumbSize)
|
|
86
|
+
local nextCanvasPosition = resolveCanvasPositionFromThumbOffset(thumbOffset, axisMetrics.viewportSize, axisMetrics.contentSize, actualTrackSize, actualThumbSize)
|
|
87
|
+
scrollAreaContext.setScrollPosition(props.orientation, nextCanvasPosition)
|
|
88
|
+
end)
|
|
89
|
+
local inputEndedConnection = UserInputService.InputEnded:Connect(function(inputObject)
|
|
90
|
+
local dragState = dragStateRef.current
|
|
91
|
+
if not dragState then
|
|
92
|
+
return nil
|
|
93
|
+
end
|
|
94
|
+
if dragState.inputType == Enum.UserInputType.Touch and inputObject.UserInputType == Enum.UserInputType.Touch then
|
|
95
|
+
dragStateRef.current = nil
|
|
96
|
+
end
|
|
97
|
+
if dragState.inputType == Enum.UserInputType.MouseButton1 and inputObject.UserInputType == Enum.UserInputType.MouseButton1 then
|
|
98
|
+
dragStateRef.current = nil
|
|
99
|
+
end
|
|
100
|
+
end)
|
|
101
|
+
return function()
|
|
102
|
+
inputChangedConnection:Disconnect()
|
|
103
|
+
inputEndedConnection:Disconnect()
|
|
104
|
+
end
|
|
105
|
+
end, { axisMetrics.contentSize, axisMetrics.viewportSize, getTrack, props.orientation, scrollAreaContext, vertical })
|
|
19
106
|
if props.asChild then
|
|
20
107
|
local child = props.children
|
|
21
108
|
if not child then
|
|
22
109
|
error("[ScrollAreaThumb] `asChild` requires a child element.")
|
|
23
110
|
end
|
|
24
111
|
return React.createElement(Slot, {
|
|
112
|
+
Active = axisMetrics.contentSize > axisMetrics.viewportSize,
|
|
113
|
+
Event = {
|
|
114
|
+
InputBegan = handleInputBegan,
|
|
115
|
+
},
|
|
25
116
|
Position = if vertical then UDim2.fromScale(0, offsetScale) else UDim2.fromScale(offsetScale, 0),
|
|
26
117
|
Size = if vertical then UDim2.fromScale(1, sizeScale) else UDim2.fromScale(sizeScale, 1),
|
|
118
|
+
ref = setThumbRef,
|
|
27
119
|
}, child)
|
|
28
120
|
end
|
|
29
121
|
return React.createElement("frame", {
|
|
122
|
+
Active = axisMetrics.contentSize > axisMetrics.viewportSize,
|
|
30
123
|
BackgroundColor3 = Color3.fromRGB(118, 128, 149),
|
|
31
124
|
BorderSizePixel = 0,
|
|
125
|
+
Event = {
|
|
126
|
+
InputBegan = handleInputBegan,
|
|
127
|
+
},
|
|
32
128
|
Position = if vertical then UDim2.fromScale(0, offsetScale) else UDim2.fromScale(offsetScale, 0),
|
|
33
129
|
Size = if vertical then UDim2.fromScale(1, sizeScale) else UDim2.fromScale(sizeScale, 1),
|
|
130
|
+
ref = setThumbRef,
|
|
34
131
|
}, React.createElement("uicorner", {
|
|
35
132
|
CornerRadius = UDim.new(1, 0),
|
|
36
133
|
}), props.children)
|
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export declare function resolveThumbSize(viewportSize: number, contentSize: number, trackSize: number, minimumThumbSize?: number): number;
|
|
2
2
|
export declare function resolveThumbOffset(scrollPosition: number, viewportSize: number, contentSize: number, trackSize: number, thumbSize: number): number;
|
|
3
3
|
export declare function resolveCanvasPositionFromThumbOffset(thumbOffset: number, viewportSize: number, contentSize: number, trackSize: number, thumbSize: number): number;
|
|
4
|
+
export declare function resolveThumbOffsetFromTrackPosition(trackPosition: number, trackSize: number, thumbSize: number): number;
|
|
5
|
+
export declare function resolveThumbOffsetFromPointerDelta(initialThumbOffset: number, pointerDelta: number, trackSize: number, thumbSize: number): number;
|
|
6
|
+
export declare function resolveCanvasPositionFromTrackPosition(trackPosition: number, viewportSize: number, contentSize: number, trackSize: number, thumbSize: number): number;
|
|
@@ -29,8 +29,23 @@ local function resolveCanvasPositionFromThumbOffset(thumbOffset, viewportSize, c
|
|
|
29
29
|
local ratio = math.clamp(thumbOffset / maxThumbOffset, 0, 1)
|
|
30
30
|
return ratio * maxScroll
|
|
31
31
|
end
|
|
32
|
+
local function resolveThumbOffsetFromTrackPosition(trackPosition, trackSize, thumbSize)
|
|
33
|
+
local maxThumbOffset = math.max(0, trackSize - thumbSize)
|
|
34
|
+
return math.clamp(trackPosition - thumbSize / 2, 0, maxThumbOffset)
|
|
35
|
+
end
|
|
36
|
+
local function resolveThumbOffsetFromPointerDelta(initialThumbOffset, pointerDelta, trackSize, thumbSize)
|
|
37
|
+
local maxThumbOffset = math.max(0, trackSize - thumbSize)
|
|
38
|
+
return math.clamp(initialThumbOffset + pointerDelta, 0, maxThumbOffset)
|
|
39
|
+
end
|
|
40
|
+
local function resolveCanvasPositionFromTrackPosition(trackPosition, viewportSize, contentSize, trackSize, thumbSize)
|
|
41
|
+
local thumbOffset = resolveThumbOffsetFromTrackPosition(trackPosition, trackSize, thumbSize)
|
|
42
|
+
return resolveCanvasPositionFromThumbOffset(thumbOffset, viewportSize, contentSize, trackSize, thumbSize)
|
|
43
|
+
end
|
|
32
44
|
return {
|
|
33
45
|
resolveThumbSize = resolveThumbSize,
|
|
34
46
|
resolveThumbOffset = resolveThumbOffset,
|
|
35
47
|
resolveCanvasPositionFromThumbOffset = resolveCanvasPositionFromThumbOffset,
|
|
48
|
+
resolveThumbOffsetFromTrackPosition = resolveThumbOffsetFromTrackPosition,
|
|
49
|
+
resolveThumbOffsetFromPointerDelta = resolveThumbOffsetFromPointerDelta,
|
|
50
|
+
resolveCanvasPositionFromTrackPosition = resolveCanvasPositionFromTrackPosition,
|
|
36
51
|
}
|
|
@@ -17,6 +17,7 @@ export type ScrollAreaContextValue = {
|
|
|
17
17
|
vertical: ScrollAxisMetrics;
|
|
18
18
|
horizontal: ScrollAxisMetrics;
|
|
19
19
|
}) => void;
|
|
20
|
+
setScrollPosition: (orientation: ScrollAreaOrientation, position: number) => void;
|
|
20
21
|
notifyScrollActivity: () => void;
|
|
21
22
|
showVerticalScrollbar: boolean;
|
|
22
23
|
showHorizontalScrollbar: boolean;
|
package/out/index.d.ts
CHANGED
|
@@ -10,5 +10,5 @@ export declare const ScrollArea: {
|
|
|
10
10
|
readonly Thumb: typeof ScrollAreaThumb;
|
|
11
11
|
readonly Corner: typeof ScrollAreaCorner;
|
|
12
12
|
};
|
|
13
|
-
export { resolveCanvasPositionFromThumbOffset, resolveThumbOffset, resolveThumbSize } from "./ScrollArea/scrollMath";
|
|
13
|
+
export { resolveCanvasPositionFromThumbOffset, resolveCanvasPositionFromTrackPosition, resolveThumbOffset, resolveThumbOffsetFromPointerDelta, resolveThumbOffsetFromTrackPosition, resolveThumbSize, } from "./ScrollArea/scrollMath";
|
|
14
14
|
export type { ScrollAreaContextValue, ScrollAreaCornerProps, ScrollAreaOrientation, ScrollAreaProps, ScrollAreaScrollbarProps, ScrollAreaThumbProps, ScrollAreaType, ScrollAreaViewportProps, ScrollAxisMetrics, } from "./ScrollArea/types";
|
package/out/init.luau
CHANGED
|
@@ -15,7 +15,10 @@ local ScrollArea = {
|
|
|
15
15
|
}
|
|
16
16
|
local _scrollMath = TS.import(script, script, "ScrollArea", "scrollMath")
|
|
17
17
|
exports.resolveCanvasPositionFromThumbOffset = _scrollMath.resolveCanvasPositionFromThumbOffset
|
|
18
|
+
exports.resolveCanvasPositionFromTrackPosition = _scrollMath.resolveCanvasPositionFromTrackPosition
|
|
18
19
|
exports.resolveThumbOffset = _scrollMath.resolveThumbOffset
|
|
20
|
+
exports.resolveThumbOffsetFromPointerDelta = _scrollMath.resolveThumbOffsetFromPointerDelta
|
|
21
|
+
exports.resolveThumbOffsetFromTrackPosition = _scrollMath.resolveThumbOffsetFromTrackPosition
|
|
19
22
|
exports.resolveThumbSize = _scrollMath.resolveThumbSize
|
|
20
23
|
exports.ScrollArea = ScrollArea
|
|
21
24
|
return exports
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lattice-ui/scroll-area",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"main": "out/init.luau",
|
|
6
6
|
"types": "out/index.d.ts",
|
|
7
7
|
"dependencies": {
|
|
8
|
-
"@lattice-ui/core": "0.
|
|
8
|
+
"@lattice-ui/core": "0.4.1"
|
|
9
9
|
},
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@rbxts/react": "17.3.7-ts.1",
|
|
@@ -51,6 +51,27 @@ export function ScrollAreaRoot(props: ScrollAreaProps) {
|
|
|
51
51
|
});
|
|
52
52
|
}, [scrollHideDelayMs, scrollType]);
|
|
53
53
|
|
|
54
|
+
const setScrollPosition = React.useCallback(
|
|
55
|
+
(orientation: "vertical" | "horizontal", position: number) => {
|
|
56
|
+
const viewport = viewportRef.current;
|
|
57
|
+
if (!viewport) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const axisMetrics = orientation === "vertical" ? metrics.vertical : metrics.horizontal;
|
|
62
|
+
const maxScroll = math.max(0, axisMetrics.contentSize - axisMetrics.viewportSize);
|
|
63
|
+
const nextPosition = math.clamp(position, 0, maxScroll);
|
|
64
|
+
|
|
65
|
+
viewport.CanvasPosition =
|
|
66
|
+
orientation === "vertical"
|
|
67
|
+
? new Vector2(viewport.CanvasPosition.X, nextPosition)
|
|
68
|
+
: new Vector2(nextPosition, viewport.CanvasPosition.Y);
|
|
69
|
+
|
|
70
|
+
notifyScrollActivity();
|
|
71
|
+
},
|
|
72
|
+
[metrics.horizontal, metrics.vertical, notifyScrollActivity],
|
|
73
|
+
);
|
|
74
|
+
|
|
54
75
|
const hasVerticalOverflow = metrics.vertical.contentSize > metrics.vertical.viewportSize + 1;
|
|
55
76
|
const hasHorizontalOverflow = metrics.horizontal.contentSize > metrics.horizontal.viewportSize + 1;
|
|
56
77
|
|
|
@@ -77,6 +98,7 @@ export function ScrollAreaRoot(props: ScrollAreaProps) {
|
|
|
77
98
|
vertical: metrics.vertical,
|
|
78
99
|
horizontal: metrics.horizontal,
|
|
79
100
|
setMetrics,
|
|
101
|
+
setScrollPosition,
|
|
80
102
|
notifyScrollActivity,
|
|
81
103
|
showVerticalScrollbar,
|
|
82
104
|
showHorizontalScrollbar,
|
|
@@ -86,6 +108,7 @@ export function ScrollAreaRoot(props: ScrollAreaProps) {
|
|
|
86
108
|
metrics.vertical,
|
|
87
109
|
notifyScrollActivity,
|
|
88
110
|
scrollHideDelayMs,
|
|
111
|
+
setScrollPosition,
|
|
89
112
|
setViewport,
|
|
90
113
|
showHorizontalScrollbar,
|
|
91
114
|
showVerticalScrollbar,
|
|
@@ -1,12 +1,78 @@
|
|
|
1
1
|
import { React, Slot } from "@lattice-ui/core";
|
|
2
2
|
import { useScrollAreaContext } from "./context";
|
|
3
|
+
import { resolveCanvasPositionFromTrackPosition, resolveThumbOffset, resolveThumbSize } from "./scrollMath";
|
|
3
4
|
import type { ScrollAreaScrollbarProps } from "./types";
|
|
4
5
|
|
|
6
|
+
function isPointerInput(inputObject: InputObject) {
|
|
7
|
+
return (
|
|
8
|
+
inputObject.UserInputType === Enum.UserInputType.MouseButton1 ||
|
|
9
|
+
inputObject.UserInputType === Enum.UserInputType.Touch
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function toGuiObject(instance: Instance | undefined) {
|
|
14
|
+
if (!instance || !instance.IsA("GuiObject")) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return instance;
|
|
19
|
+
}
|
|
20
|
+
|
|
5
21
|
export function ScrollAreaScrollbar(props: ScrollAreaScrollbarProps) {
|
|
6
22
|
const scrollAreaContext = useScrollAreaContext();
|
|
23
|
+
const scrollbarRef = React.useRef<GuiObject>();
|
|
7
24
|
|
|
8
25
|
const vertical = props.orientation === "vertical";
|
|
9
26
|
const visible = vertical ? scrollAreaContext.showVerticalScrollbar : scrollAreaContext.showHorizontalScrollbar;
|
|
27
|
+
const axisMetrics = vertical ? scrollAreaContext.vertical : scrollAreaContext.horizontal;
|
|
28
|
+
|
|
29
|
+
const setScrollbarRef = React.useCallback((instance: Instance | undefined) => {
|
|
30
|
+
scrollbarRef.current = toGuiObject(instance);
|
|
31
|
+
}, []);
|
|
32
|
+
|
|
33
|
+
const handleInputBegan = React.useCallback(
|
|
34
|
+
(rbx: GuiObject, inputObject: InputObject) => {
|
|
35
|
+
if (!isPointerInput(inputObject) || axisMetrics.contentSize <= axisMetrics.viewportSize) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const track = scrollbarRef.current ?? rbx;
|
|
40
|
+
const trackSize = math.max(1, vertical ? track.AbsoluteSize.Y : track.AbsoluteSize.X);
|
|
41
|
+
const trackStart = vertical ? track.AbsolutePosition.Y : track.AbsolutePosition.X;
|
|
42
|
+
const pointerPosition = vertical ? inputObject.Position.Y : inputObject.Position.X;
|
|
43
|
+
const trackPosition = pointerPosition - trackStart;
|
|
44
|
+
const thumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, trackSize);
|
|
45
|
+
const thumbOffset = resolveThumbOffset(
|
|
46
|
+
axisMetrics.scrollPosition,
|
|
47
|
+
axisMetrics.viewportSize,
|
|
48
|
+
axisMetrics.contentSize,
|
|
49
|
+
trackSize,
|
|
50
|
+
thumbSize,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
if (trackPosition >= thumbOffset && trackPosition <= thumbOffset + thumbSize) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const nextCanvasPosition = resolveCanvasPositionFromTrackPosition(
|
|
58
|
+
trackPosition,
|
|
59
|
+
axisMetrics.viewportSize,
|
|
60
|
+
axisMetrics.contentSize,
|
|
61
|
+
trackSize,
|
|
62
|
+
thumbSize,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
scrollAreaContext.setScrollPosition(props.orientation, nextCanvasPosition);
|
|
66
|
+
},
|
|
67
|
+
[
|
|
68
|
+
axisMetrics.contentSize,
|
|
69
|
+
axisMetrics.scrollPosition,
|
|
70
|
+
axisMetrics.viewportSize,
|
|
71
|
+
props.orientation,
|
|
72
|
+
scrollAreaContext,
|
|
73
|
+
vertical,
|
|
74
|
+
],
|
|
75
|
+
);
|
|
10
76
|
|
|
11
77
|
if (props.asChild) {
|
|
12
78
|
const child = props.children;
|
|
@@ -14,16 +80,23 @@ export function ScrollAreaScrollbar(props: ScrollAreaScrollbarProps) {
|
|
|
14
80
|
error("[ScrollAreaScrollbar] `asChild` requires a child element.");
|
|
15
81
|
}
|
|
16
82
|
|
|
17
|
-
return
|
|
83
|
+
return (
|
|
84
|
+
<Slot Active={visible} Event={{ InputBegan: handleInputBegan }} Visible={visible} ref={setScrollbarRef}>
|
|
85
|
+
{child}
|
|
86
|
+
</Slot>
|
|
87
|
+
);
|
|
18
88
|
}
|
|
19
89
|
|
|
20
90
|
return (
|
|
21
91
|
<frame
|
|
92
|
+
Active={visible}
|
|
22
93
|
BackgroundColor3={Color3.fromRGB(44, 52, 67)}
|
|
23
94
|
BorderSizePixel={0}
|
|
95
|
+
Event={{ InputBegan: handleInputBegan }}
|
|
24
96
|
Position={vertical ? UDim2.fromScale(1, 0) : UDim2.fromScale(0, 1)}
|
|
25
97
|
Size={vertical ? UDim2.fromOffset(8, 160) : UDim2.fromOffset(260, 8)}
|
|
26
98
|
Visible={visible}
|
|
99
|
+
ref={setScrollbarRef}
|
|
27
100
|
>
|
|
28
101
|
{props.children}
|
|
29
102
|
</frame>
|
|
@@ -1,11 +1,41 @@
|
|
|
1
1
|
import { React, Slot } from "@lattice-ui/core";
|
|
2
2
|
import { useScrollAreaContext } from "./context";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
resolveCanvasPositionFromThumbOffset,
|
|
5
|
+
resolveThumbOffset,
|
|
6
|
+
resolveThumbOffsetFromPointerDelta,
|
|
7
|
+
resolveThumbSize,
|
|
8
|
+
} from "./scrollMath";
|
|
4
9
|
import type { ScrollAreaThumbProps } from "./types";
|
|
5
10
|
|
|
11
|
+
const UserInputService = game.GetService("UserInputService");
|
|
12
|
+
|
|
13
|
+
type DragState = {
|
|
14
|
+
inputType: Enum.UserInputType;
|
|
15
|
+
pointerStart: number;
|
|
16
|
+
thumbOffsetStart: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function isThumbDragStart(inputObject: InputObject) {
|
|
20
|
+
return (
|
|
21
|
+
inputObject.UserInputType === Enum.UserInputType.MouseButton1 ||
|
|
22
|
+
inputObject.UserInputType === Enum.UserInputType.Touch
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function toGuiObject(instance: Instance | undefined) {
|
|
27
|
+
if (!instance || !instance.IsA("GuiObject")) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return instance;
|
|
32
|
+
}
|
|
33
|
+
|
|
6
34
|
export function ScrollAreaThumb(props: ScrollAreaThumbProps) {
|
|
7
35
|
const scrollAreaContext = useScrollAreaContext();
|
|
8
36
|
const vertical = props.orientation === "vertical";
|
|
37
|
+
const thumbRef = React.useRef<GuiObject>();
|
|
38
|
+
const dragStateRef = React.useRef<DragState>();
|
|
9
39
|
|
|
10
40
|
const axisMetrics = vertical ? scrollAreaContext.vertical : scrollAreaContext.horizontal;
|
|
11
41
|
const trackSize = math.max(1, axisMetrics.viewportSize);
|
|
@@ -21,6 +51,120 @@ export function ScrollAreaThumb(props: ScrollAreaThumbProps) {
|
|
|
21
51
|
const sizeScale = trackSize > 0 ? thumbSize / trackSize : 1;
|
|
22
52
|
const offsetScale = trackSize > 0 ? thumbOffset / trackSize : 0;
|
|
23
53
|
|
|
54
|
+
const setThumbRef = React.useCallback((instance: Instance | undefined) => {
|
|
55
|
+
thumbRef.current = toGuiObject(instance);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const getTrack = React.useCallback(() => {
|
|
59
|
+
const thumb = thumbRef.current;
|
|
60
|
+
const parent = thumb?.Parent;
|
|
61
|
+
if (!parent || !parent.IsA("GuiObject")) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return parent;
|
|
66
|
+
}, []);
|
|
67
|
+
|
|
68
|
+
const handleInputBegan = React.useCallback(
|
|
69
|
+
(rbx: GuiObject, inputObject: InputObject) => {
|
|
70
|
+
if (!isThumbDragStart(inputObject) || axisMetrics.contentSize <= axisMetrics.viewportSize) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const track = getTrack();
|
|
75
|
+
if (!track) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const actualTrackSize = math.max(1, vertical ? track.AbsoluteSize.Y : track.AbsoluteSize.X);
|
|
80
|
+
const actualThumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, actualTrackSize);
|
|
81
|
+
const actualThumbOffset = resolveThumbOffset(
|
|
82
|
+
axisMetrics.scrollPosition,
|
|
83
|
+
axisMetrics.viewportSize,
|
|
84
|
+
axisMetrics.contentSize,
|
|
85
|
+
actualTrackSize,
|
|
86
|
+
actualThumbSize,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
dragStateRef.current = {
|
|
90
|
+
inputType: inputObject.UserInputType,
|
|
91
|
+
pointerStart: vertical ? inputObject.Position.Y : inputObject.Position.X,
|
|
92
|
+
thumbOffsetStart: actualThumbOffset,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
if (rbx.IsA("GuiButton")) {
|
|
96
|
+
rbx.AutoButtonColor = false;
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
[axisMetrics.contentSize, axisMetrics.scrollPosition, axisMetrics.viewportSize, getTrack, vertical],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
React.useEffect(() => {
|
|
103
|
+
const inputChangedConnection = UserInputService.InputChanged.Connect((inputObject) => {
|
|
104
|
+
const dragState = dragStateRef.current;
|
|
105
|
+
if (!dragState) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const isMatchingInput =
|
|
110
|
+
dragState.inputType === Enum.UserInputType.Touch
|
|
111
|
+
? inputObject.UserInputType === Enum.UserInputType.Touch
|
|
112
|
+
: inputObject.UserInputType === Enum.UserInputType.MouseMovement;
|
|
113
|
+
|
|
114
|
+
if (!isMatchingInput) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const track = getTrack();
|
|
119
|
+
if (!track) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const actualTrackSize = math.max(1, vertical ? track.AbsoluteSize.Y : track.AbsoluteSize.X);
|
|
124
|
+
const actualThumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, actualTrackSize);
|
|
125
|
+
const pointerPosition = vertical ? inputObject.Position.Y : inputObject.Position.X;
|
|
126
|
+
const thumbOffset = resolveThumbOffsetFromPointerDelta(
|
|
127
|
+
dragState.thumbOffsetStart,
|
|
128
|
+
pointerPosition - dragState.pointerStart,
|
|
129
|
+
actualTrackSize,
|
|
130
|
+
actualThumbSize,
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const nextCanvasPosition = resolveCanvasPositionFromThumbOffset(
|
|
134
|
+
thumbOffset,
|
|
135
|
+
axisMetrics.viewportSize,
|
|
136
|
+
axisMetrics.contentSize,
|
|
137
|
+
actualTrackSize,
|
|
138
|
+
actualThumbSize,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
scrollAreaContext.setScrollPosition(props.orientation, nextCanvasPosition);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const inputEndedConnection = UserInputService.InputEnded.Connect((inputObject) => {
|
|
145
|
+
const dragState = dragStateRef.current;
|
|
146
|
+
if (!dragState) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (dragState.inputType === Enum.UserInputType.Touch && inputObject.UserInputType === Enum.UserInputType.Touch) {
|
|
151
|
+
dragStateRef.current = undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
dragState.inputType === Enum.UserInputType.MouseButton1 &&
|
|
156
|
+
inputObject.UserInputType === Enum.UserInputType.MouseButton1
|
|
157
|
+
) {
|
|
158
|
+
dragStateRef.current = undefined;
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return () => {
|
|
163
|
+
inputChangedConnection.Disconnect();
|
|
164
|
+
inputEndedConnection.Disconnect();
|
|
165
|
+
};
|
|
166
|
+
}, [axisMetrics.contentSize, axisMetrics.viewportSize, getTrack, props.orientation, scrollAreaContext, vertical]);
|
|
167
|
+
|
|
24
168
|
if (props.asChild) {
|
|
25
169
|
const child = props.children;
|
|
26
170
|
if (!child) {
|
|
@@ -29,8 +173,11 @@ export function ScrollAreaThumb(props: ScrollAreaThumbProps) {
|
|
|
29
173
|
|
|
30
174
|
return (
|
|
31
175
|
<Slot
|
|
176
|
+
Active={axisMetrics.contentSize > axisMetrics.viewportSize}
|
|
177
|
+
Event={{ InputBegan: handleInputBegan }}
|
|
32
178
|
Position={vertical ? UDim2.fromScale(0, offsetScale) : UDim2.fromScale(offsetScale, 0)}
|
|
33
179
|
Size={vertical ? UDim2.fromScale(1, sizeScale) : UDim2.fromScale(sizeScale, 1)}
|
|
180
|
+
ref={setThumbRef}
|
|
34
181
|
>
|
|
35
182
|
{child}
|
|
36
183
|
</Slot>
|
|
@@ -39,10 +186,13 @@ export function ScrollAreaThumb(props: ScrollAreaThumbProps) {
|
|
|
39
186
|
|
|
40
187
|
return (
|
|
41
188
|
<frame
|
|
189
|
+
Active={axisMetrics.contentSize > axisMetrics.viewportSize}
|
|
42
190
|
BackgroundColor3={Color3.fromRGB(118, 128, 149)}
|
|
43
191
|
BorderSizePixel={0}
|
|
192
|
+
Event={{ InputBegan: handleInputBegan }}
|
|
44
193
|
Position={vertical ? UDim2.fromScale(0, offsetScale) : UDim2.fromScale(offsetScale, 0)}
|
|
45
194
|
Size={vertical ? UDim2.fromScale(1, sizeScale) : UDim2.fromScale(sizeScale, 1)}
|
|
195
|
+
ref={setThumbRef}
|
|
46
196
|
>
|
|
47
197
|
<uicorner CornerRadius={new UDim(1, 0)} />
|
|
48
198
|
{props.children}
|
|
@@ -43,3 +43,29 @@ export function resolveCanvasPositionFromThumbOffset(
|
|
|
43
43
|
const ratio = math.clamp(thumbOffset / maxThumbOffset, 0, 1);
|
|
44
44
|
return ratio * maxScroll;
|
|
45
45
|
}
|
|
46
|
+
|
|
47
|
+
export function resolveThumbOffsetFromTrackPosition(trackPosition: number, trackSize: number, thumbSize: number) {
|
|
48
|
+
const maxThumbOffset = math.max(0, trackSize - thumbSize);
|
|
49
|
+
return math.clamp(trackPosition - thumbSize / 2, 0, maxThumbOffset);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveThumbOffsetFromPointerDelta(
|
|
53
|
+
initialThumbOffset: number,
|
|
54
|
+
pointerDelta: number,
|
|
55
|
+
trackSize: number,
|
|
56
|
+
thumbSize: number,
|
|
57
|
+
) {
|
|
58
|
+
const maxThumbOffset = math.max(0, trackSize - thumbSize);
|
|
59
|
+
return math.clamp(initialThumbOffset + pointerDelta, 0, maxThumbOffset);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resolveCanvasPositionFromTrackPosition(
|
|
63
|
+
trackPosition: number,
|
|
64
|
+
viewportSize: number,
|
|
65
|
+
contentSize: number,
|
|
66
|
+
trackSize: number,
|
|
67
|
+
thumbSize: number,
|
|
68
|
+
) {
|
|
69
|
+
const thumbOffset = resolveThumbOffsetFromTrackPosition(trackPosition, trackSize, thumbSize);
|
|
70
|
+
return resolveCanvasPositionFromThumbOffset(thumbOffset, viewportSize, contentSize, trackSize, thumbSize);
|
|
71
|
+
}
|
package/src/ScrollArea/types.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type ScrollAreaContextValue = {
|
|
|
17
17
|
vertical: ScrollAxisMetrics;
|
|
18
18
|
horizontal: ScrollAxisMetrics;
|
|
19
19
|
setMetrics: (metrics: { vertical: ScrollAxisMetrics; horizontal: ScrollAxisMetrics }) => void;
|
|
20
|
+
setScrollPosition: (orientation: ScrollAreaOrientation, position: number) => void;
|
|
20
21
|
notifyScrollActivity: () => void;
|
|
21
22
|
showVerticalScrollbar: boolean;
|
|
22
23
|
showHorizontalScrollbar: boolean;
|
package/src/index.ts
CHANGED
|
@@ -12,7 +12,14 @@ export const ScrollArea = {
|
|
|
12
12
|
Corner: ScrollAreaCorner,
|
|
13
13
|
} as const;
|
|
14
14
|
|
|
15
|
-
export {
|
|
15
|
+
export {
|
|
16
|
+
resolveCanvasPositionFromThumbOffset,
|
|
17
|
+
resolveCanvasPositionFromTrackPosition,
|
|
18
|
+
resolveThumbOffset,
|
|
19
|
+
resolveThumbOffsetFromPointerDelta,
|
|
20
|
+
resolveThumbOffsetFromTrackPosition,
|
|
21
|
+
resolveThumbSize,
|
|
22
|
+
} from "./ScrollArea/scrollMath";
|
|
16
23
|
export type {
|
|
17
24
|
ScrollAreaContextValue,
|
|
18
25
|
ScrollAreaCornerProps,
|