@lotics/ui 2.3.0 → 2.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
package/src/combobox.tsx CHANGED
@@ -132,6 +132,13 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
132
132
  const triggerRef = useRef<View>(null);
133
133
  const inputRef = useRef<RNTextInput>(null);
134
134
  const scrollRef = useRef<ScrollView>(null);
135
+ // Options are focusable (RN-Web Pressable), so a mouse-press on one blurs the
136
+ // input; commit then refocuses it to keep the textbox focus (aria pattern),
137
+ // which would re-fire onFocus and reopen the popover. This one-shot flag lets
138
+ // that programmatic refocus pass without reopening — set on a single-select
139
+ // commit, consumed by the next onFocus, and cleared on blur (the keyboard
140
+ // path never blurs, so the refocus is a no-op and onFocus never fires).
141
+ const suppressFocusOpenRef = useRef(false);
135
142
  const baseId = useId();
136
143
  const listboxId = `${baseId}-listbox`;
137
144
  const optionId = (i: number) => `${baseId}-option-${i}`;
@@ -188,6 +195,9 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
188
195
  setQuery("");
189
196
  }
190
197
  setOpen(false);
198
+ // Single-select: the refocus below must not reopen the just-closed menu.
199
+ // Multi-select keeps the menu open to add more, so it isn't suppressed.
200
+ if (!multi) suppressFocusOpenRef.current = true;
191
201
  inputRef.current?.focus();
192
202
  },
193
203
  [onValueChange, isServer, debouncedSearch, onSearchChange, multi, reflectSelection],
@@ -278,8 +288,19 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
278
288
  onClear={onClear}
279
289
  onChangeText={handleChangeText}
280
290
  onFocus={() => {
291
+ // A programmatic refocus right after a single-select commit must not
292
+ // reopen the menu; consume the one-shot flag instead of opening.
293
+ if (suppressFocusOpenRef.current) {
294
+ suppressFocusOpenRef.current = false;
295
+ return;
296
+ }
281
297
  if (!disabled) setOpen(true);
282
298
  }}
299
+ onBlur={() => {
300
+ // Clear a flag the keyboard path left set (its refocus was a no-op,
301
+ // so onFocus never consumed it) — the next real focus opens normally.
302
+ suppressFocusOpenRef.current = false;
303
+ }}
283
304
  onKeyPress={handleKeyPress}
284
305
  placeholder={chips.length > 0 ? undefined : placeholder}
285
306
  placeholderTextColor={colors.zinc["400"]}
@@ -368,7 +389,7 @@ export function Combobox<T extends string = string, D = unknown>(props: Combobox
368
389
  selected={!multi && !isCustom && single?.value === opt.value}
369
390
  disabled={opt.disabled}
370
391
  onPress={() => handleSelect(i)}
371
- onHoverIn={() => setActiveIndex(i)}
392
+ onHoverIn={() => setActiveIndex(i, false)}
372
393
  />
373
394
  );
374
395
  })
@@ -103,4 +103,11 @@ describe("useListKeyboardNav", () => {
103
103
  act(() => void view.result.current.handleKey("ArrowDown"));
104
104
  expect(onActiveChange).toHaveBeenCalledWith(1);
105
105
  });
106
+
107
+ it("setActiveIndex moves the active row but skips onActiveChange when scrollIntoView is false (mouse hover)", () => {
108
+ const { view, onActiveChange } = setup();
109
+ act(() => view.result.current.setActiveIndex(2, false));
110
+ expect(view.result.current.activeIndex).toBe(2);
111
+ expect(onActiveChange).not.toHaveBeenCalled();
112
+ });
106
113
  });
@@ -17,7 +17,10 @@ export interface ListKeyboardNav {
17
17
  /** The virtually-focused row. The list stays visually highlighted here while
18
18
  * DOM focus remains on the controlling input (ARIA `aria-activedescendant`). */
19
19
  activeIndex: number;
20
- setActiveIndex: (index: number) => void;
20
+ /** Set the active row. `scrollIntoView` defaults to true (keyboard nav); pass
21
+ * false on mouse hover so it doesn't scroll the row into view — the cursor is
22
+ * already on it, and scrolling would slide a different row under the cursor. */
23
+ setActiveIndex: (index: number, scrollIntoView?: boolean) => void;
21
24
  /** Feed a key from the controlling input's `onKeyPress`. Returns true when it
22
25
  * handled the key, so the caller can `preventDefault()`. */
23
26
  handleKey: (key: string) => boolean;
@@ -34,9 +37,13 @@ export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNa
34
37
  const [activeIndex, setActiveIndexState] = useState(0);
35
38
 
36
39
  const setActiveIndex = useCallback(
37
- (index: number) => {
40
+ (index: number, scrollIntoView = true) => {
38
41
  setActiveIndexState(index);
39
- onActiveChange?.(index);
42
+ // Only scroll for keyboard nav. Mouse hover sets the active row without
43
+ // scrolling — the cursor is already on it, and scrolling it into view
44
+ // would slide a different row under the cursor → onHoverIn → scroll →
45
+ // runaway loop.
46
+ if (scrollIntoView) onActiveChange?.(index);
40
47
  },
41
48
  [onActiveChange],
42
49
  );