From 7c92ba32296ab532a7d742f2599a4bf2eb4a5075 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 2 Apr 2026 16:35:17 +0900 Subject: [PATCH 1/3] Update API and docs for dropdown components --- docs/components/autocomplete.md | 34 ++- docs/components/combobox.md | 39 ++- docs/components/select.md | 32 +++ .../core/src/components/autocomplete.test.tsx | 126 +++++---- packages/core/src/components/autocomplete.tsx | 63 ++++- .../core/src/components/combobox.test.tsx | 254 +++++++++--------- packages/core/src/components/combobox.tsx | 150 ++++++++--- .../core/src/components/select-standalone.tsx | 105 ++++++-- 8 files changed, 543 insertions(+), 260 deletions(-) diff --git a/docs/components/autocomplete.md b/docs/components/autocomplete.md index b5395236..c98b92d5 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 656b476d..13e34849 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 6c118ddd..458a1abf 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.test.tsx b/packages/core/src/components/autocomplete.test.tsx index 5fe284cb..f5e848a0 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 e311a75a..3a1d8ccf 100644 --- a/packages/core/src/components/autocomplete.tsx +++ b/packages/core/src/components/autocomplete.tsx @@ -11,7 +11,12 @@ import type { UseAsyncItemsOptions } from "@/hooks/use-async-items"; // upstream changes don't leak as breaking changes to consumers. type AutocompletePickedRootProps = Pick< AutocompleteRootProps, - "value" | "defaultValue" | "onValueChange" | "filter" | "disabled" | "children" + | "value" + | "defaultValue" + | "onValueChange" + | "filter" + | "disabled" + | "children" > & { items?: readonly Value[]; }; @@ -26,7 +31,9 @@ function AutocompleteRoot(props: AutocompletePickedRootProps) { } AutocompleteRoot.displayName = "Autocomplete.Root"; -function AutocompleteValue({ ...props }: React.ComponentProps) { +function AutocompleteValue({ + ...props +}: React.ComponentProps) { return ; } AutocompleteValue.displayName = "Autocomplete.Value"; @@ -76,7 +83,11 @@ function AutocompleteTrigger({ } AutocompleteTrigger.displayName = "Autocomplete.Trigger"; -function AutocompleteInputGroup({ className, children, ...props }: React.ComponentProps<"div">) { +function AutocompleteInputGroup({ + className, + children, + ...props +}: React.ComponentProps<"div">) { return (
) { return ( - + ) { +function AutocompleteGroup({ + ...props +}: React.ComponentProps) { return ; } AutocompleteGroup.displayName = "Autocomplete.Group"; @@ -212,7 +227,12 @@ AutocompleteGroupLabel.displayName = "Autocomplete.GroupLabel"; function AutocompleteCollection({ ...props }: React.ComponentProps) { - return ; + return ( + + ); } AutocompleteCollection.displayName = "Autocomplete.Collection"; @@ -293,8 +313,11 @@ export interface AutocompleteUseAsyncReturn { * * ``` */ -function useAsync(options: UseAsyncItemsOptions): AutocompleteUseAsyncReturn { - const { items, loading, query, error, onInputValueChange } = useAsyncItems(options); +function useAsync( + options: UseAsyncItemsOptions, +): AutocompleteUseAsyncReturn { + const { items, loading, query, error, onInputValueChange } = + useAsyncItems(options); return { items, @@ -305,6 +328,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 +368,6 @@ const AutocompleteParts = { GroupLabel: AutocompleteGroupLabel, Collection: AutocompleteCollection, Status: AutocompleteStatus, - useFilter: BaseAutocomplete.useFilter, - useAsync, }; type AutocompleteParts = typeof AutocompleteParts; @@ -347,4 +389,5 @@ export { AutocompleteStatus, AutocompleteParts, useAsync, + useFilter, }; diff --git a/packages/core/src/components/combobox.test.tsx b/packages/core/src/components/combobox.test.tsx index f42b6d74..4fb537ee 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 363c6e95..791e1018 100644 --- a/packages/core/src/components/combobox.tsx +++ b/packages/core/src/components/combobox.tsx @@ -4,12 +4,18 @@ import { Combobox as BaseCombobox } from "@base-ui/react/combobox"; import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAsyncItems } from "@/hooks/use-async-items"; -import type { UseAsyncItemsOptions, UseAsyncItemsReturn } from "@/hooks/use-async-items"; +import type { + UseAsyncItemsOptions, + UseAsyncItemsReturn, +} from "@/hooks/use-async-items"; // Only the props relevant to the Combobox abstraction are picked from BaseCombobox.Root. // Base UI-internal props are intentionally excluded so that // upstream changes don't leak as breaking changes to consumers. -type ComboboxRootProps = Pick< +type ComboboxRootProps< + Value, + Multiple extends boolean | undefined = false, +> = Pick< React.ComponentProps>, | "items" | "value" @@ -33,7 +39,10 @@ function ComboboxRoot( } ComboboxRoot.displayName = "Combobox.Root"; -function ComboboxInput({ className, ...props }: React.ComponentProps) { +function ComboboxInput({ + className, + ...props +}: React.ComponentProps) { return ( ) { +function ComboboxContent({ + className, + ...props +}: React.ComponentProps) { return ( - + ) { +function ComboboxList({ + className, + ...props +}: React.ComponentProps) { return ( ) { +function ComboboxEmpty({ + className, + ...props +}: React.ComponentProps) { return ( ) { +function ComboboxGroup({ + ...props +}: React.ComponentProps) { return ; } ComboboxGroup.displayName = "Combobox.Group"; @@ -211,7 +233,10 @@ function ComboboxClear({ } ComboboxClear.displayName = "Combobox.Clear"; -function ComboboxChips({ className, ...props }: React.ComponentProps) { +function ComboboxChips({ + className, + ...props +}: React.ComponentProps) { return ( ) { +function ComboboxChip({ + className, + ...props +}: React.ComponentProps) { return ( ) { +function ComboboxValue({ + ...props +}: React.ComponentProps) { return ; } ComboboxValue.displayName = "Combobox.Value"; -function ComboboxStatus({ className, ...props }: React.ComponentProps) { +function ComboboxStatus({ + className, + ...props +}: React.ComponentProps) { return ( ) { +function ComboboxCollection({ + ...props +}: React.ComponentProps) { return ; } ComboboxCollection.displayName = "Combobox.Collection"; @@ -345,14 +380,18 @@ interface UseCreatableOptionsBase { formatCreateLabel?: (value: string) => string; } -interface UseCreatableOptionsMultiple extends UseCreatableOptionsBase { +interface UseCreatableOptionsMultiple< + T extends object, +> extends UseCreatableOptionsBase { multiple: true; defaultValue?: T[]; /** Called when selection changes (sentinel items are already excluded) */ onValueChange?: (value: T[]) => void; } -interface UseCreatableOptionsSingle extends UseCreatableOptionsBase { +interface UseCreatableOptionsSingle< + T extends object, +> extends UseCreatableOptionsBase { multiple?: false | undefined; defaultValue?: T | null; /** Called when selection changes */ @@ -363,7 +402,9 @@ interface UseCreatableOptionsSingle extends UseCreatableOption * Combined options type for callers that don't know single vs multiple at compile time. * Accepts the union of both value shapes. */ -interface UseCreatableOptionsCombined extends UseCreatableOptionsBase { +interface UseCreatableOptionsCombined< + T extends object, +> extends UseCreatableOptionsBase { multiple?: boolean; defaultValue?: T[] | T | null; onValueChange?: ((value: T[]) => void) | ((value: T | null) => void); @@ -474,8 +515,12 @@ function useCreatable( // Type-safe accessors const multiValue = selection as T[]; const singleValue = selection as T | null; - const setMultiValue = setSelection as React.Dispatch>; - const setSingleValue = setSelection as React.Dispatch>; + const setMultiValue = setSelection as React.Dispatch< + React.SetStateAction + >; + const setSingleValue = setSelection as React.Dispatch< + React.SetStateAction + >; // Tracks the label being created during an async onItemCreated flow. // While set, onInputValueChange ignores base-ui's close-handler clearing @@ -487,7 +532,8 @@ function useCreatable( const trimmed = query.trim(); const lowered = trimmed.toLocaleLowerCase(); const exactExists = - trimmed !== "" && items.some((item) => getLabel(item).trim().toLocaleLowerCase() === lowered); + trimmed !== "" && + items.some((item) => getLabel(item).trim().toLocaleLowerCase() === lowered); // --- Sentinel: the "Create X" option appended to the items list --- // Hide the sentinel while an async create is in-flight to prevent double-clicks. @@ -546,10 +592,18 @@ function useCreatable( const base = baseMultiValue ?? []; const next = [...base, selected]; setMultiValue(next); - (onValueChange as UseCreatableOptionsMultiple["onValueChange"] | undefined)?.(next); + ( + onValueChange as + | UseCreatableOptionsMultiple["onValueChange"] + | undefined + )?.(next); } else { setSingleValue(selected); - (onValueChange as UseCreatableOptionsSingle["onValueChange"] | undefined)?.(selected); + ( + onValueChange as + | UseCreatableOptionsSingle["onValueChange"] + | undefined + )?.(selected); } setQuery(isMultiple ? "" : getLabel(selected)); }; @@ -573,7 +627,9 @@ function useCreatable( return; } // If result is an object (T), use it as the selected value - applySelection(result != null && typeof result === "object" ? result : undefined); + applySelection( + result != null && typeof result === "object" ? result : undefined, + ); }; const handleError = () => { @@ -586,7 +642,10 @@ function useCreatable( const result = onItemCreated(newItem); // If callback returned a Promise, handle async - if (result != null && typeof (result as Promise).then === "function") { + if ( + result != null && + typeof (result as Promise).then === "function" + ) { setCreating(true); (result as Promise).then(handleResult, handleError); } else { @@ -611,15 +670,23 @@ function useCreatable( // --- Value change handlers --- const handleMultipleValueChange = useCallback( (next: T[]) => { - const creatableItem = next.find((item) => sentinel !== null && item === sentinel.item); + const creatableItem = next.find( + (item) => sentinel !== null && item === sentinel.item, + ); if (creatableItem && sentinel) { const label = sentinel.label; - const cleaned = next.filter((item) => !(sentinel !== null && item === sentinel.item)); + const cleaned = next.filter( + (item) => !(sentinel !== null && item === sentinel.item), + ); performCreate(label, cleaned); return; } setMultiValue(next); - (onValueChange as UseCreatableOptionsMultiple["onValueChange"] | undefined)?.(next); + ( + onValueChange as + | UseCreatableOptionsMultiple["onValueChange"] + | undefined + )?.(next); setQuery(""); }, [performCreate, onValueChange, sentinel, setMultiValue], @@ -633,7 +700,11 @@ function useCreatable( return; } setSingleValue(next); - (onValueChange as UseCreatableOptionsSingle["onValueChange"] | undefined)?.(next); + ( + onValueChange as + | UseCreatableOptionsSingle["onValueChange"] + | undefined + )?.(next); setQuery(""); }, [performCreate, onValueChange, sentinel, setSingleValue], @@ -722,6 +793,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 +836,6 @@ const ComboboxParts = { Value: ComboboxValue, Collection: ComboboxCollection, Status: ComboboxStatus, - useFilter: BaseCombobox.useFilter, - useCreatable, - useAsync, }; type ComboboxParts = typeof ComboboxParts; @@ -772,4 +861,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 06107c7a..5cd42c5d 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 }; From 8b23cf667c1e768aebd19a2e0d87866a471487c4 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 2 Apr 2026 16:35:54 +0900 Subject: [PATCH 2/3] Format --- packages/core/src/components/autocomplete.tsx | 39 ++---- packages/core/src/components/combobox.tsx | 125 ++++-------------- 2 files changed, 35 insertions(+), 129 deletions(-) diff --git a/packages/core/src/components/autocomplete.tsx b/packages/core/src/components/autocomplete.tsx index 3a1d8ccf..51925954 100644 --- a/packages/core/src/components/autocomplete.tsx +++ b/packages/core/src/components/autocomplete.tsx @@ -11,12 +11,7 @@ import type { UseAsyncItemsOptions } from "@/hooks/use-async-items"; // upstream changes don't leak as breaking changes to consumers. type AutocompletePickedRootProps = Pick< AutocompleteRootProps, - | "value" - | "defaultValue" - | "onValueChange" - | "filter" - | "disabled" - | "children" + "value" | "defaultValue" | "onValueChange" | "filter" | "disabled" | "children" > & { items?: readonly Value[]; }; @@ -31,9 +26,7 @@ function AutocompleteRoot(props: AutocompletePickedRootProps) { } AutocompleteRoot.displayName = "Autocomplete.Root"; -function AutocompleteValue({ - ...props -}: React.ComponentProps) { +function AutocompleteValue({ ...props }: React.ComponentProps) { return ; } AutocompleteValue.displayName = "Autocomplete.Value"; @@ -83,11 +76,7 @@ function AutocompleteTrigger({ } AutocompleteTrigger.displayName = "Autocomplete.Trigger"; -function AutocompleteInputGroup({ - className, - children, - ...props -}: React.ComponentProps<"div">) { +function AutocompleteInputGroup({ className, children, ...props }: React.ComponentProps<"div">) { return (
) { return ( - + ) { +function AutocompleteGroup({ ...props }: React.ComponentProps) { return ; } AutocompleteGroup.displayName = "Autocomplete.Group"; @@ -227,12 +212,7 @@ AutocompleteGroupLabel.displayName = "Autocomplete.GroupLabel"; function AutocompleteCollection({ ...props }: React.ComponentProps) { - return ( - - ); + return ; } AutocompleteCollection.displayName = "Autocomplete.Collection"; @@ -313,11 +293,8 @@ export interface AutocompleteUseAsyncReturn { * * ``` */ -function useAsync( - options: UseAsyncItemsOptions, -): AutocompleteUseAsyncReturn { - const { items, loading, query, error, onInputValueChange } = - useAsyncItems(options); +function useAsync(options: UseAsyncItemsOptions): AutocompleteUseAsyncReturn { + const { items, loading, query, error, onInputValueChange } = useAsyncItems(options); return { items, diff --git a/packages/core/src/components/combobox.tsx b/packages/core/src/components/combobox.tsx index 791e1018..f132457f 100644 --- a/packages/core/src/components/combobox.tsx +++ b/packages/core/src/components/combobox.tsx @@ -4,18 +4,12 @@ import { Combobox as BaseCombobox } from "@base-ui/react/combobox"; import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { useAsyncItems } from "@/hooks/use-async-items"; -import type { - UseAsyncItemsOptions, - UseAsyncItemsReturn, -} from "@/hooks/use-async-items"; +import type { UseAsyncItemsOptions, UseAsyncItemsReturn } from "@/hooks/use-async-items"; // Only the props relevant to the Combobox abstraction are picked from BaseCombobox.Root. // Base UI-internal props are intentionally excluded so that // upstream changes don't leak as breaking changes to consumers. -type ComboboxRootProps< - Value, - Multiple extends boolean | undefined = false, -> = Pick< +type ComboboxRootProps = Pick< React.ComponentProps>, | "items" | "value" @@ -39,10 +33,7 @@ function ComboboxRoot( } ComboboxRoot.displayName = "Combobox.Root"; -function ComboboxInput({ - className, - ...props -}: React.ComponentProps) { +function ComboboxInput({ className, ...props }: React.ComponentProps) { return ( ) { +function ComboboxContent({ className, ...props }: React.ComponentProps) { return ( - + ) { +function ComboboxList({ className, ...props }: React.ComponentProps) { return ( ) { +function ComboboxEmpty({ className, ...props }: React.ComponentProps) { return ( ) { +function ComboboxGroup({ ...props }: React.ComponentProps) { return ; } ComboboxGroup.displayName = "Combobox.Group"; @@ -233,10 +211,7 @@ function ComboboxClear({ } ComboboxClear.displayName = "Combobox.Clear"; -function ComboboxChips({ - className, - ...props -}: React.ComponentProps) { +function ComboboxChips({ className, ...props }: React.ComponentProps) { return ( ) { +function ComboboxChip({ className, ...props }: React.ComponentProps) { return ( ) { +function ComboboxValue({ ...props }: React.ComponentProps) { return ; } ComboboxValue.displayName = "Combobox.Value"; -function ComboboxStatus({ - className, - ...props -}: React.ComponentProps) { +function ComboboxStatus({ className, ...props }: React.ComponentProps) { return ( ) { +function ComboboxCollection({ ...props }: React.ComponentProps) { return ; } ComboboxCollection.displayName = "Combobox.Collection"; @@ -380,18 +345,14 @@ interface UseCreatableOptionsBase { formatCreateLabel?: (value: string) => string; } -interface UseCreatableOptionsMultiple< - T extends object, -> extends UseCreatableOptionsBase { +interface UseCreatableOptionsMultiple extends UseCreatableOptionsBase { multiple: true; defaultValue?: T[]; /** Called when selection changes (sentinel items are already excluded) */ onValueChange?: (value: T[]) => void; } -interface UseCreatableOptionsSingle< - T extends object, -> extends UseCreatableOptionsBase { +interface UseCreatableOptionsSingle extends UseCreatableOptionsBase { multiple?: false | undefined; defaultValue?: T | null; /** Called when selection changes */ @@ -402,9 +363,7 @@ interface UseCreatableOptionsSingle< * Combined options type for callers that don't know single vs multiple at compile time. * Accepts the union of both value shapes. */ -interface UseCreatableOptionsCombined< - T extends object, -> extends UseCreatableOptionsBase { +interface UseCreatableOptionsCombined extends UseCreatableOptionsBase { multiple?: boolean; defaultValue?: T[] | T | null; onValueChange?: ((value: T[]) => void) | ((value: T | null) => void); @@ -515,12 +474,8 @@ function useCreatable( // Type-safe accessors const multiValue = selection as T[]; const singleValue = selection as T | null; - const setMultiValue = setSelection as React.Dispatch< - React.SetStateAction - >; - const setSingleValue = setSelection as React.Dispatch< - React.SetStateAction - >; + const setMultiValue = setSelection as React.Dispatch>; + const setSingleValue = setSelection as React.Dispatch>; // Tracks the label being created during an async onItemCreated flow. // While set, onInputValueChange ignores base-ui's close-handler clearing @@ -532,8 +487,7 @@ function useCreatable( const trimmed = query.trim(); const lowered = trimmed.toLocaleLowerCase(); const exactExists = - trimmed !== "" && - items.some((item) => getLabel(item).trim().toLocaleLowerCase() === lowered); + trimmed !== "" && items.some((item) => getLabel(item).trim().toLocaleLowerCase() === lowered); // --- Sentinel: the "Create X" option appended to the items list --- // Hide the sentinel while an async create is in-flight to prevent double-clicks. @@ -592,18 +546,10 @@ function useCreatable( const base = baseMultiValue ?? []; const next = [...base, selected]; setMultiValue(next); - ( - onValueChange as - | UseCreatableOptionsMultiple["onValueChange"] - | undefined - )?.(next); + (onValueChange as UseCreatableOptionsMultiple["onValueChange"] | undefined)?.(next); } else { setSingleValue(selected); - ( - onValueChange as - | UseCreatableOptionsSingle["onValueChange"] - | undefined - )?.(selected); + (onValueChange as UseCreatableOptionsSingle["onValueChange"] | undefined)?.(selected); } setQuery(isMultiple ? "" : getLabel(selected)); }; @@ -627,9 +573,7 @@ function useCreatable( return; } // If result is an object (T), use it as the selected value - applySelection( - result != null && typeof result === "object" ? result : undefined, - ); + applySelection(result != null && typeof result === "object" ? result : undefined); }; const handleError = () => { @@ -642,10 +586,7 @@ function useCreatable( const result = onItemCreated(newItem); // If callback returned a Promise, handle async - if ( - result != null && - typeof (result as Promise).then === "function" - ) { + if (result != null && typeof (result as Promise).then === "function") { setCreating(true); (result as Promise).then(handleResult, handleError); } else { @@ -670,23 +611,15 @@ function useCreatable( // --- Value change handlers --- const handleMultipleValueChange = useCallback( (next: T[]) => { - const creatableItem = next.find( - (item) => sentinel !== null && item === sentinel.item, - ); + const creatableItem = next.find((item) => sentinel !== null && item === sentinel.item); if (creatableItem && sentinel) { const label = sentinel.label; - const cleaned = next.filter( - (item) => !(sentinel !== null && item === sentinel.item), - ); + const cleaned = next.filter((item) => !(sentinel !== null && item === sentinel.item)); performCreate(label, cleaned); return; } setMultiValue(next); - ( - onValueChange as - | UseCreatableOptionsMultiple["onValueChange"] - | undefined - )?.(next); + (onValueChange as UseCreatableOptionsMultiple["onValueChange"] | undefined)?.(next); setQuery(""); }, [performCreate, onValueChange, sentinel, setMultiValue], @@ -700,11 +633,7 @@ function useCreatable( return; } setSingleValue(next); - ( - onValueChange as - | UseCreatableOptionsSingle["onValueChange"] - | undefined - )?.(next); + (onValueChange as UseCreatableOptionsSingle["onValueChange"] | undefined)?.(next); setQuery(""); }, [performCreate, onValueChange, sentinel, setSingleValue], From c196692f5c4101a31f197bb17952806476eb99d8 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 3 Apr 2026 10:46:35 +0900 Subject: [PATCH 3/3] fix: attach useAsync/useFilter/useCreatable hooks to Autocomplete and Combobox namespaces --- packages/core/src/components/autocomplete-standalone.tsx | 3 +++ packages/core/src/components/combobox-standalone.tsx | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/packages/core/src/components/autocomplete-standalone.tsx b/packages/core/src/components/autocomplete-standalone.tsx index e27d65bf..d07cdb5e 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/combobox-standalone.tsx b/packages/core/src/components/combobox-standalone.tsx index e1e83134..8279248a 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 };