@lattice-ui/slider 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 +18 -0
- package/out/Slider/SliderRange.d.ts +3 -0
- package/out/Slider/SliderRange.luau +33 -0
- package/out/Slider/SliderRoot.d.ts +3 -0
- package/out/Slider/SliderRoot.luau +203 -0
- package/out/Slider/SliderThumb.d.ts +3 -0
- package/out/Slider/SliderThumb.luau +81 -0
- package/out/Slider/SliderTrack.d.ts +3 -0
- package/out/Slider/SliderTrack.luau +45 -0
- package/out/Slider/context.d.ts +3 -0
- package/out/Slider/context.luau +10 -0
- package/out/Slider/internals/math.d.ts +12 -0
- package/out/Slider/internals/math.luau +64 -0
- package/out/Slider/types.d.ts +41 -0
- package/out/Slider/types.luau +2 -0
- package/out/index.d.ts +11 -0
- package/out/init.luau +15 -0
- package/package.json +24 -0
- package/src/Slider/SliderRange.tsx +38 -0
- package/src/Slider/SliderRoot.tsx +228 -0
- package/src/Slider/SliderThumb.tsx +97 -0
- package/src/Slider/SliderTrack.tsx +55 -0
- package/src/Slider/context.ts +6 -0
- package/src/Slider/internals/math.ts +80 -0
- package/src/Slider/types.ts +48 -0
- package/src/index.ts +22 -0
- package/tsconfig.json +16 -0
- package/tsconfig.typecheck.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# @lattice-ui/slider
|
|
2
|
+
|
|
3
|
+
Headless single-thumb slider primitives for Roblox UI.
|
|
4
|
+
|
|
5
|
+
## Exports
|
|
6
|
+
|
|
7
|
+
- `Slider`
|
|
8
|
+
- `Slider.Root`
|
|
9
|
+
- `Slider.Track`
|
|
10
|
+
- `Slider.Range`
|
|
11
|
+
- `Slider.Thumb`
|
|
12
|
+
|
|
13
|
+
## Notes
|
|
14
|
+
|
|
15
|
+
- Single-thumb only in this release.
|
|
16
|
+
- Supports controlled/uncontrolled `value`.
|
|
17
|
+
- Pointer drag and keyboard adjustments are both supported.
|
|
18
|
+
- Values are clamped to `[min, max]` and snapped to `step`.
|
|
@@ -0,0 +1,33 @@
|
|
|
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 useSliderContext = TS.import(script, script.Parent, "context").useSliderContext
|
|
7
|
+
local valueToPercent = TS.import(script, script.Parent, "internals", "math").valueToPercent
|
|
8
|
+
local function SliderRange(props)
|
|
9
|
+
local sliderContext = useSliderContext()
|
|
10
|
+
local percent = valueToPercent(sliderContext.value, sliderContext.min, sliderContext.max)
|
|
11
|
+
local rangeSize = if sliderContext.orientation == "horizontal" then UDim2.fromScale(percent, 1) else UDim2.fromScale(1, percent)
|
|
12
|
+
local rangePosition = if sliderContext.orientation == "horizontal" then UDim2.fromScale(0, 0) else UDim2.fromScale(0, 1 - percent)
|
|
13
|
+
if props.asChild then
|
|
14
|
+
local child = props.children
|
|
15
|
+
if not child then
|
|
16
|
+
error("[SliderRange] `asChild` requires a child element.")
|
|
17
|
+
end
|
|
18
|
+
return React.createElement(Slot, {
|
|
19
|
+
Name = "SliderRange",
|
|
20
|
+
Position = rangePosition,
|
|
21
|
+
Size = rangeSize,
|
|
22
|
+
}, child)
|
|
23
|
+
end
|
|
24
|
+
return React.createElement("frame", {
|
|
25
|
+
BackgroundColor3 = Color3.fromRGB(86, 142, 255),
|
|
26
|
+
BorderSizePixel = 0,
|
|
27
|
+
Position = rangePosition,
|
|
28
|
+
Size = rangeSize,
|
|
29
|
+
}, props.children)
|
|
30
|
+
end
|
|
31
|
+
return {
|
|
32
|
+
SliderRange = SliderRange,
|
|
33
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
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 useControllableState = _core.useControllableState
|
|
6
|
+
local SliderContextProvider = TS.import(script, script.Parent, "context").SliderContextProvider
|
|
7
|
+
local _math = TS.import(script, script.Parent, "internals", "math")
|
|
8
|
+
local normalizeBounds = _math.normalizeBounds
|
|
9
|
+
local normalizeStep = _math.normalizeStep
|
|
10
|
+
local pointerPositionToValue = _math.pointerPositionToValue
|
|
11
|
+
local snapValueToStep = _math.snapValueToStep
|
|
12
|
+
local UserInputService = game:GetService("UserInputService")
|
|
13
|
+
local function toGuiObject(instance)
|
|
14
|
+
if not instance or not instance:IsA("GuiObject") then
|
|
15
|
+
return nil
|
|
16
|
+
end
|
|
17
|
+
return instance
|
|
18
|
+
end
|
|
19
|
+
local function isPointerStartInput(inputObject)
|
|
20
|
+
return inputObject.UserInputType == Enum.UserInputType.MouseButton1 or inputObject.UserInputType == Enum.UserInputType.Touch
|
|
21
|
+
end
|
|
22
|
+
local function SliderRoot(props)
|
|
23
|
+
local _condition = props.min
|
|
24
|
+
if _condition == nil then
|
|
25
|
+
_condition = 0
|
|
26
|
+
end
|
|
27
|
+
local _condition_1 = props.max
|
|
28
|
+
if _condition_1 == nil then
|
|
29
|
+
_condition_1 = 100
|
|
30
|
+
end
|
|
31
|
+
local bounds = normalizeBounds(_condition, _condition_1)
|
|
32
|
+
local min = bounds.min
|
|
33
|
+
local max = bounds.max
|
|
34
|
+
local _condition_2 = props.step
|
|
35
|
+
if _condition_2 == nil then
|
|
36
|
+
_condition_2 = 1
|
|
37
|
+
end
|
|
38
|
+
local step = normalizeStep(_condition_2)
|
|
39
|
+
local orientation = props.orientation or "horizontal"
|
|
40
|
+
local disabled = props.disabled == true
|
|
41
|
+
local _condition_3 = props.defaultValue
|
|
42
|
+
if _condition_3 == nil then
|
|
43
|
+
_condition_3 = min
|
|
44
|
+
end
|
|
45
|
+
local defaultValue = snapValueToStep(_condition_3, min, max, step)
|
|
46
|
+
local _binding = useControllableState({
|
|
47
|
+
value = props.value,
|
|
48
|
+
defaultValue = defaultValue,
|
|
49
|
+
onChange = props.onValueChange,
|
|
50
|
+
})
|
|
51
|
+
local valueState = _binding[1]
|
|
52
|
+
local setValueState = _binding[2]
|
|
53
|
+
local value = snapValueToStep(valueState, min, max, step)
|
|
54
|
+
local trackRef = React.useRef()
|
|
55
|
+
local thumbRef = React.useRef()
|
|
56
|
+
local latestValueRef = React.useRef(value)
|
|
57
|
+
React.useEffect(function()
|
|
58
|
+
latestValueRef.current = value
|
|
59
|
+
end, { value })
|
|
60
|
+
local setValue = React.useCallback(function(nextValue)
|
|
61
|
+
if disabled then
|
|
62
|
+
return nil
|
|
63
|
+
end
|
|
64
|
+
local normalizedValue = snapValueToStep(nextValue, min, max, step)
|
|
65
|
+
latestValueRef.current = normalizedValue
|
|
66
|
+
setValueState(normalizedValue)
|
|
67
|
+
end, { disabled, max, min, setValueState, step })
|
|
68
|
+
local commitValue = React.useCallback(function(nextValue)
|
|
69
|
+
if disabled then
|
|
70
|
+
return nil
|
|
71
|
+
end
|
|
72
|
+
local normalizedValue = snapValueToStep(nextValue, min, max, step)
|
|
73
|
+
local _result = props.onValueCommit
|
|
74
|
+
if _result ~= nil then
|
|
75
|
+
_result(normalizedValue)
|
|
76
|
+
end
|
|
77
|
+
end, { disabled, max, min, props.onValueCommit, step })
|
|
78
|
+
local activeDragInputRef = React.useRef()
|
|
79
|
+
local moveConnectionRef = React.useRef()
|
|
80
|
+
local endConnectionRef = React.useRef()
|
|
81
|
+
local disconnectDragging = React.useCallback(function()
|
|
82
|
+
local _result = moveConnectionRef.current
|
|
83
|
+
if _result ~= nil then
|
|
84
|
+
_result:Disconnect()
|
|
85
|
+
end
|
|
86
|
+
moveConnectionRef.current = nil
|
|
87
|
+
local _result_1 = endConnectionRef.current
|
|
88
|
+
if _result_1 ~= nil then
|
|
89
|
+
_result_1:Disconnect()
|
|
90
|
+
end
|
|
91
|
+
endConnectionRef.current = nil
|
|
92
|
+
activeDragInputRef.current = nil
|
|
93
|
+
end, {})
|
|
94
|
+
local updateValueFromInput = React.useCallback(function(inputObject)
|
|
95
|
+
local trackNode = trackRef.current
|
|
96
|
+
if not trackNode then
|
|
97
|
+
return nil
|
|
98
|
+
end
|
|
99
|
+
local pointerPosition = Vector2.new(inputObject.Position.X, inputObject.Position.Y)
|
|
100
|
+
local nextValue = pointerPositionToValue(pointerPosition, trackNode.AbsolutePosition, trackNode.AbsoluteSize, min, max, step, orientation)
|
|
101
|
+
setValue(nextValue)
|
|
102
|
+
return nextValue
|
|
103
|
+
end, { max, min, orientation, setValue, step })
|
|
104
|
+
local finishDrag = React.useCallback(function(inputObject)
|
|
105
|
+
local activeDragInput = activeDragInputRef.current
|
|
106
|
+
if not activeDragInput then
|
|
107
|
+
return nil
|
|
108
|
+
end
|
|
109
|
+
if inputObject then
|
|
110
|
+
local nextValue = updateValueFromInput(inputObject)
|
|
111
|
+
if nextValue ~= nil then
|
|
112
|
+
latestValueRef.current = nextValue
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
commitValue(latestValueRef.current)
|
|
116
|
+
disconnectDragging()
|
|
117
|
+
end, { commitValue, disconnectDragging, updateValueFromInput })
|
|
118
|
+
local startDrag = React.useCallback(function(inputObject)
|
|
119
|
+
if disabled or not isPointerStartInput(inputObject) then
|
|
120
|
+
return nil
|
|
121
|
+
end
|
|
122
|
+
activeDragInputRef.current = inputObject
|
|
123
|
+
local initialValue = updateValueFromInput(inputObject)
|
|
124
|
+
if initialValue ~= nil then
|
|
125
|
+
latestValueRef.current = initialValue
|
|
126
|
+
end
|
|
127
|
+
local _result = moveConnectionRef.current
|
|
128
|
+
if _result ~= nil then
|
|
129
|
+
_result:Disconnect()
|
|
130
|
+
end
|
|
131
|
+
local _result_1 = endConnectionRef.current
|
|
132
|
+
if _result_1 ~= nil then
|
|
133
|
+
_result_1:Disconnect()
|
|
134
|
+
end
|
|
135
|
+
moveConnectionRef.current = UserInputService.InputChanged:Connect(function(changedInput)
|
|
136
|
+
local activeDragInput = activeDragInputRef.current
|
|
137
|
+
if not activeDragInput then
|
|
138
|
+
return nil
|
|
139
|
+
end
|
|
140
|
+
if activeDragInput.UserInputType == Enum.UserInputType.Touch then
|
|
141
|
+
if changedInput.UserInputType ~= Enum.UserInputType.Touch or changedInput ~= activeDragInput then
|
|
142
|
+
return nil
|
|
143
|
+
end
|
|
144
|
+
local touchValue = updateValueFromInput(changedInput)
|
|
145
|
+
if touchValue ~= nil then
|
|
146
|
+
latestValueRef.current = touchValue
|
|
147
|
+
end
|
|
148
|
+
return nil
|
|
149
|
+
end
|
|
150
|
+
if changedInput.UserInputType ~= Enum.UserInputType.MouseMovement then
|
|
151
|
+
return nil
|
|
152
|
+
end
|
|
153
|
+
local mouseValue = updateValueFromInput(changedInput)
|
|
154
|
+
if mouseValue ~= nil then
|
|
155
|
+
latestValueRef.current = mouseValue
|
|
156
|
+
end
|
|
157
|
+
end)
|
|
158
|
+
endConnectionRef.current = UserInputService.InputEnded:Connect(function(endedInput)
|
|
159
|
+
local activeDragInput = activeDragInputRef.current
|
|
160
|
+
if not activeDragInput then
|
|
161
|
+
return nil
|
|
162
|
+
end
|
|
163
|
+
local endedTouch = activeDragInput.UserInputType == Enum.UserInputType.Touch and endedInput == activeDragInput
|
|
164
|
+
local endedMouse = activeDragInput.UserInputType == Enum.UserInputType.MouseButton1 and endedInput.UserInputType == Enum.UserInputType.MouseButton1
|
|
165
|
+
if not endedTouch and not endedMouse then
|
|
166
|
+
return nil
|
|
167
|
+
end
|
|
168
|
+
finishDrag(if endedTouch then endedInput else nil)
|
|
169
|
+
end)
|
|
170
|
+
end, { disabled, finishDrag, updateValueFromInput })
|
|
171
|
+
React.useEffect(function()
|
|
172
|
+
return function()
|
|
173
|
+
disconnectDragging()
|
|
174
|
+
end
|
|
175
|
+
end, { disconnectDragging })
|
|
176
|
+
local setTrackNode = React.useCallback(function(instance)
|
|
177
|
+
trackRef.current = toGuiObject(instance)
|
|
178
|
+
end, {})
|
|
179
|
+
local setThumbNode = React.useCallback(function(instance)
|
|
180
|
+
thumbRef.current = toGuiObject(instance)
|
|
181
|
+
end, {})
|
|
182
|
+
local contextValue = React.useMemo(function()
|
|
183
|
+
return {
|
|
184
|
+
value = value,
|
|
185
|
+
setValue = setValue,
|
|
186
|
+
commitValue = commitValue,
|
|
187
|
+
min = min,
|
|
188
|
+
max = max,
|
|
189
|
+
step = step,
|
|
190
|
+
orientation = orientation,
|
|
191
|
+
disabled = disabled,
|
|
192
|
+
setTrackNode = setTrackNode,
|
|
193
|
+
setThumbNode = setThumbNode,
|
|
194
|
+
startDrag = startDrag,
|
|
195
|
+
}
|
|
196
|
+
end, { commitValue, disabled, max, min, orientation, setThumbNode, setTrackNode, setValue, startDrag, step, value })
|
|
197
|
+
return React.createElement(SliderContextProvider, {
|
|
198
|
+
value = contextValue,
|
|
199
|
+
}, props.children)
|
|
200
|
+
end
|
|
201
|
+
return {
|
|
202
|
+
SliderRoot = SliderRoot,
|
|
203
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
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 useSliderContext = TS.import(script, script.Parent, "context").useSliderContext
|
|
7
|
+
local valueToPercent = TS.import(script, script.Parent, "internals", "math").valueToPercent
|
|
8
|
+
local function isPointerStartInput(inputObject)
|
|
9
|
+
return inputObject.UserInputType == Enum.UserInputType.MouseButton1 or inputObject.UserInputType == Enum.UserInputType.Touch
|
|
10
|
+
end
|
|
11
|
+
local function SliderThumb(props)
|
|
12
|
+
local sliderContext = useSliderContext()
|
|
13
|
+
local percent = valueToPercent(sliderContext.value, sliderContext.min, sliderContext.max)
|
|
14
|
+
local position = if sliderContext.orientation == "horizontal" then UDim2.fromScale(percent, 0.5) else UDim2.fromScale(0.5, 1 - percent)
|
|
15
|
+
local handleInputBegan = React.useCallback(function(_rbx, inputObject)
|
|
16
|
+
if isPointerStartInput(inputObject) then
|
|
17
|
+
sliderContext.startDrag(inputObject)
|
|
18
|
+
return nil
|
|
19
|
+
end
|
|
20
|
+
if sliderContext.disabled then
|
|
21
|
+
return nil
|
|
22
|
+
end
|
|
23
|
+
local keyCode = inputObject.KeyCode
|
|
24
|
+
local nextValue
|
|
25
|
+
local pageStep = sliderContext.step * 10
|
|
26
|
+
if keyCode == Enum.KeyCode.Home then
|
|
27
|
+
nextValue = sliderContext.min
|
|
28
|
+
elseif keyCode == Enum.KeyCode.End then
|
|
29
|
+
nextValue = sliderContext.max
|
|
30
|
+
elseif keyCode == Enum.KeyCode.PageUp then
|
|
31
|
+
nextValue = sliderContext.value + pageStep
|
|
32
|
+
elseif keyCode == Enum.KeyCode.PageDown then
|
|
33
|
+
nextValue = sliderContext.value - pageStep
|
|
34
|
+
elseif keyCode == Enum.KeyCode.Right or keyCode == Enum.KeyCode.Up then
|
|
35
|
+
nextValue = sliderContext.value + sliderContext.step
|
|
36
|
+
elseif keyCode == Enum.KeyCode.Left or keyCode == Enum.KeyCode.Down then
|
|
37
|
+
nextValue = sliderContext.value - sliderContext.step
|
|
38
|
+
elseif keyCode == Enum.KeyCode.Return or keyCode == Enum.KeyCode.Space then
|
|
39
|
+
sliderContext.commitValue(sliderContext.value)
|
|
40
|
+
return nil
|
|
41
|
+
else
|
|
42
|
+
return nil
|
|
43
|
+
end
|
|
44
|
+
if nextValue == nil then
|
|
45
|
+
return nil
|
|
46
|
+
end
|
|
47
|
+
sliderContext.setValue(nextValue)
|
|
48
|
+
sliderContext.commitValue(nextValue)
|
|
49
|
+
end, { sliderContext })
|
|
50
|
+
local sharedProps = {
|
|
51
|
+
Active = not sliderContext.disabled,
|
|
52
|
+
AnchorPoint = Vector2.new(0.5, 0.5),
|
|
53
|
+
Name = "SliderThumb",
|
|
54
|
+
Position = position,
|
|
55
|
+
Selectable = not sliderContext.disabled,
|
|
56
|
+
Event = {
|
|
57
|
+
InputBegan = handleInputBegan,
|
|
58
|
+
},
|
|
59
|
+
ref = sliderContext.setThumbNode,
|
|
60
|
+
}
|
|
61
|
+
if props.asChild then
|
|
62
|
+
local child = props.children
|
|
63
|
+
if not child then
|
|
64
|
+
error("[SliderThumb] `asChild` requires a child element.")
|
|
65
|
+
end
|
|
66
|
+
local _attributes = table.clone(sharedProps)
|
|
67
|
+
setmetatable(_attributes, nil)
|
|
68
|
+
return React.createElement(Slot, _attributes, child)
|
|
69
|
+
end
|
|
70
|
+
local _attributes = table.clone(sharedProps)
|
|
71
|
+
setmetatable(_attributes, nil)
|
|
72
|
+
_attributes.AutoButtonColor = false
|
|
73
|
+
_attributes.BackgroundColor3 = Color3.fromRGB(235, 241, 250)
|
|
74
|
+
_attributes.BorderSizePixel = 0
|
|
75
|
+
_attributes.Size = UDim2.fromOffset(16, 16)
|
|
76
|
+
_attributes.Text = ""
|
|
77
|
+
return React.createElement("textbutton", _attributes, props.children)
|
|
78
|
+
end
|
|
79
|
+
return {
|
|
80
|
+
SliderThumb = SliderThumb,
|
|
81
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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 useSliderContext = TS.import(script, script.Parent, "context").useSliderContext
|
|
7
|
+
local function isPointerStartInput(inputObject)
|
|
8
|
+
return inputObject.UserInputType == Enum.UserInputType.MouseButton1 or inputObject.UserInputType == Enum.UserInputType.Touch
|
|
9
|
+
end
|
|
10
|
+
local function SliderTrack(props)
|
|
11
|
+
local sliderContext = useSliderContext()
|
|
12
|
+
local handleInputBegan = React.useCallback(function(_rbx, inputObject)
|
|
13
|
+
if not isPointerStartInput(inputObject) then
|
|
14
|
+
return nil
|
|
15
|
+
end
|
|
16
|
+
sliderContext.startDrag(inputObject)
|
|
17
|
+
end, { sliderContext })
|
|
18
|
+
local sharedProps = {
|
|
19
|
+
Active = not sliderContext.disabled,
|
|
20
|
+
Name = "SliderTrack",
|
|
21
|
+
Selectable = not sliderContext.disabled,
|
|
22
|
+
Event = {
|
|
23
|
+
InputBegan = handleInputBegan,
|
|
24
|
+
},
|
|
25
|
+
ref = sliderContext.setTrackNode,
|
|
26
|
+
}
|
|
27
|
+
if props.asChild then
|
|
28
|
+
local child = props.children
|
|
29
|
+
if not child then
|
|
30
|
+
error("[SliderTrack] `asChild` requires a child element.")
|
|
31
|
+
end
|
|
32
|
+
local _attributes = table.clone(sharedProps)
|
|
33
|
+
setmetatable(_attributes, nil)
|
|
34
|
+
return React.createElement(Slot, _attributes, child)
|
|
35
|
+
end
|
|
36
|
+
local _attributes = table.clone(sharedProps)
|
|
37
|
+
setmetatable(_attributes, nil)
|
|
38
|
+
_attributes.BackgroundColor3 = Color3.fromRGB(47, 53, 68)
|
|
39
|
+
_attributes.BorderSizePixel = 0
|
|
40
|
+
_attributes.Size = if sliderContext.orientation == "horizontal" then UDim2.fromOffset(260, 10) else UDim2.fromOffset(10, 220)
|
|
41
|
+
return React.createElement("frame", _attributes, props.children)
|
|
42
|
+
end
|
|
43
|
+
return {
|
|
44
|
+
SliderTrack = SliderTrack,
|
|
45
|
+
}
|
|
@@ -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("Slider")
|
|
5
|
+
local SliderContextProvider = _binding[1]
|
|
6
|
+
local useSliderContext = _binding[2]
|
|
7
|
+
return {
|
|
8
|
+
SliderContextProvider = SliderContextProvider,
|
|
9
|
+
useSliderContext = useSliderContext,
|
|
10
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { SliderOrientation } from "../types";
|
|
2
|
+
export declare function clampNumber(value: number, min: number, max: number): number;
|
|
3
|
+
export declare function normalizeBounds(min: number, max: number): {
|
|
4
|
+
min: number;
|
|
5
|
+
max: number;
|
|
6
|
+
};
|
|
7
|
+
export declare function normalizeStep(step: number): number;
|
|
8
|
+
export declare function snapValueToStep(value: number, min: number, max: number, step: number): number;
|
|
9
|
+
export declare function valueToPercent(value: number, min: number, max: number): number;
|
|
10
|
+
export declare function percentToValue(percent: number, min: number, max: number, step: number): number;
|
|
11
|
+
export declare function pointerPositionToPercent(pointerPosition: Vector2, trackPosition: Vector2, trackSize: Vector2, orientation: SliderOrientation): number;
|
|
12
|
+
export declare function pointerPositionToValue(pointerPosition: Vector2, trackPosition: Vector2, trackSize: Vector2, min: number, max: number, step: number, orientation: SliderOrientation): number;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local function clampNumber(value, min, max)
|
|
3
|
+
return math.clamp(value, min, max)
|
|
4
|
+
end
|
|
5
|
+
local function normalizeBounds(min, max)
|
|
6
|
+
if min <= max then
|
|
7
|
+
return {
|
|
8
|
+
min = min,
|
|
9
|
+
max = max,
|
|
10
|
+
}
|
|
11
|
+
end
|
|
12
|
+
return {
|
|
13
|
+
min = max,
|
|
14
|
+
max = min,
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
local function normalizeStep(step)
|
|
18
|
+
return if step > 0 then step else 1
|
|
19
|
+
end
|
|
20
|
+
local function snapValueToStep(value, min, max, step)
|
|
21
|
+
local clamped = clampNumber(value, min, max)
|
|
22
|
+
local stepCount = math.round((clamped - min) / step)
|
|
23
|
+
local snapped = min + stepCount * step
|
|
24
|
+
return clampNumber(snapped, min, max)
|
|
25
|
+
end
|
|
26
|
+
local function valueToPercent(value, min, max)
|
|
27
|
+
if max <= min then
|
|
28
|
+
return 0
|
|
29
|
+
end
|
|
30
|
+
return clampNumber((value - min) / (max - min), 0, 1)
|
|
31
|
+
end
|
|
32
|
+
local function percentToValue(percent, min, max, step)
|
|
33
|
+
local clampedPercent = clampNumber(percent, 0, 1)
|
|
34
|
+
local rawValue = min + (max - min) * clampedPercent
|
|
35
|
+
return snapValueToStep(rawValue, min, max, step)
|
|
36
|
+
end
|
|
37
|
+
local function pointerPositionToPercent(pointerPosition, trackPosition, trackSize, orientation)
|
|
38
|
+
if orientation == "horizontal" then
|
|
39
|
+
if trackSize.X <= 0 then
|
|
40
|
+
return 0
|
|
41
|
+
end
|
|
42
|
+
local percent = (pointerPosition.X - trackPosition.X) / trackSize.X
|
|
43
|
+
return clampNumber(percent, 0, 1)
|
|
44
|
+
end
|
|
45
|
+
if trackSize.Y <= 0 then
|
|
46
|
+
return 0
|
|
47
|
+
end
|
|
48
|
+
local percent = 1 - (pointerPosition.Y - trackPosition.Y) / trackSize.Y
|
|
49
|
+
return clampNumber(percent, 0, 1)
|
|
50
|
+
end
|
|
51
|
+
local function pointerPositionToValue(pointerPosition, trackPosition, trackSize, min, max, step, orientation)
|
|
52
|
+
local percent = pointerPositionToPercent(pointerPosition, trackPosition, trackSize, orientation)
|
|
53
|
+
return percentToValue(percent, min, max, step)
|
|
54
|
+
end
|
|
55
|
+
return {
|
|
56
|
+
clampNumber = clampNumber,
|
|
57
|
+
normalizeBounds = normalizeBounds,
|
|
58
|
+
normalizeStep = normalizeStep,
|
|
59
|
+
snapValueToStep = snapValueToStep,
|
|
60
|
+
valueToPercent = valueToPercent,
|
|
61
|
+
percentToValue = percentToValue,
|
|
62
|
+
pointerPositionToPercent = pointerPositionToPercent,
|
|
63
|
+
pointerPositionToValue = pointerPositionToValue,
|
|
64
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type React from "@rbxts/react";
|
|
2
|
+
export type SliderOrientation = "horizontal" | "vertical";
|
|
3
|
+
export type SliderSetValue = (value: number) => void;
|
|
4
|
+
export type SliderCommitValue = (value: number) => void;
|
|
5
|
+
export type SliderContextValue = {
|
|
6
|
+
value: number;
|
|
7
|
+
setValue: SliderSetValue;
|
|
8
|
+
commitValue: SliderCommitValue;
|
|
9
|
+
min: number;
|
|
10
|
+
max: number;
|
|
11
|
+
step: number;
|
|
12
|
+
orientation: SliderOrientation;
|
|
13
|
+
disabled: boolean;
|
|
14
|
+
setTrackNode: (instance: Instance | undefined) => void;
|
|
15
|
+
setThumbNode: (instance: Instance | undefined) => void;
|
|
16
|
+
startDrag: (inputObject: InputObject) => void;
|
|
17
|
+
};
|
|
18
|
+
export type SliderProps = {
|
|
19
|
+
value?: number;
|
|
20
|
+
defaultValue?: number;
|
|
21
|
+
onValueChange?: (value: number) => void;
|
|
22
|
+
onValueCommit?: (value: number) => void;
|
|
23
|
+
min?: number;
|
|
24
|
+
max?: number;
|
|
25
|
+
step?: number;
|
|
26
|
+
orientation?: SliderOrientation;
|
|
27
|
+
disabled?: boolean;
|
|
28
|
+
children?: React.ReactNode;
|
|
29
|
+
};
|
|
30
|
+
export type SliderTrackProps = {
|
|
31
|
+
asChild?: boolean;
|
|
32
|
+
children?: React.ReactElement;
|
|
33
|
+
};
|
|
34
|
+
export type SliderRangeProps = {
|
|
35
|
+
asChild?: boolean;
|
|
36
|
+
children?: React.ReactElement;
|
|
37
|
+
};
|
|
38
|
+
export type SliderThumbProps = {
|
|
39
|
+
asChild?: boolean;
|
|
40
|
+
children?: React.ReactElement;
|
|
41
|
+
};
|
package/out/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SliderRange } from "./Slider/SliderRange";
|
|
2
|
+
import { SliderRoot } from "./Slider/SliderRoot";
|
|
3
|
+
import { SliderThumb } from "./Slider/SliderThumb";
|
|
4
|
+
import { SliderTrack } from "./Slider/SliderTrack";
|
|
5
|
+
export declare const Slider: {
|
|
6
|
+
readonly Root: typeof SliderRoot;
|
|
7
|
+
readonly Track: typeof SliderTrack;
|
|
8
|
+
readonly Range: typeof SliderRange;
|
|
9
|
+
readonly Thumb: typeof SliderThumb;
|
|
10
|
+
};
|
|
11
|
+
export type { SliderCommitValue, SliderContextValue, SliderOrientation, SliderProps, SliderRangeProps, SliderSetValue, SliderThumbProps, SliderTrackProps, } from "./Slider/types";
|
package/out/init.luau
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
-- Compiled with roblox-ts v3.0.0
|
|
2
|
+
local TS = _G[script]
|
|
3
|
+
local SliderRange = TS.import(script, script, "Slider", "SliderRange").SliderRange
|
|
4
|
+
local SliderRoot = TS.import(script, script, "Slider", "SliderRoot").SliderRoot
|
|
5
|
+
local SliderThumb = TS.import(script, script, "Slider", "SliderThumb").SliderThumb
|
|
6
|
+
local SliderTrack = TS.import(script, script, "Slider", "SliderTrack").SliderTrack
|
|
7
|
+
local Slider = {
|
|
8
|
+
Root = SliderRoot,
|
|
9
|
+
Track = SliderTrack,
|
|
10
|
+
Range = SliderRange,
|
|
11
|
+
Thumb = SliderThumb,
|
|
12
|
+
}
|
|
13
|
+
return {
|
|
14
|
+
Slider = Slider,
|
|
15
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lattice-ui/slider",
|
|
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
|
+
"@lattice-ui/focus": "0.3.0"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@rbxts/react": "17.3.7-ts.1",
|
|
13
|
+
"@rbxts/react-roblox": "17.3.7-ts.1"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"@rbxts/react": "^17",
|
|
17
|
+
"@rbxts/react-roblox": "^17"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "rbxtsc -p tsconfig.json",
|
|
21
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
22
|
+
"watch": "rbxtsc -p tsconfig.json -w"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { React, Slot } from "@lattice-ui/core";
|
|
2
|
+
import { useSliderContext } from "./context";
|
|
3
|
+
import { valueToPercent } from "./internals/math";
|
|
4
|
+
import type { SliderRangeProps } from "./types";
|
|
5
|
+
|
|
6
|
+
export function SliderRange(props: SliderRangeProps) {
|
|
7
|
+
const sliderContext = useSliderContext();
|
|
8
|
+
const percent = valueToPercent(sliderContext.value, sliderContext.min, sliderContext.max);
|
|
9
|
+
|
|
10
|
+
const rangeSize =
|
|
11
|
+
sliderContext.orientation === "horizontal" ? UDim2.fromScale(percent, 1) : UDim2.fromScale(1, percent);
|
|
12
|
+
const rangePosition =
|
|
13
|
+
sliderContext.orientation === "horizontal" ? UDim2.fromScale(0, 0) : UDim2.fromScale(0, 1 - percent);
|
|
14
|
+
|
|
15
|
+
if (props.asChild) {
|
|
16
|
+
const child = props.children;
|
|
17
|
+
if (!child) {
|
|
18
|
+
error("[SliderRange] `asChild` requires a child element.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Slot Name="SliderRange" Position={rangePosition} Size={rangeSize}>
|
|
23
|
+
{child}
|
|
24
|
+
</Slot>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<frame
|
|
30
|
+
BackgroundColor3={Color3.fromRGB(86, 142, 255)}
|
|
31
|
+
BorderSizePixel={0}
|
|
32
|
+
Position={rangePosition}
|
|
33
|
+
Size={rangeSize}
|
|
34
|
+
>
|
|
35
|
+
{props.children}
|
|
36
|
+
</frame>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { React, useControllableState } from "@lattice-ui/core";
|
|
2
|
+
import { SliderContextProvider } from "./context";
|
|
3
|
+
import { normalizeBounds, normalizeStep, pointerPositionToValue, snapValueToStep } from "./internals/math";
|
|
4
|
+
import type { SliderProps } from "./types";
|
|
5
|
+
|
|
6
|
+
const UserInputService = game.GetService("UserInputService");
|
|
7
|
+
|
|
8
|
+
function toGuiObject(instance: Instance | undefined) {
|
|
9
|
+
if (!instance || !instance.IsA("GuiObject")) {
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return instance;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isPointerStartInput(inputObject: InputObject) {
|
|
17
|
+
return (
|
|
18
|
+
inputObject.UserInputType === Enum.UserInputType.MouseButton1 ||
|
|
19
|
+
inputObject.UserInputType === Enum.UserInputType.Touch
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function SliderRoot(props: SliderProps) {
|
|
24
|
+
const bounds = normalizeBounds(props.min ?? 0, props.max ?? 100);
|
|
25
|
+
const min = bounds.min;
|
|
26
|
+
const max = bounds.max;
|
|
27
|
+
const step = normalizeStep(props.step ?? 1);
|
|
28
|
+
const orientation = props.orientation ?? "horizontal";
|
|
29
|
+
const disabled = props.disabled === true;
|
|
30
|
+
|
|
31
|
+
const defaultValue = snapValueToStep(props.defaultValue ?? min, min, max, step);
|
|
32
|
+
|
|
33
|
+
const [valueState, setValueState] = useControllableState<number>({
|
|
34
|
+
value: props.value,
|
|
35
|
+
defaultValue,
|
|
36
|
+
onChange: props.onValueChange,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const value = snapValueToStep(valueState, min, max, step);
|
|
40
|
+
|
|
41
|
+
const trackRef = React.useRef<GuiObject>();
|
|
42
|
+
const thumbRef = React.useRef<GuiObject>();
|
|
43
|
+
const latestValueRef = React.useRef(value);
|
|
44
|
+
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
latestValueRef.current = value;
|
|
47
|
+
}, [value]);
|
|
48
|
+
|
|
49
|
+
const setValue = React.useCallback(
|
|
50
|
+
(nextValue: number) => {
|
|
51
|
+
if (disabled) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const normalizedValue = snapValueToStep(nextValue, min, max, step);
|
|
56
|
+
latestValueRef.current = normalizedValue;
|
|
57
|
+
setValueState(normalizedValue);
|
|
58
|
+
},
|
|
59
|
+
[disabled, max, min, setValueState, step],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const commitValue = React.useCallback(
|
|
63
|
+
(nextValue: number) => {
|
|
64
|
+
if (disabled) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const normalizedValue = snapValueToStep(nextValue, min, max, step);
|
|
69
|
+
props.onValueCommit?.(normalizedValue);
|
|
70
|
+
},
|
|
71
|
+
[disabled, max, min, props.onValueCommit, step],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const activeDragInputRef = React.useRef<InputObject>();
|
|
75
|
+
const moveConnectionRef = React.useRef<RBXScriptConnection>();
|
|
76
|
+
const endConnectionRef = React.useRef<RBXScriptConnection>();
|
|
77
|
+
|
|
78
|
+
const disconnectDragging = React.useCallback(() => {
|
|
79
|
+
moveConnectionRef.current?.Disconnect();
|
|
80
|
+
moveConnectionRef.current = undefined;
|
|
81
|
+
|
|
82
|
+
endConnectionRef.current?.Disconnect();
|
|
83
|
+
endConnectionRef.current = undefined;
|
|
84
|
+
|
|
85
|
+
activeDragInputRef.current = undefined;
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const updateValueFromInput = React.useCallback(
|
|
89
|
+
(inputObject: InputObject) => {
|
|
90
|
+
const trackNode = trackRef.current;
|
|
91
|
+
if (!trackNode) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const pointerPosition = new Vector2(inputObject.Position.X, inputObject.Position.Y);
|
|
96
|
+
const nextValue = pointerPositionToValue(
|
|
97
|
+
pointerPosition,
|
|
98
|
+
trackNode.AbsolutePosition,
|
|
99
|
+
trackNode.AbsoluteSize,
|
|
100
|
+
min,
|
|
101
|
+
max,
|
|
102
|
+
step,
|
|
103
|
+
orientation,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
setValue(nextValue);
|
|
107
|
+
return nextValue;
|
|
108
|
+
},
|
|
109
|
+
[max, min, orientation, setValue, step],
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const finishDrag = React.useCallback(
|
|
113
|
+
(inputObject?: InputObject) => {
|
|
114
|
+
const activeDragInput = activeDragInputRef.current;
|
|
115
|
+
if (!activeDragInput) {
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (inputObject) {
|
|
120
|
+
const nextValue = updateValueFromInput(inputObject);
|
|
121
|
+
if (nextValue !== undefined) {
|
|
122
|
+
latestValueRef.current = nextValue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
commitValue(latestValueRef.current);
|
|
127
|
+
disconnectDragging();
|
|
128
|
+
},
|
|
129
|
+
[commitValue, disconnectDragging, updateValueFromInput],
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const startDrag = React.useCallback(
|
|
133
|
+
(inputObject: InputObject) => {
|
|
134
|
+
if (disabled || !isPointerStartInput(inputObject)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
activeDragInputRef.current = inputObject;
|
|
139
|
+
const initialValue = updateValueFromInput(inputObject);
|
|
140
|
+
if (initialValue !== undefined) {
|
|
141
|
+
latestValueRef.current = initialValue;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
moveConnectionRef.current?.Disconnect();
|
|
145
|
+
endConnectionRef.current?.Disconnect();
|
|
146
|
+
|
|
147
|
+
moveConnectionRef.current = UserInputService.InputChanged.Connect((changedInput) => {
|
|
148
|
+
const activeDragInput = activeDragInputRef.current;
|
|
149
|
+
if (!activeDragInput) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (activeDragInput.UserInputType === Enum.UserInputType.Touch) {
|
|
154
|
+
if (changedInput.UserInputType !== Enum.UserInputType.Touch || changedInput !== activeDragInput) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const touchValue = updateValueFromInput(changedInput);
|
|
159
|
+
if (touchValue !== undefined) {
|
|
160
|
+
latestValueRef.current = touchValue;
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (changedInput.UserInputType !== Enum.UserInputType.MouseMovement) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const mouseValue = updateValueFromInput(changedInput);
|
|
170
|
+
if (mouseValue !== undefined) {
|
|
171
|
+
latestValueRef.current = mouseValue;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
endConnectionRef.current = UserInputService.InputEnded.Connect((endedInput) => {
|
|
176
|
+
const activeDragInput = activeDragInputRef.current;
|
|
177
|
+
if (!activeDragInput) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const endedTouch = activeDragInput.UserInputType === Enum.UserInputType.Touch && endedInput === activeDragInput;
|
|
182
|
+
const endedMouse =
|
|
183
|
+
activeDragInput.UserInputType === Enum.UserInputType.MouseButton1 &&
|
|
184
|
+
endedInput.UserInputType === Enum.UserInputType.MouseButton1;
|
|
185
|
+
|
|
186
|
+
if (!endedTouch && !endedMouse) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
finishDrag(endedTouch ? endedInput : undefined);
|
|
191
|
+
});
|
|
192
|
+
},
|
|
193
|
+
[disabled, finishDrag, updateValueFromInput],
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
React.useEffect(() => {
|
|
197
|
+
return () => {
|
|
198
|
+
disconnectDragging();
|
|
199
|
+
};
|
|
200
|
+
}, [disconnectDragging]);
|
|
201
|
+
|
|
202
|
+
const setTrackNode = React.useCallback((instance: Instance | undefined) => {
|
|
203
|
+
trackRef.current = toGuiObject(instance);
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
206
|
+
const setThumbNode = React.useCallback((instance: Instance | undefined) => {
|
|
207
|
+
thumbRef.current = toGuiObject(instance);
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
const contextValue = React.useMemo(
|
|
211
|
+
() => ({
|
|
212
|
+
value,
|
|
213
|
+
setValue,
|
|
214
|
+
commitValue,
|
|
215
|
+
min,
|
|
216
|
+
max,
|
|
217
|
+
step,
|
|
218
|
+
orientation,
|
|
219
|
+
disabled,
|
|
220
|
+
setTrackNode,
|
|
221
|
+
setThumbNode,
|
|
222
|
+
startDrag,
|
|
223
|
+
}),
|
|
224
|
+
[commitValue, disabled, max, min, orientation, setThumbNode, setTrackNode, setValue, startDrag, step, value],
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
return <SliderContextProvider value={contextValue}>{props.children}</SliderContextProvider>;
|
|
228
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { React, Slot } from "@lattice-ui/core";
|
|
2
|
+
import { useSliderContext } from "./context";
|
|
3
|
+
import { valueToPercent } from "./internals/math";
|
|
4
|
+
import type { SliderThumbProps } from "./types";
|
|
5
|
+
|
|
6
|
+
function isPointerStartInput(inputObject: InputObject) {
|
|
7
|
+
return (
|
|
8
|
+
inputObject.UserInputType === Enum.UserInputType.MouseButton1 ||
|
|
9
|
+
inputObject.UserInputType === Enum.UserInputType.Touch
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SliderThumb(props: SliderThumbProps) {
|
|
14
|
+
const sliderContext = useSliderContext();
|
|
15
|
+
const percent = valueToPercent(sliderContext.value, sliderContext.min, sliderContext.max);
|
|
16
|
+
|
|
17
|
+
const position =
|
|
18
|
+
sliderContext.orientation === "horizontal" ? UDim2.fromScale(percent, 0.5) : UDim2.fromScale(0.5, 1 - percent);
|
|
19
|
+
|
|
20
|
+
const handleInputBegan = React.useCallback(
|
|
21
|
+
(_rbx: GuiObject, inputObject: InputObject) => {
|
|
22
|
+
if (isPointerStartInput(inputObject)) {
|
|
23
|
+
sliderContext.startDrag(inputObject);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (sliderContext.disabled) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const keyCode = inputObject.KeyCode;
|
|
32
|
+
let nextValue: number | undefined;
|
|
33
|
+
const pageStep = sliderContext.step * 10;
|
|
34
|
+
|
|
35
|
+
if (keyCode === Enum.KeyCode.Home) {
|
|
36
|
+
nextValue = sliderContext.min;
|
|
37
|
+
} else if (keyCode === Enum.KeyCode.End) {
|
|
38
|
+
nextValue = sliderContext.max;
|
|
39
|
+
} else if (keyCode === Enum.KeyCode.PageUp) {
|
|
40
|
+
nextValue = sliderContext.value + pageStep;
|
|
41
|
+
} else if (keyCode === Enum.KeyCode.PageDown) {
|
|
42
|
+
nextValue = sliderContext.value - pageStep;
|
|
43
|
+
} else if (keyCode === Enum.KeyCode.Right || keyCode === Enum.KeyCode.Up) {
|
|
44
|
+
nextValue = sliderContext.value + sliderContext.step;
|
|
45
|
+
} else if (keyCode === Enum.KeyCode.Left || keyCode === Enum.KeyCode.Down) {
|
|
46
|
+
nextValue = sliderContext.value - sliderContext.step;
|
|
47
|
+
} else if (keyCode === Enum.KeyCode.Return || keyCode === Enum.KeyCode.Space) {
|
|
48
|
+
sliderContext.commitValue(sliderContext.value);
|
|
49
|
+
return;
|
|
50
|
+
} else {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (nextValue === undefined) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
sliderContext.setValue(nextValue);
|
|
59
|
+
sliderContext.commitValue(nextValue);
|
|
60
|
+
},
|
|
61
|
+
[sliderContext],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const sharedProps = {
|
|
65
|
+
Active: !sliderContext.disabled,
|
|
66
|
+
AnchorPoint: new Vector2(0.5, 0.5),
|
|
67
|
+
Name: "SliderThumb",
|
|
68
|
+
Position: position,
|
|
69
|
+
Selectable: !sliderContext.disabled,
|
|
70
|
+
Event: {
|
|
71
|
+
InputBegan: handleInputBegan,
|
|
72
|
+
},
|
|
73
|
+
ref: sliderContext.setThumbNode,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (props.asChild) {
|
|
77
|
+
const child = props.children;
|
|
78
|
+
if (!child) {
|
|
79
|
+
error("[SliderThumb] `asChild` requires a child element.");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return <Slot {...sharedProps}>{child}</Slot>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<textbutton
|
|
87
|
+
{...sharedProps}
|
|
88
|
+
AutoButtonColor={false}
|
|
89
|
+
BackgroundColor3={Color3.fromRGB(235, 241, 250)}
|
|
90
|
+
BorderSizePixel={0}
|
|
91
|
+
Size={UDim2.fromOffset(16, 16)}
|
|
92
|
+
Text=""
|
|
93
|
+
>
|
|
94
|
+
{props.children}
|
|
95
|
+
</textbutton>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { React, Slot } from "@lattice-ui/core";
|
|
2
|
+
import { useSliderContext } from "./context";
|
|
3
|
+
import type { SliderTrackProps } from "./types";
|
|
4
|
+
|
|
5
|
+
function isPointerStartInput(inputObject: InputObject) {
|
|
6
|
+
return (
|
|
7
|
+
inputObject.UserInputType === Enum.UserInputType.MouseButton1 ||
|
|
8
|
+
inputObject.UserInputType === Enum.UserInputType.Touch
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SliderTrack(props: SliderTrackProps) {
|
|
13
|
+
const sliderContext = useSliderContext();
|
|
14
|
+
|
|
15
|
+
const handleInputBegan = React.useCallback(
|
|
16
|
+
(_rbx: GuiObject, inputObject: InputObject) => {
|
|
17
|
+
if (!isPointerStartInput(inputObject)) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
sliderContext.startDrag(inputObject);
|
|
22
|
+
},
|
|
23
|
+
[sliderContext],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const sharedProps = {
|
|
27
|
+
Active: !sliderContext.disabled,
|
|
28
|
+
Name: "SliderTrack",
|
|
29
|
+
Selectable: !sliderContext.disabled,
|
|
30
|
+
Event: {
|
|
31
|
+
InputBegan: handleInputBegan,
|
|
32
|
+
},
|
|
33
|
+
ref: sliderContext.setTrackNode,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
if (props.asChild) {
|
|
37
|
+
const child = props.children;
|
|
38
|
+
if (!child) {
|
|
39
|
+
error("[SliderTrack] `asChild` requires a child element.");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return <Slot {...sharedProps}>{child}</Slot>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<frame
|
|
47
|
+
{...sharedProps}
|
|
48
|
+
BackgroundColor3={Color3.fromRGB(47, 53, 68)}
|
|
49
|
+
BorderSizePixel={0}
|
|
50
|
+
Size={sliderContext.orientation === "horizontal" ? UDim2.fromOffset(260, 10) : UDim2.fromOffset(10, 220)}
|
|
51
|
+
>
|
|
52
|
+
{props.children}
|
|
53
|
+
</frame>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { SliderOrientation } from "../types";
|
|
2
|
+
|
|
3
|
+
export function clampNumber(value: number, min: number, max: number) {
|
|
4
|
+
return math.clamp(value, min, max);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeBounds(min: number, max: number) {
|
|
8
|
+
if (min <= max) {
|
|
9
|
+
return {
|
|
10
|
+
min,
|
|
11
|
+
max,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
min: max,
|
|
17
|
+
max: min,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function normalizeStep(step: number) {
|
|
22
|
+
return step > 0 ? step : 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function snapValueToStep(value: number, min: number, max: number, step: number) {
|
|
26
|
+
const clamped = clampNumber(value, min, max);
|
|
27
|
+
const stepCount = math.round((clamped - min) / step);
|
|
28
|
+
const snapped = min + stepCount * step;
|
|
29
|
+
return clampNumber(snapped, min, max);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function valueToPercent(value: number, min: number, max: number) {
|
|
33
|
+
if (max <= min) {
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return clampNumber((value - min) / (max - min), 0, 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function percentToValue(percent: number, min: number, max: number, step: number) {
|
|
41
|
+
const clampedPercent = clampNumber(percent, 0, 1);
|
|
42
|
+
const rawValue = min + (max - min) * clampedPercent;
|
|
43
|
+
return snapValueToStep(rawValue, min, max, step);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function pointerPositionToPercent(
|
|
47
|
+
pointerPosition: Vector2,
|
|
48
|
+
trackPosition: Vector2,
|
|
49
|
+
trackSize: Vector2,
|
|
50
|
+
orientation: SliderOrientation,
|
|
51
|
+
) {
|
|
52
|
+
if (orientation === "horizontal") {
|
|
53
|
+
if (trackSize.X <= 0) {
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const percent = (pointerPosition.X - trackPosition.X) / trackSize.X;
|
|
58
|
+
return clampNumber(percent, 0, 1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (trackSize.Y <= 0) {
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const percent = 1 - (pointerPosition.Y - trackPosition.Y) / trackSize.Y;
|
|
66
|
+
return clampNumber(percent, 0, 1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function pointerPositionToValue(
|
|
70
|
+
pointerPosition: Vector2,
|
|
71
|
+
trackPosition: Vector2,
|
|
72
|
+
trackSize: Vector2,
|
|
73
|
+
min: number,
|
|
74
|
+
max: number,
|
|
75
|
+
step: number,
|
|
76
|
+
orientation: SliderOrientation,
|
|
77
|
+
) {
|
|
78
|
+
const percent = pointerPositionToPercent(pointerPosition, trackPosition, trackSize, orientation);
|
|
79
|
+
return percentToValue(percent, min, max, step);
|
|
80
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type React from "@rbxts/react";
|
|
2
|
+
|
|
3
|
+
export type SliderOrientation = "horizontal" | "vertical";
|
|
4
|
+
|
|
5
|
+
export type SliderSetValue = (value: number) => void;
|
|
6
|
+
export type SliderCommitValue = (value: number) => void;
|
|
7
|
+
|
|
8
|
+
export type SliderContextValue = {
|
|
9
|
+
value: number;
|
|
10
|
+
setValue: SliderSetValue;
|
|
11
|
+
commitValue: SliderCommitValue;
|
|
12
|
+
min: number;
|
|
13
|
+
max: number;
|
|
14
|
+
step: number;
|
|
15
|
+
orientation: SliderOrientation;
|
|
16
|
+
disabled: boolean;
|
|
17
|
+
setTrackNode: (instance: Instance | undefined) => void;
|
|
18
|
+
setThumbNode: (instance: Instance | undefined) => void;
|
|
19
|
+
startDrag: (inputObject: InputObject) => void;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type SliderProps = {
|
|
23
|
+
value?: number;
|
|
24
|
+
defaultValue?: number;
|
|
25
|
+
onValueChange?: (value: number) => void;
|
|
26
|
+
onValueCommit?: (value: number) => void;
|
|
27
|
+
min?: number;
|
|
28
|
+
max?: number;
|
|
29
|
+
step?: number;
|
|
30
|
+
orientation?: SliderOrientation;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
children?: React.ReactNode;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type SliderTrackProps = {
|
|
36
|
+
asChild?: boolean;
|
|
37
|
+
children?: React.ReactElement;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type SliderRangeProps = {
|
|
41
|
+
asChild?: boolean;
|
|
42
|
+
children?: React.ReactElement;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type SliderThumbProps = {
|
|
46
|
+
asChild?: boolean;
|
|
47
|
+
children?: React.ReactElement;
|
|
48
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { SliderRange } from "./Slider/SliderRange";
|
|
2
|
+
import { SliderRoot } from "./Slider/SliderRoot";
|
|
3
|
+
import { SliderThumb } from "./Slider/SliderThumb";
|
|
4
|
+
import { SliderTrack } from "./Slider/SliderTrack";
|
|
5
|
+
|
|
6
|
+
export const Slider = {
|
|
7
|
+
Root: SliderRoot,
|
|
8
|
+
Track: SliderTrack,
|
|
9
|
+
Range: SliderRange,
|
|
10
|
+
Thumb: SliderThumb,
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
SliderCommitValue,
|
|
15
|
+
SliderContextValue,
|
|
16
|
+
SliderOrientation,
|
|
17
|
+
SliderProps,
|
|
18
|
+
SliderRangeProps,
|
|
19
|
+
SliderSetValue,
|
|
20
|
+
SliderThumbProps,
|
|
21
|
+
SliderTrackProps,
|
|
22
|
+
} from "./Slider/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
|
+
}
|