@zentauri-ui/zentauri-components 1.9.2 → 2.0.0
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/CHANGELOG.md +17 -0
- package/README.md +32 -5
- package/cli/registry.json +14 -0
- package/dist/{chunk-L4PDJ6IB.mjs → chunk-44NX3DAZ.mjs} +3 -3
- package/dist/{chunk-L4PDJ6IB.mjs.map → chunk-44NX3DAZ.mjs.map} +1 -1
- package/dist/chunk-FAMHSJTK.js +19 -0
- package/dist/{chunk-AQHY4S33.js.map → chunk-FAMHSJTK.js.map} +1 -1
- package/dist/chunk-I42UYWYA.mjs +128 -0
- package/dist/chunk-I42UYWYA.mjs.map +1 -0
- package/dist/{chunk-5J6QMTES.js → chunk-IKXO5SJ4.js} +21 -5
- package/dist/chunk-IKXO5SJ4.js.map +1 -0
- package/dist/{chunk-OPUO55TO.mjs → chunk-JXSM2EHC.mjs} +3 -3
- package/dist/{chunk-OPUO55TO.mjs.map → chunk-JXSM2EHC.mjs.map} +1 -1
- package/dist/{chunk-LQPKZ5ZD.js → chunk-LS4GY2ZQ.js} +6 -6
- package/dist/{chunk-LQPKZ5ZD.js.map → chunk-LS4GY2ZQ.js.map} +1 -1
- package/dist/{chunk-VIKQGO4W.mjs → chunk-VQQHVKEU.mjs} +21 -5
- package/dist/{chunk-VIKQGO4W.mjs.map → chunk-VQQHVKEU.mjs.map} +1 -1
- package/dist/chunk-ZVRGLG35.js +144 -0
- package/dist/chunk-ZVRGLG35.js.map +1 -0
- package/dist/design-system/combobox.d.ts +124 -0
- package/dist/design-system/combobox.d.ts.map +1 -0
- package/dist/design-system/facade.js +6 -5
- package/dist/design-system/facade.js.map +1 -1
- package/dist/design-system/facade.mjs +5 -4
- package/dist/design-system/facade.mjs.map +1 -1
- package/dist/design-system/index.d.ts +1 -0
- package/dist/design-system/index.d.ts.map +1 -1
- package/dist/hooks/index.d.ts +13 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/useCookie/index.d.ts +2 -0
- package/dist/hooks/useCookie/index.d.ts.map +1 -0
- package/dist/hooks/useCookie/useCookie.d.ts +36 -0
- package/dist/hooks/useCookie/useCookie.d.ts.map +1 -0
- package/dist/hooks/useCookie.js +82 -0
- package/dist/hooks/useCookie.js.map +1 -0
- package/dist/hooks/useCookie.mjs +80 -0
- package/dist/hooks/useCookie.mjs.map +1 -0
- package/dist/hooks/useCountdown/index.d.ts +2 -0
- package/dist/hooks/useCountdown/index.d.ts.map +1 -0
- package/dist/hooks/useCountdown/useCountdown.d.ts +40 -0
- package/dist/hooks/useCountdown/useCountdown.d.ts.map +1 -0
- package/dist/hooks/useCountdown.js +60 -0
- package/dist/hooks/useCountdown.js.map +1 -0
- package/dist/hooks/useCountdown.mjs +58 -0
- package/dist/hooks/useCountdown.mjs.map +1 -0
- package/dist/hooks/useEventListener/index.d.ts +2 -0
- package/dist/hooks/useEventListener/index.d.ts.map +1 -0
- package/dist/hooks/useEventListener/useEventListener.d.ts +22 -0
- package/dist/hooks/useEventListener/useEventListener.d.ts.map +1 -0
- package/dist/hooks/useEventListener.js +45 -0
- package/dist/hooks/useEventListener.js.map +1 -0
- package/dist/hooks/useEventListener.mjs +43 -0
- package/dist/hooks/useEventListener.mjs.map +1 -0
- package/dist/hooks/useGeolocation/index.d.ts +2 -0
- package/dist/hooks/useGeolocation/index.d.ts.map +1 -0
- package/dist/hooks/useGeolocation/useGeolocation.d.ts +48 -0
- package/dist/hooks/useGeolocation/useGeolocation.d.ts.map +1 -0
- package/dist/hooks/useGeolocation.js +111 -0
- package/dist/hooks/useGeolocation.js.map +1 -0
- package/dist/hooks/useGeolocation.mjs +109 -0
- package/dist/hooks/useGeolocation.mjs.map +1 -0
- package/dist/hooks/useHotkeys/index.d.ts +2 -0
- package/dist/hooks/useHotkeys/index.d.ts.map +1 -0
- package/dist/hooks/useHotkeys/useHotkeys.d.ts +24 -0
- package/dist/hooks/useHotkeys/useHotkeys.d.ts.map +1 -0
- package/dist/hooks/useHotkeys.js +86 -0
- package/dist/hooks/useHotkeys.js.map +1 -0
- package/dist/hooks/useHotkeys.mjs +84 -0
- package/dist/hooks/useHotkeys.mjs.map +1 -0
- package/dist/hooks/useIdleTimeout/index.d.ts +2 -0
- package/dist/hooks/useIdleTimeout/index.d.ts.map +1 -0
- package/dist/hooks/useIdleTimeout/useIdleTimeout.d.ts +31 -0
- package/dist/hooks/useIdleTimeout/useIdleTimeout.d.ts.map +1 -0
- package/dist/hooks/useIdleTimeout.js +77 -0
- package/dist/hooks/useIdleTimeout.js.map +1 -0
- package/dist/hooks/useIdleTimeout.mjs +75 -0
- package/dist/hooks/useIdleTimeout.mjs.map +1 -0
- package/dist/hooks/useInterval/index.d.ts +2 -0
- package/dist/hooks/useInterval/index.d.ts.map +1 -0
- package/dist/hooks/useInterval/useInterval.d.ts +12 -0
- package/dist/hooks/useInterval/useInterval.d.ts.map +1 -0
- package/dist/hooks/useInterval.js +27 -0
- package/dist/hooks/useInterval.js.map +1 -0
- package/dist/hooks/useInterval.mjs +25 -0
- package/dist/hooks/useInterval.mjs.map +1 -0
- package/dist/hooks/useKeyPress/index.d.ts +2 -0
- package/dist/hooks/useKeyPress/index.d.ts.map +1 -0
- package/dist/hooks/useKeyPress/useKeyPress.d.ts +15 -0
- package/dist/hooks/useKeyPress/useKeyPress.d.ts.map +1 -0
- package/dist/hooks/useKeyPress.js +47 -0
- package/dist/hooks/useKeyPress.js.map +1 -0
- package/dist/hooks/useKeyPress.mjs +45 -0
- package/dist/hooks/useKeyPress.mjs.map +1 -0
- package/dist/hooks/useLongPress/index.d.ts +2 -0
- package/dist/hooks/useLongPress/index.d.ts.map +1 -0
- package/dist/hooks/useLongPress/useLongPress.d.ts +46 -0
- package/dist/hooks/useLongPress/useLongPress.d.ts.map +1 -0
- package/dist/hooks/useLongPress.js +116 -0
- package/dist/hooks/useLongPress.js.map +1 -0
- package/dist/hooks/useLongPress.mjs +114 -0
- package/dist/hooks/useLongPress.mjs.map +1 -0
- package/dist/hooks/usePrevious/index.d.ts +2 -0
- package/dist/hooks/usePrevious/index.d.ts.map +1 -0
- package/dist/hooks/usePrevious/usePrevious.d.ts +13 -0
- package/dist/hooks/usePrevious/usePrevious.d.ts.map +1 -0
- package/dist/hooks/usePrevious.js +17 -0
- package/dist/hooks/usePrevious.js.map +1 -0
- package/dist/hooks/usePrevious.mjs +15 -0
- package/dist/hooks/usePrevious.mjs.map +1 -0
- package/dist/hooks/useScrollPosition/index.d.ts +2 -0
- package/dist/hooks/useScrollPosition/index.d.ts.map +1 -0
- package/dist/hooks/useScrollPosition/useScrollPosition.d.ts +37 -0
- package/dist/hooks/useScrollPosition/useScrollPosition.d.ts.map +1 -0
- package/dist/hooks/useScrollPosition.js +41 -0
- package/dist/hooks/useScrollPosition.js.map +1 -0
- package/dist/hooks/useScrollPosition.mjs +39 -0
- package/dist/hooks/useScrollPosition.mjs.map +1 -0
- package/dist/hooks/useTimeout/index.d.ts +2 -0
- package/dist/hooks/useTimeout/index.d.ts.map +1 -0
- package/dist/hooks/useTimeout/useTimeout.d.ts +19 -0
- package/dist/hooks/useTimeout/useTimeout.d.ts.map +1 -0
- package/dist/hooks/useTimeout.js +38 -0
- package/dist/hooks/useTimeout.js.map +1 -0
- package/dist/hooks/useTimeout.mjs +36 -0
- package/dist/hooks/useTimeout.mjs.map +1 -0
- package/dist/hooks/useVirtualList/index.d.ts +2 -0
- package/dist/hooks/useVirtualList/index.d.ts.map +1 -0
- package/dist/hooks/useVirtualList/useVirtualList.d.ts +47 -0
- package/dist/hooks/useVirtualList/useVirtualList.d.ts.map +1 -0
- package/dist/hooks/useVirtualList.js +87 -0
- package/dist/hooks/useVirtualList.js.map +1 -0
- package/dist/hooks/useVirtualList.mjs +85 -0
- package/dist/hooks/useVirtualList.mjs.map +1 -0
- package/dist/lib/facade.d.ts.map +1 -1
- package/dist/ui/buttons/animated.js +8 -7
- package/dist/ui/buttons/animated.js.map +1 -1
- package/dist/ui/buttons/animated.mjs +6 -5
- package/dist/ui/buttons/animated.mjs.map +1 -1
- package/dist/ui/buttons.js +9 -8
- package/dist/ui/buttons.mjs +7 -6
- package/dist/ui/combobox/combobox-base.d.ts +37 -0
- package/dist/ui/combobox/combobox-base.d.ts.map +1 -0
- package/dist/ui/combobox/combobox.d.ts +6 -0
- package/dist/ui/combobox/combobox.d.ts.map +1 -0
- package/dist/ui/combobox/index.d.ts +4 -0
- package/dist/ui/combobox/index.d.ts.map +1 -0
- package/dist/ui/combobox/types.d.ts +70 -0
- package/dist/ui/combobox/types.d.ts.map +1 -0
- package/dist/ui/combobox/variants.d.ts +17 -0
- package/dist/ui/combobox/variants.d.ts.map +1 -0
- package/dist/ui/combobox.js +510 -0
- package/dist/ui/combobox.js.map +1 -0
- package/dist/ui/combobox.mjs +495 -0
- package/dist/ui/combobox.mjs.map +1 -0
- package/dist/ui/dynamic-stepper.js +18 -17
- package/dist/ui/dynamic-stepper.js.map +1 -1
- package/dist/ui/dynamic-stepper.mjs +7 -6
- package/dist/ui/dynamic-stepper.mjs.map +1 -1
- package/dist/ui/pagination.js +14 -13
- package/dist/ui/pagination.js.map +1 -1
- package/dist/ui/pagination.mjs +6 -5
- package/dist/ui/pagination.mjs.map +1 -1
- package/package.json +1 -1
- package/src/design-system/combobox.ts +204 -0
- package/src/design-system/index.ts +1 -0
- package/src/hooks/index.ts +50 -0
- package/src/hooks/useCookie/index.ts +5 -0
- package/src/hooks/useCookie/useCookie.test.ts +57 -0
- package/src/hooks/useCookie/useCookie.ts +133 -0
- package/src/hooks/useCountdown/index.ts +5 -0
- package/src/hooks/useCountdown/useCountdown.test.ts +113 -0
- package/src/hooks/useCountdown/useCountdown.ts +106 -0
- package/src/hooks/useEventListener/index.ts +4 -0
- package/src/hooks/useEventListener/useEventListener.test.ts +60 -0
- package/src/hooks/useEventListener/useEventListener.ts +98 -0
- package/src/hooks/useGeolocation/index.ts +6 -0
- package/src/hooks/useGeolocation/useGeolocation.test.ts +108 -0
- package/src/hooks/useGeolocation/useGeolocation.ts +173 -0
- package/src/hooks/useHotkeys/index.ts +5 -0
- package/src/hooks/useHotkeys/useHotkeys.test.ts +82 -0
- package/src/hooks/useHotkeys/useHotkeys.ts +130 -0
- package/src/hooks/useIdleTimeout/index.ts +5 -0
- package/src/hooks/useIdleTimeout/useIdleTimeout.test.ts +97 -0
- package/src/hooks/useIdleTimeout/useIdleTimeout.ts +111 -0
- package/src/hooks/useInterval/index.ts +1 -0
- package/src/hooks/useInterval/useInterval.test.ts +56 -0
- package/src/hooks/useInterval/useInterval.ts +36 -0
- package/src/hooks/useKeyPress/index.ts +1 -0
- package/src/hooks/useKeyPress/useKeyPress.test.ts +67 -0
- package/src/hooks/useKeyPress/useKeyPress.ts +65 -0
- package/src/hooks/useLongPress/index.ts +5 -0
- package/src/hooks/useLongPress/useLongPress.test.ts +180 -0
- package/src/hooks/useLongPress/useLongPress.ts +177 -0
- package/src/hooks/usePrevious/index.ts +1 -0
- package/src/hooks/usePrevious/usePrevious.test.ts +33 -0
- package/src/hooks/usePrevious/usePrevious.ts +24 -0
- package/src/hooks/useScrollPosition/index.ts +5 -0
- package/src/hooks/useScrollPosition/useScrollPosition.test.ts +69 -0
- package/src/hooks/useScrollPosition/useScrollPosition.ts +88 -0
- package/src/hooks/useTimeout/index.ts +1 -0
- package/src/hooks/useTimeout/useTimeout.test.ts +63 -0
- package/src/hooks/useTimeout/useTimeout.ts +58 -0
- package/src/hooks/useVirtualList/index.ts +6 -0
- package/src/hooks/useVirtualList/useVirtualList.test.ts +102 -0
- package/src/hooks/useVirtualList/useVirtualList.ts +144 -0
- package/src/lib/facade.test.ts +7 -7
- package/src/lib/facade.ts +6 -2
- package/src/ui/combobox/combobox-base.tsx +552 -0
- package/src/ui/combobox/combobox.test.tsx +292 -0
- package/src/ui/combobox/combobox.tsx +8 -0
- package/src/ui/combobox/index.ts +33 -0
- package/src/ui/combobox/types.ts +91 -0
- package/src/ui/combobox/variants.ts +58 -0
- package/dist/chunk-5J6QMTES.js.map +0 -1
- package/dist/chunk-AQHY4S33.js +0 -19
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
Combobox,
|
|
7
|
+
ComboboxContent,
|
|
8
|
+
ComboboxEmpty,
|
|
9
|
+
ComboboxItem,
|
|
10
|
+
ComboboxList,
|
|
11
|
+
ComboboxSearch,
|
|
12
|
+
ComboboxTrigger,
|
|
13
|
+
ComboboxValue,
|
|
14
|
+
} from "./combobox-base";
|
|
15
|
+
|
|
16
|
+
function BasicCombobox({
|
|
17
|
+
multiple = false,
|
|
18
|
+
defaultValue = [],
|
|
19
|
+
onChange,
|
|
20
|
+
}: {
|
|
21
|
+
multiple?: boolean;
|
|
22
|
+
defaultValue?: string[];
|
|
23
|
+
onChange?: (v: string[]) => void;
|
|
24
|
+
}) {
|
|
25
|
+
return (
|
|
26
|
+
<Combobox
|
|
27
|
+
multiple={multiple}
|
|
28
|
+
defaultValue={defaultValue}
|
|
29
|
+
onChange={onChange}
|
|
30
|
+
>
|
|
31
|
+
<ComboboxTrigger>
|
|
32
|
+
<ComboboxValue placeholder="Pick a fruit" />
|
|
33
|
+
</ComboboxTrigger>
|
|
34
|
+
<ComboboxContent>
|
|
35
|
+
<ComboboxSearch placeholder="Search fruits..." />
|
|
36
|
+
<ComboboxList>
|
|
37
|
+
<ComboboxItem value="apple">Apple</ComboboxItem>
|
|
38
|
+
<ComboboxItem value="banana">Banana</ComboboxItem>
|
|
39
|
+
<ComboboxItem value="cherry">Cherry</ComboboxItem>
|
|
40
|
+
<ComboboxItem value="disabled-grape" disabled>
|
|
41
|
+
Grape
|
|
42
|
+
</ComboboxItem>
|
|
43
|
+
<ComboboxEmpty>No fruit found.</ComboboxEmpty>
|
|
44
|
+
</ComboboxList>
|
|
45
|
+
</ComboboxContent>
|
|
46
|
+
</Combobox>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("Combobox", () => {
|
|
51
|
+
it("renders the trigger as a button", () => {
|
|
52
|
+
render(<BasicCombobox />);
|
|
53
|
+
expect(screen.getByRole("button")).toHaveAttribute("type", "button");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("shows placeholder when nothing is selected", () => {
|
|
57
|
+
render(<BasicCombobox />);
|
|
58
|
+
expect(
|
|
59
|
+
screen.getByRole("button", { name: /pick a fruit/i }),
|
|
60
|
+
).toBeInTheDocument();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("shows selected label in trigger after selection", async () => {
|
|
64
|
+
const user = userEvent.setup();
|
|
65
|
+
render(<BasicCombobox />);
|
|
66
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
67
|
+
await user.click(await screen.findByRole("option", { name: /apple/i }));
|
|
68
|
+
expect(screen.getByRole("button")).toHaveTextContent("Apple");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("opens the panel and lists options on trigger click", async () => {
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
render(<BasicCombobox />);
|
|
74
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
75
|
+
expect(
|
|
76
|
+
await screen.findByRole("option", { name: /apple/i }),
|
|
77
|
+
).toBeInTheDocument();
|
|
78
|
+
expect(screen.getByRole("option", { name: /banana/i })).toBeInTheDocument();
|
|
79
|
+
expect(screen.getByRole("option", { name: /cherry/i })).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("closes the panel on second trigger click", async () => {
|
|
83
|
+
const user = userEvent.setup();
|
|
84
|
+
render(<BasicCombobox />);
|
|
85
|
+
const trigger = screen.getByRole("button");
|
|
86
|
+
await user.click(trigger);
|
|
87
|
+
await screen.findByRole("option", { name: /apple/i });
|
|
88
|
+
await user.click(trigger);
|
|
89
|
+
expect(
|
|
90
|
+
screen.queryByRole("option", { name: /apple/i }),
|
|
91
|
+
).not.toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("closes after picking one value in single mode", async () => {
|
|
95
|
+
const user = userEvent.setup();
|
|
96
|
+
render(<BasicCombobox multiple={false} />);
|
|
97
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
98
|
+
await user.click(await screen.findByRole("option", { name: /banana/i }));
|
|
99
|
+
expect(
|
|
100
|
+
screen.queryByRole("option", { name: /banana/i }),
|
|
101
|
+
).not.toBeInTheDocument();
|
|
102
|
+
expect(screen.getByRole("button")).toHaveTextContent("Banana");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("keeps panel open after picking in multiple mode", async () => {
|
|
106
|
+
const user = userEvent.setup();
|
|
107
|
+
render(<BasicCombobox multiple />);
|
|
108
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
109
|
+
await user.click(await screen.findByRole("option", { name: /apple/i }));
|
|
110
|
+
expect(screen.getByRole("option", { name: /banana/i })).toBeInTheDocument();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("marks selected option with aria-selected=true", async () => {
|
|
114
|
+
const user = userEvent.setup();
|
|
115
|
+
render(<BasicCombobox defaultValue={["apple"]} />);
|
|
116
|
+
await user.click(screen.getByRole("button"));
|
|
117
|
+
const apple = await screen.findByRole("option", { name: /apple/i });
|
|
118
|
+
const banana = screen.getByRole("option", { name: /banana/i });
|
|
119
|
+
expect(apple).toHaveAttribute("aria-selected", "true");
|
|
120
|
+
expect(banana).toHaveAttribute("aria-selected", "false");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("calls onChange when a value is toggled", async () => {
|
|
124
|
+
const user = userEvent.setup();
|
|
125
|
+
const onChange = vi.fn();
|
|
126
|
+
render(<BasicCombobox onChange={onChange} />);
|
|
127
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
128
|
+
await user.click(await screen.findByRole("option", { name: /cherry/i }));
|
|
129
|
+
expect(onChange).toHaveBeenCalledWith(["cherry"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("deselects a value in multiple mode on second click", async () => {
|
|
133
|
+
const user = userEvent.setup();
|
|
134
|
+
const onChange = vi.fn();
|
|
135
|
+
render(
|
|
136
|
+
<BasicCombobox multiple defaultValue={["apple"]} onChange={onChange} />,
|
|
137
|
+
);
|
|
138
|
+
await user.click(screen.getByRole("button"));
|
|
139
|
+
await user.click(await screen.findByRole("option", { name: /apple/i }));
|
|
140
|
+
expect(onChange).toHaveBeenCalledWith([]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("marks a disabled option with aria-disabled and ignores clicks", async () => {
|
|
144
|
+
const user = userEvent.setup();
|
|
145
|
+
const onChange = vi.fn();
|
|
146
|
+
render(<BasicCombobox onChange={onChange} />);
|
|
147
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
148
|
+
const grape = await screen.findByRole("option", { name: /grape/i });
|
|
149
|
+
expect(grape).toHaveAttribute("aria-disabled", "true");
|
|
150
|
+
await user.click(grape);
|
|
151
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("Combobox — search and filtering", () => {
|
|
156
|
+
it("renders a search input inside the panel", async () => {
|
|
157
|
+
const user = userEvent.setup();
|
|
158
|
+
render(<BasicCombobox />);
|
|
159
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
160
|
+
const search = await screen.findByRole("combobox");
|
|
161
|
+
expect(search).toBeInTheDocument();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("auto-focuses the search input when panel opens", async () => {
|
|
165
|
+
const user = userEvent.setup();
|
|
166
|
+
render(<BasicCombobox />);
|
|
167
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
168
|
+
const search = await screen.findByRole("combobox");
|
|
169
|
+
await waitFor(() => expect(search).toHaveFocus());
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("filters options as the user types", async () => {
|
|
173
|
+
const user = userEvent.setup();
|
|
174
|
+
render(<BasicCombobox />);
|
|
175
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
176
|
+
const search = await screen.findByRole("combobox");
|
|
177
|
+
await user.type(search, "ban");
|
|
178
|
+
expect(screen.getByRole("option", { name: /banana/i })).toBeInTheDocument();
|
|
179
|
+
expect(
|
|
180
|
+
screen.queryByRole("option", { name: /apple/i }),
|
|
181
|
+
).not.toBeInTheDocument();
|
|
182
|
+
expect(
|
|
183
|
+
screen.queryByRole("option", { name: /cherry/i }),
|
|
184
|
+
).not.toBeInTheDocument();
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("shows the empty state when no options match", async () => {
|
|
188
|
+
const user = userEvent.setup();
|
|
189
|
+
render(<BasicCombobox />);
|
|
190
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
191
|
+
const search = await screen.findByRole("combobox");
|
|
192
|
+
await user.type(search, "xyz");
|
|
193
|
+
expect(screen.getByText(/no fruit found/i)).toBeInTheDocument();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("clears the filter and restores all options after closing and reopening", async () => {
|
|
197
|
+
const user = userEvent.setup();
|
|
198
|
+
render(<BasicCombobox />);
|
|
199
|
+
const trigger = screen.getByRole("button", { name: /pick a fruit/i });
|
|
200
|
+
await user.click(trigger);
|
|
201
|
+
await user.type(await screen.findByRole("combobox"), "ban");
|
|
202
|
+
await user.click(trigger);
|
|
203
|
+
await user.click(trigger);
|
|
204
|
+
expect(
|
|
205
|
+
await screen.findByRole("option", { name: /apple/i }),
|
|
206
|
+
).toBeInTheDocument();
|
|
207
|
+
expect(screen.getByRole("option", { name: /banana/i })).toBeInTheDocument();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("Combobox — keyboard navigation", () => {
|
|
212
|
+
it("closes the panel on Escape", async () => {
|
|
213
|
+
const user = userEvent.setup();
|
|
214
|
+
render(<BasicCombobox />);
|
|
215
|
+
const trigger = screen.getByRole("button", { name: /pick a fruit/i });
|
|
216
|
+
await user.click(trigger);
|
|
217
|
+
await screen.findByRole("combobox");
|
|
218
|
+
await user.keyboard("{Escape}");
|
|
219
|
+
expect(
|
|
220
|
+
screen.queryByRole("option", { name: /apple/i }),
|
|
221
|
+
).not.toBeInTheDocument();
|
|
222
|
+
expect(trigger).toHaveFocus();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("moves highlight through options with ArrowDown/ArrowUp", async () => {
|
|
226
|
+
const user = userEvent.setup();
|
|
227
|
+
render(<BasicCombobox />);
|
|
228
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
229
|
+
const search = await screen.findByRole("combobox");
|
|
230
|
+
await waitFor(() => expect(search).toHaveFocus());
|
|
231
|
+
|
|
232
|
+
await user.keyboard("{ArrowDown}");
|
|
233
|
+
const apple = screen.getByRole("option", { name: /apple/i });
|
|
234
|
+
expect(apple).toHaveAttribute("data-active", "true");
|
|
235
|
+
|
|
236
|
+
await user.keyboard("{ArrowDown}");
|
|
237
|
+
const banana = screen.getByRole("option", { name: /banana/i });
|
|
238
|
+
expect(banana).toHaveAttribute("data-active", "true");
|
|
239
|
+
|
|
240
|
+
await user.keyboard("{ArrowUp}");
|
|
241
|
+
expect(apple).toHaveAttribute("data-active", "true");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("selects the highlighted option on Enter", async () => {
|
|
245
|
+
const user = userEvent.setup();
|
|
246
|
+
const onChange = vi.fn();
|
|
247
|
+
render(<BasicCombobox onChange={onChange} />);
|
|
248
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
249
|
+
const search = await screen.findByRole("combobox");
|
|
250
|
+
await waitFor(() => expect(search).toHaveFocus());
|
|
251
|
+
|
|
252
|
+
await user.keyboard("{ArrowDown}");
|
|
253
|
+
await user.keyboard("{Enter}");
|
|
254
|
+
expect(onChange).toHaveBeenCalledWith(["apple"]);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("skips disabled options during keyboard navigation", async () => {
|
|
258
|
+
const user = userEvent.setup();
|
|
259
|
+
const onChange = vi.fn();
|
|
260
|
+
render(<BasicCombobox onChange={onChange} />);
|
|
261
|
+
await user.click(screen.getByRole("button", { name: /pick a fruit/i }));
|
|
262
|
+
const search = await screen.findByRole("combobox");
|
|
263
|
+
await waitFor(() => expect(search).toHaveFocus());
|
|
264
|
+
|
|
265
|
+
await user.keyboard("{ArrowUp}");
|
|
266
|
+
expect(screen.getByRole("option", { name: /cherry/i })).toHaveAttribute(
|
|
267
|
+
"data-active",
|
|
268
|
+
"true",
|
|
269
|
+
);
|
|
270
|
+
expect(screen.getByRole("option", { name: /grape/i })).not.toHaveAttribute(
|
|
271
|
+
"data-active",
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
await user.keyboard("{Enter}");
|
|
275
|
+
expect(onChange).toHaveBeenCalledWith(["cherry"]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("wires trigger aria attributes to the listbox", async () => {
|
|
279
|
+
const user = userEvent.setup();
|
|
280
|
+
render(<BasicCombobox />);
|
|
281
|
+
const trigger = screen.getByRole("button");
|
|
282
|
+
expect(trigger).toHaveAttribute("aria-haspopup", "listbox");
|
|
283
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
284
|
+
|
|
285
|
+
await user.click(trigger);
|
|
286
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
287
|
+
|
|
288
|
+
const listbox = screen.getByRole("listbox");
|
|
289
|
+
const controls = trigger.getAttribute("aria-controls");
|
|
290
|
+
expect(listbox).toHaveAttribute("id", controls);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
Combobox,
|
|
5
|
+
ComboboxContext,
|
|
6
|
+
useCombobox,
|
|
7
|
+
ComboboxTrigger,
|
|
8
|
+
ComboboxValue,
|
|
9
|
+
ComboboxContent,
|
|
10
|
+
ComboboxSearch,
|
|
11
|
+
ComboboxList,
|
|
12
|
+
ComboboxItem,
|
|
13
|
+
ComboboxEmpty,
|
|
14
|
+
} from "./combobox-base";
|
|
15
|
+
export type {
|
|
16
|
+
ComboboxProps,
|
|
17
|
+
ComboboxTriggerProps,
|
|
18
|
+
ComboboxValueProps,
|
|
19
|
+
ComboboxContentProps,
|
|
20
|
+
ComboboxSearchProps,
|
|
21
|
+
ComboboxListProps,
|
|
22
|
+
ComboboxItemProps,
|
|
23
|
+
ComboboxEmptyProps,
|
|
24
|
+
ComboboxOption,
|
|
25
|
+
ComboboxContextType,
|
|
26
|
+
ComboboxAppearance,
|
|
27
|
+
} from "./types";
|
|
28
|
+
export {
|
|
29
|
+
comboboxTriggerVariants,
|
|
30
|
+
comboboxItemVariants,
|
|
31
|
+
comboboxListVariants,
|
|
32
|
+
comboboxContentVariants,
|
|
33
|
+
} from "./variants";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { VariantProps } from "class-variance-authority";
|
|
2
|
+
import type {
|
|
3
|
+
ButtonHTMLAttributes,
|
|
4
|
+
HTMLAttributes,
|
|
5
|
+
InputHTMLAttributes,
|
|
6
|
+
ReactNode,
|
|
7
|
+
RefObject,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
import type { zuiComboboxContentAppearances } from "../../design-system/combobox";
|
|
11
|
+
import type { comboboxTriggerVariants } from "./variants";
|
|
12
|
+
|
|
13
|
+
export type ComboboxOption = {
|
|
14
|
+
label: ReactNode;
|
|
15
|
+
value: string;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ComboboxAppearance = keyof typeof zuiComboboxContentAppearances;
|
|
20
|
+
|
|
21
|
+
export type ComboboxContextType = {
|
|
22
|
+
open: boolean;
|
|
23
|
+
setOpen: (v: boolean) => void;
|
|
24
|
+
query: string;
|
|
25
|
+
setQuery: (q: string) => void;
|
|
26
|
+
selected: string[];
|
|
27
|
+
toggleValue: (v: string) => void;
|
|
28
|
+
isSelected: (v: string) => boolean;
|
|
29
|
+
multiple: boolean;
|
|
30
|
+
options: ComboboxOption[];
|
|
31
|
+
registerOption: (opt: ComboboxOption) => void;
|
|
32
|
+
visibleValues: string[];
|
|
33
|
+
isVisible: (v: string) => boolean;
|
|
34
|
+
activeValue: string | null;
|
|
35
|
+
setActiveValue: (v: string | null) => void;
|
|
36
|
+
triggerId: string;
|
|
37
|
+
listboxId: string;
|
|
38
|
+
searchRef: RefObject<HTMLInputElement | null>;
|
|
39
|
+
triggerRef: RefObject<HTMLButtonElement | null>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ComboboxProps = {
|
|
43
|
+
value?: string[];
|
|
44
|
+
defaultValue?: string[];
|
|
45
|
+
onChange?: (value: string[]) => void;
|
|
46
|
+
multiple?: boolean;
|
|
47
|
+
children: ReactNode;
|
|
48
|
+
className?: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type ComboboxTriggerVariantProps = VariantProps<typeof comboboxTriggerVariants>;
|
|
52
|
+
|
|
53
|
+
export type ComboboxTriggerProps = ComboboxTriggerVariantProps &
|
|
54
|
+
Omit<ButtonHTMLAttributes<HTMLButtonElement>, "size"> & {
|
|
55
|
+
className?: string;
|
|
56
|
+
ref?: React.Ref<HTMLButtonElement>;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type ComboboxValueProps = HTMLAttributes<HTMLSpanElement> & {
|
|
60
|
+
placeholder?: ReactNode;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type ComboboxContentProps = HTMLAttributes<HTMLDivElement> & {
|
|
64
|
+
className?: string;
|
|
65
|
+
appearance?: ComboboxAppearance;
|
|
66
|
+
size?: "sm" | "md" | "lg";
|
|
67
|
+
spacing?: "none" | "default" | "sm" | "md" | "lg" | "xl";
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type ComboboxSearchProps = Omit<
|
|
71
|
+
InputHTMLAttributes<HTMLInputElement>,
|
|
72
|
+
"value" | "onChange" | "type"
|
|
73
|
+
> & {
|
|
74
|
+
className?: string;
|
|
75
|
+
ref?: React.Ref<HTMLInputElement>;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type ComboboxListProps = HTMLAttributes<HTMLDivElement> & {
|
|
79
|
+
className?: string;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type ComboboxItemProps = HTMLAttributes<HTMLDivElement> & {
|
|
83
|
+
value: string;
|
|
84
|
+
children: ReactNode;
|
|
85
|
+
disabled?: boolean;
|
|
86
|
+
appearance?: ComboboxAppearance;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
export type ComboboxEmptyProps = HTMLAttributes<HTMLDivElement> & {
|
|
90
|
+
className?: string;
|
|
91
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cva } from "class-variance-authority";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
zuiComboboxContentAppearances,
|
|
5
|
+
zuiComboboxContentBase,
|
|
6
|
+
zuiComboboxDisabled,
|
|
7
|
+
zuiComboboxItemAppearances,
|
|
8
|
+
zuiComboboxItemBase,
|
|
9
|
+
zuiComboboxListAppearances,
|
|
10
|
+
zuiComboboxListBase,
|
|
11
|
+
zuiComboboxSizes,
|
|
12
|
+
zuiComboboxSpacing,
|
|
13
|
+
zuiComboboxTriggerBase,
|
|
14
|
+
zuiComboboxTriggerVariants,
|
|
15
|
+
} from "../../design-system/combobox";
|
|
16
|
+
|
|
17
|
+
export const comboboxTriggerVariants = cva(zuiComboboxTriggerBase, {
|
|
18
|
+
variants: {
|
|
19
|
+
variant: zuiComboboxTriggerVariants,
|
|
20
|
+
size: zuiComboboxSizes,
|
|
21
|
+
},
|
|
22
|
+
defaultVariants: {
|
|
23
|
+
variant: "default",
|
|
24
|
+
size: "md",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export const comboboxItemVariants = cva(zuiComboboxItemBase, {
|
|
29
|
+
variants: {
|
|
30
|
+
appearance: zuiComboboxItemAppearances,
|
|
31
|
+
disabled: zuiComboboxDisabled,
|
|
32
|
+
},
|
|
33
|
+
defaultVariants: {
|
|
34
|
+
appearance: "default",
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const comboboxListVariants = cva(zuiComboboxListBase, {
|
|
39
|
+
variants: {
|
|
40
|
+
appearance: zuiComboboxListAppearances,
|
|
41
|
+
},
|
|
42
|
+
defaultVariants: {
|
|
43
|
+
appearance: "default",
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const comboboxContentVariants = cva(zuiComboboxContentBase, {
|
|
48
|
+
variants: {
|
|
49
|
+
appearance: zuiComboboxContentAppearances,
|
|
50
|
+
size: zuiComboboxSizes,
|
|
51
|
+
spacing: zuiComboboxSpacing,
|
|
52
|
+
},
|
|
53
|
+
defaultVariants: {
|
|
54
|
+
appearance: "default",
|
|
55
|
+
size: "md",
|
|
56
|
+
spacing: "default",
|
|
57
|
+
},
|
|
58
|
+
});
|