@boxcustodia/library 2.0.0-alpha.30 → 2.0.0-alpha.31
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/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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
-
import {
|
|
2
|
+
import { useState } from "react";
|
|
3
3
|
import { Button } from "../button";
|
|
4
4
|
import { Field } from "../field";
|
|
5
5
|
import { Form as LibraryForm } from "../form";
|
|
@@ -122,7 +122,7 @@ export const AlignItemWithTrigger: Story = {
|
|
|
122
122
|
<span className="text-muted-foreground text-xs">
|
|
123
123
|
Default (alignItemWithTrigger=false)
|
|
124
124
|
</span>
|
|
125
|
-
<Select items={years} defaultValue={preselected} />
|
|
125
|
+
<Select items={years} defaultValue={preselected.value} />
|
|
126
126
|
</div>
|
|
127
127
|
<div className="flex flex-col gap-1">
|
|
128
128
|
<span className="text-muted-foreground text-xs">
|
|
@@ -200,9 +200,9 @@ export const CustomObject: Story = {
|
|
|
200
200
|
|
|
201
201
|
/**
|
|
202
202
|
* `multiple` enables multi-selection. The trigger shows selected labels as a
|
|
203
|
-
* comma-separated list with automatic truncation. Use `value:
|
|
204
|
-
* `onValueChange: (
|
|
205
|
-
* union ensures type safety between single and multiple modes.
|
|
203
|
+
* comma-separated list with automatic truncation. Use `value: TId[]` and
|
|
204
|
+
* `onValueChange: (ids: TId[], items: TItem[]) => void` for controlled usage —
|
|
205
|
+
* the discriminated union ensures type safety between single and multiple modes.
|
|
206
206
|
*/
|
|
207
207
|
export const Multiple: Story = {
|
|
208
208
|
render: () => (
|
|
@@ -240,21 +240,23 @@ export const CustomRender: Story = {
|
|
|
240
240
|
};
|
|
241
241
|
|
|
242
242
|
/**
|
|
243
|
-
* `onValueChange`
|
|
244
|
-
* `value`
|
|
243
|
+
* `onValueChange` reports the selected **id first** and the resolved **item
|
|
244
|
+
* second**. `value` is the id (whatever `getId` returns). Hold only the id in state.
|
|
245
245
|
*/
|
|
246
246
|
export const Controlled: Story = {
|
|
247
|
-
render: (
|
|
248
|
-
const [
|
|
247
|
+
render: () => {
|
|
248
|
+
const [value, setValue] = useState<string | null>(null);
|
|
249
|
+
const selected = animals.find((a) => a.value === value) ?? null;
|
|
249
250
|
return (
|
|
250
251
|
<div className="flex flex-col gap-4">
|
|
251
252
|
<Select
|
|
252
|
-
{
|
|
253
|
-
value={value
|
|
254
|
-
onValueChange={(
|
|
253
|
+
items={animals}
|
|
254
|
+
value={value}
|
|
255
|
+
onValueChange={(id) => setValue(id)}
|
|
256
|
+
placeholder="Select an animal"
|
|
255
257
|
/>
|
|
256
258
|
<p className="text-sm text-muted-foreground">
|
|
257
|
-
Selected: {
|
|
259
|
+
Selected: {selected ? selected.label : "none"}
|
|
258
260
|
</p>
|
|
259
261
|
</div>
|
|
260
262
|
);
|
|
@@ -277,6 +279,42 @@ export const Form: Story = {
|
|
|
277
279
|
),
|
|
278
280
|
};
|
|
279
281
|
|
|
282
|
+
/**
|
|
283
|
+
* Controlled by **id** — the common form case where you hold only the id in
|
|
284
|
+
* state, not the whole object. An external button resets the id to `null` and
|
|
285
|
+
* the trigger falls back to `placeholder`.
|
|
286
|
+
*
|
|
287
|
+
* Pass `value` + `onValueChange` together. Setting `value={null}` clears the
|
|
288
|
+
* selection; passing only `value={null}` (no `onValueChange`) would switch the
|
|
289
|
+
* component to uncontrolled mode.
|
|
290
|
+
*/
|
|
291
|
+
export const Clearable: Story = {
|
|
292
|
+
render: () => {
|
|
293
|
+
const [id, setId] = useState<string | null>(null);
|
|
294
|
+
return (
|
|
295
|
+
<div className="flex flex-col gap-4">
|
|
296
|
+
<Select
|
|
297
|
+
items={animals}
|
|
298
|
+
value={id}
|
|
299
|
+
onValueChange={(next) => setId(next)}
|
|
300
|
+
placeholder="Select an animal"
|
|
301
|
+
/>
|
|
302
|
+
<Button
|
|
303
|
+
type="button"
|
|
304
|
+
variant="outline"
|
|
305
|
+
size="sm"
|
|
306
|
+
onClick={() => setId(null)}
|
|
307
|
+
>
|
|
308
|
+
Clear
|
|
309
|
+
</Button>
|
|
310
|
+
<p className="text-muted-foreground text-sm">
|
|
311
|
+
Selected: {id ?? "none"}
|
|
312
|
+
</p>
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
|
|
280
318
|
/**
|
|
281
319
|
* Use `SelectRoot` + primitives for full structural control: custom grouping,
|
|
282
320
|
* separators, and arbitrary content inside items.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { render, screen } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import * as React from "react";
|
|
3
4
|
import { describe, expect, it, vi } from "vitest";
|
|
4
5
|
import {
|
|
5
6
|
Select,
|
|
@@ -277,14 +278,15 @@ describe("Select disabled items", () => {
|
|
|
277
278
|
// ─── Composite Select — value / onChange ─────────────────────────────────────
|
|
278
279
|
|
|
279
280
|
describe("Select onValueChange", () => {
|
|
280
|
-
it("
|
|
281
|
+
it("reports id first, then the resolved item (single)", async () => {
|
|
281
282
|
const onChange = vi.fn();
|
|
282
283
|
render(<Select items={fruits} defaultOpen onValueChange={onChange} />);
|
|
283
284
|
|
|
284
285
|
await userEvent.click(screen.getByRole("option", { name: "Banana" }));
|
|
285
286
|
|
|
286
287
|
expect(onChange).toHaveBeenCalledOnce();
|
|
287
|
-
expect(onChange.mock.calls[0][0]).
|
|
288
|
+
expect(onChange.mock.calls[0][0]).toBe("2");
|
|
289
|
+
expect(onChange.mock.calls[0][1]).toEqual({ id: "2", name: "Banana" });
|
|
288
290
|
});
|
|
289
291
|
|
|
290
292
|
it("accepts null as a controlled value without crashing", () => {
|
|
@@ -297,7 +299,7 @@ describe("Select onValueChange", () => {
|
|
|
297
299
|
expect(slot("select-trigger")).toBeInTheDocument();
|
|
298
300
|
});
|
|
299
301
|
|
|
300
|
-
it("
|
|
302
|
+
it("reports ids first, then items (multiple mode)", async () => {
|
|
301
303
|
const onChange = vi.fn();
|
|
302
304
|
render(
|
|
303
305
|
<Select
|
|
@@ -312,7 +314,8 @@ describe("Select onValueChange", () => {
|
|
|
312
314
|
await userEvent.click(screen.getByRole("option", { name: "Apple" }));
|
|
313
315
|
|
|
314
316
|
expect(onChange).toHaveBeenCalledOnce();
|
|
315
|
-
expect(onChange.mock.calls[0][0]).toEqual([
|
|
317
|
+
expect(onChange.mock.calls[0][0]).toEqual(["1"]);
|
|
318
|
+
expect(onChange.mock.calls[0][1]).toEqual([fruits[0]]);
|
|
316
319
|
});
|
|
317
320
|
|
|
318
321
|
it("maps multiple selection back to item objects", async () => {
|
|
@@ -322,7 +325,7 @@ describe("Select onValueChange", () => {
|
|
|
322
325
|
multiple
|
|
323
326
|
items={fruits}
|
|
324
327
|
defaultOpen
|
|
325
|
-
defaultValue={[
|
|
328
|
+
defaultValue={["1"]}
|
|
326
329
|
onValueChange={onChange}
|
|
327
330
|
/>,
|
|
328
331
|
);
|
|
@@ -330,28 +333,123 @@ describe("Select onValueChange", () => {
|
|
|
330
333
|
await userEvent.click(screen.getByRole("option", { name: "Banana" }));
|
|
331
334
|
|
|
332
335
|
expect(onChange).toHaveBeenCalled();
|
|
333
|
-
const
|
|
334
|
-
|
|
336
|
+
const ids: string[] = onChange.mock.calls[0][0];
|
|
337
|
+
const items: typeof fruits = onChange.mock.calls[0][1];
|
|
338
|
+
expect(ids).toContain("2");
|
|
339
|
+
expect(items.some((i) => i.name === "Banana")).toBe(true);
|
|
335
340
|
});
|
|
336
341
|
});
|
|
337
342
|
|
|
338
|
-
// ─── Composite Select — controlled value
|
|
343
|
+
// ─── Composite Select — controlled value (id-first) ──────────────────────────
|
|
339
344
|
|
|
340
|
-
describe("Select controlled value", () => {
|
|
341
|
-
it("reflects a controlled
|
|
342
|
-
render(<Select items={fruits}
|
|
345
|
+
describe("Select controlled value (id-first)", () => {
|
|
346
|
+
it("reflects a controlled multiple value", () => {
|
|
347
|
+
render(<Select multiple items={fruits} defaultValue={["1", "3"]} />);
|
|
348
|
+
expect(slot("select-trigger")).toBeInTheDocument();
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it("clears the trigger after selecting a value and resetting to null", async () => {
|
|
352
|
+
function Wrapper() {
|
|
353
|
+
const [id, setId] = React.useState<string | null>(null);
|
|
354
|
+
return (
|
|
355
|
+
<>
|
|
356
|
+
<Select
|
|
357
|
+
items={fruits}
|
|
358
|
+
value={id}
|
|
359
|
+
onValueChange={(next) => setId(next)}
|
|
360
|
+
placeholder="Pick a fruit"
|
|
361
|
+
/>
|
|
362
|
+
<button type="button" onClick={() => setId(null)}>
|
|
363
|
+
Clear
|
|
364
|
+
</button>
|
|
365
|
+
</>
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
render(<Wrapper />);
|
|
370
|
+
|
|
371
|
+
expect(slot("select-trigger")).toHaveTextContent("Pick a fruit");
|
|
372
|
+
|
|
373
|
+
await userEvent.click(slot("select-trigger")!);
|
|
374
|
+
await userEvent.click(screen.getByRole("option", { name: "Banana" }));
|
|
375
|
+
|
|
376
|
+
expect(slot("select-trigger")).toHaveTextContent("Banana");
|
|
377
|
+
|
|
378
|
+
await userEvent.click(screen.getByRole("button", { name: "Clear" }));
|
|
379
|
+
|
|
380
|
+
expect(slot("select-trigger")).toHaveTextContent("Pick a fruit");
|
|
381
|
+
expect(slot("select-trigger")).not.toHaveTextContent("Banana");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("reflects a controlled id string (single)", () => {
|
|
385
|
+
render(<Select items={fruits} value="2" />);
|
|
343
386
|
expect(slot("select-trigger")).toHaveTextContent("Banana");
|
|
344
387
|
});
|
|
345
388
|
|
|
346
|
-
it("
|
|
389
|
+
it("uses an id string as defaultValue (uncontrolled)", () => {
|
|
390
|
+
render(<Select items={fruits} defaultValue="3" />);
|
|
391
|
+
expect(slot("select-trigger")).toHaveTextContent("Cherry");
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it("reflects controlled ids for multiple", () => {
|
|
395
|
+
render(<Select multiple items={fruits} value={["1", "3"]} />);
|
|
396
|
+
expect(slot("select-trigger")).toBeInTheDocument();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it("matches a numeric id via custom getId and returns it as a number", async () => {
|
|
400
|
+
const numeric = [
|
|
401
|
+
{ id: 1, name: "Aumentar retención" },
|
|
402
|
+
{ id: 2, name: "Reducir churn" },
|
|
403
|
+
];
|
|
404
|
+
const onChange = vi.fn();
|
|
347
405
|
render(
|
|
348
|
-
<Select
|
|
406
|
+
<Select
|
|
407
|
+
items={numeric}
|
|
408
|
+
getId={(o) => o.id}
|
|
409
|
+
value={2}
|
|
410
|
+
defaultOpen
|
|
411
|
+
onValueChange={onChange}
|
|
412
|
+
/>,
|
|
349
413
|
);
|
|
414
|
+
|
|
415
|
+
// value={2} (number) resolves to the matching item
|
|
416
|
+
expect(slot("select-trigger")).toHaveTextContent("Reducir churn");
|
|
417
|
+
|
|
418
|
+
await userEvent.click(
|
|
419
|
+
screen.getByRole("option", { name: "Aumentar retención" }),
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// id comes back as the original number type, not stringified
|
|
423
|
+
expect(onChange.mock.calls[0][0]).toBe(1);
|
|
424
|
+
expect(onChange.mock.calls[0][1]).toEqual(numeric[0]);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("rejects an item object as value at compile time", () => {
|
|
428
|
+
// @ts-expect-error — value is id-first; passing the item must not compile.
|
|
429
|
+
render(<Select items={fruits} value={fruits[1]} />);
|
|
350
430
|
expect(slot("select-trigger")).toBeInTheDocument();
|
|
351
431
|
});
|
|
432
|
+
});
|
|
352
433
|
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
434
|
+
// ─── Composite Select — duplicate id warning ─────────────────────────────────
|
|
435
|
+
|
|
436
|
+
describe("Select duplicate id warning", () => {
|
|
437
|
+
it("warns in dev when items collide on the same id", () => {
|
|
438
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
439
|
+
// `{ title }` items have no id/value → defaultGetId collapses them all to
|
|
440
|
+
// "[object Object]", a silent collision this warning surfaces.
|
|
441
|
+
render(<Select items={[{ title: "Alpha" }, { title: "Beta" }]} />);
|
|
442
|
+
|
|
443
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
444
|
+
expect(warn.mock.calls[0][0]).toContain("[Select]");
|
|
445
|
+
warn.mockRestore();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("does not warn when ids are unique", () => {
|
|
449
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
450
|
+
render(<Select items={fruits} />);
|
|
451
|
+
|
|
452
|
+
expect(warn).not.toHaveBeenCalled();
|
|
453
|
+
warn.mockRestore();
|
|
356
454
|
});
|
|
357
455
|
});
|
|
@@ -295,15 +295,33 @@ function defaultGetDisabled(item: unknown): boolean {
|
|
|
295
295
|
return false;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
-
|
|
298
|
+
/** Identifier an item can be addressed by. */
|
|
299
|
+
type SelectId = string | number;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Infers the id type from the shape of an item, mirroring `defaultGetId`'s
|
|
303
|
+
* lookup order (`id` before `value`, then the primitive itself). Lets the
|
|
304
|
+
* common case — `{ id, name }`, `{ label, value }`, or plain primitives — drive
|
|
305
|
+
* `value` / `onValueChange` typing with no explicit `getId`. Falls back to
|
|
306
|
+
* `string` for shapes `defaultGetId` can't address.
|
|
307
|
+
*/
|
|
308
|
+
type InferId<TItem> = TItem extends { id: infer I extends SelectId }
|
|
309
|
+
? I
|
|
310
|
+
: TItem extends { value: infer V extends SelectId }
|
|
311
|
+
? V
|
|
312
|
+
: TItem extends SelectId
|
|
313
|
+
? TItem
|
|
314
|
+
: string;
|
|
315
|
+
|
|
316
|
+
type SelectBaseProps<TItem, TId extends SelectId> = Omit<
|
|
299
317
|
SelectPrimitive.Root.Props<string, false>,
|
|
300
318
|
"children" | "onValueChange" | "value" | "defaultValue" | "items" | "multiple"
|
|
301
319
|
> & {
|
|
302
320
|
items: readonly TItem[];
|
|
303
321
|
/** 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. */
|
|
304
322
|
getLabel?: (item: TItem) => string;
|
|
305
|
-
/** Returns a stable
|
|
306
|
-
getId?: (item: TItem) =>
|
|
323
|
+
/** 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. */
|
|
324
|
+
getId?: (item: TItem) => TId;
|
|
307
325
|
/** Returns whether an item should be disabled. Defaults to `item.disabled === true`. */
|
|
308
326
|
getDisabled?: (item: TItem) => boolean;
|
|
309
327
|
renderItem?: (item: TItem) => React.ReactNode;
|
|
@@ -320,18 +338,27 @@ type SelectBaseProps<TItem = unknown> = Omit<
|
|
|
320
338
|
};
|
|
321
339
|
};
|
|
322
340
|
|
|
323
|
-
export type SelectProps<
|
|
324
|
-
|
|
341
|
+
export type SelectProps<
|
|
342
|
+
TItem = unknown,
|
|
343
|
+
TId extends SelectId = InferId<TItem>,
|
|
344
|
+
> =
|
|
345
|
+
| (SelectBaseProps<TItem, TId> & {
|
|
325
346
|
multiple?: false;
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
347
|
+
/** Controlled selection. The id (whatever `getId` returns — `string` or `number`), or `null` when cleared. */
|
|
348
|
+
value?: TId | null;
|
|
349
|
+
/** Uncontrolled initial selection. The id (whatever `getId` returns). */
|
|
350
|
+
defaultValue?: TId | null;
|
|
351
|
+
/** Called on change with the selected id first, then the resolved item (both `null` when cleared). */
|
|
352
|
+
onValueChange?: (id: TId | null, item: TItem | null) => void;
|
|
329
353
|
})
|
|
330
|
-
| (SelectBaseProps<TItem> & {
|
|
354
|
+
| (SelectBaseProps<TItem, TId> & {
|
|
331
355
|
multiple: true;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
356
|
+
/** Controlled selection. The ids (whatever `getId` returns). */
|
|
357
|
+
value?: ReadonlyArray<TId>;
|
|
358
|
+
/** Uncontrolled initial selection. The ids (whatever `getId` returns). */
|
|
359
|
+
defaultValue?: ReadonlyArray<TId>;
|
|
360
|
+
/** Called on change with the selected ids first, then the resolved items. */
|
|
361
|
+
onValueChange?: (ids: TId[], items: TItem[]) => void;
|
|
335
362
|
});
|
|
336
363
|
|
|
337
364
|
/**
|
|
@@ -340,14 +367,20 @@ export type SelectProps<TItem = unknown> =
|
|
|
340
367
|
* Items shaped as `{ id, name }`, `{ label, value }`, or plain strings/numbers
|
|
341
368
|
* work with no extra props. For other shapes, provide `getLabel` and/or `getId`.
|
|
342
369
|
*
|
|
370
|
+
* `value` / `defaultValue` accept the id only (whatever `getId` returns —
|
|
371
|
+
* `string` or `number`). `onValueChange` always reports the id first and the
|
|
372
|
+
* resolved item second, so it wires directly into react-hook-form
|
|
373
|
+
* (`onValueChange={field.onChange}`) while still exposing the full item when
|
|
374
|
+
* you need to render it.
|
|
375
|
+
*
|
|
343
376
|
* `renderItem` customizes the content **inside** each `SelectItem` (the
|
|
344
377
|
* checkmark indicator is always rendered by the item wrapper).
|
|
345
378
|
*
|
|
346
379
|
* Use `SelectRoot` + primitives directly when you need full structural
|
|
347
380
|
* control beyond what `className` + `classNames` slots offer.
|
|
348
381
|
*/
|
|
349
|
-
export function Select<TItem = unknown
|
|
350
|
-
allProps: SelectProps<TItem>,
|
|
382
|
+
export function Select<TItem = unknown, TId extends SelectId = InferId<TItem>>(
|
|
383
|
+
allProps: SelectProps<TItem, TId>,
|
|
351
384
|
): React.ReactElement {
|
|
352
385
|
const {
|
|
353
386
|
items,
|
|
@@ -365,51 +398,78 @@ export function Select<TItem = unknown>(
|
|
|
365
398
|
icon,
|
|
366
399
|
"aria-invalid": ariaInvalid,
|
|
367
400
|
...rest
|
|
368
|
-
} = allProps as SelectBaseProps<TItem> & {
|
|
401
|
+
} = allProps as SelectBaseProps<TItem, TId> & {
|
|
369
402
|
multiple?: boolean;
|
|
370
|
-
onValueChange?: (
|
|
403
|
+
onValueChange?: (id: any, item: any) => void;
|
|
371
404
|
value?: any;
|
|
372
405
|
defaultValue?: any;
|
|
373
406
|
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
|
|
374
407
|
};
|
|
375
408
|
|
|
376
409
|
const getLabel: (item: TItem) => string = getLabelProp ?? defaultGetLabel;
|
|
377
|
-
const getId
|
|
410
|
+
const getId = (getIdProp ?? defaultGetId) as (item: TItem) => TId;
|
|
378
411
|
const getDisabled: (item: TItem) => boolean =
|
|
379
412
|
getDisabledProp ?? defaultGetDisabled;
|
|
380
413
|
|
|
414
|
+
// Base UI matches the selected value against item values by `Object.is`, so
|
|
415
|
+
// the composite keys every option by a normalized string. `value` arrives as
|
|
416
|
+
// the id (string or number); `String(...)` normalizes both to that same key,
|
|
417
|
+
// so matching stays stable across string/number ids.
|
|
418
|
+
const itemKey = (item: TItem): string => String(getId(item));
|
|
419
|
+
const findByKey = (key: string): TItem | null =>
|
|
420
|
+
items.find((i) => itemKey(i) === key) ?? null;
|
|
421
|
+
|
|
422
|
+
// Warn (dev only, once) when two items resolve to the same id — e.g. objects
|
|
423
|
+
// with only a `title` and no `id`/`value`, which collapse to "[object Object]".
|
|
424
|
+
const warnedRef = React.useRef(false);
|
|
425
|
+
if (process.env.NODE_ENV !== "production" && !warnedRef.current) {
|
|
426
|
+
const seen = new Set<string>();
|
|
427
|
+
const dupes = new Set<string>();
|
|
428
|
+
for (const item of items) {
|
|
429
|
+
const key = itemKey(item);
|
|
430
|
+
if (seen.has(key)) dupes.add(key);
|
|
431
|
+
seen.add(key);
|
|
432
|
+
}
|
|
433
|
+
if (dupes.size > 0) {
|
|
434
|
+
warnedRef.current = true;
|
|
435
|
+
console.warn(
|
|
436
|
+
`[Select] Multiple items resolve to the same id (${[...dupes].join(", ")}). ` +
|
|
437
|
+
"Provide a `getId` that returns a unique value per item.",
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
381
442
|
const stringValue = multiple
|
|
382
|
-
? (value as
|
|
383
|
-
: value
|
|
384
|
-
?
|
|
385
|
-
:
|
|
443
|
+
? (value as ReadonlyArray<TId> | undefined)?.map((v) => String(v as TId))
|
|
444
|
+
: value == null
|
|
445
|
+
? (value as null | undefined)
|
|
446
|
+
: String(value as TId);
|
|
386
447
|
|
|
387
448
|
const stringDefaultValue = multiple
|
|
388
|
-
? (defaultValue as
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
449
|
+
? (defaultValue as ReadonlyArray<TId> | undefined)?.map((v) =>
|
|
450
|
+
String(v as TId),
|
|
451
|
+
)
|
|
452
|
+
: defaultValue == null
|
|
453
|
+
? undefined
|
|
454
|
+
: String(defaultValue as TId);
|
|
392
455
|
|
|
393
456
|
const handleChange = (stringVal: string | string[] | null) => {
|
|
394
457
|
if (multiple) {
|
|
395
|
-
const
|
|
396
|
-
const found =
|
|
397
|
-
|
|
398
|
-
.filter(Boolean) as TItem[];
|
|
399
|
-
onValueChange?.(found);
|
|
458
|
+
const keys = (stringVal as string[]) ?? [];
|
|
459
|
+
const found = keys.map(findByKey).filter(Boolean) as TItem[];
|
|
460
|
+
onValueChange?.(found.map(getId), found);
|
|
400
461
|
} else {
|
|
401
462
|
if (stringVal == null) {
|
|
402
|
-
onValueChange?.(null);
|
|
463
|
+
onValueChange?.(null, null);
|
|
403
464
|
return;
|
|
404
465
|
}
|
|
405
|
-
const item =
|
|
406
|
-
|
|
407
|
-
onValueChange?.(item);
|
|
466
|
+
const item = findByKey(stringVal as string);
|
|
467
|
+
onValueChange?.(item ? getId(item) : null, item);
|
|
408
468
|
}
|
|
409
469
|
};
|
|
410
470
|
|
|
411
471
|
const itemToStringLabel = (stringVal: string) => {
|
|
412
|
-
const item =
|
|
472
|
+
const item = findByKey(stringVal);
|
|
413
473
|
return item ? getLabel(item) : stringVal;
|
|
414
474
|
};
|
|
415
475
|
|
|
@@ -435,8 +495,8 @@ export function Select<TItem = unknown>(
|
|
|
435
495
|
<SelectPopup className={classNames?.popup}>
|
|
436
496
|
{items.map((item) => (
|
|
437
497
|
<SelectItem
|
|
438
|
-
key={
|
|
439
|
-
value={
|
|
498
|
+
key={itemKey(item)}
|
|
499
|
+
value={itemKey(item)}
|
|
440
500
|
disabled={getDisabled(item)}
|
|
441
501
|
className={classNames?.item}
|
|
442
502
|
>
|