@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.
@@ -9,7 +9,6 @@ import {
9
9
  MoreHorizontal,
10
10
  } from "lucide-react";
11
11
  import {
12
- ChangeEvent,
13
12
  ComponentProps,
14
13
  ComponentType,
15
14
  createContext,
@@ -21,6 +20,7 @@ import {
21
20
  import { DOTS, useRangePagination } from "../../hooks";
22
21
  import { cn } from "../../lib";
23
22
  import { Button } from "../button";
23
+ import { Select, type SelectProps } from "../select";
24
24
  import { PAGINATION_SIZES, PageSize } from "./pagination.model";
25
25
 
26
26
  type GetPageHref = (state: { page: number; pageSize: number }) => string;
@@ -522,10 +522,12 @@ export function PaginationRange({
522
522
  );
523
523
  }
524
524
 
525
- interface PaginationSizeSelectProps
526
- extends Omit<ComponentProps<"select">, "value" | "onChange"> {
525
+ type PaginationSizeSelectProps = Omit<
526
+ SelectProps<{ label: string; value: PageSize }, PageSize>,
527
+ "items" | "value" | "defaultValue" | "onValueChange" | "multiple"
528
+ > & {
527
529
  sizes?: readonly PageSize[] | PageSize[];
528
- }
530
+ };
529
531
 
530
532
  export function PaginationSizeSelect({
531
533
  sizes = PAGINATION_SIZES,
@@ -534,30 +536,29 @@ export function PaginationSizeSelect({
534
536
  }: PaginationSizeSelectProps) {
535
537
  const { pageSize, setPageSize, goTo } = usePaginationContext();
536
538
 
537
- const handleChange = (event: ChangeEvent<HTMLSelectElement>) => {
538
- const next = Number(event.target.value) as PageSize;
539
- setPageSize(next);
539
+ const handleChange = (value: PageSize) => {
540
+ setPageSize(value);
540
541
  goTo(1);
541
542
  };
542
543
 
544
+ const sizesOptions = sizes.map((size) => ({
545
+ label: String(size),
546
+ value: size,
547
+ }));
548
+
543
549
  return (
544
- <select
550
+ <Select
551
+ items={sizesOptions}
545
552
  data-slot="pagination-size-select"
546
553
  value={pageSize}
547
- onChange={handleChange}
554
+ onValueChange={(value) => value != null && handleChange(value)}
548
555
  className={cn(
549
556
  "border border-input rounded-md w-fit min-w-[3rem] bg-background p-2 ring-offset-background",
550
557
  "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
551
558
  className,
552
559
  )}
553
560
  {...props}
554
- >
555
- {sizes.map((size) => (
556
- <option key={size} value={size} data-slot="pagination-size-option">
557
- {size}
558
- </option>
559
- ))}
560
- </select>
561
+ />
561
562
  );
562
563
  }
563
564
 
@@ -1,5 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react-vite";
2
- import { useArgs } from "storybook/preview-api";
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: TItem[]` and
204
- * `onValueChange: (value: TItem[]) => void` for controlled usage — the discriminated
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` receives the full item object (or `null`).
244
- * `value` accepts the item object directly no string conversion needed.
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: (args) => {
248
- const [{ value }, updateArgs] = useArgs();
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
- {...args}
253
- value={value ?? null}
254
- onValueChange={(item) => updateArgs({ value: item })}
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: {value ? (value as Animal).label : "none"}
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("fires onValueChange with selected item object (single)", async () => {
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]).toEqual({ id: "2", name: "Banana" });
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("fires onValueChange with array of items (multiple mode)", async () => {
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([fruits[0]]);
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={[fruits[0]]}
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 selected: typeof fruits = onChange.mock.calls[0][0];
334
- expect(selected.some((i) => i.name === "Banana")).toBe(true);
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 single value", () => {
342
- render(<Select items={fruits} value={fruits[1]} />);
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("reflects a controlled multiple value", () => {
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 multiple items={fruits} defaultValue={[fruits[0], fruits[2]]} />,
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
- it("uses defaultValue for uncontrolled single", () => {
354
- render(<Select items={fruits} defaultValue={fruits[2]} />);
355
- expect(slot("select-trigger")).toHaveTextContent("Cherry");
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
- type SelectBaseProps<TItem = unknown> = Omit<
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 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. */
306
- getId?: (item: TItem) => string;
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<TItem = unknown> =
324
- | (SelectBaseProps<TItem> & {
341
+ export type SelectProps<
342
+ TItem = unknown,
343
+ TId extends SelectId = InferId<TItem>,
344
+ > =
345
+ | (SelectBaseProps<TItem, TId> & {
325
346
  multiple?: false;
326
- value?: TItem | null;
327
- defaultValue?: TItem | null;
328
- onValueChange?: (value: TItem | null) => void;
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
- value?: TItem[];
333
- defaultValue?: TItem[];
334
- onValueChange?: (value: TItem[]) => void;
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?: (value: any) => void;
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: (item: TItem) => string = getIdProp ?? defaultGetId;
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 TItem[] | undefined)?.map(getId)
383
- : value != null
384
- ? getId(value as TItem)
385
- : undefined;
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 TItem[] | undefined)?.map(getId)
389
- : defaultValue != null
390
- ? getId(defaultValue as TItem)
391
- : undefined;
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 vals = (stringVal as string[]) ?? [];
396
- const found = vals
397
- .map((sv) => items.find((i) => getId(i) === sv) ?? null)
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
- items.find((i) => getId(i) === (stringVal as string)) ?? null;
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 = items.find((i) => getId(i) === stringVal);
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={getId(item)}
439
- value={getId(item)}
498
+ key={itemKey(item)}
499
+ value={itemKey(item)}
440
500
  disabled={getDisabled(item)}
441
501
  className={classNames?.item}
442
502
  >
@@ -324,6 +324,32 @@ describe("usePagination — Controllable Page", () => {
324
324
  expect(onPageChange).toHaveBeenCalledWith(3);
325
325
  });
326
326
 
327
+ it("Scenario 21b — onPageChange fires when controlled prop diverges from internal state then user navigates back to page 1", () => {
328
+ // Regression: if the page prop is updated externally (no internal navigation),
329
+ // pageState stays at its default (1). A subsequent goTo(1) calls setPageState(1)
330
+ // which is a React no-op → useEffect never fires → onPageChange silently skipped.
331
+ const onPageChange = vi.fn();
332
+ let controlledPage = 1;
333
+ const { result, rerender } = renderHook(() =>
334
+ usePagination({
335
+ totalItems: 100,
336
+ defaultPageSize: 10,
337
+ page: controlledPage,
338
+ onPageChange,
339
+ }),
340
+ );
341
+
342
+ // Parent externally jumps to page 5 — no internal navigation, pageState stays 1
343
+ controlledPage = 5;
344
+ rerender();
345
+ expect(result.current.page).toBe(5);
346
+ onPageChange.mockClear();
347
+
348
+ // User clicks "First" — should fire onPageChange(1)
349
+ act(() => result.current.goTo(1));
350
+ expect(onPageChange).toHaveBeenCalledWith(1);
351
+ });
352
+
327
353
  it("Scenario 22 — onPageChange NOT fired on mount", () => {
328
354
  const onPageChange = vi.fn();
329
355
  renderHook(() =>
@@ -136,6 +136,7 @@ export function usePagination({
136
136
 
137
137
  // Refs so memoized actions read live values without stale closures
138
138
  const pageRef = useLatestRef(page);
139
+ const pageStateRef = useLatestRef(pageState);
139
140
  const pageSizeRef = useLatestRef(pageSize);
140
141
  const pageCountRef = useLatestRef(pageCount);
141
142
  const onPageChangeRef = useLatestRef(onPageChange);
@@ -186,6 +187,14 @@ export function usePagination({
186
187
  const applyPage = (target: number) => {
187
188
  const clamped = clampPage(target, pageCountRef.current);
188
189
  if (clamped === pageRef.current) return; // no-op for same page
190
+ // In controlled mode, pageState may already equal `clamped` (diverged from
191
+ // pageProp), so setPageState would be a no-op and the useEffect that fires
192
+ // onPageChange would never run. Mirror the same direct-fire pattern used in
193
+ // setPageSize to guarantee the callback reaches the consumer.
194
+ if (pageStateRef.current === clamped) {
195
+ lastEmittedPageRef.current = clamped;
196
+ onPageChangeRef.current?.(clamped);
197
+ }
189
198
  setPageState(clamped);
190
199
  };
191
200