@boxcustodia/library 2.0.0-alpha.30 → 2.0.0-alpha.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -51,12 +51,25 @@ export declare function ComboboxChip({ children, removeProps, ...props }: Combob
51
51
  removeProps?: ComboboxPrimitive.ChipRemove.Props;
52
52
  }): React.ReactElement;
53
53
  export declare function ComboboxChipRemove(props: ComboboxPrimitive.ChipRemove.Props): React.ReactElement;
54
- type ComboboxBaseProps<TItem = unknown> = Omit<ComboboxPrimitive.Root.Props<TItem, boolean>, "items" | "itemToStringLabel" | "itemToStringValue" | "children" | "multiple" | "onValueChange" | "value" | "defaultValue"> & {
54
+ type ComboboxId = string | number;
55
+ /**
56
+ * Infers the id type from the shape of an item, mirroring `defaultGetId`'s
57
+ * lookup order (`id` before `value`, then the primitive itself). Lets the
58
+ * common case — `{ id, name }`, `{ label, value }`, or plain primitives — drive
59
+ * `value` / `onValueChange` typing with no explicit `getId`. Falls back to
60
+ * `string` for shapes `defaultGetId` can't address.
61
+ */
62
+ type InferId<TItem> = TItem extends {
63
+ id: infer I extends ComboboxId;
64
+ } ? I : TItem extends {
65
+ value: infer V extends ComboboxId;
66
+ } ? V : TItem extends ComboboxId ? TItem : string;
67
+ type ComboboxBaseProps<TItem = unknown, TId extends ComboboxId = InferId<TItem>> = Omit<ComboboxPrimitive.Root.Props<TItem, boolean>, "items" | "itemToStringLabel" | "itemToStringValue" | "children" | "multiple" | "onValueChange" | "value" | "defaultValue" | "isItemEqualToValue"> & {
55
68
  items: readonly TItem[];
56
69
  /** Returns the display text for an item. Used for filter matching, ARIA, and the trigger input. Defaults to `item.label`, `item.name`, `item.title`, or the stringified primitive. */
57
70
  getLabel?: (item: TItem) => string;
58
- /** Returns a stable string identifier for an item. Used as the React key and as the hidden form value when `name` is set. Defaults to `item.id`, `item.value`, or the stringified primitive. */
59
- getId?: (item: TItem) => string;
71
+ /** Returns a stable id for an item. Used as the controlled `value`, React key, and hidden form value. Defaults to `item.id`, `item.value`, or the stringified primitive. */
72
+ getId?: (item: TItem) => TId;
60
73
  renderItem?: (item: TItem) => React.ReactNode;
61
74
  placeholder?: string;
62
75
  emptyText?: string;
@@ -83,16 +96,22 @@ type ComboboxBaseProps<TItem = unknown> = Omit<ComboboxPrimitive.Root.Props<TIte
83
96
  empty?: string;
84
97
  };
85
98
  };
