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 ); + } ); } );