Skip to content

Commit bd23942

Browse files
committed
fix(ui): dropdown clickable options
1 parent 4b26cf0 commit bd23942

3 files changed

Lines changed: 96 additions & 13 deletions

File tree

packages/ui/src/Dropdown/browser.test.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ test("Dropdown: шеврон открывает список, aria-expanded пе
3232
await expect.element(combobox).toHaveAttribute("aria-expanded", "false");
3333
});
3434

35+
test("Dropdown: клик по пункту списка выбирает значение", async () => {
36+
const { screen } = mountBrowser(
37+
<Dropdown<string> items={["a", "b", "c"]} value="a" label="Выбор значения" />,
38+
);
39+
await screen.getByRole("button", { name: "Открыть список" }).click();
40+
// Реальный клик по телу пункта: значение меняется, список закрывается
41+
await screen.getByRole("option").nth(1).click();
42+
await expect.element(screen.getByRole("combobox")).toHaveValue("b");
43+
await expect.element(screen.getByRole("listbox")).not.toBeInTheDocument();
44+
});
45+
3546
test("Dropdown: ARIA-снапшот базового состояния", async () => {
3647
const { screen } = mountBrowser(
3748
<Dropdown<string> items={["a", "b"]} value="a" label="Выбор значения" />,

packages/ui/src/Dropdown/index.tsx

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,11 @@ const DropdownContext: Context<DropdownContextInterface<any>> = React.createCont
3232
let listboxIdCounter = 0;
3333

3434
// ── Пункт списка ──
35-
// a11y (stage 4): обёртка пункта неинтерактивна (без tabIndex/onClick/onKeyDown) —
36-
// nested-interactive: интерактивный контрол в строке — кнопки внутри renderItem,
37-
// клик по телу пункта ничего не выбирает.
35+
// Клик по телу пункта выбирает значение (disabled-пункт кликом не выбирается).
36+
// Клик-выбору есть клавиатурный эквивалент: пункт фокусируем (tabIndex=0)
37+
// и выбирает его Enter/Space (a11y useKeyWithClickEvents). Кнопки внутри
38+
// renderItem остаются своими: их обработчики делают stopPropagation, так
39+
// что клик по контролу строки пункт не выбирает.
3840
function DropdownItem<T>(props: { value: T; index: number; nodeRef?: React.Ref<HTMLDivElement> }) {
3941
const { value, index, nodeRef } = props;
4042
const ctx = useContext(DropdownContext);
@@ -49,10 +51,21 @@ function DropdownItem<T>(props: { value: T; index: number; nodeRef?: React.Ref<H
4951
};
5052

5153
return (
52-
// biome-ignore lint/a11y/useFocusableInteractive: пункт вне tab-порядка (axe nested-interactive)
5354
<div
5455
ref={nodeRef}
5556
role="option"
57+
tabIndex={disabled ? undefined : 0}
58+
onClick={disabled ? undefined : () => ctx.onItemClick?.(value)}
59+
// a11y: у пункта с onClick есть клавиатурный эквивалент (Enter/Space).
60+
onKeyDown={(event) => {
61+
if (disabled) {
62+
return;
63+
}
64+
if (event.key === "Enter" || event.key === " ") {
65+
event.preventDefault();
66+
ctx.onItemClick?.(value);
67+
}
68+
}}
5669
className={cx("ui-dropdown-item", disabled && "ui-dropdown-item--disabled")}
5770
>
5871
{renderItem()}

packages/ui/src/Dropdown/test.tsx

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import { fireEvent, render, screen } from "@testing-library/react";
22
import React from "react";
33
import { Dropdown } from "./index";
44

5+
// Значение инпута-комбобокса (getByRole возвращает HTMLElement).
6+
const comboboxValue = () => (screen.getByRole("combobox") as HTMLInputElement).value;
7+
58
test("Dropdown smoke: дропдаун рендерит выбранное значение", () => {
69
const { container } = render(<Dropdown<string> items={["a", "b"]} value="a" />);
710
const input = container.querySelector("input");
@@ -49,14 +52,70 @@ test("Dropdown: у listbox есть пункты role=option прямыми де
4952
}
5053
});
5154

52-
// a11y (stage 4): nested-interactive — пункт не самостоятельный интерактивный
53-
// элемент: у обёртки нет tabindex, клик по телу пункта ничего не выбирает
54-
// (интерактивные контролы — кнопки внутри строк).
55-
test("Dropdown: пункты не фокусируемы и не выбираются кликом по телу", () => {
56-
render(<Dropdown<string> items={["a", "b"]} value="a" />);
55+
// Кликабельность пунктов: клик по телу пункта выбирает значение — инпут
56+
// показывает выбранное, onChange вызывается, список закрывается.
57+
test("Dropdown: клик по пункту выбирает значение и закрывает список", () => {
58+
const onChange = vi.fn();
59+
render(<Dropdown<string> items={["a", "b", "c"]} value="a" onChange={onChange} />);
5760
fireEvent.click(screen.getByRole("button", { name: "Открыть список" }));
58-
const option = screen.getAllByRole("option")[0];
59-
expect(option.hasAttribute("tabindex")).toBe(false);
60-
fireEvent.click(option);
61-
expect(screen.getByRole("listbox")).not.toBeNull();
61+
fireEvent.click(screen.getAllByRole("option")[1]);
62+
expect(onChange).toHaveBeenCalledWith("b");
63+
const input = screen.getByRole("combobox") as HTMLInputElement;
64+
expect(input.value).toBe("b");
65+
expect(screen.queryByRole("listbox")).toBeNull();
66+
});
67+
68+
// a11y (useKeyWithClickEvents): у пункта с onClick есть клавиатурный
69+
// эквивалент — пункт фокусируем (tabIndex) и выбирается по Enter/Space.
70+
test("Dropdown: пункт фокусируем и выбирается по Enter", () => {
71+
const onChange = vi.fn();
72+
render(<Dropdown<string> items={["a", "b"]} value="a" onChange={onChange} />);
73+
fireEvent.click(screen.getByRole("button", { name: "Открыть список" }));
74+
const option = screen.getAllByRole("option")[1];
75+
expect(option.getAttribute("tabindex")).toBe("0");
76+
fireEvent.keyDown(option, { key: "Enter" });
77+
expect(onChange).toHaveBeenCalledWith("b");
78+
expect(comboboxValue()).toBe("b");
79+
});
80+
81+
// Отключённый пункт (checkDisabledItem) кликом не выбирается.
82+
test("Dropdown: клик по отключённому пункту ничего не выбирает", () => {
83+
const onChange = vi.fn();
84+
render(
85+
<Dropdown<string>
86+
items={["a", "b"]}
87+
value="a"
88+
onChange={onChange}
89+
checkDisabledItem={(item) => item === "b"}
90+
/>,
91+
);
92+
fireEvent.click(screen.getByRole("button", { name: "Открыть список" }));
93+
fireEvent.click(screen.getAllByRole("option")[1]);
94+
expect(onChange).not.toHaveBeenCalled();
95+
expect(comboboxValue()).toBe("a");
96+
});
97+
98+
// Пункты с собственными контролами (renderItem): их обработчики делают
99+
// stopPropagation — клик по кнопке внутри строки не выбирает пункт.
100+
test("Dropdown: кнопка внутри renderItem не выбирает пункт", () => {
101+
const onChange = vi.fn();
102+
render(
103+
<Dropdown<string>
104+
items={["a", "b"]}
105+
value="a"
106+
onChange={onChange}
107+
renderItem={({ item }) => (
108+
<span>
109+
{item}
110+
<button type="button" onClick={(event) => event.stopPropagation()}>
111+
x
112+
</button>
113+
</span>
114+
)}
115+
/>,
116+
);
117+
fireEvent.click(screen.getByRole("button", { name: "Открыть список" }));
118+
fireEvent.click(screen.getAllByRole("option")[1].querySelector("button")!);
119+
expect(onChange).not.toHaveBeenCalled();
120+
expect(comboboxValue()).toBe("a");
62121
});

0 commit comments

Comments
 (0)