@lotics/ui 5.9.0 → 5.10.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.
Files changed (97) hide show
  1. package/AGENTS.md +131 -44
  2. package/examples/tpl_approvals.tsx +3 -3
  3. package/examples/tpl_dossier.tsx +5 -4
  4. package/examples/tpl_pipeline.tsx +400 -0
  5. package/examples/tpl_record.tsx +5 -4
  6. package/examples/tpl_shifts.tsx +7 -5
  7. package/examples/tpl_task_board.tsx +473 -0
  8. package/examples/tpl_tasks.tsx +456 -0
  9. package/package.json +10 -5
  10. package/src/accordion.tsx +1 -1
  11. package/src/agent_progress.tsx +1 -1
  12. package/src/agent_run.tsx +1 -1
  13. package/src/breakdown.tsx +2 -2
  14. package/src/button.tsx +12 -3
  15. package/src/calendar/calendar_view.tsx +10 -10
  16. package/src/calendar/month_view.tsx +30 -11
  17. package/src/calendar/time_grid_view.tsx +25 -7
  18. package/src/card_select_item.tsx +11 -13
  19. package/src/check_circle.tsx +88 -0
  20. package/src/checkbox_input.tsx +5 -1
  21. package/src/chip.tsx +1 -1
  22. package/src/chip_group.tsx +1 -1
  23. package/src/column_filter.tsx +3 -2
  24. package/src/combobox.tsx +84 -219
  25. package/src/confidence.tsx +14 -3
  26. package/src/control_surface.ts +22 -7
  27. package/src/data_grid.tsx +159 -0
  28. package/src/date_calendar.tsx +46 -23
  29. package/src/date_field.tsx +10 -2
  30. package/src/date_filter.tsx +5 -9
  31. package/src/date_picker.tsx +78 -26
  32. package/src/date_picker_value.ts +11 -0
  33. package/src/date_range_filter_field.tsx +6 -4
  34. package/src/drawer.tsx +1 -1
  35. package/src/file_dropzone.tsx +5 -0
  36. package/src/file_gallery_modal.tsx +43 -42
  37. package/src/file_preview.web.tsx +25 -11
  38. package/src/file_preview_types.ts +9 -0
  39. package/src/file_row.tsx +5 -1
  40. package/src/file_rows.tsx +6 -2
  41. package/src/file_thumbnail.tsx +13 -4
  42. package/src/file_thumbnail_grid.tsx +5 -0
  43. package/src/files_editor.tsx +232 -0
  44. package/src/filter_chip.tsx +25 -12
  45. package/src/focus_ring_pressable.tsx +34 -0
  46. package/src/form_picker.tsx +3 -3
  47. package/src/gantt/gantt_view.tsx +25 -7
  48. package/src/heatmap.tsx +5 -1
  49. package/src/icon_button.tsx +2 -2
  50. package/src/image_gallery.tsx +8 -10
  51. package/src/index.css +8 -12
  52. package/src/inline_date_picker.tsx +11 -11
  53. package/src/inline_edit.tsx +22 -6
  54. package/src/inline_member_select.tsx +1 -1
  55. package/src/inline_select.tsx +9 -8
  56. package/src/inline_text_input.tsx +4 -1
  57. package/src/link.tsx +11 -1
  58. package/src/link_button.tsx +1 -1
  59. package/src/list_item.tsx +2 -2
  60. package/src/member_select.tsx +9 -4
  61. package/src/menu_button.tsx +2 -2
  62. package/src/menu_list_item.tsx +2 -2
  63. package/src/number_input.tsx +11 -4
  64. package/src/option_list.tsx +211 -0
  65. package/src/peek.tsx +1 -1
  66. package/src/picker.tsx +24 -213
  67. package/src/pressable_highlight.tsx +42 -23
  68. package/src/radio_picker.tsx +2 -2
  69. package/src/range_slider.tsx +35 -7
  70. package/src/react_native.d.ts +3 -0
  71. package/src/scroll_to_bottom.tsx +1 -1
  72. package/src/section.tsx +7 -2
  73. package/src/segmented_control.tsx +2 -2
  74. package/src/select.tsx +299 -0
  75. package/src/sort_header.tsx +1 -1
  76. package/src/sources.tsx +1 -1
  77. package/src/spreadsheet_view.tsx +43 -17
  78. package/src/status_grid.tsx +4 -3
  79. package/src/stepper.tsx +1 -1
  80. package/src/switch.tsx +5 -1
  81. package/src/switch_button.tsx +1 -1
  82. package/src/switcher.tsx +1 -1
  83. package/src/table.tsx +5 -1
  84. package/src/tabs.tsx +2 -2
  85. package/src/text_input_field.tsx +15 -4
  86. package/src/time_picker.tsx +11 -4
  87. package/src/timeline.tsx +1 -1
  88. package/src/use_focus_ring.ts +80 -0
  89. package/src/use_hover.ts +26 -0
  90. package/src/use_list_keyboard_nav.test.ts +71 -0
  91. package/src/use_list_keyboard_nav.ts +52 -5
  92. package/src/use_option_list.test.ts +193 -0
  93. package/src/use_option_list.ts +354 -0
  94. package/src/command_menu.tsx +0 -205
  95. package/src/picker_menu.tsx +0 -355
  96. package/src/tag_input.tsx +0 -203
  97. package/src/time_field.tsx +0 -297
