diff --git a/src/wp-admin/admin-header.php b/src/wp-admin/admin-header.php index 055d801533701..36962c3e44326 100644 --- a/src/wp-admin/admin-header.php +++ b/src/wp-admin/admin-header.php @@ -103,6 +103,12 @@ wp_enqueue_script( 'utils' ); wp_enqueue_script( 'svg-painter' ); +/* + * View transitions animate the admin menu, so they are only enqueued on screens that print it. + * Iframe screens such as Thickbox modals do not load this header and so do not opt in. + */ +wp_enqueue_view_transitions_admin_css(); + $admin_body_class = preg_replace( '/[^a-z0-9_-]+/i', '-', $hook_suffix ); // Print the global admin inline scripts through the script tag API so the diff --git a/src/wp-admin/css/view-transitions.css b/src/wp-admin/css/view-transitions.css index 538a90b502f9e..78adf39075692 100644 --- a/src/wp-admin/css/view-transitions.css +++ b/src/wp-admin/css/view-transitions.css @@ -3,7 +3,74 @@ navigation: auto; } + /* + * Give each top-level menu item its own view transition name — its id, + * such as "menu-posts" — so each one is captured as an independent group + * and animates from its old position and size to its new ones across a + * navigation. This produces the accordion effect: the previously open + * section collapses as the newly current one expands, instead of the + * whole menu simply cross-fading. attr() falls back to "none" for any + * item whose id is missing or is not a valid custom identifier. The + * shared wp-menu-top class lets the image-pair rule below address every + * menu-top group at once. + */ #adminmenu > .menu-top { view-transition-name: attr(id type(), none); + view-transition-class: wp-menu-top; + } + + /* + * Name the collapse button too, so it slides into place with the menu + * items instead of cross-fading as part of the page root. + */ + #collapse-menu { + view-transition-name: collapse-menu; + } + + /* + * Clip each menu-top's snapshot to its animating group box. By default a + * captured snapshot keeps its full intrinsic height, so the new (expanded) + * submenu paints at full height from the first frame and overlaps the + * items below while they are still sliding down. Clipping the image pair + * to the group — whose height animates from the link row to the full + * expanded height — makes the submenu wipe open, and closed in reverse, + * in step with those items. + */ + ::view-transition-image-pair(.wp-menu-top) { + overflow: clip; + } + + /* + * Capture an open flyout submenu — one shown on hover or focus — as its own + * group, so that it fades out instead of being cut off by the clip above, + * since it extends outside its menu-top box. match-element gives each + * flyout a unique name, so two open at once cannot collide and abort the + * transition. view-transition-group nests the flyout's group inside its + * menu-top's group, so it moves along with its menu item while fading out + * instead of staying behind at its old position. Since the nested group is + * a sibling of the menu-top's image pair, it is not cut off by the clip. + * + * In the expanded menu, the current item's submenu is in the normal flow, + * so it is left to the clipped menu-top. When the menu is folded, either + * explicitly or automatically on narrower screens, every submenu is a + * flyout, including the current one. Below 783px the menu is responsive and + * its submenus are in the normal flow, so none are named there. + */ + @media screen and (min-width: 783px) { + #adminmenu > .wp-not-current-submenu.opensub > .wp-submenu, + #adminmenu > .wp-not-current-submenu > a.menu-top:focus + .wp-submenu, + .folded #adminmenu > .opensub > .wp-submenu, + .folded #adminmenu > .menu-top > a.menu-top:focus + .wp-submenu { + view-transition-name: match-element; + view-transition-group: nearest; + } + } + + @media screen and (min-width: 783px) and (max-width: 960px) { + .auto-fold #adminmenu > .opensub > .wp-submenu, + .auto-fold #adminmenu > .menu-top > a.menu-top:focus + .wp-submenu { + view-transition-name: match-element; + view-transition-group: nearest; + } } } diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index c229fbfcb85e0..7f5f9d0fb0eb8 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -634,7 +634,7 @@ add_action( 'admin_enqueue_scripts', 'wp_localize_jquery_ui_datepicker', 1000 ); add_action( 'admin_enqueue_scripts', 'wp_common_block_scripts_and_styles' ); add_action( 'admin_enqueue_scripts', 'wp_enqueue_command_palette_assets' ); -add_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ); +add_action( 'admin_head', 'wp_print_view_transitions_render_blocking_link' ); add_action( 'enqueue_block_assets', 'wp_enqueue_classic_theme_styles' ); add_action( 'enqueue_block_assets', 'wp_enqueue_registered_block_scripts_and_styles' ); add_action( 'enqueue_block_assets', 'enqueue_block_styles_assets', 30 ); diff --git a/src/wp-includes/view-transitions.php b/src/wp-includes/view-transitions.php index 2d2ad61e0b431..a6323098b1e85 100644 --- a/src/wp-includes/view-transitions.php +++ b/src/wp-includes/view-transitions.php @@ -10,7 +10,11 @@ /** * Enqueues View Transitions CSS for the admin. * + * To disable admin View Transitions, dequeue the `wp-view-transitions-admin` style at the + * {@see 'admin_enqueue_scripts'} action, rather than unhooking this function. + * * @since 7.0.0 + * @since 7.2.0 Called from wp-admin/admin-header.php instead of hooked to {@see 'admin_enqueue_scripts'}. */ function wp_enqueue_view_transitions_admin_css(): void { wp_enqueue_style( 'wp-view-transitions-admin' ); @@ -26,5 +30,41 @@ function wp_enqueue_view_transitions_admin_css(): void { function wp_get_view_transitions_admin_css(): string { $affix = SCRIPT_DEBUG ? '' : '.min'; $path = ABSPATH . "wp-admin/css/view-transitions{$affix}.css"; - return file_get_contents( $path ); + return (string) file_get_contents( $path ); +} + +/** + * Prints a render-blocking expectation so View Transitions capture a fully parsed admin menu. + * + * A cross-document view transition snapshots the incoming page at its first render, which can + * happen before the `` has finished parsing. The snapshot would then capture a partially + * built page — for example an incomplete admin menu — and the transition animates to or from + * the wrong state. + * + * The `expect` link blocks the first render until `#wpcontent` is parsed. Rendering is unblocked + * as soon as the target's start tag is parsed, not its children, so the target is the element + * right after the admin menu rather than the menu itself: once `#wpcontent` starts, the whole + * menu has been parsed. Targeting an element near the end of the body would also work, but it + * would delay the first render by the time it takes to parse all of the page content. The media + * query limits the first-render delay to when view transitions can run, matching the + * `@view-transition` rule in view-transitions.css. It does not detect whether a transition is + * actually in progress, so a reload or a direct visit is also held until `#wpcontent` is parsed, + * but since that element starts right after the admin menu, the delay is negligible. + * + * Nothing is printed unless the admin View Transitions style has already been printed, since + * there is otherwise no transition to wait for. That style is only enqueued from + * wp-admin/admin-header.php, so this also leaves out iframe screens such as Thickbox modals, + * which have no `#wpcontent`: an `expect` link whose target never appears blocks the first + * render until the whole document has been parsed. + * + * @since 7.2.0 + * + * @link https://html.spec.whatwg.org/multipage/links.html#link-type-expect + */ +function wp_print_view_transitions_render_blocking_link(): void { + if ( ! wp_style_is( 'wp-view-transitions-admin', 'done' ) ) { + return; + } + + echo '' . "\n"; } diff --git a/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php b/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php index 874bd11bb70fb..2ff70a2bfdc16 100644 --- a/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php +++ b/tests/phpunit/tests/view-transitions/wpEnqueueViewTransitionsAdminCss.php @@ -28,15 +28,6 @@ public function tear_down() { parent::tear_down(); } - /** - * Tests that the hook for enqueuing admin view transitions CSS is set up. - * - * @ticket 64470 - */ - public function test_hook() { - $this->assertSame( 10, has_action( 'admin_enqueue_scripts', 'wp_enqueue_view_transitions_admin_css' ) ); - } - /** * Tests that the admin view transitions style handle includes the inline CSS. * diff --git a/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php new file mode 100644 index 0000000000000..46118bd2f09a5 --- /dev/null +++ b/tests/phpunit/tests/view-transitions/wpPrintViewTransitionsRenderBlockingLink.php @@ -0,0 +1,91 @@ +original_wp_styles = $wp_styles; + $wp_styles = null; + } + + public function tear_down() { + global $wp_styles; + + $wp_styles = $this->original_wp_styles; + parent::tear_down(); + } + + /** + * Tests that the hook for printing the render-blocking link is set up. + * + * @ticket 65032 + */ + public function test_hook(): void { + $this->assertSame( 10, has_action( 'admin_head', 'wp_print_view_transitions_render_blocking_link' ) ); + } + + /** + * Tests that a render-blocking expect link is printed. + * + * @ticket 65032 + */ + public function test_prints_render_blocking_link(): void { + wp_enqueue_view_transitions_admin_css(); + get_echo( 'wp_print_styles', array( 'wp-view-transitions-admin' ) ); + $this->assertTrue( wp_style_is( 'wp-view-transitions-admin', 'done' ), 'Expected the style to have been printed.' ); + + $output = get_echo( 'wp_print_view_transitions_render_blocking_link' ); + + $processor = new WP_HTML_Tag_Processor( $output ); + $this->assertTrue( $processor->next_tag( 'LINK' ), 'Expected a LINK tag to be printed.' ); + $this->assertSame( 'expect', $processor->get_attribute( 'rel' ), 'Expected the LINK to have a rel of "expect".' ); + $this->assertSame( '#wpcontent', $processor->get_attribute( 'href' ), 'Expected the LINK to point at the element after the admin menu.' ); + $this->assertSame( 'render', $processor->get_attribute( 'blocking' ), 'Expected the LINK to block rendering.' ); + $this->assertSame( '(prefers-reduced-motion: no-preference)', $processor->get_attribute( 'media' ), 'Expected the LINK to be scoped to the view transitions media query.' ); + $this->assertFalse( $processor->next_tag(), 'Expected only a single tag to be printed.' ); + } + + /** + * Tests that nothing is printed when the admin View Transitions style is not enqueued. + * + * @ticket 65032 + */ + public function test_prints_nothing_when_style_not_enqueued(): void { + $this->assertFalse( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to not be enqueued.' ); + $this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) ); + } + + /** + * Tests that nothing is printed when the admin View Transitions style is enqueued but not yet printed. + * + * This happens when the style is enqueued too late for the head, so it is printed in the footer instead. + * + * @ticket 65032 + */ + public function test_prints_nothing_when_style_not_printed(): void { + wp_enqueue_view_transitions_admin_css(); + $this->assertTrue( wp_style_is( 'wp-view-transitions-admin' ), 'Expected the style to be enqueued.' ); + $this->assertFalse( wp_style_is( 'wp-view-transitions-admin', 'done' ), 'Expected the style to not be printed yet.' ); + + $this->assertSame( '', get_echo( 'wp_print_view_transitions_render_blocking_link' ) ); + } +}