Skip to content

Commit 10907ea

Browse files
committed
Add tests
1 parent 3352778 commit 10907ea

File tree

4 files changed

+944
-0
lines changed

4 files changed

+944
-0
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { renderHook, act } from "@testing-library/react";
2+
import { UseComboboxStateChange } from "downshift";
3+
import { describe, it, expect } from "vitest";
4+
5+
import { useHighlightedIndex } from "./useHighlightedIndex";
6+
import { Option } from "./types";
7+
8+
// Helper to create a state change object with just highlightedIndex
9+
const createStateChange = (
10+
highlightedIndex: number
11+
): UseComboboxStateChange<Option> =>
12+
({ highlightedIndex }) as UseComboboxStateChange<Option>;
13+
14+
describe("useHighlightedIndex", () => {
15+
const createOptions = (count: number): Option[] =>
16+
Array.from({ length: count }, (_, i) => ({
17+
value: `value-${i}`,
18+
label: `Label ${i}`,
19+
}));
20+
21+
describe("initial state", () => {
22+
it("returns -1 when selectedItem is null", () => {
23+
const items = createOptions(3);
24+
const { result } = renderHook(() => useHighlightedIndex(items, null));
25+
26+
expect(result.current.highlightedIndex).toBe(-1);
27+
});
28+
29+
it("returns -1 when selectedItem is undefined", () => {
30+
const items = createOptions(3);
31+
const { result } = renderHook(() =>
32+
useHighlightedIndex(items, undefined)
33+
);
34+
35+
expect(result.current.highlightedIndex).toBe(-1);
36+
});
37+
38+
it("returns -1 when items array is empty", () => {
39+
const { result } = renderHook(() =>
40+
useHighlightedIndex([], { value: "test", label: "Test" })
41+
);
42+
43+
expect(result.current.highlightedIndex).toBe(-1);
44+
});
45+
});
46+
47+
describe("with selectedItem", () => {
48+
it("returns the index of selectedItem in items array", () => {
49+
const items = createOptions(5);
50+
const selectedItem = items[2];
51+
52+
const { result } = renderHook(() =>
53+
useHighlightedIndex(items, selectedItem)
54+
);
55+
56+
expect(result.current.highlightedIndex).toBe(2);
57+
});
58+
59+
it("returns -1 when selectedItem is not in items array", () => {
60+
const items = createOptions(3);
61+
const selectedItem = { value: "not-in-list", label: "Not Found" };
62+
63+
const { result } = renderHook(() =>
64+
useHighlightedIndex(items, selectedItem)
65+
);
66+
67+
expect(result.current.highlightedIndex).toBe(-1);
68+
});
69+
70+
it("matches by value property, not by reference", () => {
71+
const items = createOptions(3);
72+
const selectedItem = { value: "value-1", label: "Different Label" };
73+
74+
const { result } = renderHook(() =>
75+
useHighlightedIndex(items, selectedItem)
76+
);
77+
78+
expect(result.current.highlightedIndex).toBe(1);
79+
});
80+
});
81+
82+
describe("dynamic updates (API data arrives)", () => {
83+
it("updates highlighted index when items array changes", () => {
84+
const selectedItem = { value: "value-2", label: "Label 2" };
85+
86+
// Start with empty items (simulating loading state)
87+
const { result, rerender } = renderHook(
88+
({ items, selectedItem }) => useHighlightedIndex(items, selectedItem),
89+
{ initialProps: { items: [] as Option[], selectedItem } }
90+
);
91+
92+
expect(result.current.highlightedIndex).toBe(-1);
93+
94+
// Items arrive from API
95+
const newItems = createOptions(5);
96+
rerender({ items: newItems, selectedItem });
97+
98+
expect(result.current.highlightedIndex).toBe(2);
99+
});
100+
101+
it("updates highlighted index when selectedItem changes", () => {
102+
const items = createOptions(5);
103+
104+
const { result, rerender } = renderHook(
105+
({ items, selectedItem }) => useHighlightedIndex(items, selectedItem),
106+
{ initialProps: { items, selectedItem: items[1] } }
107+
);
108+
109+
expect(result.current.highlightedIndex).toBe(1);
110+
111+
// User selects a different item
112+
rerender({ items, selectedItem: items[3] });
113+
114+
expect(result.current.highlightedIndex).toBe(3);
115+
});
116+
117+
it("updates to -1 when selectedItem becomes null", () => {
118+
const items = createOptions(3);
119+
120+
const { result, rerender } = renderHook(
121+
({ items, selectedItem }) => useHighlightedIndex(items, selectedItem),
122+
{ initialProps: { items, selectedItem: items[1] as Option | null } }
123+
);
124+
125+
expect(result.current.highlightedIndex).toBe(1);
126+
127+
rerender({ items, selectedItem: null });
128+
129+
expect(result.current.highlightedIndex).toBe(-1);
130+
});
131+
});
132+
133+
describe("onHighlightedIndexChange handler", () => {
134+
it("updates highlighted index when called with new index", () => {
135+
const items = createOptions(5);
136+
137+
const { result } = renderHook(() => useHighlightedIndex(items, items[0]));
138+
139+
expect(result.current.highlightedIndex).toBe(0);
140+
141+
// Simulate keyboard navigation
142+
act(() => {
143+
result.current.onHighlightedIndexChange(createStateChange(3));
144+
});
145+
146+
expect(result.current.highlightedIndex).toBe(3);
147+
});
148+
149+
it("preserves user-set index across rerenders with same props", () => {
150+
const items = createOptions(5);
151+
152+
const { result, rerender } = renderHook(
153+
({ items, selectedItem }) => useHighlightedIndex(items, selectedItem),
154+
{ initialProps: { items, selectedItem: items[0] } }
155+
);
156+
157+
// User navigates to index 3
158+
act(() => {
159+
result.current.onHighlightedIndexChange(createStateChange(3));
160+
});
161+
162+
expect(result.current.highlightedIndex).toBe(3);
163+
164+
// Rerender with same props
165+
rerender({ items, selectedItem: items[0] });
166+
167+
// User's choice should be preserved
168+
expect(result.current.highlightedIndex).toBe(3);
169+
});
170+
171+
it("resets to derived index when handler is called with -1 and selectedItem exists", () => {
172+
const items = createOptions(5);
173+
const selectedItem = items[2];
174+
175+
const { result } = renderHook(() =>
176+
useHighlightedIndex(items, selectedItem)
177+
);
178+
179+
// User navigates to index 4
180+
act(() => {
181+
result.current.onHighlightedIndexChange(createStateChange(4));
182+
});
183+
184+
expect(result.current.highlightedIndex).toBe(4);
185+
186+
// Downshift resets to -1 (e.g., when menu closes and reopens)
187+
act(() => {
188+
result.current.onHighlightedIndexChange(createStateChange(-1));
189+
});
190+
191+
// Should fall back to derived index (selectedItem's position)
192+
expect(result.current.highlightedIndex).toBe(2);
193+
});
194+
195+
it("sets to -1 when handler is called with -1 and no selectedItem", () => {
196+
const items = createOptions(5);
197+
198+
const { result } = renderHook(() => useHighlightedIndex(items, null));
199+
200+
// User navigates to index 2
201+
act(() => {
202+
result.current.onHighlightedIndexChange(createStateChange(2));
203+
});
204+
205+
expect(result.current.highlightedIndex).toBe(2);
206+
207+
// Downshift resets to -1
208+
act(() => {
209+
result.current.onHighlightedIndexChange(createStateChange(-1));
210+
});
211+
212+
// No selectedItem, so stays at -1
213+
expect(result.current.highlightedIndex).toBe(-1);
214+
});
215+
});
216+
217+
describe("edge cases", () => {
218+
it("handles string selectedItem (for legacy compatibility)", () => {
219+
const items = createOptions(3);
220+
// TypeScript allows string as T extends Option, and code handles it
221+
const selectedItem = "value-1" as unknown as Option;
222+
223+
const { result } = renderHook(() =>
224+
useHighlightedIndex(items, selectedItem)
225+
);
226+
227+
expect(result.current.highlightedIndex).toBe(1);
228+
});
229+
230+
it("handles items with duplicate values (returns first match)", () => {
231+
const items: Option[] = [
232+
{ value: "a", label: "First A" },
233+
{ value: "b", label: "B" },
234+
{ value: "a", label: "Second A" },
235+
];
236+
const selectedItem = { value: "a", label: "Any A" };
237+
238+
const { result } = renderHook(() =>
239+
useHighlightedIndex(items, selectedItem)
240+
);
241+
242+
// Should return index of first match
243+
expect(result.current.highlightedIndex).toBe(0);
244+
});
245+
});
246+
});

0 commit comments

Comments
 (0)