Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions docs/components/autocomplete.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,6 @@ const {
GroupLabel,
Collection,
Status,
useFilter,
useAsync,
} = Autocomplete.Parts;
```

Expand Down Expand Up @@ -181,6 +179,38 @@ const fetcher: AutocompleteAsyncFetcher<string> = async (query, { signal }) => {
/>;
```

### Async with Parts (custom composition)

Combine `Autocomplete.useAsync` with `Autocomplete.Parts` for full control over layout and rendering:

```tsx
const suggestions = Autocomplete.useAsync({
fetcher: async (query, { signal }) => {
const res = await fetch(`/api/suggestions?q=${query ?? ""}`, { signal });
return res.json();
},
});

<Autocomplete.Parts.Root {...suggestions} filter={null}>
<Autocomplete.Parts.InputGroup>
<Autocomplete.Parts.Input placeholder="Search..." />
<Autocomplete.Parts.Clear />
</Autocomplete.Parts.InputGroup>
<Autocomplete.Parts.Content>
<Autocomplete.Parts.List>
{suggestions.items.map((item) => (
<Autocomplete.Parts.Item key={item} value={item}>
{item}
</Autocomplete.Parts.Item>
))}
<Autocomplete.Parts.Empty>
{suggestions.loading ? "Loading..." : "No results."}
</Autocomplete.Parts.Empty>
</Autocomplete.Parts.List>
</Autocomplete.Parts.Content>
</Autocomplete.Parts.Root>;
```

## Accessibility

- Input is keyboard accessible with arrow key navigation
Expand Down
39 changes: 36 additions & 3 deletions docs/components/combobox.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,9 +182,6 @@ const {
Value,
Collection,
Status,
useFilter,
useCreatable,
useAsync,
} = Combobox.Parts;
```

Expand All @@ -204,6 +201,42 @@ const [selected, setSelected] = useState<User | null>(null);
/>;
```

### Async with Parts (custom composition)

Combine `Combobox.useAsync` with `Combobox.Parts` for full control over layout and rendering:

```tsx
type Country = { code: string; name: string };

const countries = Combobox.useAsync({
fetcher: async (query, { signal }) => {
const res = await fetch(`/api/countries?q=${query ?? ""}`, { signal });
if (!res.ok) return [];
return res.json() as Promise<Country[]>;
},
});

<Combobox.Parts.Root {...countries} filter={null} itemToStringLabel={(c) => c.name}>
<Combobox.Parts.InputGroup>
<Combobox.Parts.Input placeholder="Search countries..." />
<Combobox.Parts.Clear />
<Combobox.Parts.Trigger />
</Combobox.Parts.InputGroup>
<Combobox.Parts.Content>
<Combobox.Parts.List>
{countries.items.map((c) => (
<Combobox.Parts.Item key={c.code} value={c}>
{c.name}
</Combobox.Parts.Item>
))}
<Combobox.Parts.Empty>
{countries.loading ? "Loading..." : "No results."}
</Combobox.Parts.Empty>
</Combobox.Parts.List>
</Combobox.Parts.Content>
</Combobox.Parts.Root>;
```

## Accessibility

- Input is keyboard accessible with arrow key navigation
Expand Down
32 changes: 32 additions & 0 deletions docs/components/select.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,38 @@ const [selected, setSelected] = useState<string | null>(null);
/>
```

### Async with Parts (custom composition)

Combine `Select.useAsync` with `Select.Parts` for full control over layout and rendering:

```tsx
type Fruit = { id: number; name: string };

const fruits = Select.useAsync({
fetcher: async ({ signal }) => {
const res = await fetch("/api/fruits", { signal });
return res.json() as Promise<Fruit[]>;
},
});

