@cerebruminc/cerebellum 20.0.3 → 20.0.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # react-component-lib-boilerplate
2
2
 
3
+ ## [20.0.4](https://github.com/cerebruminc/cerebellum/compare/v20.0.3...v20.0.4) (2026-08-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * expose Google secondary address ([2ce454c](https://github.com/cerebruminc/cerebellum/commit/2ce454cc9a2f55b5eea285e2c158d0c4fa310378))
9
+ * upgrade next to 16.2.11 ([89700e3](https://github.com/cerebruminc/cerebellum/commit/89700e37e09c1f680001cff682c4f7646351070a))
10
+
3
11
  ## [20.0.3](https://github.com/cerebruminc/cerebellum/compare/v20.0.2...v20.0.3) (2026-08-06)
4
12
 
5
13
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cerebruminc/cerebellum",
3
- "version": "20.0.3",
3
+ "version": "20.0.4",
4
4
  "description": "Cerebrum's React Component Library",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -94,7 +94,7 @@
94
94
  "lint-staged": "^15.0.0",
95
95
  "lorem-ipsum": "^2.0.4",
96
96
  "msw": "^2.14.6",
97
- "next": "^16.2.10",
97
+ "next": "^16.2.11",
98
98
  "react": "^18.3.1",
99
99
  "react-dom": "^18.3.1",
100
100
  "remark-gfm": "^4.0.0",
@@ -64,6 +64,7 @@ import { AddressInput } from "@cerebruminc/cerebellum";
64
64
  | stateAbbreviation | string | Two letter state abbreviation |
65
65
  | street | string | Street address, including number. Does not include preceeding text, like "Historic District" |
66
66
  | postalCode | string | zip code |
67
+ | secondaryAddress | optional string | Apartment, unit, or suite from Google's `subpremise` component, when available |
67
68
  | rawAddressObject | AddressObjectProps | The location object returned by Google maps |
68
69
 
69
70
  <br />
@@ -1,6 +1,7 @@
1
- import { fireEvent, render, screen } from "@testing-library/react";
1
+ import { act, fireEvent, render, screen } from "@testing-library/react";
2
+ import { geocodeByPlaceId } from "react-places-autocomplete";
2
3
  import { withTheme } from "../../hocs/withTheme";
3
- import { AddressInput } from "./AddressInput";
4
+ import { AddressInput, getSecondaryAddress } from "./AddressInput";
4
5
 
5
6
  jest.mock("@react-google-maps/api", () => {
6
7
  return {
@@ -17,9 +18,11 @@ jest.mock("@react-google-maps/api", () => {
17
18
  });
18
19
 
19
20
  let mockShouldThrow = false;
21
+ let mockOnSelect: ((address: string, placeId: string, addressObject: any) => Promise<void>) | undefined;
20
22
 
21
23
  interface Props {
22
24
  children: () => void;
25
+ onSelect: (address: string, placeId: string, addressObject: any) => Promise<void>;
23
26
  }
24
27
  jest.mock("react-places-autocomplete", () => {
25
28
  const React = require("react");
@@ -44,18 +47,57 @@ jest.mock("react-places-autocomplete", () => {
44
47
  if (mockShouldThrow) {
45
48
  throw new Error("Google Maps JavaScript API library must be loaded");
46
49
  }
50
+ mockOnSelect = this.props.onSelect;
47
51
  return <>{this.props.children(this.renderProps)}</>;
48
52
  }
49
53
  }
50
54
 
51
- return PlacesAutocomplete;
55
+ return {
56
+ __esModule: true,
57
+ default: PlacesAutocomplete,
58
+ geocodeByPlaceId: jest.fn(),
59
+ };
52
60
  });
53
61
 
54
62
  const ThemedAddressInput = withTheme(AddressInput);
63
+ const mockGeocodeByPlaceId = geocodeByPlaceId as jest.Mock;
55
64
 
56
65
  describe("AddressInput", () => {
57
66
  afterEach(() => {
58
67
  mockShouldThrow = false;
68
+ mockGeocodeByPlaceId.mockReset();
69
+ mockOnSelect = undefined;
70
+ });
71
+
72
+ test("passes Google's secondary address to selection callbacks", async () => {
73
+ const onChange = jest.fn();
74
+ const addressObject = {
75
+ formattedSuggestion: { mainText: "415 East Irving Park Road" },
76
+ terms: [],
77
+ };
78
+ mockGeocodeByPlaceId.mockResolvedValue([
79
+ {
80
+ address_components: [
81
+ { long_name: "60613", types: ["postal_code"] },
82
+ { long_name: "APT 2N", types: ["subpremise"] },
83
+ ],
84
+ },
85
+ ]);
86
+
87
+ render(<ThemedAddressInput inputLabel="Address" value="" onChange={onChange} />);
88
+
89
+ await act(async () => {
90
+ await mockOnSelect?.("415 East Irving Park Road, Chicago, IL, USA", "place-id", addressObject);
91
+ });
92
+
93
+ expect(onChange).toHaveBeenLastCalledWith(
94
+ { target: { value: "415 East Irving Park Road, Chicago, IL, USA", name: undefined } },
95
+ expect.objectContaining({
96
+ postalCode: "60613",
97
+ rawAddressObject: addressObject,
98
+ secondaryAddress: "APT 2N",
99
+ })
100
+ );
59
101
  });
60
102
 
61
103
  test("renders AddressInput with a label", async () => {
@@ -88,3 +130,18 @@ describe("AddressInput", () => {
88
130
  });
89
131
  });
90
132
  });
133
+
134
+ describe("getSecondaryAddress", () => {
135
+ test("returns Google's subpremise address component", () => {
136
+ expect(
137
+ getSecondaryAddress([
138
+ { long_name: "415", types: ["street_number"] },
139
+ { long_name: "APT 2N", types: ["subpremise"] },
140
+ ])
141
+ ).toBe("APT 2N");
142
+ });
143
+
144
+ test("returns undefined when there is no subpremise", () => {
145
+ expect(getSecondaryAddress([{ long_name: "East Irving Park Road", types: ["route"] }])).toBeUndefined();
146
+ });
147
+ });
@@ -8,6 +8,15 @@ import { AddressInputErrorBoundary } from "./AddressInputErrorBoundary";
8
8
  import { AddressInputContainer, PlaceOption, PlacesList } from "./AddressInputStyles";
9
9
  import { AddressInputType, AddressObjectProps } from "./types";
10
10
 
11
+ type GoogleAddressComponent = {
12
+ long_name: string;
13
+ types: string[];
14
+ };
15
+
16
+ export const getSecondaryAddress = (addressComponents: GoogleAddressComponent[]): string | undefined => {
17
+ return addressComponents.find((component) => component.types.includes("subpremise"))?.long_name;
18
+ };
19
+
11
20
  export const AddressInput: FC<AddressInputType> = (props: AddressInputType) => {
12
21
  const {
13
22
  disabled,
@@ -43,6 +52,7 @@ export const AddressInput: FC<AddressInputType> = (props: AddressInputType) => {
43
52
  // Get zip code
44
53
  const [place] = await geocodeByPlaceId(placeId);
45
54
  const { long_name: postalCode = "" } = place.address_components.find((c) => c.types.includes("postal_code")) || {};
55
+ const secondaryAddress = getSecondaryAddress(place.address_components);
46
56
  const stateAbbreviation = addressObject?.terms?.[addressObject?.terms?.length - 2]?.value;
47
57
 
48
58
  const details = {
@@ -52,6 +62,7 @@ export const AddressInput: FC<AddressInputType> = (props: AddressInputType) => {
52
62
  stateAbbreviation: addressObject?.terms?.[addressObject?.terms?.length - 2]?.value,
53
63
  street: addressObject?.formattedSuggestion?.mainText,
54
64
  postalCode,
65
+ secondaryAddress,
55
66
  rawAddressObject: addressObject,
56
67
  };
57
68
 
@@ -49,6 +49,7 @@ export interface AddressDetailsType {
49
49
  stateAbbreviation?: string;
50
50
  street?: string;
51
51
  postalCode?: string;
52
+ secondaryAddress?: string;
52
53
  rawAddressObject: AddressObjectProps;
53
54
  }
54
55
 
@@ -46,6 +46,24 @@ const getLabelStyles = (theme: MantineTheme) => {
46
46
  };
47
47
  };
48
48
 
49
+ /**
50
+ * Whether a Mantine `style` prop pins an explicit width. The prop accepts a
51
+ * single object, a theme callback, or arbitrarily nested arrays of either, so
52
+ * this walks the whole structure. Callbacks are treated as width-free because
53
+ * they can only be resolved once a theme is applied.
54
+ */
55
+ const styleSetsWidth = (style: ButtonProps["style"]): boolean => {
56
+ if (!style || typeof style === "function") {
57
+ return false;
58
+ }
59
+
60
+ if (Array.isArray(style)) {
61
+ return style.some(styleSetsWidth);
62
+ }
63
+
64
+ return style.width !== undefined;
65
+ };
66
+
49
67
  /**
50
68
  * Common variables for form input sizing.
51
69
  */
@@ -459,16 +477,27 @@ export const createCustomMantineTheme = (optionsOrBrandColor?: CreateCustomManti
459
477
 
460
478
  // Normalize sizes: sm and xs render at sm dimensions; md and all
461
479
  // other unsupported sizes (lg, xl, etc.) use md dimensions.
462
- if (props.size === "xs" || props.size === "sm") {
463
- root["--button-height"] = "32px";
464
- } else {
465
- // md (default) and all unsupported sizes md sizing
466
- root["--button-height"] = "46px";
467
- }
480
+ const isSmall = props.size === "xs" || props.size === "sm";
481
+ root["--button-height"] = isSmall ? "32px" : "46px";
482
+
483
+ // An explicit width means the caller owns the button's footprint, so
484
+ // the generous default padding below is skipped and Mantine's
485
+ // per-size padding applies — otherwise the wider padding would eat
486
+ // into that fixed width and clip the label. Mirrors Cerebellum's
487
+ // `$buttonWidth ? 0` rule.
488
+ const hasExplicitWidth = props.w !== undefined || props.miw !== undefined || styleSetsWidth(props.style);
468
489
 
469
490
  if (props.boxed || props.variant === "subtle") {
470
491
  root["--button-radius"] = "8px";
471
492
  root["--button-padding-x"] = "22px";
493
+ } else if (!hasExplicitWidth) {
494
+ // Match Cerebellum's pill buttons, which used 45px of horizontal
495
+ // padding (30px when an icon was present). Mantine derives the
496
+ // icon-side padding as `--button-padding-x / 1.5`, so a single
497
+ // value reproduces both. Still overridable per call site via the
498
+ // `px`/`p` style props or `styles={{ root: { paddingInline } }}`,
499
+ // which render as inline styles and therefore win over this var.
500
+ root["--button-padding-x"] = isSmall ? "30px" : "45px";
472
501
  }
473
502
 
474
503
  if (props.variant === "light") {