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
206 changes: 206 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,206 @@
/**
* 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,
windowWidth: 0,
prevScroll: 0,
isDarkMode: false,
isDarkModeTogglerHidden: false,
},
actions: {
togglePrimaryMenu: () => {
state.isPrimaryMenuOpen = ! state.isPrimaryMenuOpen;
},

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

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

toggleDarkMode: () => {
state.isDarkMode = ! state.isDarkMode;
window.localStorage.setItem( 'twentytwentyoneDarkMode', state.isDarkMode ? 'yes' : 'no' );
Copy link
Member

Choose a reason for hiding this comment

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

Not that this is wrong, but noting here that this can also be done reactively, which is a bit safer in cases where state.isDarkMode would be modified from a few different places:

data-wp-watch="callbacks.storeDarkMode"
callbacks: {
  storeDarkMode: () => {
    // This callback is triggered each time `state.isDarkMode` changes, no matter from where.
    window.localStorage.setItem( 'twentytwentyoneDarkMode', state.isDarkMode ? 'yes' : 'no' );
  }
}

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz Follow up question: Wouldn't your suggestion call window.localStorage.setItem any time that any of the store's state properties are updated? If not, how does it not only to look for state.isDarkMode changes?

Copy link
Member

Choose a reason for hiding this comment

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

The callback only runs when the state.isDarkMode value changes because it's only subscribed to the state.isDarkMode signal.

That's simply how signals work: data-wp-watch is a computation and when you access a property (state.isDarkMode), before returning its value, it checks if it's inside a computation. If it is, it adds the computation to its internal graph of dependencies. When state.isDarkMode is updated, it invalidates all its computations, and the computation callbacks run again, updating their values/DOM.

In the Interactivity API, the interface is transparent (transparent reactive programming) because we are using Proxies on top of state and context to track their access/assignments.

By the way, terms are a bit vague in the world of signals. Computations are sometimes called effects, reactions or observers, and signals are sometimes called observables or reactive variables. Oh, and now with Svelte 5, they are also called runes 😆 .

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz Thanks! Updated in b3af9e0 (please ignore the whitespace changes)

Another follow up question: I have now two data-wp-watch callbacks for the same state property on the same HTML element. I think that makes sense, given that they serve different purposes, but I wanted to check whether there's any downside to this approach compared to putting the logic for both into the same callback, e.g. performance-wise.

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz Actually, on second thought, I'm not sure that this change makes sense - from a functionality perspective:

  • The isDarkMode value should only be cached in localStorage if it was modified by the user.
  • With the change in the new commit, I don't think that's the case any longer. Because it reacts to any isDarkMode changes, I believe it now also caches the initial value that comes purely from the browser detection. The original code only cached the value when invoked via the toggle, which is the intention.
  • Let me know if this makes sense. Do you have any suggestions how to achieve that functionality in a way that still uses data-wp-watch? Or best to go back to my previous code where it happens together with the toggling? 🤔

Copy link
Member

Choose a reason for hiding this comment

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

Can the user "unselect" the color preference to go back to prefers-color-scheme? So toggle between light/dark/prefers-color-scheme?

If so, I'd check the value of the setting in the watch callback and use removeItem() when the user has selected prefers-color-scheme, which should also be the initial value and therefore nothing should be saved on page load.

Would that make sense?

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz That isn't possible. As soon as the user makes a choice in that UI, it's "permanent".

Copy link
Member

@luisherranz luisherranz Mar 20, 2024

Choose a reason for hiding this comment

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

Ok. So, even though the user can't go back to system once they select light/dark, there are three possible values. So what I would do is to replace state.isDarkMode = true | false, which only stores two values, with state.colorMode = 'system' | 'light' | 'dark' to fully represent all the possibilities.

The default value should be system (you can define it in PHP using wp_interactivity_state or in JS directly in the store definition). Then, I'd modify the watch callback to do something like this:

callbacks: {
  storeDarkMode: () => {
    if ( state.colorMode !== 'system' ) {
      window.localStorage.setItem( 
        'twentytwentyoneDarkMode', 
        state.colorMode === 'dark' ? 'yes' : 'no' 
      );
    }
  }
}

I'm assuming you need to keep twentytwentyoneDarkMode for backward compatibility but if not you could use twentytwentyoneColorMode and store light/dark.

Does that make sense?

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz That's a good idea, however with one modification necessary: We can't only store system, we would need to still know whether the system's value is light or dark. So we could use something like system-light and system-dark, but at that point I feel like we're overloading that property.

I went with two separate properties: isDarkMode remains as before, while a separate boolean property isDarkModeManuallyOverwritten (yes, very cumbersome name 😆) stores whether it's the system default or manually overwritten.

See 4a35f3f

Copy link
Member

Choose a reason for hiding this comment

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

Ok. But you should derive isDarkMode. Remember that all the state should have only one source of truth, so all the state that can be derived, should be derived.

Something like this:

state: {
  colorPreference: window.localStorage.getItem( 'twentytwentyoneColorPreference' ) || "system",
  get isDarkMode() {
    if ( state.colorPreference === "system" )
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? "dark" : "light";
    return state.colorPreference;
  }
},
actions: {
  toggleDarkMode() {
    state.colorPreference = state.colorPreference !== "dark" ? "dark" : "light";
  }
},
callbacks: {
  storeColorPreference() {
    if ( state.colorPreference !== 'system' ) {
      window.localStorage.setItem( 
        'twentytwentyoneColorPreference', 
        state.colorPreference
      );
    }
  }
}

},

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';
}
} );
},

updateWindowWidthOnResize: () => {
// The following may be needed here since we can't use `data-wp-on--resize`?
felixarntz marked this conversation as resolved.
Show resolved Hide resolved
const refreshWidth = () => {
state.windowWidth = window.innerWidth;
}
window.onresize = refreshWidth;
},

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

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

state.isDarkMode = isDarkMode;

// The following may be needed here since we can't use `data-wp-on--scroll`?
Copy link
Member

Choose a reason for hiding this comment

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

data-wp-on--scroll works fine, but it's scoped to the element that has the directive:

<div data-wp-on--scroll="callbacks.onScroll">...</div>
// element is the <div>
element.addEventListener("scroll", callbacks.onScroll);

But here, you want to use the global scroll event: window.addEventListener( 'scroll', checkScroll ).

We don't have any special syntax for directives attached to global events yet, so for now it's fine to use data-wp-init. Once we see how common is this, maybe we can come up with something more specific, like data-wp-global-event--scroll or something like that.

What do you think? 🙂

Copy link
Member Author

Choose a reason for hiding this comment

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

I think having a more declarative way via attribute to add listeners for global / window events would be great. Maybe data-wp-on-global--{event} or data-wp-on-window--{event}? This could then work for any window events, like scroll, resize etc.

Copy link
Member

Choose a reason for hiding this comment

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

Makes sense. Would you mind opening a new discussion to talk about it? 🙂

Copy link
Member

Choose a reason for hiding this comment

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

I've opened a related discussion:

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz I've updated this PR to use the new data-wp-on-window and data-wp-on-document in 44e4977, reducing the amount of custom JS further. 🎉

cc @c4rl0sbr4v0

const checkScroll = () => {
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;
}
window.addEventListener( 'scroll', checkScroll );
},

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' );
}
},
},
} );
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,22 @@ 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( 'gutenberg_register_module' ) ) {
$defaults['data-wp-on--click'] = 'actions.toggleDarkMode';
$defaults['data-wp-bind--aria-pressed'] = 'state.isDarkMode';
$defaults['data-wp-class--hide'] = 'state.isDarkModeTogglerHidden';
} 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 +375,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( 'gutenberg_register_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( 'gutenberg_register_module' ) ) {
gutenberg_register_module(
'@twentytwentyone/interactivity',
get_template_directory_uri() . '/assets/js/interactivity.js',
array( '@wordpress/interactivity' ),
wp_get_theme()->get( 'Version' )
);
gutenberg_enqueue_module( '@twentytwentyone/interactivity' );
return;
}

// Main navigation scripts.
if ( has_nav_menu( 'primary' ) ) {
wp_enqueue_script(
Expand Down
12 changes: 11 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,17 @@
<?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.updateWindowWidthOnResize"
data-wp-watch--iframes="callbacks.makeIframesResponsive"
data-wp-init--darkmode="callbacks.initDarkMode"
data-wp-watch--darkmode="callbacks.refreshHtmlElementDarkMode"
Copy link
Member

@luisherranz luisherranz Jan 8, 2024

Choose a reason for hiding this comment

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

Using directives in <body> is fine 🙂

Copy link
Member Author

Choose a reason for hiding this comment

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

My concern is that the <body> element isn't in any way "owned" by a specific plugin or theme. Normally, regions can be assumed to be subject to a specific plugin or theme (e.g. a block, or a specific HTML container element), but <body> may be manipulated by anyone. For instance, any plugin could add a dynamic body class, in which case it would need to use directives on the body.

How could that work? Wouldn't there be conflicts between namespaces? That's where my concern stems from.

Copy link
Member

Choose a reason for hiding this comment

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

I guess it's fine for the theme to set it to its own namespace. After all, the namespace option in data-wp-interactive is just a shorter way to say "the default namespace of the directives of this element and its children is X (until you find another data-wp-interactive that overrides the value)".

Other plugins that want to use directives in the <body> element can do so using the non-default syntax to avoid conflicts: data-wp-class--my-plugin-class="myPlugin::state.someClass".

What do you think? Would that be enough?

Copy link
Member Author

Choose a reason for hiding this comment

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

@luisherranz Thanks, I didn't know it was even possible to put namespaces into the attribute values as a prefix. Are both of these methods fully supported? For example, could I choose to not put data-wp-interactive anywhere but instead prefix every single action/callback/state reference with the namespace (e.g. data-wp-class--primary-navigation-open="twentytwentyone::state.isPrimaryMenuOpen")?

While generally that would be cumbersome, for something like <body> which by definition isn't "owned" by any particular plugin or theme I'm thinking it may be more appropriate to not use a default namespace.

Copy link
Member

Choose a reason for hiding this comment

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

data-wp-interactive is still required for hydration. It can be empty if you don't want to set a default namespace, though.

By the way, you can use a single string now: data-wp-interactive="twentytwentyone" 🙂

Copy link
Member Author

Choose a reason for hiding this comment

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

That seems to be a quite significant limitation to me. Is there a reasonable way this could be fixed in the Interactivity API?

Copy link
Contributor

Choose a reason for hiding this comment

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

I'll check it 👀

Directives using a different namespace should work, as there is other namespaces compatibility.

But I will add a test just to be sure that SSR is working as expected.

Copy link
Contributor

@cbravobernal cbravobernal Mar 12, 2024

Choose a reason for hiding this comment

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

This PR would fix it, but I would love some feedback from @DAreRodz. As is setting a default 'WP' namespace that every developer could read and use.

#6262

cc: @felixarntz @gziolo

Copy link
Member

Choose a reason for hiding this comment

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

I don't have enough context to help. What implications the namespace have on the client? The fix proposed only changes the default value from null to WP. Does it mean that devs will have to explicitly use WP as a namespace when interacting with stores?

Copy link
Contributor

Choose a reason for hiding this comment

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

No, stores should infer the namespace. But all interactive chunks created with just data-wp-interactive would use that default one, being accesible.

It would create a "rule" and, as @luisherranz mentions, could create unexpected bugs as would have a different behavior than the client.

Let's close that one and work on a better solution.

>
<?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( 'gutenberg_register_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