86
- export type ComboboxProps<TItem = unknown> = (ComboboxBaseProps<TItem> & {
99
+ export type ComboboxProps<TItem = unknown, TId extends ComboboxId = InferId<TItem>> = (ComboboxBaseProps<TItem, TId> & {
87
100
  multiple?: false;
88
- value?: TItem | null;
89
- defaultValue?: TItem | null;
90
- onValueChange?: (value: TItem | null) => void;
91
- }) | (ComboboxBaseProps<TItem> & {
101
+ /** Controlled selection id. Pass the value returned by `getId` (string or number), not the item object. */
102
+ value?: TId | null;
103
+ /** Initial selection id for uncontrolled usage. */
104
+ defaultValue?: TId | null;
105
+ /** Called with the selected id and the full item when selection changes. Both are `null` when cleared. */
106
+ onValueChange?: (id: TId | null, item: TItem | null) => void;
107
+ }) | (ComboboxBaseProps<TItem, TId> & {
92
108
  multiple: true;
93
- value?: TItem[];
94
- defaultValue?: TItem[];
95
- onValueChange?: (value: TItem[]) => void;
109
+ /** Controlled selection ids for multiple mode. Pass the array of values returned by `getId`. */
110
+ value?: ReadonlyArray<TId>;
111
+ /** Initial selection ids for uncontrolled multiple mode. */
112
+ defaultValue?: ReadonlyArray<TId>;
113
+ /** Called with the array of selected ids and the array of full items when selection changes. */
114
+ onValueChange?: (ids: TId[], items: TItem[]) => void;
96
115
  });
97
116
  /**
98
117
  * Composite combobox for single and multiple selection.
@@ -109,6 +128,6 @@ export type ComboboxProps<TItem = unknown> = (ComboboxBaseProps<TItem> & {
109
128
  * Use `ComboboxRoot` + primitives directly when you need full structural
110
129
  * control beyond what escape-hatch props offer.
111
130
  */
112
- export declare function Combobox<TItem = unknown>(allProps: ComboboxProps<TItem>): React.ReactElement;
131
+ export declare function Combobox<TItem = unknown, TId extends ComboboxId = InferId<TItem>>(allProps: ComboboxProps<TItem, TId>): React.ReactElement;
113
132
  export declare const useComboboxFilter: typeof ComboboxPrimitive.useFilter;
114
133
  export { ComboboxPrimitive };
@@ -1,5 +1,6 @@
1
1
  import { ComponentProps, ComponentType, ElementType, ReactNode } from 'react';
2
2
  import { Button } from '../button';
3
+ import { SelectProps } from '../select';
3
4
  import { PageSize } from './pagination.model';
4
5
  type GetPageHref = (state: {
5
6
  page: number;
@@ -116,9 +117,12 @@ interface PaginationRangeProps {
116
117
  }) => ReactNode);
117
118
  }
118
119
  export declare function PaginationRange({ children, className, ...props }: PaginationRangeProps): import("react/jsx-runtime").JSX.Element;
119
- interface PaginationSizeSelectProps extends Omit<ComponentProps<"select">, "value" | "onChange"> {
120
+ type PaginationSizeSelectProps = Omit<SelectProps<{
121
+ label: string;
122
+ value: PageSize;
123
+ }, PageSize>, "items" | "value" | "defaultValue" | "onValueChange" | "multiple"> & {
120
124
  sizes?: readonly PageSize[] | PageSize[];
121
- }
125
+ };
122
126
  export declare function PaginationSizeSelect({ sizes, className, ...props }: PaginationSizeSelectProps): import("react/jsx-runtime").JSX.Element;
123
127
  interface PaginationProps extends Omit<PaginationRootProps, "children"> {
124
128
  sizes?: readonly PageSize[] | PageSize[] | false;
@@ -29,12 +29,26 @@ export declare function SelectSeparator({ className, ...props }: SelectPrimitive
29
29
  export declare function SelectGroup(props: SelectPrimitive.Group.Props): React.ReactElement;
30
30
  export declare function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props): React.ReactElement;
31
31
  export declare function SelectLabel({ className, ...props }: SelectPrimitive.Label.Props): React.ReactElement;
32
- type SelectBaseProps<TItem = unknown> = Omit<SelectPrimitive.Root.Props<string, false>, "children" | "onValueChange" | "value" | "defaultValue" | "items" | "multiple"> & {
32
+ /** Identifier an item can be addressed by. */
33
+ type SelectId = string | number;
34
+ /**
35
+ * Infers the id type from the shape of an item, mirroring `defaultGetId`'s
36
+ * lookup order (`id` before `value`, then the primitive itself). Lets the
37
+ * common case — `{ id, name }`, `{ label, value }`, or plain primitives — drive
38
+ * `value` / `onValueChange` typing with no explicit `getId`. Falls back to
39
+ * `string` for shapes `defaultGetId` can't address.
40
+ */
41
+ type InferId<TItem> = TItem extends {
42
+ id: infer I extends SelectId;
43
+ } ? I : TItem extends {
44
+ value: infer V extends SelectId;
45
+ } ? V : TItem extends SelectId ? TItem : string;
46
+ type SelectBaseProps<TItem, TId extends SelectId> = Omit<SelectPrimitive.Root.Props<string, false>, "children" | "onValueChange" | "value" | "defaultValue" | "items" | "multiple"> & {
33
47
  items: readonly TItem[];
34
48
  /** Returns the display text for an item. Used for the trigger value and ARIA. Defaults to `item.label`, `item.name`, `item.title`, or the stringified primitive. */
35
49
  getLabel?: (item: TItem) => string;
36
- /** Returns a stable string identifier for an item. Used as the React key and the underlying option value. Defaults to `item.id`, `item.value`, or the stringified primitive. */
37
- getId?: (item: TItem) => string;
50
+ /** Returns a stable identifier for an item — `string` or `number`. Used as the option value and to match against `value`. Its return type drives the `value` / `onValueChange` id type. Defaults to `item.id`, `item.value`, or the stringified primitive. */
51
+ getId?: (item: TItem) => TId;
38
52
  /** Returns whether an item should be disabled. Defaults to `item.disabled === true`. */
39
53
  getDisabled?: (item: TItem) => boolean;
40
54
  renderItem?: (item: TItem) => React.ReactNode;
@@ -50,16 +64,22 @@ type SelectBaseProps<TItem = unknown> = Omit<SelectPrimitive.Root.Props<string,
50
64
  item?: string;
51
65
  };
52
66
  };
53
- export type SelectProps<TItem = unknown> = (SelectBaseProps<TItem> & {
67
+ export type SelectProps<TItem = unknown, TId extends SelectId = InferId<TItem>> = (SelectBaseProps<TItem, TId> & {
54
68
  multiple?: false;
55
- value?: TItem | null;
56
- defaultValue?: TItem | null;
57
- onValueChange?: (value: TItem | null) => void;
58
- }) | (SelectBaseProps<TItem> & {
69
+ /** Controlled selection. The id (whatever `getId` returns — `string` or `number`), or `null` when cleared. */
70
+ value?: TId | null;
71
+ /** Uncontrolled initial selection. The id (whatever `getId` returns). */
72
+ defaultValue?: TId | null;
73
+ /** Called on change with the selected id first, then the resolved item (both `null` when cleared). */
74
+ onValueChange?: (id: TId | null, item: TItem | null) => void;
75
+ }) | (SelectBaseProps<TItem, TId> & {
59
76
  multiple: true;
60
- value?: TItem[];
61
- defaultValue?: TItem[];
62
- onValueChange?: (value: TItem[]) => void;
77
+ /** Controlled selection. The ids (whatever `getId` returns). */
78
+ value?: ReadonlyArray<TId>;
79
+ /** Uncontrolled initial selection. The ids (whatever `getId` returns). */
80
+ defaultValue?: ReadonlyArray<TId>;
81
+ /** Called on change with the selected ids first, then the resolved items. */
82
+ onValueChange?: (ids: TId[], items: TItem[]) => void;
63
83
  });
64
84
  /**
65
85
  * Composite select for single and multiple selection.
@@ -67,12 +87,18 @@ export type SelectProps<TItem = unknown> = (SelectBaseProps<TItem> & {
67
87
  * Items shaped as `{ id, name }`, `{ label, value }`, or plain strings/numbers
68
88
  * work with no extra props. For other shapes, provide `getLabel` and/or `getId`.
69
89
  *
90
+ * `value` / `defaultValue` accept the id only (whatever `getId` returns —
91
+ * `string` or `number`). `onValueChange` always reports the id first and the
92
+ * resolved item second, so it wires directly into react-hook-form
93
+ * (`onValueChange={field.onChange}`) while still exposing the full item when
94
+ * you need to render it.
95
+ *
70
96
  * `renderItem` customizes the content **inside** each `SelectItem` (the
71
97
  * checkmark indicator is always rendered by the item wrapper).
72
98
  *
73
99
  * Use `SelectRoot` + primitives directly when you need full structural
74
100
  * control beyond what `className` + `classNames` slots offer.
75
101
  */
76
- export declare function Select<TItem = unknown>(allProps: SelectProps<TItem>): React.ReactElement;
102
+ export declare function Select<TItem = unknown, TId extends SelectId = InferId<TItem>>(allProps: SelectProps<TItem, TId>): React.ReactElement;
77
103
  export { SelectPrimitive };
78
104
  export { SelectPopup as SelectContent };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxcustodia/library",
3
- "version": "2.0.0-alpha.30",
3
+ "version": "2.0.0-alpha.32",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.es.js",
@@ -32,7 +32,7 @@ function CalendarDropdown({
32
32
  return (
33
33
  <Select
34
34
  items={items}
35
- value={selected}
35
+ value={selected?.value ?? null}
36
36
  icon={<ChevronDownIcon className={selectTriggerIconClassName} />}
37
37
  // Month/year dropdowns are calendar navigation, not form fields. Force
38
38
  // off the invalid state so a surrounding invalid Field never paints them
@@ -40,8 +40,8 @@ function CalendarDropdown({
40
40
  aria-invalid={false}
41
41
  className="py-1 h-7 border-none font-medium! w-fit! [&>span]:capitalize"
42
42
  getLabel={(o) => o.label}
43
- getId={(o) => String(o.value)}
44
- onValueChange={(item) => {
43
+ getId={(o) => o.value}
44
+ onValueChange={(_id, item) => {
45
45
  if (!onChange || item === null) return;
46
46
  onChange({
47
47
  target: { value: String(item.value) },
@@ -110,17 +110,20 @@ export const WithClassNames: Story = {
110
110
  */
111
111
  export const Controlled: Story = {
112
112
  render: () => {
113
- const [value, setValue] = useState<Item | null>(null);
113
+ const [value, setValue] = useState<string | null>(null);
114
114
  return (
115
115
  <div className="flex flex-col gap-4">
116
116
  <Combobox<Item>
117
117
  items={items}
118
118
  placeholder="Select a fruit…"
119
119
  value={value}
120
- onValueChange={setValue}
120
+ onValueChange={(id) => setValue(id)}
121
121
  />
122
122
  <p className="text-sm text-muted-foreground">
123
- Selected: {value ? value.label : "none"}
123
+ Selected:{" "}
124
+ {value
125
+ ? (items.find((i) => i.value === value)?.label ?? value)
126
+ : "none"}
124
127
  </p>
125
128
  </div>
126
129
  );
@@ -142,7 +145,7 @@ export const MultiplePreselected: Story = {
142
145
  <Combobox<Item>
143
146
  items={items}
144
147
  multiple
145
- defaultValue={items.slice(0, 7)}
148
+ defaultValue={items.slice(0, 7).map((i) => i.value)}
146
149
  placeholder="Select fruits…"
147
150
  />
148
151
  ),
@@ -51,7 +51,7 @@ describe("Combobox (single mode)", () => {
51
51
  expect(options.map((o) => o.textContent)).toEqual(["Banana"]);
52
52
  });
53
53
 
54
- it("fires onValueChange with the selected item object", async () => {
54
+ it("fires onValueChange with (id, item)", async () => {
55
55
  const onValueChange = vi.fn();
56
56
  render(
57
57
  <Combobox
@@ -66,7 +66,10 @@ describe("Combobox (single mode)", () => {
66
66
  await userEvent.type(input, "Cherry");
67
67
  await userEvent.click(screen.getByRole("option"));
68
68
 
69
- expect(onValueChange.mock.calls[0][0]).toEqual({ id: "3", name: "Cherry" });
69
+ expect(onValueChange.mock.calls[0]).toEqual([
70
+ "3",
71
+ { id: "3", name: "Cherry" },
72
+ ]);
70
73
  });
71
74
 
72
75
  it("shows the default empty text when nothing matches", async () => {
@@ -120,7 +123,7 @@ describe("Combobox (single mode)", () => {
120
123
  });
121
124
 
122
125
  it("reflects a controlled value in the input", () => {
123
- render(<Combobox items={items} placeholder="P" value={items[1]} />);
126
+ render(<Combobox items={items} placeholder="P" value={items[1].id} />);
124
127
 
125
128
  expect(getInput().value).toBe("Banana");
126
129
  });
@@ -178,7 +181,7 @@ describe("Combobox getId / getLabel defaults", () => {
178
181
  { label: "One", value: "1" },
179
182
  { label: "Two", value: "2" },
180
183
  ];
181
- render(<Combobox items={opts} placeholder="P" value={opts[0]} />);
184
+ render(<Combobox items={opts} placeholder="P" value={opts[0].value} />);
182
185
 
183
186
  expect(getInput().value).toBe("One");
184
187
  });
@@ -198,7 +201,7 @@ describe("Combobox getId / getLabel defaults", () => {
198
201
  <Combobox
199
202
  items={opts}
200
203
  placeholder="P"
201
- value={opts[1]}
204
+ value={opts[1].code}
202
205
  getLabel={(o) => o.country}
203
206
  getId={(o) => o.code}
204
207
  />,
@@ -207,11 +210,12 @@ describe("Combobox getId / getLabel defaults", () => {
207
210
  expect(getInput().value).toBe("Brazil");
208
211
  });
209
212
 
210
- it("handles { title } shaped items", () => {
213
+ it("handles { title } shaped items", async () => {
211
214
  const opts = [{ title: "First" }, { title: "Second" }];
212
- render(<Combobox items={opts} placeholder="P" value={opts[1]} />);
215
+ render(<Combobox items={opts} placeholder="P" />);
213
216
 
214
- expect(getInput().value).toBe("Second");
217
+ await userEvent.click(getInput());
218
+ expect(screen.getAllByRole("option").length).toBe(2);
215
219
  });
216
220
  });
217
221
 
@@ -222,7 +226,7 @@ describe("Combobox (multiple mode)", () => {
222
226
  multiple
223
227
  items={items}
224
228
  placeholder="P"
225
- defaultValue={[items[0], items[1]]}
229
+ defaultValue={[items[0].id, items[1].id]}
226
230
  />,
227
231
  );
228
232
 
@@ -254,7 +258,7 @@ describe("Combobox (multiple mode)", () => {
254
258
  multiple
255
259
  items={items}
256
260
  placeholder="P"
257
- defaultValue={[items[0]]}
261
+ defaultValue={[items[0].id]}
258
262
  />,
259
263
  );
260
264
 
@@ -281,7 +285,7 @@ describe("Combobox (multiple mode)", () => {
281
285
  await userEvent.type(chipsInput, "Apple");
282
286
  await userEvent.click(screen.getByRole("option"));
283
287
 
284
- expect(onValueChange.mock.calls[0][0]).toEqual([items[0]]);
288
+ expect(onValueChange.mock.calls[0]).toEqual([[items[0].id], [items[0]]]);
285
289
  });
286
290
 
287
291
  it("renders a start addon alongside the chips", () => {
@@ -290,7 +294,7 @@ describe("Combobox (multiple mode)", () => {
290
294
  multiple
291
295
  items={items}
292
296
  placeholder="P"
293
- defaultValue={[items[0]]}
297
+ defaultValue={[items[0].id]}
294
298
  startAddon={<span data-testid="chips-addon">#</span>}
295
299
  />,
296
300
  );
@@ -307,7 +311,7 @@ describe("Combobox Field integration", () => {
307
311
  multiple
308
312
  items={items}
309
313
  placeholder="P"
310
- defaultValue={[items[0]]}
314
+ defaultValue={[items[0].id]}
311
315
  />
312
316
  </Field>,
313
317
  );
@@ -323,7 +327,7 @@ describe("Combobox Field integration", () => {
323
327
  multiple
324
328
  items={items}
325
329
  placeholder="P"
326
- defaultValue={[items[0]]}
330
+ defaultValue={[items[0].id]}
327
331
  />,
328
332
  );
329
333
 
@@ -339,7 +343,7 @@ describe("Combobox Field integration", () => {
339
343
  multiple
340
344
  items={items}
341
345
  placeholder="P"
342
- defaultValue={[items[0]]}
346
+ defaultValue={[items[0].id]}
343
347
  />
344
348
  </Field>,
345
349
  );
@@ -459,3 +463,36 @@ describe("Combobox escape-hatch primitives", () => {
459
463
  ).toHaveTextContent("Fruits");
460
464
  });
461
465
  });
466
+
467
+ // ─── Type contract ────────────────────────────────────────────────────────────
468
+
469
+ describe("Combobox type contract", () => {
470
+ it("rejects an item object as value at compile time", () => {
471
+ // @ts-expect-error — object must not compile as value
472
+ render(<Combobox items={items} value={items[0]} />);
473
+ expect(getInput()).toBeInTheDocument();
474
+ });
475
+ });
476
+
477
+ // ─── Duplicate id warning ─────────────────────────────────────────────────────
478
+
479
+ describe("Combobox duplicate-id warning", () => {
480
+ it("warns in dev when items collide on the same id", () => {
481
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
482
+ // `{ title }` items have no id/value → defaultGetId collapses them all to
483
+ // "[object Object]", a silent collision this warning surfaces.
484
+ render(<Combobox items={[{ title: "A" }, { title: "B" }]} />);
485
+
486
+ expect(warn).toHaveBeenCalledOnce();
487
+ expect(warn.mock.calls[0][0]).toContain("[Combobox]");
488
+ warn.mockRestore();
489
+ });
490
+
491
+ it("does not warn when ids are unique", () => {
492
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
493
+ render(<Combobox items={items} />);
494
+
495
+ expect(warn).not.toHaveBeenCalled();
496
+ warn.mockRestore();
497
+ });
498
+ });
@@ -777,7 +777,27 @@ function ComboboxMultipleTrigger({
777
777
  );
778
778
  }
779
779
 
780
- type ComboboxBaseProps<TItem = unknown> = Omit<
780
+ type ComboboxId = string | number;
781
+
782
+ /**
783
+ * Infers the id type from the shape of an item, mirroring `defaultGetId`'s
784
+ * lookup order (`id` before `value`, then the primitive itself). Lets the
785
+ * common case — `{ id, name }`, `{ label, value }`, or plain primitives — drive
786
+ * `value` / `onValueChange` typing with no explicit `getId`. Falls back to
787
+ * `string` for shapes `defaultGetId` can't address.
788
+ */
789
+ type InferId<TItem> = TItem extends { id: infer I extends ComboboxId }
790
+ ? I
791
+ : TItem extends { value: infer V extends ComboboxId }
792
+ ? V
793
+ : TItem extends ComboboxId
794
+ ? TItem
795
+ : string;
796
+
797
+ type ComboboxBaseProps<
798
+ TItem = unknown,
799
+ TId extends ComboboxId = InferId<TItem>,
800
+ > = Omit<
781
801
  ComboboxPrimitive.Root.Props<TItem, boolean>,
782
802
  | "items"
783
803
  | "itemToStringLabel"
@@ -787,12 +807,13 @@ type ComboboxBaseProps<TItem = unknown> = Omit<
787
807
  | "onValueChange"
788
808
  | "value"
789
809
  | "defaultValue"
810
+ | "isItemEqualToValue"
790
811
  > & {
791
812
  items: readonly TItem[];
792
813
  /** Returns the display text for an item. Used for filter matching, ARIA, and the trigger input. Defaults to `item.label`, `item.name`, `item.title`, or the stringified primitive. */
793
814
  getLabel?: (item: TItem) => string;
794
- /** Returns a stable string identifier for an item. Used as the React key and as the hidden form value when `name` is set. Defaults to `item.id`, `item.value`, or the stringified primitive. */
795
- getId?: (item: TItem) => string;
815
+ /** Returns a stable id for an item. Used as the controlled `value`, React key, and hidden form value. Defaults to `item.id`, `item.value`, or the stringified primitive. */
816
+ getId?: (item: TItem) => TId;
796
817
  renderItem?: (item: TItem) => React.ReactNode;
797
818
  placeholder?: string;
798
819
  emptyText?: string;
@@ -820,18 +841,27 @@ type ComboboxBaseProps<TItem = unknown> = Omit<
820
841
  };
821
842
  };
822
843
 
823
- export type ComboboxProps<TItem = unknown> =
824
- | (ComboboxBaseProps<TItem> & {
844
+ export type ComboboxProps<
845
+ TItem = unknown,
846
+ TId extends ComboboxId = InferId<TItem>,
847
+ > =
848
+ | (ComboboxBaseProps<TItem, TId> & {
825
849
  multiple?: false;
826
- value?: TItem | null;
827
- defaultValue?: TItem | null;
828
- onValueChange?: (value: TItem | null) => void;
850
+ /** Controlled selection id. Pass the value returned by `getId` (string or number), not the item object. */
851
+ value?: TId | null;
852
+ /** Initial selection id for uncontrolled usage. */
853
+ defaultValue?: TId | null;
854
+ /** Called with the selected id and the full item when selection changes. Both are `null` when cleared. */
855
+ onValueChange?: (id: TId | null, item: TItem | null) => void;
829
856
  })
830
- | (ComboboxBaseProps<TItem> & {
857
+ | (ComboboxBaseProps<TItem, TId> & {
831
858
  multiple: true;
832
- value?: TItem[];
833
- defaultValue?: TItem[];
834
- onValueChange?: (value: TItem[]) => void;
859
+ /** Controlled selection ids for multiple mode. Pass the array of values returned by `getId`. */
860
+ value?: ReadonlyArray<TId>;
861
+ /** Initial selection ids for uncontrolled multiple mode. */
862
+ defaultValue?: ReadonlyArray<TId>;
863
+ /** Called with the array of selected ids and the array of full items when selection changes. */
864
+ onValueChange?: (ids: TId[], items: TItem[]) => void;
835
865
  });
836
866
 
837
867
  /**
@@ -849,9 +879,10 @@ export type ComboboxProps<TItem = unknown> =
849
879
  * Use `ComboboxRoot` + primitives directly when you need full structural
850
880
  * control beyond what escape-hatch props offer.
851
881
  */
852
- export function Combobox<TItem = unknown>(
853
- allProps: ComboboxProps<TItem>,
854
- ): React.ReactElement {
882
+ export function Combobox<
883
+ TItem = unknown,
884
+ TId extends ComboboxId = InferId<TItem>,
885
+ >(allProps: ComboboxProps<TItem, TId>): React.ReactElement {
855
886
  // Cast internally — ComboboxProps discriminated union enforces correctness for consumers.
856
887
  // The union can't be spread directly into ComboboxRoot because TypeScript can't resolve
857
888
  // which branch is active at the call site.
@@ -871,25 +902,80 @@ export function Combobox<TItem = unknown>(
871
902
  showClear = true,
872
903
  searchable = true,
873
904
  ...rest
874
- } = allProps as ComboboxBaseProps<TItem> & {
905
+ } = allProps as ComboboxBaseProps<TItem, TId> & {
875
906
  multiple?: boolean;
876
- onValueChange?: (value: any, eventDetails?: any) => void;
907
+ onValueChange?: (v: any, e?: any) => void;
877
908
  value?: any;
878
909
  defaultValue?: any;
879
910
  };
880
911
 
881
912
  const getLabel: (item: TItem) => string = getLabelProp ?? defaultGetLabel;
882
- const getId: (item: TItem) => string = getIdProp ?? defaultGetId;
913
+ const getId = (getIdProp ?? defaultGetId) as (item: TItem) => TId;
914
+
915
+ // Base UI matches the selected value against item values by `Object.is`, so
916
+ // the composite keys every option by a normalized string. `value` arrives as
917
+ // the id (string or number); `String(...)` normalizes both to that same key,
918
+ // so matching stays stable across string/number ids.
919
+ const itemKey = (item: TItem): string => String(getId(item));
920
+ const findByKey = (key: string): TItem | null =>
921
+ items.find((i) => itemKey(i) === key) ?? null;
922
+
923
+ // Warn (dev only, once) when two items resolve to the same id — e.g. objects
924
+ // with only a `title` and no `id`/`value`, which collapse to "[object Object]".
925
+ const warnedRef = React.useRef(false);
926
+ if (process.env.NODE_ENV !== "production" && !warnedRef.current) {
927
+ const seen = new Set<string>();
928
+ const dupes = new Set<string>();
929
+ for (const item of items) {
930
+ const key = itemKey(item);
931
+ if (seen.has(key)) dupes.add(key);
932
+ seen.add(key);
933
+ }
934
+ if (dupes.size > 0) {
935
+ warnedRef.current = true;
936
+ console.warn(
937
+ `[Combobox] Multiple items resolve to the same id (${[...dupes].join(", ")}). ` +
938
+ "Provide a `getId` that returns a unique value per item.",
939
+ );
940
+ }
941
+ }
942
+
943
+ const objectValue = multiple
944
+ ? ((value as ReadonlyArray<TId> | undefined)
945
+ ?.map((v) => findByKey(String(v)))
946
+ .filter(Boolean) as TItem[] | undefined)
947
+ : value == null
948
+ ? (value as null | undefined)
949
+ : findByKey(String(value as TId));
950
+
951
+ const objectDefaultValue = multiple
952
+ ? ((defaultValue as ReadonlyArray<TId> | undefined)
953
+ ?.map((v) => findByKey(String(v)))
954
+ .filter(Boolean) as TItem[] | undefined)
955
+ : defaultValue == null
956
+ ? undefined
957
+ : findByKey(String(defaultValue as TId));
958
+
959
+ const handleChange = (v: TItem | TItem[] | null) => {
960
+ if (multiple) {
961
+ const items_ = (v as TItem[]) ?? [];
962
+ onValueChange?.(items_.map(getId), items_);
963
+ } else {
964
+ const item = (v as TItem | null) ?? null;
965
+ onValueChange?.(item ? getId(item) : null, item);
966
+ }
967
+ };
883
968
 
884
969
  return (
885
970
  <ComboboxRoot
886
971
  items={items}
887
972
  itemToStringLabel={getLabel}
888
- itemToStringValue={getId}
973
+ itemToStringValue={getId as (item: TItem) => string}
889
974
  multiple={multiple as boolean}
890
- onValueChange={onValueChange}
891
- value={value}
892
- defaultValue={defaultValue}
975
+ onValueChange={handleChange as any}
976
+ value={objectValue as any}
977
+ defaultValue={objectDefaultValue as any}
978
+ isItemEqualToValue={(a, b) => itemKey(a) === itemKey(b)}
893
979
  autoHighlight
894
980
  loopFocus
895
981
  {...rest}
@@ -897,7 +983,7 @@ export function Combobox<TItem = unknown>(
897
983
  {multiple ? (
898
984
  <ComboboxMultipleTrigger
899
985
  getLabel={getLabel}
900
- getId={getId}
986
+ getId={getId as (item: any) => string}
901
987
  showClear={showClear}
902
988
  startAddon={startAddon}
903
989
  className={classNames?.chips}
@@ -657,7 +657,8 @@ type MemberFormData = z.infer<typeof MemberSchema>;
657
657
  * Spread `{...field}` into inputs that accept `value` / `onChange` / `onBlur` / `ref`.
658
658
  *
659
659
  * Components with a typed item API require manual mapping:
660
- * - `Select` — `onChange` returns `TItem | null`, extract `.value` for `z.string()` schemas.
660
+ * - `Select` — `onValueChange` returns `(id, item)`; pass the `id` straight to
661
+ * `field.onChange` for `z.string()` schemas (no `.value` extraction needed).
661
662
  * - `Checkbox` — uses `checked` / `onCheckedChange`, not `value` / `onChange`.
662
663
  */
663
664
  export const WithReactHookFormFull: Story = {
@@ -729,7 +730,7 @@ export const WithReactHookFormFull: Story = {
729
730
  placeholder="Select a role…"
730
731
  {...field}
731
732
  value={field.value}
732
- onValueChange={(item) => onChange(item?.value)}
733
+ onValueChange={(id) => onChange(id)}
733
734
  />
734
735
  )}
735
736
  />
@@ -1,4 +1,5 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
2
3
  import { describe, expect, it, vi } from "vitest";
3
4
  import {
4
5
  Pagination,
@@ -173,7 +174,7 @@ describe("Pagination primitives", () => {
173
174
  expect(screen.getByText(/Página 3 de 3/)).toBeInTheDocument();
174
175
  });
175
176
 
176
- it("PaginationSizeSelect changes page size and resets to page 1", () => {
177
+ it("PaginationSizeSelect changes page size and resets to page 1", async () => {
177
178
  render(
178
179
  <PaginationRoot defaultPageSize={10} totalItems={50}>
179
180
  <PaginationSizeSelect />
@@ -181,9 +182,8 @@ describe("Pagination primitives", () => {
181
182
  </PaginationRoot>,
182
183
  );
183
184
  expect(screen.getByText(/1 - 10 de 50/)).toBeInTheDocument();
184
- fireEvent.change(screen.getByRole("combobox"), {
185
- target: { value: "25" },
186
- });
185
+ await userEvent.click(screen.getByRole("combobox"));
186
+ await userEvent.click(screen.getByRole("option", { name: "25" }));
187
187
  expect(screen.getByText(/1 - 25 de 50/)).toBeInTheDocument();
188
188
  });
189
189