<Select.Parts.Root {...fruits} itemToStringLabel={(f) => f.name}>
<Select.Parts.Trigger>
<Select.Parts.Value placeholder="Pick a fruit" />
</Select.Parts.Trigger>
<Select.Parts.Content>
{fruits.loading ? (
<div className="px-4 py-2 text-center text-sm text-muted-foreground">Loading...</div>
) : (
fruits.items.map((f) => (
<Select.Parts.Item key={f.id} value={f}>
{f.name}
</Select.Parts.Item>
))
)}
</Select.Parts.Content>
</Select.Parts.Root>;
```

## Related Components

- [Combobox](./combobox.md) - Searchable combobox with filtering
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/components/autocomplete-standalone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
AutocompleteCollection,
AutocompleteParts,
useAsync,
useFilter,
} from "./autocomplete";
import { defaultMapItem, isGroupedItems } from "./dropdown-items";
import type { MappedItem, ItemGroup, ExtractItem } from "./dropdown-items";
Expand Down Expand Up @@ -205,6 +206,8 @@ AutocompleteAsyncStandalone.displayName = "Autocomplete.Async";
const Autocomplete = Object.assign(AutocompleteStandalone, {
Async: AutocompleteAsyncStandalone,
Parts: AutocompleteParts,
useAsync,
useFilter,
});

export { Autocomplete };
126 changes: 62 additions & 64 deletions packages/core/src/components/autocomplete.test.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, screen, waitFor, renderHook, act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { AutocompleteParts } from "./autocomplete";

const Autocomplete = { Parts: AutocompleteParts };
import { AutocompleteParts, useAsync } from "./autocomplete";

afterEach(() => {
cleanup();
Expand All @@ -17,23 +15,23 @@ function SimpleAutocomplete(props: {
value?: string;
}) {
return (
<Autocomplete.Parts.Root {...props}>
<Autocomplete.Parts.InputGroup>
<Autocomplete.Parts.Input data-testid="input" placeholder="Type a fruit..." />
<Autocomplete.Parts.Clear />
<Autocomplete.Parts.Trigger />
</Autocomplete.Parts.InputGroup>
<Autocomplete.Parts.Content>
<Autocomplete.Parts.List>
<AutocompleteParts.Root {...props}>
<AutocompleteParts.InputGroup>
<AutocompleteParts.Input data-testid="input" placeholder="Type a fruit..." />
<AutocompleteParts.Clear />
<AutocompleteParts.Trigger />
</AutocompleteParts.InputGroup>
<AutocompleteParts.Content>
<AutocompleteParts.List>
{suggestions.map((s) => (
<Autocomplete.Parts.Item key={s} value={s}>
<AutocompleteParts.Item key={s} value={s}>
{s}
</Autocomplete.Parts.Item>
</AutocompleteParts.Item>
))}
<Autocomplete.Parts.Empty>No suggestions</Autocomplete.Parts.Empty>
</Autocomplete.Parts.List>
</Autocomplete.Parts.Content>
</Autocomplete.Parts.Root>
<AutocompleteParts.Empty>No suggestions</AutocompleteParts.Empty>
</AutocompleteParts.List>
</AutocompleteParts.Content>
</AutocompleteParts.Root>
);
}

Expand Down Expand Up @@ -62,17 +60,17 @@ describe("Autocomplete.Parts", () => {

it("with groups", () => {
const { container } = render(
<Autocomplete.Parts.Root>
<Autocomplete.Parts.Input placeholder="Search..." />
<Autocomplete.Parts.Content>
<Autocomplete.Parts.List>
<Autocomplete.Parts.Group>
<Autocomplete.Parts.GroupLabel>Fruits</Autocomplete.Parts.GroupLabel>
<Autocomplete.Parts.Item value="apple">Apple</Autocomplete.Parts.Item>
</Autocomplete.Parts.Group>
</Autocomplete.Parts.List>
</Autocomplete.Parts.Content>
</Autocomplete.Parts.Root>,
<AutocompleteParts.Root>
<AutocompleteParts.Input placeholder="Search..." />
<AutocompleteParts.Content>
<AutocompleteParts.List>
<AutocompleteParts.Group>
<AutocompleteParts.GroupLabel>Fruits</AutocompleteParts.GroupLabel>
<AutocompleteParts.Item value="apple">Apple</AutocompleteParts.Item>
</AutocompleteParts.Group>
</AutocompleteParts.List>
</AutocompleteParts.Content>
</AutocompleteParts.Root>,
);
expect(container.innerHTML).toMatchSnapshot();
});
Expand Down Expand Up @@ -135,50 +133,50 @@ describe("Autocomplete.Parts", () => {

it("applies custom className to input", () => {
render(
<Autocomplete.Parts.Root>
<Autocomplete.Parts.Input data-testid="input" className="custom-class" />
<Autocomplete.Parts.Content>
<Autocomplete.Parts.List>
<Autocomplete.Parts.Item value="a">Apple</Autocomplete.Parts.Item>
</Autocomplete.Parts.List>
</Autocomplete.Parts.Content>
</Autocomplete.Parts.Root>,
<AutocompleteParts.Root>
<AutocompleteParts.Input data-testid="input" className="custom-class" />
<AutocompleteParts.Content>
<AutocompleteParts.List>
<AutocompleteParts.Item value="a">Apple</AutocompleteParts.Item>
</AutocompleteParts.List>
</AutocompleteParts.Content>
</AutocompleteParts.Root>,
);

expect(screen.getByTestId("input").classList.contains("custom-class")).toBe(true);
});

it("renders the trigger with default icon", () => {
render(
<Autocomplete.Parts.Root>
<AutocompleteParts.Root>
<div style={{ position: "relative" }}>
<Autocomplete.Parts.Input data-testid="input" />
<Autocomplete.Parts.Trigger data-testid="trigger" />
<AutocompleteParts.Input data-testid="input" />
<AutocompleteParts.Trigger data-testid="trigger" />
</div>
<Autocomplete.Parts.Content>
<Autocomplete.Parts.List>
<Autocomplete.Parts.Item value="a">Apple</Autocomplete.Parts.Item>
</Autocomplete.Parts.List>
</Autocomplete.Parts.Content>
</Autocomplete.Parts.Root>,
<AutocompleteParts.Content>
<AutocompleteParts.List>
<AutocompleteParts.Item value="a">Apple</AutocompleteParts.Item>
</AutocompleteParts.List>
</AutocompleteParts.Content>
</AutocompleteParts.Root>,
);

expect(screen.getByTestId("trigger")).toBeDefined();
});

it("renders the clear button", () => {
render(
<Autocomplete.Parts.Root defaultValue="Apple">
<AutocompleteParts.Root defaultValue="Apple">
<div style={{ position: "relative" }}>
<Autocomplete.Parts.Input data-testid="input" />
<Autocomplete.Parts.Clear data-testid="clear" />
<AutocompleteParts.Input data-testid="input" />
<AutocompleteParts.Clear data-testid="clear" />
</div>
<Autocomplete.Parts.Content>
<Autocomplete.Parts.List>
<Autocomplete.Parts.Item value="Apple">Apple</Autocomplete.Parts.Item>
</Autocomplete.Parts.List>
</Autocomplete.Parts.Content>
</Autocomplete.Parts.Root>,
<AutocompleteParts.Content>
<AutocompleteParts.List>
<AutocompleteParts.Item value="Apple">Apple</AutocompleteParts.Item>
</AutocompleteParts.List>
</AutocompleteParts.Content>
</AutocompleteParts.Root>,
);

expect(screen.getByTestId("clear")).toBeDefined();
Expand All @@ -193,10 +191,10 @@ describe("Autocomplete.Parts", () => {
});

// ============================================================================
// Autocomplete.Parts.useAsync tests
// useAsync tests
// ============================================================================

describe("Autocomplete.Parts.useAsync", () => {
describe("useAsync", () => {
beforeEach(() => {
vi.useFakeTimers();
});
Expand All @@ -214,7 +212,7 @@ describe("Autocomplete.Parts.useAsync", () => {

it("returns correct shape with mapped property names", () => {
const fetcher = vi.fn(async () => []);
const { result } = renderHook(() => Autocomplete.Parts.useAsync({ fetcher }));
const { result } = renderHook(() => useAsync({ fetcher }));

// Should use `value` (not `query`) and `onValueChange` (not `onInputValueChange`)
expect(result.current).toHaveProperty("items");
Expand All @@ -228,7 +226,7 @@ describe("Autocomplete.Parts.useAsync", () => {

it("maps value from internal query state", () => {
const fetcher = vi.fn(async () => []);
const { result } = renderHook(() => Autocomplete.Parts.useAsync({ fetcher }));
const { result } = renderHook(() => useAsync({ fetcher }));

act(() => {
result.current.onValueChange("hello");
Expand All @@ -240,7 +238,7 @@ describe("Autocomplete.Parts.useAsync", () => {
it("fetches items after debounce", async () => {
const items = ["Apple", "Apricot"];
const fetcher = vi.fn(async () => items);
const { result } = renderHook(() => Autocomplete.Parts.useAsync({ fetcher }));
const { result } = renderHook(() => useAsync({ fetcher }));

act(() => {
result.current.onValueChange("ap");
Expand All @@ -261,7 +259,7 @@ describe("Autocomplete.Parts.useAsync", () => {
it("respects custom debounceMs via object fetcher", async () => {
const fetcher = vi.fn(async () => ["a"]);
const { result } = renderHook(() =>
Autocomplete.Parts.useAsync({
useAsync({
fetcher: { fn: fetcher, debounceMs: 500 },
}),
);
Expand All @@ -279,7 +277,7 @@ describe("Autocomplete.Parts.useAsync", () => {

it("calls fetcher with null when value is empty", async () => {
const fetcher = vi.fn(async (_q: string | null) => (_q === null ? [] : ["a", "b"]));
const { result } = renderHook(() => Autocomplete.Parts.useAsync({ fetcher }));
const { result } = renderHook(() => useAsync({ fetcher }));

act(() => {
result.current.onValueChange("test");
Expand All @@ -299,7 +297,7 @@ describe("Autocomplete.Parts.useAsync", () => {
const fetcher = vi.fn(async () => {
throw error;
});
const { result } = renderHook(() => Autocomplete.Parts.useAsync({ fetcher }));
const { result } = renderHook(() => useAsync({ fetcher }));

act(() => {
result.current.onValueChange("test");
Expand All @@ -319,7 +317,7 @@ describe("Autocomplete.Parts.useAsync", () => {
});
});

const { result } = renderHook(() => Autocomplete.Parts.useAsync({ fetcher }));
const { result } = renderHook(() => useAsync({ fetcher }));

act(() => {
result.current.onValueChange("first");
Expand All @@ -336,7 +334,7 @@ describe("Autocomplete.Parts.useAsync", () => {

it("calls fetcher with null for whitespace-only input", async () => {
const fetcher = vi.fn(async () => []);
const { result } = renderHook(() => Autocomplete.Parts.useAsync({ fetcher }));
const { result } = renderHook(() => useAsync({ fetcher }));

act(() => {
result.current.onValueChange(" ");
Expand Down
Loading
Loading