Commit 92519a24cee for woocommerce
commit 92519a24cee0db551547d002ac74ccd9568b5904
Author: Allison Levine <1689238+allilevine@users.noreply.github.com>
Date: Fri Jul 24 10:00:34 2026 -0400
fix(email-editor): wrap overflowing button rows so nav menus don't widen emails (#66891)
* fix(email-editor): wrap overflowing button rows so nav menus don't widen emails
The Flex_Layout_Renderer emitted every button in a core/buttons block as a
cell in a single <tr>. Table rows can't reflow in email, so a wide button
group — e.g. a footer navigation menu with several items — overflowed the
content width and stretched the whole email sideways in Gmail desktop and
Outlook (NL-737).
When auto-width buttons are known to overflow the available width, render
them as inline-block items instead: clients that honor inline-block (Gmail,
Apple Mail, most webmail) wrap them onto multiple lines, and an Outlook-only
<br> before each subsequent item makes Outlook stack them vertically rather
than overflow. Rows that fit, single buttons, and explicitly-sized buttons
(which are already shrunk to fit) keep the exact single-row markup, so the
common case is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018NLtafPzaa6919XcsYMoZz
* Broaden flex layout wrap to rows with any auto-width item
* Assert inline-block in mixed-row wrap test
* Pick the wrap layout gap side from the row alignment
The wrapping layout padded the start side of every item after the first,
which indented wrapped lines (and Outlook's stacked buttons) away from the
flush edge of left- and center-aligned rows. Pad the end side of all items
before the last instead, so lines stay flush at the aligned edge — but keep
the start-side padding for end-aligned rows (right in LTR), where end-side
padding would push wrapped lines off the flush right edge and misalign them.
Logical sides keep RTL correct, and tests now lock in the gap side for both
alignments.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Rostislav Wolny <rosta.wolny@automattic.com>
diff --git a/packages/php/email-editor/changelog/fix-nl-737-flex-layout-wrap-email b/packages/php/email-editor/changelog/fix-nl-737-flex-layout-wrap-email
new file mode 100644
index 00000000000..bfda5030185
--- /dev/null
+++ b/packages/php/email-editor/changelog/fix-nl-737-flex-layout-wrap-email
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Wrap overflowing button/navigation rows in emails instead of stretching the layout. When auto-width buttons don't fit on one line, they now wrap (Gmail, Apple Mail, webmail) or stack vertically (Outlook) rather than pushing the email past its content width.
diff --git a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Layout/class-flex-layout-renderer.php b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Layout/class-flex-layout-renderer.php
index 632c58b023f..897f90de36b 100644
--- a/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Layout/class-flex-layout-renderer.php
+++ b/packages/php/email-editor/src/Engine/Renderer/ContentRenderer/Layout/class-flex-layout-renderer.php
@@ -33,6 +33,30 @@ class Flex_Layout_Renderer {
$styles .= 'margin-top: ' . $margin_top . ';';
$styles .= 'text-align: ' . $justify;
+ list( $inner_blocks, $should_wrap ) = $this->compute_widths_for_flex_layout( $parsed_block, $flex_gap_number );
+
+ if ( $should_wrap ) {
+ return $this->render_wrapping_layout( $inner_blocks, $styles, $justify, $flex_gap, $rendering_context );
+ }
+
+ return $this->render_single_row_layout( $inner_blocks, $styles, $justify, $flex_gap, $rendering_context );
+ }
+
+ /**
+ * Render the inner blocks as a single, non-wrapping row.
+ *
+ * This is the default layout: a table row whose cells sit side by side. It's correct whenever
+ * the items are known to fit the available width (and it's what every explicitly-sized buttons
+ * block uses, since those are shrunk to fit by {@see compute_widths_for_flex_layout()}).
+ *
+ * @param array $inner_blocks Inner blocks with computed widths.
+ * @param string $styles Wrapper styles (already includes margin-top and text-align).
+ * @param string $justify Resolved horizontal alignment (left/center/right).
+ * @param string $flex_gap Gap between items (e.g. "16px").
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @return string
+ */
+ private function render_single_row_layout( array $inner_blocks, string $styles, string $justify, string $flex_gap, Rendering_Context $rendering_context ): string {
// MS Outlook doesn't support style attribute in divs so we conditionally wrap the buttons in a table and repeat styles.
$output_html = sprintf(
'<!--[if mso | IE]><table align="%2$s" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%"><tr><td style="%1$s" ><![endif]-->
@@ -41,17 +65,15 @@ class Flex_Layout_Renderer {
esc_attr( $justify )
);
- $inner_blocks = $this->compute_widths_for_flex_layout( $parsed_block, $flex_gap_number );
-
foreach ( $inner_blocks as $key => $block ) {
- $styles = array();
+ $item_styles = array();
if ( $block['email_attrs']['layout_width'] ?? null ) {
- $styles['width'] = $block['email_attrs']['layout_width'];
+ $item_styles['width'] = $block['email_attrs']['layout_width'];
}
if ( $key > 0 ) {
- $styles[ 'padding-' . $rendering_context->get_start_side() ] = $flex_gap;
+ $item_styles[ 'padding-' . $rendering_context->get_start_side() ] = $flex_gap;
}
- $output_html .= '<td class="layout-flex-item" style="' . esc_attr( \WP_Style_Engine::compile_css( $styles, '' ) ) . '">' . render_block( $block ) . '</td>';
+ $output_html .= '<td class="layout-flex-item" style="' . esc_attr( \WP_Style_Engine::compile_css( $item_styles, '' ) ) . '">' . render_block( $block ) . '</td>';
}
$output_html .= '</tr></table></div>
<!--[if mso | IE]></td></tr></table><![endif]-->';
@@ -60,21 +82,85 @@ class Flex_Layout_Renderer {
}
/**
- * Compute widths for blocks in flex layout.
+ * Render the inner blocks so they wrap when they don't fit on one line.
+ *
+ * Used for auto-width button rows (e.g. a footer navigation menu) whose combined width exceeds
+ * the available width. A table row can't reflow in email, so each item is emitted as an
+ * inline-block <div>: clients that honor it (Gmail desktop, Apple Mail, most webmail) flow the
+ * items and wrap them onto multiple lines instead of stretching the email past its content
+ * width. MS Outlook (Word engine) ignores inline-block and can't wrap a row either, so an
+ * Outlook-only <br> before each item after the first forces it to stack the buttons vertically
+ * — no overflow, at the cost of a vertical list rather than a grid. Fixes NL-737.
+ *
+ * @param array $inner_blocks Inner blocks (at least one is auto-width in this path).
+ * @param string $styles Wrapper styles (already includes margin-top and text-align).
+ * @param string $justify Resolved horizontal alignment (left/center/right).
+ * @param string $flex_gap Gap between items (e.g. "16px").
+ * @param Rendering_Context $rendering_context Rendering context.
+ * @return string
+ */
+ private function render_wrapping_layout( array $inner_blocks, string $styles, string $justify, string $flex_gap, Rendering_Context $rendering_context ): string {
+ // Outlook ignores style on divs, so repeat the wrapper styles on a conditional table cell.
+ $output_html = sprintf(
+ '<!--[if mso | IE]><table align="%2$s" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%"><tr><td style="%1$s" ><![endif]-->
+ <div class="layout-flex-wrapper" style="%1$s">',
+ esc_attr( $styles ),
+ esc_attr( $justify )
+ );
+
+ // The gap padding must sit on the side away from the aligned (flush) edge, otherwise wrapped
+ // lines carry an edge pad and misalign against each other. When items align to the end side
+ // of the line flow (e.g. right in LTR), pad the start side of every item after the first;
+ // otherwise (start-aligned or centered) pad the end side of every item before the last.
+ $align_to_flow_end = $justify === $rendering_context->get_end_side();
+ $last_key = count( $inner_blocks ) - 1;
+
+ foreach ( $inner_blocks as $key => $block ) {
+ $item_styles = array(
+ 'display' => 'inline-block',
+ 'vertical-align' => 'top',
+ // Vertical gap so items that wrap onto a new line (and Outlook's stacked buttons) don't touch.
+ 'padding-bottom' => $flex_gap,
+ );
+ if ( $block['email_attrs']['layout_width'] ?? null ) {
+ $item_styles['width'] = $block['email_attrs']['layout_width'];
+ }
+ if ( $align_to_flow_end && $key > 0 ) {
+ $item_styles[ 'padding-' . $rendering_context->get_start_side() ] = $flex_gap;
+ } elseif ( ! $align_to_flow_end && $key < $last_key ) {
+ $item_styles[ 'padding-' . $rendering_context->get_end_side() ] = $flex_gap;
+ }
+ if ( $key > 0 ) {
+ // Force a line break before every item after the first in Outlook, which can't wrap an inline row.
+ $output_html .= '<!--[if mso | IE]><br><![endif]-->';
+ }
+ $output_html .= '<div class="layout-flex-item" style="' . esc_attr( \WP_Style_Engine::compile_css( $item_styles, '' ) ) . '">' . render_block( $block ) . '</div>';
+ }
+ $output_html .= '</div>
+ <!--[if mso | IE]></td></tr></table><![endif]-->';
+
+ return $output_html;
+ }
+
+ /**
+ * Compute widths for blocks in flex layout and decide whether the row must wrap.
*
* @param array $parsed_block Parsed block.
* @param float $flex_gap Flex gap.
- * @return array
+ * @return array{0: array, 1: bool} The inner blocks (with computed widths) and whether the row
+ * should wrap instead of rendering as a single row.
*/
private function compute_widths_for_flex_layout( array $parsed_block, float $flex_gap ): array {
- // When there is no parent width we can't compute widths so auto width will be used.
+ // When there is no parent width we can't compute widths so auto width will be used, and we
+ // can't tell whether the items overflow — fall back to the (non-wrapping) single row.
if ( ! isset( $parsed_block['email_attrs']['width'] ) ) {
- return $parsed_block['innerBlocks'] ?? array();
+ return array( $parsed_block['innerBlocks'] ?? array(), false );
}
$blocks_count = count( $parsed_block['innerBlocks'] );
$total_used_width = 0; // Total width assuming items without set width would consume proportional width.
$parent_width = Styles_Helper::parse_value( $parsed_block['email_attrs']['width'] );
$inner_blocks = $parsed_block['innerBlocks'] ?? array();
+ $has_auto_width = false;
foreach ( $inner_blocks as $key => $block ) {
$block_width_percent = ( $block['attrs']['width'] ?? 0 ) ? intval( $block['attrs']['width'] ) : 0;
@@ -83,15 +169,27 @@ class Flex_Layout_Renderer {
$total_used_width += $block_width ? $block_width : floor( $parent_width * ( 25 / 100 ) );
if ( ! $block_width ) {
+ $has_auto_width = true;
$inner_blocks[ $key ]['email_attrs']['layout_width'] = null; // Will be rendered as auto.
continue;
}
$inner_blocks[ $key ]['email_attrs']['layout_width'] = $this->get_width_without_gap( $block_width, $flex_gap, $block_width_percent ) . 'px';
}
- // When there is only one block, or percentage is set reasonably we don't need to adjust and just render as set by user.
- if ( $blocks_count <= 1 || ( $total_used_width <= $parent_width ) ) {
- return $inner_blocks;
+ $overflows = $blocks_count > 1 && $total_used_width > $parent_width;
+
+ // When there is only one block, or the items fit, render the single row as set by the user.
+ if ( ! $overflows ) {
+ return array( $inner_blocks, false );
+ }
+
+ // Auto-width items (e.g. a nav menu of buttons) can't be shrunk to fit, so an overflowing row
+ // that contains any of them can only be kept inside the content width by wrapping — the
+ // explicitly-sized items in the same row keep their width and flow alongside. A row made up
+ // entirely of explicit-width items is instead scaled down proportionally so it still fits on
+ // one row, preserving the existing behavior.
+ if ( $has_auto_width ) {
+ return array( $inner_blocks, true );
}
foreach ( $inner_blocks as $key => $block ) {
@@ -101,7 +199,7 @@ class Flex_Layout_Renderer {
$block_proportional_percentage = ( $block_proportional_width / $parent_width ) * 100;
$inner_blocks[ $key ]['email_attrs']['layout_width'] = $block_width ? $this->get_width_without_gap( $block_proportional_width, $flex_gap, $block_proportional_percentage ) . 'px' : null;
}
- return $inner_blocks;
+ return array( $inner_blocks, false );
}
/**
diff --git a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Layout/Flex_Layout_Renderer_Test.php b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Layout/Flex_Layout_Renderer_Test.php
index 75943295df1..3e6390ef433 100644
--- a/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Layout/Flex_Layout_Renderer_Test.php
+++ b/packages/php/email-editor/tests/integration/Engine/Renderer/ContentRenderer/Layout/Flex_Layout_Renderer_Test.php
@@ -279,24 +279,189 @@ class Flex_Layout_Renderer_Test extends \Email_Editor_Integration_Test_Case {
$flex_items = $this->getFlexItemsFromOutput( $output );
$this->assertStringContainsString( 'width:312px;', $flex_items[0] );
$this->assertStringContainsString( 'width:312px;', $flex_items[1] );
+ }
- // 100% and auto
- $parsed_block['innerBlocks'] = array(
+ /**
+ * A row of auto-width buttons whose combined width exceeds the parent (e.g. a footer nav menu)
+ * is rendered as wrapping inline-block items instead of a single non-wrapping row, so it can't
+ * stretch the email past its content width in Gmail/Outlook (NL-737).
+ */
+ public function testItWrapsAutoWidthButtonsThatOverflow(): void {
+ // Five auto-width items at a 25% estimate each = 125% of the parent, so they overflow.
+ $inner_blocks = array();
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $inner_blocks[] = array(
+ 'blockName' => 'dummy/block',
+ 'innerHTML' => "Dummy $i",
+ 'attrs' => array(),
+ );
+ }
+ $parsed_block = array(
+ 'innerBlocks' => $inner_blocks,
+ 'email_attrs' => array( 'width' => '640px' ),
+ );
+
+ $output = $this->renderer->render_inner_blocks_in_layout( $parsed_block, $this->rendering_context );
+
+ // Items are inline-block divs (which wrap in clients that support it) rather than table cells.
+ $this->assertStringContainsString( '<div class="layout-flex-item"', $output );
+ $this->assertStringContainsString( 'display:inline-block', $output );
+ $this->assertStringNotContainsString( '<td class="layout-flex-item"', $output );
+ // Outlook can't wrap a row, so a conditional <br> forces it to stack the buttons vertically.
+ $this->assertStringContainsString( '<!--[if mso | IE]><br><![endif]-->', $output );
+ // All buttons are still rendered.
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $this->assertStringContainsString( "Dummy $i", $output );
+ }
+ // For start-aligned (and centered) rows the gap pads the end side of all items but the last,
+ // so every wrapped line starts flush at the aligned edge.
+ $item_styles = $this->getWrapItemStylesFromOutput( $output );
+ $this->assertCount( 5, $item_styles );
+ foreach ( array_slice( $item_styles, 0, 4 ) as $style ) {
+ $this->assertStringContainsString( 'padding-right', $style );
+ }
+ $this->assertStringNotContainsString( 'padding-right', $item_styles[4] );
+ $this->assertStringNotContainsString( 'padding-left', $output );
+ }
+
+ /**
+ * When a wrapped row is end-aligned (right in LTR), the gap must pad the start side of every
+ * item after the first instead — end-side padding would push each wrapped line away from the
+ * flush right edge and misalign the lines against each other.
+ */
+ public function testItPadsTheStartSideWhenWrappedButtonsAlignToTheEndSide(): void {
+ $inner_blocks = array();
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $inner_blocks[] = array(
+ 'blockName' => 'dummy/block',
+ 'innerHTML' => "Dummy $i",
+ 'attrs' => array(),
+ );
+ }
+ $parsed_block = array(
+ 'innerBlocks' => $inner_blocks,
+ 'attrs' => array( 'layout' => array( 'justifyContent' => 'right' ) ),
+ 'email_attrs' => array( 'width' => '640px' ),
+ );
+
+ $output = $this->renderer->render_inner_blocks_in_layout( $parsed_block, $this->rendering_context );
+
+ $this->assertStringContainsString( '<div class="layout-flex-item"', $output );
+ $item_styles = $this->getWrapItemStylesFromOutput( $output );
+ $this->assertCount( 5, $item_styles );
+ $this->assertStringNotContainsString( 'padding-left', $item_styles[0] );
+ foreach ( array_slice( $item_styles, 1 ) as $style ) {
+ $this->assertStringContainsString( 'padding-left', $style );
+ }
+ $this->assertStringNotContainsString( 'padding-right', $output );
+ }
+
+ /**
+ * A row that mixes an explicit-width button with auto-width buttons and overflows also wraps:
+ * the auto-width items can't be shrunk to fit, so setting a width on a single button in the row
+ * must not defeat the wrapping (NL-737). The explicit-width item keeps its width in the wrap.
+ */
+ public function testItWrapsAMixedRowWithAnAutoWidthItemThatOverflows(): void {
+ // One 50% item (312px) + three auto items (25% estimate each) = ~125% of the parent, so the
+ // row overflows even though one button has an explicit width.
+ $inner_blocks = array(
array(
'blockName' => 'dummy/block',
'innerHTML' => 'Dummy 1',
- 'attrs' => array( 'width' => '100' ),
+ 'attrs' => array( 'width' => '50' ),
),
- array(
+ );
+ for ( $i = 2; $i <= 4; $i++ ) {
+ $inner_blocks[] = array(
'blockName' => 'dummy/block',
- 'innerHTML' => 'Dummy 2',
+ 'innerHTML' => "Dummy $i",
'attrs' => array(),
+ );
+ }
+ $parsed_block = array(
+ 'innerBlocks' => $inner_blocks,
+ 'email_attrs' => array( 'width' => '640px' ),
+ );
+
+ $output = $this->renderer->render_inner_blocks_in_layout( $parsed_block, $this->rendering_context );
+
+ // The row wraps (inline-block divs) rather than staying a single non-wrapping table row.
+ $this->assertStringContainsString( '<div class="layout-flex-item"', $output );
+ $this->assertStringContainsString( 'display:inline-block', $output );
+ $this->assertStringNotContainsString( '<td class="layout-flex-item"', $output );
+ // The explicit-width button keeps its computed width in the wrapped layout.
+ $this->assertStringContainsString( 'width:312px', $output );
+ // Outlook still stacks the buttons vertically via the conditional <br>.
+ $this->assertStringContainsString( '<!--[if mso | IE]><br><![endif]-->', $output );
+ // All buttons are still rendered.
+ for ( $i = 1; $i <= 4; $i++ ) {
+ $this->assertStringContainsString( "Dummy $i", $output );
+ }
+ }
+
+ /**
+ * Auto-width buttons that fit within the parent width keep the default single-row layout — the
+ * wrap path must not kick in for a small button group (e.g. Comment/Like).
+ */
+ public function testItKeepsAutoWidthButtonsOnOneRowWhenTheyFit(): void {
+ // Two auto-width items at a 25% estimate each = 50% of the parent, so they fit.
+ $parsed_block = array(
+ 'innerBlocks' => array(
+ array(
+ 'blockName' => 'dummy/block',
+ 'innerHTML' => 'Dummy 1',
+ 'attrs' => array(),
+ ),
+ array(
+ 'blockName' => 'dummy/block',
+ 'innerHTML' => 'Dummy 2',
+ 'attrs' => array(),
+ ),
),
+ 'email_attrs' => array( 'width' => '640px' ),
);
- $output = $this->renderer->render_inner_blocks_in_layout( $parsed_block, $this->rendering_context );
- $flex_items = $this->getFlexItemsFromOutput( $output );
- $this->assertStringContainsString( 'width:508px;', $flex_items[0] );
- $this->assertStringNotContainsString( 'width:', $flex_items[1] );
+
+ $output = $this->renderer->render_inner_blocks_in_layout( $parsed_block, $this->rendering_context );
+
+ $this->assertStringContainsString( '<td class="layout-flex-item"', $output );
+ $this->assertStringNotContainsString( '<div class="layout-flex-item"', $output );
+ $this->assertStringNotContainsString( '<!--[if mso | IE]><br><![endif]-->', $output );
+ }
+
+ /**
+ * Without a parent width we can't tell whether the items overflow, so we conservatively keep the
+ * single-row layout even for many auto-width items rather than wrapping unnecessarily.
+ */
+ public function testItDoesNotWrapWhenParentWidthIsUnknown(): void {
+ $inner_blocks = array();
+ for ( $i = 1; $i <= 5; $i++ ) {
+ $inner_blocks[] = array(
+ 'blockName' => 'dummy/block',
+ 'innerHTML' => "Dummy $i",
+ 'attrs' => array(),
+ );
+ }
+ $parsed_block = array(
+ 'innerBlocks' => $inner_blocks,
+ 'email_attrs' => array(),
+ );
+
+ $output = $this->renderer->render_inner_blocks_in_layout( $parsed_block, $this->rendering_context );
+
+ $this->assertStringContainsString( '<td class="layout-flex-item"', $output );
+ $this->assertStringNotContainsString( '<div class="layout-flex-item"', $output );
+ }
+
+ /**
+ * Get the style attribute of each item in a wrapping layout output.
+ *
+ * @param string $output Output.
+ * @return string[]
+ */
+ private function getWrapItemStylesFromOutput( string $output ): array {
+ $matches = array();
+ preg_match_all( '/<div class="layout-flex-item" style="([^"]*)"/', $output, $matches );
+ return $matches[1];
}
/**