diff --git a/src/TwigComponent/src/ComponentAttributes.php b/src/TwigComponent/src/ComponentAttributes.php index 57c5e4676d6..bea7876dfd1 100644 --- a/src/TwigComponent/src/ComponentAttributes.php +++ b/src/TwigComponent/src/ComponentAttributes.php @@ -22,6 +22,8 @@ final class ComponentAttributes implements \Stringable, \IteratorAggregate, \Countable { private const NESTED_REGEX = '#^([\w-]+):(.+)$#'; + private const ALPINE_REGEX = '#^x-([a-z]+):[^:]+$#'; + private const VUE_REGEX = '#^v-([a-z]+):[^:]+$#'; /** @var array */ private array $rendered = []; @@ -35,43 +37,43 @@ public function __construct(private array $attributes) public function __toString(): string { - return array_reduce( - array_filter( - array_keys($this->attributes), - fn (string $key) => !isset($this->rendered[$key]) - ), - function (string $carry, string $key) { - if (preg_match(self::NESTED_REGEX, $key)) { - return $carry; - } - - $value = $this->attributes[$key]; - - if ($value instanceof \Stringable) { - $value = (string) $value; - } - - if (!\is_scalar($value) && null !== $value) { - throw new \LogicException(\sprintf('A "%s" prop was passed when creating the component. No matching "%s" property or mount() argument was found, so we attempted to use this as an HTML attribute. But, the value is not a scalar (it\'s a "%s"). Did you mean to pass this to your component or is there a typo on its name?', $key, $key, get_debug_type($value))); - } - - if (null === $value) { - trigger_deprecation('symfony/ux-twig-component', '2.8.0', 'Passing "null" as an attribute value is deprecated and will throw an exception in 3.0.'); - $value = true; - } - - if (true === $value && str_starts_with($key, 'aria-')) { - $value = 'true'; - } - - return match ($value) { - true => "{$carry} {$key}", - false => $carry, - default => \sprintf('%s %s="%s"', $carry, $key, $value), - }; - }, - '' - ); + $attributes = ''; + + foreach ($this->attributes as $key => $value) { + if (isset($this->rendered[$key])) { + continue; + } + + if ( + str_contains($key, ':') + && preg_match(self::NESTED_REGEX, $key) + && !preg_match(self::ALPINE_REGEX, $key) + && !preg_match(self::VUE_REGEX, $key) + ) { + continue; + } + + if (null === $value) { + trigger_deprecation('symfony/ux-twig-component', '2.8.0', 'Passing "null" as an attribute value is deprecated and will throw an exception in 3.0.'); + $value = true; + } + + if (!\is_scalar($value) && !($value instanceof \Stringable)) { + throw new \LogicException(\sprintf('A "%s" prop was passed when creating the component. No matching "%s" property or mount() argument was found, so we attempted to use this as an HTML attribute. But, the value is not a scalar (it\'s a "%s"). Did you mean to pass this to your component or is there a typo on its name?', $key, $key, get_debug_type($value))); + } + + if (true === $value && str_starts_with($key, 'aria-')) { + $value = 'true'; + } + + $attributes .= match ($value) { + true => ' '.$key, + false => '', + default => \sprintf(' %s="%s"', $key, $value), + }; + } + + return $attributes; } public function __clone(): void @@ -215,7 +217,10 @@ public function nested(string $namespace): self $attributes = []; foreach ($this->attributes as $key => $value) { - if (preg_match(self::NESTED_REGEX, $key, $matches) && $namespace === $matches[1]) { + if ( + str_contains($key, ':') + && preg_match(self::NESTED_REGEX, $key, $matches) && $namespace === $matches[1] + ) { $attributes[$matches[2]] = $value; } } diff --git a/src/TwigComponent/tests/Fixtures/templates/components/PrefixedAttributes.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/PrefixedAttributes.html.twig new file mode 100644 index 00000000000..338d9f2f31c --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/components/PrefixedAttributes.html.twig @@ -0,0 +1 @@ + diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php index 8b2b0e0987a..66327e13398 100644 --- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php +++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php @@ -316,6 +316,58 @@ public function testRenderingComponentWithNestedAttributes(): void ); } + /** + * @dataProvider providePrefixedAttributesCases + */ + public function testRenderPrefixedAttributes(string $attributes, bool $expectContains): void + { + /** @var Environment $twig */ + $twig = self::getContainer()->get(Environment::class); + $template = $twig->createTemplate(\sprintf('', $attributes)); + + if ($expectContains) { + self::assertStringContainsString($attributes, trim($template->render())); + + return; + } + + self::assertStringNotContainsString($attributes, trim($template->render())); + } + + /** + * @return iterable + */ + public static function providePrefixedAttributesCases(): iterable + { + // General + yield ['x:men', false]; // Nested + yield ['x:men="u"', false]; // Nested + yield ['x-men', true]; + yield ['x-men="u"', true]; + + // AlpineJS + yield ['x-click="count++"', true]; + yield ['x-on:click="count++"', true]; + yield ['@click="open"', true]; + // Not AlpineJS + yield ['z-click="count++"', true]; + yield ['z-on:click="count++"', false]; // Nested + + // Stencil + yield ['onClick="count++"', true]; + yield ['@onClick="count++"', true]; + + // VueJs + yield ['v-model="message"', true]; + yield ['v-bind:id="dynamicId"', true]; + yield ['v-bind:id', true]; + yield ['@submit.prevent="onSubmit"', true]; + // Not VueJs + yield ['z-model="message"', true]; + yield ['z-bind:id="dynamicId"', false]; // Nested + yield ['z-bind:id', false]; // Nested + } + public function testRenderingHtmlSyntaxComponentWithNestedAttributes(): void { $output = self::getContainer() diff --git a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php index 8ed7aa4f004..d1999400c55 100644 --- a/src/TwigComponent/tests/Unit/ComponentAttributesTest.php +++ b/src/TwigComponent/tests/Unit/ComponentAttributesTest.php @@ -259,6 +259,19 @@ public function testNestedAttributes(): void $this->assertSame('', (string) $attributes->nested('invalid')); } + public function testPrefixedAttributes(): void + { + $attributes = new ComponentAttributes([ + 'x-click' => 'x+', + 'title:x-click' => 'title:x+', + ]); + + $this->assertSame(' x-click="x+"', (string) $attributes); + $this->assertSame(' x-click="title:x+"', (string) $attributes->nested('title')); + $this->assertSame('', (string) $attributes->nested('title')->nested('span')); + $this->assertSame('', (string) $attributes->nested('invalid')); + } + public function testConvertTrueAriaAttributeValue(): void { $attributes = new ComponentAttributes([