@stonedogcode/style 0.20.3 → 0.23.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stonedogcode/style",
3
- "version": "0.20.3",
3
+ "version": "0.23.0",
4
4
  "description": "A Panda CSS design system: a themeable Panda preset plus the React components built on it.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "StoneDogCode L.L.C.",
@@ -37,7 +37,7 @@
37
37
  "prepare": "npm run panda:prepare",
38
38
  "pretype-check": "npm run panda:prepare",
39
39
  "type-check": "tsc --noEmit",
40
- "lint": "eslint . --ext ts,tsx",
40
+ "lint": "eslint .",
41
41
  "//pretest": "panda:build, not panda:prepare — the token-contract suite asserts against the generated stylesheet, which cssgen produces and codegen alone does not.",
42
42
  "pretest": "npm run panda:build",
43
43
  "test": "jest",
@@ -64,6 +64,7 @@
64
64
  "csstype": "^3.2.3"
65
65
  },
66
66
  "devDependencies": {
67
+ "@eslint/js": "^9.0.0",
67
68
  "@pandacss/dev": "^1.11.1",
68
69
  "@playwright/experimental-ct-react": "^1.62.1",
69
70
  "@playwright/test": "^1.62.1",
@@ -75,9 +76,10 @@
75
76
  "@types/react-dom": "^19.2.3",
76
77
  "@typescript-eslint/eslint-plugin": "^8.0.0",
77
78
  "@typescript-eslint/parser": "^8.0.0",
78
- "eslint": "^8.57.1",
79
+ "eslint": "^9.39.5",
79
80
  "eslint-plugin-react": "^7.37.0",
80
81
  "eslint-plugin-react-hooks": "^5.0.0",
82
+ "globals": "^17.12.0",
81
83
  "jest": "^29.7.0",
82
84
  "jest-environment-jsdom": "^29.7.0",
83
85
  "react": "^19.0.4",
@@ -101,6 +101,79 @@ export interface StyledBoxProps
101
101
  scrollbar?: "auto" | "on" | "off";
102
102
  }
103
103
 
104
+ /**
105
+ * The layout props that describe how a caller wants THEIR CHILDREN arranged.
106
+ *
107
+ * Without `noWrap`, `StyledBox` puts children three levels down:
108
+ *
109
+ * ```
110
+ * StyledBoxRoot ← these props used to land here, and only here
111
+ * └ StyledVStack ← ...whose only child is this
112
+ * └ div
113
+ * └ StyledGridPanel
114
+ * └ div ← a plain block div, where the children actually live
115
+ * ```
116
+ *
117
+ * So a root flex container laid out exactly ONE item and the caller's children
118
+ * stayed in ordinary block flow — `display="flex" flexDirection="column"` did
119
+ * nothing, `alignItems` centred a wrapper, and `gap` separated nothing.
120
+ *
121
+ * The visible cost, three times over: two adjacent `StyledText` siblings render
122
+ * as one welded run, because `StyledText` is a `<span>` and JSX strips the
123
+ * whitespace between elements on separate lines. Hopper Vitals shipped
124
+ * `264.2Weight` and `Sep 6Record another to see a trend.` (NEH-1473), which is
125
+ * the same symptom NEH-490 fixed twice at the call site. Fixing it at the call
126
+ * site is why it came back.
127
+ *
128
+ * **This cannot break anything that currently works.** These declarations were
129
+ * discarded before — a flex container with one child arranges nothing — so
130
+ * nothing can be depending on their effect. Same argument `StyledText`'s block
131
+ * promotion makes for vertical margins on an inline box.
132
+ *
133
+ * They are FORWARDED, not moved: the root keeps them too, so a caller relying
134
+ * on the root's own box (a `gap` between header, content and footer, say) is
135
+ * unaffected.
136
+ *
137
+ * Sizing props are deliberately absent — `width`, `height`, `padding`,
138
+ * `margin`, `overflow` and friends genuinely apply to the root's own box, and
139
+ * copying them inward would double padding and re-clip content.
140
+ */
141
+ const LAYOUT_PROPS = [
142
+ "display",
143
+ "flexDirection",
144
+ "flexWrap",
145
+ "alignItems",
146
+ "alignContent",
147
+ "justifyContent",
148
+ "justifyItems",
149
+ "gap",
150
+ "rowGap",
151
+ "columnGap",
152
+ "gridTemplateColumns",
153
+ "gridTemplateRows",
154
+ "gridAutoFlow",
155
+ "placeItems",
156
+ "placeContent",
157
+ ] as const;
158
+
159
+ /**
160
+ * Did the caller ask for a layout of their own?
161
+ *
162
+ * When they did, and nothing else needs the wrapper, `StyledBox` renders the
163
+ * children DIRECTLY inside the root so the root really is their parent — which
164
+ * is what makes every Panda prop work, `gap` included.
165
+ *
166
+ * Forwarding the props as inline CSS was tried first and is wrong: `gap="2"` is
167
+ * a Panda spacing TOKEN, not a length, so `style={{ gap: "2" }}` is invalid CSS
168
+ * and silently dropped. Panda resolves tokens at build time for props on a
169
+ * styled component; it cannot for a runtime value written into a style
170
+ * attribute. Making the root the parent sidesteps the problem entirely — the props
171
+ * stay exactly where Panda already handles them.
172
+ */
173
+ function wantsOwnLayout(rest: Record<string, unknown>): boolean {
174
+ return LAYOUT_PROPS.some((prop) => rest[prop] !== undefined && rest[prop] !== null);
175
+ }
176
+
104
177
  const StyledBoxRoot = styled("div", boxRecipe);
