Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Replace Twenty Twenty-One custom JS frontend code with Interactivity API #5795

Draft
wants to merge 8 commits into
base: trunk
Choose a base branch
from
208 changes: 208 additions & 0 deletions src/wp-content/themes/twentytwentyone/assets/js/interactivity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* WordPress dependencies
*/
import { store, getContext, getElement } from '@wordpress/interactivity';

function checkClass( element, className ) {
if ( element.classList.contains( className ) ) {
return element;
}
if ( element.parentElement && element.parentElement.classList.contains( className ) ) {
return element.parentElement;
}
if ( element.parentElement.parentElement && element.parentElement.parentElement.classList.contains( className ) ) {
return element.parentElement.parentElement;
}
return null;
}

const { state, actions } = store( 'twentytwentyone', {
state: {
isPrimaryMenuOpen: false,
prevScroll: 0,
isDarkMode: false,
isDarkModeManuallyOverwritten: false,
isDarkModeTogglerHidden: false,
},
actions: {
togglePrimaryMenu: () => {
state.isPrimaryMenuOpen = ! state.isPrimaryMenuOpen;
},

openPrimaryMenu: () => {
state.isPrimaryMenuOpen = true;
},

closePrimaryMenu: () => {
state.isPrimaryMenuOpen = false;
},

toggleDarkMode: () => {
state.isDarkMode = ! state.isDarkMode;
state.isDarkModeManuallyOverwritten = true;
},

trapFocusInModal: ( event ) => {
if ( ! state.isPrimaryMenuOpen ) {
return;
}

const ctx = getContext();

const escKey = event.keyCode === 27;
if ( escKey ) {
event.preventDefault();
actions.closePrimaryMenu();
if ( ctx.firstFocusable ) {
ctx.firstFocusable.focus();
}
return;
}

const tabKey = event.keyCode === 9;
const shiftKey = event.shiftKey;
const activeEl = document.activeElement; // eslint-disable-line @wordpress/no-global-active-element

if ( ! shiftKey && tabKey && ctx.lastFocusable === activeEl ) {
event.preventDefault();
if ( ctx.firstFocusable ) {
ctx.firstFocusable.focus();
}
return;
}

if ( shiftKey && tabKey && ctx.firstFocusable === activeEl ) {
event.preventDefault();
if ( ctx.lastFocusable ) {
ctx.lastFocusable.focus();
}
return;
}

// If there are no elements in the menu, don't move the focus
if ( tabKey && ctx.firstFocusable === ctx.lastFocusable ) {
event.preventDefault();
}
},

listenToSpecialClicks: ( event ) => {
const ctx = getContext();

// Check if this was a `.sub-menu-toggle` click.
const subMenuToggle = checkClass( event.target, 'sub-menu-toggle' );
if ( subMenuToggle ) {
if ( ctx.activeSubmenu === subMenuToggle ) {
ctx.activeSubmenu = null;
} else {
ctx.activeSubmenu = subMenuToggle;
}
return;
}

// Otherwise, check if this was an anchor link click.
if ( ! event.target.hash ) {
return;
}

actions.closePrimaryMenu();

// Wait 550 and scroll to the anchor.
setTimeout( () => {
var anchor = document.getElementById( event.target.hash.slice( 1 ) );
if ( anchor ) {
anchor.scrollIntoView();
}
}, 550 );
},
},
callbacks: {
determineFocusableElements: () => {
if ( ! state.isPrimaryMenuOpen ) {
return;
}

const ctx = getContext();
const { ref } = getElement();
const elements = ref.querySelectorAll( 'input, a, button' );

ctx.firstFocusable = elements[ 0 ];
ctx.lastFocusable = elements[ elements.length - 1 ];
},

refreshSubmenus: () => {
const ctx = getContext();
const { ref } = getElement();
const elements = ref.querySelectorAll( '.sub-menu-toggle' );
elements.forEach( ( subMenuToggle ) => {
if ( ctx.activeSubmenu === subMenuToggle ) {
subMenuToggle.setAttribute( 'aria-expanded', 'true' );
} else {
subMenuToggle.setAttribute( 'aria-expanded', 'false' );
}
} );
},

makeIframesResponsive: () => {
const { ref } = getElement();

ref.querySelectorAll( 'iframe' ).forEach( function( iframe ) {
// Only continue if the iframe has a width & height defined.
if ( iframe.width && iframe.height ) {
// Calculate the proportion/ratio based on the width & height.
proportion = parseFloat( iframe.width ) / parseFloat( iframe.height );
// Get the parent element's width.
parentWidth = parseFloat( window.getComputedStyle( iframe.parentElement, null ).width.replace( 'px', '' ) );
// Set the max-width & height.
iframe.style.maxWidth = '100%';
iframe.style.maxHeight = Math.round( parentWidth / proportion ).toString() + 'px';
}
} );
},

initDarkMode: () => {
let isDarkMode = window.matchMedia( '(prefers-color-scheme: dark)' ).matches;
let isDarkModeManuallyOverwritten = false;

const cachedValue = window.localStorage.getItem( 'twentytwentyoneDarkMode' );
if ( 'yes' === cachedValue ) {
isDarkMode = true;
isDarkModeManuallyOverwritten = true;
} else if ( 'no' === cachedValue ) {
isDarkMode = false;
isDarkModeManuallyOverwritten = true;
}

state.isDarkMode = isDarkMode;
state.isDarkModeManuallyOverwritten = isDarkModeManuallyOverwritten;
},

storeDarkMode: () => {
// Store dark mode preference in local storage only if it was explicitly set via the website toggle.
if ( state.isDarkModeManuallyOverwritten ) {
window.localStorage.setItem( 'twentytwentyoneDarkMode', state.isDarkMode ? 'yes' : 'no' );
}
},

refreshHtmlElementDarkMode: () => {
// This hack may be needed since the HTML element cannot be controlled with the API attributes?
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<html> is not supported as an interactivity target, so this is the exception where mutating the DOM manually is correct.

Oh, and doing so reactively with a data-wp-watch like you're doing here is the best possible way to do so because it will always be in sync, no matter where state.isDarkMode is changed 🙂

if ( state.isDarkMode ) {
document.documentElement.classList.add( 'is-dark-theme' );
} else {
document.documentElement.classList.remove( 'is-dark-theme' );
}
},

refreshDarkModeToggler: () => {
const currentScroll = window.scrollY || document.documentElement.scrollTop;
if (
currentScroll + ( window.innerHeight * 1.5 ) > document.body.clientHeight ||
currentScroll < state.prevScroll
) {
state.isDarkModeTogglerHidden = false;
} else if ( currentScroll > state.prevScroll && 250 < currentScroll ) {
state.isDarkModeTogglerHidden = true;
}
state.prevScroll = currentScroll;
},
},
} );
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ public function __construct() {
* @return void
*/
public function editor_custom_color_variables() {
// See potential bug that this fixes in https://core.trac.wordpress.org/ticket/60111.
if ( ! is_admin() ) {
return;
}

if ( ! $this->switch_should_render() ) {
return;
}
Expand Down Expand Up @@ -306,15 +311,23 @@ public function the_switch() {
* @return void
*/
public function the_html( $attrs = array() ) {
$attrs = wp_parse_args(
$attrs,
array(
'id' => 'dark-mode-toggler',
'class' => 'fixed-bottom',
'aria-pressed' => 'false',
'onClick' => 'toggleDarkMode()',
)
$defaults = array(
'id' => 'dark-mode-toggler',
'class' => 'fixed-bottom',
'aria-pressed' => 'false',
);

// Extra attributes depending on whether or not the Interactivity API is being used.
if ( function_exists( 'wp_register_script_module' ) ) {
$defaults['data-wp-on--click'] = 'actions.toggleDarkMode';
$defaults['data-wp-bind--aria-pressed'] = 'state.isDarkMode';
$defaults['data-wp-class--hide'] = 'state.isDarkModeTogglerHidden';
$defaults['data-wp-on-document--scroll'] = 'callbacks.refreshDarkModeToggler';
} else {
$defaults['onClick'] = 'toggleDarkMode()';
}

$attrs = wp_parse_args( $attrs, $defaults );
echo '<button';
foreach ( $attrs as $key => $val ) {
echo ' ' . esc_attr( $key ) . '="' . esc_attr( $val ) . '"';
Expand Down Expand Up @@ -363,6 +376,11 @@ public function the_html( $attrs = array() ) {
* @return void
*/
public function the_script() {
// If the Interactivity API is being used, loading this JS code is not necessary.
if ( function_exists( 'wp_register_script_module' ) ) {
return;
}

echo '<script>';
include get_template_directory() . '/assets/js/dark-mode-toggler.js'; // phpcs:ignore WPThemeReview.CoreFunctionality.FileInclude
echo '</script>';
Expand Down
12 changes: 12 additions & 0 deletions src/wp-content/themes/twentytwentyone/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,18 @@ function twenty_twenty_one_scripts() {
)
);

// Use WordPress Interactivity API if available.
if ( function_exists( 'wp_register_script_module' ) ) {
wp_register_script_module(
'@twentytwentyone/interactivity',
get_template_directory_uri() . '/assets/js/interactivity.js',
array( '@wordpress/interactivity' ),
wp_get_theme()->get( 'Version' )
);
wp_enqueue_script_module( '@twentytwentyone/interactivity' );
return;
}

// Main navigation scripts.
if ( has_nav_menu( 'primary' ) ) {
wp_enqueue_script(
Expand Down
13 changes: 12 additions & 1 deletion src/wp-content/themes/twentytwentyone/header.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,18 @@
<?php wp_head(); ?>
</head>

<body <?php body_class(); ?>>
<body
<?php body_class(); ?>
data-wp-interactive='{"namespace": "twentytwentyone"}'
data-wp-class--primary-navigation-open="state.isPrimaryMenuOpen"
data-wp-class--lock-scrolling="state.isPrimaryMenuOpen"
data-wp-class--is-dark-theme="state.isDarkMode"
data-wp-init--iframes="callbacks.makeIframesResponsive"
data-wp-on-window--resize="callbacks.makeIframesResponsive"
data-wp-init--darkmode="callbacks.initDarkMode"
data-wp-watch--darkmode-cache="callbacks.storeDarkMode"
data-wp-watch--darkmode-class="callbacks.refreshHtmlElementDarkMode"
>
<?php wp_body_open(); ?>
<div id="page" class="site">
<a class="skip-link screen-reader-text" href="#content">
Expand Down
9 changes: 8 additions & 1 deletion src/wp-content/themes/twentytwentyone/inc/menu-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,15 @@
function twenty_twenty_one_add_sub_menu_toggle( $output, $item, $depth, $args ) {
if ( 0 === $depth && in_array( 'menu-item-has-children', $item->classes, true ) ) {

// Extra attributes depending on whether or not the Interactivity API is being used.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice side effect: avoiding onClick inline script is better for CSP

if ( function_exists( 'wp_register_script_module' ) ) {
$extra_attr = '';
} else {
$extra_attr = ' onClick="twentytwentyoneExpandSubMenu(this)"';
}

// Add toggle button.
$output .= '<button class="sub-menu-toggle" aria-expanded="false" onClick="twentytwentyoneExpandSubMenu(this)">';
$output .= '<button class="sub-menu-toggle" aria-expanded="false"' . $extra_attr . '>';
$output .= '<span class="icon-plus">' . twenty_twenty_one_get_icon_svg( 'ui', 'plus', 18 ) . '</span>';
$output .= '<span class="icon-minus">' . twenty_twenty_one_get_icon_svg( 'ui', 'minus', 18 ) . '</span>';
/* translators: Hidden accessibility text. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,25 @@
?>

<?php if ( has_nav_menu( 'primary' ) ) : ?>
<nav id="site-navigation" class="primary-navigation" aria-label="<?php esc_attr_e( 'Primary menu', 'twentytwentyone' ); ?>">
<nav
id="site-navigation"
class="primary-navigation"
aria-label="<?php esc_attr_e( 'Primary menu', 'twentytwentyone' ); ?>"
data-wp-on--click="actions.listenToSpecialClicks"
data-wp-on--keydown="actions.trapFocusInModal"
data-wp-context='{"firstFocusable": null, "lastFocusable": null, "activeSubmenu": null}'
data-wp-watch--focusable="callbacks.determineFocusableElements"
data-wp-watch--submenus="callbacks.refreshSubmenus"
>
<div class="menu-button-container">
<button id="primary-mobile-menu" class="button" aria-controls="primary-menu-list" aria-expanded="false">
<button
id="primary-mobile-menu"
class="button"
aria-controls="primary-menu-list"
aria-expanded="false"
data-wp-on--click="actions.togglePrimaryMenu"
data-wp-bind--aria-expanded="state.isPrimaryMenuOpen"
>
<span class="dropdown-icon open"><?php esc_html_e( 'Menu', 'twentytwentyone' ); ?>
<?php echo twenty_twenty_one_get_icon_svg( 'ui', 'menu' ); // phpcs:ignore WordPress.Security.EscapeOutput ?>
</span>
Expand Down
Loading