diff --git a/packages/editor/CHANGELOG.md b/packages/editor/CHANGELOG.md
index f0a8d1d2fb4d0d..20562849ebba68 100644
--- a/packages/editor/CHANGELOG.md
+++ b/packages/editor/CHANGELOG.md
@@ -9,6 +9,7 @@
### Enhancements
- Migrate the classic-theme template selector to `@wordpress/ui` `SelectControl` ([#84345](https://github.com/WordPress/gutenberg/pull/84345)).
+- Show viewport dimensions and responsive editing status in the editor footer, and clarify when viewport-specific styling is available ([#84146](https://github.com/WordPress/gutenberg/pull/84146)).
### Bug Fixes
diff --git a/packages/editor/src/components/commands/index.js b/packages/editor/src/components/commands/index.js
index 2652d7048c008f..dd29060c1c33c7 100644
--- a/packages/editor/src/components/commands/index.js
+++ b/packages/editor/src/components/commands/index.js
@@ -264,16 +264,16 @@ const getEditorCommandLoader = () =>
commands.push( {
name: 'core/toggle-breadcrumbs',
label: showBlockBreadcrumbs
- ? __( 'Hide block breadcrumbs' )
- : __( 'Show block breadcrumbs' ),
+ ? __( 'Hide editor footer' )
+ : __( 'Show editor footer' ),
category: 'command',
callback: ( { close } ) => {
toggle( 'core', 'showBlockBreadcrumbs' );
close();
createInfoNotice(
showBlockBreadcrumbs
- ? __( 'Breadcrumbs hidden.' )
- : __( 'Breadcrumbs visible.' ),
+ ? __( 'Editor footer hidden.' )
+ : __( 'Editor footer visible.' ),
{
id: 'core/editor/toggle-breadcrumbs/notice',
type: 'snackbar',
diff --git a/packages/editor/src/components/editor-interface/index.jsx b/packages/editor/src/components/editor-interface/index.jsx
index 5c34fdb69981fd..ece2655182873f 100644
--- a/packages/editor/src/components/editor-interface/index.jsx
+++ b/packages/editor/src/components/editor-interface/index.jsx
@@ -14,6 +14,7 @@ import { useState, useCallback, useLayoutEffect } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { InlineNotices } from '@wordpress/notices';
import { ThemeProvider } from '@wordpress/theme';
+import { Stack } from '@wordpress/ui';
import { store as editorStore } from '../../store';
import { unlock } from '../../lock-unlock';
import TemplateValidationNotice from '../template-validation-notice';
@@ -31,6 +32,7 @@ import SavePublishPanels from '../save-publish-panels';
import TextEditor from '../text-editor';
import VisualEditor from '../visual-editor';
import StylesCanvas from '../styles-canvas';
+import ViewportStatus from '../viewport-status';
const { blockToolbarShowPlaceholderKey } = unlock( blockEditorPrivateApis );
@@ -85,6 +87,7 @@ export default function EditorInterface( {
isDistractionFree,
isPreviewMode,
showBlockBreadcrumbs,
+ hasCanvasWidth,
postTypeLabel,
stylesPath,
showStylebook,
@@ -103,6 +106,7 @@ export default function EditorInterface( {
getShowStylebook,
isRevisionsMode: _isRevisionsMode,
isShowingRevisionDiff,
+ getCanvasWidth,
} = unlock( select( editorStore ) );
const editorSettings = getEditorSettings();
@@ -123,6 +127,7 @@ export default function EditorInterface( {
isDistractionFree: get( 'core', 'distractionFree' ),
isPreviewMode: editorSettings.isPreviewMode,
showBlockBreadcrumbs: get( 'core', 'showBlockBreadcrumbs' ),
+ hasCanvasWidth: getCanvasWidth() !== undefined,
postTypeLabel: getPostTypeLabel(),
stylesPath: getStylesPath(),
showStylebook: getShowStylebook(),
@@ -283,15 +288,23 @@ export default function EditorInterface( {
! isDistractionFree &&
isLargeViewport &&
showBlockBreadcrumbs &&
+ shouldShowBlockEditor &&
mode === 'visual' && (
-
+
+
+ { hasCanvasWidth && }
+
)
}
actions={
diff --git a/packages/editor/src/components/editor-interface/style.scss b/packages/editor/src/components/editor-interface/style.scss
index 196c3b27e4c466..b2186873598cc8 100644
--- a/packages/editor/src/components/editor-interface/style.scss
+++ b/packages/editor/src/components/editor-interface/style.scss
@@ -4,6 +4,13 @@
flex: 1 1 auto;
height: var(--wpds-dimension-size-md);
padding: 0 ($grid-unit-15 + 6px);
+ min-width: 0;
+}
+
+.editor-editor-interface__footer {
+ flex: 1;
+ min-width: 0;
+ min-height: var(--wpds-dimension-size-md);
}
.editor-editor-interface .entities-saved-states__panel-header {
diff --git a/packages/editor/src/components/preferences-modal/index.jsx b/packages/editor/src/components/preferences-modal/index.jsx
index 835deb61b06940..0db6de51cc04d6 100644
--- a/packages/editor/src/components/preferences-modal/index.jsx
+++ b/packages/editor/src/components/preferences-modal/index.jsx
@@ -47,7 +47,7 @@ export default function EditorPreferencesModal( { extraSections = {} } ) {
function PreferencesModalContents( { extraSections = {} } ) {
const isLargeViewport = useViewportMatch( 'medium' );
- const { showBlockBreadcrumbsOption, showCollaborationOptions } = useSelect(
+ const { showFooterOptions, showCollaborationOptions } = useSelect(
( select ) => {
const { getEditorSettings, isCollaborationEnabledForCurrentPost } =
unlock( select( editorStore ) );
@@ -55,7 +55,7 @@ function PreferencesModalContents( { extraSections = {} } ) {
const isRichEditingEnabled = getEditorSettings().richEditingEnabled;
const isDistractionFreeEnabled = get( 'core', 'distractionFree' );
return {
- showBlockBreadcrumbsOption:
+ showFooterOptions:
! isDistractionFreeEnabled &&
isLargeViewport &&
isRichEditingEnabled,
@@ -89,14 +89,14 @@ function PreferencesModalContents( { extraSections = {} } ) {
) }
label={ __( 'Always open List View' ) }
/>
- { showBlockBreadcrumbsOption && (
+ { showFooterOptions && (
) }
- { __(
- 'Style changes apply only to the selected viewport.'
- ) }
+ { deviceType === 'Desktop'
+ ? __(
+ 'Select Tablet or Mobile to style a specific viewport.'
+ )
+ : __(
+ 'Style changes apply only to the selected viewport.'
+ ) }
diff --git a/packages/editor/src/components/preview-dropdown/test/index.browser.test.tsx b/packages/editor/src/components/preview-dropdown/test/index.browser.test.tsx
index 8a283e34169698..71e83d6f01b3f9 100644
--- a/packages/editor/src/components/preview-dropdown/test/index.browser.test.tsx
+++ b/packages/editor/src/components/preview-dropdown/test/index.browser.test.tsx
@@ -7,6 +7,8 @@ import { useDispatch, useSelect } from '@wordpress/data';
import { lock } from '../../../lock-unlock';
import PreviewDropdown from '../';
+const setResponsiveEditing = vi.fn();
+
vi.mock( import( '@wordpress/compose' ), async ( importOriginal ) => ( {
...( await importOriginal() ),
useViewportMatch: vi.fn< typeof useViewportMatch >(),
@@ -27,10 +29,31 @@ beforeEach( () => {
lock( actions, {
setDeviceType: vi.fn(),
resetZoomLevel: vi.fn(),
+ setResponsiveEditing,
+ setStyleStateViewport: vi.fn(),
} );
vi.mocked( useDispatch ).mockReturnValue( actions );
} );
+it( 'keeps responsive styles discoverable but prevents activation on Desktop', async () => {
+ await render( );
+ await page.getByRole( 'button', { name: 'View', exact: true } ).click();
+ const option = page.getByRole( 'menuitemcheckbox', {
+ name: 'Responsive styles',
+ } );
+ await expect.element( option ).toHaveAttribute( 'aria-disabled', 'true' );
+ await expect
+ .element( option )
+ .toHaveAccessibleDescription(
+ 'Select Tablet or Mobile to style a specific viewport.'
+ );
+ await userEvent.keyboard( '{Home}{ArrowDown}{ArrowDown}{ArrowDown}' );
+ await expect.element( option ).toHaveFocus();
+ await userEvent.keyboard( '{Enter} ' );
+ expect( setResponsiveEditing ).not.toHaveBeenCalled();
+ await expect.element( option ).toHaveAttribute( 'aria-checked', 'false' );
+} );
+
function Preview( { disabled }: { disabled: boolean } ) {
return (
@@ -58,3 +81,28 @@ it( 'closes the View menu when it becomes unavailable and keeps its trigger focu
await userEvent.keyboard( '{Escape}' );
await expect.element( page.getByRole( 'menu' ) ).not.toBeInTheDocument();
} );
+
+it( 'shows the responsive editing cue only on viewport-specific breakpoints', async () => {
+ const selection = {
+ deviceType: 'Tablet',
+ hasMobileViewport: true,
+ hasTabletViewport: true,
+ isResponsiveEditing: true,
+ isResponsiveEditingEnabled: true,
+ };
+ vi.mocked( useSelect ).mockReturnValue( selection );
+ const screen = await render( );
+ const trigger = page.getByRole( 'button', { name: 'View', exact: true } );
+ await expect.element( trigger ).toHaveClass( 'is-responsive-editing' );
+
+ vi.mocked( useSelect ).mockReturnValue( {
+ ...selection,
+ deviceType: 'Desktop',
+ } );
+ await screen.rerender( );
+ await expect.element( trigger ).not.toHaveClass( 'is-responsive-editing' );
+
+ vi.mocked( useSelect ).mockReturnValue( selection );
+ await screen.rerender( );
+ await expect.element( trigger ).toHaveClass( 'is-responsive-editing' );
+} );
diff --git a/packages/editor/src/components/resizable-editor/index.jsx b/packages/editor/src/components/resizable-editor/index.jsx
index eb23f649711da7..c404a3deda6310 100644
--- a/packages/editor/src/components/resizable-editor/index.jsx
+++ b/packages/editor/src/components/resizable-editor/index.jsx
@@ -1,11 +1,11 @@
import clsx from 'clsx';
import { useDispatch } from '@wordpress/data';
-import { useRef, useCallback, useState } from '@wordpress/element';
+import { useRef, useCallback, useState, useEffect } from '@wordpress/element';
import {
ResizableBox,
__unstableMotion as motion,
} from '@wordpress/components';
-import { useReducedMotion } from '@wordpress/compose';
+import { useReducedMotion, useResizeObserver } from '@wordpress/compose';
import ResizeHandle from './resize-handle';
import { store as editorStore } from '../../store';
import { unlock } from '../../lock-unlock';
@@ -44,7 +44,16 @@ function ResizableEditor( {
} ) {
const [ isResizing, setIsResizing ] = useState( false );
const disableMotion = useReducedMotion();
- const { setCanvasWidth } = unlock( useDispatch( editorStore ) );
+ const { setCanvasWidth, setPhysicalCanvasWidth } = unlock(
+ useDispatch( editorStore )
+ );
+ const measuredRef = useResizeObserver( ( entries ) => {
+ setPhysicalCanvasWidth( entries[ 0 ].contentRect.width );
+ } );
+ useEffect(
+ () => () => setPhysicalCanvasWidth( undefined ),
+ [ setPhysicalCanvasWidth ]
+ );
const resizableRef = useRef();
const resizeWidthBy = useCallback(
@@ -94,6 +103,7 @@ function ResizableEditor( {
} ) }
ref={ ( api ) => {
resizableRef.current = api?.resizable;
+ measuredRef( api?.resizable );
} }
size={ {
width,
diff --git a/packages/editor/src/components/viewport-status/index.jsx b/packages/editor/src/components/viewport-status/index.jsx
new file mode 100644
index 00000000000000..453f4de309ff61
--- /dev/null
+++ b/packages/editor/src/components/viewport-status/index.jsx
@@ -0,0 +1,91 @@
+import { __, sprintf } from '@wordpress/i18n';
+import { useSelect } from '@wordpress/data';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { Icon, Stack } from '@wordpress/ui';
+import { store as editorStore } from '../../store';
+import { unlock } from '../../lock-unlock';
+import { DEVICE_TYPE_METADATA } from '../../utils/device-type';
+
+/** Reports the width-constrained viewport and responsive editing state in the footer. */
+export default function ViewportStatus() {
+ const {
+ deviceType,
+ canvasWidth,
+ canvasHeight,
+ physicalCanvasWidth,
+ isResponsiveEditing,
+ } = useSelect( ( select ) => {
+ const {
+ getDeviceType,
+ getCanvasWidth,
+ getCanvasHeight,
+ getPhysicalCanvasWidth,
+ } = unlock( select( editorStore ) );
+ return {
+ deviceType: getDeviceType(),
+ canvasWidth: getCanvasWidth(),
+ canvasHeight: getCanvasHeight(),
+ physicalCanvasWidth: getPhysicalCanvasWidth(),
+ isResponsiveEditing: unlock(
+ select( blockEditorStore )
+ ).isResponsiveEditing(),
+ };
+ }, [] );
+
+ if ( canvasWidth === undefined ) {
+ return null;
+ }
+
+ const { label, icon } = DEVICE_TYPE_METADATA[ deviceType ];
+ // Allow a pixel of rounding difference between layout and the requested size.
+ const isConstrained =
+ physicalCanvasWidth !== undefined &&
+ physicalCanvasWidth < canvasWidth - 1;
+ let dimensions;
+ if ( ! isConstrained ) {
+ dimensions =
+ typeof canvasHeight === 'number'
+ ? sprintf(
+ // translators: 1: Canvas width in pixels. 2: Canvas height in pixels.
+ __( '%1$d × %2$d' ),
+ Math.round( canvasWidth ),
+ Math.round( canvasHeight )
+ )
+ : sprintf(
+ // translators: %d: Canvas width in pixels. "auto" means the height is unconstrained.
+ __( '%d × auto' ),
+ Math.round( canvasWidth )
+ );
+ }
+
+ return (
+
+ { isResponsiveEditing && deviceType !== 'Desktop' && (
+
+ { __( 'Responsive styles' ) }
+
+ ) }
+
+
+ { dimensions
+ ? sprintf(
+ // translators: 1: Viewport type, e.g. "Tablet". 2: Canvas dimensions, e.g. "800 × 400".
+ __( '%1$s (%2$s)' ),
+ label,
+ dimensions
+ )
+ : label }
+
+
+ );
+}
diff --git a/packages/editor/src/components/viewport-status/style.scss b/packages/editor/src/components/viewport-status/style.scss
new file mode 100644
index 00000000000000..ad3582ad464085
--- /dev/null
+++ b/packages/editor/src/components/viewport-status/style.scss
@@ -0,0 +1,20 @@
+@use "@wordpress/base-styles/variables" as *;
+
+.editor-viewport-status {
+ flex-shrink: 0;
+ white-space: nowrap;
+ margin-left: auto;
+ padding: 0 ( $grid-unit-15 + 6px );
+ color: var(--wpds-color-foreground-content-neutral);
+ font-size: $default-font-size;
+
+ .editor-viewport-status__responsive-styles {
+ // Match the responsive styles cue in the View menu.
+ color: var(--wp-block-synced-color);
+ }
+
+ .editor-viewport-status__responsive-styles + .editor-viewport-status__device {
+ border-left: $border-width solid var(--wpds-color-stroke-surface-neutral);
+ padding-left: $grid-unit-15;
+ }
+}
diff --git a/packages/editor/src/components/viewport-status/test/index.browser.test.jsx b/packages/editor/src/components/viewport-status/test/index.browser.test.jsx
new file mode 100644
index 00000000000000..cb5a59de47236d
--- /dev/null
+++ b/packages/editor/src/components/viewport-status/test/index.browser.test.jsx
@@ -0,0 +1,107 @@
+import { beforeEach, expect, it } from 'vitest';
+import { render } from 'vitest-browser-react';
+import { page } from 'vitest/browser';
+import { createRegistry, RegistryProvider } from '@wordpress/data';
+import { store as blockEditorStore } from '@wordpress/block-editor';
+import { store as coreStore } from '@wordpress/core-data';
+import { store as editorStore } from '../../../store';
+import { unlock } from '../../../lock-unlock';
+import ViewportStatus from '../';
+import '../style.scss';
+
+/** @type {ReturnType} */
+let registry;
+
+beforeEach( () => {
+ registry = createRegistry();
+ registry.register( coreStore );
+ registry.register( blockEditorStore );
+ registry.register( editorStore );
+ unlock( registry.dispatch( blockEditorStore ) ).resetZoomLevel();
+ unlock( registry.dispatch( editorStore ) ).setCanvasWidth( 781 );
+ unlock( registry.dispatch( editorStore ) ).setCanvasHeight( 640 );
+} );
+
+it( 'updates viewport dimensions when the canvas changes size', async () => {
+ await render(
+
+
+
+ );
+ await expect
+ .element( page.getByText( 'Tablet (781 × 640)' ) )
+ .toBeVisible();
+
+ const actions = unlock( registry.dispatch( editorStore ) );
+ actions.setCanvasWidth( 400.4 );
+ actions.setCanvasHeight( 600.6 );
+ await expect
+ .element( page.getByText( 'Mobile (400 × 601)' ) )
+ .toBeVisible();
+
+ actions.setCanvasHeight( undefined );
+ await expect
+ .element( page.getByText( 'Mobile (400 × auto)' ) )
+ .toBeVisible();
+} );
+
+it( 'shows responsive editing status when enabled and removes it when disabled', async () => {
+ await render(
+
+
+
+ );
+ await expect
+ .element( page.getByText( 'Responsive styles', { exact: true } ) )
+ .not.toBeInTheDocument();
+
+ const actions = unlock( registry.dispatch( blockEditorStore ) );
+ actions.setResponsiveEditing( true );
+ await expect
+ .element( page.getByText( 'Responsive styles', { exact: true } ) )
+ .toBeVisible();
+ const editorActions = unlock( registry.dispatch( editorStore ) );
+ editorActions.setCanvasWidth( 1200 );
+ await expect
+ .element( page.getByText( 'Responsive styles', { exact: true } ) )
+ .not.toBeInTheDocument();
+ await expect
+ .element( page.getByText( 'Desktop (1200 × 640)' ) )
+ .toBeVisible();
+ editorActions.setCanvasWidth( 781 );
+ await expect
+ .element( page.getByText( 'Responsive styles', { exact: true } ) )
+ .toBeVisible();
+ actions.setResponsiveEditing( false );
+ await expect
+ .element( page.getByText( 'Responsive styles', { exact: true } ) )
+ .not.toBeInTheDocument();
+ await expect
+ .element( page.getByText( 'Tablet (781 × 640)' ) )
+ .toBeVisible();
+} );
+
+it( 'shows Desktop dimensions for a resized canvas and hides status at full width and while zoomed out', async () => {
+ await render(
+
+
+
+ );
+ const status = page.getByText( 'Tablet (781 × 640)' );
+ await expect.element( status ).toBeVisible();
+
+ const actions = unlock( registry.dispatch( editorStore ) );
+ actions.setCanvasWidth( 1200 );
+ await expect.element( status ).not.toBeInTheDocument();
+ const desktopStatus = page.getByText( 'Desktop (1200 × 640)' );
+ await expect.element( desktopStatus ).toBeVisible();
+ actions.setCanvasWidth( undefined );
+ await expect.element( desktopStatus ).not.toBeInTheDocument();
+ actions.setCanvasWidth( 781 );
+ await expect.element( status ).toBeVisible();
+
+ unlock( registry.dispatch( blockEditorStore ) ).setZoomLevel( 50 );
+ await expect.element( status ).not.toBeInTheDocument();
+ unlock( registry.dispatch( blockEditorStore ) ).resetZoomLevel();
+ await expect.element( status ).toBeVisible();
+} );
diff --git a/packages/editor/src/components/visual-editor/index.jsx b/packages/editor/src/components/visual-editor/index.jsx
index 307449e20c964c..ed4924bfb2210c 100644
--- a/packages/editor/src/components/visual-editor/index.jsx
+++ b/packages/editor/src/components/visual-editor/index.jsx
@@ -8,7 +8,7 @@ import {
privateApis as blockEditorPrivateApis,
} from '@wordpress/block-editor';
import { useEffect, useRef, useMemo, useState } from '@wordpress/element';
-import { useSelect } from '@wordpress/data';
+import { useSelect, useDispatch } from '@wordpress/data';
import { parse } from '@wordpress/blocks';
import { store as coreStore } from '@wordpress/core-data';
import { store as preferencesStore } from '@wordpress/preferences';
@@ -404,6 +404,15 @@ function VisualEditor( {
? getCanvasHeight( canvasWidth, containerSize )
: '100%';
+ const { setCanvasHeight } = unlock( useDispatch( editorStore ) );
+ useEffect( () => {
+ setCanvasHeight(
+ typeof canvasHeight === 'number' ? canvasHeight : undefined
+ );
+ }, [ canvasHeight, setCanvasHeight ] );
+ // The store must not retain dimensions for a canvas that is no longer mounted.
+ useEffect( () => () => setCanvasHeight( undefined ), [ setCanvasHeight ] );
+
// A resizable canvas (device preview) or a scaled one (zoom out) has no
// margin.
const hasCanvasMargin = ! isPreview && ! isZoomedOut && ! enableResizing;
diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js
index df9e906bdbbae9..a8e870bbf36d04 100644
--- a/packages/editor/src/store/private-actions.js
+++ b/packages/editor/src/store/private-actions.js
@@ -493,6 +493,32 @@ export function setCanvasWidth( width ) {
};
}
+/**
+ * Set the calculated canvas height for the footer viewport status.
+ *
+ * @param {number|undefined} height Canvas height in pixels, or undefined when unconstrained.
+ * @return {Object} Action object.
+ */
+export function setCanvasHeight( height ) {
+ return {
+ type: 'SET_CANVAS_HEIGHT',
+ height,
+ };
+}
+
+/**
+ * Set the measured canvas width without changing the requested width.
+ *
+ * @param {number|undefined} width Rendered canvas width in pixels.
+ * @return {Object} Action object.
+ */
+export function setPhysicalCanvasWidth( width ) {
+ return {
+ type: 'SET_PHYSICAL_CANVAS_WIDTH',
+ width,
+ };
+}
+
/**
* Set the current revision ID for revisions preview mode.
* Pass a revision ID to enter revisions mode, or null to exit.
diff --git a/packages/editor/src/store/private-selectors.js b/packages/editor/src/store/private-selectors.js
index 964f8d9d96ea0c..8e837a73d80d4a 100644
--- a/packages/editor/src/store/private-selectors.js
+++ b/packages/editor/src/store/private-selectors.js
@@ -322,6 +322,31 @@ export const getCanvasWidth = createRegistrySelector(
}
);
+/**
+ * Get the calculated canvas height published by the visual editor.
+ *
+ * @param {Object} state Global application state.
+ * @return {number|undefined} Canvas height in pixels, or undefined when unconstrained or zoomed out.
+ */
+export const getCanvasHeight = createRegistrySelector(
+ ( select ) => ( state ) => {
+ if ( unlock( select( blockEditorStore ) ).isZoomOut() ) {
+ return undefined;
+ }
+ return state.canvasHeight;
+ }
+);
+
+/**
+ * Get the measured canvas width, independently of the requested width.
+ *
+ * @param {Object} state Global application state.
+ * @return {number|undefined} Rendered canvas width in pixels.
+ */
+export function getPhysicalCanvasWidth( state ) {
+ return state.physicalCanvasWidth;
+}
+
/**
* Returns the current revisions page number.
*
diff --git a/packages/editor/src/store/reducer.js b/packages/editor/src/store/reducer.js
index 46aa0c361b73a7..1deb366c445aa0 100644
--- a/packages/editor/src/store/reducer.js
+++ b/packages/editor/src/store/reducer.js
@@ -284,6 +284,35 @@ export function canvasWidth( state = undefined, action ) {
return state;
}
+/**
+ * Reducer for the calculated canvas height.
+ *
+ * @param {number|undefined} state Current state.
+ * @param {Object} action Dispatched action.
+ * @return {number|undefined} Updated state.
+ */
+export function canvasHeight( state = undefined, action ) {
+ switch ( action.type ) {
+ case 'SET_CANVAS_HEIGHT':
+ return action.height;
+ }
+ return state;
+}
+
+/**
+ * Reducer for the measured canvas width.
+ *
+ * @param {number|undefined} state Current state.
+ * @param {Object} action Dispatched action.
+ * @return {number|undefined} Updated state.
+ */
+export function physicalCanvasWidth( state = undefined, action ) {
+ if ( action.type === 'SET_PHYSICAL_CANVAS_WIDTH' ) {
+ return action.width;
+ }
+ return state;
+}
+
/**
* Reducer storing the list of all programmatically removed panels.
*
@@ -492,6 +521,8 @@ export default combineReducers( {
postAutosavingLock,
renderingMode,
canvasWidth,
+ canvasHeight,
+ physicalCanvasWidth,
removedPanels,
blockInserterPanel,
inserterSidebarToggleRef,
diff --git a/packages/editor/src/style.scss b/packages/editor/src/style.scss
index a5bc385f59d88b..afc44834d063f9 100644
--- a/packages/editor/src/style.scss
+++ b/packages/editor/src/style.scss
@@ -72,5 +72,6 @@
@use "./components/text-editor/style.scss" as *;
@use "./components/upload-progress-snackbar/style.scss" as *;
@use "./components/visual-editor/style.scss" as *;
+@use "./components/viewport-status/style.scss" as *;
@use "./dataviews/fields/content-preview/style.scss" as *;
@use "./hooks/push-changes-to-global-styles/style.scss" as *;
diff --git a/packages/editor/src/utils/device-type.js b/packages/editor/src/utils/device-type.js
index 11d9683ba4b1c4..055d86ded20014 100644
--- a/packages/editor/src/utils/device-type.js
+++ b/packages/editor/src/utils/device-type.js
@@ -1,4 +1,6 @@
import { privateApis as globalStylesEnginePrivateApis } from '@wordpress/global-styles-engine';
+import { __ } from '@wordpress/i18n';
+import { desktop, tablet, mobile } from '@wordpress/icons';
import { unlock } from '../lock-unlock';
const { getViewportBreakpoints, getViewportBreakpointValueInPixels } = unlock(
@@ -15,6 +17,13 @@ const TABLET_DEVICE_TYPE = 'Tablet';
const MOBILE_DEVICE_TYPE = 'Mobile';
const DEVICE_PREVIEW_WIDTH_OFFSET = 1;
+/** Labels and icons shared by device preview controls and viewport status. */
+export const DEVICE_TYPE_METADATA = {
+ Desktop: { label: __( 'Desktop' ), icon: desktop },
+ Tablet: { label: __( 'Tablet' ), icon: tablet },
+ Mobile: { label: __( 'Mobile' ), icon: mobile },
+};
+
/**
* Maps a device preview type to its corresponding viewport style state. Used
* when Responsive editing is enabled so the device preview drives which
diff --git a/test/e2e/specs/editor/blocks/cover.spec.js b/test/e2e/specs/editor/blocks/cover.spec.js
index 3a876c618fc72a..d5062e761e6974 100644
--- a/test/e2e/specs/editor/blocks/cover.spec.js
+++ b/test/e2e/specs/editor/blocks/cover.spec.js
@@ -595,10 +595,10 @@ test.describe( 'Cover', () => {
await expect( overlayControl ).toBeVisible();
await page.getByRole( 'button', { name: 'View', exact: true } ).click();
+ await page.getByRole( 'menuitemradio', { name: 'Tablet' } ).click();
await page
.getByRole( 'menuitemcheckbox', { name: 'Responsive styles' } )
.click();
- await page.getByRole( 'menuitemradio', { name: 'Tablet' } ).click();
await page.keyboard.press( 'Escape' );
await expect( overlayControl ).toBeHidden();
diff --git a/test/e2e/specs/editor/various/block-style-state-colors.spec.js b/test/e2e/specs/editor/various/block-style-state-colors.spec.js
index 9a4a55530355e1..fc0157f818dbcf 100644
--- a/test/e2e/specs/editor/various/block-style-state-colors.spec.js
+++ b/test/e2e/specs/editor/various/block-style-state-colors.spec.js
@@ -17,10 +17,10 @@ test.describe( 'Relocated color controls with block style states', () => {
} );
await page.getByRole( 'button', { name: 'View', exact: true } ).click();
+ await page.getByRole( 'menuitemradio', { name: 'Mobile' } ).click();
await page
.getByRole( 'menuitemcheckbox', { name: 'Responsive styles' } )
.click();
- await page.getByRole( 'menuitemradio', { name: 'Mobile' } ).click();
await page.keyboard.press( 'Escape' );
// Text color now lives in the Typography panel.
@@ -75,10 +75,10 @@ test.describe( 'Relocated color controls with block style states', () => {
// Switch the block's editing context to the Mobile viewport state.
await page.getByRole( 'button', { name: 'View', exact: true } ).click();
+ await page.getByRole( 'menuitemradio', { name: 'Mobile' } ).click();
await page
.getByRole( 'menuitemcheckbox', { name: 'Responsive styles' } )
.click();
- await page.getByRole( 'menuitemradio', { name: 'Mobile' } ).click();
await page.keyboard.press( 'Escape' );
const elementsPanel = settings
@@ -199,10 +199,10 @@ test.describe( 'Relocated color controls with block style states', () => {
} );
await page.getByRole( 'button', { name: 'View', exact: true } ).click();
+ await page.getByRole( 'menuitemradio', { name: 'Mobile' } ).click();
await page
.getByRole( 'menuitemcheckbox', { name: 'Responsive styles' } )
.click();
- await page.getByRole( 'menuitemradio', { name: 'Mobile' } ).click();
await page.keyboard.press( 'Escape' );
// Reset all of the Background panel while the Mobile state is selected.
diff --git a/test/e2e/specs/editor/various/contrast-checker.spec.js b/test/e2e/specs/editor/various/contrast-checker.spec.js
index 86ff20ec08a53d..dbe8df0ae70258 100644
--- a/test/e2e/specs/editor/various/contrast-checker.spec.js
+++ b/test/e2e/specs/editor/various/contrast-checker.spec.js
@@ -107,10 +107,10 @@ test.describe( 'Contrast Checker', () => {
} );
await page.getByRole( 'button', { name: 'View', exact: true } ).click();
+ await page.getByRole( 'menuitemradio', { name: 'Mobile' } ).click();
await page
.getByRole( 'menuitemcheckbox', { name: 'Responsive styles' } )
.click();
- await page.getByRole( 'menuitemradio', { name: 'Mobile' } ).click();
await page.keyboard.press( 'Escape' );
const editorSettings = page.getByRole( 'region', {
diff --git a/test/e2e/specs/editor/various/preferences.spec.js b/test/e2e/specs/editor/various/preferences.spec.js
index 5598a2d9188e06..311ede084aee7a 100644
--- a/test/e2e/specs/editor/various/preferences.spec.js
+++ b/test/e2e/specs/editor/various/preferences.spec.js
@@ -6,11 +6,47 @@ test.describe( 'Preferences', () => {
} );
test.afterEach( async ( { requestUtils } ) => {
- // Reset preferences via REST so the dismissed-sidebar preference
- // doesn't leak into other tests that expect it open by default.
+ // Reset preferences via REST so interface visibility preferences
+ // don't leak into other tests that expect the defaults.
await requestUtils.resetPreferences();
} );
+ test( 'shows and hides the editor footer from preferences', async ( {
+ page,
+ } ) => {
+ await page.getByRole( 'button', { name: 'View', exact: true } ).click();
+ await page.getByRole( 'menuitemradio', { name: 'Tablet' } ).click();
+ await page.keyboard.press( 'Escape' );
+
+ const footer = page.getByRole( 'region', { name: 'Editor footer' } );
+ const breadcrumbs = footer.getByRole( 'list', {
+ name: 'Block breadcrumb',
+ } );
+ const viewportStatus = footer.getByText( /Tablet \(\d+ × \d+\)/ );
+ await expect( breadcrumbs ).toBeVisible();
+ await expect( viewportStatus ).toBeVisible();
+
+ for ( const showFooter of [ false, true ] ) {
+ await page
+ .getByRole( 'region', { name: 'Editor top bar' } )
+ .getByRole( 'button', { name: 'Options' } )
+ .click();
+ await page.getByRole( 'menuitem', { name: 'Preferences' } ).click();
+ const preferences = page.getByRole( 'dialog', {
+ name: 'Preferences',
+ } );
+ await preferences
+ .getByRole( 'checkbox', { name: 'Show editor footer' } )
+ .setChecked( showFooter );
+ await preferences.getByRole( 'button', { name: 'Close' } ).click();
+
+ await expect( breadcrumbs ).toBeVisible( { visible: showFooter } );
+ await expect( viewportStatus ).toBeVisible( {
+ visible: showFooter,
+ } );
+ }
+ } );
+
test( 'remembers sidebar dismissal between sessions', async ( {
editor,
page,
diff --git a/test/e2e/specs/editor/various/preview.spec.js b/test/e2e/specs/editor/various/preview.spec.js
index d120ce1a2d958b..70a5ac0e838ef7 100644
--- a/test/e2e/specs/editor/various/preview.spec.js
+++ b/test/e2e/specs/editor/various/preview.spec.js
@@ -18,6 +18,72 @@ test.describe( 'Preview', () => {
await admin.createNewPost();
} );
+ test( 'hides viewport dimensions when space is limited and restores them when space returns', async ( {
+ page,
+ } ) => {
+ await page.setViewportSize( { width: 1600, height: 900 } );
+ await page.getByRole( 'button', { name: 'View', exact: true } ).click();
+ await page.getByRole( 'menuitemradio', { name: 'Tablet' } ).click();
+ await page.keyboard.press( 'Escape' );
+ const footer = page.getByRole( 'region', { name: 'Editor footer' } );
+ await expect( footer ).toContainText( /Tablet \(781 × \d+\)/ );
+ const canvas = page.locator( 'iframe[name="editor-canvas"]' );
+ await expect
+ .poll( async () =>
+ Math.round( ( await canvas.boundingBox() ).width )
+ )
+ .toBe( 781 );
+
+ await page.setViewportSize( { width: 900, height: 900 } );
+ await expect(
+ footer.getByText( 'Tablet', { exact: true } )
+ ).toBeVisible();
+ await expect( footer ).not.toContainText( /Tablet \(781 × \d+\)/ );
+ await expect
+ .poll( async () => ( await canvas.boundingBox() ).width )
+ .toBeLessThan( 780 );
+
+ await page.setViewportSize( { width: 1600, height: 900 } );
+ await expect( footer ).toContainText( /Tablet \(781 × \d+\)/ );
+ await expect
+ .poll( async () =>
+ Math.round( ( await canvas.boundingBox() ).width )
+ )
+ .toBe( 781 );
+ } );
+
+ test( 'preserves responsive editing when switching through the default viewport', async ( {
+ page,
+ } ) => {
+ const view = page.getByRole( 'button', { name: 'View', exact: true } );
+ await view.click();
+ const responsiveStyles = page.getByRole( 'menuitemcheckbox', {
+ name: 'Responsive styles',
+ } );
+ await expect( responsiveStyles ).toBeDisabled();
+ await page.getByRole( 'menuitemradio', { name: 'Tablet' } ).click();
+ await expect( responsiveStyles ).toBeEnabled();
+ await responsiveStyles.click();
+ await expect( responsiveStyles ).toBeChecked();
+ await expect( view ).toHaveClass( /is-responsive-editing/ );
+
+ await page.getByRole( 'menuitemradio', { name: 'Desktop' } ).click();
+ await expect( responsiveStyles ).toBeDisabled();
+ await expect( responsiveStyles ).toBeChecked();
+ await expect( view ).not.toHaveClass( /is-responsive-editing/ );
+
+ await page.getByRole( 'menuitemradio', { name: 'Tablet' } ).click();
+ await expect( responsiveStyles ).toBeEnabled();
+ await expect( responsiveStyles ).toBeChecked();
+ await expect( view ).toHaveClass( /is-responsive-editing/ );
+ await page.keyboard.press( 'Escape' );
+ await expect(
+ page
+ .getByRole( 'region', { name: 'Editor footer' } )
+ .getByText( 'Responsive styles', { exact: true } )
+ ).toBeVisible();
+ } );
+
test( 'should open a preview window for a new post', async ( {
editor,
page,
diff --git a/tools/eslint/suppressions.json b/tools/eslint/suppressions.json
index 6493987cd5aa2c..59c6ede2e3820b 100644
--- a/tools/eslint/suppressions.json
+++ b/tools/eslint/suppressions.json
@@ -7178,6 +7178,16 @@
"count": 1
}
},
+ "packages/editor/src/components/viewport-status/index.jsx": {
+ "react/jsx-filename-extension": {
+ "count": 1
+ }
+ },
+ "packages/editor/src/components/viewport-status/test/index.browser.test.jsx": {
+ "react/jsx-filename-extension": {
+ "count": 1
+ }
+ },
"packages/editor/src/components/visual-editor/edit-template-blocks-notification.jsx": {
"react/jsx-filename-extension": {
"count": 1