Skip to content

Commit a94d150

Browse files
committed
Introduce wp_js_dataset_name() for custom data attributes.
1 parent b96f25f commit a94d150

File tree

3 files changed

+167
-0
lines changed

3 files changed

+167
-0
lines changed

src/wp-includes/js-interop.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
/**
4+
* Core JavaScript Interoperability functions.
5+
*
6+
* @package WordPress
7+
* @subpackage JS-Interop
8+
*/
9+
10+
/**
11+
* Return the corresponding JavaScript `dataset` name for an attribute
12+
* if it represents a custom data attribute, or `null` if not.
13+
*
14+
* Custom data attributes appear in an element's `dataset` property in a
15+
* browser, but there's a specific way the names are translated from HTML
16+
* into JavaScript. This function indicates how the name would appear in
17+
* JavaScript if a browser would recognize it as a custom data attribute.
18+
*
19+
* Example:
20+
*
21+
* 'postId' === wp_js_dataset_name( 'data-post-id' );
22+
* 'Before' === wp_js_dataset_name( 'data--before' );
23+
* '-One--Two---' === wp_js_dataset_name( 'data---one---two---' );
24+
* null === wp_js_dataset_name( 'post-id' );
25+
*
26+
* @since 6.9.0
27+
* @access private
28+
*
29+
* @see https://html.spec.whatwg.org/#concept-domstringmap-pairs
30+
*
31+
* @param string $html_attribute_name Raw attribute name as found in the source HTML.
32+
* @return string|null Transformed `dataset` name, if interpretable as a custom data attribute, else `null`.
33+
*/
34+
function wp_js_dataset_name( $html_attribute_name ) {
35+
if ( 0 !== substr_compare( $html_attribute_name, 'data-', 0, 5, true ) ) {
36+
return null;
37+
}
38+
39+
$end = strlen( $html_attribute_name );
40+
41+
/*
42+
* If it contains characters which would end the attribute name parsing then
43+
* something else is wrong and this contains more than just an attribute name.
44+
*/
45+
if ( $end !== 5 + strcspn( $html_attribute_name, "=/> \t\f\r\n", 5 ) ) {
46+
return null;
47+
}
48+
49+
/*
50+
* > For each name in list, for each U+002D HYPHEN-MINUS character (-)
51+
* > in the name that is followed by an ASCII lower alpha, remove the
52+
* > U+002D HYPHEN-MINUS character (-) and replace the character that
53+
* > followed it by the same character converted to ASCII uppercase.
54+
*
55+
* @link https://html.spec.whatwg.org/#concept-domstringmap-pairs
56+
*/
57+
$custom_name = '';
58+
$at = 5;
59+
$was_at = $at;
60+
61+
while ( $at < $end ) {
62+
$next_dash_after = strcspn( $html_attribute_name, '-', $at );
63+
$next_dash_at = $at + $next_dash_after;
64+
if ( $next_dash_at >= $end - 1 ) {
65+
break;
66+
}
67+
68+
// Transform `-a` to `A`, for example.
69+
$c = $html_attribute_name[ $next_dash_at + 1 ];
70+
if ( ( $c >= 'A' && $c <= 'Z' ) || ( $c >= 'a' && $c <= 'z' ) ) {
71+
$prefix = substr( $html_attribute_name, $was_at, $next_dash_at - $was_at );
72+
$custom_name .= strtolower( $prefix );
73+
$custom_name .= strtoupper( $c );
74+
$at = $next_dash_at + 2;
75+
$was_at = $at;
76+
continue;
77+
}
78+
79+
$at = $next_dash_at + 1;
80+
}
81+
82+
// If nothing has been added it means there are no dash-letter pairs; return the name as-is.
83+
return '' === $custom_name
84+
? strtolower( substr( $html_attribute_name, 5 ) )
85+
: ( $custom_name . strtolower( substr( $html_attribute_name, $was_at ) ) );
86+
}

src/wp-settings.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@
238238
require ABSPATH . WPINC . '/kses.php';
239239
require ABSPATH . WPINC . '/cron.php';
240240
require ABSPATH . WPINC . '/deprecated.php';
241+
require ABSPATH . WPINC . '/js-interop.php';
241242
require ABSPATH . WPINC . '/script-loader.php';
242243
require ABSPATH . WPINC . '/taxonomy.php';
243244
require ABSPATH . WPINC . '/class-wp-taxonomy.php';

