Skip to content

Grid: accept the column count as steps by container width - #85216

Merged
retrofox merged 15 commits into
trunkfrom
update/grid-column-policy
Oct 21, 2026
Merged

retrofox merged 15 commits into
trunkfrom
update/grid-column-policy

Conversation

@retrofox

@retrofox retrofox commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

What?

columns on DashboardGrid and DashboardLanes accepts a ColumnSteps map, besides a number.

{ 0: 1, 600: 2, 960: 4 } renders one column from 0px, two from 600px and four from 960px.

A step can be 'auto'. From that width up there is no cap, and minColumnWidth alone decides the count.

A number resolves exactly as before. minColumnWidth composes with every shape.

Why?

A surface takes one count for every width. Stepping it by container width has to live in the consumer, and @wordpress/widget-dashboard hardcodes 960px and 600px.

A host should declare that policy as data, next to the floor, and let the surface apply it from its own measured width and gap.

The 'auto' step serves hosts that want fixed counts on narrow containers and a floor-driven count above them.

The dashboard package forwards the policy in a follow-up PR.

How?

packages/grid/src/shared/column-policy.ts. New. resolveColumnCap returns the policy's count at a container width, or undefined with no count or an 'auto' step. resolveColumnCount lowers it by the floor, fits the floor alone when no count applies, and falls back to the default, never below one.

packages/grid/src/dashboard-grid/index.tsx and packages/grid/src/dashboard-lanes/index.tsx. Both surfaces call resolveColumnCount instead of their inline math, with the same inputs: measured width, computed gap, default of six.

packages/grid/src/dashboard-grid/types.ts, packages/grid/src/dashboard-lanes/types.ts and packages/grid/src/index.ts. columns?: number | ColumnSteps, the prop docs, and the ColumnPolicy and ColumnSteps exports.

packages/grid/src/shared/test/column-policy.test.ts. Unit rows for both functions: pass-through, the step at each width, the 'auto' step, floor alone, cap over floor, a step lowered by the floor, never below one.

Stories. Responsive and Layered merge into one Columns story per surface. ColumnSteps shows the map; ColumnStepsWithFloor shows the 'auto' step over a floor.

packages/grid/README.md and packages/grid/CHANGELOG.md. The Columns section describes the three shapes with one example each. One enhancement entry.

Testing Instructions

  1. npm run storybook:dev, open Widgets → Grid → DashboardGrid → Column Steps. Resize the window: one column under 600px, two up to 960px, four from 960px.
  2. Open Column Steps With Floor. Same steps under 960px; from 960px the 240px floor decides: four columns from 1032px, five from 1296px.
  3. Open Columns, set columns to 4 and minColumnWidth to 240. Unchanged from trunk: the count drops one at a time as a column would fall under 240px.
  4. Repeat on DashboardLanes. Its floor stories use 200px.
  5. npm run test:unit -- packages/grid/src/shared/test/column-policy.test.ts: 13 tests pass.

Testing Instructions for Keyboard

No new interface. In the Columns stories, enable editMode and move or resize a tile with the keyboard. Gestures snap to the resolved count under every shape of columns.

Screenshots or screencast

Resolved count by container width, default 24px gap:

columns minColumnWidth 500px 800px 1000px 1300px
4 unset 4 4 4 4
{ 0: 1, 600: 2, 960: 4 } unset 1 2 4 4
{ 0: 1, 600: 2, 960: 4 } 300 1 2 3 4
{ 0: 1, 600: 2, 960: 'auto' } 240 1 2 3 5

DashboardGrid, Column Steps With Floor

[video: DashboardGrid, Column Steps With Floor]

DashboardLanes, Column Steps With Floor

[video: DashboardLanes, Column Steps With Floor]

columns and minColumnWidth compose; one Columns story replaces Responsive and Layered
columns accepts a count or a map by container width
the floor alone decides the count from that width up
eight tiles with explicit width and height
@retrofox retrofox added the [Type] Enhancement A suggestion for improvement. label Oct 20, 2026
@retrofox retrofox self-assigned this Oct 20, 2026
@github-actions github-actions Bot added the [Package] Grid /packages/grid label Oct 20, 2026
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

🤖 PR meta 🤖

Preview in WordPress Playground

