@lattice-ui/scroll-area 0.3.1 → 0.4.0

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.
@@ -0,0 +1,4 @@
1
+
2
+ > @lattice-ui/scroll-area@0.4.0 build C:\Users\retur\OneDrive\Desktop\Workspace\rojo\unnamed-ui-package\packages\scroll-area
3
+ > rbxtsc -p tsconfig.json
4
+
@@ -0,0 +1,4 @@
1
+
2
+ > @lattice-ui/scroll-area@0.4.0 typecheck /home/runner/work/lattice-ui/lattice-ui/packages/scroll-area
3
+ > tsc -p tsconfig.typecheck.json
4
+
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.1",
3
+ "version": "0.4.0",
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.3.1"
8
+ "@lattice-ui/core": "0.4.0"
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 <Slot Visible={visible}>{child}</Slot>;
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 { resolveThumbOffset, resolveThumbSize } from "./scrollMath";
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
+ }
@@ -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 { resolveCanvasPositionFromThumbOffset, resolveThumbOffset, resolveThumbSize } from "./ScrollArea/scrollMath";
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,