d from `:root` to the BODY element. if ( get_theme_mod( 'respect_user_color_preference', false ) ) { $styles = str_replace( ':root {', 'body {', $styles ); } // Append extra rules needed for nav menus according to changes made to the document during sanitization. $styles .= ' /* Trap keyboard navigation within mobile menu when it\'s open */ @media only screen and (max-width: 481px) { .primary-navigation-open #page { visibility: hidden; } .primary-navigation-open .menu-button-container { visibility: visible; } } @media (min-width: 482px) { /* Show the sub-menu on hover of menu item */ .primary-menu-container > .menu-wrapper > .menu-item-has-children:hover > .sub-menu { display: block; } /* Hide the plus icon on hover of menu item */ .primary-menu-container > .menu-wrapper > .menu-item-has-children:hover > .sub-menu-toggle > .icon-plus { display: none; } /* Show the minus icon on hover of menu item */ .primary-menu-container > .menu-wrapper > .menu-item-has-children:hover > .sub-menu-toggle > .icon-minus { display: flex; } } '; /* * In Twenty Twenty-One, when a button is used to resize AMP elements, they can appear transparent when hovered over. * To resolve this issue, the theme's background color is used as the background color for the button * when it is in the hovered state. */ $styles .= ' button[overflow]:hover { background-color: var(--global--color-background); } '; // Ideally wp_add_inline_style() would accept a $position argument like wp_add_inline_script() does, in // which case the following could be replaced with wp_add_inline_style( $style_handle, $new_styles, 'before' ). // But this is not supported, so we have to resort to manipulating the underlying after array. if ( ! isset( $dependency->extra['after'] ) ) { $dependency->extra['after'] = []; } array_unshift( $dependency->extra['after'], $styles ); }, 11 ); } /** * Make the dark mode toggle in the Twenty Twenty-One theme AMP compatible. */ public function add_twentytwentyone_dark_mode_toggle() { // First check if dark mode is enabled. $should_respect_color_scheme = get_theme_mod( 'respect_user_color_preference', false ); if ( ! $should_respect_color_scheme ) { return; } // Create button element for dark mode toggle. // Dark mode toggle button html and js has been omitted due to removing `the_switch` function. $button = AMP_DOM_Utils::create_node( $this->dom, Tag::BUTTON, [ Attribute::ID => 'dark-mode-toggler', Attribute::CLASS_ => 'fixed-bottom', ] ); /* translators: %s: On/Off */ $dark_mode_label = __( 'Dark Mode: %s', 'twentytwentyone' ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch $dark_mode_off = sprintf( $dark_mode_label, __( 'Off', 'twentytwentyone' ) ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch $dark_mode_on = sprintf( $dark_mode_label, __( 'On', 'twentytwentyone' ) ); // phpcs:ignore WordPress.WP.I18n.TextDomainMismatch // Create span tag to show `On` for dark mode. $button_text_on = $this->dom->createElement( Tag::SPAN ); $button_text_on->setAttribute( Attribute::CLASS_, 'dark-mode-button-on' ); $button_text_on->nodeValue = $dark_mode_on; // Create span tag to show `Off` for dark mode. $button_text_off = $this->dom->createElement( Tag::SPAN ); $button_text_off->setAttribute( Attribute::CLASS_, 'dark-mode-button-off' ); $button_text_off->nodeValue = $dark_mode_off; // Add button_text_{On, Off} to button. $button->appendChild( $button_text_on ); $button->appendChild( $button_text_off ); // Add button to body. $this->dom->body->appendChild( $button ); $style = $this->dom->createElement( Tag::STYLE ); $style->setAttribute( Attribute::ID, 'amp-twentytwentyone-dark-mode-toggle-styles' ); $style->textContent = sprintf( // We need to add these styles to show On and Off to the user. ' .no-js #dark-mode-toggler { display: block; } #dark-mode-toggler > span { margin-%s: 5px; } .dark-mode-button-on { display: none; } body.is-dark-theme .dark-mode-button-on { display: inline-block; } body.is-dark-theme .dark-mode-button-off { display: none; } ', is_rtl() ? 'right' : 'left' ); $this->dom->head->appendChild( $style ); $toggle_class = 'is-dark-theme'; // Add data-prefers-dark-mode-class in body to use toggleTheme component. $this->dom->body->setAttribute( 'data-prefers-dark-mode-class', $toggle_class ); AMP_DOM_Utils::add_amp_action( $button, 'tap', 'AMP.toggleTheme()' ); } /** * Make the mobile menu for the Twenty Twenty-One theme AMP compatible. */ public function add_twentytwentyone_mobile_modal() { $menu_toggle = $this->dom->getElementById( 'primary-mobile-menu' ); if ( ! $menu_toggle ) { return; } $state_string = 'mobile_menu_toggled'; $body_id = $this->dom->getElementId( $this->dom->body, 'body' ); AMP_DOM_Utils::add_amp_action( $menu_toggle, 'tap', "AMP.setState({{$state_string}: !{$state_string}})" ); AMP_DOM_Utils::add_amp_action( $menu_toggle, 'tap', "{$body_id}.toggleClass(class=primary-navigation-open)" ); AMP_DOM_Utils::add_amp_action( $menu_toggle, 'tap', "{$body_id}.toggleClass(class=lock-scrolling)" ); $menu_toggle->setAttribute( 'data-amp-bind-aria-expanded', "{$state_string} ? 'true' : 'false'" ); // Close the mobile modal when clicking in-page anchor links in the menu. foreach ( $this->dom->xpath->query( '//*[ @id = "site-navigation" ]//a[ @href and contains( @href, "#" ) ]' ) as $link ) { /** @var DOMElement $link */ AMP_DOM_Utils::add_amp_action( $link, 'tap', "AMP.setState({{$state_string}: false})" ); AMP_DOM_Utils::add_amp_action( $link, 'tap', "{$body_id}.toggleClass(class=primary-navigation-open,force=false)" ); AMP_DOM_Utils::add_amp_action( $link, 'tap', "{$body_id}.toggleClass(class=lock-scrolling,force=false)" ); // Ensure target is scrolled into view. Note that in-page anchor links currently do not work in the non-AMP // version. Normally scrollTo shouldn't be necessary but it appears necessary due to scroll locking. $target = preg_replace( '/.*#/', '', $link->getAttribute( 'href' ) ); if ( $target && $this->dom->getElementById( $target ) ) { AMP_DOM_Utils::add_amp_action( $link, 'tap', "{$target}.scrollTo" ); } } } /** * Make the sub-menu functionality for the Twenty Twenty-One theme AMP compatible. * * Note: Hover functionality is accomplished through CSS. * * @see amend_twentytwentyone_styles() */ public function add_twentytwentyone_sub_menu_fix() { $menu_toggles = $this->dom->xpath->query( '//nav//button[ @class and contains( concat( " ", normalize-space( @class ), " " ), " sub-menu-toggle " ) ]' ); if ( 0 === $menu_toggles->length ) { return; } $menu_toggle_ids = substr_replace( range( 1, $menu_toggles->length ), 'toggle_', 0, 0 ); // Sub-menus to be closed when the user clicks on the body. $toggles_to_disable_for_body = []; foreach ( $menu_toggle_ids as $key => $menu_toggle_id ) { /** @var DOMElement $menu_toggle */ $menu_toggle = $menu_toggles->item( $key ); $menu_toggle->setAttribute( 'data-amp-bind-aria-expanded', "{$menu_toggle_id} ? 'true' : 'false'" ); // Sub-menus to be closed when this one is to be opened. $toggles_to_disable = ''; $toggles_to_disable_for_body[] = "{$menu_toggle_id}:false"; foreach ( $menu_toggle_ids as $other_menu_toggle_id ) { if ( $menu_toggle_id === $other_menu_toggle_id ) { continue; } $toggles_to_disable .= ",{$other_menu_toggle_id}:false"; } AMP_DOM_Utils::add_amp_action( $menu_toggle, 'tap', "AMP.setState({{$menu_toggle_id}:!{$menu_toggle_id}{$toggles_to_disable}})" ); } $state_vars = implode( ',', $toggles_to_disable_for_body ); AMP_DOM_Utils::add_amp_action( $this->dom->body, 'tap', "AMP.setState({{$state_vars}})" ); $this->dom->body->setAttribute( 'role', 'document' ); $this->dom->body->setAttribute( 'tabindex', '-1' ); } /** * Sanitize the sub-menus in the Twenty Twenty-One theme. */ public function amend_twentytwentyone_sub_menu_toggles() { $menu_toggles = $this->dom->xpath->query( '//button[ @onclick = "twentytwentyoneExpandSubMenu(this)" ]' ); // Remove the `onclick` attribute for sub-menu toggles in the primary and secondary menus. foreach ( $menu_toggles as $menu_toggle ) { /** @var DOMElement $menu_toggle */ $menu_toggle->removeAttribute( 'onclick' ); } } /** * Show "Desktop Expanded Menu" in AMP mode. * Removes 'no-js' class from menu element. * * @return void */ public function show_twentytwenty_desktop_expanded_menu() { $xpath = "//*[@class='header-navigation-wrapper']/div[ @class and contains( concat( ' ', normalize-space( @class ), ' ' ), ' header-toggles hide-no-js ' ) ]"; $expanded_menu = $this->dom->xpath->query( $xpath )->item( 0 ); if ( $expanded_menu instanceof DOMElement ) { $class = $expanded_menu->getAttribute( Attribute::CLASS_ ); $expanded_menu->setAttribute( Attribute::CLASS_, str_replace( 'hide-no-js', '', $class ) ); } } /** * Get the closest sub-menu within a menu item. * * @param DOMElement $element Element to get the closest sub-menu of. * @return DOMElement Requested sub-menu element, or the starting element * if none found. */ protected function get_closest_submenu( DOMElement $element ) { $menu_item = $element; while ( ! AMP_DOM_Utils::has_class( $menu_item, 'menu-item' ) ) { $menu_item = $menu_item->parentNode; if ( ! $menu_item ) { return $element; } } $sub_menu = $this->dom->xpath->query( ".//*[ @class and contains( concat( ' ', normalize-space( @class ), ' ' ), ' sub-menu ' ) ]", $menu_item )->item( 0 ); if ( ! $sub_menu instanceof DOMElement ) { return $element; } return $sub_menu; } /** * Automatically open the submenus related to the current page in the menu modal. */ public function add_twentytwenty_current_page_awareness() { $page_ancestors = $this->dom->xpath->query( "//li[ @class and contains( concat( ' ', normalize-space( @class ), ' ' ), ' current_page_ancestor ' ) ]" ); foreach ( $page_ancestors as $page_ancestor ) { $toggle = $this->dom->xpath->query( "./div/button[ @class and contains( concat( ' ', normalize-space( @class ), ' ' ), ' sub-menu-toggle ' ) ]", $page_ancestor )->item( 0 ); $children = $this->dom->xpath->query( "./ul[ @class and contains( concat( ' ', normalize-space( @class ), ' ' ), ' children ' ) ]", $page_ancestor )->item( 0 ); foreach ( [ $toggle, $children ] as $element ) { if ( ! $element instanceof DOMElement ) { continue; } $classes = $element->hasAttribute( 'class' ) ? explode( ' ', $element->getAttribute( 'class' ) ) : []; $classes[] = 'active'; $element->setAttribute( 'class', implode( ' ', array_unique( $classes ) ) ); } } } /** * Provides a "best guess" as to what XPath would mirror a given CSS * selector. * * This is a very simplistic conversion and will only work for very basic * CSS selectors. * * @param string $css_selector CSS selector to convert. * @return string|null XPath that closely mirrors the provided CSS selector, * or null if an error occurred. * @since 1.4.0 */ protected function xpath_from_css_selector( $css_selector ) { // Start with basic clean-up. $css_selector = trim( $css_selector ); $css_selector = preg_replace( '/\s+/', ' ', $css_selector ); $xpath = ''; $direct_descendant = false; $token = strtok( $css_selector, ' ' ); while ( false !== $token ) { $matches = []; // Direct descendant. if ( preg_match( '/^>$/', $token, $matches ) ) { $direct_descendant = true; $token = strtok( ' ' ); continue; } // Single ID. if ( preg_match( '/^#(?[a-zA-Z0-9-_]*)$/', $token, $matches ) ) { $descendant = $direct_descendant ? '/' : '//'; $xpath .= "{$descendant}*[ @id = '{$matches['id']}' ]"; $direct_descendant = false; $token = strtok( ' ' ); continue; } // Single class. if ( preg_match( '/^\.(?[a-zA-Z0-9-_]*)$/', $token, $matches ) ) { $descendant = $direct_descendant ? '/' : '//'; $xpath .= "{$descendant}*[ @class and contains( concat( ' ', normalize-space( @class ), ' ' ), ' {$matches['class']} ' ) ]"; $direct_descendant = false; $token = strtok( ' ' ); continue; } // Element. if ( preg_match( '/^(?[^.][a-zA-Z0-9-_]*)$/', $token, $matches ) ) { $descendant = $direct_descendant ? '/' : '//'; $xpath .= "{$descendant}{$matches['element']}"; $direct_descendant = false; $token = strtok( ' ' ); continue; } $token = strtok( ' ' ); } return $xpath; } /** * Try to guess the role of a modal based on its classes. * * @param DOMElement $modal Modal to guess the role for. * @return string Role that was guessed. */ protected function guess_modal_role( DOMElement $modal ) { // No classes to base our guess on, so keep it generic. if ( ! $modal->hasAttribute( Attribute::CLASS_ ) ) { return Role::DIALOG; } $classes = preg_split( '/\s+/', trim( $modal->getAttribute( Attribute::CLASS_ ) ) ); foreach ( self::$modal_roles as $role ) { if ( in_array( $role, $classes, true ) ) { return $role; } } // None of the roles we are looking for match any of the classes. return Role::DIALOG; } /** * Evaluates the given XPath expression and give first Element if exist. * * @param string $expression The XPath expression to execute. * @param Element $context_node The optional context node can be specified for doing relative XPath queries. * By default, the queries are relative to the root element. * * @return Element|null Return Element if exists otherwise null. */ protected function get_first_element( $expression, $context_node = null ) { /** @var DOMNodeList $dom_node_list */ $dom_node_list = $this->dom->xpath->query( $expression, $context_node ); $dom_node = $dom_node_list->item( 0 ); return $dom_node instanceof Element ? $dom_node : null; } /** * Update main navigation menu for "twentynineteen" theme. * * @return void */ public function update_twentynineteen_mobile_main_menu() { $xpaths = [ 'main_menu' => '//nav[ @id = "site-navigation" ]//ul[ @class = "main-menu"]', 'top_menu_items' => './li[ contains( @class, "menu-item" ) and contains( @class, "menu-item-has-children" ) ]', 'expand_button' => './button[ @class = "submenu-expand" ]', 'submenu' => './ul[ @class = "sub-menu" ]', 'close_button_in_submenu' => './li[ contains( @class, "mobile-parent-nav-menu-item" ) ]//button[ contains( @class, "menu-item-link-return" ) ]', ]; $main_menu = $this->get_first_element( $xpaths['main_menu'] ); if ( empty( $main_menu ) ) { return; } $menu_items = $this->dom->xpath->query( $xpaths['top_menu_items'], $main_menu ); /** @var Element $menu_item */ foreach ( $menu_items as $menu_item ) { $expand_button = $this->get_first_element( $xpaths['expand_button'], $menu_item ); $sub_menu = $this->get_first_element( $xpaths['submenu'], $menu_item ); if ( empty( $expand_button ) || empty( $sub_menu ) ) { continue; } // AMP Sidebar. $amp_sidebar = AMP_DOM_Utils::create_node( $this->dom, Extension::SIDEBAR, [ Attribute::LAYOUT => Layout::NODISPLAY, Attribute::CLASS_ => 'amp-twentynineteen-main-navigation', Attribute::SIDE => is_rtl() ? 'left' : 'right', ] ); $sidebar_id = $this->dom->getElementId( $amp_sidebar ); // AMP nested menu. $amp_nested_menu = AMP_DOM_Utils::create_node( $this->dom, Extension::NESTED_MENU, [ Attribute::LAYOUT => Layout::FILL, ] ); // Clone the sub-menu and expand button for AMP navigation. /** @var Element $amp_sub_menu */ $amp_sub_menu = $sub_menu->cloneNode( true ); /** @var Element $amp_expand_button */ $amp_expand_button = $expand_button->cloneNode( true ); $sub_menu->setAttribute( Attribute::CLASS_, $sub_menu->getAttribute( Attribute::CLASS_ ) . ' display-on-desktop' ); $expand_button->setAttribute( Attribute::CLASS_, $expand_button->getAttribute( Attribute::CLASS_ ) . ' display-on-desktop' ); $menu_item->appendChild( $amp_expand_button ); $menu_item->appendChild( $amp_sub_menu ); $this->update_twentynineteen_main_nested_menu( $amp_sub_menu ); $amp_sub_menu->setAttribute( Attribute::CLASS_, $amp_sub_menu->getAttribute( Attribute::CLASS_ ) . ' expanded-true display-on-mobile' ); $amp_expand_button->setAttribute( Attribute::CLASS_, $amp_expand_button->getAttribute( Attribute::CLASS_ ) . ' display-on-mobile' ); // Handle buttons. $amp_expand_button->addAmpAction( 'tap', "$sidebar_id.open" ); $back_button = $this->get_first_element( $xpaths['close_button_in_submenu'], $amp_sub_menu ); if ( ! empty( $back_button ) ) { $back_button->addAmpAction( 'tap', "$sidebar_id.close" ); } $amp_nested_menu->appendChild( $amp_sub_menu ); $amp_sidebar->appendChild( $amp_nested_menu ); $menu_item->appendChild( $amp_sidebar ); } } /** * Update the markup of the nested menu to AMP compatible markup. * * @param Element $sub_menu Element of sub-menu from main navigation. * * @return void */ public function update_twentynineteen_main_nested_menu( Element $sub_menu ) { $xpaths = [ 'menu_items' => './li[ contains( @class, "menu-item" ) and contains( @class, "menu-item-has-children" ) ]', 'expand_button' => './button[ @class = "submenu-expand" ]', 'back_button' => './li[ contains( @class, "mobile-parent-nav-menu-item" ) ]//button[ contains( @class, "menu-item-link-return" ) ]', 'submenu' => './ul[ @class = "sub-menu" ]', ]; $menu_items = $this->dom->xpath->query( $xpaths['menu_items'], $sub_menu ); if ( 0 === $menu_items->length ) { return; } /** @var Element $menu_item */ foreach ( $menu_items as $menu_item ) { $nested_sub_menu = $this->get_first_element( $xpaths['submenu'], $menu_item ); if ( empty( $nested_sub_menu ) ) { continue; } $this->update_twentynineteen_main_nested_menu( $nested_sub_menu ); $back_button = $this->get_first_element( $xpaths['back_button'], $nested_sub_menu ); if ( ! empty( $back_button ) ) { $back_button->setAttribute( Attribute::AMP_NESTED_SUBMENU_CLOSE, '' ); } $open_button = $this->get_first_element( $xpaths['expand_button'], $menu_item ); if ( ! empty( $open_button ) ) { $open_button->setAttribute( Attribute::AMP_NESTED_SUBMENU_OPEN, '' ); } $amp_nested_menu_div = AMP_DOM_Utils::create_node( $this->dom, Tag::DIV, [ Attribute::AMP_NESTED_SUBMENU => '', ] ); $nested_sub_menu->setAttribute( Attribute::CLASS_, $nested_sub_menu->getAttribute( Attribute::CLASS_ ) . ' expanded-true' ); $amp_nested_menu_div->appendChild( $nested_sub_menu ); $menu_item->appendChild( $amp_nested_menu_div ); } } }