@lattice-ui/layer 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/out/dismissable/DismissableLayer.d.ts +3 -0
- package/out/dismissable/DismissableLayer.luau +111 -0
- package/out/dismissable/events.d.ts +8 -0
- package/out/dismissable/events.luau +63 -0
- package/out/dismissable/layerStack.d.ts +19 -0
- package/out/dismissable/layerStack.luau +141 -0
- package/out/dismissable/types.d.ts +16 -0
- package/out/dismissable/types.luau +2 -0
- package/out/index.d.ts +5 -0
- package/out/init.luau +19 -0
- package/out/internals/constants.d.ts +3 -0
- package/out/internals/constants.luau +9 -0
- package/out/internals/env.d.ts +3 -0
- package/out/internals/env.luau +12 -0
- package/out/portal/Portal.d.ts +2 -0
- package/out/portal/Portal.luau +12 -0
- package/out/portal/PortalProvider.d.ts +5 -0
- package/out/portal/PortalProvider.luau +29 -0
- package/out/portal/types.d.ts +14 -0
- package/out/portal/types.luau +2 -0
- package/out/presence/Presence.d.ts +3 -0
- package/out/presence/Presence.luau +88 -0
- package/out/presence/types.d.ts +13 -0
- package/out/presence/types.luau +2 -0
- package/package.json +23 -0
- package/src/dismissable/DismissableLayer.tsx +118 -0
- package/src/dismissable/events.ts +78 -0
- package/src/dismissable/layerStack.ts +147 -0
- package/src/dismissable/types.ts +18 -0
- package/src/index.ts +5 -0
- package/src/internals/constants.ts +3 -0
- package/src/internals/env.ts +7 -0
- package/src/portal/Portal.tsx +9 -0
- package/src/portal/PortalProvider.tsx +20 -0
- package/src/portal/types.ts +17 -0
- package/src/presence/Presence.tsx +93 -0
- package/src/presence/types.ts +16 -0
- package/tsconfig.json +16 -0
- package/tsconfig.typecheck.json +25 -0
|
@@ -0,0 +1,111 @@
|
|
|
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 DEFAULT_LAYER_IGNORE_GUI_INSET = TS.import(script, script.Parent.Parent, "internals", "constants").DEFAULT_LAYER_IGNORE_GUI_INSET
|
|
5
|
+
local Portal = TS.import(script, script.Parent.Parent, "portal", "Portal").Portal
|
|
6
|
+
local usePortalContext = TS.import(script, script.Parent.Parent, "portal", "PortalProvider").usePortalContext
|
|
7
|
+
local isOutsidePointerEvent = TS.import(script, script.Parent, "events").isOutsidePointerEvent
|
|
8
|
+
local _layerStack = TS.import(script, script.Parent, "layerStack")
|
|
9
|
+
local registerLayer = _layerStack.registerLayer
|
|
10
|
+
local unregisterLayer = _layerStack.unregisterLayer
|
|
11
|
+
local function useLatest(value)
|
|
12
|
+
local ref = React.useRef(value)
|
|
13
|
+
React.useEffect(function()
|
|
14
|
+
ref.current = value
|
|
15
|
+
end, { value })
|
|
16
|
+
return ref
|
|
17
|
+
end
|
|
18
|
+
local function DismissableLayer(props)
|
|
19
|
+
local _condition = props.enabled
|
|
20
|
+
if _condition == nil then
|
|
21
|
+
_condition = true
|
|
22
|
+
end
|
|
23
|
+
local enabled = _condition
|
|
24
|
+
local shouldBlockOutsidePointer = props.modal == true or props.disableOutsidePointerEvents == true
|
|
25
|
+
local layerIgnoresGuiInset = DEFAULT_LAYER_IGNORE_GUI_INSET
|
|
26
|
+
local portalContext = usePortalContext()
|
|
27
|
+
local contentRootRef = React.useRef()
|
|
28
|
+
local stackOrder, setStackOrder = React.useState(0)
|
|
29
|
+
local enabledRef = useLatest(enabled)
|
|
30
|
+
local onDismissRef = useLatest(props.onDismiss)
|
|
31
|
+
local onPointerDownOutsideRef = useLatest(props.onPointerDownOutside)
|
|
32
|
+
local onInteractOutsideRef = useLatest(props.onInteractOutside)
|
|
33
|
+
local onEscapeKeyDownRef = useLatest(props.onEscapeKeyDown)
|
|
34
|
+
local callPointerDownOutside = React.useCallback(function(event)
|
|
35
|
+
local _result = onPointerDownOutsideRef.current
|
|
36
|
+
if _result ~= nil then
|
|
37
|
+
_result(event)
|
|
38
|
+
end
|
|
39
|
+
end, {})
|
|
40
|
+
local callInteractOutside = React.useCallback(function(event)
|
|
41
|
+
local _result = onInteractOutsideRef.current
|
|
42
|
+
if _result ~= nil then
|
|
43
|
+
_result(event)
|
|
44
|
+
end
|
|
45
|
+
end, {})
|
|
46
|
+
local callEscape = React.useCallback(function(event)
|
|
47
|
+
local _result = onEscapeKeyDownRef.current
|
|
48
|
+
if _result ~= nil then
|
|
49
|
+
_result(event)
|
|
50
|
+
end
|
|
51
|
+
end, {})
|
|
52
|
+
local callDismiss = React.useCallback(function()
|
|
53
|
+
local _result = onDismissRef.current
|
|
54
|
+
if _result ~= nil then
|
|
55
|
+
_result()
|
|
56
|
+
end
|
|
57
|
+
end, {})
|
|
58
|
+
React.useEffect(function()
|
|
59
|
+
local registration = registerLayer({
|
|
60
|
+
getEnabled = function()
|
|
61
|
+
return enabledRef.current
|
|
62
|
+
end,
|
|
63
|
+
isPointerOutside = function(inputObject)
|
|
64
|
+
local contentRoot = contentRootRef.current
|
|
65
|
+
if not contentRoot then
|
|
66
|
+
return false
|
|
67
|
+
end
|
|
68
|
+
return isOutsidePointerEvent(inputObject, portalContext.container, contentRoot, {
|
|
69
|
+
layerIgnoresGuiInset = layerIgnoresGuiInset,
|
|
70
|
+
})
|
|
71
|
+
end,
|
|
72
|
+
onPointerDownOutside = callPointerDownOutside,
|
|
73
|
+
onInteractOutside = callInteractOutside,
|
|
74
|
+
onEscapeKeyDown = callEscape,
|
|
75
|
+
onDismiss = callDismiss,
|
|
76
|
+
})
|
|
77
|
+
setStackOrder(registration.mountOrder)
|
|
78
|
+
return function()
|
|
79
|
+
unregisterLayer(registration.id)
|
|
80
|
+
end
|
|
81
|
+
end, { callDismiss, callEscape, callInteractOutside, callPointerDownOutside, enabledRef, layerIgnoresGuiInset, portalContext.container })
|
|
82
|
+
return React.createElement(Portal, nil, React.createElement("screengui", {
|
|
83
|
+
key = `Layer_{stackOrder}`,
|
|
84
|
+
DisplayOrder = portalContext.displayOrderBase + stackOrder,
|
|
85
|
+
IgnoreGuiInset = layerIgnoresGuiInset,
|
|
86
|
+
ResetOnSpawn = false,
|
|
87
|
+
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
|
88
|
+
}, if shouldBlockOutsidePointer then (React.createElement("textbutton", {
|
|
89
|
+
Active = true,
|
|
90
|
+
AutoButtonColor = false,
|
|
91
|
+
BackgroundTransparency = 1,
|
|
92
|
+
BorderSizePixel = 0,
|
|
93
|
+
Modal = true,
|
|
94
|
+
Position = UDim2.fromScale(0, 0),
|
|
95
|
+
Selectable = false,
|
|
96
|
+
Size = UDim2.fromScale(1, 1),
|
|
97
|
+
Text = "",
|
|
98
|
+
TextTransparency = 1,
|
|
99
|
+
ZIndex = 0,
|
|
100
|
+
})) else nil, React.createElement("frame", {
|
|
101
|
+
BackgroundTransparency = 1,
|
|
102
|
+
BorderSizePixel = 0,
|
|
103
|
+
Position = UDim2.fromScale(0, 0),
|
|
104
|
+
Size = UDim2.fromScale(1, 1),
|
|
105
|
+
ref = contentRootRef,
|
|
106
|
+
ZIndex = 1,
|
|
107
|
+
}, props.children)))
|
|
108
|
+
end
|
|
109
|
+
return {
|
|
110
|
+
DismissableLayer = DismissableLayer,
|
|
111
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { LayerInteractEvent } from "./types";
|
|
2
|
+
type OutsidePointerOptions = {
|
|
3
|
+
layerIgnoresGuiInset: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare function isPointerInput(inputObject: InputObject): boolean;
|
|
6
|
+
export declare function toLayerInteractEvent(originalEvent: InputObject): LayerInteractEvent;
|
|
7
|
+
export declare function isOutsidePointerEvent(inputObject: InputObject, container: BasePlayerGui, contentRoot: GuiObject, options: OutsidePointerOptions): boolean;
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local TS = _G[script]
|
|
3
|
+
local getGuiInsetTopLeft = TS.import(script, script.Parent.Parent, "internals", "env").getGuiInsetTopLeft
|
|
4
|
+
local function isPointerInput(inputObject)
|
|
5
|
+
return inputObject.UserInputType == Enum.UserInputType.MouseButton1 or inputObject.UserInputType == Enum.UserInputType.Touch
|
|
6
|
+
end
|
|
7
|
+
local function toLayerInteractEvent(originalEvent)
|
|
8
|
+
local event
|
|
9
|
+
event = {
|
|
10
|
+
originalEvent = originalEvent,
|
|
11
|
+
defaultPrevented = false,
|
|
12
|
+
preventDefault = function()
|
|
13
|
+
event.defaultPrevented = true
|
|
14
|
+
end,
|
|
15
|
+
}
|
|
16
|
+
return event
|
|
17
|
+
end
|
|
18
|
+
local function addUniqueSample(samples, sampleKeys, x, y)
|
|
19
|
+
local roundedX = math.round(x)
|
|
20
|
+
local roundedY = math.round(y)
|
|
21
|
+
local key = `{roundedX}:{roundedY}`
|
|
22
|
+
if sampleKeys[key] then
|
|
23
|
+
return nil
|
|
24
|
+
end
|
|
25
|
+
sampleKeys[key] = true
|
|
26
|
+
local _samples = samples
|
|
27
|
+
local _vector2 = Vector2.new(roundedX, roundedY)
|
|
28
|
+
table.insert(_samples, _vector2)
|
|
29
|
+
end
|
|
30
|
+
local function getPointerSamples(pointerPosition, options)
|
|
31
|
+
local insetTopLeft = getGuiInsetTopLeft()
|
|
32
|
+
local samples = {}
|
|
33
|
+
local sampleKeys = {}
|
|
34
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X, pointerPosition.Y)
|
|
35
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X + insetTopLeft.X, pointerPosition.Y + insetTopLeft.Y)
|
|
36
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X - insetTopLeft.X, pointerPosition.Y - insetTopLeft.Y)
|
|
37
|
+
if options.layerIgnoresGuiInset then
|
|
38
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X, pointerPosition.Y + insetTopLeft.Y)
|
|
39
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X, pointerPosition.Y - insetTopLeft.Y)
|
|
40
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X + insetTopLeft.X, pointerPosition.Y)
|
|
41
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X - insetTopLeft.X, pointerPosition.Y)
|
|
42
|
+
end
|
|
43
|
+
return samples
|
|
44
|
+
end
|
|
45
|
+
local function isOutsidePointerEvent(inputObject, container, contentRoot, options)
|
|
46
|
+
local rawPointerPosition = inputObject.Position
|
|
47
|
+
local pointerPosition = Vector2.new(rawPointerPosition.X, rawPointerPosition.Y)
|
|
48
|
+
local pointerSamples = getPointerSamples(pointerPosition, options)
|
|
49
|
+
for _, sample in pointerSamples do
|
|
50
|
+
local hitGuiObjects = container:GetGuiObjectsAtPosition(sample.X, sample.Y)
|
|
51
|
+
for _1, hitObject in hitGuiObjects do
|
|
52
|
+
if hitObject:IsDescendantOf(contentRoot) then
|
|
53
|
+
return false
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
return true
|
|
58
|
+
end
|
|
59
|
+
return {
|
|
60
|
+
isPointerInput = isPointerInput,
|
|
61
|
+
toLayerInteractEvent = toLayerInteractEvent,
|
|
62
|
+
isOutsidePointerEvent = isOutsidePointerEvent,
|
|
63
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { LayerInteractEvent } from "./types";
|
|
2
|
+
type LayerEntry = {
|
|
3
|
+
id: number;
|
|
4
|
+
mountOrder: number;
|
|
5
|
+
getEnabled: () => boolean;
|
|
6
|
+
isPointerOutside: (inputObject: InputObject) => boolean;
|
|
7
|
+
onPointerDownOutside?: (event: LayerInteractEvent) => void;
|
|
8
|
+
onInteractOutside?: (event: LayerInteractEvent) => void;
|
|
9
|
+
onEscapeKeyDown?: (event: LayerInteractEvent) => void;
|
|
10
|
+
onDismiss?: () => void;
|
|
11
|
+
};
|
|
12
|
+
type RegisterLayerParams = Omit<LayerEntry, "id" | "mountOrder">;
|
|
13
|
+
export type LayerRegistration = {
|
|
14
|
+
id: number;
|
|
15
|
+
mountOrder: number;
|
|
16
|
+
};
|
|
17
|
+
export declare function registerLayer(params: RegisterLayerParams): LayerRegistration;
|
|
18
|
+
export declare function unregisterLayer(layerId: number): void;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local TS = _G[script]
|
|
3
|
+
local _env = TS.import(script, script.Parent.Parent, "internals", "env")
|
|
4
|
+
local GuiService = _env.GuiService
|
|
5
|
+
local UserInputService = _env.UserInputService
|
|
6
|
+
local _events = TS.import(script, script.Parent, "events")
|
|
7
|
+
local isPointerInput = _events.isPointerInput
|
|
8
|
+
local toLayerInteractEvent = _events.toLayerInteractEvent
|
|
9
|
+
local layerEntries = {}
|
|
10
|
+
local nextLayerId = 0
|
|
11
|
+
local nextMountOrder = 0
|
|
12
|
+
local inputConnection
|
|
13
|
+
local function getTopMostEnabledLayer()
|
|
14
|
+
for index = #layerEntries - 1, 0, -1 do
|
|
15
|
+
local entry = layerEntries[index + 1]
|
|
16
|
+
if entry.getEnabled() then
|
|
17
|
+
return entry
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
return nil
|
|
21
|
+
end
|
|
22
|
+
local function handleDismissEvent(entry, event)
|
|
23
|
+
if not event.defaultPrevented then
|
|
24
|
+
local _result = entry.onDismiss
|
|
25
|
+
if _result ~= nil then
|
|
26
|
+
_result()
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
local function shouldIgnoreEscapeDismiss()
|
|
31
|
+
local focusedTextBox = UserInputService:GetFocusedTextBox()
|
|
32
|
+
if focusedTextBox then
|
|
33
|
+
return true
|
|
34
|
+
end
|
|
35
|
+
local selectedObject = GuiService.SelectedObject
|
|
36
|
+
if selectedObject and selectedObject:IsA("TextBox") then
|
|
37
|
+
return true
|
|
38
|
+
end
|
|
39
|
+
return false
|
|
40
|
+
end
|
|
41
|
+
local function handleInputBegan(inputObject, gameProcessedEvent)
|
|
42
|
+
if gameProcessedEvent then
|
|
43
|
+
return nil
|
|
44
|
+
end
|
|
45
|
+
local topLayer = getTopMostEnabledLayer()
|
|
46
|
+
if not topLayer then
|
|
47
|
+
return nil
|
|
48
|
+
end
|
|
49
|
+
if inputObject.KeyCode == Enum.KeyCode.Escape then
|
|
50
|
+
if shouldIgnoreEscapeDismiss() then
|
|
51
|
+
return nil
|
|
52
|
+
end
|
|
53
|
+
local escapeEvent = toLayerInteractEvent(inputObject)
|
|
54
|
+
local _result = topLayer.onEscapeKeyDown
|
|
55
|
+
if _result ~= nil then
|
|
56
|
+
_result(escapeEvent)
|
|
57
|
+
end
|
|
58
|
+
handleDismissEvent(topLayer, escapeEvent)
|
|
59
|
+
return nil
|
|
60
|
+
end
|
|
61
|
+
if not isPointerInput(inputObject) then
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
if not topLayer.isPointerOutside(inputObject) then
|
|
65
|
+
return nil
|
|
66
|
+
end
|
|
67
|
+
local outsideEvent = toLayerInteractEvent(inputObject)
|
|
68
|
+
local _result = topLayer.onPointerDownOutside
|
|
69
|
+
if _result ~= nil then
|
|
70
|
+
_result(outsideEvent)
|
|
71
|
+
end
|
|
72
|
+
local _result_1 = topLayer.onInteractOutside
|
|
73
|
+
if _result_1 ~= nil then
|
|
74
|
+
_result_1(outsideEvent)
|
|
75
|
+
end
|
|
76
|
+
handleDismissEvent(topLayer, outsideEvent)
|
|
77
|
+
end
|
|
78
|
+
local function startInputListener()
|
|
79
|
+
if inputConnection then
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
inputConnection = UserInputService.InputBegan:Connect(function(inputObject, gameProcessedEvent)
|
|
83
|
+
handleInputBegan(inputObject, gameProcessedEvent)
|
|
84
|
+
end)
|
|
85
|
+
end
|
|
86
|
+
local function stopInputListener()
|
|
87
|
+
if not inputConnection then
|
|
88
|
+
return nil
|
|
89
|
+
end
|
|
90
|
+
inputConnection:Disconnect()
|
|
91
|
+
inputConnection = nil
|
|
92
|
+
end
|
|
93
|
+
local function syncInputListener()
|
|
94
|
+
if #layerEntries > 0 then
|
|
95
|
+
startInputListener()
|
|
96
|
+
else
|
|
97
|
+
stopInputListener()
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
local function registerLayer(params)
|
|
101
|
+
nextLayerId += 1
|
|
102
|
+
nextMountOrder += 1
|
|
103
|
+
local _object = {
|
|
104
|
+
id = nextLayerId,
|
|
105
|
+
mountOrder = nextMountOrder,
|
|
106
|
+
}
|
|
107
|
+
for _k, _v in params do
|
|
108
|
+
_object[_k] = _v
|
|
109
|
+
end
|
|
110
|
+
local entry = _object
|
|
111
|
+
table.insert(layerEntries, entry)
|
|
112
|
+
syncInputListener()
|
|
113
|
+
return {
|
|
114
|
+
id = entry.id,
|
|
115
|
+
mountOrder = entry.mountOrder,
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
local function unregisterLayer(layerId)
|
|
119
|
+
-- ▼ ReadonlyArray.findIndex ▼
|
|
120
|
+
local _callback = function(entry)
|
|
121
|
+
return entry.id == layerId
|
|
122
|
+
end
|
|
123
|
+
local _result = -1
|
|
124
|
+
for _i, _v in layerEntries do
|
|
125
|
+
if _callback(_v, _i - 1, layerEntries) == true then
|
|
126
|
+
_result = _i - 1
|
|
127
|
+
break
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
-- ▲ ReadonlyArray.findIndex ▲
|
|
131
|
+
local layerIndex = _result
|
|
132
|
+
if layerIndex == -1 then
|
|
133
|
+
return nil
|
|
134
|
+
end
|
|
135
|
+
table.remove(layerEntries, layerIndex + 1)
|
|
136
|
+
syncInputListener()
|
|
137
|
+
end
|
|
138
|
+
return {
|
|
139
|
+
registerLayer = registerLayer,
|
|
140
|
+
unregisterLayer = unregisterLayer,
|
|
141
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type React from "@rbxts/react";
|
|
2
|
+
export type LayerInteractEvent = {
|
|
3
|
+
originalEvent: InputObject;
|
|
4
|
+
defaultPrevented: boolean;
|
|
5
|
+
preventDefault: () => void;
|
|
6
|
+
};
|
|
7
|
+
export type DismissableLayerProps = {
|
|
8
|
+
children?: React.ReactNode;
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
modal?: boolean;
|
|
11
|
+
disableOutsidePointerEvents?: boolean;
|
|
12
|
+
onPointerDownOutside?: (event: LayerInteractEvent) => void;
|
|
13
|
+
onInteractOutside?: (event: LayerInteractEvent) => void;
|
|
14
|
+
onEscapeKeyDown?: (event: LayerInteractEvent) => void;
|
|
15
|
+
onDismiss?: () => void;
|
|
16
|
+
};
|
package/out/index.d.ts
ADDED
package/out/init.luau
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
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, "dismissable", "DismissableLayer") or {} do
|
|
5
|
+
exports[_k] = _v
|
|
6
|
+
end
|
|
7
|
+
for _k, _v in TS.import(script, script, "dismissable", "types") or {} do
|
|
8
|
+
exports[_k] = _v
|
|
9
|
+
end
|
|
10
|
+
for _k, _v in TS.import(script, script, "portal", "Portal") or {} do
|
|
11
|
+
exports[_k] = _v
|
|
12
|
+
end
|
|
13
|
+
for _k, _v in TS.import(script, script, "portal", "PortalProvider") or {} do
|
|
14
|
+
exports[_k] = _v
|
|
15
|
+
end
|
|
16
|
+
for _k, _v in TS.import(script, script, "presence", "Presence") or {} do
|
|
17
|
+
exports[_k] = _v
|
|
18
|
+
end
|
|
19
|
+
return exports
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local DEFAULT_DISPLAY_ORDER_BASE = 1000
|
|
3
|
+
local DEFAULT_PRESENCE_EXIT_FALLBACK_MS = 500
|
|
4
|
+
local DEFAULT_LAYER_IGNORE_GUI_INSET = true
|
|
5
|
+
return {
|
|
6
|
+
DEFAULT_DISPLAY_ORDER_BASE = DEFAULT_DISPLAY_ORDER_BASE,
|
|
7
|
+
DEFAULT_PRESENCE_EXIT_FALLBACK_MS = DEFAULT_PRESENCE_EXIT_FALLBACK_MS,
|
|
8
|
+
DEFAULT_LAYER_IGNORE_GUI_INSET = DEFAULT_LAYER_IGNORE_GUI_INSET,
|
|
9
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local UserInputService = game:GetService("UserInputService")
|
|
3
|
+
local GuiService = game:GetService("GuiService")
|
|
4
|
+
local function getGuiInsetTopLeft()
|
|
5
|
+
local topLeftInset = GuiService:GetGuiInset()
|
|
6
|
+
return topLeftInset
|
|
7
|
+
end
|
|
8
|
+
return {
|
|
9
|
+
getGuiInsetTopLeft = getGuiInsetTopLeft,
|
|
10
|
+
UserInputService = UserInputService,
|
|
11
|
+
GuiService = GuiService,
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local TS = _G[script]
|
|
3
|
+
local ReactRoblox = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out).ReactRoblox
|
|
4
|
+
local usePortalContext = TS.import(script, script.Parent, "PortalProvider").usePortalContext
|
|
5
|
+
local function Portal(props)
|
|
6
|
+
local contextValue = usePortalContext()
|
|
7
|
+
local target = props.container or contextValue.container
|
|
8
|
+
return ReactRoblox.createPortal(props.children, target)
|
|
9
|
+
end
|
|
10
|
+
return {
|
|
11
|
+
Portal = Portal,
|
|
12
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { React } from "@lattice-ui/core";
|
|
2
|
+
import type { PortalContextValue, PortalProviderProps } from "./types";
|
|
3
|
+
declare const usePortalContext: () => PortalContextValue;
|
|
4
|
+
export declare function PortalProvider(props: PortalProviderProps): React.JSX.Element;
|
|
5
|
+
export { usePortalContext };
|
|
@@ -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 createStrictContext = _core.createStrictContext
|
|
5
|
+
local React = _core.React
|
|
6
|
+
local DEFAULT_DISPLAY_ORDER_BASE = TS.import(script, script.Parent.Parent, "internals", "constants").DEFAULT_DISPLAY_ORDER_BASE
|
|
7
|
+
local _binding = createStrictContext("PortalProvider")
|
|
8
|
+
local PortalContextProvider = _binding[1]
|
|
9
|
+
local usePortalContext = _binding[2]
|
|
10
|
+
local function PortalProvider(props)
|
|
11
|
+
local _condition = props.displayOrderBase
|
|
12
|
+
if _condition == nil then
|
|
13
|
+
_condition = DEFAULT_DISPLAY_ORDER_BASE
|
|
14
|
+
end
|
|
15
|
+
local displayOrderBase = _condition
|
|
16
|
+
local contextValue = React.useMemo(function()
|
|
17
|
+
return {
|
|
18
|
+
container = props.container,
|
|
19
|
+
displayOrderBase = displayOrderBase,
|
|
20
|
+
}
|
|
21
|
+
end, { displayOrderBase, props.container })
|
|
22
|
+
return React.createElement(PortalContextProvider, {
|
|
23
|
+
value = contextValue,
|
|
24
|
+
}, props.children)
|
|
25
|
+
end
|
|
26
|
+
return {
|
|
27
|
+
PortalProvider = PortalProvider,
|
|
28
|
+
usePortalContext = usePortalContext,
|
|
29
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type React from "@rbxts/react";
|
|
2
|
+
export type PortalContextValue = {
|
|
3
|
+
container: BasePlayerGui;
|
|
4
|
+
displayOrderBase: number;
|
|
5
|
+
};
|
|
6
|
+
export type PortalProviderProps = {
|
|
7
|
+
container: BasePlayerGui;
|
|
8
|
+
displayOrderBase?: number;
|
|
9
|
+
children?: React.ReactNode;
|
|
10
|
+
};
|
|
11
|
+
export type PortalProps = {
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
container?: Instance;
|
|
14
|
+
};
|
|
@@ -0,0 +1,88 @@
|
|
|
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 DEFAULT_PRESENCE_EXIT_FALLBACK_MS = TS.import(script, script.Parent.Parent, "internals", "constants").DEFAULT_PRESENCE_EXIT_FALLBACK_MS
|
|
5
|
+
local function Presence(props)
|
|
6
|
+
local mounted, setMounted = React.useState(props.present)
|
|
7
|
+
local isPresent, setIsPresent = React.useState(props.present)
|
|
8
|
+
local mountedRef = React.useRef(mounted)
|
|
9
|
+
local fallbackTaskRef = React.useRef()
|
|
10
|
+
local onExitCompleteRef = React.useRef(props.onExitComplete)
|
|
11
|
+
React.useEffect(function()
|
|
12
|
+
onExitCompleteRef.current = props.onExitComplete
|
|
13
|
+
end, { props.onExitComplete })
|
|
14
|
+
React.useEffect(function()
|
|
15
|
+
mountedRef.current = mounted
|
|
16
|
+
end, { mounted })
|
|
17
|
+
local completeExit = React.useCallback(function()
|
|
18
|
+
if not mountedRef.current then
|
|
19
|
+
return nil
|
|
20
|
+
end
|
|
21
|
+
local fallbackTask = fallbackTaskRef.current
|
|
22
|
+
if fallbackTask then
|
|
23
|
+
task.cancel(fallbackTask)
|
|
24
|
+
fallbackTaskRef.current = nil
|
|
25
|
+
end
|
|
26
|
+
mountedRef.current = false
|
|
27
|
+
setMounted(false)
|
|
28
|
+
local _result = onExitCompleteRef.current
|
|
29
|
+
if _result ~= nil then
|
|
30
|
+
_result()
|
|
31
|
+
end
|
|
32
|
+
end, {})
|
|
33
|
+
React.useEffect(function()
|
|
34
|
+
if props.present then
|
|
35
|
+
local fallbackTask = fallbackTaskRef.current
|
|
36
|
+
if fallbackTask then
|
|
37
|
+
task.cancel(fallbackTask)
|
|
38
|
+
fallbackTaskRef.current = nil
|
|
39
|
+
end
|
|
40
|
+
if not mountedRef.current then
|
|
41
|
+
mountedRef.current = true
|
|
42
|
+
setMounted(true)
|
|
43
|
+
end
|
|
44
|
+
setIsPresent(true)
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
if not mountedRef.current then
|
|
48
|
+
return nil
|
|
49
|
+
end
|
|
50
|
+
setIsPresent(false)
|
|
51
|
+
local fallbackTask = fallbackTaskRef.current
|
|
52
|
+
if fallbackTask then
|
|
53
|
+
task.cancel(fallbackTask)
|
|
54
|
+
fallbackTaskRef.current = nil
|
|
55
|
+
end
|
|
56
|
+
local _condition = props.exitFallbackMs
|
|
57
|
+
if _condition == nil then
|
|
58
|
+
_condition = DEFAULT_PRESENCE_EXIT_FALLBACK_MS
|
|
59
|
+
end
|
|
60
|
+
local timeoutMs = _condition
|
|
61
|
+
fallbackTaskRef.current = task.delay(timeoutMs / 1000, function()
|
|
62
|
+
completeExit()
|
|
63
|
+
end)
|
|
64
|
+
end, { completeExit, props.exitFallbackMs, props.present })
|
|
65
|
+
React.useEffect(function()
|
|
66
|
+
return function()
|
|
67
|
+
local fallbackTask = fallbackTaskRef.current
|
|
68
|
+
if fallbackTask then
|
|
69
|
+
task.cancel(fallbackTask)
|
|
70
|
+
fallbackTaskRef.current = nil
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end, {})
|
|
74
|
+
if not mounted then
|
|
75
|
+
return nil
|
|
76
|
+
end
|
|
77
|
+
local render = props.render or props.children
|
|
78
|
+
if not render then
|
|
79
|
+
return nil
|
|
80
|
+
end
|
|
81
|
+
return render({
|
|
82
|
+
isPresent = isPresent,
|
|
83
|
+
onExitComplete = completeExit,
|
|
84
|
+
})
|
|
85
|
+
end
|
|
86
|
+
return {
|
|
87
|
+
Presence = Presence,
|
|
88
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type React from "@rbxts/react";
|
|
2
|
+
export type PresenceRenderState = {
|
|
3
|
+
isPresent: boolean;
|
|
4
|
+
onExitComplete: () => void;
|
|
5
|
+
};
|
|
6
|
+
export type PresenceRender = (state: PresenceRenderState) => React.ReactElement | undefined;
|
|
7
|
+
export type PresenceProps = {
|
|
8
|
+
present: boolean;
|
|
9
|
+
exitFallbackMs?: number;
|
|
10
|
+
onExitComplete?: () => void;
|
|
11
|
+
children?: PresenceRender;
|
|
12
|
+
render?: PresenceRender;
|
|
13
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lattice-ui/layer",
|
|
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
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { React } from "@lattice-ui/core";
|
|
2
|
+
import { DEFAULT_LAYER_IGNORE_GUI_INSET } from "../internals/constants";
|
|
3
|
+
import { Portal } from "../portal/Portal";
|
|
4
|
+
import { usePortalContext } from "../portal/PortalProvider";
|
|
5
|
+
import { isOutsidePointerEvent } from "./events";
|
|
6
|
+
import { registerLayer, unregisterLayer } from "./layerStack";
|
|
7
|
+
import type { DismissableLayerProps, LayerInteractEvent } from "./types";
|
|
8
|
+
|
|
9
|
+
function useLatest<T>(value: T) {
|
|
10
|
+
const ref = React.useRef(value);
|
|
11
|
+
React.useEffect(() => {
|
|
12
|
+
ref.current = value;
|
|
13
|
+
}, [value]);
|
|
14
|
+
return ref;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function DismissableLayer(props: DismissableLayerProps) {
|
|
18
|
+
const enabled = props.enabled ?? true;
|
|
19
|
+
const shouldBlockOutsidePointer = props.modal === true || props.disableOutsidePointerEvents === true;
|
|
20
|
+
const layerIgnoresGuiInset = DEFAULT_LAYER_IGNORE_GUI_INSET;
|
|
21
|
+
|
|
22
|
+
const portalContext = usePortalContext();
|
|
23
|
+
const contentRootRef = React.useRef<Frame>();
|
|
24
|
+
const [stackOrder, setStackOrder] = React.useState(0);
|
|
25
|
+
|
|
26
|
+
const enabledRef = useLatest(enabled);
|
|
27
|
+
const onDismissRef = useLatest(props.onDismiss);
|
|
28
|
+
const onPointerDownOutsideRef = useLatest(props.onPointerDownOutside);
|
|
29
|
+
const onInteractOutsideRef = useLatest(props.onInteractOutside);
|
|
30
|
+
const onEscapeKeyDownRef = useLatest(props.onEscapeKeyDown);
|
|
31
|
+
|
|
32
|
+
const callPointerDownOutside = React.useCallback((event: LayerInteractEvent) => {
|
|
33
|
+
onPointerDownOutsideRef.current?.(event);
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
const callInteractOutside = React.useCallback((event: LayerInteractEvent) => {
|
|
37
|
+
onInteractOutsideRef.current?.(event);
|
|
38
|
+
}, []);
|
|
39
|
+
|
|
40
|
+
const callEscape = React.useCallback((event: LayerInteractEvent) => {
|
|
41
|
+
onEscapeKeyDownRef.current?.(event);
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
const callDismiss = React.useCallback(() => {
|
|
45
|
+
onDismissRef.current?.();
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
const registration = registerLayer({
|
|
50
|
+
getEnabled: () => enabledRef.current,
|
|
51
|
+
isPointerOutside: (inputObject) => {
|
|
52
|
+
const contentRoot = contentRootRef.current;
|
|
53
|
+
if (!contentRoot) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return isOutsidePointerEvent(inputObject, portalContext.container, contentRoot, {
|
|
57
|
+
layerIgnoresGuiInset,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
onPointerDownOutside: callPointerDownOutside,
|
|
61
|
+
onInteractOutside: callInteractOutside,
|
|
62
|
+
onEscapeKeyDown: callEscape,
|
|
63
|
+
onDismiss: callDismiss,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
setStackOrder(registration.mountOrder);
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
unregisterLayer(registration.id);
|
|
70
|
+
};
|
|
71
|
+
}, [
|
|
72
|
+
callDismiss,
|
|
73
|
+
callEscape,
|
|
74
|
+
callInteractOutside,
|
|
75
|
+
callPointerDownOutside,
|
|
76
|
+
enabledRef,
|
|
77
|
+
layerIgnoresGuiInset,
|
|
78
|
+
portalContext.container,
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Portal>
|
|
83
|
+
<screengui
|
|
84
|
+
key={`Layer_${stackOrder}`}
|
|
85
|
+
DisplayOrder={portalContext.displayOrderBase + stackOrder}
|
|
86
|
+
IgnoreGuiInset={layerIgnoresGuiInset}
|
|
87
|
+
ResetOnSpawn={false}
|
|
88
|
+
ZIndexBehavior={Enum.ZIndexBehavior.Sibling}
|
|
89
|
+
>
|
|
90
|
+
{shouldBlockOutsidePointer ? (
|
|
91
|
+
<textbutton
|
|
92
|
+
Active={true}
|
|
93
|
+
AutoButtonColor={false}
|
|
94
|
+
BackgroundTransparency={1}
|
|
95
|
+
BorderSizePixel={0}
|
|
96
|
+
Modal={true}
|
|
97
|
+
Position={UDim2.fromScale(0, 0)}
|
|
98
|
+
Selectable={false}
|
|
99
|
+
Size={UDim2.fromScale(1, 1)}
|
|
100
|
+
Text=""
|
|
101
|
+
TextTransparency={1}
|
|
102
|
+
ZIndex={0}
|
|
103
|
+
/>
|
|
104
|
+
) : undefined}
|
|
105
|
+
<frame
|
|
106
|
+
BackgroundTransparency={1}
|
|
107
|
+
BorderSizePixel={0}
|
|
108
|
+
Position={UDim2.fromScale(0, 0)}
|
|
109
|
+
Size={UDim2.fromScale(1, 1)}
|
|
110
|
+
ref={contentRootRef}
|
|
111
|
+
ZIndex={1}
|
|
112
|
+
>
|
|
113
|
+
{props.children}
|
|
114
|
+
</frame>
|
|
115
|
+
</screengui>
|
|
116
|
+
</Portal>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { getGuiInsetTopLeft } from "../internals/env";
|
|
2
|
+
import type { LayerInteractEvent } from "./types";
|
|
3
|
+
|
|
4
|
+
type OutsidePointerOptions = {
|
|
5
|
+
layerIgnoresGuiInset: boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export function isPointerInput(inputObject: InputObject) {
|
|
9
|
+
return (
|
|
10
|
+
inputObject.UserInputType === Enum.UserInputType.MouseButton1 ||
|
|
11
|
+
inputObject.UserInputType === Enum.UserInputType.Touch
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function toLayerInteractEvent(originalEvent: InputObject): LayerInteractEvent {
|
|
16
|
+
const event: LayerInteractEvent = {
|
|
17
|
+
originalEvent,
|
|
18
|
+
defaultPrevented: false,
|
|
19
|
+
preventDefault: () => {
|
|
20
|
+
event.defaultPrevented = true;
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
return event;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function addUniqueSample(samples: Array<Vector2>, sampleKeys: Record<string, true>, x: number, y: number) {
|
|
27
|
+
const roundedX = math.round(x);
|
|
28
|
+
const roundedY = math.round(y);
|
|
29
|
+
const key = `${roundedX}:${roundedY}`;
|
|
30
|
+
if (sampleKeys[key]) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
sampleKeys[key] = true;
|
|
35
|
+
samples.push(new Vector2(roundedX, roundedY));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getPointerSamples(pointerPosition: Vector2, options: OutsidePointerOptions) {
|
|
39
|
+
const insetTopLeft = getGuiInsetTopLeft();
|
|
40
|
+
|
|
41
|
+
const samples = new Array<Vector2>();
|
|
42
|
+
const sampleKeys: Record<string, true> = {};
|
|
43
|
+
|
|
44
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X, pointerPosition.Y);
|
|
45
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X + insetTopLeft.X, pointerPosition.Y + insetTopLeft.Y);
|
|
46
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X - insetTopLeft.X, pointerPosition.Y - insetTopLeft.Y);
|
|
47
|
+
|
|
48
|
+
if (options.layerIgnoresGuiInset) {
|
|
49
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X, pointerPosition.Y + insetTopLeft.Y);
|
|
50
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X, pointerPosition.Y - insetTopLeft.Y);
|
|
51
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X + insetTopLeft.X, pointerPosition.Y);
|
|
52
|
+
addUniqueSample(samples, sampleKeys, pointerPosition.X - insetTopLeft.X, pointerPosition.Y);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return samples;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function isOutsidePointerEvent(
|
|
59
|
+
inputObject: InputObject,
|
|
60
|
+
container: BasePlayerGui,
|
|
61
|
+
contentRoot: GuiObject,
|
|
62
|
+
options: OutsidePointerOptions,
|
|
63
|
+
) {
|
|
64
|
+
const rawPointerPosition = inputObject.Position;
|
|
65
|
+
const pointerPosition = new Vector2(rawPointerPosition.X, rawPointerPosition.Y);
|
|
66
|
+
const pointerSamples = getPointerSamples(pointerPosition, options);
|
|
67
|
+
|
|
68
|
+
for (const sample of pointerSamples) {
|
|
69
|
+
const hitGuiObjects = container.GetGuiObjectsAtPosition(sample.X, sample.Y);
|
|
70
|
+
for (const hitObject of hitGuiObjects) {
|
|
71
|
+
if (hitObject.IsDescendantOf(contentRoot)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { GuiService, UserInputService } from "../internals/env";
|
|
2
|
+
import { isPointerInput, toLayerInteractEvent } from "./events";
|
|
3
|
+
import type { LayerInteractEvent } from "./types";
|
|
4
|
+
|
|
5
|
+
type LayerEntry = {
|
|
6
|
+
id: number;
|
|
7
|
+
mountOrder: number;
|
|
8
|
+
getEnabled: () => boolean;
|
|
9
|
+
isPointerOutside: (inputObject: InputObject) => boolean;
|
|
10
|
+
onPointerDownOutside?: (event: LayerInteractEvent) => void;
|
|
11
|
+
onInteractOutside?: (event: LayerInteractEvent) => void;
|
|
12
|
+
onEscapeKeyDown?: (event: LayerInteractEvent) => void;
|
|
13
|
+
onDismiss?: () => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type RegisterLayerParams = Omit<LayerEntry, "id" | "mountOrder">;
|
|
17
|
+
|
|
18
|
+
export type LayerRegistration = {
|
|
19
|
+
id: number;
|
|
20
|
+
mountOrder: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const layerEntries = new Array<LayerEntry>();
|
|
24
|
+
let nextLayerId = 0;
|
|
25
|
+
let nextMountOrder = 0;
|
|
26
|
+
let inputConnection: RBXScriptConnection | undefined;
|
|
27
|
+
|
|
28
|
+
function getTopMostEnabledLayer() {
|
|
29
|
+
for (let index = layerEntries.size() - 1; index >= 0; index--) {
|
|
30
|
+
const entry = layerEntries[index];
|
|
31
|
+
if (entry.getEnabled()) {
|
|
32
|
+
return entry;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function handleDismissEvent(entry: LayerEntry, event: LayerInteractEvent) {
|
|
39
|
+
if (!event.defaultPrevented) {
|
|
40
|
+
entry.onDismiss?.();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function shouldIgnoreEscapeDismiss() {
|
|
45
|
+
const focusedTextBox = UserInputService.GetFocusedTextBox();
|
|
46
|
+
if (focusedTextBox) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const selectedObject = GuiService.SelectedObject;
|
|
51
|
+
if (selectedObject && selectedObject.IsA("TextBox")) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function handleInputBegan(inputObject: InputObject, gameProcessedEvent: boolean) {
|
|
59
|
+
if (gameProcessedEvent) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const topLayer = getTopMostEnabledLayer();
|
|
64
|
+
if (!topLayer) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (inputObject.KeyCode === Enum.KeyCode.Escape) {
|
|
69
|
+
if (shouldIgnoreEscapeDismiss()) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const escapeEvent = toLayerInteractEvent(inputObject);
|
|
74
|
+
topLayer.onEscapeKeyDown?.(escapeEvent);
|
|
75
|
+
handleDismissEvent(topLayer, escapeEvent);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!isPointerInput(inputObject)) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!topLayer.isPointerOutside(inputObject)) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const outsideEvent = toLayerInteractEvent(inputObject);
|
|
88
|
+
topLayer.onPointerDownOutside?.(outsideEvent);
|
|
89
|
+
topLayer.onInteractOutside?.(outsideEvent);
|
|
90
|
+
handleDismissEvent(topLayer, outsideEvent);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function startInputListener() {
|
|
94
|
+
if (inputConnection) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
inputConnection = UserInputService.InputBegan.Connect((inputObject, gameProcessedEvent) => {
|
|
99
|
+
handleInputBegan(inputObject, gameProcessedEvent);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stopInputListener() {
|
|
104
|
+
if (!inputConnection) {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
inputConnection.Disconnect();
|
|
109
|
+
inputConnection = undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function syncInputListener() {
|
|
113
|
+
if (layerEntries.size() > 0) {
|
|
114
|
+
startInputListener();
|
|
115
|
+
} else {
|
|
116
|
+
stopInputListener();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function registerLayer(params: RegisterLayerParams): LayerRegistration {
|
|
121
|
+
nextLayerId += 1;
|
|
122
|
+
nextMountOrder += 1;
|
|
123
|
+
|
|
124
|
+
const entry: LayerEntry = {
|
|
125
|
+
id: nextLayerId,
|
|
126
|
+
mountOrder: nextMountOrder,
|
|
127
|
+
...params,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
layerEntries.push(entry);
|
|
131
|
+
syncInputListener();
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
id: entry.id,
|
|
135
|
+
mountOrder: entry.mountOrder,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function unregisterLayer(layerId: number) {
|
|
140
|
+
const layerIndex = layerEntries.findIndex((entry) => entry.id === layerId);
|
|
141
|
+
if (layerIndex === -1) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
layerEntries.remove(layerIndex);
|
|
146
|
+
syncInputListener();
|
|
147
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type React from "@rbxts/react";
|
|
2
|
+
|
|
3
|
+
export type LayerInteractEvent = {
|
|
4
|
+
originalEvent: InputObject;
|
|
5
|
+
defaultPrevented: boolean;
|
|
6
|
+
preventDefault: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type DismissableLayerProps = {
|
|
10
|
+
children?: React.ReactNode;
|
|
11
|
+
enabled?: boolean;
|
|
12
|
+
modal?: boolean;
|
|
13
|
+
disableOutsidePointerEvents?: boolean;
|
|
14
|
+
onPointerDownOutside?: (event: LayerInteractEvent) => void;
|
|
15
|
+
onInteractOutside?: (event: LayerInteractEvent) => void;
|
|
16
|
+
onEscapeKeyDown?: (event: LayerInteractEvent) => void;
|
|
17
|
+
onDismiss?: () => void;
|
|
18
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ReactRoblox } from "@lattice-ui/core";
|
|
2
|
+
import { usePortalContext } from "./PortalProvider";
|
|
3
|
+
import type { PortalProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export function Portal(props: PortalProps) {
|
|
6
|
+
const contextValue = usePortalContext();
|
|
7
|
+
const target = props.container ?? contextValue.container;
|
|
8
|
+
return ReactRoblox.createPortal(props.children, target);
|
|
9
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { createStrictContext, React } from "@lattice-ui/core";
|
|
2
|
+
import { DEFAULT_DISPLAY_ORDER_BASE } from "../internals/constants";
|
|
3
|
+
import type { PortalContextValue, PortalProviderProps } from "./types";
|
|
4
|
+
|
|
5
|
+
const [PortalContextProvider, usePortalContext] = createStrictContext<PortalContextValue>("PortalProvider");
|
|
6
|
+
|
|
7
|
+
export function PortalProvider(props: PortalProviderProps) {
|
|
8
|
+
const displayOrderBase = props.displayOrderBase ?? DEFAULT_DISPLAY_ORDER_BASE;
|
|
9
|
+
const contextValue = React.useMemo(
|
|
10
|
+
() => ({
|
|
11
|
+
container: props.container,
|
|
12
|
+
displayOrderBase,
|
|
13
|
+
}),
|
|
14
|
+
[displayOrderBase, props.container],
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
return <PortalContextProvider value={contextValue}>{props.children}</PortalContextProvider>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { usePortalContext };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type React from "@rbxts/react";
|
|
2
|
+
|
|
3
|
+
export type PortalContextValue = {
|
|
4
|
+
container: BasePlayerGui;
|
|
5
|
+
displayOrderBase: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type PortalProviderProps = {
|
|
9
|
+
container: BasePlayerGui;
|
|
10
|
+
displayOrderBase?: number;
|
|
11
|
+
children?: React.ReactNode;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type PortalProps = {
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
container?: Instance;
|
|
17
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { React } from "@lattice-ui/core";
|
|
2
|
+
import { DEFAULT_PRESENCE_EXIT_FALLBACK_MS } from "../internals/constants";
|
|
3
|
+
import type { PresenceProps } from "./types";
|
|
4
|
+
|
|
5
|
+
export function Presence(props: PresenceProps) {
|
|
6
|
+
const [mounted, setMounted] = React.useState(props.present);
|
|
7
|
+
const [isPresent, setIsPresent] = React.useState(props.present);
|
|
8
|
+
const mountedRef = React.useRef(mounted);
|
|
9
|
+
const fallbackTaskRef = React.useRef<thread>();
|
|
10
|
+
const onExitCompleteRef = React.useRef(props.onExitComplete);
|
|
11
|
+
|
|
12
|
+
React.useEffect(() => {
|
|
13
|
+
onExitCompleteRef.current = props.onExitComplete;
|
|
14
|
+
}, [props.onExitComplete]);
|
|
15
|
+
|
|
16
|
+
React.useEffect(() => {
|
|
17
|
+
mountedRef.current = mounted;
|
|
18
|
+
}, [mounted]);
|
|
19
|
+
|
|
20
|
+
const completeExit = React.useCallback(() => {
|
|
21
|
+
if (!mountedRef.current) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const fallbackTask = fallbackTaskRef.current;
|
|
26
|
+
if (fallbackTask) {
|
|
27
|
+
task.cancel(fallbackTask);
|
|
28
|
+
fallbackTaskRef.current = undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
mountedRef.current = false;
|
|
32
|
+
setMounted(false);
|
|
33
|
+
onExitCompleteRef.current?.();
|
|
34
|
+
}, []);
|
|
35
|
+
|
|
36
|
+
React.useEffect(() => {
|
|
37
|
+
if (props.present) {
|
|
38
|
+
const fallbackTask = fallbackTaskRef.current;
|
|
39
|
+
if (fallbackTask) {
|
|
40
|
+
task.cancel(fallbackTask);
|
|
41
|
+
fallbackTaskRef.current = undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!mountedRef.current) {
|
|
45
|
+
mountedRef.current = true;
|
|
46
|
+
setMounted(true);
|
|
47
|
+
}
|
|
48
|
+
setIsPresent(true);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!mountedRef.current) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setIsPresent(false);
|
|
57
|
+
|
|
58
|
+
const fallbackTask = fallbackTaskRef.current;
|
|
59
|
+
if (fallbackTask) {
|
|
60
|
+
task.cancel(fallbackTask);
|
|
61
|
+
fallbackTaskRef.current = undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const timeoutMs = props.exitFallbackMs ?? DEFAULT_PRESENCE_EXIT_FALLBACK_MS;
|
|
65
|
+
fallbackTaskRef.current = task.delay(timeoutMs / 1000, () => {
|
|
66
|
+
completeExit();
|
|
67
|
+
});
|
|
68
|
+
}, [completeExit, props.exitFallbackMs, props.present]);
|
|
69
|
+
|
|
70
|
+
React.useEffect(() => {
|
|
71
|
+
return () => {
|
|
72
|
+
const fallbackTask = fallbackTaskRef.current;
|
|
73
|
+
if (fallbackTask) {
|
|
74
|
+
task.cancel(fallbackTask);
|
|
75
|
+
fallbackTaskRef.current = undefined;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
if (!mounted) {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const render = props.render ?? props.children;
|
|
85
|
+
if (!render) {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return render({
|
|
90
|
+
isPresent,
|
|
91
|
+
onExitComplete: completeExit,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type React from "@rbxts/react";
|
|
2
|
+
|
|
3
|
+
export type PresenceRenderState = {
|
|
4
|
+
isPresent: boolean;
|
|
5
|
+
onExitComplete: () => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type PresenceRender = (state: PresenceRenderState) => React.ReactElement | undefined;
|
|
9
|
+
|
|
10
|
+
export type PresenceProps = {
|
|
11
|
+
present: boolean;
|
|
12
|
+
exitFallbackMs?: number;
|
|
13
|
+
onExitComplete?: () => void;
|
|
14
|
+
children?: PresenceRender;
|
|
15
|
+
render?: PresenceRender;
|
|
16
|
+
};
|
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
|
+
}
|