@lattice-ui/combobox 0.5.0-next.1 → 0.5.0-next.3

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.
@@ -1,19 +1,29 @@
1
1
  -- Compiled with roblox-ts v3.0.0
2
2
  local TS = _G[script]
3
3
  local _core = TS.import(script, TS.getModule(script, "@lattice-ui", "core").out)
4
+ local composeRefs = _core.composeRefs
5
+ local getElementRef = _core.getElementRef
4
6
  local React = _core.React
5
- local Slot = _core.Slot
6
7
  local _layer = TS.import(script, TS.getModule(script, "@lattice-ui", "layer").out)
7
8
  local DismissableLayer = _layer.DismissableLayer
8
9
  local Presence = _layer.Presence
9
10
  local _motion = TS.import(script, TS.getModule(script, "@lattice-ui", "motion").out)
10
- local createPopperEntranceRecipe = _motion.createPopperEntranceRecipe
11
- local usePresenceMotion = _motion.usePresenceMotion
11
+ local createCanvasGroupPopperEntranceRecipe = _motion.createCanvasGroupPopperEntranceRecipe
12
+ local usePresenceMotionController = _motion.usePresenceMotionController
12
13
  local usePopper = TS.import(script, TS.getModule(script, "@lattice-ui", "popper").out).usePopper
13
14
  local useComboboxContext = TS.import(script, script.Parent, "context").useComboboxContext
14
15
  local CONTENT_OFFSET = 6
16
+ local HIDDEN_POSITION = UDim2.fromOffset(-9999, -9999)
17
+ local function toGuiPropBag(value)
18
+ local _value = value
19
+ return if type(_value) == "table" then value else {}
20
+ end
15
21
  local function toGuiObject(instance)
16
- if not instance or not instance:IsA("GuiObject") then
22
+ local _result = instance
23
+ if _result ~= nil then
24
+ _result = _result:IsA("GuiObject")
25
+ end
26
+ if not _result then
17
27
  return nil
18
28
  end
19
29
  return instance
@@ -21,43 +31,69 @@ end
21
31
  local function ComboboxContentImpl(props)
22
32
  local comboboxContext = useComboboxContext()
23
33
  local open = comboboxContext.open
34
+ local shouldMeasure = open or props.motionPresent or props.onExitComplete ~= nil
35
+ local contentBoundaryRef = React.useRef()
24
36
  local popper = usePopper({
25
37
  anchorRef = comboboxContext.anchorRef,
26
38
  contentRef = comboboxContext.contentRef,
39
+ alignOffset = props.alignOffset,
40
+ collisionPadding = props.collisionPadding,
41
+ sideOffset = props.sideOffset,
27
42
  placement = props.placement,
28
- offset = props.offset,
29
- padding = props.padding,
30
- enabled = open,
43
+ enabled = shouldMeasure,
31
44
  })
32
45
  local defaultTransition = React.useMemo(function()
33
- return createPopperEntranceRecipe(popper.placement, CONTENT_OFFSET)
46
+ return createCanvasGroupPopperEntranceRecipe(popper.placement, CONTENT_OFFSET)
34
47
  end, { popper.placement })
35
- local motionRef = usePresenceMotion(props.motionPresent and popper.isPositioned, props.transition or defaultTransition, props.onExitComplete)
48
+ local recipe = props.transition or defaultTransition
49
+ local motion = usePresenceMotionController({
50
+ present = props.motionPresent,
51
+ ready = popper.isPositioned,
52
+ forceMount = props.forceMount,
53
+ config = recipe,
54
+ onExitComplete = props.onExitComplete,
55
+ })
36
56
  local setContentRef = React.useCallback(function(instance)
37
- comboboxContext.contentRef.current = toGuiObject(instance)
38
- motionRef.current = toGuiObject(instance)
39
- end, { comboboxContext.contentRef, motionRef })
57
+ local guiObject = toGuiObject(instance)
58
+ comboboxContext.contentRef.current = guiObject
59
+ contentBoundaryRef.current = guiObject
60
+ motion.ref.current = guiObject
61
+ end, { comboboxContext.contentRef, motion.ref })
40
62
  local handleDismiss = React.useCallback(function()
41
63
  comboboxContext.setOpen(false)
42
64
  end, { comboboxContext })
