diff --git a/docs/components/autocomplete.md b/docs/components/autocomplete.md index b539523..c98b92d 100644 --- a/docs/components/autocomplete.md +++ b/docs/components/autocomplete.md @@ -150,8 +150,6 @@ const { GroupLabel, Collection, Status, - useFilter, - useAsync, } = Autocomplete.Parts; ``` @@ -181,6 +179,38 @@ const fetcher: AutocompleteAsyncFetcher = 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(); + }, +}); + + + + + + + + + {suggestions.items.map((item) => ( + + {item} + + ))} + + {suggestions.loading ? "Loading..." : "No results."} + + + +; +``` + ## Accessibility - Input is keyboard accessible with arrow key navigation diff --git a/docs/components/combobox.md b/docs/components/combobox.md index 656b476..13e3484 100644 --- a/docs/components/combobox.md +++ b/docs/components/combobox.md @@ -182,9 +182,6 @@ const { Value, Collection, Status, - useFilter, - useCreatable, - useAsync, } = Combobox.Parts; ``` @@ -204,6 +201,42 @@ const [selected, setSelected] = useState(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; + }, +}); + + c.name}> + + + + + + + + {countries.items.map((c) => ( + + {c.name} + + ))} + + {countries.loading ? "Loading..." : "No results."} + + + +; +``` + ## Accessibility - Input is keyboard accessible with arrow key navigation diff --git a/docs/components/select.md b/docs/components/select.md index 6c118dd..458a1ab 100644 --- a/docs/components/select.md +++ b/docs/components/select.md @@ -183,6 +183,38 @@ const [selected, setSelected] = useState(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; + }, +}); + + f.name}> + + + + + {fruits.loading ? ( +
Loading...
+ ) : ( + fruits.items.map((f) => ( + + {f.name} + + )) + )} +
+
; +``` + ## Related Components - [Combobox](./combobox.md) - Searchable combobox with filtering diff --git a/packages/core/src/components/autocomplete-standalone.tsx b/packages/core/src/components/autocomplete-standalone.tsx index e27d65b..d07cdb5 100644 --- a/packages/core/src/components/autocomplete-standalone.tsx +++ b/packages/core/src/components/autocomplete-standalone.tsx @@ -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"; @@ -205,6 +206,8 @@ AutocompleteAsyncStandalone.displayName = "Autocomplete.Async"; const Autocomplete = Object.assign(AutocompleteStandalone, { Async: AutocompleteAsyncStandalone, Parts: AutocompleteParts, + useAsync, + useFilter, }); export { Autocomplete }; diff --git a/packages/core/src/components/autocomplete.test.tsx b/packages/core/src/components/autocomplete.test.tsx index 5fe284c..f5e848a 100644 --- a/packages/core/src/components/autocomplete.test.tsx +++ b/packages/core/src/components/autocomplete.test.tsx @@ -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(); @@ -17,23 +15,23 @@ function SimpleAutocomplete(props: { value?: string; }) { return ( - - - - - - - - + + + + + + + + {suggestions.map((s) => ( - + {s} - + ))} - No suggestions - - - + No suggestions + + + ); } @@ -62,17 +60,17 @@ describe("Autocomplete.Parts", () => { it("with groups", () => { const { container } = render( - - - - - - Fruits - Apple - - - - , + + + + + + Fruits + Apple + + + + , ); expect(container.innerHTML).toMatchSnapshot(); }); @@ -135,14 +133,14 @@ describe("Autocomplete.Parts", () => { it("applies custom className to input", () => { render( - - - - - Apple - - - , + + + + + Apple + + + , ); expect(screen.getByTestId("input").classList.contains("custom-class")).toBe(true); @@ -150,17 +148,17 @@ describe("Autocomplete.Parts", () => { it("renders the trigger with default icon", () => { render( - +
- - + +
- - - Apple - - -
, + + + Apple + + + , ); expect(screen.getByTestId("trigger")).toBeDefined(); @@ -168,17 +166,17 @@ describe("Autocomplete.Parts", () => { it("renders the clear button", () => { render( - +
- - + +
- - - Apple - - -
, + + + Apple + + + , ); expect(screen.getByTestId("clear")).toBeDefined(); @@ -193,10 +191,10 @@ describe("Autocomplete.Parts", () => { }); // ============================================================================ -// Autocomplete.Parts.useAsync tests +// useAsync tests // ============================================================================ -describe("Autocomplete.Parts.useAsync", () => { +describe("useAsync", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -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"); @@ -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"); @@ -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"); @@ -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 }, }), ); @@ -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"); @@ -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"); @@ -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"); @@ -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(" "); diff --git a/packages/core/src/components/autocomplete.tsx b/packages/core/src/components/autocomplete.tsx index e311a75..5192595 100644 --- a/packages/core/src/components/autocomplete.tsx +++ b/packages/core/src/components/autocomplete.tsx @@ -305,6 +305,27 @@ function useAsync(options: UseAsyncItemsOptions): AutocompleteUseAsyncRetu }; } +/** + * Hook for robust string matching using `Intl.Collator`. + * + * Returns an object with `contains`, `startsWith`, and `endsWith` methods + * that can be passed to the `filter` prop of `` + * to customize how suggestions are filtered against the input value. + * + * Accepts `Intl.CollatorOptions` (e.g. `sensitivity`, `usage`) plus + * an optional `locale` for locale-aware matching. + * + * @example + * ```tsx + * const filter = Autocomplete.useFilter({ sensitivity: "base" }); + * + * + * ... + * + * ``` + */ +const useFilter = BaseAutocomplete.useFilter; + // ============================================================================ // Export // ============================================================================ @@ -324,8 +345,6 @@ const AutocompleteParts = { GroupLabel: AutocompleteGroupLabel, Collection: AutocompleteCollection, Status: AutocompleteStatus, - useFilter: BaseAutocomplete.useFilter, - useAsync, }; type AutocompleteParts = typeof AutocompleteParts; @@ -347,4 +366,5 @@ export { AutocompleteStatus, AutocompleteParts, useAsync, + useFilter, }; diff --git a/packages/core/src/components/combobox-standalone.tsx b/packages/core/src/components/combobox-standalone.tsx index e1e8313..8279248 100644 --- a/packages/core/src/components/combobox-standalone.tsx +++ b/packages/core/src/components/combobox-standalone.tsx @@ -19,6 +19,7 @@ import { ComboboxParts, useCreatable, useAsync, + useFilter, } from "./combobox"; import { defaultMapItem, isGroupedItems } from "./dropdown-items"; @@ -531,6 +532,9 @@ function ComboboxAsyncStandalone(props: any) { const Combobox = Object.assign(ComboboxStandalone, { Async: ComboboxAsyncStandalone, Parts: ComboboxParts, + useAsync, + useFilter, + useCreatable, }); export { Combobox }; diff --git a/packages/core/src/components/combobox.test.tsx b/packages/core/src/components/combobox.test.tsx index f42b6d7..4fb537e 100644 --- a/packages/core/src/components/combobox.test.tsx +++ b/packages/core/src/components/combobox.test.tsx @@ -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 { ComboboxParts } from "./combobox"; - -const Combobox = { Parts: ComboboxParts }; +import { ComboboxParts, useCreatable, useAsync } from "./combobox"; afterEach(() => { cleanup(); @@ -17,19 +15,19 @@ function SimpleCombobox(props: { value?: string; }) { return ( - - - - + + + + {fruits.map((fruit) => ( - + {fruit} - + ))} - No results found - - - + No results found + + + ); } @@ -58,35 +56,35 @@ describe("Combobox.Parts", () => { it("with InputGroup, Clear, and Trigger", () => { const { container } = render( - - - - - - - - - Alpha - - - , + + + + + + + + + Alpha + + + , ); expect(container.innerHTML).toMatchSnapshot(); }); it("with groups", () => { const { container } = render( - - - - - - Fruits - Apple - - - - , + + + + + + Fruits + Apple + + + + , ); expect(container.innerHTML).toMatchSnapshot(); }); @@ -137,14 +135,14 @@ describe("Combobox.Parts", () => { it("applies custom className to input", () => { render( - - - - - Apple - - - , + + + + + Apple + + + , ); expect(screen.getByTestId("input").classList.contains("custom-class")).toBe(true); @@ -154,17 +152,17 @@ describe("Combobox.Parts", () => { const user = userEvent.setup(); render( - - - - - - Fruits - Apple - - - - , + + + + + + Fruits + Apple + + + + , ); const input = screen.getByTestId("input"); @@ -186,17 +184,17 @@ describe("Combobox.Parts", () => { it("renders the trigger with default icon", () => { render( - +
- - + +
- - - Apple - - -
, + + + Apple + + + , ); expect(screen.getByTestId("trigger")).toBeDefined(); @@ -204,17 +202,17 @@ describe("Combobox.Parts", () => { it("renders the clear button", () => { render( - +
- - + +
- - - Apple - - -
, + + + Apple + + + , ); expect(screen.getByTestId("clear")).toBeDefined(); @@ -241,7 +239,7 @@ const createTag = (value: string): Tag => ({ name: value, }); -describe("Combobox.Parts.useCreatable", () => { +describe("useCreatable", () => { describe("multiple mode", () => { const defaultOptions = { items: initialTags, @@ -251,14 +249,14 @@ describe("Combobox.Parts.useCreatable", () => { }; it("returns items unchanged when inputValue is empty", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); expect(result.current.items).toBe(initialTags); expect(result.current.value).toEqual([]); expect(result.current.multiple).toBe(true); }); it("appends a sentinel item when query has no exact match", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); act(() => { result.current.onInputValueChange("Svelte"); @@ -270,7 +268,7 @@ describe("Combobox.Parts.useCreatable", () => { }); it("does not append sentinel when query exactly matches an item (case-insensitive)", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); act(() => { result.current.onInputValueChange("react"); @@ -284,7 +282,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated, onValueChange, @@ -314,7 +312,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated: () => false, onValueChange, @@ -340,7 +338,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated: (_item: Tag) => new Promise((resolve) => { @@ -378,7 +376,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated: (_item: Tag) => new Promise((resolve) => { @@ -411,7 +409,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated: async (_item: Tag) => { // Simulate API call @@ -438,7 +436,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated: async (_item: Tag): Promise => { return false; @@ -465,7 +463,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated: async (_item: Tag) => { throw new Error("API error"); @@ -492,7 +490,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onValueChange, }), @@ -508,7 +506,7 @@ describe("Combobox.Parts.useCreatable", () => { it("respects defaultValue", () => { const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, defaultValue: [initialTags[0]], }), @@ -519,7 +517,7 @@ describe("Combobox.Parts.useCreatable", () => { it("uses custom formatCreateLabel", () => { const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, formatCreateLabel: (v) => `「${v}」を作成`, }), @@ -537,7 +535,7 @@ describe("Combobox.Parts.useCreatable", () => { }; it("returns value as null initially", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); expect(result.current.value).toBeNull(); expect(result.current.multiple).toBe(false); @@ -548,7 +546,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated, onValueChange, @@ -574,7 +572,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onValueChange, }), @@ -592,7 +590,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, defaultValue: initialTags[0], onValueChange, @@ -618,7 +616,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated, onValueChange, @@ -649,7 +647,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, onItemCreated: () => false, onValueChange, @@ -672,7 +670,7 @@ describe("Combobox.Parts.useCreatable", () => { it("respects defaultValue in single mode", () => { const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ ...defaultOptions, defaultValue: initialTags[1], }), @@ -685,7 +683,7 @@ describe("Combobox.Parts.useCreatable", () => { describe("isCreateItem / getCreateLabel", () => { it("correctly identifies sentinel vs regular items", () => { const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, multiple: true, getLabel: (item: Tag) => item.name, @@ -726,7 +724,7 @@ describe("Combobox.Parts.useCreatable", () => { }; it("trims leading/trailing whitespace for sentinel check", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); act(() => { result.current.onInputValueChange(" Svelte "); @@ -739,7 +737,7 @@ describe("Combobox.Parts.useCreatable", () => { }); it("does not append sentinel for whitespace-only input", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); act(() => { result.current.onInputValueChange(" "); @@ -749,7 +747,7 @@ describe("Combobox.Parts.useCreatable", () => { }); it("matches exact item even with leading/trailing spaces in query", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); act(() => { result.current.onInputValueChange(" React "); @@ -770,7 +768,7 @@ describe("Combobox.Parts.useCreatable", () => { const serverTag: Tag = { id: "server-id", name: "Svelte" }; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -798,7 +796,7 @@ describe("Combobox.Parts.useCreatable", () => { const serverTag: Tag = { id: "server-id", name: "Svelte" }; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, multiple: true as const, getLabel: (item: Tag) => item.name, @@ -827,7 +825,7 @@ describe("Combobox.Parts.useCreatable", () => { const serverTag: Tag = { id: "server-id", name: "Svelte" }; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -860,7 +858,7 @@ describe("Combobox.Parts.useCreatable", () => { let resolvePromise!: () => void; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -902,7 +900,7 @@ describe("Combobox.Parts.useCreatable", () => { let rejectPromise!: () => void; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -944,7 +942,7 @@ describe("Combobox.Parts.useCreatable", () => { }; it("returns formatted create label for sentinel item", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); act(() => { result.current.onInputValueChange("Svelte"); @@ -955,13 +953,13 @@ describe("Combobox.Parts.useCreatable", () => { }); it("returns getLabel for regular items", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); expect(result.current.itemToStringLabel(initialTags[0])).toBe("React"); }); it("returns empty string for sentinel itemToStringValue", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); act(() => { result.current.onInputValueChange("Svelte"); @@ -972,7 +970,7 @@ describe("Combobox.Parts.useCreatable", () => { }); it("returns getLabel for regular items in itemToStringValue", () => { - const { result } = renderHook(() => Combobox.Parts.useCreatable(defaultOptions)); + const { result } = renderHook(() => useCreatable(defaultOptions)); expect(result.current.itemToStringValue(initialTags[0])).toBe("React"); }); @@ -988,7 +986,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, multiple: true as const, getLabel: (item: Tag) => item.name, @@ -1024,7 +1022,7 @@ describe("Combobox.Parts.useCreatable", () => { it("clears input after creating in multiple mode", () => { const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, multiple: true as const, getLabel: (item: Tag) => item.name, @@ -1056,7 +1054,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -1082,7 +1080,7 @@ describe("Combobox.Parts.useCreatable", () => { const onValueChange = vi.fn(); const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, multiple: true as const, getLabel: (item: Tag) => item.name, @@ -1113,7 +1111,7 @@ describe("Combobox.Parts.useCreatable", () => { describe("creating state", () => { it("is false initially", () => { const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -1127,7 +1125,7 @@ describe("Combobox.Parts.useCreatable", () => { let resolvePromise!: () => void; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -1163,7 +1161,7 @@ describe("Combobox.Parts.useCreatable", () => { let resolvePromise!: () => void; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, multiple: true as const, getLabel: (item: Tag) => item.name, @@ -1198,7 +1196,7 @@ describe("Combobox.Parts.useCreatable", () => { let rejectPromise!: () => void; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -1232,7 +1230,7 @@ describe("Combobox.Parts.useCreatable", () => { let resolvePromise!: (v: false) => void; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -1266,7 +1264,7 @@ describe("Combobox.Parts.useCreatable", () => { let resolvePromise!: () => void; const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -1303,7 +1301,7 @@ describe("Combobox.Parts.useCreatable", () => { it("stays false for sync onItemCreated", () => { const { result } = renderHook(() => - Combobox.Parts.useCreatable({ + useCreatable({ items: initialTags, getLabel: (item: Tag) => item.name, createItem: createTag, @@ -1328,10 +1326,10 @@ describe("Combobox.Parts.useCreatable", () => { }); // ============================================================================ -// Combobox.Parts.useAsync tests +// useAsync tests // ============================================================================ -describe("Combobox.Parts.useAsync", () => { +describe("useAsync", () => { beforeEach(() => { vi.useFakeTimers(); }); @@ -1349,7 +1347,7 @@ describe("Combobox.Parts.useAsync", () => { it("returns correct shape", () => { const fetcher = vi.fn(async () => []); - const { result } = renderHook(() => Combobox.Parts.useAsync({ fetcher })); + const { result } = renderHook(() => useAsync({ fetcher })); expect(result.current).toHaveProperty("items"); expect(result.current).toHaveProperty("loading"); @@ -1362,7 +1360,7 @@ describe("Combobox.Parts.useAsync", () => { const items = [{ id: 1, name: "React" }]; const fetcher = vi.fn(async () => items); - const { result } = renderHook(() => Combobox.Parts.useAsync({ fetcher })); + const { result } = renderHook(() => useAsync({ fetcher })); act(() => { result.current.onInputValueChange("react"); @@ -1383,7 +1381,7 @@ describe("Combobox.Parts.useAsync", () => { it("updates query on input change", () => { const fetcher = vi.fn(async () => []); - const { result } = renderHook(() => Combobox.Parts.useAsync({ fetcher })); + const { result } = renderHook(() => useAsync({ fetcher })); act(() => { result.current.onInputValueChange("hello"); @@ -1394,9 +1392,7 @@ describe("Combobox.Parts.useAsync", () => { it("respects custom debounceMs via object fetcher", async () => { const fetcher = vi.fn(async () => ["a"]); - const { result } = renderHook(() => - Combobox.Parts.useAsync({ fetcher: { fn: fetcher, debounceMs: 100 } }), - ); + const { result } = renderHook(() => useAsync({ fetcher: { fn: fetcher, debounceMs: 100 } })); act(() => { result.current.onInputValueChange("test"); @@ -1411,7 +1407,7 @@ describe("Combobox.Parts.useAsync", () => { it("calls fetcher with null when input is empty", async () => { const fetcher = vi.fn(async (_q: string | null) => (_q === null ? [] : ["a", "b"])); - const { result } = renderHook(() => Combobox.Parts.useAsync({ fetcher })); + const { result } = renderHook(() => useAsync({ fetcher })); act(() => { result.current.onInputValueChange("test"); @@ -1431,7 +1427,7 @@ describe("Combobox.Parts.useAsync", () => { const fetcher = vi.fn(async () => { throw error; }); - const { result } = renderHook(() => Combobox.Parts.useAsync({ fetcher })); + const { result } = renderHook(() => useAsync({ fetcher })); act(() => { result.current.onInputValueChange("test"); @@ -1451,7 +1447,7 @@ describe("Combobox.Parts.useAsync", () => { }); }); - const { result } = renderHook(() => Combobox.Parts.useAsync({ fetcher })); + const { result } = renderHook(() => useAsync({ fetcher })); act(() => { result.current.onInputValueChange("first"); diff --git a/packages/core/src/components/combobox.tsx b/packages/core/src/components/combobox.tsx index 363c6e9..f132457 100644 --- a/packages/core/src/components/combobox.tsx +++ b/packages/core/src/components/combobox.tsx @@ -722,6 +722,27 @@ function useAsync(options: UseAsyncItemsOptions): UseAsyncItemsReturn { return useAsyncItems(options); } +/** + * Hook for robust string matching using `Intl.Collator`. + * + * Returns an object with `contains`, `startsWith`, and `endsWith` methods + * that can be passed to the `filter` prop of `` + * to customize how items are filtered against the input value. + * + * Accepts `Intl.CollatorOptions` (e.g. `sensitivity`, `usage`) plus + * an optional `locale` for locale-aware matching. + * + * @example + * ```tsx + * const filter = Combobox.useFilter({ sensitivity: "base" }); + * + * + * ... + * + * ``` + */ +const useFilter = BaseCombobox.useFilter; + // ============================================================================ // Export // ============================================================================ @@ -744,9 +765,6 @@ const ComboboxParts = { Value: ComboboxValue, Collection: ComboboxCollection, Status: ComboboxStatus, - useFilter: BaseCombobox.useFilter, - useCreatable, - useAsync, }; type ComboboxParts = typeof ComboboxParts; @@ -772,4 +790,5 @@ export { ComboboxParts, useCreatable, useAsync, + useFilter, }; diff --git a/packages/core/src/components/select-standalone.tsx b/packages/core/src/components/select-standalone.tsx index 06107c7..5cd42c5 100644 --- a/packages/core/src/components/select-standalone.tsx +++ b/packages/core/src/components/select-standalone.tsx @@ -179,40 +179,69 @@ function SelectStandalone(props: SelectStandaloneProps) { } // ============================================================================ -// Select.Async +// Select.useAsync // ============================================================================ -interface SelectAsyncOwnProps { +interface UseSelectAsyncOptions { /** Fetcher for async item loading. Called each time the dropdown is opened. */ fetcher: SelectAsyncFetcher; - /** Text shown while loading. @default "Loading..." */ - loadingText?: string; } -type SelectAsyncProps = - | (SelectAsyncOwnProps & SelectPropsSingle) - | (SelectAsyncOwnProps & SelectPropsMultiple); - -function SelectAsyncStandalone(props: SelectAsyncProps) { - const { - fetcher, - placeholder, - loadingText = "Loading...", - mapItem: mapItemProp, - className, - disabled, - ...rest - } = props; +interface UseSelectAsyncReturn { + /** Fetched items — pass to the Root `items` prop or render manually */ + items: T[]; + /** Whether a fetch is currently in-flight */ + loading: boolean; + /** The error thrown by the last fetch, if any */ + error: unknown; + /** Open change handler — pass to the Root `onOpenChange` prop */ + onOpenChange: (open: boolean) => void; +} +/** + * Hook that encapsulates the async select pattern — fetching on open, + * request cancellation via `AbortController`, and loading/error state. + * + * Unlike `Combobox.useAsync`, this does **not** involve a search query + * or debouncing — Select has no text input. The fetcher is called each + * time the dropdown opens. + * + * @example + * ```tsx + * const countries = Select.useAsync({ + * fetcher: async ({ signal }) => { + * const res = await fetch("/api/countries", { signal }); + * return res.json(); + * }, + * }); + * + * + * + * + * + * + * {countries.loading + * ? "Loading..." + * : countries.items.map((c) => ( + * {c.name} + * ))} + * + * + * ``` + */ +function useAsync({ fetcher }: UseSelectAsyncOptions): UseSelectAsyncReturn { const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); const fetcherRef = useRef(fetcher); fetcherRef.current = fetcher; const abortControllerRef = useRef(null); - const handleOpenChange = React.useCallback((open: boolean) => { + const onOpenChange = React.useCallback((open: boolean) => { if (open) { abortControllerRef.current?.abort(); const controller = new AbortController(); @@ -224,12 +253,14 @@ function SelectAsyncStandalone(props: SelectAsyncProps) { .then((result) => { if (!controller.signal.aborted) { setItems(result); + setError(undefined); } }) .catch((e) => { if (e instanceof DOMException && e.name === "AbortError") return; if (!controller.signal.aborted) { setItems([]); + setError(e); } }) .finally(() => { @@ -240,12 +271,41 @@ function SelectAsyncStandalone(props: SelectAsyncProps) { } }, []); - // Abort any in-flight request when the component unmounts - // (e.g. page navigation while the dropdown is open and fetching). useEffect(() => { return () => abortControllerRef.current?.abort(); }, []); + return { items, loading, error, onOpenChange }; +} + +// ============================================================================ +// Select.Async +// ============================================================================ + +interface SelectAsyncOwnProps { + /** Fetcher for async item loading. Called each time the dropdown is opened. */ + fetcher: SelectAsyncFetcher; + /** Text shown while loading. @default "Loading..." */ + loadingText?: string; +} + +type SelectAsyncProps = + | (SelectAsyncOwnProps & SelectPropsSingle) + | (SelectAsyncOwnProps & SelectPropsMultiple); + +function SelectAsyncStandalone(props: SelectAsyncProps) { + const { + fetcher, + placeholder, + loadingText = "Loading...", + mapItem: mapItemProp, + className, + disabled, + ...rest + } = props; + + const { items, loading, onOpenChange: handleOpenChange } = useAsync({ fetcher }); + const mapItem = (mapItemProp ?? defaultMapItem) as (item: T) => MappedItem; const getLabel = (item: T) => mapItem(item).label; @@ -322,6 +382,7 @@ function SelectAsyncStandalone(props: SelectAsyncProps) { const Select = Object.assign(SelectStandalone, { Async: SelectAsyncStandalone, Parts: SelectParts, + useAsync, }); -export { Select }; +export { Select, useAsync };