@boxcustodia/library 2.0.0-alpha.31 → 2.0.0-alpha.33

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.
@@ -1,83 +1,83 @@
1
- import { useState as b, useRef as P, useEffect as p, useMemo as V } from "react";
2
- import { useLatestRef as s } from "../internal/use-latest-ref.es.js";
3
- const Z = 10, $ = 1;
1
+ import { useState as b, useRef as S, useEffect as l, useMemo as Z } from "react";
2
+ import { useLatestRef as o } from "../internal/use-latest-ref.es.js";
3
+ const $ = 10, j = 1;
4
4
  function d(e, t) {
5
5
  return e <= 0 || Number.isNaN(e) ? (process.env.NODE_ENV !== "production" && (!t || !t.current) && (t && (t.current = !0), console.warn(
6
6
  `[usePagination] pageSize must be a positive number, received ${e}. Coercing to 1.`
7
7
  )), 1) : Math.floor(e);
8
8
  }
9
- function j(e, t) {
9
+ function k(e, t) {
10
10
  return e <= 0 ? 0 : Math.ceil(e / t);
11
11
  }
12
12
  function N(e, t) {
13
13
  return t <= 0 || Number.isNaN(e) ? 1 : Math.min(Math.max(1, Math.floor(e)), t);
14
14
  }
15
- function B({
15
+ function H({
16
16
  totalItems: e,
17
17
  pageSize: t,
18
- defaultPageSize: x = Z,
18
+ defaultPageSize: x = $,
19
19
  onPageSizeChange: A,
20
20
  page: L,
21
- defaultPage: _ = $,
21
+ defaultPage: _ = j,
22
22
  onPageChange: D,
23
23
  onPaginationChange: F
24
24
  }) {
25
- const [S, l] = b(_), [m, R] = b(x), T = P(!1), u = d(t ?? m, T), o = j(e, u), n = N(L ?? S, o), i = s(n), G = s(u), c = s(o), v = s(D), z = s(A), U = s(F), g = P(!1), h = P(null), E = P(null);
26
- p(() => {
27
- if (!g.current) return;
28
- const r = N(S, c.current);
29
- h.current !== r && (h.current = r, v.current?.(r));
30
- }, [S]), p(() => {
31
- if (!g.current) return;
32
- const r = d(m);
33
- E.current !== r && (E.current = r, z.current?.(r));
34
- }, [m]), p(() => {
35
- g.current && U.current?.({ page: n, pageSize: u });
36
- }, [n, u]), p(() => {
37
- g.current = !0;
25
+ const [g, m] = b(_), [h, v] = b(x), T = S(!1), c = d(t ?? h, T), i = k(e, c), u = N(L ?? g, i), s = o(u), G = o(g), U = o(c), a = o(i), E = o(D), z = o(A), y = o(F), P = S(!1), p = S(null), R = S(null);
26
+ l(() => {
27
+ if (!P.current) return;
28
+ const r = N(g, a.current);
29
+ p.current !== r && (p.current = r, E.current?.(r));
30
+ }, [g]), l(() => {
31
+ if (!P.current) return;
32
+ const r = d(h);
33
+ R.current !== r && (R.current = r, z.current?.(r));
34
+ }, [h]), l(() => {
35
+ P.current && y.current?.({ page: u, pageSize: c });
36
+ }, [u, c]), l(() => {
37
+ P.current = !0;
38
38
  }, []);
39
- const y = V(() => {
39
+ const O = Z(() => {
40
40
  const r = (f) => {
41
- const a = N(f, c.current);
42
- a !== i.current && l(a);
41
+ const n = N(f, a.current);
42
+ n !== s.current && (G.current === n && (p.current = n, E.current?.(n)), m(n));
43
43
  };
44
44
  return {
45
45
  goTo: (f) => {
46
- c.current <= 0 || r(f);
46
+ a.current <= 0 || r(f);
47
47
  },
48
48
  next: () => {
49
- i.current >= c.current || r(i.current + 1);
49
+ s.current >= a.current || r(s.current + 1);
50
50
  },
51
51
  prev: () => {
52
- i.current <= 1 || r(i.current - 1);
52
+ s.current <= 1 || r(s.current - 1);
53
53
  },
54
54
  firstPage: () => {
55
- c.current <= 0 || r(1);
55
+ a.current <= 0 || r(1);
56
56
  },
57
57
  lastPage: () => {
58
- c.current <= 0 || r(c.current);
58
+ a.current <= 0 || r(a.current);
59
59
  },
60
60
  setPageSize: (f) => {
61
- const a = d(f);
62
- a !== G.current && (E.current = a, R(a), z.current?.(a), i.current !== 1 && (h.current = 1, v.current?.(1)), l(1));
61
+ const n = d(f);
62
+ n !== U.current && (R.current = n, v(n), z.current?.(n), s.current !== 1 && (p.current = 1, E.current?.(1)), m(1));
63
63
  }
64
64
  };
65
- }, [l, R]), M = o <= 0 ? !0 : n <= 1, C = o <= 0 ? !0 : n >= o, O = e <= 0 ? { start: 0, end: 0 } : {
66
- start: (n - 1) * u + 1,
67
- end: Math.min(n * u, e)
65
+ }, [m, v]), M = i <= 0 ? !0 : u <= 1, C = i <= 0 ? !0 : u >= i, V = e <= 0 ? { start: 0, end: 0 } : {
66
+ start: (u - 1) * c + 1,
67
+ end: Math.min(u * c, e)
68
68
  };
69
69
  return {
70
- page: n,
71
- pageSize: u,
72
- pageCount: o,
70
+ page: u,
71
+ pageSize: c,
72
+ pageCount: i,
73
73
  isFirstPage: M,
74
74
  isLastPage: C,
75
75
  hasPrevPage: !M,
76
76
  hasNextPage: !C,
77
- range: O,
78
- ...y
77
+ range: V,
78
+ ...O
79
79
  };
80
80
  }
81
81
  export {
82
- B as usePagination
82
+ H as usePagination
83
83
  };
@@ -1,4 +1,5 @@
1
1
  import { Combobox as ComboboxPrimitive } from '@base-ui/react/combobox';
2
+ import { InferItemId, ItemId } from '../../utils/internal';
2
3
  import * as React from "react";
3
4
  export declare const ComboboxContext: React.Context<{
4
5
  chipsRef: React.RefObject<Element | null> | null;
@@ -51,20 +52,7 @@ export declare function ComboboxChip({ children, removeProps, ...props }: Combob
51
52
  removeProps?: ComboboxPrimitive.ChipRemove.Props;
52
53
  }): React.ReactElement;
53
54
  export declare function ComboboxChipRemove(props: ComboboxPrimitive.ChipRemove.Props): React.ReactElement;
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
+ type ComboboxBaseProps<TItem = unknown, TId extends ItemId = InferItemId<TItem>> = Omit<ComboboxPrimitive.Root.Props<TItem, boolean>, "items" | "itemToStringLabel" | "itemToStringValue" | "children" | "multiple" | "onValueChange" | "value" | "defaultValue" | "isItemEqualToValue"> & {
68
56
  items: readonly TItem[];
69
57
  /** 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. */
70
58
  getLabel?: (item: TItem) => string;
@@ -96,7 +84,7 @@ type ComboboxBaseProps<TItem = unknown, TId extends ComboboxId = InferId<TItem>>
96
84
  empty?: string;
97
85
  };
98
86
  };
99
- export type ComboboxProps<TItem = unknown, TId extends ComboboxId = InferId<TItem>> = (ComboboxBaseProps<TItem, TId> & {
87
+ export type ComboboxProps<TItem = unknown, TId extends ItemId = InferItemId<TItem>> = (ComboboxBaseProps<TItem, TId> & {
100
88
  multiple?: false;
101
89
  /** Controlled selection id. Pass the value returned by `getId` (string or number), not the item object. */
102
90
  value?: TId | null;
@@ -128,6 +116,6 @@ export type ComboboxProps<TItem = unknown, TId extends ComboboxId = InferId<TIte
128
116
  * Use `ComboboxRoot` + primitives directly when you need full structural
129
117
  * control beyond what escape-hatch props offer.
130
118
  */
131
- export declare function Combobox<TItem = unknown, TId extends ComboboxId = InferId<TItem>>(allProps: ComboboxProps<TItem, TId>): React.ReactElement;
119
+ export declare function Combobox<TItem = unknown, TId extends ItemId = InferItemId<TItem>>(allProps: ComboboxProps<TItem, TId>): React.ReactElement;
132
120
  export declare const useComboboxFilter: typeof ComboboxPrimitive.useFilter;
133
121
  export { ComboboxPrimitive };
@@ -1,5 +1,6 @@
1
1
  import { Select as SelectPrimitive } from '@base-ui/react/select';
2
2
  import { useRender } from '@base-ui/react/use-render';
3
+ import { InferItemId, ItemId } from '../../utils/internal';
3
4
  import * as React from "react";
4
5
  export declare const selectTriggerClasses: string;
5
6
  export declare const selectTriggerIconClassName = "-me-1 size-4.5 opacity-80 sm:size-4";
@@ -29,21 +30,7 @@ export declare function SelectSeparator({ className, ...props }: SelectPrimitive
29
30
  export declare function SelectGroup(props: SelectPrimitive.Group.Props): React.ReactElement;
30
31
  export declare function SelectGroupLabel(props: SelectPrimitive.GroupLabel.Props): React.ReactElement;
31
32
  export declare function SelectLabel({ className, ...props }: SelectPrimitive.Label.Props): React.ReactElement;
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
+ type SelectBaseProps<TItem, TId extends ItemId> = Omit<SelectPrimitive.Root.Props<string, false>, "children" | "onValueChange" | "value" | "defaultValue" | "items" | "multiple"> & {
47
34
  items: readonly TItem[];
48
35
  /** 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. */
49
36
  getLabel?: (item: TItem) => string;
@@ -64,7 +51,7 @@ type SelectBaseProps<TItem, TId extends SelectId> = Omit<SelectPrimitive.Root.Pr
64
51
  item?: string;
65
52
  };
66
53
  };
67
- export type SelectProps<TItem = unknown, TId extends SelectId = InferId<TItem>> = (SelectBaseProps<TItem, TId> & {
54
+ export type SelectProps<TItem = unknown, TId extends ItemId = InferItemId<TItem>> = (SelectBaseProps<TItem, TId> & {
68
55
  multiple?: false;
69
56
  /** Controlled selection. The id (whatever `getId` returns — `string` or `number`), or `null` when cleared. */
70
57
  value?: TId | null;
@@ -99,6 +86,6 @@ export type SelectProps<TItem = unknown, TId extends SelectId = InferId<TItem>>
99
86
  * Use `SelectRoot` + primitives directly when you need full structural
100
87
  * control beyond what `className` + `classNames` slots offer.
101
88
  */
102
- export declare function Select<TItem = unknown, TId extends SelectId = InferId<TItem>>(allProps: SelectProps<TItem, TId>): React.ReactElement;
89
+ export declare function Select<TItem = unknown, TId extends ItemId = InferItemId<TItem>>(allProps: SelectProps<TItem, TId>): React.ReactElement;
103
90
  export { SelectPrimitive };
104
91
  export { SelectPopup as SelectContent };
@@ -0,0 +1,2 @@
1
+ export type { InferItemId, ItemId } from './item';
2
+ export { defaultGetId, defaultGetLabel } from './item';
@@ -0,0 +1,27 @@
1
+ /** Identifier an item can be addressed by. */
2
+ export type ItemId = string | number;
3
+ /**
4
+ * Infers the id type from the shape of an item, mirroring `defaultGetId`'s
5
+ * lookup order (`id` before `value`, then the primitive itself). Lets the
6
+ * common case — `{ id, name }`, `{ label, value }`, or plain primitives — drive
7
+ * `value` / `onValueChange` typing with no explicit `getId`. Falls back to
8
+ * `string` for shapes `defaultGetId` can't address.
9
+ */
10
+ export type InferItemId<TItem> = TItem extends {
11
+ id: infer I extends ItemId;
12
+ } ? I : TItem extends {
13
+ value: infer V extends ItemId;
14
+ } ? V : TItem extends ItemId ? TItem : string;
15
+ /**
16
+ * Resolves the display text for an item. Defaults to `item.label`, `item.name`,
17
+ * `item.title`, or the stringified primitive. Always returns a string — labels
18
+ * are display text.
19
+ */
20
+ export declare function defaultGetLabel(item: unknown): string;
21
+ /**
22
+ * Resolves the id for an item, preserving its native type. The internal Base UI
23
+ * layer keys options by string (callers wrap this in `String(...)`), so keeping
24
+ * the raw type here lets `onValueChange` hand back a numeric id as a number, not
25
+ * a string. Defaults to `item.id`, `item.value`, or the stringified primitive.
26
+ */
27
+ export declare function defaultGetId(item: unknown): ItemId;
@@ -0,0 +1 @@
1
+ "use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});function t(r){if(r==null)return"";if(typeof r=="string"||typeof r=="number")return String(r);if(typeof r=="object"){const n=r;if("label"in n)return String(n.label);if("name"in n)return String(n.name);if("title"in n)return String(n.title)}return String(r)}function e(r){if(r==null)return"";if(typeof r=="string"||typeof r=="number")return r;if(typeof r=="object"){const n=r;if("id"in n)return n.id;if("value"in n)return n.value}return String(r)}exports.defaultGetId=e;exports.defaultGetLabel=t;
@@ -0,0 +1,25 @@
1
+ function t(r) {
2
+ if (r == null) return "";
3
+ if (typeof r == "string" || typeof r == "number") return String(r);
4
+ if (typeof r == "object") {
5
+ const n = r;
6
+ if ("label" in n) return String(n.label);
7
+ if ("name" in n) return String(n.name);
8
+ if ("title" in n) return String(n.title);
9
+ }
10
+ return String(r);
11
+ }
12
+ function e(r) {
13
+ if (r == null) return "";
14
+ if (typeof r == "string" || typeof r == "number") return r;
15
+ if (typeof r == "object") {
16
+ const n = r;
17
+ if ("id" in n) return n.id;
18
+ if ("value" in n) return n.value;
19
+ }
20
+ return String(r);
21
+ }
22
+ export {
23
+ e as defaultGetId,
24
+ t as defaultGetLabel
25
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boxcustodia/library",
3
- "version": "2.0.0-alpha.31",
3
+ "version": "2.0.0-alpha.33",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs.js",
6
6
  "module": "dist/index.es.js",
@@ -217,6 +217,31 @@ describe("Combobox getId / getLabel defaults", () => {
217
217
  await userEvent.click(getInput());
218
218
  expect(screen.getAllByRole("option").length).toBe(2);
219
219
  });
220
+
221
+ it("returns a numeric id as a number without a custom getId", async () => {
222
+ const numeric = [
223
+ { id: 1, name: "Apple" },
224
+ { id: 2, name: "Banana" },
225
+ ];
226
+ const onValueChange = vi.fn();
227
+ render(
228
+ <Combobox
229
+ items={numeric}
230
+ placeholder="P"
231
+ onValueChange={onValueChange}
232
+ />,
233
+ );
234
+
235
+ const input = getInput();
236
+ await userEvent.click(input);
237
+ await userEvent.type(input, "Banana");
238
+ await userEvent.click(screen.getByRole("option"));
239
+
240
+ // defaultGetId preserves the native id type — number stays a number,
241
+ // not a stringified "2".
242
+ expect(onValueChange.mock.calls[0][0]).toBe(2);
243
+ expect(typeof onValueChange.mock.calls[0][0]).toBe("number");
244
+ });
220
245
  });
221
246
 
222
247
  describe("Combobox (multiple mode)", () => {
@@ -6,6 +6,12 @@ import { ChevronsUpDownIcon, XIcon } from "lucide-react";
6
6
  import * as React from "react";
7
7
  import { useIsomorphicLayoutEffect } from "../../hooks/internal";
8
8
  import { cn } from "../../lib";
9
+ import {
10
+ defaultGetId,
11
+ defaultGetLabel,
12
+ type InferItemId,
13
+ type ItemId,
14
+ } from "../../utils/internal";
9
15
  import { useIsInsideFieldRoot } from "../field";
10
16
  import { Input, inputBaseClasses } from "../input";
11
17
  import { ScrollArea } from "../scroll-area";
@@ -702,29 +708,6 @@ function ComboboxMultipleChipsContent({
702
708
 
703
709
  // ─── Composite ────────────────────────────────────────────────────────────────
704
710
 
705
- function defaultGetLabel(item: unknown): string {
706
- if (item == null) return "";
707
- if (typeof item === "string" || typeof item === "number") return String(item);
708
- if (typeof item === "object") {
709
- const record = item as Record<string, unknown>;
710
- if ("label" in record) return String(record.label);
711
- if ("name" in record) return String(record.name);
712
- if ("title" in record) return String(record.title);
713
- }
714
- return String(item);
715
- }
716
-
717
- function defaultGetId(item: unknown): string {
718
- if (item == null) return "";
719
- if (typeof item === "string" || typeof item === "number") return String(item);
720
- if (typeof item === "object") {
721
- const record = item as Record<string, unknown>;
722
- if ("id" in record) return String(record.id);
723
- if ("value" in record) return String(record.value);
724
- }
725
- return String(item);
726
- }
727
-
728
711
  function ComboboxSingleTrigger({
729
712
  placeholder,
730
713
  showClear,
@@ -777,26 +760,9 @@ function ComboboxMultipleTrigger({
777
760
  );
778
761
  }
779
762
 
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
763
  type ComboboxBaseProps<
798
764
  TItem = unknown,
799
- TId extends ComboboxId = InferId<TItem>,
765
+ TId extends ItemId = InferItemId<TItem>,
800
766
  > = Omit<
801
767
  ComboboxPrimitive.Root.Props<TItem, boolean>,
802
768
  | "items"
@@ -843,7 +809,7 @@ type ComboboxBaseProps<
843
809
 
844
810
  export type ComboboxProps<
845
811
  TItem = unknown,
846
- TId extends ComboboxId = InferId<TItem>,
812
+ TId extends ItemId = InferItemId<TItem>,
847
813
  > =
848
814
  | (ComboboxBaseProps<TItem, TId> & {
849
815
  multiple?: false;
@@ -881,7 +847,7 @@ export type ComboboxProps<
881
847
  */
882
848
  export function Combobox<
883
849
  TItem = unknown,
884
- TId extends ComboboxId = InferId<TItem>,
850
+ TId extends ItemId = InferItemId<TItem>,
885
851
  >(allProps: ComboboxProps<TItem, TId>): React.ReactElement {
886
852
  // Cast internally — ComboboxProps discriminated union enforces correctness for consumers.
887
853
  // The union can't be spread directly into ComboboxRoot because TypeScript can't resolve
@@ -424,6 +424,24 @@ describe("Select controlled value (id-first)", () => {
424
424
  expect(onChange.mock.calls[0][1]).toEqual(numeric[0]);
425
425
  });
426
426
 
427
+ it("returns a numeric id as a number without a custom getId", async () => {
428
+ const numeric = [
429
+ { id: 1, name: "Aumentar retención" },
430
+ { id: 2, name: "Reducir churn" },
431
+ ];
432
+ const onChange = vi.fn();
433
+ render(<Select items={numeric} defaultOpen onValueChange={onChange} />);
434
+
435
+ await userEvent.click(
436
+ screen.getByRole("option", { name: "Aumentar retención" }),
437
+ );
438
+
439
+ // defaultGetId preserves the native id type — number stays a number,
440
+ // not a stringified "1".
441
+ expect(onChange.mock.calls[0][0]).toBe(1);
442
+ expect(typeof onChange.mock.calls[0][0]).toBe("number");
443
+ });
444
+
427
445
  it("rejects an item object as value at compile time", () => {
428
446
  // @ts-expect-error — value is id-first; passing the item must not compile.
429
447
  render(<Select items={fruits} value={fruits[1]} />);
@@ -10,6 +10,12 @@ import {
10
10
  } from "lucide-react";
11
11
  import * as React from "react";
12
12
  import { cn } from "../../lib";
13
+ import {
14
+ defaultGetId,
15
+ defaultGetLabel,
16
+ type InferItemId,
17
+ type ItemId,
18
+ } from "../../utils/internal";
13
19
  import { inputBaseClasses } from "../input";
14
20
 
15
21
  // ─── Primitives ───────────────────────────────────────────────────────────────
@@ -266,54 +272,13 @@ export function SelectLabel({
266
272
 
267
273
  // ─── Composite ────────────────────────────────────────────────────────────────
268
274
 
269
- function defaultGetLabel(item: unknown): string {
270
- if (item == null) return "";
271
- if (typeof item === "string" || typeof item === "number") return String(item);
272
- if (typeof item === "object") {
273
- const record = item as Record<string, unknown>;
274
- if ("label" in record) return String(record.label);
275
- if ("name" in record) return String(record.name);
276
- if ("title" in record) return String(record.title);
277
- }
278
- return String(item);
279
- }
280
-
281
- function defaultGetId(item: unknown): string {
282
- if (item == null) return "";
283
- if (typeof item === "string" || typeof item === "number") return String(item);
284
- if (typeof item === "object") {
285
- const record = item as Record<string, unknown>;
286
- if ("id" in record) return String(record.id);
287
- if ("value" in record) return String(record.value);
288
- }
289
- return String(item);
290
- }
291
-
292
275
  function defaultGetDisabled(item: unknown): boolean {
293
276
  if (item != null && typeof item === "object" && "disabled" in item)
294
277
  return Boolean((item as Record<string, unknown>).disabled);
295
278
  return false;
296
279
  }
297
280
 
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<
281
+ type SelectBaseProps<TItem, TId extends ItemId> = Omit<
317
282
  SelectPrimitive.Root.Props<string, false>,
318
283
  "children" | "onValueChange" | "value" | "defaultValue" | "items" | "multiple"
319
284
  > & {
@@ -340,7 +305,7 @@ type SelectBaseProps<TItem, TId extends SelectId> = Omit<
340
305
 
341
306
  export type SelectProps<
342
307
  TItem = unknown,
343
- TId extends SelectId = InferId<TItem>,
308
+ TId extends ItemId = InferItemId<TItem>,
344
309
  > =
345
310
  | (SelectBaseProps<TItem, TId> & {
346
311
  multiple?: false;
@@ -379,9 +344,10 @@ export type SelectProps<
379
344
  * Use `SelectRoot` + primitives directly when you need full structural
380
345
  * control beyond what `className` + `classNames` slots offer.
381
346
  */
382
- export function Select<TItem = unknown, TId extends SelectId = InferId<TItem>>(
383
- allProps: SelectProps<TItem, TId>,
384
- ): React.ReactElement {
347
+ export function Select<
348
+ TItem = unknown,
349
+ TId extends ItemId = InferItemId<TItem>,
350
+ >(allProps: SelectProps<TItem, TId>): React.ReactElement {
385
351
  const {
386
352
  items,
387
353
  getLabel: getLabelProp,
@@ -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
 
@@ -0,0 +1,2 @@
1
+ export type { InferItemId, ItemId } from "./item";
2
+ export { defaultGetId, defaultGetLabel } from "./item";
@@ -0,0 +1,51 @@
1
+ /** Identifier an item can be addressed by. */
2
+ export type ItemId = string | number;
3
+
4
+ /**
5
+ * Infers the id type from the shape of an item, mirroring `defaultGetId`'s
6
+ * lookup order (`id` before `value`, then the primitive itself). Lets the
7
+ * common case — `{ id, name }`, `{ label, value }`, or plain primitives — drive
8
+ * `value` / `onValueChange` typing with no explicit `getId`. Falls back to
9
+ * `string` for shapes `defaultGetId` can't address.
10
+ */
11
+ export type InferItemId<TItem> = TItem extends { id: infer I extends ItemId }
12
+ ? I
13
+ : TItem extends { value: infer V extends ItemId }
14
+ ? V
15
+ : TItem extends ItemId
16
+ ? TItem
17
+ : string;
18
+
19
+ /**
20
+ * Resolves the display text for an item. Defaults to `item.label`, `item.name`,
21
+ * `item.title`, or the stringified primitive. Always returns a string — labels
22
+ * are display text.
23
+ */
24
+ export function defaultGetLabel(item: unknown): string {
25
+ if (item == null) return "";
26
+ if (typeof item === "string" || typeof item === "number") return String(item);
27
+ if (typeof item === "object") {
28
+ const record = item as Record<string, unknown>;
29
+ if ("label" in record) return String(record.label);
30
+ if ("name" in record) return String(record.name);
31
+ if ("title" in record) return String(record.title);
32
+ }
33
+ return String(item);
34
+ }
35
+
36
+ /**
37
+ * Resolves the id for an item, preserving its native type. The internal Base UI
38
+ * layer keys options by string (callers wrap this in `String(...)`), so keeping
39
+ * the raw type here lets `onValueChange` hand back a numeric id as a number, not
40
+ * a string. Defaults to `item.id`, `item.value`, or the stringified primitive.
41
+ */
42
+ export function defaultGetId(item: unknown): ItemId {
43
+ if (item == null) return "";
44
+ if (typeof item === "string" || typeof item === "number") return item;
45
+ if (typeof item === "object") {
46
+ const record = item as Record<string, unknown>;
47
+ if ("id" in record) return record.id as ItemId;
48
+ if ("value" in record) return record.value as ItemId;
49
+ }
50
+ return String(item);
51
+ }