@lattice-ui/select 0.3.2 → 0.4.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.
@@ -0,0 +1,4 @@
1
+
2
+ > @lattice-ui/select@0.4.1 build C:\Users\retur\OneDrive\Desktop\Workspace\rojo\lattice-ui\packages\select
3
+ > rbxtsc -p tsconfig.json
4
+
@@ -0,0 +1,4 @@
1
+
2
+ > @lattice-ui/select@0.4.1 typecheck C:\Users\retur\OneDrive\Desktop\Workspace\rojo\lattice-ui\packages\select
3
+ > tsc -p tsconfig.typecheck.json
4
+
package/README.md CHANGED
@@ -19,5 +19,5 @@ Headless single-select primitives built for Roblox UI.
19
19
 
20
20
  - Single value only in this release.
21
21
  - Supports controlled/uncontrolled `value` and `open`.
22
- - Content uses dismissable-layer semantics (outside pointer / escape dismiss).
23
- - Item navigation uses roving focus and includes typeahead matching.
22
+ - Content uses dismissable-layer semantics (outside pointer dismiss).
23
+ - No built-in Roblox native selection or directional keyboard navigation.
@@ -3,112 +3,24 @@ local TS = _G[script]
3
3
  local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
4
4
  local React = _core.React
5
5
  local Slot = _core.Slot
6
- local RovingFocusGroup = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out).RovingFocusGroup
6
+ local FocusScope = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out).FocusScope
7
7
  local _layer = TS.import(script, TS.getModule(script, "@lattice-ui", "layer").out)
8
8
  local DismissableLayer = _layer.DismissableLayer
9
9
  local Presence = _layer.Presence
10
10
  local usePopper = TS.import(script, TS.getModule(script, "@lattice-ui", "popper").out).usePopper
11
11
  local useSelectContext = TS.import(script, script.Parent, "context").useSelectContext
12
- local GuiService = game:GetService("GuiService")
13
- local UserInputService = game:GetService("UserInputService")
14
- local digitKeyMap = {
15
- Zero = "0",
16
- One = "1",
17
- Two = "2",
18
- Three = "3",
19
- Four = "4",
20
- Five = "5",
21
- Six = "6",
22
- Seven = "7",
23
- Eight = "8",
24
- Nine = "9",
25
- }
12
+ local TweenService = game:GetService("TweenService")
13
+ local OPEN_TWEEN_INFO = TweenInfo.new(0.12, Enum.EasingStyle.Quad, Enum.EasingDirection.Out)
14
+ local CLOSE_TWEEN_INFO = TweenInfo.new(0.09, Enum.EasingStyle.Quad, Enum.EasingDirection.In)
15
+ local CONTENT_OPEN_Y_OFFSET = 6
26
16
  local function toGuiObject(instance)
27
17
  if not instance or not instance:IsA("GuiObject") then
28
18
  return nil
29
19
  end
30
20
  return instance
31
21
  end