43
- local isActuallyVisible = open or (props.motionPresent and popper.isPositioned)
65
+ local shouldRender = motion.mounted
66
+ local contentVisible = shouldRender and (motion.present or motion.phase ~= "exited")
67
+ local popperPosition = if popper.isPositioned then popper.position else HIDDEN_POSITION
68
+ local popperContentSize = popper.contentSize or Vector2.new(0, 0)
69
+ local popperWrapperSize = if popper.isPositioned then UDim2.fromOffset(popperContentSize.X, popperContentSize.Y) else UDim2.fromOffset(0, 0)
44
70
  local contentNode = if props.asChild then ((function()
45
71
  local child = props.children
46
72
  if not React.isValidElement(child) then
47
73
  error("[ComboboxContent] `asChild` requires a child element.")
48
74
  end
49
- return React.createElement(Slot, {
50
- AnchorPoint = popper.anchorPoint,
51
- Visible = isActuallyVisible,
75
+ local childProps = toGuiPropBag(child.props)
76
+ local childRef = getElementRef(child)
77
+ local _exp = child
78
+ local _object = table.clone(childProps)
79
+ setmetatable(_object, nil)
80
+ _object.Position = UDim2.fromOffset(0, 0)
81
+ _object.Visible = contentVisible
82
+ _object.ref = composeRefs(childRef)
83
+ return React.createElement("canvasgroup", {
84
+ AutomaticSize = Enum.AutomaticSize.XY,
85
+ BackgroundTransparency = 1,
86
+ BorderSizePixel = 0,
87
+ Size = UDim2.fromOffset(0, 0),
88
+ Visible = contentVisible,
52
89
  ref = setContentRef,
53
- }, child)
54
- end)()) else (React.createElement("frame", {
55
- AnchorPoint = popper.anchorPoint,
90
+ }, React.cloneElement(_exp, _object))
91
+ end)()) else (React.createElement("canvasgroup", {
56
92
  AutomaticSize = Enum.AutomaticSize.XY,
57
93
  BackgroundTransparency = 1,
58
94
  BorderSizePixel = 0,
59
95
  Size = UDim2.fromOffset(0, 0),
60
- Visible = isActuallyVisible,
96
+ Visible = contentVisible,
61
97
  ref = setContentRef,
62
98
  }, props.children))
63
99
  return React.createElement(DismissableLayer, {
@@ -67,11 +103,14 @@ local function ComboboxContentImpl(props)
67
103
  onDismiss = handleDismiss,
68
104
  onInteractOutside = props.onInteractOutside,
69
105
  onPointerDownOutside = props.onPointerDownOutside,
106
+ contentBoundaryRef = contentBoundaryRef,
70
107
  }, React.createElement("frame", {
108
+ AnchorPoint = popper.anchorPoint,
71
109
  BackgroundTransparency = 1,
72
110
  BorderSizePixel = 0,
73
- Position = if popper.isPositioned then popper.position else UDim2.fromOffset(-9999, -9999),
74
- Size = UDim2.fromOffset(0, 0),
111
+ Position = popperPosition,
112
+ Size = popperWrapperSize,
113
+ Visible = shouldRender,
75
114
  }, contentNode))
76
115
  end
77
116
  local function ComboboxContent(props)
@@ -80,13 +119,14 @@ local function ComboboxContent(props)
80
119
  if props.forceMount then
81
120
  return React.createElement(ComboboxContentImpl, {
82
121
  asChild = props.asChild,
122
+ alignOffset = props.alignOffset,
123
+ collisionPadding = props.collisionPadding,
83
124
  forceMount = props.forceMount,
84
125
  motionPresent = open,
85
- offset = props.offset,
86
126
  onInteractOutside = props.onInteractOutside,
87
127
  onPointerDownOutside = props.onPointerDownOutside,
88
- padding = props.padding,
89
128
  placement = props.placement,
129
+ sideOffset = props.sideOffset,
90
130
  transition = props.transition,
91
131
  }, props.children)
92
132
  end
@@ -95,14 +135,15 @@ local function ComboboxContent(props)
95
135
  render = function(state)
96
136
  return React.createElement(ComboboxContentImpl, {
97
137
  asChild = props.asChild,
138
+ alignOffset = props.alignOffset,
139
+ collisionPadding = props.collisionPadding,
98
140
  forceMount = props.forceMount,
99
141
  motionPresent = state.isPresent,
100
- offset = props.offset,
101
142
  onExitComplete = state.onExitComplete,
102
143
  onInteractOutside = props.onInteractOutside,
103
144
  onPointerDownOutside = props.onPointerDownOutside,
104
- padding = props.padding,
105
145
  placement = props.placement,
146
+ sideOffset = props.sideOffset,
106
147
  transition = props.transition,
107
148
  }, props.children)
108
149
  end,
@@ -4,8 +4,13 @@ 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
6
  local useComboboxContext = TS.import(script, script.Parent, "context").useComboboxContext
7
+ local UserInputService = game:GetService("UserInputService")
7
8
  local function toTextBox(instance)
8
- if not instance or not instance:IsA("TextBox") then
9
+ local _result = instance
10
+ if _result ~= nil then
11
+ _result = _result:IsA("TextBox")
12
+ end
13
+ if not _result then
9
14
  return nil
10
15
  end
11
16
  return instance
@@ -32,6 +37,9 @@ local function ComboboxInput(props)
32
37
  if textBox.Text == lastInputValueRef.current then
33
38
  return nil
34
39
  end
40
+ if UserInputService:GetFocusedTextBox() ~= textBox then
41
+ return nil
42
+ end
35
43
  if disabled or readOnly then
36
44
  if textBox.Text ~= lastInputValueRef.current then
37
45
  textBox.Text = lastInputValueRef.current
@@ -12,14 +12,10 @@ local function ComboboxItem(props)
12
12
  if _condition == nil then
13
13
  _condition = props.value
14
14
  end
15
- local itemQueryMatch = comboboxContext.filterFn(_condition, comboboxContext.inputValue)
15
+ local textValue = _condition
16
+ local itemQueryMatch = comboboxContext.filterFn(textValue, comboboxContext.queryValue)
16
17
  local disabled = comboboxContext.disabled or props.disabled == true
17
18
  local interactionDisabled = disabled or not itemQueryMatch
18
- local _condition_1 = props.textValue
19
- if _condition_1 == nil then
20
- _condition_1 = props.value
21
- end
22
- local textValue = _condition_1
23
19
  local disabledRef = React.useRef(disabled)
24
20
  local textValueRef = React.useRef(textValue)
25
21
  React.useEffect(function()
@@ -74,6 +74,7 @@ local function ComboboxRoot(props)
74
74
  local _binding_2 = useControllableState(_object_1)
75
75
  local inputValue = _binding_2[1]
76
76
  local setInputValueState = _binding_2[2]
77
+ local visibleQueryValue, setVisibleQueryValue = React.useState(inputValue)
77
78
  local disabled = props.disabled == true
78
79
  local readOnly = props.readOnly == true
79
80
  local required = props.required == true
@@ -84,6 +85,7 @@ local function ComboboxRoot(props)
84
85
  local contentRef = React.useRef()
85
86
  local itemEntriesRef = React.useRef({})
86
87
  local itemTextCacheRef = React.useRef({})
88
+ local programmaticInputValueRef = React.useRef()
87
89
  local registryRevision, setRegistryRevision = React.useState(0)
88
90
  local registerItem = React.useCallback(function(item)
89
91
  local _exp = itemEntriesRef.current
@@ -169,6 +171,7 @@ local function ComboboxRoot(props)
169
171
  _result = ""
170
172
  end
171
173
  local nextInputValue = _result
174
+ programmaticInputValueRef.current = nextInputValue
172
175
  setInputValueState(nextInputValue)
173
176
  end, { getItemText, setInputValueState, value })
174
177
  local setOpen = React.useCallback(function(nextOpen)
@@ -195,7 +198,11 @@ local function ComboboxRoot(props)
195
198
  end
196
199
  -- ▲ ReadonlyArray.find ▲
197
200
  local selected = _result
198
- if selected and selected.getDisabled() then
201
+ local _result_1 = selected
202
+ if _result_1 ~= nil then
203
+ _result_1 = _result_1.getDisabled()
204
+ end
205
+ if _result_1 then
199
206
  return nil
200
207
  end
201
208
  setValueState(nextValue)
@@ -204,12 +211,21 @@ local function ComboboxRoot(props)
204
211
  _condition_2 = nextValue
205
212
  end
206
213
  local nextInputValue = _condition_2
214
+ programmaticInputValueRef.current = nextInputValue
207
215
  setInputValueState(nextInputValue)
208
216
  end, { disabled, getItemText, resolveOrderedItems, setInputValueState, setValueState })
209
217
  local setInputValue = React.useCallback(function(nextInputValue)
210
218
  if disabled or readOnly then
211
219
  return nil
212
220
  end
221
+ if programmaticInputValueRef.current ~= nil and nextInputValue == programmaticInputValueRef.current then
222
+ programmaticInputValueRef.current = nil
223
+ return nil
224
+ end
225
+ programmaticInputValueRef.current = nil
226
+ setVisibleQueryValue(function(currentQueryValue)
227
+ return if currentQueryValue == nextInputValue then currentQueryValue else nextInputValue
228
+ end)
213
229
  if nextInputValue == inputValue then
214
230
  return nil
215
231
  end
@@ -234,7 +250,11 @@ local function ComboboxRoot(props)
234
250
  return nil
235
251
  end
236
252
  syncInputFromValue()
237
- end, { open, syncInputFromValue, value })
253
+ setVisibleQueryValue(function(currentQueryValue)
254
+ return if currentQueryValue == inputValue then currentQueryValue else inputValue
255
+ end)
256
+ end, { inputValue, open, syncInputFromValue, value })
257
+ local queryValue = if open then visibleQueryValue else inputValue
238
258
  local contextValue = React.useMemo(function()
239
259
  return {
240
260
  open = open,
@@ -242,6 +262,7 @@ local function ComboboxRoot(props)
242
262
  value = value,
243
263
  setValue = setValue,
244
264
  inputValue = inputValue,
265
+ queryValue = queryValue,
245
266
  setInputValue = setInputValue,
246
267
  syncInputFromValue = syncInputFromValue,
247
268
  disabled = disabled,
@@ -255,7 +276,7 @@ local function ComboboxRoot(props)
255
276
  registerItem = registerItem,
256
277
  getItemText = getItemText,
257
278
  }
258
- end, { disabled, filterFn, getItemText, inputValue, open, readOnly, registerItem, required, resolveOrderedItems, setInputValue, setOpen, setValue, syncInputFromValue, value })
279
+ end, { disabled, filterFn, getItemText, inputValue, open, queryValue, readOnly, registerItem, required, resolveOrderedItems, setInputValue, setOpen, setValue, syncInputFromValue, value, visibleQueryValue })
259
280
  return React.createElement(ComboboxContextProvider, {
260
281
  value = contextValue,
261
282
  }, props.children)
