Skip to content

Commit 4565baa

Browse files
fix(combobox): make TriggerInput caret button clickable (#387)
1 parent 4dfdc3f commit 4565baa

File tree

3 files changed

+289
-7
lines changed

3 files changed

+289
-7
lines changed

.changeset/fix-combobox-trigger.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/kumo": patch
3+
---
4+
5+
fix(combobox): make TriggerInput caret button clickable for Playwright tests
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import { describe, test, expect } from "vitest";
2+
import { render } from "vitest-browser-react";
3+
import { Combobox } from "./combobox";
4+
5+
const fruits = ["Apple", "Banana", "Cherry", "Date", "Elderberry"];
6+
7+
describe("Combobox Playwright Interactions", () => {
8+
describe("TriggerInput", () => {
9+
test("clicking the input opens the listbox", async () => {
10+
const { getByPlaceholder, getByRole } = await render(
11+
<Combobox items={fruits}>
12+
<Combobox.TriggerInput placeholder="Search fruits..." />
13+
<Combobox.Content>
14+
<Combobox.List>
15+
{(item: string) => (
16+
<Combobox.Item key={item} value={item}>
17+
{item}
18+
</Combobox.Item>
19+
)}
20+
</Combobox.List>
21+
</Combobox.Content>
22+
</Combobox>,
23+
);
24+
25+
// Listbox should not be visible initially
26+
await expect.element(getByRole("listbox")).not.toBeInTheDocument();
27+
28+
// Click the input
29+
await getByPlaceholder("Search fruits...").click();
30+
31+
// Listbox should now be visible
32+
await expect.element(getByRole("listbox")).toBeVisible();
33+
});
34+
35+
test("focusing the input does NOT open the listbox (click required)", async () => {
36+
const { getByPlaceholder, getByRole } = await render(
37+
<Combobox items={fruits}>
38+
<Combobox.TriggerInput placeholder="Search fruits..." />
39+
<Combobox.Content>
40+
<Combobox.List>
41+
{(item: string) => (
42+
<Combobox.Item key={item} value={item}>
43+
{item}
44+
</Combobox.Item>
45+
)}
46+
</Combobox.List>
47+
</Combobox.Content>
48+
</Combobox>,
49+
);
50+
51+
// Focus the input (use native focus on the element)
52+
getByPlaceholder("Search fruits...").element().focus();
53+
54+
// Listbox should NOT be visible - focus alone doesn't open it
55+
// This is expected Base UI behavior: openOnInputClick applies to click, not focus
56+
await expect.element(getByRole("listbox")).not.toBeInTheDocument();
57+
});
58+
59+
test("typing in the input opens the listbox and filters items", async () => {
60+
const { getByPlaceholder, getByRole } = await render(
61+
<Combobox items={fruits}>
62+
<Combobox.TriggerInput placeholder="Search fruits..." />
63+
<Combobox.Content>
64+
<Combobox.List>
65+
{(item: string) => (
66+
<Combobox.Item key={item} value={item}>
67+
{item}
68+
</Combobox.Item>
69+
)}
70+
</Combobox.List>
71+
</Combobox.Content>
72+
</Combobox>,
73+
);
74+
75+
// Type in the input
76+
await getByPlaceholder("Search fruits...").fill("ban");
77+
78+
// Listbox should be visible
79+
await expect.element(getByRole("listbox")).toBeVisible();
80+
81+
// Should show filtered result
82+
await expect
83+
.element(getByRole("option", { name: "Banana" }))
84+
.toBeVisible();
85+
});
86+
87+
test("clicking an option selects it", async () => {
88+
const { getByPlaceholder, getByRole } = await render(
89+
<Combobox items={fruits}>
90+
<Combobox.TriggerInput placeholder="Search fruits..." />
91+
<Combobox.Content>
92+
<Combobox.List>
93+
{(item: string) => (
94+
<Combobox.Item key={item} value={item}>
95+
{item}
96+
</Combobox.Item>
97+
)}
98+
</Combobox.List>
99+
</Combobox.Content>
100+
</Combobox>,
101+
);
102+
103+
// Open by clicking input
104+
await getByPlaceholder("Search fruits...").click();
105+
106+
// Click an option
107+
await getByRole("option", { name: "Cherry" }).click();
108+
109+
// Input should now contain the selected value
110+
const input = getByPlaceholder("Search fruits...");
111+
await expect.element(input).toHaveValue("Cherry");
112+
});
113+
114+
test("trigger button (caret icon) can be clicked to open listbox", async () => {
115+
const { getByPlaceholder, getByRole } = await render(
116+
<Combobox items={fruits}>
117+
<Combobox.TriggerInput placeholder="Search fruits..." />
118+
<Combobox.Content>
119+
<Combobox.List>
120+
{(item: string) => (
121+
<Combobox.Item key={item} value={item}>
122+
{item}
123+
</Combobox.Item>
124+
)}
125+
</Combobox.List>
126+
</Combobox.Content>
127+
</Combobox>,
128+
);
129+
130+
// Find the trigger button - it's a button element inside the combobox wrapper
131+
// The trigger wraps the caret icon
132+
const inputWrapper =
133+
getByPlaceholder("Search fruits...").element().parentElement;
134+
const triggerButton = inputWrapper?.querySelector("button");
135+
136+
expect(triggerButton, "trigger button should exist").toBeTruthy();
137+
138+
// Click the trigger button
139+
triggerButton!.click();
140+
141+
// Wait for listbox to appear
142+
await expect.element(getByRole("listbox")).toBeVisible();
143+
});
144+
145+
test("trigger button can be located with Playwright locator pattern", async () => {
146+
const { getByPlaceholder, getByRole } = await render(
147+
<Combobox items={fruits}>
148+
<Combobox.TriggerInput placeholder="Search fruits..." />
149+
<Combobox.Content>
150+
<Combobox.List>
151+
{(item: string) => (
152+
<Combobox.Item key={item} value={item}>
153+
{item}
154+
</Combobox.Item>
155+
)}
156+
</Combobox.List>
157+
</Combobox.Content>
158+
</Combobox>,
159+
);
160+
161+
// This mimics what a Playwright user would do:
162+
// await page.getByPlaceholder('Search fruits...').locator('..').getByRole('button').click()
163+
const input = getByPlaceholder("Search fruits...").element();
164+
const wrapper = input.parentElement!;
165+
const button = wrapper.querySelector("button");
166+
167+
expect(button, "should find button sibling to input").toBeTruthy();
168+
169+
// Verify clicking the button opens the listbox
170+
button!.click();
171+
await expect.element(getByRole("listbox")).toBeVisible();
172+
});
173+
174+
test("trigger button can be located with CSS adjacent sibling selector (+button)", async () => {
175+
const { getByPlaceholder, getByRole } = await render(
176+
<Combobox items={fruits}>
177+
<Combobox.TriggerInput placeholder="Search fruits..." />
178+
<Combobox.Content>
179+
<Combobox.List>
180+
{(item: string) => (
181+
<Combobox.Item key={item} value={item}>
182+
{item}
183+
</Combobox.Item>
184+
)}
185+
</Combobox.List>
186+
</Combobox.Content>
187+
</Combobox>,
188+
);
189+
190+
// Scott's pattern: page.getByPlaceholder('IATA').locator('+button')
191+
// This uses CSS adjacent sibling selector
192+
const input = getByPlaceholder("Search fruits...").element();
193+
194+
// CSS: input + button (adjacent sibling)
195+
// NOTE: There's also a Clear button before the Trigger button!
196+
// The order in DOM is: input, [Clear button], Trigger button
197+
const adjacentButton =
198+
input.parentElement?.querySelector("input + button");
199+
200+
expect(
201+
adjacentButton,
202+
"should find button via CSS adjacent sibling selector (input + button)",
203+
).toBeTruthy();
204+
205+
// Verify clicking the button opens the listbox
206+
adjacentButton!.click();
207+
await expect.element(getByRole("listbox")).toBeVisible();
208+
});
209+
210+
test("trigger button has zero size due to p-0 and absolute icon positioning", async () => {
211+
const { getByPlaceholder } = await render(
212+
<Combobox items={fruits}>
213+
<Combobox.TriggerInput placeholder="Search fruits..." />
214+
<Combobox.Content>
215+
<Combobox.List>
216+
{(item: string) => (
217+
<Combobox.Item key={item} value={item}>
218+
{item}
219+
</Combobox.Item>
220+
)}
221+
</Combobox.List>
222+
</Combobox.Content>
223+
</Combobox>,
224+
);
225+
226+
const input = getByPlaceholder("Search fruits...").element();
227+
const wrapper = input.parentElement!;
228+
229+
// Find the trigger button (last button in wrapper)
230+
const buttons = wrapper.querySelectorAll("button");
231+
const triggerButton = buttons[buttons.length - 1];
232+
233+
const rect = triggerButton.getBoundingClientRect();
234+
235+
// Document the issue: button has p-0 and its icon is absolutely positioned
236+
// relative to the wrapper, so the button itself may have minimal/zero size
237+
console.log("Trigger button dimensions:", {
238+
width: rect.width,
239+
height: rect.height,
240+
classList: triggerButton.className,
241+
});
242+
243+
// The button exists but may have very small dimensions
244+
expect(triggerButton).toBeTruthy();
245+
});
246+
});
247+
248+
describe("TriggerValue", () => {
249+
test("clicking TriggerValue opens the listbox", async () => {
250+
const { getByRole } = await render(
251+
<Combobox items={fruits} defaultValue="Apple">
252+
<Combobox.TriggerValue placeholder="Select a fruit" />
253+
<Combobox.Content>
254+
<Combobox.List>
255+
{(item: string) => (
256+
<Combobox.Item key={item} value={item}>
257+
{item}
258+
</Combobox.Item>
259+
)}
260+
</Combobox.List>
261+
</Combobox.Content>
262+
</Combobox>,
263+
);
264+
265+
// TriggerValue renders as a button with role="combobox"
266+
const trigger = getByRole("combobox");
267+
await expect.element(trigger).toBeVisible();
268+
269+
// Click to open
270+
await trigger.click();
271+
272+
// Listbox should be visible
273+
await expect.element(getByRole("listbox")).toBeVisible();
274+
});
275+
});
276+
});

packages/kumo/src/components/combobox/combobox.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -340,13 +340,14 @@ function TriggerInput(props: ComboboxBase.Input.Props) {
340340
<XIcon size={iconStyles.iconSize} />
341341
</ComboboxBase.Clear>
342342

343-
<ComboboxBase.Trigger className="p-0">
344-
<ComboboxBase.Icon
345-
className={cn(
346-
"absolute top-1/2 flex -translate-y-1/2 cursor-pointer text-kumo-subtle",
347-
iconStyles.caretRight,
348-
)}
349-
>
343+
<ComboboxBase.Trigger
344+
className={cn(
345+
"absolute top-1/2 -translate-y-1/2 flex items-center justify-center cursor-pointer text-kumo-subtle",
346+
"m-0 bg-transparent p-0", // Reset Stratus global button styles
347+
iconStyles.caretRight,
348+
)}
349+
>
350+
<ComboboxBase.Icon>
350351
<CaretDownIcon size={iconStyles.iconSize} className="fill-current" />
351352
</ComboboxBase.Icon>
352353
</ComboboxBase.Trigger>

0 commit comments

Comments
 (0)