32
- local function toSearchCharacter(keyCode)
33
- if keyCode == Enum.KeyCode.Space then
34
- return " "
35
- end
36
- local digitCharacter = digitKeyMap[keyCode.Name]
37
- if digitCharacter ~= nil then
38
- return digitCharacter
39
- end
40
- if #keyCode.Name == 1 then
41
- return string.lower(keyCode.Name)
42
- end
43
- return nil
44
- end
45
- local function startsWithIgnoreCase(value, query)
46
- if #query == 0 then
47
- return false
48
- end
49
- local normalizedValue = string.lower(value)
50
- return string.sub(normalizedValue, 1, #query) == query
51
- end
52
- local function findCurrentIndex(items, selectedObject)
53
- if not selectedObject then
54
- return -1
55
- end
56
- -- ▼ ReadonlyArray.findIndex ▼
57
- local _callback = function(item)
58
- local node = item.getNode()
59
- if not node then
60
- return false
61
- end
62
- return selectedObject == node or selectedObject:IsDescendantOf(node)
63
- end
64
- local _result = -1
65
- for _i, _v in items do
66
- if _callback(_v, _i - 1, items) == true then
67
- _result = _i - 1
68
- break
69
- end
70
- end
71
- -- ▲ ReadonlyArray.findIndex ▲
72
- return _result
73
- end
74
- local function findTypeaheadMatch(items, query, startIndex)
75
- local itemCount = #items
76
- if itemCount == 0 then
77
- return -1
78
- end
79
- do
80
- local attempts = 0
81
- local _shouldIncrement = false
82
- while true do
83
- if _shouldIncrement then
84
- attempts += 1
85
- else
86
- _shouldIncrement = true
87
- end
88
- if not (attempts < itemCount) then
89
- break
90
- end
91
- local candidateIndex = (startIndex + attempts) % itemCount
92
- local candidate = items[candidateIndex + 1]
93
- if not candidate or candidate.getDisabled() then
94
- continue
95
- end
96
- if startsWithIgnoreCase(candidate.getTextValue(), query) then
97
- return candidateIndex
98
- end
99
- end
100
- end
101
- return -1
102
- end
103
- local function focusItem(item)
104
- if not item then
105
- return nil
106
- end
107
- local node = item.getNode()
108
- if not node or not node.Selectable then
109
- return nil
110
- end
111
- GuiService.SelectedObject = node
22
+ local function withVerticalOffset(position, offset)
23
+ return UDim2.new(position.X.Scale, position.X.Offset, position.Y.Scale, position.Y.Offset + offset)
112
24
  end
113
25
  local function SelectContentImpl(props)
114
26
  local selectContext = useSelectContext()
@@ -123,69 +35,68 @@ local function SelectContentImpl(props)
123
35
  local setContentRef = React.useCallback(function(instance)
124
36
  selectContext.contentRef.current = toGuiObject(instance)
125
37
  end, { selectContext.contentRef })
126
- local searchRef = React.useRef("")
127
- local searchTimestampRef = React.useRef(0)
128
- React.useEffect(function()
129
- if not props.enabled then
130
- return nil
38
+ local positionTweenRef = React.useRef()
39
+ local tweenCompletedConnectionRef = React.useRef()
40
+ local previousVisibleRef = React.useRef(props.visible)
41
+ local previousExitingRef = React.useRef(props.exiting)
42
+ local clearTween = React.useCallback(function()
43
+ local tween = positionTweenRef.current
44
+ if tween then
45
+ tween:Cancel()
46
+ positionTweenRef.current = nil
131
47
  end
132
- local orderedItems = selectContext.getOrderedItems()
133
- -- ReadonlyArray.find ▼
134
- local _callback = function(item)
135
- return item.value == selectContext.value and not item.getDisabled()
48
+ local completedConnection = tweenCompletedConnectionRef.current
49
+ if completedConnection then
50
+ completedConnection:Disconnect()
51
+ tweenCompletedConnectionRef.current = nil
136
52
  end
137
- local _result
138
- for _i, _v in orderedItems do
139
- if _callback(_v, _i - 1, orderedItems) == true then
140
- _result = _v
141
- break
142
- end
53
+ end, {})
54
+ React.useEffect(function()
55
+ return function()
56
+ clearTween()
143
57
  end
144
- -- ReadonlyArray.find
145
- local selectedItem = _result
146
- focusItem(selectedItem)
147
- end, { props.enabled, selectContext, selectContext.value })
58
+ end, { clearTween })
148
59
  React.useEffect(function()
149
- if not props.enabled then
60
+ local contentNode = selectContext.contentRef.current
61
+ if not contentNode then
150
62
  return nil
151
63
  end
152
- searchRef.current = ""
153
- searchTimestampRef.current = 0
154
- local connection = UserInputService.InputBegan:Connect(function(inputObject, gameProcessedEvent)
155
- if gameProcessedEvent then
156
- return nil
157
- end
158
- if inputObject.UserInputType ~= Enum.UserInputType.Keyboard then
64
+ local wasVisible = previousVisibleRef.current
65
+ local wasExiting = previousExitingRef.current
66
+ previousVisibleRef.current = props.visible
67
+ previousExitingRef.current = props.exiting
68
+ if props.exiting then
69
+ if wasExiting then
159
70
  return nil
160
71
  end
161
- local contentNode = selectContext.contentRef.current
162
- local selectedObject = GuiService.SelectedObject
163
- if not contentNode or not selectedObject or not selectedObject:IsDescendantOf(contentNode) then
164
- return nil
165
- end
166
- local searchCharacter = toSearchCharacter(inputObject.KeyCode)
167
- if searchCharacter == nil then
168
- return nil
169
- end
170
- local now = os.clock()
171
- local shouldResetQuery = now - searchTimestampRef.current > 0.8
172
- local nextQuery = if shouldResetQuery then searchCharacter else `{searchRef.current}{searchCharacter}`
173
- searchRef.current = nextQuery
174
- searchTimestampRef.current = now
175
- local orderedItems = selectContext.getOrderedItems()
176
- local currentIndex = findCurrentIndex(orderedItems, selectedObject)
177
- local startIndex = if currentIndex >= 0 then currentIndex + 1 else 0
178
- local matchIndex = findTypeaheadMatch(orderedItems, string.lower(nextQuery), startIndex)
179
- if matchIndex < 0 then
180
- return nil
181
- end
182
- local matchedItem = orderedItems[matchIndex + 1]
183
- focusItem(matchedItem)
184
- end)
185
- return function()
186
- connection:Disconnect()
72
+ clearTween()
73
+ local tween = TweenService:Create(contentNode, CLOSE_TWEEN_INFO, {
74
+ Position = withVerticalOffset(popper.position, CONTENT_OPEN_Y_OFFSET),
75
+ })
76
+ positionTweenRef.current = tween
77
+ tweenCompletedConnectionRef.current = tween.Completed:Connect(function(playbackState)
78
+ if playbackState == Enum.PlaybackState.Completed then
79
+ local _result = props.onExitComplete
80
+ if _result ~= nil then
81
+ _result()
82
+ end
83
+ end
84
+ end)
85
+ tween:Play()
86
+ return nil
87
+ end
88
+ clearTween()
89
+ if props.visible and not wasVisible then
90
+ contentNode.Position = withVerticalOffset(popper.position, CONTENT_OPEN_Y_OFFSET)
91
+ local tween = TweenService:Create(contentNode, OPEN_TWEEN_INFO, {
92
+ Position = popper.position,
93
+ })
94
+ positionTweenRef.current = tween
95
+ tween:Play()
96
+ return nil
187
97
  end
188
- end, { props.enabled, selectContext })
98
+ contentNode.Position = popper.position
99
+ end, { clearTween, popper.position, props.exiting, props.onExitComplete, props.visible, selectContext.contentRef })
189
100
  local contentNode = if props.asChild then ((function()
190
101
  local child = props.children
191
102
  if not React.isValidElement(child) then
@@ -194,7 +105,7 @@ local function SelectContentImpl(props)
194
105
  return React.createElement(Slot, {
195
106
  AnchorPoint = popper.anchorPoint,
196
107
  Position = popper.position,
197
- Visible = props.visible,
108
+ Visible = props.visible or props.exiting,
198
109
  ref = setContentRef,
199
110
  }, child)
200
111
  end)()) else (React.createElement("frame", {
@@ -203,21 +114,19 @@ local function SelectContentImpl(props)
203
114
  BorderSizePixel = 0,
204
115
  Position = popper.position,
205
116
  Size = UDim2.fromOffset(0, 0),
206
- Visible = props.visible,
117
+ Visible = props.visible or props.exiting,
207
118
  ref = setContentRef,
208
119
  }, props.children))