🎉 Props

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: retrofox <retrofox@git.wordpress.org>
Co-authored-by: chihsuan <chihsuan@git.wordpress.org>
Co-authored-by: louwie17 <louwie17@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

Updated as activity occurs, without notifying anyone named here. Add the props-bot label to refresh.

📦 Bundle size

Size Change: +24 B (0%)

Total Size: 9.05 MB

📦 View Changed
Filename Size Change
build/styles/components/style-rtl.css 16.9 kB +4 B (+0.02%)
build/styles/components/style-rtl.min.css 14.3 kB +9 B (+0.06%)
build/styles/components/style.css 16.9 kB +2 B (+0.01%)
build/styles/components/style.min.css 14.3 kB +9 B (+0.06%)

4d85a8f Run

⚡ Performance

Show the results

Client side metrics exclude the server response time.

front-end-block-theme

Metric 1ac3a36 trunk % Change
timeToFirstByte 52.7 ms +15.56% -1.61% 53.35 ms +8.9% -3.66% -1.22%
largestContentfulPaint 96 ms +2.08% -6.25% 88 ms +9.09% -2.27% 9.09%
lcpMinusTtfb 37.65 ms +16.33% -8.76% 34.7 ms +6.92% -3.75% 8.5%
wpBeforeTemplate 28.36 ms +20.13% -1.13% 28.13 ms +16.92% -2.56% 0.82%
wpTemplate 20.93 ms +3.82% -3.68% 20.9 ms +5.65% -3.73% 0.14%
wpTotal 48.99 ms +16.51% -1.74% 49.66 ms +9.1% -3.97% -1.35%
wpMemoryUsage 7.65 MB +0% -0% 7.61 MB +0% -0% 0.46%
wpDbQueries 16 +0% -0% 16 +0% -0% 0%

front-end-classic-theme

Metric 1ac3a36 trunk % Change
timeToFirstByte 45.7 ms +6.89% -1.97% 45.6 ms +6.47% -1.32% 0.22%
largestContentfulPaint 100 ms +4% -0% 100 ms +4% -0% 0%
lcpMinusTtfb 55.2 ms +1.63% -1.63% 55 ms +1.36% -2.27% 0.36%
wpBeforeTemplate 26.49 ms +7.06% -1.36% 26.52 ms +3.58% -2.22% -0.11%
wpTemplate 15.86 ms +5.11% -1.13% 15.88 ms +2.2% -1.83% -0.13%
wpTotal 42.62 ms +6.85% -1.81% 42.58 ms +6.29% -1.48% 0.09%
wpMemoryUsage 6.34 MB +0% -0% 6.30 MB +0% -0% 0.57%
wpDbQueries 13 +0% -0% 13 +0% -0% 0%

media-processing

Metric 1ac3a36 trunk % Change
mediaProcessingJpeg 399.23 ms +1.84% -0.54% 399.22 ms +0.63% -0.87% 0%
mediaProcessingAvif 6075.24 ms +0.38% -0.51% 6102.3 ms +0.24% -0.22% -0.44%
mediaProcessingJpegToAvif 4185.98 ms +0.14% -0.08% 4218.74 ms +0.19% -0.11% -0.78%

media-upload

Metric 1ac3a36 trunk % Change
jpegUploadProcessing 1426.46 ms +38.75% -0.81% 1434.68 ms +1.12% -0.87% -0.57%
pngUploadProcessing 172.39 ms +27.42% -2.48% 194.05 ms +7.3% -12.27% -11.16%
largeJpegUploadProcessing 1414.38 ms +1.77% -0.14% 1414.93 ms +0.04% -0.53% -0.04%
multipleImageUploadProcessing 2020.01 ms +3.46% -19.93% 2005.34 ms +1.91% -20.85% 0.73%

post-editor

