@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.
- package/dist/components/calendar/calendar.cjs.js +1 -1
- package/dist/components/calendar/calendar.es.js +33 -33
- package/dist/components/combobox/combobox.cjs.js +1 -1
- package/dist/components/combobox/combobox.es.js +302 -282
- package/dist/components/pagination/pagination.cjs.js +1 -1
- package/dist/components/pagination/pagination.es.js +70 -67
- package/dist/components/select/select.cjs.js +1 -1
- package/dist/components/select/select.es.js +168 -154
- package/dist/hooks/use-pagination/use-pagination.cjs.js +1 -1
- package/dist/hooks/use-pagination/use-pagination.es.js +39 -39
- package/dist/src/components/combobox/combobox.d.ts +31 -12
- package/dist/src/components/pagination/pagination.d.ts +6 -2
- package/dist/src/components/select/select.d.ts +38 -12
- package/package.json +1 -1
- package/src/components/calendar/calendar.tsx +3 -3
- package/src/components/combobox/combobox.stories.tsx +7 -4
- package/src/components/combobox/combobox.test.tsx +52 -15
- package/src/components/combobox/combobox.tsx +109 -23
- package/src/components/form/form.stories.tsx +3 -2
- package/src/components/pagination/pagination.test.tsx +5 -5
- package/src/components/pagination/pagination.tsx +17 -16
- package/src/components/select/select.stories.tsx +51 -13
- package/src/components/select/select.test.tsx +114 -16
- package/src/components/select/select.tsx +97 -37
- package/src/hooks/use-pagination/use-pagination.test.tsx +26 -0
- package/src/hooks/use-pagination/use-pagination.ts +9 -0
|
@@ -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
|
|
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
|
|
59
|
-
getId?: (item: TItem) =>
|
|
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
|
|
99
|
+
export type ComboboxProps<TItem = unknown, TId extends ComboboxId = InferId<TItem>> = (ComboboxBaseProps<TItem, TId> & {
|
|
87
100
|
multiple?: false;
|
|
88
|
-
value
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
getId?: (item: TItem) =>
|
|
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
|
|
67
|
+
export type SelectProps<TItem = unknown, TId extends SelectId = InferId<TItem>> = (SelectBaseProps<TItem, TId> & {
|
|
54
68
|
multiple?: false;
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
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
|
@@ -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) =>
|
|
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<
|
|
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:
|
|
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
|
|
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]
|
|
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"
|
|
215
|
+
render(<Combobox items={opts} placeholder="P" />);
|
|
213
216
|
|
|
214
|
-
|
|
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]
|
|
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
|
|
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
|
|
795
|
-
getId?: (item: TItem) =>
|
|
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<
|
|
824
|
-
|
|
844
|
+
export type ComboboxProps<
|
|
845
|
+
TItem = unknown,
|
|
846
|
+
TId extends ComboboxId = InferId<TItem>,
|
|
847
|
+
> =
|
|
848
|
+
| (ComboboxBaseProps<TItem, TId> & {
|
|
825
849
|
multiple?: false;
|
|
826
|
-
value
|
|
827
|
-
|
|
828
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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<
|
|
853
|
-
|
|
854
|
-
|
|
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?: (
|
|
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
|
|
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={
|
|
891
|
-
value={
|
|
892
|
-
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` — `
|
|
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={(
|
|
733
|
+
onValueChange={(id) => onChange(id)}
|
|
733
734
|
/>
|
|
734
735
|
)}
|
|
735
736
|
/>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|