@lattice-ui/select 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.
Files changed (40) hide show
  1. package/README.md +23 -0
  2. package/out/Select/SelectContent.d.ts +3 -0
  3. package/out/Select/SelectContent.luau +268 -0
  4. package/out/Select/SelectGroup.d.ts +3 -0
  5. package/out/Select/SelectGroup.luau +22 -0
  6. package/out/Select/SelectItem.d.ts +3 -0
  7. package/out/Select/SelectItem.luau +124 -0
  8. package/out/Select/SelectLabel.d.ts +3 -0
  9. package/out/Select/SelectLabel.luau +26 -0
  10. package/out/Select/SelectPortal.d.ts +3 -0
  11. package/out/Select/SelectPortal.luau +33 -0
  12. package/out/Select/SelectRoot.d.ts +3 -0
  13. package/out/Select/SelectRoot.luau +201 -0
  14. package/out/Select/SelectSeparator.d.ts +3 -0
  15. package/out/Select/SelectSeparator.luau +22 -0
  16. package/out/Select/SelectTrigger.d.ts +3 -0
  17. package/out/Select/SelectTrigger.luau +72 -0
  18. package/out/Select/SelectValue.d.ts +3 -0
  19. package/out/Select/SelectValue.luau +47 -0
  20. package/out/Select/context.d.ts +3 -0
  21. package/out/Select/context.luau +10 -0
  22. package/out/Select/types.d.ts +84 -0
  23. package/out/Select/types.luau +2 -0
  24. package/out/index.d.ts +22 -0
  25. package/out/init.luau +34 -0
  26. package/package.json +26 -0
  27. package/src/Select/SelectContent.tsx +297 -0
  28. package/src/Select/SelectGroup.tsx +19 -0
  29. package/src/Select/SelectItem.tsx +132 -0
  30. package/src/Select/SelectLabel.tsx +27 -0
  31. package/src/Select/SelectPortal.tsx +28 -0
  32. package/src/Select/SelectRoot.tsx +124 -0
  33. package/src/Select/SelectSeparator.tsx +19 -0
  34. package/src/Select/SelectTrigger.tsx +89 -0
  35. package/src/Select/SelectValue.tsx +42 -0
  36. package/src/Select/context.ts +6 -0
  37. package/src/Select/types.ts +96 -0
  38. package/src/index.ts +59 -0
  39. package/tsconfig.json +16 -0
  40. package/tsconfig.typecheck.json +35 -0
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @lattice-ui/select
2
+
3
+ Headless single-select primitives built for Roblox UI.
4
+
5
+ ## Exports
6
+
7
+ - `Select`
8
+ - `Select.Root`
9
+ - `Select.Trigger`
10
+ - `Select.Value`
11
+ - `Select.Portal`
12
+ - `Select.Content`
13
+ - `Select.Item`
14
+ - `Select.Group`
15
+ - `Select.Label`
16
+ - `Select.Separator`
17
+
18
+ ## Notes
19
+
20
+ - Single value only in this release.
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.
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { SelectContentProps } from "./types";
3
+ export declare function SelectContent(props: SelectContentProps): React.JSX.Element | undefined;
@@ -0,0 +1,268 @@
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 RovingFocusGroup = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out).RovingFocusGroup
7
+ local _layer = TS.import(script, TS.getModule(script, "@lattice-ui", "layer").out)
8
+ local DismissableLayer = _layer.DismissableLayer
9
+ local Presence = _layer.Presence
10
+ local usePopper = TS.import(script, TS.getModule(script, "@lattice-ui", "popper").out).usePopper
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
+ }
26
+ local function toGuiObject(instance)
27
+ if not instance or not instance:IsA("GuiObject") then
28
+ return nil
29
+ end
30
+ return instance
31
+ 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
112
+ end
113
+ local function SelectContentImpl(props)
114
+ local selectContext = useSelectContext()
115
+ local popper = usePopper({
116
+ anchorRef = selectContext.triggerRef,
117
+ contentRef = selectContext.contentRef,
118
+ placement = props.placement,
119
+ offset = props.offset,
120
+ padding = props.padding,
121
+ enabled = props.enabled,
122
+ })
123
+ local setContentRef = React.useCallback(function(instance)
124
+ selectContext.contentRef.current = toGuiObject(instance)
125
+ 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
131
+ end
132
+ local orderedItems = selectContext.getOrderedItems()
133
+ -- ▼ ReadonlyArray.find ▼
134
+ local _callback = function(item)
135
+ return item.value == selectContext.value and not item.getDisabled()
136
+ 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
143
+ end
144
+ -- ▲ ReadonlyArray.find ▲
145
+ local selectedItem = _result
146
+ focusItem(selectedItem)
147
+ end, { props.enabled, selectContext, selectContext.value })
148
+ React.useEffect(function()
149
+ if not props.enabled then
150
+ return nil
151
+ 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
159
+ return nil
160
+ 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()
187
+ end
188
+ end, { props.enabled, selectContext })
189
+ local contentNode = if props.asChild then ((function()
190
+ local child = props.children
191
+ if not React.isValidElement(child) then
192
+ error("[SelectContent] `asChild` requires a child element.")
193
+ end
194
+ return React.createElement(Slot, {
195
+ AnchorPoint = popper.anchorPoint,
196
+ Position = popper.position,
197
+ Visible = props.visible,
198
+ ref = setContentRef,
199
+ }, child)
200
+ end)()) else (React.createElement("frame", {
201
+ AnchorPoint = popper.anchorPoint,
202
+ BackgroundTransparency = 1,
203
+ BorderSizePixel = 0,
204
+ Position = popper.position,
205
+ Size = UDim2.fromOffset(0, 0),
206
+ Visible = props.visible,
207
+ ref = setContentRef,
208
+ }, props.children))
209
+ return React.createElement(DismissableLayer, {
210
+ enabled = props.enabled,
211
+ modal = false,
212
+ onDismiss = props.onDismiss,
213
+ onEscapeKeyDown = props.onEscapeKeyDown,
214
+ onInteractOutside = props.onInteractOutside,
215
+ onPointerDownOutside = props.onPointerDownOutside,
216
+ }, React.createElement(RovingFocusGroup, {
217
+ active = props.enabled,
218
+ autoFocus = "first",
219
+ loop = selectContext.loop,
220
+ orientation = "vertical",
221
+ }, contentNode))
222
+ end
223
+ local function SelectContent(props)
224
+ local selectContext = useSelectContext()
225
+ local open = selectContext.open
226
+ local forceMount = props.forceMount == true
227
+ local handleDismiss = React.useCallback(function()
228
+ selectContext.setOpen(false)
229
+ end, { selectContext })
230
+ if not open and not forceMount then
231
+ return nil
232
+ end
233
+ if forceMount then
234
+ return React.createElement(SelectContentImpl, {
235
+ asChild = props.asChild,
236
+ enabled = open,
237
+ offset = props.offset,
238
+ onDismiss = handleDismiss,
239
+ onEscapeKeyDown = props.onEscapeKeyDown,
240
+ onInteractOutside = props.onInteractOutside,
241
+ onPointerDownOutside = props.onPointerDownOutside,
242
+ padding = props.padding,
243
+ placement = props.placement,
244
+ visible = open,
245
+ }, props.children)
246
+ end
247
+ return React.createElement(Presence, {
248
+ exitFallbackMs = 0,
249
+ present = open,
250
+ render = function(state)
251
+ return React.createElement(SelectContentImpl, {
252
+ asChild = props.asChild,
253
+ enabled = state.isPresent,
254
+ offset = props.offset,
255
+ onDismiss = handleDismiss,
256
+ onEscapeKeyDown = props.onEscapeKeyDown,
257
+ onInteractOutside = props.onInteractOutside,
258
+ onPointerDownOutside = props.onPointerDownOutside,
259
+ padding = props.padding,
260
+ placement = props.placement,
261
+ visible = state.isPresent,
262
+ }, props.children)
263
+ end,
264
+ })
265
+ end
266
+ return {
267
+ SelectContent = SelectContent,
268
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { SelectGroupProps } from "./types";
3
+ export declare function SelectGroup(props: SelectGroupProps): React.JSX.Element;
@@ -0,0 +1,22 @@
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 function SelectGroup(props)
7
+ if props.asChild then
8
+ local child = props.children
9
+ if not child then
10
+ error("[SelectGroup] `asChild` requires a child element.")
11
+ end
12
+ return React.createElement(Slot, nil, child)
13
+ end
14
+ return React.createElement("frame", {
15
+ BackgroundTransparency = 1,
16
+ BorderSizePixel = 0,
17
+ Size = UDim2.fromOffset(220, 108),
18
+ }, props.children)
19
+ end
20
+ return {
21
+ SelectGroup = SelectGroup,
22
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { SelectItemProps } from "./types";
3
+ export declare function SelectItem(props: SelectItemProps): React.JSX.Element;
@@ -0,0 +1,124 @@
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 RovingFocusItem = TS.import(script, TS.getModule(script, "@lattice-ui", "focus").out).RovingFocusItem
7
+ local useSelectContext = TS.import(script, script.Parent, "context").useSelectContext
8
+ local nextItemId = 0
9
+ 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
+ local function SelectItem(props)
17
+ local selectContext = useSelectContext()
18
+ local itemRef = React.useRef()
19
+ local disabled = selectContext.disabled or props.disabled == true
20
+ local _condition = props.textValue
21
+ if _condition == nil then
22
+ _condition = props.value
23
+ end
24
+ local textValue = _condition
25
+ local disabledRef = React.useRef(disabled)
26
+ local textValueRef = React.useRef(textValue)
27
+ React.useEffect(function()
28
+ disabledRef.current = disabled
29
+ end, { disabled })
30
+ React.useEffect(function()
31
+ textValueRef.current = textValue
32
+ end, { textValue })
33
+ local itemIdRef = React.useRef(0)
34
+ if itemIdRef.current == 0 then
35
+ nextItemId += 1
36
+ itemIdRef.current = nextItemId
37
+ end
38
+ local itemOrderRef = React.useRef(0)
39
+ if itemOrderRef.current == 0 then
40
+ nextItemOrder += 1
41
+ itemOrderRef.current = nextItemOrder
42
+ end
43
+ React.useEffect(function()
44
+ return selectContext.registerItem({
45
+ id = itemIdRef.current,
46
+ value = props.value,
47
+ order = itemOrderRef.current,
48
+ getNode = function()
49
+ return itemRef.current
50
+ end,
51
+ getDisabled = function()
52
+ return disabledRef.current
53
+ end,
54
+ getTextValue = function()
55
+ return textValueRef.current
56
+ end,
57
+ })
58
+ end, { props.value, selectContext })
59
+ local setItemRef = React.useCallback(function(instance)
60
+ itemRef.current = toGuiObject(instance)
61
+ end, {})
62
+ local handleSelect = React.useCallback(function()
63
+ if disabled then
64
+ return nil
65
+ end
66
+ selectContext.setValue(props.value)
67
+ selectContext.setOpen(false)
68
+ end, { disabled, props.value, selectContext })
69
+ local handleInputBegan = React.useCallback(function(_rbx, inputObject)
70
+ if disabled then
71
+ return nil
72
+ end
73
+ local keyCode = inputObject.KeyCode
74
+ if keyCode ~= Enum.KeyCode.Return and keyCode ~= Enum.KeyCode.Space then
75
+ return nil
76
+ end
77
+ selectContext.setValue(props.value)
78
+ selectContext.setOpen(false)
79
+ end, { disabled, props.value, selectContext })
80
+ local eventHandlers = React.useMemo(function()
81
+ return {
82
+ Activated = handleSelect,
83
+ InputBegan = handleInputBegan,
84
+ }
85
+ end, { handleInputBegan, handleSelect })
86
+ if props.asChild then
87
+ local child = props.children
88
+ if not child then
89
+ error("[SelectItem] `asChild` requires a child element.")
90
+ end
91
+ return React.createElement(RovingFocusItem, {
92
+ asChild = true,
93
+ disabled = disabled,
94
+ }, React.createElement(Slot, {
95
+ Active = not disabled,
96
+ Event = eventHandlers,
97
+ Selectable = not disabled,
98
+ ref = setItemRef,
99
+ }, child))
100
+ end
101
+ return React.createElement(RovingFocusItem, {
102
+ asChild = true,
103
+ disabled = disabled,
104
+ }, React.createElement("textbutton", {
105
+ Active = not disabled,
106
+ AutoButtonColor = false,
107
+ BackgroundColor3 = Color3.fromRGB(47, 53, 68),
108
+ BorderSizePixel = 0,
109
+ Event = eventHandlers,
110
+ Selectable = not disabled,
111
+ Size = UDim2.fromOffset(220, 32),
112
+ Text = textValue,
113
+ TextColor3 = if disabled then Color3.fromRGB(134, 141, 156) else Color3.fromRGB(234, 239, 247),
114
+ TextSize = 15,
115
+ TextXAlignment = Enum.TextXAlignment.Left,
116
+ ref = setItemRef,
117
+ }, React.createElement("uipadding", {
118
+ PaddingLeft = UDim.new(0, 10),
119
+ PaddingRight = UDim.new(0, 10),
120
+ }), props.children))
121
+ end
122
+ return {
123
+ SelectItem = SelectItem,
124
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { SelectLabelProps } from "./types";
3
+ export declare function SelectLabel(props: SelectLabelProps): React.JSX.Element;
@@ -0,0 +1,26 @@
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 function SelectLabel(props)
7
+ if props.asChild then
8
+ local child = props.children
9
+ if not child then
10
+ error("[SelectLabel] `asChild` requires a child element.")
11
+ end
12
+ return React.createElement(Slot, nil, child)
13
+ end
14
+ return React.createElement("textlabel", {
15
+ BackgroundTransparency = 1,
16
+ BorderSizePixel = 0,
17
+ Size = UDim2.fromOffset(220, 20),
18
+ Text = "Label",
19
+ TextColor3 = Color3.fromRGB(168, 176, 191),
20
+ TextSize = 13,
21
+ TextXAlignment = Enum.TextXAlignment.Left,
22
+ }, props.children)
23
+ end
24
+ return {
25
+ SelectLabel = SelectLabel,
26
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { SelectPortalProps } from "./types";
3
+ export declare function SelectPortal(props: SelectPortalProps): React.JSX.Element;
@@ -0,0 +1,33 @@
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 _layer = TS.import(script, TS.getModule(script, "@lattice-ui", "layer").out)
5
+ local Portal = _layer.Portal
6
+ local PortalProvider = _layer.PortalProvider
7
+ local usePortalContext = _layer.usePortalContext
8
+ local function SelectPortalWithOverrides(props)
9
+ local portalContext = usePortalContext()
10
+ local container = props.container or portalContext.container
11
+ local _condition = props.displayOrderBase
12
+ if _condition == nil then
13
+ _condition = portalContext.displayOrderBase
14
+ end
15
+ local displayOrderBase = _condition
16
+ return React.createElement(PortalProvider, {
17
+ container = container,
18
+ displayOrderBase = displayOrderBase,
19
+ }, React.createElement(Portal, nil, props.children))
20
+ end
21
+ local function SelectPortal(props)
22
+ local hasOverrides = props.container ~= nil or props.displayOrderBase ~= nil
23
+ if hasOverrides then
24
+ return React.createElement(SelectPortalWithOverrides, {
25
+ container = props.container,
26
+ displayOrderBase = props.displayOrderBase,
27
+ }, props.children)
28
+ end
29
+ return React.createElement(Portal, nil, props.children)
30
+ end
31
+ return {
32
+ SelectPortal = SelectPortal,
33
+ }
@@ -0,0 +1,3 @@
1
+ import { React } from "@lattice-ui/core";
2
+ import type { SelectProps } from "./types";
3
+ export declare function SelectRoot(props: SelectProps): React.JSX.Element;