@lattice-ui/scroll-area 0.3.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.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # @lattice-ui/scroll-area
2
+
3
+ Headless scroll area primitives for Roblox UI with custom scrollbars and thumbs.
4
+
5
+ ## Exports
6
+
7
+ - `ScrollArea`
8
+ - `ScrollArea.Root`
9
+ - `ScrollArea.Viewport`
10
+ - `ScrollArea.Scrollbar`
11
+ - `ScrollArea.Thumb`
12
+ - `ScrollArea.Corner`
13
+
14
+ ## Notes
15
+
16
+ - Supports vertical and horizontal scrollbar primitives.
17
+ - `type` supports `auto`, `always`, and `scroll` visibility policies.
18
+ - Thumb/canvas math helpers are exported for unit testing.
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { ScrollAreaCornerProps } from "./types";
3
+ export declare function ScrollAreaCorner(props: ScrollAreaCornerProps): React.JSX.Element;
@@ -0,0 +1,29 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
4
+ local React = _core.React
5
+ local Slot = _core.Slot
6
+ local useScrollAreaContext = TS.import(script, script.Parent, "context").useScrollAreaContext
7
+ local function ScrollAreaCorner(props)
8
+ local scrollAreaContext = useScrollAreaContext()
9
+ local visible = scrollAreaContext.showHorizontalScrollbar and scrollAreaContext.showVerticalScrollbar
10
+ if props.asChild then
11
+ local child = props.children
12
+ if not child then
13
+ error("[ScrollAreaCorner] `asChild` requires a child element.")
14
+ end
15
+ return React.createElement(Slot, {
16
+ Visible = visible,
17
+ }, child)
18
+ end
19
+ return React.createElement("frame", {
20
+ BackgroundColor3 = Color3.fromRGB(44, 52, 67),
21
+ BorderSizePixel = 0,
22
+ Position = UDim2.fromScale(1, 1),
23
+ Size = UDim2.fromOffset(8, 8),
24
+ Visible = visible,
25
+ }, props.children)
26
+ end
27
+ return {
28
+ ScrollAreaCorner = ScrollAreaCorner,
29
+ }
@@ -0,0 +1,4 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { ScrollAreaProps } from "./types";
3
+ export declare function ScrollAreaRoot(props: ScrollAreaProps): React.JSX.Element;
4
+ export { ScrollAreaRoot as ScrollArea };
@@ -0,0 +1,75 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local React = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out).React
4
+ local ScrollAreaContextProvider = TS.import(script, script.Parent, "context").ScrollAreaContextProvider
5
+ local function createAxisMetrics()
6
+ return {
7
+ viewportSize = 0,
8
+ contentSize = 0,
9
+ scrollPosition = 0,
10
+ }
11
+ end
12
+ local function ScrollAreaRoot(props)
13
+ local scrollType = props.type or "auto"
14
+ local _condition = props.scrollHideDelayMs
15
+ if _condition == nil then
16
+ _condition = 600
17
+ end
18
+ local scrollHideDelayMs = math.max(0, _condition)
19
+ local viewportRef = React.useRef()
20
+ local metrics, setMetrics = React.useState(function()
21
+ return {
22
+ vertical = createAxisMetrics(),
23
+ horizontal = createAxisMetrics(),
24
+ }
25
+ end)
26
+ local showScrollbarsFromActivity, setShowScrollbarsFromActivity = React.useState(scrollType ~= "scroll")
27
+ local activitySequenceRef = React.useRef(0)
28
+ React.useEffect(function()
29
+ if scrollType ~= "scroll" then
30
+ setShowScrollbarsFromActivity(true)
31
+ end
32
+ end, { scrollType })
33
+ local setViewport = React.useCallback(function(instance)
34
+ viewportRef.current = instance
35
+ end, {})
36
+ local notifyScrollActivity = React.useCallback(function()
37
+ if scrollType ~= "scroll" then
38
+ return nil
39
+ end
40
+ activitySequenceRef.current += 1
41
+ local sequence = activitySequenceRef.current
42
+ setShowScrollbarsFromActivity(true)
43
+ task.delay(scrollHideDelayMs / 1000, function()
44
+ if sequence ~= activitySequenceRef.current then
45
+ return nil
46
+ end
47
+ setShowScrollbarsFromActivity(false)
48
+ end)
49
+ end, { scrollHideDelayMs, scrollType })
50
+ local hasVerticalOverflow = metrics.vertical.contentSize > metrics.vertical.viewportSize + 1
51
+ local hasHorizontalOverflow = metrics.horizontal.contentSize > metrics.horizontal.viewportSize + 1
52
+ local showVerticalScrollbar = if scrollType == "always" then hasVerticalOverflow elseif scrollType == "scroll" then hasVerticalOverflow and showScrollbarsFromActivity else hasVerticalOverflow
53
+ local showHorizontalScrollbar = if scrollType == "always" then hasHorizontalOverflow elseif scrollType == "scroll" then hasHorizontalOverflow and showScrollbarsFromActivity else hasHorizontalOverflow
54
+ local contextValue = React.useMemo(function()
55
+ return {
56
+ type = scrollType,
57
+ scrollHideDelayMs = scrollHideDelayMs,
58
+ viewportRef = viewportRef,
59
+ setViewport = setViewport,
60
+ vertical = metrics.vertical,
61
+ horizontal = metrics.horizontal,
62
+ setMetrics = setMetrics,
63
+ notifyScrollActivity = notifyScrollActivity,
64
+ showVerticalScrollbar = showVerticalScrollbar,
65
+ showHorizontalScrollbar = showHorizontalScrollbar,
66
+ }
67
+ end, { metrics.horizontal, metrics.vertical, notifyScrollActivity, scrollHideDelayMs, setViewport, showHorizontalScrollbar, showVerticalScrollbar, scrollType })
68
+ return React.createElement(ScrollAreaContextProvider, {
69
+ value = contextValue,
70
+ }, props.children)
71
+ end
72
+ return {
73
+ ScrollAreaRoot = ScrollAreaRoot,
74
+ ScrollArea = ScrollAreaRoot,
75
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { ScrollAreaScrollbarProps } from "./types";
3
+ export declare function ScrollAreaScrollbar(props: ScrollAreaScrollbarProps): React.JSX.Element;
@@ -0,0 +1,30 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
4
+ local React = _core.React
5
+ local Slot = _core.Slot
6
+ local useScrollAreaContext = TS.import(script, script.Parent, "context").useScrollAreaContext
7
+ local function ScrollAreaScrollbar(props)
8
+ local scrollAreaContext = useScrollAreaContext()
9
+ local vertical = props.orientation == "vertical"
10
+ local visible = if vertical then scrollAreaContext.showVerticalScrollbar else scrollAreaContext.showHorizontalScrollbar
11
+ if props.asChild then
12
+ local child = props.children
13
+ if not child then
14
+ error("[ScrollAreaScrollbar] `asChild` requires a child element.")
15
+ end
16
+ return React.createElement(Slot, {
17
+ Visible = visible,
18
+ }, child)
19
+ end
20
+ return React.createElement("frame", {
21
+ BackgroundColor3 = Color3.fromRGB(44, 52, 67),
22
+ BorderSizePixel = 0,
23
+ Position = if vertical then UDim2.fromScale(1, 0) else UDim2.fromScale(0, 1),
24
+ Size = if vertical then UDim2.fromOffset(8, 160) else UDim2.fromOffset(260, 8),
25
+ Visible = visible,
26
+ }, props.children)
27
+ end
28
+ return {
29
+ ScrollAreaScrollbar = ScrollAreaScrollbar,
30
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { ScrollAreaThumbProps } from "./types";
3
+ export declare function ScrollAreaThumb(props: ScrollAreaThumbProps): React.JSX.Element;
@@ -0,0 +1,40 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
4
+ local React = _core.React
5
+ local Slot = _core.Slot
6
+ local useScrollAreaContext = TS.import(script, script.Parent, "context").useScrollAreaContext
7
+ local _scrollMath = TS.import(script, script.Parent, "scrollMath")
8
+ local resolveThumbOffset = _scrollMath.resolveThumbOffset
9
+ local resolveThumbSize = _scrollMath.resolveThumbSize
10
+ local function ScrollAreaThumb(props)
11
+ local scrollAreaContext = useScrollAreaContext()
12
+ local vertical = props.orientation == "vertical"
13
+ local axisMetrics = if vertical then scrollAreaContext.vertical else scrollAreaContext.horizontal
14
+ local trackSize = math.max(1, axisMetrics.viewportSize)
15
+ local thumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, trackSize)
16
+ local thumbOffset = resolveThumbOffset(axisMetrics.scrollPosition, axisMetrics.viewportSize, axisMetrics.contentSize, trackSize, thumbSize)
17
+ local sizeScale = if trackSize > 0 then thumbSize / trackSize else 1
18
+ local offsetScale = if trackSize > 0 then thumbOffset / trackSize else 0
19
+ if props.asChild then
20
+ local child = props.children
21
+ if not child then
22
+ error("[ScrollAreaThumb] `asChild` requires a child element.")
23
+ end
24
+ return React.createElement(Slot, {
25
+ Position = if vertical then UDim2.fromScale(0, offsetScale) else UDim2.fromScale(offsetScale, 0),
26
+ Size = if vertical then UDim2.fromScale(1, sizeScale) else UDim2.fromScale(sizeScale, 1),
27
+ }, child)
28
+ end
29
+ return React.createElement("frame", {
30
+ BackgroundColor3 = Color3.fromRGB(118, 128, 149),
31
+ BorderSizePixel = 0,
32
+ Position = if vertical then UDim2.fromScale(0, offsetScale) else UDim2.fromScale(offsetScale, 0),
33
+ Size = if vertical then UDim2.fromScale(1, sizeScale) else UDim2.fromScale(sizeScale, 1),
34
+ }, React.createElement("uicorner", {
35
+ CornerRadius = UDim.new(1, 0),
36
+ }), props.children)
37
+ end
38
+ return {
39
+ ScrollAreaThumb = ScrollAreaThumb,
40
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { ScrollAreaViewportProps } from "./types";
3
+ export declare function ScrollAreaViewport(props: ScrollAreaViewportProps): React.JSX.Element;
@@ -0,0 +1,74 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
4
+ local React = _core.React
5
+ local Slot = _core.Slot
6
+ local useScrollAreaContext = TS.import(script, script.Parent, "context").useScrollAreaContext
7
+ local function toScrollingFrame(instance)
8
+ if not instance or not instance:IsA("ScrollingFrame") then
9
+ return nil
10
+ end
11
+ return instance
12
+ end
13
+ local function ScrollAreaViewport(props)
14
+ local scrollAreaContext = useScrollAreaContext()
15
+ local setViewportRef = React.useCallback(function(instance)
16
+ scrollAreaContext.setViewport(toScrollingFrame(instance))
17
+ end, { scrollAreaContext })
18
+ React.useEffect(function()
19
+ local viewport = scrollAreaContext.viewportRef.current
20
+ if not viewport then
21
+ return nil
22
+ end
23
+ local updateMetrics = function()
24
+ scrollAreaContext.setMetrics({
25
+ vertical = {
26
+ viewportSize = viewport.AbsoluteWindowSize.Y,
27
+ contentSize = viewport.AbsoluteCanvasSize.Y,
28
+ scrollPosition = viewport.CanvasPosition.Y,
29
+ },
30
+ horizontal = {
31
+ viewportSize = viewport.AbsoluteWindowSize.X,
32
+ contentSize = viewport.AbsoluteCanvasSize.X,
33
+ scrollPosition = viewport.CanvasPosition.X,
34
+ },
35
+ })
36
+ end
37
+ updateMetrics()
38
+ local canvasConnection = viewport:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
39
+ updateMetrics()
40
+ scrollAreaContext.notifyScrollActivity()
41
+ end)
42
+ local absoluteCanvasConnection = viewport:GetPropertyChangedSignal("AbsoluteCanvasSize"):Connect(updateMetrics)
43
+ local absoluteWindowConnection = viewport:GetPropertyChangedSignal("AbsoluteWindowSize"):Connect(updateMetrics)
44
+ return function()
45
+ canvasConnection:Disconnect()
46
+ absoluteCanvasConnection:Disconnect()
47
+ absoluteWindowConnection:Disconnect()
48
+ end
49
+ end, { scrollAreaContext })
50
+ if props.asChild then
51
+ local child = props.children
52
+ if not child then
53
+ error("[ScrollAreaViewport] `asChild` requires a child element.")
54
+ end
55
+ return React.createElement(Slot, {
56
+ ref = setViewportRef,
57
+ }, child)
58
+ end
59
+ return React.createElement("scrollingframe", {
60
+ Active = true,
61
+ AutomaticCanvasSize = Enum.AutomaticSize.XY,
62
+ BackgroundTransparency = 1,
63
+ BorderSizePixel = 0,
64
+ CanvasSize = UDim2.fromScale(0, 0),
65
+ ScrollBarImageTransparency = 1,
66
+ ScrollBarThickness = 0,
67
+ ScrollingDirection = Enum.ScrollingDirection.XY,
68
+ Size = UDim2.fromOffset(260, 160),
69
+ ref = setViewportRef,
70
+ }, props.children)
71
+ end
72
+ return {
73
+ ScrollAreaViewport = ScrollAreaViewport,
74
+ }
@@ -0,0 +1,3 @@
1
+ import type { ScrollAreaContextValue } from "./types";
2
+ declare const ScrollAreaContextProvider: import("@rbxts/react").Provider<ScrollAreaContextValue | undefined>, useScrollAreaContext: () => ScrollAreaContextValue;
3
+ export { ScrollAreaContextProvider, useScrollAreaContext };
@@ -0,0 +1,10 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local createStrictContext = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out).createStrictContext
4
+ local _binding = createStrictContext("ScrollArea")
5
+ local ScrollAreaContextProvider = _binding[1]
6
+ local useScrollAreaContext = _binding[2]
7
+ return {
8
+ ScrollAreaContextProvider = ScrollAreaContextProvider,
9
+ useScrollAreaContext = useScrollAreaContext,
10
+ }
@@ -0,0 +1,3 @@
1
+ export declare function resolveThumbSize(viewportSize: number, contentSize: number, trackSize: number, minimumThumbSize?: number): number;
2
+ export declare function resolveThumbOffset(scrollPosition: number, viewportSize: number, contentSize: number, trackSize: number, thumbSize: number): number;
3
+ export declare function resolveCanvasPositionFromThumbOffset(thumbOffset: number, viewportSize: number, contentSize: number, trackSize: number, thumbSize: number): number;
@@ -0,0 +1,36 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local function resolveThumbSize(viewportSize, contentSize, trackSize, minimumThumbSize)
3
+ if minimumThumbSize == nil then
4
+ minimumThumbSize = 18
5
+ end
6
+ if viewportSize <= 0 or contentSize <= 0 or trackSize <= 0 then
7
+ return minimumThumbSize
8
+ end
9
+ if contentSize <= viewportSize then
10
+ return trackSize
11
+ end
12
+ local proportionalSize = (viewportSize / contentSize) * trackSize
13
+ return math.clamp(proportionalSize, minimumThumbSize, trackSize)
14
+ end
15
+ local function resolveThumbOffset(scrollPosition, viewportSize, contentSize, trackSize, thumbSize)
16
+ if contentSize <= viewportSize or trackSize <= thumbSize then
17
+ return 0
18
+ end
19
+ local maxScroll = math.max(1, contentSize - viewportSize)
20
+ local maxThumbOffset = trackSize - thumbSize
21
+ return math.clamp((scrollPosition / maxScroll) * maxThumbOffset, 0, maxThumbOffset)
22
+ end
23
+ local function resolveCanvasPositionFromThumbOffset(thumbOffset, viewportSize, contentSize, trackSize, thumbSize)
24
+ if contentSize <= viewportSize or trackSize <= thumbSize then
25
+ return 0
26
+ end
27
+ local maxScroll = math.max(1, contentSize - viewportSize)
28
+ local maxThumbOffset = trackSize - thumbSize
29
+ local ratio = math.clamp(thumbOffset / maxThumbOffset, 0, 1)
30
+ return ratio * maxScroll
31
+ end
32
+ return {
33
+ resolveThumbSize = resolveThumbSize,
34
+ resolveThumbOffset = resolveThumbOffset,
35
+ resolveCanvasPositionFromThumbOffset = resolveCanvasPositionFromThumbOffset,
36
+ }
@@ -0,0 +1,46 @@
1
+ import type React from "@rbxts/react";
2
+ export type ScrollAreaType = "auto" | "always" | "scroll";
3
+ export type ScrollAreaOrientation = "vertical" | "horizontal";
4
+ export type ScrollAxisMetrics = {
5
+ viewportSize: number;
6
+ contentSize: number;
7
+ scrollPosition: number;
8
+ };
9
+ export type ScrollAreaContextValue = {
10
+ type: ScrollAreaType;
11
+ scrollHideDelayMs: number;
12
+ viewportRef: React.MutableRefObject<ScrollingFrame | undefined>;
13
+ setViewport: (instance: ScrollingFrame | undefined) => void;
14
+ vertical: ScrollAxisMetrics;
15
+ horizontal: ScrollAxisMetrics;
16
+ setMetrics: (metrics: {
17
+ vertical: ScrollAxisMetrics;
18
+ horizontal: ScrollAxisMetrics;
19
+ }) => void;
20
+ notifyScrollActivity: () => void;
21
+ showVerticalScrollbar: boolean;
22
+ showHorizontalScrollbar: boolean;
23
+ };
24
+ export type ScrollAreaProps = {
25
+ type?: ScrollAreaType;
26
+ scrollHideDelayMs?: number;
27
+ children?: React.ReactNode;
28
+ };
29
+ export type ScrollAreaViewportProps = {
30
+ asChild?: boolean;
31
+ children?: React.ReactElement;
32
+ };
33
+ export type ScrollAreaScrollbarProps = {
34
+ orientation: ScrollAreaOrientation;
35
+ asChild?: boolean;
36
+ children?: React.ReactElement;
37
+ };
38
+ export type ScrollAreaThumbProps = {
39
+ orientation: ScrollAreaOrientation;
40
+ asChild?: boolean;
41
+ children?: React.ReactElement;
42
+ };
43
+ export type ScrollAreaCornerProps = {
44
+ asChild?: boolean;
45
+ children?: React.ReactElement;
46
+ };
@@ -0,0 +1,2 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ return nil
package/out/index.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { ScrollAreaCorner } from "./ScrollArea/ScrollAreaCorner";
2
+ import { ScrollAreaRoot } from "./ScrollArea/ScrollAreaRoot";
3
+ import { ScrollAreaScrollbar } from "./ScrollArea/ScrollAreaScrollbar";
4
+ import { ScrollAreaThumb } from "./ScrollArea/ScrollAreaThumb";
5
+ import { ScrollAreaViewport } from "./ScrollArea/ScrollAreaViewport";
6
+ export declare const ScrollArea: {
7
+ readonly Root: typeof ScrollAreaRoot;
8
+ readonly Viewport: typeof ScrollAreaViewport;
9
+ readonly Scrollbar: typeof ScrollAreaScrollbar;
10
+ readonly Thumb: typeof ScrollAreaThumb;
11
+ readonly Corner: typeof ScrollAreaCorner;
12
+ };
13
+ export { resolveCanvasPositionFromThumbOffset, resolveThumbOffset, resolveThumbSize } from "./ScrollArea/scrollMath";
14
+ export type { ScrollAreaContextValue, ScrollAreaCornerProps, ScrollAreaOrientation, ScrollAreaProps, ScrollAreaScrollbarProps, ScrollAreaThumbProps, ScrollAreaType, ScrollAreaViewportProps, ScrollAxisMetrics, } from "./ScrollArea/types";
package/out/init.luau ADDED
@@ -0,0 +1,21 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local exports = {}
4
+ local ScrollAreaCorner = TS.import(script, script, "ScrollArea", "ScrollAreaCorner").ScrollAreaCorner
5
+ local ScrollAreaRoot = TS.import(script, script, "ScrollArea", "ScrollAreaRoot").ScrollAreaRoot
6
+ local ScrollAreaScrollbar = TS.import(script, script, "ScrollArea", "ScrollAreaScrollbar").ScrollAreaScrollbar
7
+ local ScrollAreaThumb = TS.import(script, script, "ScrollArea", "ScrollAreaThumb").ScrollAreaThumb
8
+ local ScrollAreaViewport = TS.import(script, script, "ScrollArea", "ScrollAreaViewport").ScrollAreaViewport
9
+ local ScrollArea = {
10
+ Root = ScrollAreaRoot,
11
+ Viewport = ScrollAreaViewport,
12
+ Scrollbar = ScrollAreaScrollbar,
13
+ Thumb = ScrollAreaThumb,
14
+ Corner = ScrollAreaCorner,
15
+ }
16
+ local _scrollMath = TS.import(script, script, "ScrollArea", "scrollMath")
17
+ exports.resolveCanvasPositionFromThumbOffset = _scrollMath.resolveCanvasPositionFromThumbOffset
18
+ exports.resolveThumbOffset = _scrollMath.resolveThumbOffset
19
+ exports.resolveThumbSize = _scrollMath.resolveThumbSize
20
+ exports.ScrollArea = ScrollArea
21
+ return exports
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@lattice-ui/scroll-area",
3
+ "version": "0.3.0",
4
+ "private": false,
5
+ "main": "out/init.luau",
6
+ "types": "out/index.d.ts",
7
+ "dependencies": {
8
+ "@lattice-ui/core": "0.3.0"
9
+ },
10
+ "devDependencies": {
11
+ "@rbxts/react": "17.3.7-ts.1",
12
+ "@rbxts/react-roblox": "17.3.7-ts.1"
13
+ },
14
+ "peerDependencies": {
15
+ "@rbxts/react": "^17",
16
+ "@rbxts/react-roblox": "^17"
17
+ },
18
+ "scripts": {
19
+ "build": "rbxtsc -p tsconfig.json",
20
+ "typecheck": "tsc -p tsconfig.typecheck.json",
21
+ "watch": "rbxtsc -p tsconfig.json -w"
22
+ }
23
+ }
@@ -0,0 +1,29 @@
1
+ import { React, Slot } from "@lattice-ui/core";
2
+ import { useScrollAreaContext } from "./context";
3
+ import type { ScrollAreaCornerProps } from "./types";
4
+
5
+ export function ScrollAreaCorner(props: ScrollAreaCornerProps) {
6
+ const scrollAreaContext = useScrollAreaContext();
7
+ const visible = scrollAreaContext.showHorizontalScrollbar && scrollAreaContext.showVerticalScrollbar;
8
+
9
+ if (props.asChild) {
10
+ const child = props.children;
11
+ if (!child) {
12
+ error("[ScrollAreaCorner] `asChild` requires a child element.");
13
+ }
14
+
15
+ return <Slot Visible={visible}>{child}</Slot>;
16
+ }
17
+
18
+ return (
19
+ <frame
20
+ BackgroundColor3={Color3.fromRGB(44, 52, 67)}
21
+ BorderSizePixel={0}
22
+ Position={UDim2.fromScale(1, 1)}
23
+ Size={UDim2.fromOffset(8, 8)}
24
+ Visible={visible}
25
+ >
26
+ {props.children}
27
+ </frame>
28
+ );
29
+ }
@@ -0,0 +1,99 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import { ScrollAreaContextProvider } from "./context";
3
+ import type { ScrollAreaProps, ScrollAxisMetrics } from "./types";
4
+
5
+ function createAxisMetrics(): ScrollAxisMetrics {
6
+ return {
7
+ viewportSize: 0,
8
+ contentSize: 0,
9
+ scrollPosition: 0,
10
+ };
11
+ }
12
+
13
+ export function ScrollAreaRoot(props: ScrollAreaProps) {
14
+ const scrollType = props.type ?? "auto";
15
+ const scrollHideDelayMs = math.max(0, props.scrollHideDelayMs ?? 600);
16
+
17
+ const viewportRef = React.useRef<ScrollingFrame>();
18
+ const [metrics, setMetrics] = React.useState(() => ({
19
+ vertical: createAxisMetrics(),
20
+ horizontal: createAxisMetrics(),
21
+ }));
22
+ const [showScrollbarsFromActivity, setShowScrollbarsFromActivity] = React.useState(scrollType !== "scroll");
23
+ const activitySequenceRef = React.useRef(0);
24
+
25
+ React.useEffect(() => {
26
+ if (scrollType !== "scroll") {
27
+ setShowScrollbarsFromActivity(true);
28
+ }
29
+ }, [scrollType]);
30
+
31
+ const setViewport = React.useCallback((instance: ScrollingFrame | undefined) => {
32
+ viewportRef.current = instance;
33
+ }, []);
34
+
35
+ const notifyScrollActivity = React.useCallback(() => {
36
+ if (scrollType !== "scroll") {
37
+ return;
38
+ }
39
+
40
+ activitySequenceRef.current += 1;
41
+ const sequence = activitySequenceRef.current;
42
+
43
+ setShowScrollbarsFromActivity(true);
44
+
45
+ task.delay(scrollHideDelayMs / 1000, () => {
46
+ if (sequence !== activitySequenceRef.current) {
47
+ return;
48
+ }
49
+
50
+ setShowScrollbarsFromActivity(false);
51
+ });
52
+ }, [scrollHideDelayMs, scrollType]);
53
+
54
+ const hasVerticalOverflow = metrics.vertical.contentSize > metrics.vertical.viewportSize + 1;
55
+ const hasHorizontalOverflow = metrics.horizontal.contentSize > metrics.horizontal.viewportSize + 1;
56
+
57
+ const showVerticalScrollbar =
58
+ scrollType === "always"
59
+ ? hasVerticalOverflow
60
+ : scrollType === "scroll"
61
+ ? hasVerticalOverflow && showScrollbarsFromActivity
62
+ : hasVerticalOverflow;
63
+
64
+ const showHorizontalScrollbar =
65
+ scrollType === "always"
66
+ ? hasHorizontalOverflow
67
+ : scrollType === "scroll"
68
+ ? hasHorizontalOverflow && showScrollbarsFromActivity
69
+ : hasHorizontalOverflow;
70
+
71
+ const contextValue = React.useMemo(
72
+ () => ({
73
+ type: scrollType,
74
+ scrollHideDelayMs,
75
+ viewportRef,
76
+ setViewport,
77
+ vertical: metrics.vertical,
78
+ horizontal: metrics.horizontal,
79
+ setMetrics,
80
+ notifyScrollActivity,
81
+ showVerticalScrollbar,
82
+ showHorizontalScrollbar,
83
+ }),
84
+ [
85
+ metrics.horizontal,
86
+ metrics.vertical,
87
+ notifyScrollActivity,
88
+ scrollHideDelayMs,
89
+ setViewport,
90
+ showHorizontalScrollbar,
91
+ showVerticalScrollbar,
92
+ scrollType,
93
+ ],
94
+ );
95
+
96
+ return <ScrollAreaContextProvider value={contextValue}>{props.children}</ScrollAreaContextProvider>;
97
+ }
98
+
99
+ export { ScrollAreaRoot as ScrollArea };
@@ -0,0 +1,31 @@
1
+ import { React, Slot } from "@lattice-ui/core";
2
+ import { useScrollAreaContext } from "./context";
3
+ import type { ScrollAreaScrollbarProps } from "./types";
4
+
5
+ export function ScrollAreaScrollbar(props: ScrollAreaScrollbarProps) {
6
+ const scrollAreaContext = useScrollAreaContext();
7
+
8
+ const vertical = props.orientation === "vertical";
9
+ const visible = vertical ? scrollAreaContext.showVerticalScrollbar : scrollAreaContext.showHorizontalScrollbar;
10
+
11
+ if (props.asChild) {
12
+ const child = props.children;
13
+ if (!child) {
14
+ error("[ScrollAreaScrollbar] `asChild` requires a child element.");
15
+ }
16
+
17
+ return <Slot Visible={visible}>{child}</Slot>;
18
+ }
19
+
20
+ return (
21
+ <frame
22
+ BackgroundColor3={Color3.fromRGB(44, 52, 67)}
23
+ BorderSizePixel={0}
24
+ Position={vertical ? UDim2.fromScale(1, 0) : UDim2.fromScale(0, 1)}
25
+ Size={vertical ? UDim2.fromOffset(8, 160) : UDim2.fromOffset(260, 8)}
26
+ Visible={visible}
27
+ >
28
+ {props.children}
29
+ </frame>
30
+ );
31
+ }
@@ -0,0 +1,51 @@
1
+ import { React, Slot } from "@lattice-ui/core";
2
+ import { useScrollAreaContext } from "./context";
3
+ import { resolveThumbOffset, resolveThumbSize } from "./scrollMath";
4
+ import type { ScrollAreaThumbProps } from "./types";
5
+
6
+ export function ScrollAreaThumb(props: ScrollAreaThumbProps) {
7
+ const scrollAreaContext = useScrollAreaContext();
8
+ const vertical = props.orientation === "vertical";
9
+
10
+ const axisMetrics = vertical ? scrollAreaContext.vertical : scrollAreaContext.horizontal;
11
+ const trackSize = math.max(1, axisMetrics.viewportSize);
12
+ const thumbSize = resolveThumbSize(axisMetrics.viewportSize, axisMetrics.contentSize, trackSize);
13
+ const thumbOffset = resolveThumbOffset(
14
+ axisMetrics.scrollPosition,
15
+ axisMetrics.viewportSize,
16
+ axisMetrics.contentSize,
17
+ trackSize,
18
+ thumbSize,
19
+ );
20
+
21
+ const sizeScale = trackSize > 0 ? thumbSize / trackSize : 1;
22
+ const offsetScale = trackSize > 0 ? thumbOffset / trackSize : 0;
23
+
24
+ if (props.asChild) {
25
+ const child = props.children;
26
+ if (!child) {
27
+ error("[ScrollAreaThumb] `asChild` requires a child element.");
28
+ }
29
+
30
+ return (
31
+ <Slot
32
+ Position={vertical ? UDim2.fromScale(0, offsetScale) : UDim2.fromScale(offsetScale, 0)}
33
+ Size={vertical ? UDim2.fromScale(1, sizeScale) : UDim2.fromScale(sizeScale, 1)}
34
+ >
35
+ {child}
36
+ </Slot>
37
+ );
38
+ }
39
+
40
+ return (
41
+ <frame
42
+ BackgroundColor3={Color3.fromRGB(118, 128, 149)}
43
+ BorderSizePixel={0}
44
+ Position={vertical ? UDim2.fromScale(0, offsetScale) : UDim2.fromScale(offsetScale, 0)}
45
+ Size={vertical ? UDim2.fromScale(1, sizeScale) : UDim2.fromScale(sizeScale, 1)}
46
+ >
47
+ <uicorner CornerRadius={new UDim(1, 0)} />
48
+ {props.children}
49
+ </frame>
50
+ );
51
+ }
@@ -0,0 +1,86 @@
1
+ import { React, Slot } from "@lattice-ui/core";
2
+ import { useScrollAreaContext } from "./context";
3
+ import type { ScrollAreaViewportProps } from "./types";
4
+
5
+ function toScrollingFrame(instance: Instance | undefined) {
6
+ if (!instance || !instance.IsA("ScrollingFrame")) {
7
+ return undefined;
8
+ }
9
+
10
+ return instance;
11
+ }
12
+
13
+ export function ScrollAreaViewport(props: ScrollAreaViewportProps) {
14
+ const scrollAreaContext = useScrollAreaContext();
15
+
16
+ const setViewportRef = React.useCallback(
17
+ (instance: Instance | undefined) => {
18
+ scrollAreaContext.setViewport(toScrollingFrame(instance));
19
+ },
20
+ [scrollAreaContext],
21
+ );
22
+
23
+ React.useEffect(() => {
24
+ const viewport = scrollAreaContext.viewportRef.current;
25
+ if (!viewport) {
26
+ return;
27
+ }
28
+
29
+ const updateMetrics = () => {
30
+ scrollAreaContext.setMetrics({
31
+ vertical: {
32
+ viewportSize: viewport.AbsoluteWindowSize.Y,
33
+ contentSize: viewport.AbsoluteCanvasSize.Y,
34
+ scrollPosition: viewport.CanvasPosition.Y,
35
+ },
36
+ horizontal: {
37
+ viewportSize: viewport.AbsoluteWindowSize.X,
38
+ contentSize: viewport.AbsoluteCanvasSize.X,
39
+ scrollPosition: viewport.CanvasPosition.X,
40
+ },
41
+ });
42
+ };
43
+
44
+ updateMetrics();
45
+
46
+ const canvasConnection = viewport.GetPropertyChangedSignal("CanvasPosition").Connect(() => {
47
+ updateMetrics();
48
+ scrollAreaContext.notifyScrollActivity();
49
+ });
50
+
51
+ const absoluteCanvasConnection = viewport.GetPropertyChangedSignal("AbsoluteCanvasSize").Connect(updateMetrics);
52
+ const absoluteWindowConnection = viewport.GetPropertyChangedSignal("AbsoluteWindowSize").Connect(updateMetrics);
53
+
54
+ return () => {
55
+ canvasConnection.Disconnect();
56
+ absoluteCanvasConnection.Disconnect();
57
+ absoluteWindowConnection.Disconnect();
58
+ };
59
+ }, [scrollAreaContext]);
60
+
61
+ if (props.asChild) {
62
+ const child = props.children;
63
+ if (!child) {
64
+ error("[ScrollAreaViewport] `asChild` requires a child element.");
65
+ }
66
+
67
+ return <Slot ref={setViewportRef}>{child}</Slot>;
68
+ }
69
+
70
+ return (
71
+ <scrollingframe
72
+ Active
73
+ AutomaticCanvasSize={Enum.AutomaticSize.XY}
74
+ BackgroundTransparency={1}
75
+ BorderSizePixel={0}
76
+ CanvasSize={UDim2.fromScale(0, 0)}
77
+ ScrollBarImageTransparency={1}
78
+ ScrollBarThickness={0}
79
+ ScrollingDirection={Enum.ScrollingDirection.XY}
80
+ Size={UDim2.fromOffset(260, 160)}
81
+ ref={setViewportRef}
82
+ >
83
+ {props.children}
84
+ </scrollingframe>
85
+ );
86
+ }
@@ -0,0 +1,6 @@
1
+ import { createStrictContext } from "@lattice-ui/core";
2
+ import type { ScrollAreaContextValue } from "./types";
3
+
4
+ const [ScrollAreaContextProvider, useScrollAreaContext] = createStrictContext<ScrollAreaContextValue>("ScrollArea");
5
+
6
+ export { ScrollAreaContextProvider, useScrollAreaContext };
@@ -0,0 +1,45 @@
1
+ export function resolveThumbSize(viewportSize: number, contentSize: number, trackSize: number, minimumThumbSize = 18) {
2
+ if (viewportSize <= 0 || contentSize <= 0 || trackSize <= 0) {
3
+ return minimumThumbSize;
4
+ }
5
+
6
+ if (contentSize <= viewportSize) {
7
+ return trackSize;
8
+ }
9
+
10
+ const proportionalSize = (viewportSize / contentSize) * trackSize;
11
+ return math.clamp(proportionalSize, minimumThumbSize, trackSize);
12
+ }
13
+
14
+ export function resolveThumbOffset(
15
+ scrollPosition: number,
16
+ viewportSize: number,
17
+ contentSize: number,
18
+ trackSize: number,
19
+ thumbSize: number,
20
+ ) {
21
+ if (contentSize <= viewportSize || trackSize <= thumbSize) {
22
+ return 0;
23
+ }
24
+
25
+ const maxScroll = math.max(1, contentSize - viewportSize);
26
+ const maxThumbOffset = trackSize - thumbSize;
27
+ return math.clamp((scrollPosition / maxScroll) * maxThumbOffset, 0, maxThumbOffset);
28
+ }
29
+
30
+ export function resolveCanvasPositionFromThumbOffset(
31
+ thumbOffset: number,
32
+ viewportSize: number,
33
+ contentSize: number,
34
+ trackSize: number,
35
+ thumbSize: number,
36
+ ) {
37
+ if (contentSize <= viewportSize || trackSize <= thumbSize) {
38
+ return 0;
39
+ }
40
+
41
+ const maxScroll = math.max(1, contentSize - viewportSize);
42
+ const maxThumbOffset = trackSize - thumbSize;
43
+ const ratio = math.clamp(thumbOffset / maxThumbOffset, 0, 1);
44
+ return ratio * maxScroll;
45
+ }
@@ -0,0 +1,51 @@
1
+ import type React from "@rbxts/react";
2
+
3
+ export type ScrollAreaType = "auto" | "always" | "scroll";
4
+ export type ScrollAreaOrientation = "vertical" | "horizontal";
5
+
6
+ export type ScrollAxisMetrics = {
7
+ viewportSize: number;
8
+ contentSize: number;
9
+ scrollPosition: number;
10
+ };
11
+
12
+ export type ScrollAreaContextValue = {
13
+ type: ScrollAreaType;
14
+ scrollHideDelayMs: number;
15
+ viewportRef: React.MutableRefObject<ScrollingFrame | undefined>;
16
+ setViewport: (instance: ScrollingFrame | undefined) => void;
17
+ vertical: ScrollAxisMetrics;
18
+ horizontal: ScrollAxisMetrics;
19
+ setMetrics: (metrics: { vertical: ScrollAxisMetrics; horizontal: ScrollAxisMetrics }) => void;
20
+ notifyScrollActivity: () => void;
21
+ showVerticalScrollbar: boolean;
22
+ showHorizontalScrollbar: boolean;
23
+ };
24
+
25
+ export type ScrollAreaProps = {
26
+ type?: ScrollAreaType;
27
+ scrollHideDelayMs?: number;
28
+ children?: React.ReactNode;
29
+ };
30
+
31
+ export type ScrollAreaViewportProps = {
32
+ asChild?: boolean;
33
+ children?: React.ReactElement;
34
+ };
35
+
36
+ export type ScrollAreaScrollbarProps = {
37
+ orientation: ScrollAreaOrientation;
38
+ asChild?: boolean;
39
+ children?: React.ReactElement;
40
+ };
41
+
42
+ export type ScrollAreaThumbProps = {
43
+ orientation: ScrollAreaOrientation;
44
+ asChild?: boolean;
45
+ children?: React.ReactElement;
46
+ };
47
+
48
+ export type ScrollAreaCornerProps = {
49
+ asChild?: boolean;
50
+ children?: React.ReactElement;
51
+ };
package/src/index.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { ScrollAreaCorner } from "./ScrollArea/ScrollAreaCorner";
2
+ import { ScrollAreaRoot } from "./ScrollArea/ScrollAreaRoot";
3
+ import { ScrollAreaScrollbar } from "./ScrollArea/ScrollAreaScrollbar";
4
+ import { ScrollAreaThumb } from "./ScrollArea/ScrollAreaThumb";
5
+ import { ScrollAreaViewport } from "./ScrollArea/ScrollAreaViewport";
6
+
7
+ export const ScrollArea = {
8
+ Root: ScrollAreaRoot,
9
+ Viewport: ScrollAreaViewport,
10
+ Scrollbar: ScrollAreaScrollbar,
11
+ Thumb: ScrollAreaThumb,
12
+ Corner: ScrollAreaCorner,
13
+ } as const;
14
+
15
+ export { resolveCanvasPositionFromThumbOffset, resolveThumbOffset, resolveThumbSize } from "./ScrollArea/scrollMath";
16
+ export type {
17
+ ScrollAreaContextValue,
18
+ ScrollAreaCornerProps,
19
+ ScrollAreaOrientation,
20
+ ScrollAreaProps,
21
+ ScrollAreaScrollbarProps,
22
+ ScrollAreaThumbProps,
23
+ ScrollAreaType,
24
+ ScrollAreaViewportProps,
25
+ ScrollAxisMetrics,
26
+ } from "./ScrollArea/types";
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "out",
6
+ "declaration": true,
7
+ "typeRoots": [
8
+ "./node_modules/@rbxts",
9
+ "../../node_modules/@rbxts",
10
+ "./node_modules/@lattice-ui",
11
+ "../../node_modules/@lattice-ui"
12
+ ],
13
+ "types": ["types", "compiler-types"]
14
+ },
15
+ "include": ["src"]
16
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "baseUrl": "..",
6
+ "rootDir": "..",
7
+ "paths": {
8
+ "@lattice-ui/accordion": ["accordion/src/index.ts"],
9
+ "@lattice-ui/avatar": ["avatar/src/index.ts"],
10
+ "@lattice-ui/checkbox": ["checkbox/src/index.ts"],
11
+ "@lattice-ui/combobox": ["combobox/src/index.ts"],
12
+ "@lattice-ui/core": ["core/src/index.ts"],
13
+ "@lattice-ui/dialog": ["dialog/src/index.ts"],
14
+ "@lattice-ui/focus": ["focus/src/index.ts"],
15
+ "@lattice-ui/layer": ["layer/src/index.ts"],
16
+ "@lattice-ui/menu": ["menu/src/index.ts"],
17
+ "@lattice-ui/popover": ["popover/src/index.ts"],
18
+ "@lattice-ui/popper": ["popper/src/index.ts"],
19
+ "@lattice-ui/progress": ["progress/src/index.ts"],
20
+ "@lattice-ui/radio-group": ["radio-group/src/index.ts"],
21
+ "@lattice-ui/scroll-area": ["scroll-area/src/index.ts"],
22
+ "@lattice-ui/select": ["select/src/index.ts"],
23
+ "@lattice-ui/slider": ["slider/src/index.ts"],
24
+ "@lattice-ui/style": ["style/src/index.ts"],
25
+ "@lattice-ui/switch": ["switch/src/index.ts"],
26
+ "@lattice-ui/system": ["system/src/index.ts"],
27
+ "@lattice-ui/tabs": ["tabs/src/index.ts"],
28
+ "@lattice-ui/text-field": ["text-field/src/index.ts"],
29
+ "@lattice-ui/textarea": ["textarea/src/index.ts"],
30
+ "@lattice-ui/toast": ["toast/src/index.ts"],
31
+ "@lattice-ui/toggle-group": ["toggle-group/src/index.ts"],
32
+ "@lattice-ui/tooltip": ["tooltip/src/index.ts"]
33
+ }
34
+ }
35
+ }