tests/phpunit/tests/js-interop.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
/**
3+
* Tests verifying behaviors for supporting interoperability with JavaScript.
4+
*
5+
* @package WordPress
6+
* @group js-interop
7+
*/
8+
class Tests_JS_Interop extends WP_UnitTestCase {
9+
/**
10+
* Ensures proper recognition of a data attribute and how to transform its
11+
* name into what JavaScript code would read from an element's `dataset`.
12+
*
13+
* @ticket 61501
14+
*
15+
* @dataProvider data_possible_custom_data_attributes_and_transformed_names
16+
*
17+
* @param string $attribute_name Raw HTML attribute name.
18+
* @param string|null $dataset_name_if_any Transformed attribute name, or `null`
19+
* if not a custom data attribute.
20+
*/
21+
public function test_transforms_custom_attributes_to_proper_dataset_name( string $attribute_name, ?string $dataset_name_if_any ) {
22+
$transformed_name = wp_js_dataset_name( $attribute_name );
23+
24+
if ( isset( $dataset_name_if_any ) ) {
25+
$this->assertNotNull(
26+
$transformed_name,
27+
"Failed to recognize '{$attribute_name}' as a custom data attribute."
28+
);
29+
30+
$this->assertSame(
31+
$dataset_name_if_any,
32+
$transformed_name,
33+
'Improperly transformed custom data attribute name.'
34+
);
35+
} else {
36+
$this->assertNull(
37+
$transformed_name,
38+
"Should not have identified '{$attribute_name}' as a custom data attribute."
39+
);
40+
}
41+
}
42+
43+
/**
44+
* Data provider.
45+
*
46+
* @return array[].
47+
*/
48+
public static function data_possible_custom_data_attributes_and_transformed_names() {
49+
return array(
50+
// Non-custom-data attributes.
51+
'Normal attribute' => array( 'post-id', null ),
52+
'Single word' => array( 'id', null ),
53+
54+
// Normative custom data attributes.
55+
'Normal custom data attribute' => array( 'data-post-id', 'postId' ),
56+
'Leading dash' => array( 'data--before', 'Before' ),
57+
'Trailing dash' => array( 'data-after-', 'after-' ),
58+
'Double-dashes' => array( 'data-wp-bind--enabled', 'wpBind-Enabled' ),
59+
'Double-dashes everywhere' => array( 'data--one--two--', 'One-Two--' ),
60+
'Triple-dashes' => array( 'data---one---two---', '-One--Two---' ),
61+
62+
// Unexpected but recognized custom data attributes.
63+
'Only comprising a prefix' => array( 'data-', '' ),
64+
'With upper case ASCII' => array( 'data-Post-ID', 'postId' ),
65+
'With medial upper casing' => array( 'data-uPPer-cAsE', 'upperCase' ),
66+
'With Unicode whitespace' => array( "data-\u{2003}", "\u{2003}" ),
67+
'With Emoji' => array( 'data-🐄-pasture', '🐄Pasture' ),
68+
'Brackets and colon' => array( 'data-[wish:granted]', '[wish:granted]' ),
69+
70+
// Pens and Pencils: a collection of interesting combinations of dash and underscore.
71+
'data-pens-and-pencils' => array( 'data-pens-and-pencils', 'pensAndPencils' ),
72+
'data-pens--and--pencils' => array( 'data-pens--and--pencils', 'pens-And-Pencils' ),
73+
'data--pens--and--pencils' => array( 'data--pens--and--pencils', 'Pens-And-Pencils' ),
74+
'data---pens---and---pencils' => array( 'data---pens---and---pencils', '-Pens--And--Pencils' ),
75+
'data-pens-and-pencils-' => array( 'data-pens-and-pencils-', 'pensAndPencils-' ),
76+
'data-pens-and-pencils--' => array( 'data-pens-and-pencils--', 'pensAndPencils--' ),
77+
'data-pens_and_pencils__' => array( 'data-pens_and_pencils__', 'pens_and_pencils__' ),
78+
);
79+
}
80+
}

0 commit comments

Comments
 (0)