Metric 1ac3a36 trunk % Change
serverResponse 322.18 ms +4.56% -8.64% 326.99 ms +8.98% -3.93% -1.47%
firstPaint 224.86 ms +78.62% -12.75% 257.18 ms +50.98% -26.34% -12.57%
domContentLoaded 913.73 ms +1.97% -1.98% 883.58 ms +5% -1.96% 3.41%
loaded 914.75 ms +1.97% -1.97% 884.49 ms +5% -1.95% 3.42%
firstContentfulPaint 403.56 ms +0.79% -1.71% 394.19 ms +2.66% -5.72% 2.38%
firstBlock 2767.41 ms +0.17% -2.97% 2674.38 ms +2.81% -5.1% 3.48%
type 15.24 ms +1.77% -4.99% 15.11 ms +2.85% -3.77% 0.86%
typeWithoutInspector 15.98 ms +2.44% -8.01% 16.07 ms +3.98% -7.78% -0.56%
typeWithTopToolbar 20.58 ms +5.34% -1.65% 19.67 ms +4.22% -5.9% 4.63%
typeContainer 6.95 ms +8.06% -5.18% 6.85 ms +1.61% -3.8% 1.46%
focus 90.7 ms +5.06% -3.02% 87.13 ms +5.55% -3% 4.1%
firstFocus 193 ms +0% -0% 182.07 ms +0% -0% 6%
selectAll 471.75 ms +2.48% -4.25% 446.73 ms +1.3% -1.88% 5.6%
listViewOpen 61.82 ms +13.52% -1.68% 57.38 ms +4.22% -7.01% 7.74%
inserterOpen 34.31 ms +14.28% -7.34% 32.81 ms +6.07% -5.94% 4.57%
inserterHover 10.14 ms +8.19% -5.23% 9.37 ms +9.28% -5.55% 8.22%
inserterSearch 6.83 ms +15.81% -4.83% 6.77 ms +13% -5.76% 0.89%
loadPatterns 507.3 ms +2.59% -4.07% 490.44 ms +1.33% -1.09% 3.44%
wpTotal 314 ms +4.5% -8.95% 317.98 ms +7.94% -3.79% -1.25%
wpMemoryUsage 14.69 MB +0% -0% 14.65 MB +0% -0% 0.26%
wpDbQueries 55 +0% -1.82% 55 +0% -0% 0%

site-editor

Metric 1ac3a36 trunk % Change
serverResponse 493.58 ms +5.72% -2.29% 481.65 ms +5.65% -2.95% 2.48%
firstPaint 269.63 ms +17.79% -5.99% 258.61 ms +22.45% -10.16% 4.26%
domContentLoaded 1324.78 ms +1.41% -1.99% 1283.34 ms +1.22% -0.56% 3.23%
loaded 1326.01 ms +1.44% -1.97% 1284.7 ms +1.18% -0.59% 3.22%
firstContentfulPaint 538.71 ms +2.58% -2.66% 502.37 ms +1.35% -3.16% 7.23%
firstBlock 4923.93 ms +1.13% -0.48% 4773.24 ms +1% -0.63% 3.16%
type 19.7 ms +2.54% -4.11% 18.42 ms +4.02% -5.92% 6.95%
navigate 127.17 ms +25.25% -3.11% 121.53 ms +8.63% -2.7% 4.64%
loadPatterns 1302.98 ms +5.17% -4.01% 1310.73 ms +6.9% -2.89% -0.59%
loadPages 1002.35 ms +1.86% -1.71% 962.37 ms +3.16% -0.93% 4.15%
wpTotal 483.61 ms +5.89% -2.3% 472.04 ms +5.71% -3.08% 2.45%
wpMemoryUsage 12.70 MB +0% -0% 12.67 MB +0% -0% 0.29%
wpDbQueries 44 +2.27% -0% 44 +2.27% -0% 0%

4d85a8f Run

🏁 Flaky tests

Some tests passed with failed attempts. The failures may not be related to this commit but are still reported for visibility. See the documentation for more information.

Regression: updating a specific option will only change its value and will not corrupt others in /test/e2e/specs/editor/various/core-settings.spec.js, passed after 1 failed attempt.
Error: expect(received).toStrictEqual(expected) // deep equality

- Expected  - 1
+ Received  + 1

  Array [
    "rewrite_rules",
-   "SERIALIZED DATA",
+   "",
  ]
    at forEach (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/core-settings.spec.js:46:26)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/core-settings.spec.js:43:35
refuses the drop and uploads nothing in /test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js, passed after 1 failed attempt.
Error: expect(received).toHaveLength(expected)