209
120
  return React.createElement(DismissableLayer, {
210
121
  enabled = props.enabled,
211
122
  modal = false,
212
123
  onDismiss = props.onDismiss,
213
- onEscapeKeyDown = props.onEscapeKeyDown,
214
124
  onInteractOutside = props.onInteractOutside,
215
125
  onPointerDownOutside = props.onPointerDownOutside,
216
- }, React.createElement(RovingFocusGroup, {
126
+ }, React.createElement(FocusScope, {
217
127
  active = props.enabled,
218
- autoFocus = "first",
219
- loop = selectContext.loop,
220
- orientation = "vertical",
128
+ restoreFocus = true,
129
+ trapped = false,
221
130
  }, contentNode))
222
131
  end
223
132
  local function SelectContent(props)
@@ -234,9 +143,9 @@ local function SelectContent(props)
234
143
  return React.createElement(SelectContentImpl, {
235
144
  asChild = props.asChild,
236
145
  enabled = open,
146
+ exiting = false,
237
147
  offset = props.offset,
238
148
  onDismiss = handleDismiss,
239
- onEscapeKeyDown = props.onEscapeKeyDown,
240
149
  onInteractOutside = props.onInteractOutside,
241
150
  onPointerDownOutside = props.onPointerDownOutside,
242
151
  padding = props.padding,
@@ -245,15 +154,16 @@ local function SelectContent(props)
245
154
  }, props.children)
246
155
  end
247
156
  return React.createElement(Presence, {
248
- exitFallbackMs = 0,
157
+ exitFallbackMs = 180,
249
158
  present = open,
250
159
  render = function(state)
251
160
  return React.createElement(SelectContentImpl, {
252
161
  asChild = props.asChild,
253
162
  enabled = state.isPresent,
163
+ exiting = not state.isPresent,
254
164
  offset = props.offset,
255
165
  onDismiss = handleDismiss,
256
- onEscapeKeyDown = props.onEscapeKeyDown,
166
+ onExitComplete = state.onExitComplete,
257
167
  onInteractOutside = props.onInteractOutside,
258
168
  onPointerDownOutside = props.onPointerDownOutside,
259
169
  padding = props.padding,
@@ -3,19 +3,11 @@ local TS = _G[script]
3
3
  local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
4
4
  local React = _core.React
5
5
  local Slot = _core.Slot
6
- local RovingFocusItem = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out).RovingFocusItem
7
6
  local useSelectContext = TS.import(script, script.Parent, "context").useSelectContext
8
7
  local nextItemId = 0
9
8
  local nextItemOrder = 0
10
- local function toGuiObject(instance)
11
- if not instance or not instance:IsA("GuiObject") then
12
- return nil
13
- end
14
- return instance
15
- end
16
9
  local function SelectItem(props)
17
10
  local selectContext = useSelectContext()
18
- local itemRef = React.useRef()
19
11
  local disabled = selectContext.disabled or props.disabled == true
20
12
  local _condition = props.textValue
21
13
  if _condition == nil then
@@ -45,9 +37,6 @@ local function SelectItem(props)
45
37
  id = itemIdRef.current,
46
38
  value = props.value,
47
39
  order = itemOrderRef.current,
48
- getNode = function()
49
- return itemRef.current
50
- end,
51
40
  getDisabled = function()
52
41
  return disabledRef.current
53
42
  end,
@@ -56,9 +45,6 @@ local function SelectItem(props)
56
45
  end,
57
46
  })
58
47
  end, { props.value, selectContext })
59
- local setItemRef = React.useCallback(function(instance)
60
- itemRef.current = toGuiObject(instance)
61
- end, {})
62
48
  local handleSelect = React.useCallback(function()
63
49
  if disabled then
64
50
  return nil
@@ -88,36 +74,28 @@ local function SelectItem(props)
88
74
  if not child then
89
75
  error("[SelectItem] `asChild` requires a child element.")
90
76
  end
91
- return React.createElement(RovingFocusItem, {
92
- asChild = true,
93
- disabled = disabled,
94
- }, React.createElement(Slot, {
77
+ return React.createElement(Slot, {
95
78
  Active = not disabled,
96
79
  Event = eventHandlers,
97
- Selectable = not disabled,
98
- ref = setItemRef,
99
- }, child))
80
+ Selectable = false,
81
+ }, child)
100
82
  end
101
- return React.createElement(RovingFocusItem, {
102
- asChild = true,
103
- disabled = disabled,
104
- }, React.createElement("textbutton", {
83
+ return React.createElement("textbutton", {
105
84
  Active = not disabled,
106
85
  AutoButtonColor = false,
107
86
  BackgroundColor3 = Color3.fromRGB(47, 53, 68),
108
87
  BorderSizePixel = 0,
109
88
  Event = eventHandlers,
110
- Selectable = not disabled,
89
+ Selectable = false,
111
90
  Size = UDim2.fromOffset(220, 32),
112
91
  Text = textValue,
113
92
  TextColor3 = if disabled then Color3.fromRGB(134, 141, 156) else Color3.fromRGB(234, 239, 247),
114
93
  TextSize = 15,
115
94
  TextXAlignment = Enum.TextXAlignment.Left,
116
- ref = setItemRef,
117
95
  }, React.createElement("uipadding", {
118
96
  PaddingLeft = UDim.new(0, 10),
119
97
  PaddingRight = UDim.new(0, 10),
120
- }), props.children))
98
+ }), props.children)
121
99
  end
122
100
  return {
123
101
  SelectItem = SelectItem,
@@ -44,11 +44,6 @@ local function SelectRoot(props)
44
44
  local setValueState = _binding_1[2]
45
45
  local disabled = props.disabled == true
46
46
  local required = props.required == true
47
- local _condition_1 = props.loop
48
- if _condition_1 == nil then
49
- _condition_1 = true
50
- end
51
- local loop = _condition_1
52
47
  local triggerRef = React.useRef()
53
48
  local contentRef = React.useRef()
54
49
  local itemEntriesRef = React.useRef({})
@@ -184,14 +179,12 @@ local function SelectRoot(props)
184
179
  setValue = setValue,
185
180
  disabled = disabled,
186
181
  required = required,
187
- loop = loop,
188
182
  triggerRef = triggerRef,
189
183
  contentRef = contentRef,
190
184
  registerItem = registerItem,
191
- getOrderedItems = resolveOrderedItems,
192
185
  getItemText = getItemText,
193
186
  }
194
- end, { disabled, getItemText, loop, open, registerItem, required, resolveOrderedItems, setOpen, setValue, value })
187
+ end, { disabled, getItemText, open, registerItem, required, setOpen, setValue, value })
195
188
  return React.createElement(SelectContextProvider, {
196
189
  value = contextValue,
197
190
  }, props.children)