@@ -5,7 +5,11 @@ local React = _core.React
5
5
  local Slot = _core.Slot
6
6
  local useComboboxContext = TS.import(script, script.Parent, "context").useComboboxContext
7
7
  local function toGuiObject(instance)
8
- if not instance or not instance:IsA("GuiObject") then
8
+ local _result = instance
9
+ if _result ~= nil then
10
+ _result = _result:IsA("GuiObject")
11
+ end
12
+ if not _result then
9
13
  return nil
10
14
  end
11
15
  return instance
@@ -19,6 +19,7 @@ export type ComboboxContextValue = {
19
19
  value?: string;
20
20
  setValue: ComboboxSetValue;
21
21
  inputValue: string;
22
+ queryValue: string;
22
23
  setInputValue: ComboboxSetInputValue;
23
24
  syncInputFromValue: () => void;
24
25
  disabled: boolean;
@@ -75,8 +76,9 @@ export type ComboboxContentProps = {
75
76
  asChild?: boolean;
76
77
  forceMount?: boolean;
77
78
  placement?: PopperPlacement;
78
- offset?: Vector2;
79
- padding?: number;
79
+ sideOffset?: number;
80
+ alignOffset?: number;
81
+ collisionPadding?: number;
80
82
  onPointerDownOutside?: (event: LayerInteractEvent) => void;
81
83
  onInteractOutside?: (event: LayerInteractEvent) => void;
82
84
  children?: React.ReactNode;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lattice-ui/combobox",
3
- "version": "0.5.0-next.1",
3
+ "version": "0.5.0-next.3",
4
4
  "private": false,
5
5
  "main": "out/init.luau",
6
6
  "types": "out/index.d.ts",
@@ -16,10 +16,10 @@
16
16
  "url": "https://github.com/astra-void/lattice-ui.git"
17
17
  },
18
18
  "dependencies": {
19
- "@lattice-ui/core": "0.5.0-next.1",
20
- "@lattice-ui/layer": "0.5.0-next.1",
21
- "@lattice-ui/motion": "0.5.0-next.1",
22
- "@lattice-ui/popper": "0.5.0-next.1"
19
+ "@lattice-ui/core": "0.5.0-next.3",
20
+ "@lattice-ui/layer": "0.5.0-next.3",
21
+ "@lattice-ui/popper": "0.5.0-next.3",
22
+ "@lattice-ui/motion": "0.5.0-next.3"
23
23
  },
24
24
  "devDependencies": {
25
25
  "@rbxts/react": "17.3.7-ts.1",
@@ -31,8 +31,8 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "rbxtsc -p tsconfig.json",
34
- "lint": "eslint .",
35
- "lint:fix": "eslint . --fix",
34
+ "lint": "biome check src",
35
+ "lint:fix": "biome check src --write --unsafe",
36
36
  "typecheck": "tsc -p tsconfig.typecheck.json",
37
37
  "watch": "rbxtsc -p tsconfig.json -w"
38
38
  }
@@ -1,16 +1,23 @@
1
- import { React, Slot } from "@lattice-ui/core";
1
+ import { composeRefs, getElementRef, React } from "@lattice-ui/core";
2
2
  import type { LayerInteractEvent } from "@lattice-ui/layer";
3
3
  import { DismissableLayer, Presence } from "@lattice-ui/layer";
4
- import { createPopperEntranceRecipe, usePresenceMotion } from "@lattice-ui/motion";
4
+ import { createCanvasGroupPopperEntranceRecipe, usePresenceMotionController } from "@lattice-ui/motion";
5
5
  import type { PopperPlacement } from "@lattice-ui/popper";
6
6
  import { usePopper } from "@lattice-ui/popper";
7
7
  import { useComboboxContext } from "./context";
8
8
  import type { ComboboxContentProps } from "./types";
9
9
 
10
10
  const CONTENT_OFFSET = 6;
11
+ const HIDDEN_POSITION = UDim2.fromOffset(-9999, -9999);
12
+
13
+ type GuiPropBag = React.Attributes & Record<string, unknown>;
14
+
15
+ function toGuiPropBag(value: unknown): GuiPropBag {
16
+ return typeIs(value, "table") ? (value as GuiPropBag) : {};
17
+ }
11
18
 
12
19
  function toGuiObject(instance: Instance | undefined) {
13
- if (!instance || !instance.IsA("GuiObject")) {
20
+ if (!instance?.IsA("GuiObject")) {
14
21
  return undefined;
15
22
  }
16
23
  return instance;
@@ -20,8 +27,9 @@ function ComboboxContentImpl(props: {
20
27
  motionPresent: boolean;
21
28
  onExitComplete?: () => void;
22
29
  placement?: PopperPlacement;
23
- offset?: Vector2;
24
- padding?: number;
30
+ sideOffset?: number;
31
+ alignOffset?: number;
32
+ collisionPadding?: number;
25
33
  forceMount?: boolean;
26
34
  onPointerDownOutside?: (event: LayerInteractEvent) => void;
27
35
  onInteractOutside?: (event: LayerInteractEvent) => void;
@@ -31,38 +39,54 @@ function ComboboxContentImpl(props: {
31
39
  }) {
32
40
  const comboboxContext = useComboboxContext();
33
41
  const open = comboboxContext.open;
42
+ const shouldMeasure = open || props.motionPresent || props.onExitComplete !== undefined;
43
+ const contentBoundaryRef = React.useRef<GuiObject>();
34
44
 
35
45
  const popper = usePopper({
36
46
  anchorRef: comboboxContext.anchorRef,
37
47
  contentRef: comboboxContext.contentRef,
48
+ alignOffset: props.alignOffset,
49
+ collisionPadding: props.collisionPadding,
50
+ sideOffset: props.sideOffset,
38
51
  placement: props.placement,
39
- offset: props.offset,
40
- padding: props.padding,
41
- enabled: open,
52
+ enabled: shouldMeasure,
42
53
  });
43
54
 
44
55
  const defaultTransition = React.useMemo(
45
- () => createPopperEntranceRecipe(popper.placement, CONTENT_OFFSET),
56
+ () => createCanvasGroupPopperEntranceRecipe(popper.placement, CONTENT_OFFSET),
46
57
  [popper.placement],
47
58
  );
48
- const motionRef = usePresenceMotion<GuiObject>(
49
- props.motionPresent && popper.isPositioned,
50
- props.transition ?? defaultTransition,
51
- props.onExitComplete,
52
- );
59
+ const recipe = props.transition ?? defaultTransition;
60
+
61
+ const motion = usePresenceMotionController<GuiObject>({
62
+ present: props.motionPresent,
63
+ ready: popper.isPositioned,
64
+ forceMount: props.forceMount,
65
+ config: recipe,
66
+ onExitComplete: props.onExitComplete,
67
+ });
53
68
 
54
69
  const setContentRef = React.useCallback(
55
70
  (instance: Instance | undefined) => {
56
- comboboxContext.contentRef.current = toGuiObject(instance);
57
- motionRef.current = toGuiObject(instance);
71
+ const guiObject = toGuiObject(instance);
72
+ comboboxContext.contentRef.current = guiObject;
73
+ contentBoundaryRef.current = guiObject;
74
+ motion.ref.current = guiObject;
58
75
  },
59
- [comboboxContext.contentRef, motionRef],
76
+ [comboboxContext.contentRef, motion.ref],
60
77
  );
61
78
 
62
79
  const handleDismiss = React.useCallback(() => {
63
80
  comboboxContext.setOpen(false);
64
81
  }, [comboboxContext]);
65
- const isActuallyVisible = open || (props.motionPresent && popper.isPositioned);
82
+
83
+ const shouldRender = motion.mounted;
84
+ const contentVisible = shouldRender && (motion.present || motion.phase !== "exited");
85
+ const popperPosition = popper.isPositioned ? popper.position : HIDDEN_POSITION;
86
+ const popperContentSize = (popper as { contentSize?: Vector2 }).contentSize ?? new Vector2(0, 0);
87
+ const popperWrapperSize = popper.isPositioned
88
+ ? UDim2.fromOffset(popperContentSize.X, popperContentSize.Y)
89
+ : UDim2.fromOffset(0, 0);
66
90
 
67
91
  const contentNode = props.asChild ? (
68
92
  (() => {
@@ -71,24 +95,38 @@ function ComboboxContentImpl(props: {
71
95
  error("[ComboboxContent] `asChild` requires a child element.");
72
96
  }
73
97
 
98
+ const childProps = toGuiPropBag((child as { props?: unknown }).props);
99
+ const childRef = getElementRef<Instance>(child);
100
+
74
101
  return (
75
- <Slot AnchorPoint={popper.anchorPoint} Visible={isActuallyVisible} ref={setContentRef}>
76
- {child}
77
- </Slot>
102
+ <canvasgroup
103
+ AutomaticSize={Enum.AutomaticSize.XY}
104
+ BackgroundTransparency={1}
105
+ BorderSizePixel={0}
106
+ Size={UDim2.fromOffset(0, 0)}
107
+ Visible={contentVisible}
108
+ ref={setContentRef as React.Ref<CanvasGroup>}
109
+ >
110
+ {React.cloneElement(child as React.ReactElement<GuiPropBag>, {
111
+ ...childProps,
112
+ Position: UDim2.fromOffset(0, 0),
113
+ Visible: contentVisible,
114
+ ref: composeRefs(childRef),
115
+ })}
116
+ </canvasgroup>
78
117
  );
79
118
  })()
80
119
  ) : (
81
- <frame
82
- AnchorPoint={popper.anchorPoint}
120
+ <canvasgroup
83
121
  AutomaticSize={Enum.AutomaticSize.XY}
84
122
  BackgroundTransparency={1}
85
123
  BorderSizePixel={0}
86
124
  Size={UDim2.fromOffset(0, 0)}
87
- Visible={isActuallyVisible}
125
+ Visible={contentVisible}
88
126
  ref={setContentRef}
89
127
  >
90
128
  {props.children}
91
- </frame>
129
+ </canvasgroup>
92
130
  );
93
131
 
94
132
  return (
@@ -99,12 +137,15 @@ function ComboboxContentImpl(props: {
99
137
  onDismiss={handleDismiss}
100
138
  onInteractOutside={props.onInteractOutside}
101
139
  onPointerDownOutside={props.onPointerDownOutside}
140
+ contentBoundaryRef={contentBoundaryRef}
102
141
  >
103
142
  <frame
143
+ AnchorPoint={popper.anchorPoint}
104
144
  BackgroundTransparency={1}
105
145
  BorderSizePixel={0}
106
- Position={popper.isPositioned ? popper.position : UDim2.fromOffset(-9999, -9999)}
107
- Size={UDim2.fromOffset(0, 0)}
146
+ Position={popperPosition}
147
+ Size={popperWrapperSize}
148
+ Visible={shouldRender}
108
149
  >
109
150
  {contentNode}
110
151
  </frame>
@@ -120,13 +161,14 @@ export function ComboboxContent(props: ComboboxContentProps) {
120
161
  return (
121
162
  <ComboboxContentImpl
122
163
  asChild={props.asChild}
164
+ alignOffset={props.alignOffset}
165
+ collisionPadding={props.collisionPadding}
123
166
  forceMount={props.forceMount}
124
167
  motionPresent={open}
125
- offset={props.offset}
126
168
  onInteractOutside={props.onInteractOutside}
127
169
  onPointerDownOutside={props.onPointerDownOutside}
128
- padding={props.padding}
129
170
  placement={props.placement}
171
+ sideOffset={props.sideOffset}
130
172
  transition={props.transition}
131
173
  >
132
174
  {props.children}
@@ -140,14 +182,15 @@ export function ComboboxContent(props: ComboboxContentProps) {
140
182
  render={(state) => (
141
183
  <ComboboxContentImpl
142
184
  asChild={props.asChild}
185
+ alignOffset={props.alignOffset}
186
+ collisionPadding={props.collisionPadding}
143
187
  forceMount={props.forceMount}
144
188
  motionPresent={state.isPresent}
145
- offset={props.offset}
146
189
  onExitComplete={state.onExitComplete}
147
190
  onInteractOutside={props.onInteractOutside}
148
191
  onPointerDownOutside={props.onPointerDownOutside}
149
- padding={props.padding}
150
192
  placement={props.placement}
193
+ sideOffset={props.sideOffset}
151
194
  transition={props.transition}
152
195
  >
153
196
  {props.children}
@@ -2,8 +2,10 @@ import { React, Slot } from "@lattice-ui/core";
2
2
  import { useComboboxContext } from "./context";
3
3
  import type { ComboboxInputProps } from "./types";
4
4
 
5
+ const UserInputService = game.GetService("UserInputService");
6
+
5
7
  function toTextBox(instance: Instance | undefined) {
6
- if (!instance || !instance.IsA("TextBox")) {
8
+ if (!instance?.IsA("TextBox")) {
7
9
  return undefined;
8
10
  }
9
11
 
@@ -43,6 +45,10 @@ export function ComboboxInput(props: ComboboxInputProps) {
43
45
  return;
44
46
  }
45
47
 
48
+ if (UserInputService.GetFocusedTextBox() !== textBox) {
49
+ return;
50
+ }
51
+
46
52
  if (disabled || readOnly) {
47
53
  if (textBox.Text !== lastInputValueRef.current) {
48
54
  textBox.Text = lastInputValueRef.current;
@@ -7,10 +7,10 @@ let nextItemOrder = 0;
7
7
 
8
8
  export function ComboboxItem(props: ComboboxItemProps) {
9
9
  const comboboxContext = useComboboxContext();
10
- const itemQueryMatch = comboboxContext.filterFn(props.textValue ?? props.value, comboboxContext.inputValue);
10
+ const textValue = props.textValue ?? props.value;
11
+ const itemQueryMatch = comboboxContext.filterFn(textValue, comboboxContext.queryValue);
11
12
  const disabled = comboboxContext.disabled || props.disabled === true;
12
13
  const interactionDisabled = disabled || !itemQueryMatch;
13
- const textValue = props.textValue ?? props.value;
14
14
 
15
15
  const disabledRef = React.useRef(disabled);
16
16
  const textValueRef = React.useRef(textValue);
@@ -39,6 +39,7 @@ export function ComboboxRoot(props: ComboboxProps) {
39
39
  defaultValue: props.defaultInputValue ?? "",
40
40
  onChange: props.onInputValueChange,
41
41
  });
42
+ const [visibleQueryValue, setVisibleQueryValue] = React.useState(inputValue);
42
43
 
43
44
  const disabled = props.disabled === true;
44
45
  const readOnly = props.readOnly === true;
@@ -52,6 +53,7 @@ export function ComboboxRoot(props: ComboboxProps) {
52
53
 
53
54
  const itemEntriesRef = React.useRef<Array<ComboboxItemRegistration>>([]);
54
55
  const itemTextCacheRef = React.useRef<Record<string, string>>({});
56
+ const programmaticInputValueRef = React.useRef<string | undefined>();
55
57
  const [registryRevision, setRegistryRevision] = React.useState(0);
56
58
 
57
59
  const registerItem = React.useCallback((item: ComboboxItemRegistration) => {
@@ -90,6 +92,7 @@ export function ComboboxRoot(props: ComboboxProps) {
90
92
 
91
93
  const syncInputFromValue = React.useCallback(() => {
92
94
  const nextInputValue = value !== undefined ? (getItemText(value) ?? "") : "";
95
+ programmaticInputValueRef.current = nextInputValue;
93
96
  setInputValueState(nextInputValue);
94
97
  }, [getItemText, setInputValueState, value]);
95
98
 
@@ -111,12 +114,13 @@ export function ComboboxRoot(props: ComboboxProps) {
111
114
  }
112
115
 
113
116
  const selected = resolveOrderedItems().find((item) => item.value === nextValue);
114
- if (selected && selected.getDisabled()) {
117
+ if (selected?.getDisabled()) {
115
118
  return;
116
119
  }
117
120
 
118
121
  setValueState(nextValue);
119
122
  const nextInputValue = getItemText(nextValue) ?? nextValue;
123
+ programmaticInputValueRef.current = nextInputValue;
120
124
  setInputValueState(nextInputValue);
121
125
  },
122
126
  [disabled, getItemText, resolveOrderedItems, setInputValueState, setValueState],
@@ -128,6 +132,16 @@ export function ComboboxRoot(props: ComboboxProps) {
128
132
  return;
129
133
  }
130
134
 
135
+ if (programmaticInputValueRef.current !== undefined && nextInputValue === programmaticInputValueRef.current) {
136
+ programmaticInputValueRef.current = undefined;
137
+ return;
138
+ }
139
+
140
+ programmaticInputValueRef.current = undefined;
141
+ setVisibleQueryValue((currentQueryValue) =>
142
+ currentQueryValue === nextInputValue ? currentQueryValue : nextInputValue,
143
+ );
144
+
131
145
  if (nextInputValue === inputValue) {
132
146
  return;
133
147
  }
@@ -160,7 +174,10 @@ export function ComboboxRoot(props: ComboboxProps) {
160
174
  }
161
175
 
162
176
  syncInputFromValue();
163
- }, [open, syncInputFromValue, value]);
177
+ setVisibleQueryValue((currentQueryValue) => (currentQueryValue === inputValue ? currentQueryValue : inputValue));
178
+ }, [inputValue, open, syncInputFromValue, value]);
179
+
180
+ const queryValue = open ? visibleQueryValue : inputValue;
164
181
 
165
182
  const contextValue = React.useMemo(
166
183
  () => ({
@@ -169,6 +186,7 @@ export function ComboboxRoot(props: ComboboxProps) {
169
186
  value,
170
187
  setValue,
171
188
  inputValue,
189
+ queryValue,
172
190
  setInputValue,
173
191
  syncInputFromValue,
174
192
  disabled,
@@ -188,6 +206,7 @@ export function ComboboxRoot(props: ComboboxProps) {
188
206
  getItemText,
189
207
  inputValue,
190
208
  open,
209
+ queryValue,
191
210
  readOnly,
192
211
  registerItem,
193
212
  required,
@@ -197,6 +216,7 @@ export function ComboboxRoot(props: ComboboxProps) {
197
216
  setValue,
198
217
  syncInputFromValue,
199
218
  value,
219
+ visibleQueryValue,
200
220
  ],
201
221
  );
202
222
 
@@ -3,7 +3,7 @@ import { useComboboxContext } from "./context";
3
3
  import type { ComboboxTriggerProps } from "./types";
4
4
 
5
5
  function toGuiObject(instance: Instance | undefined) {
6
- if (!instance || !instance.IsA("GuiObject")) {
6
+ if (!instance?.IsA("GuiObject")) {
7
7
  return undefined;
8
8
  }
9
9
 
@@ -23,6 +23,7 @@ export type ComboboxContextValue = {
23
23
  value?: string;
24
24
  setValue: ComboboxSetValue;
25
25
  inputValue: string;
26
+ queryValue: string;
26
27
  setInputValue: ComboboxSetInputValue;
27
28
  syncInputFromValue: () => void;
28
29
  disabled: boolean;
@@ -85,8 +86,9 @@ export type ComboboxContentProps = {
85
86
  asChild?: boolean;
86
87
  forceMount?: boolean;
87
88
  placement?: PopperPlacement;
88
- offset?: Vector2;
89
- padding?: number;
89
+ sideOffset?: number;
90
+ alignOffset?: number;
91
+ collisionPadding?: number;
90
92
  onPointerDownOutside?: (event: LayerInteractEvent) => void;
91
93
  onInteractOutside?: (event: LayerInteractEvent) => void;
92
94
  children?: React.ReactNode;