diff --git a/plugins/newspack-plugin/includes/class-blocks.php b/plugins/newspack-plugin/includes/class-blocks.php
index 6035d14131..0d03216995 100644
--- a/plugins/newspack-plugin/includes/class-blocks.php
+++ b/plugins/newspack-plugin/includes/class-blocks.php
@@ -41,6 +41,8 @@ public static function init() {
require_once NEWSPACK_ABSPATH . 'src/blocks/overlay-menu/trigger/class-overlay-menu-trigger-block.php';
require_once NEWSPACK_ABSPATH . 'src/blocks/overlay-menu/panel/class-overlay-menu-panel-block.php';
require_once NEWSPACK_ABSPATH . 'src/blocks/overlay-search/class-overlay-search-block.php';
+ require_once NEWSPACK_ABSPATH . 'src/blocks/responsive-container/class-responsive-container-block.php';
+ require_once NEWSPACK_ABSPATH . 'src/blocks/responsive-container/breakpoint/class-responsive-container-breakpoint-block.php';
Social_Icons::init();
}
if ( Collections::is_module_active() ) {
diff --git a/plugins/newspack-plugin/src/blocks/index.js b/plugins/newspack-plugin/src/blocks/index.js
index 6ff2df0540..dfbb953ea6 100644
--- a/plugins/newspack-plugin/src/blocks/index.js
+++ b/plugins/newspack-plugin/src/blocks/index.js
@@ -25,6 +25,8 @@ import * as overlayMenu from './overlay-menu';
import * as overlayMenuTrigger from './overlay-menu/trigger';
import * as overlayMenuPanel from './overlay-menu/panel';
import * as overlaySearch from './overlay-search';
+import * as responsiveContainer from './responsive-container';
+import * as responsiveContainerBreakpoint from './responsive-container/breakpoint';
/**
* Block Scripts
@@ -49,6 +51,8 @@ export const blocks = [
overlayMenuTrigger,
overlayMenuPanel,
overlaySearch,
+ responsiveContainer,
+ responsiveContainerBreakpoint,
];
const readerActivationBlocks = [ 'newspack/reader-registration', 'newspack/my-account-button' ];
@@ -67,7 +71,10 @@ const blockThemeBlocks = [
'newspack/overlay-menu-panel',
'newspack/my-account-button',
'newspack/overlay-search',
+ 'newspack/responsive-container',
+ 'newspack/responsive-container-breakpoint',
];
+const siteEditorOnlyBlocks = [ 'newspack/responsive-container', 'newspack/responsive-container-breakpoint' ];
/**
* Function to register an individual block.
@@ -102,6 +109,10 @@ const registerBlock = block => {
if ( blockThemeBlocks.includes( name ) && ! newspack_blocks.is_block_theme ) {
return;
}
+ /** Do not register Site Editor-only blocks outside the Site Editor. */
+ if ( siteEditorOnlyBlocks.includes( name ) && window.pagenow !== 'site-editor' ) {
+ return;
+ }
registerBlockType( blockMetadata, settings );
};
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/block.json b/plugins/newspack-plugin/src/blocks/responsive-container/block.json
new file mode 100644
index 0000000000..a086dd8a70
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/block.json
@@ -0,0 +1,17 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "newspack/responsive-container",
+ "title": "Responsive Container",
+ "category": "newspack",
+ "description": "A container that shows one set of blocks on desktop and another on mobile, swapping automatically at a breakpoint. Ideal for responsive headers and footers.",
+ "keywords": [ "responsive", "adaptive", "header", "footer", "mobile", "desktop" ],
+ "textdomain": "newspack-plugin",
+ "supports": {
+ "html": false,
+ "anchor": true,
+ "position": { "sticky": true }
+ },
+ "editorStyle": "file:../../../dist/blocks.css",
+ "style": "file:../../../dist/blocks.css"
+}
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/block.json b/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/block.json
new file mode 100644
index 0000000000..dd3e57b606
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/block.json
@@ -0,0 +1,28 @@
+{
+ "$schema": "https://schemas.wp.org/trunk/block.json",
+ "apiVersion": 3,
+ "name": "newspack/responsive-container-breakpoint",
+ "title": "Breakpoint",
+ "category": "newspack",
+ "description": "A single view (desktop or mobile) within a Responsive Container.",
+ "textdomain": "newspack-plugin",
+ "parent": [ "newspack/responsive-container" ],
+ "attributes": {
+ "view": {
+ "type": "string",
+ "enum": [ "desktop", "mobile" ],
+ "default": "desktop"
+ },
+ "lock": {
+ "type": "object",
+ "default": { "move": true, "remove": true }
+ }
+ },
+ "supports": {
+ "html": false,
+ "lock": false,
+ "inserter": false
+ },
+ "editorStyle": "file:../../../../dist/blocks.css",
+ "style": "file:../../../../dist/blocks.css"
+}
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/class-responsive-container-breakpoint-block.php b/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/class-responsive-container-breakpoint-block.php
new file mode 100644
index 0000000000..a387c320be
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/class-responsive-container-breakpoint-block.php
@@ -0,0 +1,38 @@
+ {
+ const { getBlockRootClientId, getBlocks, getBlockOrder } = select( 'core/block-editor' );
+ const root = getBlockRootClientId( clientId );
+ return {
+ parentClientId: root,
+ // Guard against a missing root during transitions (e.g. template-part
+ // switches): getBlocks( falsy ) returns the top-level blocks, not [].
+ siblings: root ? getBlocks( root ) : [],
+ isEmpty: getBlockOrder( clientId ).length === 0,
+ };
+ },
+ [ clientId ]
+ );
+
+ // Key to our own clientId until the parent resolves, so transient state is
+ // never shared under a `null` key; useView re-subscribes when it changes.
+ const [ activeView, setView ] = useView( parentClientId || clientId );
+ const isActive = activeView === view;
+
+ const { selectBlock } = useDispatch( 'core/block-editor' );
+
+ const switchView = newView => {
+ setView( newView );
+ // Move selection onto the breakpoint that is becoming visible (falling
+ // back to the container) so the toolbar always anchors to a visible block.
+ const target = siblings.find( block => block.attributes?.view === newView );
+ selectBlock( target ? target.clientId : parentClientId || clientId );
+ };
+
+ const className =
+ 'newspack-responsive-container-breakpoint' +
+ ` newspack-responsive-container-breakpoint--${ view }` +
+ ( isActive ? '' : ' is-inactive-view' ) +
+ ( isEmpty ? ' is-empty' : '' );
+
+ const blockProps = useBlockProps( { className } );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/index.js b/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/index.js
new file mode 100644
index 0000000000..94ec683420
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/breakpoint/index.js
@@ -0,0 +1,59 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { desktop, mobile } from '@wordpress/icons';
+import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import colors from '../../../../packages/colors/colors.module.scss';
+
+export const title = __( 'Breakpoint', 'newspack-plugin' );
+
+const { name } = metadata;
+
+export { metadata, name };
+
+const foreground = colors[ 'primary-400' ];
+
+export const settings = {
+ title,
+ icon: {
+ src: desktop,
+ foreground,
+ },
+ // A single block type shares one static icon, so two variations — matched to
+ // the `view` attribute via `isActive` — give the desktop and mobile breakpoints
+ // their own icon and label in the List View and breadcrumb.
+ variations: [
+ {
+ name: 'desktop',
+ title: __( 'Desktop', 'newspack-plugin' ),
+ icon: { src: desktop, foreground },
+ attributes: { view: 'desktop' },
+ isActive: [ 'view' ],
+ },
+ {
+ name: 'mobile',
+ title: __( 'Mobile', 'newspack-plugin' ),
+ icon: { src: mobile, foreground },
+ attributes: { view: 'mobile' },
+ isActive: [ 'view' ],
+ },
+ ],
+ edit: Edit,
+ save: ( { attributes } ) => {
+ const blockProps = useBlockProps.save( {
+ className: `newspack-responsive-container-breakpoint--${ attributes.view }`,
+ } );
+ return (
+
+
+
+ );
+ },
+};
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/class-responsive-container-block.php b/plugins/newspack-plugin/src/blocks/responsive-container/class-responsive-container-block.php
new file mode 100644
index 0000000000..c644af541d
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/class-responsive-container-block.php
@@ -0,0 +1,99 @@
+= this width;
+ * mobile breakpoint shows at <= ( breakpoint - 1 ).
+ */
+ const DEFAULT_BREAKPOINT = 782;
+
+ /**
+ * Initializes the block.
+ *
+ * @return void
+ */
+ public static function init() {
+ add_action( 'init', [ __CLASS__, 'register_block' ] );
+ add_action( 'wp_enqueue_scripts', [ __CLASS__, 'enqueue_visibility_style' ] );
+ }
+
+ /**
+ * Registers the block type from metadata (static block, no render callback).
+ *
+ * @return void
+ */
+ public static function register_block() {
+ register_block_type_from_metadata( __DIR__ . '/block.json' );
+ }
+
+ /**
+ * Resolves the swap breakpoint.
+ *
+ * Precedence (highest first): the `newspack_responsive_container_breakpoint`
+ * filter, the theme.json `settings.custom.newspackResponsiveBreakpoint` value,
+ * then the default.
+ *
+ * @return int Breakpoint in pixels.
+ */
+ public static function get_breakpoint() {
+ $default = self::DEFAULT_BREAKPOINT;
+ $custom = wp_get_global_settings( [ 'custom', 'newspackResponsiveBreakpoint' ] );
+ $value = is_numeric( $custom ) ? (int) $custom : $default;
+
+ /**
+ * Filters the breakpoint (in pixels) at which the Responsive Container
+ * swaps between its desktop and mobile breakpoints.
+ *
+ * @param int $value Resolved breakpoint in pixels.
+ */
+ $value = (int) apply_filters( 'newspack_responsive_container_breakpoint', $value );
+
+ // A non-positive breakpoint would produce invalid media queries; fall back.
+ return $value > 0 ? $value : $default;
+ }
+
+ /**
+ * Builds the front-end visibility CSS for the current breakpoint.
+ *
+ * @return string CSS rules.
+ */
+ public static function get_visibility_css() {
+ $breakpoint = self::get_breakpoint();
+ return sprintf(
+ '@media (max-width:%1$dpx){.newspack-responsive-container-breakpoint--desktop{display:none !important;}}@media (min-width:%2$dpx){.newspack-responsive-container-breakpoint--mobile{display:none !important;}}',
+ $breakpoint - 1,
+ $breakpoint
+ );
+ }
+
+ /**
+ * Enqueues the single global visibility stylesheet on the front-end.
+ *
+ * Always enqueued (a few bytes) because the block commonly lives in header/
+ * footer template parts, which `has_block()` cannot detect from post content.
+ *
+ * @return void
+ */
+ public static function enqueue_visibility_style() {
+ wp_register_style( 'newspack-responsive-container', false, [], NEWSPACK_PLUGIN_VERSION );
+ wp_enqueue_style( 'newspack-responsive-container' );
+ wp_add_inline_style( 'newspack-responsive-container', self::get_visibility_css() );
+ }
+}
+Responsive_Container_Block::init();
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/edit.js b/plugins/newspack-plugin/src/blocks/responsive-container/edit.js
new file mode 100644
index 0000000000..c0d74fd318
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/edit.js
@@ -0,0 +1,48 @@
+/**
+ * WordPress dependencies
+ */
+import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import ViewToggle from './view-toggle';
+import { useView } from './view-state';
+
+const ALLOWED_BLOCKS = [ 'newspack/responsive-container-breakpoint' ];
+const BLOCKS_TEMPLATE = [
+ [ 'newspack/responsive-container-breakpoint', { view: 'desktop' } ],
+ [ 'newspack/responsive-container-breakpoint', { view: 'mobile' } ],
+];
+
+/**
+ * Edit component for the Responsive Container block.
+ *
+ * Renders a locked template of exactly two breakpoints (desktop + mobile) and
+ * the view toggle. The edited view defaults to desktop and is held in ephemeral
+ * editor-only state (shared with the breakpoints, so toggling never dirties the
+ * post); the inactive breakpoint hides itself in the editor. The same toggle is
+ * rendered by each breakpoint so it can be switched without reselecting the
+ * container.
+ *
+ * @param {Object} props Block props.
+ * @param {string} props.clientId Block client ID.
+ *
+ * @return {JSX.Element} The block editor UI.
+ */
+export default function ResponsiveContainerEdit( { clientId } ) {
+ const [ view, setView ] = useView( clientId );
+
+ const blockProps = useBlockProps( {
+ className: 'newspack-responsive-container',
+ } );
+
+ return (
+ <>
+
+
+
+
+ >
+ );
+}
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/index.js b/plugins/newspack-plugin/src/blocks/responsive-container/index.js
new file mode 100644
index 0000000000..8eafae55fe
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/index.js
@@ -0,0 +1,46 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { resizeCornerNE as icon } from '@wordpress/icons';
+import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
+
+/**
+ * Internal dependencies
+ */
+import metadata from './block.json';
+import Edit from './edit';
+import colors from '../../../packages/colors/colors.module.scss';
+import './style.scss';
+
+export const title = __( 'Responsive Container', 'newspack-plugin' );
+
+const { name } = metadata;
+
+export { metadata, name };
+
+export const settings = {
+ title,
+ icon: {
+ src: icon,
+ foreground: colors[ 'primary-400' ],
+ },
+ keywords: [
+ __( 'responsive', 'newspack-plugin' ),
+ __( 'adaptive', 'newspack-plugin' ),
+ __( 'header', 'newspack-plugin' ),
+ __( 'footer', 'newspack-plugin' ),
+ __( 'mobile', 'newspack-plugin' ),
+ __( 'desktop', 'newspack-plugin' ),
+ ],
+ description: __(
+ 'A container that shows one set of blocks on desktop and another on mobile, swapping automatically at a breakpoint. Ideal for responsive headers and footers.',
+ 'newspack-plugin'
+ ),
+ edit: Edit,
+ save: () => (
+
+
+
+ ),
+};
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/style.scss b/plugins/newspack-plugin/src/blocks/responsive-container/style.scss
new file mode 100644
index 0000000000..375fca6ea0
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/style.scss
@@ -0,0 +1,11 @@
+// Editor-only helpers (these classes are added only in the editor).
+.newspack-responsive-container-breakpoint {
+ &.is-inactive-view {
+ display: none !important;
+ }
+
+ // Keep an empty breakpoint clickable instead of collapsing.
+ &.is-empty {
+ min-height: 48px;
+ }
+}
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/view-state.js b/plugins/newspack-plugin/src/blocks/responsive-container/view-state.js
new file mode 100644
index 0000000000..fcdf931715
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/view-state.js
@@ -0,0 +1,69 @@
+/**
+ * WordPress dependencies
+ */
+import { useState, useEffect } from '@wordpress/element';
+
+/**
+ * Ephemeral, editor-only store for which view (desktop / mobile) is being edited
+ * in each Responsive Container instance.
+ *
+ * The value is kept in module memory rather than a block attribute so toggling
+ * views never dirties the post — the same approach the Overlay Menu block uses
+ * for its preview state. State is keyed by the container's clientId and shared
+ * between the container and its breakpoint children.
+ */
+const views = new Map();
+const listeners = new Map();
+
+const DEFAULT_VIEW = 'desktop';
+
+/**
+ * Returns the current view for a container, defaulting to desktop.
+ *
+ * @param {string} clientId Container clientId.
+ * @return {string} 'desktop' | 'mobile'
+ */
+export function getView( clientId ) {
+ return views.get( clientId ) || DEFAULT_VIEW;
+}
+
+/**
+ * Sets the view for a container and notifies subscribers.
+ *
+ * @param {string} clientId Container clientId.
+ * @param {string} view 'desktop' | 'mobile'
+ */
+function setView( clientId, view ) {
+ views.set( clientId, view );
+ listeners.get( clientId )?.forEach( callback => callback( view ) );
+}
+
+/**
+ * Subscribe to the view of a container instance and read/update it.
+ *
+ * @param {string} clientId Container clientId (the container's own, or a
+ * breakpoint's parent clientId).
+ * @return {Array} `[ view, setViewForContainer ]`.
+ */
+export function useView( clientId ) {
+ const [ view, setLocal ] = useState( () => getView( clientId ) );
+
+ useEffect( () => {
+ // Re-sync in case the value changed between render and subscribe.
+ setLocal( getView( clientId ) );
+ let subscribers = listeners.get( clientId );
+ if ( ! subscribers ) {
+ subscribers = new Set();
+ listeners.set( clientId, subscribers );
+ }
+ subscribers.add( setLocal );
+ return () => {
+ subscribers.delete( setLocal );
+ if ( subscribers.size === 0 ) {
+ listeners.delete( clientId );
+ }
+ };
+ }, [ clientId ] );
+
+ return [ view, newView => setView( clientId, newView ) ];
+}
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/view-state.test.js b/plugins/newspack-plugin/src/blocks/responsive-container/view-state.test.js
new file mode 100644
index 0000000000..c3a750c573
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/view-state.test.js
@@ -0,0 +1,77 @@
+/**
+ * External dependencies
+ */
+import { renderHook, act } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import { getView, useView } from './view-state';
+
+/**
+ * Each test uses a unique clientId because the view store is module-global and
+ * persists for the lifetime of the editor session (and so across tests here).
+ */
+describe( 'responsive-container view-state', () => {
+ it( 'defaults to the desktop view for an unknown container', () => {
+ expect( getView( 'unknown' ) ).toBe( 'desktop' );
+ } );
+
+ it( 'useView returns the current view and a setter', () => {
+ const { result } = renderHook( () => useView( 'returns' ) );
+ const [ view, setView ] = result.current;
+ expect( view ).toBe( 'desktop' );
+ expect( typeof setView ).toBe( 'function' );
+ } );
+
+ it( 'updates the view and reflects it in getView', () => {
+ const { result } = renderHook( () => useView( 'updates' ) );
+ act( () => result.current[ 1 ]( 'mobile' ) );
+ expect( result.current[ 0 ] ).toBe( 'mobile' );
+ expect( getView( 'updates' ) ).toBe( 'mobile' );
+ } );
+
+ it( 'shares state across instances of the same container', () => {
+ // The container and its breakpoint children all subscribe to the same
+ // clientId; setting from one must update the others.
+ const container = renderHook( () => useView( 'shared' ) );
+ const breakpoint = renderHook( () => useView( 'shared' ) );
+
+ act( () => container.result.current[ 1 ]( 'mobile' ) );
+
+ expect( container.result.current[ 0 ] ).toBe( 'mobile' );
+ expect( breakpoint.result.current[ 0 ] ).toBe( 'mobile' );
+ } );
+
+ it( 'keeps separate containers independent', () => {
+ const first = renderHook( () => useView( 'independent-a' ) );
+ const second = renderHook( () => useView( 'independent-b' ) );
+
+ act( () => first.result.current[ 1 ]( 'mobile' ) );
+
+ expect( first.result.current[ 0 ] ).toBe( 'mobile' );
+ expect( second.result.current[ 0 ] ).toBe( 'desktop' );
+ } );
+
+ it( 'unsubscribing one instance does not break the others', () => {
+ const a = renderHook( () => useView( 'cleanup' ) );
+ const b = renderHook( () => useView( 'cleanup' ) );
+
+ a.unmount();
+ act( () => b.result.current[ 1 ]( 'mobile' ) );
+
+ expect( b.result.current[ 0 ] ).toBe( 'mobile' );
+ expect( getView( 'cleanup' ) ).toBe( 'mobile' );
+ } );
+
+ it( 'initializes from the persisted view when remounted', () => {
+ // View is ephemeral (not a block attribute) but lives in module memory,
+ // so a remounted instance should pick up the last-set value.
+ const first = renderHook( () => useView( 'persist' ) );
+ act( () => first.result.current[ 1 ]( 'mobile' ) );
+ first.unmount();
+
+ const second = renderHook( () => useView( 'persist' ) );
+ expect( second.result.current[ 0 ] ).toBe( 'mobile' );
+ } );
+} );
diff --git a/plugins/newspack-plugin/src/blocks/responsive-container/view-toggle.js b/plugins/newspack-plugin/src/blocks/responsive-container/view-toggle.js
new file mode 100644
index 0000000000..6689b4289d
--- /dev/null
+++ b/plugins/newspack-plugin/src/blocks/responsive-container/view-toggle.js
@@ -0,0 +1,41 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { BlockControls } from '@wordpress/block-editor';
+import { ToolbarGroup, ToolbarButton } from '@wordpress/components';
+
+/**
+ * Toolbar toggle for switching the edited view (desktop / mobile).
+ *
+ * Rendered in the BlockControls of both the container and its breakpoints so the
+ * active view can be switched from whichever block is selected — mirroring how
+ * the Overlay Menu surfaces its toggle on more than one related block, and
+ * avoiding the need to climb back up to the container to switch views.
+ *
+ * @param {Object} props Component props.
+ * @param {string} props.value Current view ( 'desktop' | 'mobile' ).
+ * @param {Function} props.onChange Called with the chosen view.
+ *
+ * @return {JSX.Element} The toolbar control.
+ */
+export default function ViewToggle( { value, onChange } ) {
+ return (
+
+
+ onChange( 'mobile' ) }
+ />
+ onChange( 'desktop' ) }
+ />
+
+
+ );
+}
diff --git a/plugins/newspack-plugin/tests/unit-tests/class-test-responsive-container-block.php b/plugins/newspack-plugin/tests/unit-tests/class-test-responsive-container-block.php
new file mode 100644
index 0000000000..322439d4ed
--- /dev/null
+++ b/plugins/newspack-plugin/tests/unit-tests/class-test-responsive-container-block.php
@@ -0,0 +1,125 @@
+theme_json_filter ) {
+ remove_filter( 'wp_theme_json_data_theme', $this->theme_json_filter );
+ $this->theme_json_filter = null;
+ }
+ wp_clean_theme_json_cache();
+ parent::tear_down();
+ }
+
+ /**
+ * Inject a custom theme.json setting for the breakpoint.
+ *
+ * @param mixed $value Value to set for settings.custom.newspackResponsiveBreakpoint.
+ */
+ private function set_theme_json_breakpoint( $value ): void {
+ $this->theme_json_filter = function ( $theme_json ) use ( $value ) {
+ return $theme_json->update_with(
+ [
+ 'version' => 2,
+ 'settings' => [ 'custom' => [ 'newspackResponsiveBreakpoint' => $value ] ],
+ ]
+ );
+ };
+ add_filter( 'wp_theme_json_data_theme', $this->theme_json_filter );
+ wp_clean_theme_json_cache();
+ }
+
+ /**
+ * Default breakpoint is 782 when nothing overrides it.
+ */
+ public function test_default_breakpoint(): void {
+ $this->assertSame( 782, Responsive_Container_Block::get_breakpoint() );
+ }
+
+ /**
+ * The filter overrides the default.
+ */
+ public function test_filter_overrides_breakpoint(): void {
+ add_filter( 'newspack_responsive_container_breakpoint', fn() => 1024 );
+ $this->assertSame( 1024, Responsive_Container_Block::get_breakpoint() );
+ }
+
+ /**
+ * A numeric theme.json custom setting is used.
+ */
+ public function test_theme_json_custom_breakpoint(): void {
+ $this->set_theme_json_breakpoint( 900 );
+ $this->assertSame( 900, Responsive_Container_Block::get_breakpoint() );
+ }
+
+ /**
+ * A non-numeric theme.json custom setting falls back to the default.
+ */
+ public function test_non_numeric_custom_falls_back_to_default(): void {
+ $this->set_theme_json_breakpoint( 'nope' );
+ $this->assertSame( 782, Responsive_Container_Block::get_breakpoint() );
+ }
+
+ /**
+ * The filter beats the theme.json custom setting.
+ */
+ public function test_filter_beats_theme_json(): void {
+ $this->set_theme_json_breakpoint( 900 );
+ add_filter( 'newspack_responsive_container_breakpoint', fn() => 600 );
+ $this->assertSame( 600, Responsive_Container_Block::get_breakpoint() );
+ }
+
+ /**
+ * A non-positive breakpoint (from filter or theme.json) falls back to the default.
+ */
+ public function test_non_positive_breakpoint_falls_back_to_default(): void {
+ add_filter( 'newspack_responsive_container_breakpoint', fn() => 0 );
+ $this->assertSame( 782, Responsive_Container_Block::get_breakpoint() );
+ }
+
+ /**
+ * The visibility CSS uses the resolved breakpoint and the breakpoint modifier classes.
+ */
+ public function test_visibility_css_uses_breakpoint(): void {
+ add_filter( 'newspack_responsive_container_breakpoint', fn() => 800 );
+ $css = Responsive_Container_Block::get_visibility_css();
+ $this->assertStringContainsString( '(max-width:799px)', $css );
+ $this->assertStringContainsString( '(min-width:800px)', $css );
+ $this->assertStringContainsString( 'newspack-responsive-container-breakpoint--desktop', $css );
+ $this->assertStringContainsString( 'newspack-responsive-container-breakpoint--mobile', $css );
+ }
+}