@lattice-ui/popper 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @lattice-ui/popper
2
+
3
+ Initial skeleton package for popper-style positioning.
4
+
5
+ ## Planned first implementation
6
+
7
+ - Anchor/content geometry observers.
8
+ - Placement compute with flip/clamp/offset.
9
+ - `usePopper` hook for live position updates.
@@ -0,0 +1,2 @@
1
+ import type { ComputePopperInput, ComputePopperResult } from "./types";
2
+ export declare function computePopper(input: ComputePopperInput): ComputePopperResult;
@@ -0,0 +1,77 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local function getPositionForPlacement(placement, anchorPosition, anchorSize, contentSize, offset, out)
3
+ if placement == "top" then
4
+ out.x = anchorPosition.X + anchorSize.X / 2 - contentSize.X / 2 + offset.X
5
+ out.y = anchorPosition.Y - contentSize.Y + offset.Y
6
+ return out
7
+ end
8
+ if placement == "left" then
9
+ out.x = anchorPosition.X - contentSize.X + offset.X
10
+ out.y = anchorPosition.Y + anchorSize.Y / 2 - contentSize.Y / 2 + offset.Y
11
+ return out
12
+ end
13
+ if placement == "right" then
14
+ out.x = anchorPosition.X + anchorSize.X + offset.X
15
+ out.y = anchorPosition.Y + anchorSize.Y / 2 - contentSize.Y / 2 + offset.Y
16
+ return out
17
+ end
18
+ out.x = anchorPosition.X + anchorSize.X / 2 - contentSize.X / 2 + offset.X
19
+ out.y = anchorPosition.Y + anchorSize.Y + offset.Y
20
+ return out
21
+ end
22
+ local function overflowsViewport(positionX, positionY, contentSize, viewportSize, padding)
23
+ local minX = padding
24
+ local minY = padding
25
+ local maxX = viewportSize.X - contentSize.X - padding
26
+ local maxY = viewportSize.Y - contentSize.Y - padding
27
+ return positionX < minX or positionX > maxX or positionY < minY or positionY > maxY
28
+ end
29
+ local function clampToViewport(positionX, positionY, contentSize, viewportSize, padding, out)
30
+ local minX = padding
31
+ local minY = padding
32
+ local maxX = math.max(minX, viewportSize.X - contentSize.X - padding)
33
+ local maxY = math.max(minY, viewportSize.Y - contentSize.Y - padding)
34
+ out.x = math.clamp(positionX, minX, maxX)
35
+ out.y = math.clamp(positionY, minY, maxY)
36
+ return out
37
+ end
38
+ local function computePopper(input)
39
+ local placement = input.placement or "bottom"
40
+ local offset = input.offset or Vector2.new(0, 0)
41
+ local _condition = input.padding
42
+ if _condition == nil then
43
+ _condition = 8
44
+ end
45
+ local padding = _condition
46
+ local primary = getPositionForPlacement(placement, input.anchorPosition, input.anchorSize, input.contentSize, offset, {
47
+ x = 0,
48
+ y = 0,
49
+ })
50
+ local fallbackPlacement = if placement == "top" then "bottom" elseif placement == "bottom" then "top" elseif placement == "left" then "right" else "left"
51
+ local resolvedPlacement = placement
52
+ local resolvedX = primary.x
53
+ local resolvedY = primary.y
54
+ if overflowsViewport(primary.x, primary.y, input.contentSize, input.viewportSize, padding) then
55
+ local fallback = getPositionForPlacement(fallbackPlacement, input.anchorPosition, input.anchorSize, input.contentSize, offset, {
56
+ x = 0,
57
+ y = 0,
58
+ })
59
+ if not overflowsViewport(fallback.x, fallback.y, input.contentSize, input.viewportSize, padding) then
60
+ resolvedPlacement = fallbackPlacement
61
+ resolvedX = fallback.x
62
+ resolvedY = fallback.y
63
+ end
64
+ end
65
+ local clamped = clampToViewport(resolvedX, resolvedY, input.contentSize, input.viewportSize, padding, {
66
+ x = 0,
67
+ y = 0,
68
+ })
69
+ return {
70
+ position = UDim2.fromOffset(clamped.x, clamped.y),
71
+ anchorPoint = Vector2.new(0, 0),
72
+ placement = resolvedPlacement,
73
+ }
74
+ end
75
+ return {
76
+ computePopper = computePopper,
77
+ }
package/out/index.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./compute";
2
+ export * from "./types";
3
+ export * from "./usePopper";
package/out/init.luau ADDED
@@ -0,0 +1,13 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local TS = _G[script]
3
+ local exports = {}
4
+ for _k, _v in TS.import(script, script, "compute") or {} do
5
+ exports[_k] = _v
6
+ end
7
+ for _k, _v in TS.import(script, script, "types") or {} do
8
+ exports[_k] = _v
9
+ end
10
+ for _k, _v in TS.import(script, script, "usePopper") or {} do
11
+ exports[_k] = _v
12
+ end
13
+ return exports
@@ -0,0 +1,4 @@
1
+ export type ObserverUnsubscribe = () => void;
2
+ export declare function subscribeAnchor(anchor: GuiObject, onChange: () => void): ObserverUnsubscribe;
3
+ export declare function subscribeContent(content: GuiObject, onChange: () => void): ObserverUnsubscribe;
4
+ export declare function subscribeViewport(onChange: () => void): ObserverUnsubscribe;
@@ -0,0 +1,52 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ local WorkspaceService = game:GetService("Workspace")
3
+ local function subscribeGuiObjectLayout(guiObject, onChange)
4
+ local positionConnection = guiObject:GetPropertyChangedSignal("AbsolutePosition"):Connect(function()
5
+ onChange()
6
+ end)
7
+ local sizeConnection = guiObject:GetPropertyChangedSignal("AbsoluteSize"):Connect(function()
8
+ onChange()
9
+ end)
10
+ return function()
11
+ positionConnection:Disconnect()
12
+ sizeConnection:Disconnect()
13
+ end
14
+ end
15
+ local function subscribeAnchor(anchor, onChange)
16
+ return subscribeGuiObjectLayout(anchor, onChange)
17
+ end
18
+ local function subscribeContent(content, onChange)
19
+ return subscribeGuiObjectLayout(content, onChange)
20
+ end
21
+ local function subscribeViewport(onChange)
22
+ local viewportConnection
23
+ local reconnectViewportConnection = function()
24
+ if viewportConnection then
25
+ viewportConnection:Disconnect()
26
+ viewportConnection = nil
27
+ end
28
+ local currentCamera = WorkspaceService.CurrentCamera
29
+ if currentCamera then
30
+ viewportConnection = currentCamera:GetPropertyChangedSignal("ViewportSize"):Connect(function()
31
+ onChange()
32
+ end)
33
+ end
34
+ end
35
+ reconnectViewportConnection()
36
+ local cameraConnection = WorkspaceService:GetPropertyChangedSignal("CurrentCamera"):Connect(function()
37
+ reconnectViewportConnection()
38
+ onChange()
39
+ end)
40
+ return function()
41
+ if viewportConnection then
42
+ viewportConnection:Disconnect()
43
+ viewportConnection = nil
44
+ end
45
+ cameraConnection:Disconnect()
46
+ end
47
+ end
48
+ return {
49
+ subscribeAnchor = subscribeAnchor,
50
+ subscribeContent = subscribeContent,
51
+ subscribeViewport = subscribeViewport,
52
+ }
package/out/types.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type React from "@rbxts/react";
2
+ export type PopperPlacement = "top" | "bottom" | "left" | "right";
3
+ export type ComputePopperInput = {
4
+ anchorPosition: Vector2;
5
+ anchorSize: Vector2;
6
+ contentSize: Vector2;
7
+ viewportSize: Vector2;
8
+ placement?: PopperPlacement;
9
+ offset?: Vector2;
10
+ padding?: number;
11
+ };
12
+ export type ComputePopperResult = {
13
+ position: UDim2;
14
+ anchorPoint: Vector2;
15
+ placement: PopperPlacement;
16
+ };
17
+ export type UsePopperOptions = {
18
+ anchorRef: React.RefObject<GuiObject> | React.MutableRefObject<GuiObject | undefined>;
19
+ contentRef: React.RefObject<GuiObject> | React.MutableRefObject<GuiObject | undefined>;
20
+ placement?: PopperPlacement;
21
+ offset?: Vector2;
22
+ padding?: number;
23
+ enabled?: boolean;
24
+ };
25
+ export type UsePopperResult = ComputePopperResult & {
26
+ update: () => void;
27
+ };
package/out/types.luau ADDED
@@ -0,0 +1,2 @@
1
+ -- Compiled with roblox-ts v3.0.0
2
+ return nil
@@ -0,0 +1,2 @@
1
+ import type { UsePopperOptions, UsePopperResult } from "./types";
2
+ export declare function usePopper(options: UsePopperOptions): UsePopperResult;
@@ -0,0 +1,132 @@
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 computePopper = TS.import(script, script.Parent, "compute").computePopper
5
+ local _observers = TS.import(script, script.Parent, "observers")
6
+ local subscribeAnchor = _observers.subscribeAnchor
7
+ local subscribeContent = _observers.subscribeContent
8
+ local subscribeViewport = _observers.subscribeViewport
9
+ local WorkspaceService = game:GetService("Workspace")
10
+ local RunService = game:GetService("RunService")
11
+ local function readGuiRef(ref)
12
+ return ref.current
13
+ end
14
+ local function getDefaultComputedResult(placement)
15
+ return {
16
+ anchorPoint = Vector2.new(0, 0),
17
+ placement = placement or "bottom",
18
+ position = UDim2.fromOffset(0, 0),
19
+ }
20
+ end
21
+ local function areResultsEqual(a, b)
22
+ return a.placement == b.placement and a.anchorPoint.X == b.anchorPoint.X and a.anchorPoint.Y == b.anchorPoint.Y and a.position.X.Scale == b.position.X.Scale and a.position.X.Offset == b.position.X.Offset and a.position.Y.Scale == b.position.Y.Scale and a.position.Y.Offset == b.position.Y.Offset
23
+ end
24
+ local function usePopper(options)
25
+ local _condition = options.enabled
26
+ if _condition == nil then
27
+ _condition = true
28
+ end
29
+ local enabled = _condition
30
+ local computedResult, setComputedResult = React.useState(function()
31
+ return getDefaultComputedResult(options.placement)
32
+ end)
33
+ local update = React.useCallback(function()
34
+ if not enabled then
35
+ return nil
36
+ end
37
+ local anchor = readGuiRef(options.anchorRef)
38
+ local content = readGuiRef(options.contentRef)
39
+ if not anchor or not content then
40
+ return nil
41
+ end
42
+ local _result = WorkspaceService.CurrentCamera
43
+ if _result ~= nil then
44
+ _result = _result.ViewportSize
45
+ end
46
+ local _condition_1 = _result
47
+ if _condition_1 == nil then
48
+ _condition_1 = Vector2.new(1920, 1080)
49
+ end
50
+ local viewportSize = _condition_1
51
+ local nextResult = computePopper({
52
+ anchorPosition = anchor.AbsolutePosition,
53
+ anchorSize = anchor.AbsoluteSize,
54
+ contentSize = content.AbsoluteSize,
55
+ offset = options.offset,
56
+ padding = options.padding,
57
+ placement = options.placement,
58
+ viewportSize = viewportSize,
59
+ })
60
+ setComputedResult(function(currentResult)
61
+ return if areResultsEqual(currentResult, nextResult) then currentResult else nextResult
62
+ end)
63
+ end, { enabled, options.anchorRef, options.contentRef, options.offset, options.padding, options.placement })
64
+ React.useEffect(function()
65
+ update()
66
+ end, { update })
67
+ React.useEffect(function()
68
+ if not enabled then
69
+ return nil
70
+ end
71
+ local disconnectAnchor
72
+ local disconnectContent
73
+ local disconnectViewport
74
+ local waitForRefsConnection
75
+ local attached = false
76
+ local attachObservers = function()
77
+ if attached then
78
+ return true
79
+ end
80
+ local anchor = readGuiRef(options.anchorRef)
81
+ local content = readGuiRef(options.contentRef)
82
+ if not anchor or not content then
83
+ return false
84
+ end
85
+ disconnectAnchor = subscribeAnchor(anchor, update)
86
+ disconnectContent = subscribeContent(content, update)
87
+ disconnectViewport = subscribeViewport(update)
88
+ attached = true
89
+ return true
90
+ end
91
+ if not attachObservers() then
92
+ waitForRefsConnection = RunService.Heartbeat:Connect(function()
93
+ if attachObservers() then
94
+ if waitForRefsConnection then
95
+ waitForRefsConnection:Disconnect()
96
+ waitForRefsConnection = nil
97
+ end
98
+ update()
99
+ end
100
+ end)
101
+ else
102
+ update()
103
+ end
104
+ return function()
105
+ if waitForRefsConnection then
106
+ waitForRefsConnection:Disconnect()
107
+ waitForRefsConnection = nil
108
+ end
109
+ local _result = disconnectAnchor
110
+ if _result ~= nil then
111
+ _result()
112
+ end
113
+ local _result_1 = disconnectContent
114
+ if _result_1 ~= nil then
115
+ _result_1()
116
+ end
117
+ local _result_2 = disconnectViewport
118
+ if _result_2 ~= nil then
119
+ _result_2()
120
+ end
121
+ end
122
+ end, { enabled, options.anchorRef, options.contentRef, update })
123
+ return React.useMemo(function()
124
+ local _object = table.clone(computedResult)
125
+ setmetatable(_object, nil)
126
+ _object.update = update
127
+ return _object
128
+ end, { computedResult, update })
129
+ end
130
+ return {
131
+ usePopper = usePopper,
132
+ }
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@lattice-ui/popper",
3
+ "version": "0.1.1",
4
+ "private": false,
5
+ "main": "out/init.luau",
6
+ "types": "out/index.d.ts",
7
+ "dependencies": {
8
+ "@lattice-ui/core": "0.1.1"
9
+ },
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
+ "watch": "rbxtsc -p tsconfig.json -w",
21
+ "typecheck": "tsc -p tsconfig.typecheck.json"
22
+ }
23
+ }
package/src/compute.ts ADDED
@@ -0,0 +1,116 @@
1
+ import type { ComputePopperInput, ComputePopperResult, PopperPlacement } from "./types";
2
+
3
+ type XY = {
4
+ x: number;
5
+ y: number;
6
+ };
7
+
8
+ function getPositionForPlacement(
9
+ placement: PopperPlacement,
10
+ anchorPosition: Vector2,
11
+ anchorSize: Vector2,
12
+ contentSize: Vector2,
13
+ offset: Vector2,
14
+ out: XY,
15
+ ): XY {
16
+ if (placement === "top") {
17
+ out.x = anchorPosition.X + anchorSize.X / 2 - contentSize.X / 2 + offset.X;
18
+ out.y = anchorPosition.Y - contentSize.Y + offset.Y;
19
+ return out;
20
+ }
21
+
22
+ if (placement === "left") {
23
+ out.x = anchorPosition.X - contentSize.X + offset.X;
24
+ out.y = anchorPosition.Y + anchorSize.Y / 2 - contentSize.Y / 2 + offset.Y;
25
+ return out;
26
+ }
27
+
28
+ if (placement === "right") {
29
+ out.x = anchorPosition.X + anchorSize.X + offset.X;
30
+ out.y = anchorPosition.Y + anchorSize.Y / 2 - contentSize.Y / 2 + offset.Y;
31
+ return out;
32
+ }
33
+
34
+ out.x = anchorPosition.X + anchorSize.X / 2 - contentSize.X / 2 + offset.X;
35
+ out.y = anchorPosition.Y + anchorSize.Y + offset.Y;
36
+ return out;
37
+ }
38
+
39
+ function overflowsViewport(
40
+ positionX: number,
41
+ positionY: number,
42
+ contentSize: Vector2,
43
+ viewportSize: Vector2,
44
+ padding: number,
45
+ ) {
46
+ const minX = padding;
47
+ const minY = padding;
48
+ const maxX = viewportSize.X - contentSize.X - padding;
49
+ const maxY = viewportSize.Y - contentSize.Y - padding;
50
+ return positionX < minX || positionX > maxX || positionY < minY || positionY > maxY;
51
+ }
52
+
53
+ function clampToViewport(
54
+ positionX: number,
55
+ positionY: number,
56
+ contentSize: Vector2,
57
+ viewportSize: Vector2,
58
+ padding: number,
59
+ out: XY,
60
+ ): XY {
61
+ const minX = padding;
62
+ const minY = padding;
63
+ const maxX = math.max(minX, viewportSize.X - contentSize.X - padding);
64
+ const maxY = math.max(minY, viewportSize.Y - contentSize.Y - padding);
65
+
66
+ out.x = math.clamp(positionX, minX, maxX);
67
+ out.y = math.clamp(positionY, minY, maxY);
68
+ return out;
69
+ }
70
+
71
+ export function computePopper(input: ComputePopperInput): ComputePopperResult {
72
+ const placement = input.placement ?? "bottom";
73
+ const offset = input.offset ?? new Vector2(0, 0);
74
+ const padding = input.padding ?? 8;
75
+
76
+ const primary = getPositionForPlacement(
77
+ placement,
78
+ input.anchorPosition,
79
+ input.anchorSize,
80
+ input.contentSize,
81
+ offset,
82
+ { x: 0, y: 0 },
83
+ );
84
+
85
+ const fallbackPlacement: PopperPlacement =
86
+ placement === "top" ? "bottom" : placement === "bottom" ? "top" : placement === "left" ? "right" : "left";
87
+
88
+ let resolvedPlacement = placement;
89
+ let resolvedX = primary.x;
90
+ let resolvedY = primary.y;
91
+
92
+ if (overflowsViewport(primary.x, primary.y, input.contentSize, input.viewportSize, padding)) {
93
+ const fallback = getPositionForPlacement(
94
+ fallbackPlacement,
95
+ input.anchorPosition,
96
+ input.anchorSize,
97
+ input.contentSize,
98
+ offset,
99
+ { x: 0, y: 0 },
100
+ );
101
+
102
+ if (!overflowsViewport(fallback.x, fallback.y, input.contentSize, input.viewportSize, padding)) {
103
+ resolvedPlacement = fallbackPlacement;
104
+ resolvedX = fallback.x;
105
+ resolvedY = fallback.y;
106
+ }
107
+ }
108
+
109
+ const clamped = clampToViewport(resolvedX, resolvedY, input.contentSize, input.viewportSize, padding, { x: 0, y: 0 });
110
+
111
+ return {
112
+ position: UDim2.fromOffset(clamped.x, clamped.y),
113
+ anchorPoint: new Vector2(0, 0),
114
+ placement: resolvedPlacement,
115
+ };
116
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ export * from "./compute";
2
+ export * from "./types";
3
+ export * from "./usePopper";
@@ -0,0 +1,57 @@
1
+ const WorkspaceService = game.GetService("Workspace");
2
+
3
+ export type ObserverUnsubscribe = () => void;
4
+
5
+ function subscribeGuiObjectLayout(guiObject: GuiObject, onChange: () => void): ObserverUnsubscribe {
6
+ const positionConnection = guiObject.GetPropertyChangedSignal("AbsolutePosition").Connect(() => {
7
+ onChange();
8
+ });
9
+ const sizeConnection = guiObject.GetPropertyChangedSignal("AbsoluteSize").Connect(() => {
10
+ onChange();
11
+ });
12
+
13
+ return () => {
14
+ positionConnection.Disconnect();
15
+ sizeConnection.Disconnect();
16
+ };
17
+ }
18
+
19
+ export function subscribeAnchor(anchor: GuiObject, onChange: () => void): ObserverUnsubscribe {
20
+ return subscribeGuiObjectLayout(anchor, onChange);
21
+ }
22
+
23
+ export function subscribeContent(content: GuiObject, onChange: () => void): ObserverUnsubscribe {
24
+ return subscribeGuiObjectLayout(content, onChange);
25
+ }
26
+
27
+ export function subscribeViewport(onChange: () => void): ObserverUnsubscribe {
28
+ let viewportConnection: RBXScriptConnection | undefined;
29
+
30
+ const reconnectViewportConnection = () => {
31
+ if (viewportConnection) {
32
+ viewportConnection.Disconnect();
33
+ viewportConnection = undefined;
34
+ }
35
+
36
+ const currentCamera = WorkspaceService.CurrentCamera;
37
+ if (currentCamera) {
38
+ viewportConnection = currentCamera.GetPropertyChangedSignal("ViewportSize").Connect(() => {
39
+ onChange();
40
+ });
41
+ }
42
+ };
43
+
44
+ reconnectViewportConnection();
45
+ const cameraConnection = WorkspaceService.GetPropertyChangedSignal("CurrentCamera").Connect(() => {
46
+ reconnectViewportConnection();
47
+ onChange();
48
+ });
49
+
50
+ return () => {
51
+ if (viewportConnection) {
52
+ viewportConnection.Disconnect();
53
+ viewportConnection = undefined;
54
+ }
55
+ cameraConnection.Disconnect();
56
+ };
57
+ }
package/src/types.ts ADDED
@@ -0,0 +1,32 @@
1
+ import type React from "@rbxts/react";
2
+
3
+ export type PopperPlacement = "top" | "bottom" | "left" | "right";
4
+
5
+ export type ComputePopperInput = {
6
+ anchorPosition: Vector2;
7
+ anchorSize: Vector2;
8
+ contentSize: Vector2;
9
+ viewportSize: Vector2;
10
+ placement?: PopperPlacement;
11
+ offset?: Vector2;
12
+ padding?: number;
13
+ };
14
+
15
+ export type ComputePopperResult = {
16
+ position: UDim2;
17
+ anchorPoint: Vector2;
18
+ placement: PopperPlacement;
19
+ };
20
+
21
+ export type UsePopperOptions = {
22
+ anchorRef: React.RefObject<GuiObject> | React.MutableRefObject<GuiObject | undefined>;
23
+ contentRef: React.RefObject<GuiObject> | React.MutableRefObject<GuiObject | undefined>;
24
+ placement?: PopperPlacement;
25
+ offset?: Vector2;
26
+ padding?: number;
27
+ enabled?: boolean;
28
+ };
29
+
30
+ export type UsePopperResult = ComputePopperResult & {
31
+ update: () => void;
32
+ };
@@ -0,0 +1,130 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import { computePopper } from "./compute";
3
+ import { subscribeAnchor, subscribeContent, subscribeViewport } from "./observers";
4
+ import type { ComputePopperResult, UsePopperOptions, UsePopperResult } from "./types";
5
+
6
+ const WorkspaceService = game.GetService("Workspace");
7
+ const RunService = game.GetService("RunService");
8
+
9
+ function readGuiRef(ref: UsePopperOptions["anchorRef"] | UsePopperOptions["contentRef"]): GuiObject | undefined {
10
+ return ref.current;
11
+ }
12
+
13
+ function getDefaultComputedResult(placement: UsePopperOptions["placement"]): ComputePopperResult {
14
+ return {
15
+ anchorPoint: new Vector2(0, 0),
16
+ placement: placement ?? "bottom",
17
+ position: UDim2.fromOffset(0, 0),
18
+ };
19
+ }
20
+
21
+ function areResultsEqual(a: ComputePopperResult, b: ComputePopperResult) {
22
+ return (
23
+ a.placement === b.placement &&
24
+ a.anchorPoint.X === b.anchorPoint.X &&
25
+ a.anchorPoint.Y === b.anchorPoint.Y &&
26
+ a.position.X.Scale === b.position.X.Scale &&
27
+ a.position.X.Offset === b.position.X.Offset &&
28
+ a.position.Y.Scale === b.position.Y.Scale &&
29
+ a.position.Y.Offset === b.position.Y.Offset
30
+ );
31
+ }
32
+
33
+ export function usePopper(options: UsePopperOptions): UsePopperResult {
34
+ const enabled = options.enabled ?? true;
35
+ const [computedResult, setComputedResult] = React.useState<ComputePopperResult>(() =>
36
+ getDefaultComputedResult(options.placement),
37
+ );
38
+
39
+ const update = React.useCallback(() => {
40
+ if (!enabled) {
41
+ return;
42
+ }
43
+
44
+ const anchor = readGuiRef(options.anchorRef);
45
+ const content = readGuiRef(options.contentRef);
46
+ if (!anchor || !content) {
47
+ return;
48
+ }
49
+
50
+ const viewportSize = WorkspaceService.CurrentCamera?.ViewportSize ?? new Vector2(1920, 1080);
51
+ const nextResult = computePopper({
52
+ anchorPosition: anchor.AbsolutePosition,
53
+ anchorSize: anchor.AbsoluteSize,
54
+ contentSize: content.AbsoluteSize,
55
+ offset: options.offset,
56
+ padding: options.padding,
57
+ placement: options.placement,
58
+ viewportSize,
59
+ });
60
+
61
+ setComputedResult((currentResult) => (areResultsEqual(currentResult, nextResult) ? currentResult : nextResult));
62
+ }, [enabled, options.anchorRef, options.contentRef, options.offset, options.padding, options.placement]);
63
+
64
+ React.useEffect(() => {
65
+ update();
66
+ }, [update]);
67
+
68
+ React.useEffect(() => {
69
+ if (!enabled) {
70
+ return;
71
+ }
72
+
73
+ let disconnectAnchor: (() => void) | undefined;
74
+ let disconnectContent: (() => void) | undefined;
75
+ let disconnectViewport: (() => void) | undefined;
76
+ let waitForRefsConnection: RBXScriptConnection | undefined;
77
+ let attached = false;
78
+
79
+ const attachObservers = () => {
80
+ if (attached) {
81
+ return true;
82
+ }
83
+
84
+ const anchor = readGuiRef(options.anchorRef);
85
+ const content = readGuiRef(options.contentRef);
86
+ if (!anchor || !content) {
87
+ return false;
88
+ }
89
+
90
+ disconnectAnchor = subscribeAnchor(anchor, update);
91
+ disconnectContent = subscribeContent(content, update);
92
+ disconnectViewport = subscribeViewport(update);
93
+ attached = true;
94
+ return true;
95
+ };
96
+
97
+ if (!attachObservers()) {
98
+ waitForRefsConnection = RunService.Heartbeat.Connect(() => {
99
+ if (attachObservers()) {
100
+ if (waitForRefsConnection) {
101
+ waitForRefsConnection.Disconnect();
102
+ waitForRefsConnection = undefined;
103
+ }
104
+ update();
105
+ }
106
+ });
107
+ } else {
108
+ update();
109
+ }
110
+
111
+ return () => {
112
+ if (waitForRefsConnection) {
113
+ waitForRefsConnection.Disconnect();
114
+ waitForRefsConnection = undefined;
115
+ }
116
+
117
+ disconnectAnchor?.();
118
+ disconnectContent?.();
119
+ disconnectViewport?.();
120
+ };
121
+ }, [enabled, options.anchorRef, options.contentRef, update]);
122
+
123
+ return React.useMemo(
124
+ () => ({
125
+ ...computedResult,
126
+ update,
127
+ }),
128
+ [computedResult, update],
129
+ );
130
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "out",
6
+ "declaration": true,
7
+ "typeRoots": [
8
+ "./node_modules/@rbxts",
9
+ "../../node_modules/@rbxts",
10
+ "./node_modules/@lattice-ui",
11
+ "../../node_modules/@lattice-ui"
12
+ ],
13
+ "types": ["types", "compiler-types"]
14
+ },
15
+ "include": ["src"]
16
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "noEmit": true,
5
+ "baseUrl": "..",
6
+ "rootDir": "..",
7
+ "paths": {
8
+ "@lattice-ui/checkbox": ["checkbox/src/index.ts"],
9
+ "@lattice-ui/core": ["core/src/index.ts"],
10
+ "@lattice-ui/dialog": ["dialog/src/index.ts"],
11
+ "@lattice-ui/focus": ["focus/src/index.ts"],
12
+ "@lattice-ui/layer": ["layer/src/index.ts"],
13
+ "@lattice-ui/menu": ["menu/src/index.ts"],
14
+ "@lattice-ui/popover": ["popover/src/index.ts"],
15
+ "@lattice-ui/popper": ["popper/src/index.ts"],
16
+ "@lattice-ui/radio-group": ["radio-group/src/index.ts"],
17
+ "@lattice-ui/style": ["style/src/index.ts"],
18
+ "@lattice-ui/switch": ["switch/src/index.ts"],
19
+ "@lattice-ui/system": ["system/src/index.ts"],
20
+ "@lattice-ui/tabs": ["tabs/src/index.ts"],
21
+ "@lattice-ui/toggle-group": ["toggle-group/src/index.ts"],
22
+ "@lattice-ui/tooltip": ["tooltip/src/index.ts"]
23
+ }
24
+ }
25
+ }