package/src/timeline.tsx CHANGED
@@ -85,7 +85,7 @@ export function Timeline(props: TimelineProps) {
85
85
 
86
86
  <View style={styles.contentColumn}>
87
87
  {hasDetails ? (
88
- <PressableHighlight onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
88
+ <PressableHighlight focusRing onPress={() => toggleItem(item.id)} style={styles.pressableRow}>
89
89
  {row}
90
90
  </PressableHighlight>
91
91
  ) : (
@@ -0,0 +1,80 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ /**
4
+ * Combine two optional event handlers into one. Either may be undefined.
5
+ * Used to layer a focus-ring handler on top of a caller's (or a tooltip's)
6
+ * `onFocus`/`onBlur` so neither silently clobbers the other.
7
+ */
8
+ export function composeHandler<E>(
9
+ a: ((event: E) => void) | undefined,
10
+ b: ((event: E) => void) | undefined,
11
+ ): ((event: E) => void) | undefined {
12
+ if (!a) return b;
13
+ if (!b) return a;
14
+ return (event: E) => {
15
+ a(event);
16
+ b(event);
17
+ };
18
+ }
19
+
20
+ // `:focus-visible` is a MODALITY heuristic: a control shows its ring on keyboard
21
+ // focus but not pointer focus. The kit no longer carries a global `:focus-visible`
22
+ // CSS rule, so every control paints its own ring — and needs that same signal. One
23
+ // focus event carries no modality, so it can only be read at the document level:
24
+ // a single module-level tracker records the last interaction. Web-only — on native
25
+ // / SSR there is no `document`, so it stays "pointer" and the ring is keyboard-gated
26
+ // off (text-like inputs opt back in with `always`).
27
+ type Modality = "keyboard" | "pointer";
28
+ let lastModality: Modality = "pointer";
29
+ let listenersInstalled = false;
30
+
31
+ function ensureModalityListeners(): void {
32
+ if (listenersInstalled) return;
33
+ if (typeof document === "undefined") return;
34
+ listenersInstalled = true;
35
+ // Capture phase so the modality is recorded BEFORE any control's focus handler
36
+ // runs. Installed once for the app's lifetime (the browser's own `:focus-visible`
37
+ // heuristic listens the same way) — per-mount add/remove would be the bug.
38
+ const opts = { capture: true, passive: true } as const;
39
+ document.addEventListener("keydown", () => { lastModality = "keyboard"; }, opts);
40
+ document.addEventListener("pointerdown", () => { lastModality = "pointer"; }, opts);
41
+ document.addEventListener("mousedown", () => { lastModality = "pointer"; }, opts);
42
+ document.addEventListener("touchstart", () => { lastModality = "pointer"; }, opts);
43
+ }
44
+
45
+ export interface UseFocusRingOptions {
46
+ /**
47
+ * Ring on ANY focus, not just keyboard focus. For text-like inputs, which the
48
+ * browser treats as focus-visible even on pointer focus, so a clicked field shows
49
+ * its ring immediately. Plain controls omit this — they ring on keyboard only.
50
+ */
51
+ always?: boolean;
52
+ }
53
+
54
+ export interface FocusRingState {
55
+ /**
56
+ * True while the element should show its focus ring: focused AND (unless
57
+ * `always`) the last interaction was the keyboard. Drive a `boxShadow` off this,
58
+ * composing with any existing one — e.g. `focusVisible ? FOCUS_RING : undefined`.
59
+ */
60
+ focusVisible: boolean;
61
+ /** Raw focus state, regardless of modality. */
62
+ focused: boolean;
63
+ /** Spread onto the focusable element, or compose with `composeHandler`. */
64
+ focusProps: { onFocus: () => void; onBlur: () => void };
65
+ }
66
+
67
+ /**
68
+ * Keyboard-aware focus state for painting a control's own focus ring inline.
69
+ * Replaces the kit's removed global `:focus-visible` CSS rule: each interactive
70
+ * primitive calls this and renders `FOCUS_RING` (`control_surface.ts`) when
71
+ * `focusVisible`. App authors do the same for their own raw focusable elements.
72
+ */
73
+ export function useFocusRing(options?: UseFocusRingOptions): FocusRingState {
74
+ ensureModalityListeners();
75
+ const [focused, setFocused] = useState(false);
76
+ const onFocus = useCallback(() => setFocused(true), []);
77
+ const onBlur = useCallback(() => setFocused(false), []);
78
+ const focusVisible = focused && (options?.always === true || lastModality === "keyboard");
79
+ return { focusVisible, focused, focusProps: { onFocus, onBlur } };
80
+ }
@@ -0,0 +1,26 @@
1
+ import { useCallback, useState } from "react";
2
+
3
+ export interface HoverState {
4
+ hovered: boolean;
5
+ /**
6
+ * Spread onto the element (or its wrapping View). `onMouseEnter`/`onMouseLeave`
7
+ * are forwarded to the DOM by react-native-web on View/TextInput/etc.; on native
8
+ * they're ignored, so `hovered` stays false (no hover on touch — correct).
9
+ */
10
+ hoverProps: { onMouseEnter: () => void; onMouseLeave: () => void };
11
+ }
12
+
13
+ /**
14
+ * Pointer-hover state for a premium hover affordance (a border darken / shadow on
15
+ * bordered inputs, selects, and other interactive surfaces — signalling "this is
16
+ * interactive" the same way the focus ring signals "this is focused"). Mirrors
17
+ * `useFocusRing`. For `Pressable`-based controls prefer the built-in `hovered`
18
+ * style state; reach for this on TextInput / the native `<select>` / raw DOM inputs
19
+ * that don't expose it.
20
+ */
21
+ export function useHover(): HoverState {
22
+ const [hovered, setHovered] = useState(false);
23
+ const onMouseEnter = useCallback(() => setHovered(true), []);
24
+ const onMouseLeave = useCallback(() => setHovered(false), []);
25
+ return { hovered, hoverProps: { onMouseEnter, onMouseLeave } };
26
+ }
@@ -26,6 +26,11 @@ describe("useListKeyboardNav", () => {
26
26
  expect(view.result.current.activeIndex).toBe(0);
27
27
  });
28
28
 
29
+ it("seats the initial active row at initialIndex", () => {
30
+ const { view } = setup({ initialIndex: 2 });
31
+ expect(view.result.current.activeIndex).toBe(2);
32
+ });
33
+
29
34
  it("ArrowDown / ArrowUp move within bounds and report handled", () => {
30
35
  const { view } = setup();
31
36
  let handled = false;
@@ -111,3 +116,69 @@ describe("useListKeyboardNav", () => {
111
116
  expect(onActiveChange).not.toHaveBeenCalled();
112
117
  });
113
118
  });
119
+
120
+ describe("useListKeyboardNav typeahead", () => {
121
+ const labels = ["Apple", "Banana", "Blueberry", "Cherry"];
122
+ function setupTypeahead(overrides: Partial<ListKeyboardNavOptions> = {}) {
123
+ return setup({
124
+ count: labels.length,
125
+ typeahead: { labelAt: (i) => labels[i] },
126
+ ...overrides,
127
+ });
128
+ }
129
+
130
+ it("jumps to the first label-prefix match and reports the key handled", () => {
131
+ const { view } = setupTypeahead();
132
+ let handled = false;
133
+ act(() => {
134
+ handled = view.result.current.handleKey("c");
135
+ });
136
+ expect(handled).toBe(true);
137
+ expect(view.result.current.activeIndex).toBe(3); // Cherry
138
+ });
139
+
140
+ it("accumulates the buffer while typing", () => {
141
+ vi.useFakeTimers();
142
+ try {
143
+ const { view } = setupTypeahead();
144
+ act(() => void view.result.current.handleKey("b")); // "b" → Banana
145
+ expect(view.result.current.activeIndex).toBe(1);
146
+ act(() => void view.result.current.handleKey("l")); // "bl" → Blueberry
147
+ expect(view.result.current.activeIndex).toBe(2);
148
+ } finally {
149
+ vi.useRealTimers();
150
+ }
151
+ });
152
+
153
+ it("resets the buffer after the idle timeout", () => {
154
+ vi.useFakeTimers();
155
+ try {
156
+ const { view } = setupTypeahead();
157
+ act(() => void view.result.current.handleKey("b")); // "b" → Banana
158
+ expect(view.result.current.activeIndex).toBe(1);
159
+ act(() => {
160
+ vi.advanceTimersByTime(700);
161
+ });
162
+ act(() => void view.result.current.handleKey("c")); // fresh "c" → Cherry, not "bc"
163
+ expect(view.result.current.activeIndex).toBe(3);
164
+ } finally {
165
+ vi.useRealTimers();
166
+ }
167
+ });
168
+
169
+ it("skips disabled rows when jumping", () => {
170
+ const { view } = setupTypeahead({ isDisabled: (i) => i === 1 }); // Banana disabled
171
+ act(() => void view.result.current.handleKey("b"));
172
+ expect(view.result.current.activeIndex).toBe(2); // Blueberry, the first enabled b*
173
+ });
174
+
175
+ it("without a typeahead option, a printable key is not handled and does not move the row", () => {
176
+ const { view } = setup(); // no typeahead
177
+ let handled = true;
178
+ act(() => {
179
+ handled = view.result.current.handleKey("b");
180
+ });
181
+ expect(handled).toBe(false);
182
+ expect(view.result.current.activeIndex).toBe(0);
183
+ });
184
+ });
@@ -1,4 +1,4 @@
1
- import { useCallback, useState } from "react";
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
3
  export interface ListKeyboardNavOptions {
4
4
  /** Number of rows in the list. */
@@ -11,6 +11,21 @@ export interface ListKeyboardNavOptions {
11
11
  onClose?: () => void;
12
12
  /** Fires when the active row changes — use it to scroll the row into view. */
13
13
  onActiveChange?: (index: number) => void;
14
+ /** The row active on first render (default 0). Read once — seat the initial
15
+ * highlight here instead of via a post-mount effect, so an open list shows the
16
+ * right row with no flash. */
17
+ initialIndex?: number;
18
+ /** Opt-in native-`<select>`-style typeahead: a printable key jumps the active
19
+ * row to the first enabled label that starts with the typed buffer (the buffer
20
+ * accumulates as you keep typing, then resets after `timeoutMs` of inactivity).
21
+ * Absent ⇒ printable keys are reported NOT handled, so a list driven by a real
22
+ * search field lets those keys flow to the input instead of jumping rows. */
23
+ typeahead?: {
24
+ /** The label to match for row `index` (undefined ⇒ the row is skipped). */
25
+ labelAt: (index: number) => string | undefined;
26
+ /** Idle window before the buffer resets, in ms. Default 600 (native parity). */
27
+ timeoutMs?: number;
28
+ };
14
29
  }
15
30
 
16
31
  export interface ListKeyboardNav {
@@ -33,8 +48,18 @@ export interface ListKeyboardNav {
33
48
  * Focus never leaves the input — this only tracks the active index.
34
49
  */
35
50
  export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNav {
36
- const { count, isDisabled, onSelect, onClose, onActiveChange } = opts;
37
- const [activeIndex, setActiveIndexState] = useState(0);
51
+ const { count, isDisabled, onSelect, onClose, onActiveChange, typeahead, initialIndex } = opts;
52
+ const [activeIndex, setActiveIndexState] = useState(() => initialIndex ?? 0);
53
+ // Typeahead buffer + its idle-reset timer. Refs so they survive renders and the
54
+ // handler stays referentially stable; the timer is cleared on unmount.
55
+ const typeaheadBufferRef = useRef("");
56
+ const typeaheadTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
57
+ useEffect(
58
+ () => () => {
59
+ if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
60
+ },
61
+ [],
62
+ );
38
63
 
39
64
  const setActiveIndex = useCallback(
40
65
  (index: number, scrollIntoView = true) => {
@@ -86,11 +111,33 @@ export function useListKeyboardNav(opts: ListKeyboardNavOptions): ListKeyboardNa
86
111
  // Let focus move on, but dismiss the list.
87
112
  onClose?.();
88
113
  return false;
89
- default:
114
+ default: {
115
+ // Native-select typeahead — only when opted in. A printable key
116
+ // (single character) extends the buffer and jumps to the first enabled
117
+ // label that starts with it; otherwise the key isn't ours (a real
118
+ // search field, if any, consumes it).
119
+ if (typeahead && key.length === 1) {
120
+ if (typeaheadTimerRef.current) clearTimeout(typeaheadTimerRef.current);
121
+ typeaheadBufferRef.current += key.toLowerCase();
122
+ const buffer = typeaheadBufferRef.current;
123
+ for (let i = 0; i < count; i++) {
124
+ if (!enabled(i)) continue;
125
+ const label = typeahead.labelAt(i);
126
+ if (label && label.toLowerCase().startsWith(buffer)) {
127
+ setActiveIndex(i);
128
+ break;
129
+ }
130
+ }
131
+ typeaheadTimerRef.current = setTimeout(() => {
132
+ typeaheadBufferRef.current = "";
133
+ }, typeahead.timeoutMs ?? 600);
134
+ return true;
135
+ }
90
136
  return false;
137
+ }
91
138
  }
92
139
  },
93
- [activeIndex, count, isDisabled, onSelect, onClose, setActiveIndex],
140
+ [activeIndex, count, isDisabled, onSelect, onClose, setActiveIndex, typeahead],
94
141
  );
95
142
 
96
143
  return { activeIndex, setActiveIndex, handleKey };
@@ -0,0 +1,193 @@
1
+ // @vitest-environment jsdom
2
+ import { describe, it, expect, vi } from "vitest";
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { useOptionList, type UseOptionListParams } from "./use_option_list";
5
+
6
+ const OPTS = [
7
+ { value: "a", label: "Apple" },
8
+ { value: "b", label: "Banana" },
9
+ { value: "c", label: "Cherry" },
10
+ ];
11
+
12
+ function setupSingle(overrides: Partial<UseOptionListParams<string, false>> = {}) {
13
+ const onValueChange = vi.fn();
14
+ const onRequestClose = vi.fn();
15
+ const onCustomCommit = vi.fn();
16
+ const params: UseOptionListParams<string, false> = {
17
+ options: OPTS,
18
+ search: { mode: "none" },
19
+ onValueChange,
20
+ onRequestClose,
21
+ onCustomCommit,
22
+ ...overrides,
23
+ };
24
+ const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
25
+ initialProps: params,
26
+ });
27
+ return { view, onValueChange, onRequestClose, onCustomCommit };
28
+ }
29
+
30
+ function setupMulti(overrides: Partial<UseOptionListParams<string, true>> = {}) {
31
+ const onValueChange = vi.fn();
32
+ const onRequestClose = vi.fn();
33
+ const params: UseOptionListParams<string, true> = {
34
+ options: OPTS,
35
+ multi: true,
36
+ search: { mode: "none" },
37
+ onValueChange,
38
+ onRequestClose,
39
+ ...overrides,
40
+ };
41
+ const view = renderHook((p: UseOptionListParams<string, true>) => useOptionList(p), {
42
+ initialProps: params,
43
+ });
44
+ return { view, onValueChange, onRequestClose };
45
+ }
46
+
47
+ describe("useOptionList", () => {
48
+ it("none mode lists every option and seats the active row on the first one", () => {
49
+ const { view } = setupSingle();
50
+ const { rows, activeIndex } = view.result.current;
51
+ expect(rows.map((r) => r.option.value)).toEqual(["a", "b", "c"]);
52
+ expect(rows.every((r) => r.kind === "option")).toBe(true);
53
+ expect(activeIndex).toBe(0);
54
+ });
55
+
56
+ it("none mode seats the active row on the selected option", () => {
57
+ const { view } = setupSingle({ value: "c" });
58
+ expect(view.result.current.activeIndex).toBe(2);
59
+ expect(view.result.current.rows[2].selected).toBe(true);
60
+ });
61
+
62
+ it("single pick emits the value and requests close", () => {
63
+ const { view, onValueChange, onRequestClose } = setupSingle();
64
+ act(() => view.result.current.pickRow(1));
65
+ expect(onValueChange).toHaveBeenCalledWith("b");
66
+ expect(onRequestClose).toHaveBeenCalledTimes(1);
67
+ });
68
+
69
+ it("multi pick toggles the value and stays open", () => {
70
+ const { view, onValueChange, onRequestClose } = setupMulti({ value: [] });
71
+ act(() => view.result.current.pickRow(0));
72
+ expect(onValueChange).toHaveBeenCalledWith(["a"]);
73
+ expect(onRequestClose).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("multi pick removes an already-selected value", () => {
77
+ const { view, onValueChange } = setupMulti({ value: ["a", "b"] });
78
+ act(() => view.result.current.pickRow(0)); // toggle off "a"
79
+ expect(onValueChange).toHaveBeenCalledWith(["b"]);
80
+ });
81
+
82
+ it("internal search filters the rows locally", () => {
83
+ const { view } = setupSingle({ search: { mode: "internal" } });
84
+ act(() => view.result.current.setQuery("ap"));
85
+ expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["a"]);
86
+ expect(view.result.current.searching).toBe(true);
87
+ });
88
+
89
+ it("allowCustom adds a create row for an unmatched query; picking it commits the raw query", () => {
90
+ const { view, onCustomCommit } = setupSingle({
91
+ search: { mode: "internal" },
92
+ allowCustom: true,
93
+ });
94
+ act(() => view.result.current.setQuery("kiwi"));
95
+ const rows = view.result.current.rows;
96
+ const custom = rows.find((r) => r.kind === "custom");
97
+ expect(custom).toBeTruthy();
98
+ expect(custom?.option.label).toBe('Add "kiwi"');
99
+ act(() => view.result.current.pickRow(custom!.index));
100
+ expect(onCustomCommit).toHaveBeenCalledWith("kiwi");
101
+ });
102
+
103
+ it("keeps the active row on the first match, never the create row (placement top)", () => {
104
+ const { view } = setupSingle({
105
+ search: { mode: "internal" },
106
+ allowCustom: true,
107
+ customOptionPlacement: "top",
108
+ });
109
+ act(() => view.result.current.setQuery("ap")); // create row + the Apple match
110
+ const rows = view.result.current.rows;
111
+ expect(rows[0].kind).toBe("custom");
112
+ expect(rows[view.result.current.activeIndex].kind).toBe("option");
113
+ });
114
+
115
+ it("includeEmptyOption prepends a clear row that empties the value", () => {
116
+ const { view, onValueChange, onRequestClose } = setupSingle({
117
+ value: "a",
118
+ includeEmptyOption: true,
119
+ });
120
+ expect(view.result.current.rows[0].kind).toBe("empty");
121
+ act(() => view.result.current.pickRow(0));
122
+ expect(onValueChange).toHaveBeenCalledWith("");
123
+ expect(onRequestClose).toHaveBeenCalledTimes(1);
124
+ });
125
+
126
+ it("select-all / deselect-all emit the full set and the empty set", () => {
127
+ const { view, onValueChange } = setupMulti({ value: ["a"], enableSelectAll: true });
128
+ expect(view.result.current.showSelectAll).toBe(true);
129
+ expect(view.result.current.showDeselectAll).toBe(true);
130
+ act(() => view.result.current.selectAll());
131
+ expect(onValueChange).toHaveBeenCalledWith(["a", "b", "c"]);
132
+ act(() => view.result.current.deselectAll());
133
+ expect(onValueChange).toHaveBeenLastCalledWith([]);
134
+ });
135
+
136
+ it("a none-mode multi toggle keeps the active row where it is (no re-seat on selection)", () => {
137
+ const onValueChange = vi.fn();
138
+ const params: UseOptionListParams<string, true> = {
139
+ options: OPTS,
140
+ multi: true,
141
+ search: { mode: "none" },
142
+ value: ["a"],
143
+ onValueChange,
144
+ };
145
+ const view = renderHook((p: UseOptionListParams<string, true>) => useOptionList(p), {
146
+ initialProps: params,
147
+ });
148
+ act(() => view.result.current.setActiveIndex(2)); // arrow down to "c"
149
+ act(() => view.result.current.pickRow(2)); // toggle "c" on, popover stays open
150
+ expect(onValueChange).toHaveBeenCalledWith(["a", "c"]);
151
+ view.rerender({ ...params, value: ["a", "c"] }); // consumer applies the new value
152
+ // Regression: must NOT jump back to the first selected option.
153
+ expect(view.result.current.activeIndex).toBe(2);
154
+ });
155
+
156
+ it("re-seats the active row to the first match when the search query changes", () => {
157
+ const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
158
+ initialProps: { options: OPTS, search: { mode: "internal" }, onValueChange: vi.fn() },
159
+ });
160
+ act(() => view.result.current.setActiveIndex(2));
161
+ expect(view.result.current.activeIndex).toBe(2);
162
+ act(() => view.result.current.setQuery("ban")); // filters to "Banana"
163
+ expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["b"]);
164
+ expect(view.result.current.activeIndex).toBe(0);
165
+ });
166
+
167
+ it("matches options accent-insensitively (cafe -> Café, dieu -> Điều)", () => {
168
+ const view = renderHook((p: UseOptionListParams<string, false>) => useOptionList(p), {
169
+ initialProps: {
170
+ options: [
171
+ { value: "cf", label: "Café" },
172
+ { value: "dd", label: "Điều độ" },
173
+ { value: "x", label: "Other" },
174
+ ],
175
+ search: { mode: "internal" },
176
+ onValueChange: vi.fn(),
177
+ },
178
+ });
179
+ act(() => view.result.current.setQuery("dieu"));
180
+ expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["dd"]);
181
+ act(() => view.result.current.setQuery("cafe"));
182
+ expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["cf"]);
183
+ });
184
+
185
+ it("controlled server-filtered mode shows options as-is (no local re-filter)", () => {
186
+ const { view } = setupSingle({
187
+ // The server already filtered to one result; the hook must not re-filter it out.
188
+ options: [{ value: "z", label: "Zucchini" }],
189
+ search: { mode: "controlled", query: "xyz", serverFiltered: true },
190
+ });
191
+ expect(view.result.current.rows.map((r) => r.option.value)).toEqual(["z"]);
192
+ });
193
+ });