105
178
 
106
179
  const StyledBox = React.forwardRef<HTMLDivElement, StyledBoxProps>(
@@ -137,6 +210,10 @@ const StyledBox = React.forwardRef<HTMLDivElement, StyledBoxProps>(
137
210
  // scrollbar prop controls inner content overflow
138
211
  const innerOverflow: Property.Overflow = scrollbar === "auto" ? "auto" : scrollbar === "on" ? "scroll" : "hidden";
139
212
 
213
+ // Does the caller lay out their own children? If so the wrapper is what
214
+ // stands between their props and the elements those props describe.
215
+ const callerLaysOutChildren = wantsOwnLayout(rest as Record<string, unknown>);
216
+
140
217
  if (noWrap) {
141
218
  if (resolvedHeader || resolvedFooter) {
142
219
  return (
@@ -153,6 +230,30 @@ const StyledBox = React.forwardRef<HTMLDivElement, StyledBoxProps>(
153
230
  </StyledBoxRoot>
154
231
  );
155
232
  }
233
+ /*
234
+ The caller lays out their own children and nothing else needs the
235
+ wrapper, so the root parents them directly.
236
+
237
+ Gated on there being no header, footer, panels or scrollbar: each of
238
+ those is a real reason the wrapper exists, and this must not take any of
239
+ them away. What is left is the case where the wrapper only ever stood
240
+ between a caller's layout props and the children they describe.
241
+ */
242
+ if (
243
+ callerLaysOutChildren &&
244
+ !resolvedHeader &&
245
+ !resolvedFooter &&
246
+ !leftPanel &&
247
+ !rightPanel &&
248
+ scrollbar === undefined
249
+ ) {
250
+ return (
251
+ <StyledBoxRoot ref={ref} {...(as ? { as } : {})} className={combinedClassName} {...rest}>
252
+ {children}
253
+ </StyledBoxRoot>
254
+ );
255
+ }
256
+
156
257
  return (
157
258
  <StyledBoxRoot ref={ref} {...(as ? { as } : {})} className={combinedClassName} {...rest}>
158
259
  <StyledVStack gap="0" width="100%" height="100%" style={{ overflow: innerOverflow }}>
@@ -3,7 +3,6 @@ import { cx, css } from "styled-system/css";
3
3
  import { stripedRecipe } from "styled-system/recipes";
4
4
  import React from "react";
5
5
  import { log } from "../config/logger";
6
- import type { ConditionalValue } from "styled-system/types";
7
6
 
8
7
  const PandaGrid = styled("div", {
9
8
  base: {
@@ -11,25 +10,300 @@ const PandaGrid = styled("div", {
11
10
  },
12
11
  });
13
12
 
14
- export interface StyledGridProps extends Omit<HTMLStyledProps<"div">, "columns"> {
13
+ /**
14
+ * The breakpoints this component's responsive objects understand.
15
+ *
16
+ * Exactly the six declared by the preset, plus `base`. Order matters: the
17
+ * runtime fills a value forward from each key to the next, so this array is the
18
+ * cascade.
19
+ */
20
+ const GRID_BREAKPOINTS = ["base", "sm", "md", "lg", "xl", "2xl", "3xl"] as const;
21
+
22
+ export type GridBreakpoint = (typeof GRID_BREAKPOINTS)[number];
23
+
24
+ /** A plain value, or one value per breakpoint. */
25
+ export type GridResponsiveValue<T> = T | Partial<Record<GridBreakpoint, T>>;
26
+
27
+ /*
28
+ * ---------------------------------------------------------------------------
29
+ * Why the track definitions travel as CSS custom properties (NEH-1453)
30
+ * ---------------------------------------------------------------------------
31
+ *
32
+ * `StyledGrid` used to compute its `grid-template-*` values at runtime and hand
33
+ * them to Panda as style props:
34
+ *
35
+ * resolvedColumns = `repeat(${columns}, 1fr)`;
36
+ * <PandaGrid gridTemplateColumns={resolvedColumns} />
37
+ *
38
+ * Panda extracts styles by **statically parsing source at build time**. A value
39
+ * computed at runtime is never seen by `panda cssgen`, so Panda's runtime
40
+ * constructs a class *name* derived from the value and no rule is ever emitted
41
+ * for it. Measured in this package's own component tier, `<StyledGrid
42
+ * columns={2}>` inside a 320px container:
43
+ *
44
+ * class="d_grid grid-tc_repeat(2,_1fr)"
45
+ * inline style: null
46
+ * computed grid-template-columns: 445.188px <- ONE implicit, content-sized track
47
+ * rules in the sheet mentioning grid-template-columns: 1, and it is
48
+ * `.grid-tc_max-content_1fr` from StyledDefinitionList
49
+ *
50
+ * The same is true of `templateColumns` / `templateRows` / `templateAreas`,
51
+ * which are not Panda property names at all, so even a static literal at the
52
+ * call site is never extracted.
53
+ *
54
+ * Where it appeared to work in an app it was a **coincidence**: the class name
55
+ * is derived from the value, so a consumer whose own source happened to contain
56
+ * the same literal elsewhere got a rule by accident. That is why the fix cannot
57
+ * simply rewrite the emitted string — a new string breaks the coincidence and
58
+ * silently drops the columns.
59
+ *
60
+ * So the value has to reach the element by a route that does not depend on
61
+ * static extraction. Inline `style` is that route, and it is what
62
+ * `StyledSimpleGrid` already does. But a plain inline `grid-template-columns`
63
+ * cannot carry media queries, which is why `StyledSimpleGrid` pays for the
64
+ * responsive form with a JS resize listener (no server-rendered value, a
65
+ * listener per grid, a flash on first paint).
66
+ *
67
+ * This component avoids that trade by splitting the two halves:
68
+ *
69
+ * - the **rules** are literals in this file, so Panda really does extract
70
+ * them, one per breakpoint, with real `@media` conditions;
71
+ * - the **values** ride in on inline custom properties, which are never
72
+ * extracted, never parsed by Panda, and always applied.
73
+ *
74
+ * Result: real CSS breakpoints, correct on the server, no resize listener, and
75
+ * no upper bound on the column count.
76
+ *
77
+ * The runtime fills a value FORWARD across breakpoints rather than relying on
78
+ * nested `var()` fallbacks, so each rule is a flat `var(--x, none)`. `none` is
79
+ * the initial value of every `grid-template-*` property, so an axis nobody set
80
+ * resolves to exactly what it would have been.
81
+ */
82
+
83
+ const TRACK_VAR = {
84
+ columns: "--sds-grid-tc",
85
+ rows: "--sds-grid-tr",
86
+ areas: "--sds-grid-ta",
87
+ } as const;
88
+
89
+ type GridAxis = keyof typeof TRACK_VAR;
90
+
91
+ /**
92
+ * One class per axis, applied only when that axis has a value.
93
+ *
94
+ * They are separate constants on purpose. A single combined class would declare
95
+ * `grid-template-rows: none` on every grid, which is the initial value but is
96
+ * still a *declaration* — and it would then race, at equal specificity, with
97
+ * any `grid-template-rows` a consumer set through `className`. An axis nobody
98
+ * asked about is left untouched instead.
99
+ *
100
+ * Every value below is a string LITERAL. That is the entire point: Panda has to
101
+ * be able to read them without running anything.
102
+ */
103
+ const AXIS_CLASS: Record<GridAxis, string> = {
104
+ columns: css({
105
+ gridTemplateColumns: "var(--sds-grid-tc-base, none)",
106
+ sm: { gridTemplateColumns: "var(--sds-grid-tc-sm, none)" },
107
+ md: { gridTemplateColumns: "var(--sds-grid-tc-md, none)" },
108
+ lg: { gridTemplateColumns: "var(--sds-grid-tc-lg, none)" },
109
+ xl: { gridTemplateColumns: "var(--sds-grid-tc-xl, none)" },
110
+ "2xl": { gridTemplateColumns: "var(--sds-grid-tc-2xl, none)" },
111
+ "3xl": { gridTemplateColumns: "var(--sds-grid-tc-3xl, none)" },
112
+ }),
113
+ rows: css({
114
+ gridTemplateRows: "var(--sds-grid-tr-base, none)",
115
+ sm: { gridTemplateRows: "var(--sds-grid-tr-sm, none)" },
116
+ md: { gridTemplateRows: "var(--sds-grid-tr-md, none)" },
117
+ lg: { gridTemplateRows: "var(--sds-grid-tr-lg, none)" },
118
+ xl: { gridTemplateRows: "var(--sds-grid-tr-xl, none)" },
119
+ "2xl": { gridTemplateRows: "var(--sds-grid-tr-2xl, none)" },
120
+ "3xl": { gridTemplateRows: "var(--sds-grid-tr-3xl, none)" },
121
+ }),
122
+ areas: css({
123
+ gridTemplateAreas: "var(--sds-grid-ta-base, none)",
124
+ sm: { gridTemplateAreas: "var(--sds-grid-ta-sm, none)" },
125
+ md: { gridTemplateAreas: "var(--sds-grid-ta-md, none)" },
126
+ lg: { gridTemplateAreas: "var(--sds-grid-ta-lg, none)" },
127
+ xl: { gridTemplateAreas: "var(--sds-grid-ta-xl, none)" },
128
+ "2xl": { gridTemplateAreas: "var(--sds-grid-ta-2xl, none)" },
129
+ "3xl": { gridTemplateAreas: "var(--sds-grid-ta-3xl, none)" },
130
+ }),
131
+ };
132
+
133
+ /**
134
+ * Spread a responsive value across every breakpoint, carrying each value
135
+ * forward until the next one overrides it.
136
+ *
137
+ * Filling forward is what lets each emitted rule be a flat `var(--x, none)`: at
138
+ * `lg` the rule reads `--sds-grid-tc-lg`, so that property has to hold the
139
+ * value in force at `lg` whether it was set there or inherited from `md`.
140
+ *
141
+ * Returns `undefined` when the caller supplied nothing, so the axis class is
142
+ * not applied at all.
143
+ */
144
+ function resolveAxis(
145
+ value: GridResponsiveValue<string> | undefined,
146
+ componentName: string,
147
+ ): Partial<Record<GridBreakpoint, string>> | undefined {
148
+ if (value === undefined || value === null) return undefined;
149
+
150
+ if (typeof value === "string") {
151
+ const filled: Partial<Record<GridBreakpoint, string>> = {};
152
+ for (const breakpoint of GRID_BREAKPOINTS) filled[breakpoint] = value;
153
+ return filled;
154
+ }
155
+
156
+ if (typeof value !== "object") return undefined;
157
+
158
+ const unsupported = Object.keys(value).filter(
159
+ (key) => !(GRID_BREAKPOINTS as readonly string[]).includes(key),
160
+ );
161
+ if (unsupported.length > 0) {
162
+ // Loud rather than silent. Panda conditions other than a breakpoint
163
+ // (`_hover`, `_dark`, the array syntax) cannot be carried by a custom
164
+ // property, because there is no rule here that reads one under that
165
+ // condition. Before NEH-1453 they were dropped without a word; now they are
166
+ // dropped with one. Reach for `className={css({ ... })}` instead, which
167
+ // Panda extracts from the call site.
168
+ log.warn(`[${componentName}] ignoring unsupported responsive key(s)`, {
169
+ unsupported,
170
+ supported: GRID_BREAKPOINTS,
171
+ });
172
+ }
173
+
174
+ const filled: Partial<Record<GridBreakpoint, string>> = {};
175
+ let carried: string | undefined;
176
+ for (const breakpoint of GRID_BREAKPOINTS) {
177
+ const declared = (value as Partial<Record<GridBreakpoint, string>>)[breakpoint];
178
+ if (declared !== undefined) carried = declared;
179
+ if (carried !== undefined) filled[breakpoint] = carried;
180
+ }
181
+ return Object.keys(filled).length > 0 ? filled : undefined;
182
+ }
183
+
184
+ /**
185
+ * `repeat(n, minmax(<minTrackWidth>, 1fr))`, not `repeat(n, 1fr)`.
186
+ *
187
+ * In CSS `1fr` **is** shorthand for `minmax(auto, 1fr)`, and that `auto` floor
188
+ * is the grid item's automatic minimum size — its min-content. One child that
189
+ * cannot shrink therefore drags the whole track past the grid's container, and
190
+ * every sibling sized `width: 100%` inherits the overflow (NEH-1446/NEH-1447,
191
+ * measured at 425.875px inside a 375px container).
192
+ *
193
+ * `StyledSimpleGrid` made this change in 0.21.0 and `StyledGrid` deliberately
194
+ * did not, because at the time the emitted string still had to match a literal
195
+ * in the consumer's own source for any rule to exist at all — a new string
196
+ * would have broken that coincidence and dropped the columns entirely. Once the
197
+ * value stops travelling through Panda, that objection disappears, which is why
198
+ * the two changes belong in the same commit and not before it.
199
+ */
200
+ function columnsToTemplate(count: number, minTrackWidth: string): string {
201
+ return `repeat(${count}, minmax(${minTrackWidth}, 1fr))`;
202
+ }
203
+
204
+ function columnsToTemplateValue(
205
+ columns: GridResponsiveValue<number>,
206
+ minTrackWidth: string,
207
+ ): GridResponsiveValue<string> | undefined {
208
+ if (typeof columns === "number") return columnsToTemplate(columns, minTrackWidth);
209
+ if (typeof columns !== "object" || columns === null) return undefined;
210
+
211
+ const mapped: Partial<Record<GridBreakpoint, string>> = {};
212
+ for (const [key, count] of Object.entries(columns)) {
213
+ if (typeof count === "number") {
214
+ mapped[key as GridBreakpoint] = columnsToTemplate(count, minTrackWidth);
215
+ }
216
+ }
217
+ return mapped;
218
+ }
219
+
220
+ /** Write one axis' resolved values out as inline custom properties. */
221
+ function writeAxisVars(
222
+ target: Record<string, string>,
223
+ axis: GridAxis,
224
+ resolved: Partial<Record<GridBreakpoint, string>>,
225
+ ) {
226
+ for (const breakpoint of GRID_BREAKPOINTS) {
227
+ const value = resolved[breakpoint];
228
+ if (value !== undefined) target[`${TRACK_VAR[axis]}-${breakpoint}`] = value;
229
+ }
230
+ }
231
+
232
+ export interface StyledGridProps
233
+ extends Omit<
234
+ HTMLStyledProps<"div">,
235
+ "columns" | "gridTemplateColumns" | "gridTemplateRows" | "gridTemplateAreas"
236
+ > {
15
237
  children?: React.ReactNode;
16
238
  isStriped?: boolean;
17
239
  showGridLines?: boolean;
18
- templateColumns?: ConditionalValue<string>;
19
- templateRows?: ConditionalValue<string>;
20
- templateAreas?: ConditionalValue<string>;
21
- columns?: ConditionalValue<number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number }>;
240
+ /**
241
+ * A `grid-template-columns` value, or one per breakpoint.
242
+ *
243
+ * Narrower than Panda's `ConditionalValue` on purpose: only `base` and the
244
+ * six preset breakpoints are carried. Any other condition is ignored with a
245
+ * warning — see `resolveAxis`.
246
+ */
247
+ templateColumns?: GridResponsiveValue<string>;
248
+ templateRows?: GridResponsiveValue<string>;
249
+ templateAreas?: GridResponsiveValue<string>;
250
+ /** Alias for `templateColumns`; takes precedence when both are supplied. */
251
+ gridTemplateColumns?: GridResponsiveValue<string>;
252
+ gridTemplateRows?: GridResponsiveValue<string>;
253
+ gridTemplateAreas?: GridResponsiveValue<string>;
254
+ /** Column count. Emits `repeat(n, minmax(minTrackWidth, 1fr))`. */
255
+ columns?: GridResponsiveValue<number>;
256
+ /**
257
+ * The minimum size of each `columns`-generated track. Defaults to `"0"`.
258
+ *
259
+ * Pass `"auto"` to let a track refuse to shrink below its widest unbreakable
260
+ * child, which is occasionally what you want — a deliberately horizontally
261
+ * scrolled strip — but now has to be asked for by name. Mirrors
262
+ * `StyledSimpleGrid`.
263
+ */
264
+ minTrackWidth?: string;
22
265
  }
23
266
 
24
267
  const StyledGrid = React.forwardRef<HTMLDivElement, StyledGridProps>(
25
- ({
26
- isStriped,
27
- showGridLines,
28
- className,
29
- templateColumns,
30
- templateRows,
31
- templateAreas,
32
- columns, ...props }, ref) => {
268
+ (
269
+ {
270
+ isStriped,
271
+ showGridLines,
272
+ className,
273
+ style,
274
+ templateColumns,
275
+ templateRows,
276
+ templateAreas,
277
+ gridTemplateColumns,
278
+ gridTemplateRows,
279
+ gridTemplateAreas,
280
+ columns,
281
+ minTrackWidth = "0",
282
+ ...props
283
+ },
284
+ ref,
285
+ ) => {
286
+ // Explicit template wins over the `columns` shorthand, as before.
287
+ const columnsValue =
288
+ gridTemplateColumns ??
289
+ templateColumns ??
290
+ (columns === undefined ? undefined : columnsToTemplateValue(columns, minTrackWidth));
291
+
292
+ const resolved: Record<GridAxis, Partial<Record<GridBreakpoint, string>> | undefined> = {
293
+ columns: resolveAxis(columnsValue, "StyledGrid"),
294
+ rows: resolveAxis(gridTemplateRows ?? templateRows, "StyledGrid"),
295
+ areas: resolveAxis(gridTemplateAreas ?? templateAreas, "StyledGrid"),
296
+ };
297
+
298
+ const trackVars: Record<string, string> = {};
299
+ const axisClasses: string[] = [];
300
+ for (const axis of ["columns", "rows", "areas"] as const) {
301
+ const axisValues = resolved[axis];
302
+ if (axisValues === undefined) continue;
303
+ writeAxisVars(trackVars, axis, axisValues);
304
+ axisClasses.push(AXIS_CLASS[axis]);
305
+ }
306
+
33
307
  const combinedClassName = cx(
34
308
  isStriped ? stripedRecipe() : undefined,
35
309
  showGridLines
@@ -42,35 +316,10 @@ const StyledGrid = React.forwardRef<HTMLDivElement, StyledGridProps>(
42
316
  },
43
317
  })
44
318
  : undefined,
319
+ ...axisClasses,
45
320
  className,
46
321
  );
47
322
 
48
- // Map shorthand props to CSS grid properties
49
- const gridTemplateColumns = props.gridTemplateColumns ?? templateColumns;
50
- const gridTemplateRows = props.gridTemplateRows ?? templateRows;
51
- const gridTemplateAreas = props.gridTemplateAreas ?? templateAreas;
52
- delete props.gridTemplateColumns;
53
- delete props.gridTemplateRows;
54
- delete props.gridTemplateAreas;
55
-
56
- // Handle columns prop (responsive column count)
57
- let resolvedColumns = gridTemplateColumns;
58
- if (columns !== undefined && !resolvedColumns) {
59
- if (typeof columns === "number") {
60
- resolvedColumns = `repeat(${columns}, 1fr)`;
61
- } else if (typeof columns === "object" && columns !== null) {
62
- const colObj = columns as { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
63
- // For responsive objects, we need to generate a responsive value
64
- const responsive: Record<string, string> = {};
65
- if (colObj.base !== undefined) responsive.base = `repeat(${colObj.base}, 1fr)`;
66
- if (colObj.sm !== undefined) responsive.sm = `repeat(${colObj.sm}, 1fr)`;
67
- if (colObj.md !== undefined) responsive.md = `repeat(${colObj.md}, 1fr)`;
68
- if (colObj.lg !== undefined) responsive.lg = `repeat(${colObj.lg}, 1fr)`;
69
- if (colObj.xl !== undefined) responsive.xl = `repeat(${colObj.xl}, 1fr)`;
70
- resolvedColumns = responsive as ConditionalValue<string>;
71
- }
72
- }
73
-
74
323
  const childrenDetails = React.Children.map(props.children, (child) => {
75
324
  if (React.isValidElement(child)) {
76
325
  const element = child as React.ReactElement<{ id?: string }>;
@@ -91,13 +340,19 @@ const StyledGrid = React.forwardRef<HTMLDivElement, StyledGridProps>(
91
340
  childrenDetails
92
341
  });
93
342
 
343
+ // The caller's own `style` goes first: a `grid-template-columns` they set
344
+ // inline is their business and should still beat our class. Our custom
345
+ // properties are appended so nothing can accidentally shadow them.
346
+ const mergedStyle =
347
+ Object.keys(trackVars).length > 0
348
+ ? ({ ...style, ...trackVars } as React.CSSProperties)
349
+ : style;
350
+
94
351
  return (
95
352
  <PandaGrid
96
353
  ref={ref}
97
354
  className={combinedClassName}
98
- gridTemplateColumns={resolvedColumns}
99
- gridTemplateRows={gridTemplateRows}
100
- gridTemplateAreas={gridTemplateAreas}
355
+ {...(mergedStyle === undefined ? {} : { style: mergedStyle })}
101
356
  {...props}
102
357
  />
103
358
  );
@@ -17,6 +17,19 @@ interface StyledSimpleGridProps extends Omit<HTMLStyledProps<"div">, "columns">
17
17
  columns?: number | { base?: number; sm?: number; md?: number; lg?: number; xl?: number };
18
18
  gridTemplateRows?: string;
19
19
  gap?: string | number;
20
+ /**
21
+ * The minimum size of each `columns`-generated track. Defaults to `"0"`.
22
+ *
23
+ * `columns={n}` emits `repeat(n, minmax(<minTrackWidth>, 1fr))`. The default
24
+ * of `0` lets a track shrink below its content's min-content width, which is
25
+ * what keeps the grid inside its container.
26
+ *
27
+ * Pass `"auto"` to restore the pre-0.21.0 behaviour, in which a track refuses
28
+ * to shrink below its widest unbreakable child and the whole grid grows past
29
+ * its container. That is occasionally what you want — a deliberately
30
+ * horizontally scrolled strip — but it now has to be asked for by name.
31
+ */
32
+ minTrackWidth?: string;
20
33
  }
21
34
 
22
35
  const PandaSimpleGrid = styled("div", {
@@ -51,6 +64,7 @@ const StyledSimpleGrid: React.FC<StyledSimpleGridProps> = ({
51
64
  columns,
52
65
  gridTemplateRows,
53
66
  gap,
67
+ minTrackWidth = "0",
54
68
  style,
55
69
  children,
56
70
  ...rest
@@ -78,9 +92,22 @@ const StyledSimpleGrid: React.FC<StyledSimpleGridProps> = ({
78
92
  }, [recalculate]);
79
93
 
80
94
  // Runtime-computed grid values MUST use inline style — Panda CSS drops them at build time
95
+ //
96
+ // `minmax(minTrackWidth, 1fr)`, not a bare `1fr`. In CSS `1fr` IS shorthand
97
+ // for `minmax(auto, 1fr)`, and that `auto` floor is the grid item's automatic
98
+ // minimum size — its min-content. So one child that cannot shrink (a long
99
+ // unbroken string, a `white-space: nowrap` row, a fixed-width control) drags
100
+ // the track wider than the grid's own container, and every sibling sized
101
+ // `width: 100%` inherits the overflow. Measured on HopperGuard's dashboard at
102
+ // 375px: a 1-column grid resolved a 425.875px track inside a 375px container,
103
+ // and the shell clipped rather than scrolled (NEH-1446, NEH-1447).
104
+ //
105
+ // Guarded by grid-track-shrink.ct.tsx, which measures the resolved track in a
106
+ // real browser. jsdom cannot see this at all — it has no layout engine, so it
107
+ // reports every box as 0x0 and would agree that a 426px track fits 375px.
81
108
  const gridStyles: React.CSSProperties = {
82
109
  ...style,
83
- gridTemplateColumns: `repeat(${resolvedCols}, 1fr)`,
110
+ gridTemplateColumns: `repeat(${resolvedCols}, minmax(${minTrackWidth}, 1fr))`,
84
111
  gridTemplateRows,
85
112
  gap,
86
113
  };