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