-
Notifications
You must be signed in to change notification settings - Fork 382
Implementing Interactivity
As noted in Adding Theme Support, the baseline experience after enabling AMP on an existing theme is that it largely behaves as if JavaScript is disabled in the user's browser. Many themes and plugins already have fallbacks in case JavaScript is not available so that will serve them well in AMP. Therefore, most of the work entailed is to progressively enhance a theme for AMP to restore features that were lost during this graceful no-JS degradation to AMP. These enhancements for AMP generally involve the use of modern CSS selectors (e.g. :focus-within), AMP components (e.g. amp-carousel and amp-position-observer), or AMP scripting via amp-bind. With these you can achieve full feature parity in AMP with the same interactivity you expect normally in a theme. This page documents some of the common solutions. In the following example is used as the theme slug/prefix.
First of all, since custom JavaScript isn't allowed in AMP you should avoid enqueueing it in the first place in order to avoid having to accept validation errors for invalid script elements. Likewise, you should skip outputting any inline scripts like this common example_javascript_detection():
/**
* Handles JavaScript detection.
*
* Adds a `js` class to the root `<html>` element when JavaScript is detected.
* This function is a no-op in AMP since custom JavaScript is not allowed.
*/
function example_javascript_detection() {
if ( example_is_amp() ) { // 👈 Added.
return;
}
echo "<script>(function(html){html.className = html.className.replace(/\bno-js\b/,'js')})(document.documentElement);</script>\n";
}
add_action( 'wp_head', 'example_javascript_detection', 0 );The example_is_amp() function here is simply a wrapper around is_amp_endpoint():
/**
* Determine whether this is an AMP response.
*
* Note that this must only be called after the parse_query action.
*
* @return bool Is AMP endpoint (and AMP plugin is active).
*/
function example_is_amp() {
return function_exists( 'is_amp_endpoint' ) && is_amp_endpoint();
}This conditional function will be re-used in examples below.
Themes will often enqueue scripts and styles in the same function which is called
at the wp_enqueue_scripts action. It makes sense to create two separate functions
for enqueueing the scripts and styles so that you can short-circuit the former in AMP:
/**
* Enqueue styles.
*/
function example_styles() {
wp_enqueue_style( 'example-style', get_stylesheet_uri() );
}
add_action( 'wp_enqueue_scripts', 'example_styles' );
/**
* Enqueue scripts (if not AMP).
*/
function example_scripts() {
if ( example_is_amp() ) {
return;
}
wp_enqueue_script( 'example-global', get_theme_file_uri( '/assets/js/global.js' ), array( 'jquery' ), '1.0', true );
// ...
}
add_action( 'wp_enqueue_scripts', 'example_scripts' );Collapsed:
Expanded:
On many themes when the template is served in a narrow viewport (e.g. on mobile) the nav menu is hidden behind a “hamburger” button to reveal it. This usually involves enqueueing some jQuery-based JavaScript code that does:
$( '#menu-toggle' ).on( 'click', function() {
var button = $( this ), nav = $( '#site-header-menu' );
button.toggleClass( 'toggled-on' );
nav.toggleClass( 'toggled-on' );
button.attr( 'aria-expanded', button.hasClass( 'toggled-on' ) ? 'true' : 'false' );
nav.attr( 'aria-expanded', button.hasClass( 'toggled-on' ) ? 'true' : 'false' );
} );Since this custom JS is not available in AMP, an alternative is needed. The AMP alternative will have the benefit of not requiring any blocking JavaScript, such as the preceding example required (2 external scripts). The solution here is to use amp-bind. The amp-bind component lets you write a subset of JavaScript in which you define some state on the page (amp-state), manipulate the state (AMP.setState()), and then react to changes to the state (via the bracketed binding attributes).
Here's an example of defining a navMenuExpanded state property with a default value of false. Then there is a button which has an AMP on attribute that takes calls AMP.setState() to toggle the navMenuExpanded state when the user does a tap on it. Then both the button and the nav have their class and aria-expanded attributes bound to changes to the navMenuExpanded state so they get updated when it changes:
<!-- 1. Define the state -->
<amp-state id="navMenuExpanded">
<script type="application/json">false</script>
</amp-state>
<!-- 2. Mutate the state -->
<button
class="menu-toggle"
on="tap:AMP.setState( { navMenuExpanded: ! navMenuExpanded } )"
[class]="'menu-toggle' + ( navMenuExpanded ? ' toggled-on' : '' )"
aria-expanded="false"
[aria-expanded]="navMenuExpanded ? 'true' : 'false'"
>
<?php _e( 'Menu', 'example' ); ?>
</button>
<!-- 3. React to state changes -->
<nav
class="site-header-menu"
[class]="'site-header-menu' + ( navMenuExpanded ? ' toggled-on' : '' )"
aria-expanded="false"
[aria-expanded]="navMenuExpanded ? 'true' : 'false'"
>
<?php wp_nav_menu( /* ... */ ); ?>
</nav>Naturally if you intend to serve this markup in non-AMP responses as well, you would want to wrap the AMP-specific elements and attributes in if ( example_is_amp() ) conditionals.
When a site has a nav menu with a hierarchy of sub-menus, it is normal for these to be displayed as a series of dropdowns which are made visible when hovering of focusing on a menu item. If you want to access a tertiary sub-menu you can hover over the parent submenu item in the secondary sub-menu: the :hover applies to the entire element tree. However, if you are using the keyboard then :focus will not behave the same way. For this reason themes often use jQuery to toggle a focus class on each ancestor for whatever item is currently focused. This doesn't work in AMP since custom JavaScript is not allowed. Nevertheless, there is now a way to achieve the same effect just with CSS via the new :focus-within pseudo selector. Beware that this is not yet recognized in IE/Edge (Can I Use?), so if you are on a paired AMP site and want to have a JS solution in addition to an AMP one, you'll need to copy the ruleset specifically for using :focus-within. In the following example, the focus class continues to be used in non-AMP:
.main-navigation li:hover > ul,
.main-navigation li.focus > ul {
left: auto;
right: 0;
}
/* The :focus-within selector is not recognized by IE/Edge. */
.main-navigation li:focus-within > ul {
left: auto;
right: 0;
}
.main-navigation ul ul li:hover > ul,
.main-navigation ul ul li.focus > ul {
left: auto;
right: 100%;
}
/* The :focus-within selector is not recognized by IE/Edge. */
.main-navigation ul ul li:focus-within > ul {
left: auto;
right: 100%;
}The preceding example of sub-menu dropdowns is primarily a desktop use case. On mobile viewports it is common to have toggle buttons with each sub-menu to expand in a touch-friendly way. When JavaScript is not available, the buttons are not inserted and all of the sub-menus are shown as expanded. In AMP however, we can prevent showing all submenus expanded by limiting the CSS rule selector to only apply if JS is disabled and it is not an AMP response:
.no-js:not([amp]) .main-navigation ul ul {
display: block;
}Now, to allow the submenus to be expanded we need to inject the buttons in an AMP-compatible way. We can do so using a PHP filter to add the buttons. Along with each toggle button there is also is an amp-state added which contains whether or not the sub-menu is expanded.
/**
* Filter the HTML output of a nav menu item to add the AMP dropdown button to reveal the sub-menu.
*
* This is only used for AMP since in JS it is added via initMainNavigation() in navigation.js.
*
* @param string $item_output Nav menu item HTML.
* @param object $item Nav menu item.
* @return string Modified nav menu item HTML.
*/
function example_add_nav_sub_menu_buttons( $item_output, $item ) {
// Only add the buttons in AMP responses.
if ( ! example_is_amp() ) ) {
return $item_output;
}
// Skip when the item has no sub-menu.
if ( ! in_array( 'menu-item-has-children', $item->classes, true ) ) {
return $item_output;
}
// Obtain the initial expanded state.
$expanded = in_array( 'current-menu-ancestor', $item->classes, true );
// Generate a unique state ID.
static $nav_menu_item_number = 0;
$nav_menu_item_number++;
$expanded_state_id = 'navMenuItemExpanded' . $nav_menu_item_number;
// Create new state for managing storing the whether the sub-menu is expanded.
$item_output .= sprintf(
'<amp-state id="%s"><script type="application/json">%s</script></amp-state>',
esc_attr( $expanded_state_id ),
wp_json_encode( $expanded )
);
/*
* Create the toggle button which mutates the state and which has class and
* aria-expanded attributes which react to the state changes.
*/
$dropdown_button = '<button';
$dropdown_class = 'dropdown-toggle';
$toggled_class = 'toggled-on';
$dropdown_button .= sprintf(
' class="%s" [class]="%s"',
esc_attr( $dropdown_class . ( $expanded ? " $toggled_class" : '' ) ),
esc_attr( sprintf( "%s + ( $expanded_state_id ? %s : '' )", wp_json_encode( $dropdown_class ), wp_json_encode( " $toggled_class" ) ) )
);
$dropdown_button .= sprintf(
' aria-expanded="%s" [aria-expanded]="%s"',
esc_attr( wp_json_encode( $expanded ) ),
esc_attr( "$expanded_state_id ? 'true' : 'false'" )
);
$dropdown_button .= sprintf(
' on="%s"',
esc_attr( "tap:AMP.setState( { $expanded_state_id: ! $expanded_state_id } )" )
);
$dropdown_button .= '>';
// Let the screen reader text in the button also update based on the expanded state.
$dropdown_button .= sprintf(
'<span class="screen-reader-text" [text]="%s">%s</span>',
esc_attr( sprintf( "$expanded_state_id ? %s : %s", wp_json_encode( __( 'collapse child menu', 'example' ) ), wp_json_encode( __( 'expand child menu', 'example' ) ) ) ),
esc_html( $expanded ? __( 'collapse child menu', 'example' ) : __( 'expand child menu', 'example' ) )
);
$dropdown_button .= '</button>';
$item_output .= $dropdown_button;
return $item_output;
}
add_filter( 'walker_nav_menu_start_el', 'example_add_nav_sub_menu_buttons', 10, 2 );Lastly, the CSS used for showing the expanded sub-menu can make use of the sibling selector to show the list when the button gets its toggled-on class added (which applies when using JavaScript as well):
.main-navigation ul button.toggled-on + ul {
display: block;
}AMP has built-in actions and events which you can use by adding an on attribute to elements, similar to how you would add event handlers in JavaScript. In Twenty Seventeen, for example, there is a button in the nav bar on the homepage that smooth-scrolls you down to the first panel. In the core theme this is implemented by requiring the jQuery scrollTo plugin, jQuery itself, and then logic in the theme's global.js to use the plugin:
// Non-AMP solution:
$( '.menu-scroll-down' ).click( function( e ) {
e.preventDefault();
$( window ).scrollTo( '#primary', {
duration: 600,
offset: { top: menuTop - navigationOuterHeight }
});
});So there are three blocking scripts to achieve this one effect. In AMP this capability is built-in to the runtime so there is no need for any of these external script dependencies:
<!-- AMP solution: -->
<a href="#content" class="menu-scroll-down" on="tap:primary.scrollTo(duration=600)">
<span class="screen-reader-text">Scroll down to content</span>
</a>- See pull request for adding AMP support for core themes.
- See pull request for adding AMP support to the underscores starter theme.
- See WP Rig which has built-in support for AMP.
Notice: Please also see the plugin documentation on amp-wp.org