@@ -13,9 +13,10 @@ end
13
13
  local function SelectTrigger(props)
14
14
  local selectContext = useSelectContext()
15
15
  local disabled = selectContext.disabled or props.disabled == true
16
+ local triggerRef = selectContext.triggerRef
16
17
  local setTriggerRef = React.useCallback(function(instance)
17
- selectContext.triggerRef.current = toGuiObject(instance)
18
- end, { selectContext.triggerRef })
18
+ triggerRef.current = toGuiObject(instance)
19
+ end, { triggerRef })
19
20
  local handleActivated = React.useCallback(function()
20
21
  if disabled then
21
22
  return nil
@@ -29,10 +30,6 @@ local function SelectTrigger(props)
29
30
  local keyCode = inputObject.KeyCode
30
31
  if keyCode == Enum.KeyCode.Return or keyCode == Enum.KeyCode.Space then
31
32
  selectContext.setOpen(not selectContext.open)
32
- return nil
33
- end
34
- if keyCode == Enum.KeyCode.Down or keyCode == Enum.KeyCode.Up then
35
- selectContext.setOpen(true)
36
33
  end
37
34
  end, { disabled, selectContext })
38
35
  local eventHandlers = React.useMemo(function()
@@ -49,7 +46,7 @@ local function SelectTrigger(props)
49
46
  return React.createElement(Slot, {
50
47
  Active = not disabled,
51
48
  Event = eventHandlers,
52
- Selectable = not disabled,
49
+ Selectable = false,
53
50
  ref = setTriggerRef,
54
51
  }, child)
55
52
  end
@@ -59,7 +56,7 @@ local function SelectTrigger(props)
59
56
  BackgroundColor3 = Color3.fromRGB(41, 48, 63),
60
57
  BorderSizePixel = 0,
61
58
  Event = eventHandlers,
62
- Selectable = not disabled,
59
+ Selectable = false,
63
60
  Size = UDim2.fromOffset(220, 36),
64
61
  Text = "Select",
65
62
  TextColor3 = if disabled then Color3.fromRGB(140, 148, 164) else Color3.fromRGB(235, 241, 248),
@@ -7,7 +7,6 @@ export type SelectItemRegistration = {
7
7
  id: number;
8
8
  value: string;
9
9
  order: number;
10
- getNode: () => GuiObject | undefined;
11
10
  getDisabled: () => boolean;
12
11
  getTextValue: () => string;
13
12
  };
@@ -18,11 +17,9 @@ export type SelectContextValue = {
18
17
  setValue: SelectSetValue;
19
18
  disabled: boolean;
20
19
  required: boolean;
21
- loop: boolean;
22
20
  triggerRef: React.MutableRefObject<GuiObject | undefined>;
23
21
  contentRef: React.MutableRefObject<GuiObject | undefined>;
24
22
  registerItem: (item: SelectItemRegistration) => () => void;
25
- getOrderedItems: () => Array<SelectItemRegistration>;
26
23
  getItemText: (value: string) => string | undefined;
27
24
  };
28
25
  export type SelectProps = {
@@ -34,7 +31,6 @@ export type SelectProps = {
34
31
  onOpenChange?: (open: boolean) => void;
35
32
  disabled?: boolean;
36
33
  required?: boolean;
37
- loop?: boolean;
38
34
  children?: React.ReactNode;
39
35
  };
40
36
  export type SelectTriggerProps = {
@@ -58,7 +54,6 @@ export type SelectContentProps = {
58
54
  placement?: PopperPlacement;
59
55
  offset?: Vector2;
60
56
  padding?: number;
61
- onEscapeKeyDown?: (event: LayerInteractEvent) => void;
62
57
  onPointerDownOutside?: (event: LayerInteractEvent) => void;
63
58
  onInteractOutside?: (event: LayerInteractEvent) => void;
64
59
  children?: React.ReactNode;
package/out/index.d.ts CHANGED
@@ -18,5 +18,5 @@ export declare const Select: {
18
18
  readonly Label: typeof SelectLabel;
19
19
  readonly Separator: typeof SelectSeparator;
20
20
  };
21
- export { SelectContent, SelectGroup, SelectItem, SelectLabel, SelectPortal, SelectRoot, SelectSeparator, SelectTrigger, SelectValue, };
22
21
  export type { SelectContentProps, SelectContextValue, SelectGroupProps, SelectItemProps, SelectItemRegistration, SelectLabelProps, SelectPortalProps, SelectProps, SelectSeparatorProps, SelectSetOpen, SelectSetValue, SelectTriggerProps, SelectValueProps, } from "./Select/types";
22
+ export { SelectContent, SelectGroup, SelectItem, SelectLabel, SelectPortal, SelectRoot, SelectSeparator, SelectTrigger, SelectValue, };
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@lattice-ui/select",
3
- "version": "0.3.2",
3
+ "version": "0.4.1",
4
4
  "private": false,
5
5
  "main": "out/init.luau",
6
6
  "types": "out/index.d.ts",
7
7
  "dependencies": {
8
- "@lattice-ui/core": "0.3.2",
9
- "@lattice-ui/focus": "0.3.2",
10
- "@lattice-ui/layer": "0.3.2",
11
- "@lattice-ui/popper": "0.3.2"
8
+ "@lattice-ui/core": "0.4.1",
9
+ "@lattice-ui/layer": "0.4.1",
10
+ "@lattice-ui/focus": "0.4.1",
11
+ "@lattice-ui/popper": "0.4.1"
12
12
  },
13
13
  "devDependencies": {
14
14
  "@rbxts/react": "17.3.7-ts.1",
@@ -1,35 +1,27 @@
1
1
  import { React, Slot } from "@lattice-ui/core";
2
- import { RovingFocusGroup } from "@lattice-ui/focus";
2
+ import { FocusScope } from "@lattice-ui/focus";
3
3
  import { DismissableLayer, Presence } from "@lattice-ui/layer";
4
4
  import { usePopper } from "@lattice-ui/popper";
5
5
  import { useSelectContext } from "./context";
6
- import type { SelectContentProps, SelectItemRegistration } from "./types";
6
+ import type { SelectContentProps } from "./types";
7
7
 
8
- const GuiService = game.GetService("GuiService");
9
- const UserInputService = game.GetService("UserInputService");
8
+ const TweenService = game.GetService("TweenService");
10
9
 
11
- const digitKeyMap: Record<string, string> = {
12
- Zero: "0",
13
- One: "1",
14
- Two: "2",
15
- Three: "3",
16
- Four: "4",
17
- Five: "5",
18
- Six: "6",
19
- Seven: "7",
20
- Eight: "8",
21
- Nine: "9",
22
- };
10
+ const OPEN_TWEEN_INFO = new TweenInfo(0.12, Enum.EasingStyle.Quad, Enum.EasingDirection.Out);
11
+ const CLOSE_TWEEN_INFO = new TweenInfo(0.09, Enum.EasingStyle.Quad, Enum.EasingDirection.In);
12
+ const CONTENT_OPEN_Y_OFFSET = 6;
23
13
 
24
14
  type SelectContentImplProps = {
25
15
  enabled: boolean;
26
16
  visible: boolean;
17
+ exiting: boolean;
27
18
  onDismiss: () => void;
19
+ onExitComplete?: () => void;
28
20
  asChild?: boolean;
29
21
  placement?: SelectContentProps["placement"];
30
22
  offset?: SelectContentProps["offset"];
31
23
  padding?: SelectContentProps["padding"];
32
- } & Pick<SelectContentProps, "children" | "onEscapeKeyDown" | "onInteractOutside" | "onPointerDownOutside">;
24
+ } & Pick<SelectContentProps, "children" | "onInteractOutside" | "onPointerDownOutside">;
33
25
 
34
26
  function toGuiObject(instance: Instance | undefined) {
35
27
  if (!instance || !instance.IsA("GuiObject")) {
@@ -39,79 +31,8 @@ function toGuiObject(instance: Instance | undefined) {
39
31
  return instance;
40
32
  }
41
33
 
42
- function toSearchCharacter(keyCode: Enum.KeyCode) {
43
- if (keyCode === Enum.KeyCode.Space) {
44
- return " ";
45
- }
46
-
47
- const digitCharacter = digitKeyMap[keyCode.Name];
48
- if (digitCharacter !== undefined) {
49
- return digitCharacter;
50
- }
51
-
52
- if (keyCode.Name.size() === 1) {
53
- return string.lower(keyCode.Name);
54
- }
55
-
56
- return undefined;
57
- }
58
-
59
- function startsWithIgnoreCase(value: string, query: string) {
60
- if (query.size() === 0) {
61
- return false;
62
- }
63
-
64
- const normalizedValue = string.lower(value);
65
- return string.sub(normalizedValue, 1, query.size()) === query;
66
- }
67
-
68
- function findCurrentIndex(items: Array<SelectItemRegistration>, selectedObject: GuiObject | undefined) {
69
- if (!selectedObject) {
70
- return -1;
71
- }
72
-
73
- return items.findIndex((item) => {
74
- const node = item.getNode();
75
- if (!node) {
76
- return false;
77
- }
78
-
79
- return selectedObject === node || selectedObject.IsDescendantOf(node);
80
- });
81
- }
82
-
83
- function findTypeaheadMatch(items: Array<SelectItemRegistration>, query: string, startIndex: number) {
84
- const itemCount = items.size();
85
- if (itemCount === 0) {
86
- return -1;
87
- }
88
-
89
- for (let attempts = 0; attempts < itemCount; attempts++) {
90
- const candidateIndex = (startIndex + attempts) % itemCount;
91
- const candidate = items[candidateIndex];
92
- if (!candidate || candidate.getDisabled()) {
93
- continue;
94
- }
95
-
96
- if (startsWithIgnoreCase(candidate.getTextValue(), query)) {
97
- return candidateIndex;
98
- }
99
- }
100
-
101
- return -1;
102
- }
103
-
104
- function focusItem(item: SelectItemRegistration | undefined) {
105
- if (!item) {
106
- return;
107
- }
108
-
109
- const node = item.getNode();
110
- if (!node || !node.Selectable) {
111
- return;
112
- }
113
-
114
- GuiService.SelectedObject = node;
34
+ function withVerticalOffset(position: UDim2, offset: number) {
35
+ return new UDim2(position.X.Scale, position.X.Offset, position.Y.Scale, position.Y.Offset + offset);
115
36
  }
116
37
 
117
38
  function SelectContentImpl(props: SelectContentImplProps) {
@@ -133,69 +54,78 @@ function SelectContentImpl(props: SelectContentImplProps) {
133
54
  [selectContext.contentRef],
134
55
  );
135
56
 
136
- const searchRef = React.useRef("");
137
- const searchTimestampRef = React.useRef(0);
57
+ const positionTweenRef = React.useRef<Tween>();
58
+ const tweenCompletedConnectionRef = React.useRef<RBXScriptConnection>();
59
+ const previousVisibleRef = React.useRef(props.visible);
60
+ const previousExitingRef = React.useRef(props.exiting);
138
61
 
139
- React.useEffect(() => {
140
- if (!props.enabled) {
141
- return;
62
+ const clearTween = React.useCallback(() => {
63
+ const tween = positionTweenRef.current;
64
+ if (tween) {
65
+ tween.Cancel();
66
+ positionTweenRef.current = undefined;
142
67
  }
143
68
 
144
- const orderedItems = selectContext.getOrderedItems();
145
- const selectedItem = orderedItems.find((item) => item.value === selectContext.value && !item.getDisabled());
146
- focusItem(selectedItem);
147
- }, [props.enabled, selectContext, selectContext.value]);
69
+ const completedConnection = tweenCompletedConnectionRef.current;
70
+ if (completedConnection) {
71
+ completedConnection.Disconnect();
72
+ tweenCompletedConnectionRef.current = undefined;
73
+ }
74
+ }, []);
148
75
 
149
76
  React.useEffect(() => {
150
- if (!props.enabled) {
77
+ return () => {
78
+ clearTween();
79
+ };
80
+ }, [clearTween]);
81
+
82
+ React.useEffect(() => {
83
+ const contentNode = selectContext.contentRef.current;
84
+ if (!contentNode) {
151
85
  return;
152
86
  }
153
87
 
154
- searchRef.current = "";
155
- searchTimestampRef.current = 0;
88
+ const wasVisible = previousVisibleRef.current;
89
+ const wasExiting = previousExitingRef.current;
90
+ previousVisibleRef.current = props.visible;
91
+ previousExitingRef.current = props.exiting;
156
92
 
157
- const connection = UserInputService.InputBegan.Connect((inputObject, gameProcessedEvent) => {
158
- if (gameProcessedEvent) {
93
+ if (props.exiting) {
94
+ if (wasExiting) {
159
95
  return;
160
96
  }
161
97
 
162
- if (inputObject.UserInputType !== Enum.UserInputType.Keyboard) {
163
- return;
164
- }
98
+ clearTween();
165
99
 
166
- const contentNode = selectContext.contentRef.current;
167
- const selectedObject = GuiService.SelectedObject;
168
- if (!contentNode || !selectedObject || !selectedObject.IsDescendantOf(contentNode)) {
169
- return;
170
- }
100
+ const tween = TweenService.Create(contentNode, CLOSE_TWEEN_INFO, {
101
+ Position: withVerticalOffset(popper.position, CONTENT_OPEN_Y_OFFSET),
102
+ });
171
103
 
172
- const searchCharacter = toSearchCharacter(inputObject.KeyCode);
173
- if (searchCharacter === undefined) {
174
- return;
175
- }
104
+ positionTweenRef.current = tween;
105
+ tweenCompletedConnectionRef.current = tween.Completed.Connect((playbackState) => {
106
+ if (playbackState === Enum.PlaybackState.Completed) {
107
+ props.onExitComplete?.();
108
+ }
109
+ });
176
110
 
177
- const now = os.clock();
178
- const shouldResetQuery = now - searchTimestampRef.current > 0.8;
179
- const nextQuery = shouldResetQuery ? searchCharacter : `${searchRef.current}${searchCharacter}`;
180
- searchRef.current = nextQuery;
181
- searchTimestampRef.current = now;
111
+ tween.Play();
112
+ return;
113
+ }
182
114
 
183
- const orderedItems = selectContext.getOrderedItems();
184
- const currentIndex = findCurrentIndex(orderedItems, selectedObject);
185
- const startIndex = currentIndex >= 0 ? currentIndex + 1 : 0;
186
- const matchIndex = findTypeaheadMatch(orderedItems, string.lower(nextQuery), startIndex);
187
- if (matchIndex < 0) {
188
- return;
189
- }
115
+ clearTween();
190
116
 
191
- const matchedItem = orderedItems[matchIndex];
192
- focusItem(matchedItem);
193
- });
117
+ if (props.visible && !wasVisible) {
118
+ contentNode.Position = withVerticalOffset(popper.position, CONTENT_OPEN_Y_OFFSET);
119
+ const tween = TweenService.Create(contentNode, OPEN_TWEEN_INFO, {
120
+ Position: popper.position,
121
+ });
122
+ positionTweenRef.current = tween;
123
+ tween.Play();
124
+ return;
125
+ }
194
126
 
195
- return () => {
196
- connection.Disconnect();
197
- };
198
- }, [props.enabled, selectContext]);
127
+ contentNode.Position = popper.position;
128
+ }, [clearTween, popper.position, props.exiting, props.onExitComplete, props.visible, selectContext.contentRef]);
199
129
 
200
130
  const contentNode = props.asChild ? (
201
131
  (() => {
@@ -205,7 +135,12 @@ function SelectContentImpl(props: SelectContentImplProps) {
205
135
  }
206
136
 
207
137
  return (
208
- <Slot AnchorPoint={popper.anchorPoint} Position={popper.position} Visible={props.visible} ref={setContentRef}>
138
+ <Slot
139
+ AnchorPoint={popper.anchorPoint}
140
+ Position={popper.position}
141
+ Visible={props.visible || props.exiting}
142
+ ref={setContentRef}
143
+ >
209
144
  {child}
210
145
  </Slot>
211
146
  );
@@ -217,7 +152,7 @@ function SelectContentImpl(props: SelectContentImplProps) {
217
152
  BorderSizePixel={0}
218
153
  Position={popper.position}
219
154
  Size={UDim2.fromOffset(0, 0)}
220
- Visible={props.visible}
155
+ Visible={props.visible || props.exiting}
221
156
  ref={setContentRef}
222
157
  >
223
158
  {props.children}
@@ -229,13 +164,12 @@ function SelectContentImpl(props: SelectContentImplProps) {
229
164
  enabled={props.enabled}
230
165
  modal={false}
231
166
  onDismiss={props.onDismiss}
232
- onEscapeKeyDown={props.onEscapeKeyDown}
233
167
  onInteractOutside={props.onInteractOutside}
234
168
  onPointerDownOutside={props.onPointerDownOutside}
235
169
  >
236
- <RovingFocusGroup active={props.enabled} autoFocus="first" loop={selectContext.loop} orientation="vertical">
170
+ <FocusScope active={props.enabled} restoreFocus={true} trapped={false}>
237
171
  {contentNode}
238
- </RovingFocusGroup>
172
+ </FocusScope>
239
173
  </DismissableLayer>
240
174
  );
241
175
  }
@@ -258,9 +192,9 @@ export function SelectContent(props: SelectContentProps) {
258
192
  <SelectContentImpl
259
193
  asChild={props.asChild}
260
194
  enabled={open}
195
+ exiting={false}
261
196
  offset={props.offset}
262
197
  onDismiss={handleDismiss}
263
- onEscapeKeyDown={props.onEscapeKeyDown}
264
198
  onInteractOutside={props.onInteractOutside}
265
199
  onPointerDownOutside={props.onPointerDownOutside}
266
200
  padding={props.padding}
@@ -274,15 +208,16 @@ export function SelectContent(props: SelectContentProps) {
274
208
 
275
209
  return (
276
210
  <Presence
277
- exitFallbackMs={0}
211
+ exitFallbackMs={180}
278
212
  present={open}
279
213
  render={(state) => (
280
214
  <SelectContentImpl
281
215
  asChild={props.asChild}
282
216
  enabled={state.isPresent}
217
+ exiting={!state.isPresent}
283
218
  offset={props.offset}
284
219
  onDismiss={handleDismiss}
285
- onEscapeKeyDown={props.onEscapeKeyDown}
220
+ onExitComplete={state.onExitComplete}
286
221
  onInteractOutside={props.onInteractOutside}
287
222
  onPointerDownOutside={props.onPointerDownOutside}
288
223
  padding={props.padding}
@@ -1,23 +1,12 @@
1
1
  import { React, Slot } from "@lattice-ui/core";
2
- import { RovingFocusItem } from "@lattice-ui/focus";
3
2
  import { useSelectContext } from "./context";
4
3
  import type { SelectItemProps } from "./types";
5
4
 
6
5
  let nextItemId = 0;
7
6
  let nextItemOrder = 0;
8
7
 
9
- function toGuiObject(instance: Instance | undefined) {
10
- if (!instance || !instance.IsA("GuiObject")) {
11
- return undefined;
12
- }
13
-
14
- return instance;
15
- }
16
-
17
8
  export function SelectItem(props: SelectItemProps) {
18
9
  const selectContext = useSelectContext();
19
- const itemRef = React.useRef<GuiObject>();
20
-
21
10
  const disabled = selectContext.disabled || props.disabled === true;
22
11
  const textValue = props.textValue ?? props.value;
23
12
 
@@ -49,16 +38,11 @@ export function SelectItem(props: SelectItemProps) {
49
38
  id: itemIdRef.current,
50
39
  value: props.value,
51
40
  order: itemOrderRef.current,
52
- getNode: () => itemRef.current,
53
41
  getDisabled: () => disabledRef.current,
54
42
  getTextValue: () => textValueRef.current,
55
43
  });
56
44
  }, [props.value, selectContext]);
57
45
 
58
- const setItemRef = React.useCallback((instance: Instance | undefined) => {
59
- itemRef.current = toGuiObject(instance);
60
- }, []);
61
-
62
46
  const handleSelect = React.useCallback(() => {
63
47
  if (disabled) {
64
48
  return;
@@ -100,33 +84,28 @@ export function SelectItem(props: SelectItemProps) {
100
84
  }
101
85
 
102
86
  return (
103
- <RovingFocusItem asChild disabled={disabled}>
104
- <Slot Active={!disabled} Event={eventHandlers} Selectable={!disabled} ref={setItemRef}>
105
- {child}
106
- </Slot>
107
- </RovingFocusItem>
87
+ <Slot Active={!disabled} Event={eventHandlers} Selectable={false}>
88
+ {child}
89
+ </Slot>
108
90
  );
109
91
  }
110
92
 
111
93
  return (
112
- <RovingFocusItem asChild disabled={disabled}>
113
- <textbutton
114
- Active={!disabled}
115
- AutoButtonColor={false}
116
- BackgroundColor3={Color3.fromRGB(47, 53, 68)}
117
- BorderSizePixel={0}
118
- Event={eventHandlers}
119
- Selectable={!disabled}
120
- Size={UDim2.fromOffset(220, 32)}
121
- Text={textValue}
122
- TextColor3={disabled ? Color3.fromRGB(134, 141, 156) : Color3.fromRGB(234, 239, 247)}
123
- TextSize={15}
124
- TextXAlignment={Enum.TextXAlignment.Left}
125
- ref={setItemRef}
126
- >
127
- <uipadding PaddingLeft={new UDim(0, 10)} PaddingRight={new UDim(0, 10)} />
128
- {props.children}
129
- </textbutton>
130
- </RovingFocusItem>
94
+ <textbutton
95
+ Active={!disabled}
96
+ AutoButtonColor={false}
97
+ BackgroundColor3={Color3.fromRGB(47, 53, 68)}
98
+ BorderSizePixel={0}
99
+ Event={eventHandlers}
100
+ Selectable={false}
101
+ Size={UDim2.fromOffset(220, 32)}
102
+ Text={textValue}
103
+ TextColor3={disabled ? Color3.fromRGB(134, 141, 156) : Color3.fromRGB(234, 239, 247)}
104
+ TextSize={15}
105
+ TextXAlignment={Enum.TextXAlignment.Left}
106
+ >
107
+ <uipadding PaddingLeft={new UDim(0, 10)} PaddingRight={new UDim(0, 10)} />
108
+ {props.children}
109
+ </textbutton>
131
110
  );
132
111
  }
@@ -27,7 +27,6 @@ export function SelectRoot(props: SelectProps) {
27
27
 
28
28
  const disabled = props.disabled === true;
29
29
  const required = props.required === true;
30
- const loop = props.loop ?? true;
31
30
 
32
31
  const triggerRef = React.useRef<GuiObject>();
33
32
  const contentRef = React.useRef<GuiObject>();
@@ -110,14 +109,12 @@ export function SelectRoot(props: SelectProps) {
110
109
  setValue,
111
110
  disabled,
112
111
  required,
113
- loop,
114
112
  triggerRef,
115
113
  contentRef,
116
114
  registerItem,
117
- getOrderedItems: resolveOrderedItems,
118
115
  getItemText,
119
116
  }),
120
- [disabled, getItemText, loop, open, registerItem, required, resolveOrderedItems, setOpen, setValue, value],
117
+ [disabled, getItemText, open, registerItem, required, setOpen, setValue, value],
121
118
  );
122
119
 
123
120
  return <SelectContextProvider value={contextValue}>{props.children}</SelectContextProvider>;
@@ -13,12 +13,13 @@ function toGuiObject(instance: Instance | undefined) {
13
13
  export function SelectTrigger(props: SelectTriggerProps) {
14
14
  const selectContext = useSelectContext();
15
15
  const disabled = selectContext.disabled || props.disabled === true;
16
+ const triggerRef = selectContext.triggerRef;
16
17
 
17
18
  const setTriggerRef = React.useCallback(
18
19
  (instance: Instance | undefined) => {
19
- selectContext.triggerRef.current = toGuiObject(instance);
20
+ triggerRef.current = toGuiObject(instance);
20
21
  },
21
- [selectContext.triggerRef],
22
+ [triggerRef],
22
23
  );
23
24
 
24
25
  const handleActivated = React.useCallback(() => {
@@ -38,11 +39,6 @@ export function SelectTrigger(props: SelectTriggerProps) {
38
39
  const keyCode = inputObject.KeyCode;
39
40
  if (keyCode === Enum.KeyCode.Return || keyCode === Enum.KeyCode.Space) {
40
41
  selectContext.setOpen(!selectContext.open);
41
- return;
42
- }
43
-
44
- if (keyCode === Enum.KeyCode.Down || keyCode === Enum.KeyCode.Up) {
45
- selectContext.setOpen(true);
46
42
  }
47
43
  },
48
44
  [disabled, selectContext],
@@ -63,7 +59,7 @@ export function SelectTrigger(props: SelectTriggerProps) {
63
59
  }
64
60
 
65
61
  return (
66
- <Slot Active={!disabled} Event={eventHandlers} Selectable={!disabled} ref={setTriggerRef}>
62
+ <Slot Active={!disabled} Event={eventHandlers} Selectable={false} ref={setTriggerRef}>
67
63
  {child}
68
64
  </Slot>
69
65
  );
@@ -76,7 +72,7 @@ export function SelectTrigger(props: SelectTriggerProps) {
76
72
  BackgroundColor3={Color3.fromRGB(41, 48, 63)}
77
73
  BorderSizePixel={0}
78
74
  Event={eventHandlers}
79
- Selectable={!disabled}
75
+ Selectable={false}
80
76
  Size={UDim2.fromOffset(220, 36)}
81
77
  Text="Select"
82
78
  TextColor3={disabled ? Color3.fromRGB(140, 148, 164) : Color3.fromRGB(235, 241, 248)}
@@ -9,7 +9,6 @@ export type SelectItemRegistration = {
9
9
  id: number;
10
10
  value: string;
11
11
  order: number;
12
- getNode: () => GuiObject | undefined;
13
12
  getDisabled: () => boolean;
14
13
  getTextValue: () => string;
15
14
  };
@@ -21,11 +20,9 @@ export type SelectContextValue = {
21
20
  setValue: SelectSetValue;
22
21
  disabled: boolean;
23
22
  required: boolean;
24
- loop: boolean;
25
23
  triggerRef: React.MutableRefObject<GuiObject | undefined>;
26
24
  contentRef: React.MutableRefObject<GuiObject | undefined>;
27
25
  registerItem: (item: SelectItemRegistration) => () => void;
28
- getOrderedItems: () => Array<SelectItemRegistration>;
29
26
  getItemText: (value: string) => string | undefined;
30
27
  };
31
28
 
@@ -38,7 +35,6 @@ export type SelectProps = {
38
35
  onOpenChange?: (open: boolean) => void;
39
36
  disabled?: boolean;
40
37
  required?: boolean;
41
- loop?: boolean;
42
38
  children?: React.ReactNode;
43
39
  };
44
40
 
@@ -66,7 +62,6 @@ export type SelectContentProps = {
66
62
  placement?: PopperPlacement;
67
63
  offset?: Vector2;
68
64
  padding?: number;
69
- onEscapeKeyDown?: (event: LayerInteractEvent) => void;
70
65
  onPointerDownOutside?: (event: LayerInteractEvent) => void;
71
66
  onInteractOutside?: (event: LayerInteractEvent) => void;
72
67
  children?: React.ReactNode;
package/src/index.ts CHANGED
@@ -30,18 +30,6 @@ export const Select = {
30
30
  Separator: typeof SelectSeparator;
31
31
  };
32
32
 
33
- export {
34
- SelectContent,
35
- SelectGroup,
36
- SelectItem,
37
- SelectLabel,
38
- SelectPortal,
39
- SelectRoot,
40
- SelectSeparator,
41
- SelectTrigger,
42
- SelectValue,
43
- };
44
-
45
33
  export type {
46
34
  SelectContentProps,
47
35
  SelectContextValue,
@@ -57,3 +45,14 @@ export type {
57
45
  SelectTriggerProps,
58
46
  SelectValueProps,
59
47
  } from "./Select/types";
48
+ export {
49
+ SelectContent,
50
+ SelectGroup,
51
+ SelectItem,
52
+ SelectLabel,
53
+ SelectPortal,
54
+ SelectRoot,
55
+ SelectSeparator,
56
+ SelectTrigger,
57
+ SelectValue,
58
+ };