diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php
index 3f03ba210470f0..3b0db3ac0d20f0 100644
--- a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php
+++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php
@@ -6,7 +6,10 @@
*/
?>
-
+
diff --git a/packages/interactivity/src/directives.tsx b/packages/interactivity/src/directives.tsx
index 31e07d095e0a4c..b8daadf307aca9 100644
--- a/packages/interactivity/src/directives.tsx
+++ b/packages/interactivity/src/directives.tsx
@@ -355,25 +355,65 @@ export default () => {
// data-wp-class--[classname]
directive(
'class',
- ( { directives: { class: classNames }, element, evaluate } ) => {
- classNames
+ ( { directives: { class: classDirectives }, element, evaluate } ) => {
+ // Create elements for processing class attribute changes.
+ const attributeParserEl = document.createElement( 'div' );
+ const classProxyElement = document.createElement( 'div' );
+
+ classDirectives
.filter( isNonDefaultDirectiveSuffix )
.forEach( ( entry ) => {
- const className = entry.suffix;
const result = evaluate( entry );
- const currentClass = element.props.class || '';
- const classFinder = new RegExp(
- `(^|\\s)${ className }(\\s|$)`,
- 'g'
- );
+ const rawClassName = entry.suffix;
+
+ // Parse the class name to process HTML entities like the HTML Processor would.
+ attributeParserEl.innerHTML = `
`;
+ const classNames = [
+ ...attributeParserEl.firstElementChild!.classList,
+ ];
+
+ // If the element already has a class, set it on the proxy element
+ // so we can manipulate it.
+ if ( 'string' === typeof element.props.class ) {
+ classProxyElement.className = element.props.class;
+ } else {
+ classProxyElement.removeAttribute( 'class' );
+ }
+
+ let modified = false;
+
+ // If the result is false it means we need to remove the class from
+ // the element.
if ( ! result ) {
- element.props.class = currentClass
- .replace( classFinder, ' ' )
- .trim();
- } else if ( ! classFinder.test( currentClass ) ) {
- element.props.class = currentClass
- ? `${ currentClass } ${ className }`
- : className;
+ for ( const className of classNames ) {
+ if (
+ classProxyElement.classList.contains(
+ className
+ )
+ ) {
+ classProxyElement.classList.remove( className );
+ modified = true;
+ }
+ }
+ } else {
+ // Otherwise, if the result is true and the class is not already
+ // on the element, add it.
+ for ( const className of classNames ) {
+ if (
+ ! classProxyElement.classList.contains(
+ className
+ )
+ ) {
+ classProxyElement.classList.add( className );
+ modified = true;
+ }
+ }
+ }
+ if ( modified ) {
+ element.props.class = classProxyElement.className;
}
useInit( () => {
@@ -383,13 +423,17 @@ export default () => {
* need deps because it only needs to do it the first time.
*/
if ( ! result ) {
- (
- element.ref as RefObject< HTMLElement >
- ).current!.classList.remove( className );
+ for ( const className of classNames ) {
+ (
+ element.ref as RefObject< HTMLElement >
+ ).current!.classList.remove( className );
+ }
} else {
- (
- element.ref as RefObject< HTMLElement >
- ).current!.classList.add( className );
+ for ( const className of classNames ) {
+ (
+ element.ref as RefObject< HTMLElement >
+ ).current!.classList.add( className );
+ }
}
} );
} );
diff --git a/packages/interactivity/src/vdom.ts b/packages/interactivity/src/vdom.ts
index b533a130e4a6f0..39621c45baad10 100644
--- a/packages/interactivity/src/vdom.ts
+++ b/packages/interactivity/src/vdom.ts
@@ -19,16 +19,15 @@ const isObject = ( item: unknown ): item is Record< string, unknown > =>
// Regular expression for directive parsing.
const directiveParser = new RegExp(
- `^data-${ p }-` + // ${p} must be a prefix string, like 'wp'.
+ `^${ fullPrefix }` + // ${fullPrefix} is the expected prefix string: "data-wp-".
// Match alphanumeric characters including hyphen-separated
// segments. It excludes underscore intentionally to prevent confusion.
// E.g., "custom-directive".
- '([a-z0-9]+(?:-[a-z0-9]+)*)' +
+ '(?:[a-zA-Z0-9]+(?:-[a-zA-Z0-9]+)*)' +
// (Optional) Match '--' followed by any alphanumeric charachters. It
// excludes underscore intentionally to prevent confusion, but it can
// contain multiple hyphens. E.g., "--custom-prefix--with-more-info".
- '(?:--([a-z0-9_-]+))?$',
- 'i' // Case insensitive.
+ '(?:--)?'
);
// Regular expression for reference parsing. It can contain a namespace before
@@ -143,13 +142,22 @@ export function toVdom( root: Node ): Array< ComponentChild > {
props.__directives = directives.reduce<
Record< string, Array< DirectiveEntry > >
>( ( obj, [ name, ns, value ] ) => {
- const directiveMatch = directiveParser.exec( name );
- if ( directiveMatch === null ) {
+ if ( ! directiveParser.test( name ) ) {
warn( `Found malformed directive name: ${ name }.` );
return obj;
}
- const prefix = directiveMatch[ 1 ] || '';
- const suffix = directiveMatch[ 2 ] || null;
+
+ const unprefixedDirective = name.slice( fullPrefix.length );
+ const splitIndex = unprefixedDirective.indexOf( '--' );
+ const [ prefix, suffix = null ] =
+ splitIndex === -1
+ ? // If '--' is not found, prefix is the same as the unprefixed directive
+ [ unprefixedDirective ]
+ : // Otherwise, split the unprefixed directive at "--" into `prefix` and `suffix`
+ [
+ unprefixedDirective.slice( 0, splitIndex ),
+ unprefixedDirective.slice( splitIndex + 2 ),
+ ];
obj[ prefix ] = obj[ prefix ] || [];
obj[ prefix ].push( {
diff --git a/test/e2e/specs/interactivity/directive-class.spec.ts b/test/e2e/specs/interactivity/directive-class.spec.ts
index 96b725568767ae..4414aa9c933531 100644
--- a/test/e2e/specs/interactivity/directive-class.spec.ts
+++ b/test/e2e/specs/interactivity/directive-class.spec.ts
@@ -92,10 +92,11 @@ test.describe( 'data-wp-class', () => {
test( 'can use context values', async ( { page } ) => {
const el = page.getByTestId( 'can use context values' );
+ const toggle = page.getByTestId( 'toggle context value' );
await expect( el ).toHaveClass( '' );
- await page.getByTestId( 'toggle context false value' ).click();
+ await toggle.click();
await expect( el ).toHaveClass( 'foo' );
- await page.getByTestId( 'toggle context false value' ).click();
+ await toggle.click();
await expect( el ).toHaveClass( '' );
} );
@@ -111,11 +112,35 @@ test.describe( 'data-wp-class', () => {
test( 'can use "default" as a class name', async ( { page } ) => {
const el = page.getByTestId( 'class name default' );
- const btn = page.getByTestId( 'toggle class name default' );
+ const toggle = page.getByTestId( 'toggle context value' );
await expect( el ).not.toHaveClass( 'default' );
- await btn.click();
+ await toggle.click();
await expect( el ).toHaveClass( 'default' );
- await btn.click();
+ await toggle.click();
await expect( el ).not.toHaveClass( 'default' );
} );
+
+ test( 'can use class names with non-alphanumeric characters', async ( {
+ page,
+ } ) => {
+ const expectedClassName = '#[^+-]$';
+ const el = page.getByTestId( 'class name no-aplhanumeric' );
+ const toggle = page.getByTestId( 'toggle context value' );
+ await expect( el ).not.toHaveClass( expectedClassName );
+ await toggle.click();
+ await expect( el ).toHaveClass( expectedClassName );
+ await toggle.click();
+ await expect( el ).not.toHaveClass( expectedClassName );
+ } );
+
+ test( 'can use class name with HTML entities', async ( { page } ) => {
+ const expectedClassName = 'class-name-attribute="FOO bar"';
+ const el = page.getByTestId( 'class name HTML entities' );
+ const toggle = page.getByTestId( 'toggle context value' );
+ await expect( el ).not.toHaveClass( expectedClassName );
+ await toggle.click();
+ await expect( el ).toHaveClass( expectedClassName );
+ await toggle.click();
+ await expect( el ).not.toHaveClass( expectedClassName );
+ } );
} );