Expected length: 0
Received length: 1
Received array:  [{"_links": {"about": [{"href": "http://localhost:8889/wp-json/wp/v2/types/attachment"}], "author": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/users/1"}], "collection": [{"href": "http://localhost:8889/wp-json/wp/v2/media"}], "curies": [{"href": "https://api.w.org/{rel}", "name": "wp", "templated": true}], "replies": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/comments?post=223"}], "self": [{"href": "http://localhost:8889/wp-json/wp/v2/media/223", "targetHints": {"allow": ["GET", "POST", "PUT", "PATCH", "DELETE"]}}], "wp:attached-to": [{"embeddable": true, "href": "http://localhost:8889/wp-json/wp/v2/posts/222", "id": 222, "post_type": "post"}]}, "alt_text": "", "author": 1, "caption": {"rendered": ""}, "class_list": ["post-223", "attachment", "type-attachment", "status-inherit", "hentry", "entry"], "comment_status": "open", "date": "2026-10-21T15:54:29", "date_gmt": "2026-10-21T15:54:29", "description": {"rendered": "<p class=\"attachment\"><a href='http://localhost:8889/wp-content/uploads/2026/10/a4e477f6-e8c2-46f3-9042-000492022da1.png'><img loading=\"lazy\" decoding=\"async\" width=\"10\" height=\"10\" src=\"http://localhost:8889/wp-content/uploads/2026/10/a4e477f6-e8c2-46f3-9042-000492022da1.png\" class=\"attachment-medium size-medium\" alt=\"\" style=\"width:100%;height:100%;max-width:10px;\" /></a></p>
"}, "featured_media": 0, "filename": "a4e477f6-e8c2-46f3-9042-000492022da1.png", "filesize": 80, "guid": {"rendered": "http://localhost:8889/wp-content/uploads/2026/10/a4e477f6-e8c2-46f3-9042-000492022da1.png"}, "id": 223, "link": "http://localhost:8889/?attachment_id=223", "media_details": {"file": "2026/10/a4e477f6-e8c2-46f3-9042-000492022da1.png", "filesize": 80, "height": 10, "image_meta": {"alt": "", "aperture": "0", "camera": "", "caption": "", "copyright": "", "created_timestamp": "0", "credit": "", "focal_length": "0", "iso": "0", "keywords": [], "orientation": "0", "shutter_speed": "0", "title": ""}, "sizes": {}, "width": 10}, "media_type": "image", "meta": [], "mime_type": "image/png", "modified": "2026-10-21T15:54:29", "modified_gmt": "2026-10-21T15:54:29", "ping_status": "closed", "post": 222, "slug": "a4e477f6-e8c2-46f3-9042-000492022da1", "source_url": "http://localhost:8889/wp-content/uploads/2026/10/a4e477f6-e8c2-46f3-9042-000492022da1.png", "status": "inherit", "template": "", "title": {"rendered": "a4e477f6-e8c2-46f3-9042-000492022da1"}, "type": "attachment"}]
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/single-file-placeholder-drop.spec.js:71:45
restores the blocks when a multi-selection drag is canceled in /test/e2e/specs/editor/various/draggable-blocks.spec.js, passed after 1 failed attempt.
TypeError: Cannot read properties of null (reading 'y')
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/draggable-blocks.spec.js:824:57
    at dragSelectionBelowLanding (/home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/draggable-blocks.spec.js:823:3)
    at /home/runner/work/gutenberg/gutenberg/test/e2e/specs/editor/various/draggable-blocks.spec.js:871:3

4d85a8f Run

@chihsuan chihsuan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this! @retrofox Tested the Column Steps stories and they work nicely. 👍 Left a few inline notes.

const cap = resolveColumnCap( policy.columns, containerWidth );
const { minColumnWidth } = policy;
if ( ! minColumnWidth || minColumnWidth <= 0 ) {
return Math.max( 1, cap ?? defaultColumns );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep the previous step's count for an 'auto' step when minColumnWidth isn't set? Right now it falls back to six, so { 0: 8, 960: 'auto' } drops from 8 to 6 columns at 960px. The dashboard doesn't pass minColumnWidth today, so it would hit this with steps.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. Two additions: the dashboard's own default is 4 (WIDGET_DASHBOARD_COLUMN_COUNT), so with the current fallback an 'auto' step there would render the grid's 6, not even the dashboard's default. And the renders the default on an auto step with no floor test locks in the 6, so it would need to change along with this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it does make sense. Thanks for the feedback. Done in b0038dc

With no floor, an 'auto' step is skipped, and the count below it holds, so { 0: 8, 960: 'auto' } renders 8 at every width. { 0: 'auto' } alone still takes the surface default, so that test stays, with a new row for the count below. The README and the ColumnSteps doc say so.

}
const reached = widths.find( ( width ) => containerWidth >= width );
const step = columns[ reached ?? widths[ widths.length - 1 ] ];
return typeof step === 'number' ? step : undefined;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be worth normalizing step values here, the way resolveDashboardColumnCap does? I noticed a string like '3' lifts the cap like 'auto', and 2.5 or NaN reach grid-template-columns as is. Since the follow-up replaces that helper, its rounding and finite check would otherwise be lost.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1. On DashboardLanes, a NaN step goes further than a bad grid-template-columns: Math.max( 1, NaN ) stays NaN, and new Array( NaN ) in lane-placement.ts throws a RangeError. A plain columns={ NaN } does the same on trunk, so normalizing in resolveColumnCount for both the number and the steps would cover both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Done in 4edad78, for the number and the map alike: counts are rounded down and never below one; a count that is not a finite number is unset; and a step that is neither a count nor 'auto' is skipped, so the count below it holds.

The floor also needs a finite positive value. Lanes no longer see a NaN. The README states the rule, and the changelog entry carries it.

Comment thread packages/grid/README.md Outdated
Comment on lines +184 to +185
`columns` and `minColumnWidth` compose. Both surfaces measure their
own width with a `ResizeObserver` and resolve the count from it.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Could we keep each new paragraph on one line? AGENTS.md asks for soft-wrapped Markdown, even when an older file like this one is hard-wrapped. Same for the other new paragraphs in this section.

Suggested change
`columns` and `minColumnWidth` compose. Both surfaces measure their
own width with a `ResizeObserver` and resolve the count from it.
`columns` and `minColumnWidth` compose. Both surfaces measure their own width with a `ResizeObserver` and resolve the count from it.

Comment thread packages/grid/src/index.ts Outdated
DashboardLanesLayoutItem,
DashboardLanesProps,
} from './dashboard-lanes/types';
export type { ColumnPolicy, ColumnSteps } from './shared/column-policy';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a consumer in mind for ColumnPolicy? It only types the parameter of the internal resolveColumnCount, so exporting just ColumnSteps might be enough for now. Adding it back later wouldn't be breaking.

Suggested change
export type { ColumnPolicy, ColumnSteps } from './shared/column-policy';
export type { ColumnSteps } from './shared/column-policy';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 99c7a6e 👍

if ( columns === undefined || typeof columns === 'number' ) {
return columns;
}
const widths = Object.keys( columns )

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

columns={ null } from untyped JS used to fall back to the default through columns ?? DEFAULT_COLUMNS. Now typeof null is 'object', so it gets past the undefined and number check and throws on Object.keys( null ). It's outside the TypeScript contract, so it's low priority, but checking columns == null on line 31 would keep the old behaviour.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covered by the same normalization in 4edad78: null goes through the count check and resolves to the default.

5b40f07 adds a test row for it 🧪

return undefined;
}
const reached = widths.find( ( width ) => containerWidth >= width );
const step = columns[ reached ?? widths[ widths.length - 1 ] ];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the container is narrower than the smallest key, the smallest step's count applies, so { 600: 2, 960: 4 } gives 2 columns at 300px. That seems a sensible fallback, and the tests cover it, but neither the PR description nor the README says so. The README describes each count as applying "from each container width up", which reads as if nothing applies below the first key. Could the Columns section mention it, or recommend starting the map at 0?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Lourens. It's documented in c8db0ab, right after the map example in the README and in the ColumnSteps doc

@retrofox
retrofox merged commit af96ddb into trunk Oct 21, 2026
99 of 100 checks passed
@retrofox
retrofox deleted the update/grid-column-policy branch October 21, 2026 16:55
@github-actions github-actions Bot added this to the Gutenberg 24.4 milestone Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Grid /packages/grid [Type] Enhancement A suggestion for improvement.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants