From bd2410fb79d1bd67f9475716ccdfaf16e0d7c5cd Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Mon, 23 Mar 2026 13:51:56 +0100 Subject: [PATCH] [TwigComponent] Add support for `AttributeValueInterface` from `twig/html-extra:^3.24.0` in `ComponentAttributes` Close https://github.com/twigphp/Twig/issues/4790, replace https://github.com/twigphp/Twig/pull/4791. This PR update `ComponentAttributes` to support `AttributeValueInterface` from Twig 3.24 with `html_attr_type` and HTML attributes merging strategy. This helps resolve situations where merging HTML attributes needs to be more sophisticated than a simple `array_merge`. For example in UX Toolkit, we have an issue where it's not possible to use a single `` with `Dialog` and `Tooltip` triggers, both triggers define a `trigger_attrs` with some attributes that may conflict. Here a simplified version: ``` {%- set dialog_trigger_attrs = { 'data-action': 'click->dialog#open', } -%} {%- set tooltip_trigger_attrs = { 'data-action': 'mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide', } -%} ``` Here, only `data-action="mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide"` will be rendered, the value from `dialog_trigger_attrs` is purely ignored. By supporting the HTML attributes merging strategy introduced in Twig HTML Extra 3.24, we can use the new Twig filter `html_attr_type`: ```twig {%- set dialog_trigger_attrs = { 'data-action': 'click->dialog#open'|html_attr_type('sst'), } -%} {%- set tooltip_trigger_attrs = { 'data-action': 'mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide'|html_attr_type('sst'), } -%} ``` Combined to `html_attr_merge` (that return an array where some values are an instance of `Twig\Extra\Html\HtmlAttr\AttributeValueInterface`), the following example will correctly render `data-action="click->dialog#open mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide"`: ```twig ``` --- src/TwigComponent/CHANGELOG.md | 4 ++++ src/TwigComponent/composer.json | 4 +++- src/TwigComponent/src/ComponentAttributes.php | 5 +++++ src/TwigComponent/tests/Fixtures/Kernel.php | 2 ++ .../Fixtures/templates/html_attr_merge.html.twig | 15 +++++++++++++++ .../tests/Integration/ComponentExtensionTest.php | 16 ++++++++++++++++ 6 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 src/TwigComponent/tests/Fixtures/templates/html_attr_merge.html.twig diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index 1b63168accb..ebc021ab2f9 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.35 + +Add support for `AttributeValueInterface` from `twig/html-extra:^3.24.0` in `ComponentAttributes` + ## 2.33 - Extended support for the `index.html.twig` template fallback when resolving namespaced anonymous components diff --git a/src/TwigComponent/composer.json b/src/TwigComponent/composer.json index cb1e08c65e0..9b81e687127 100644 --- a/src/TwigComponent/composer.json +++ b/src/TwigComponent/composer.json @@ -41,7 +41,9 @@ "symfony/phpunit-bridge": "^6.0|^7.0|^8.0", "symfony/stimulus-bundle": "^2.9.1", "symfony/twig-bundle": "^5.4|^6.0|^7.0|^8.0", - "symfony/webpack-encore-bundle": "^1.15|^2.3.0" + "symfony/webpack-encore-bundle": "^1.15|^2.3.0", + "twig/extra-bundle": "^3.10.3", + "twig/html-extra": "^3.10.3" }, "conflict": { "symfony/config": "<5.4.0" diff --git a/src/TwigComponent/src/ComponentAttributes.php b/src/TwigComponent/src/ComponentAttributes.php index bd4029145e2..9359e12e7b4 100644 --- a/src/TwigComponent/src/ComponentAttributes.php +++ b/src/TwigComponent/src/ComponentAttributes.php @@ -13,6 +13,7 @@ use Symfony\UX\StimulusBundle\Dto\StimulusAttributes; use Symfony\WebpackEncoreBundle\Dto\AbstractStimulusDto; +use Twig\Extra\Html\HtmlAttr\AttributeValueInterface; use Twig\Runtime\EscaperRuntime; /** @@ -65,6 +66,10 @@ public function __toString(): string $value = true; } + if ($value instanceof AttributeValueInterface) { + $value = $value->getValue(); + } + 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))); } diff --git a/src/TwigComponent/tests/Fixtures/Kernel.php b/src/TwigComponent/tests/Fixtures/Kernel.php index e320ecbaca7..25cbec20313 100644 --- a/src/TwigComponent/tests/Fixtures/Kernel.php +++ b/src/TwigComponent/tests/Fixtures/Kernel.php @@ -20,6 +20,7 @@ use Symfony\UX\TwigComponent\Tests\Fixtures\Bundle\AcmeBundle\AcmeBundle; use Symfony\UX\TwigComponent\Tests\Fixtures\Component\ComponentB; use Symfony\UX\TwigComponent\TwigComponentBundle; +use Twig\Extra\TwigExtraBundle\TwigExtraBundle; /** * @author Kevin Bond @@ -33,6 +34,7 @@ public function registerBundles(): iterable yield new FrameworkBundle(); yield new TwigBundle(); yield new TwigComponentBundle(); + yield new TwigExtraBundle(); yield new AcmeBundle(); } diff --git a/src/TwigComponent/tests/Fixtures/templates/html_attr_merge.html.twig b/src/TwigComponent/tests/Fixtures/templates/html_attr_merge.html.twig new file mode 100644 index 00000000000..8308ec9de41 --- /dev/null +++ b/src/TwigComponent/tests/Fixtures/templates/html_attr_merge.html.twig @@ -0,0 +1,15 @@ +{%- set dialog_trigger_attrs = { + 'data-action': 'click->dialog#open'|html_attr_type('sst'), + 'data-no-html-attr-type': 'dialog', + 'data-html-attr-type-cst': 'dialog'|html_attr_type('cst'), +} -%} +{%- set tooltip_trigger_attrs = { + 'data-action': 'mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide'|html_attr_type('sst'), + 'data-no-html-attr-type': 'trigger', + 'data-html-attr-type-cst': 'trigger'|html_attr_type('cst'), +} -%} + + diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php index d474950c90f..b4ec29e3223 100644 --- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php +++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php @@ -16,6 +16,7 @@ use Symfony\UX\TwigComponent\Tests\Fixtures\User; use Twig\Environment; use Twig\Error\RuntimeError; +use Twig\Extra\Html\HtmlAttr\AttributeValueInterface; /** * @author Kevin Bond @@ -624,6 +625,21 @@ public function testAttributesDoNotLeakToTemplateContext() $this->assertStringContainsString('data_foo-var-defined=no', $output); } + public function testPropsWithHtmlAttrMergeFilter() + { + if (!interface_exists(AttributeValueInterface::class)) { + $this->markTestSkipped('Test requires Twig HTML extra >= 3.24.'); + } + + $output = self::getContainer()->get(Environment::class)->render('html_attr_merge.html.twig'); + + $this->assertStringContainsString('class="primary"', $output); + $this->assertStringContainsString('data-action="click->dialog#open mouseenter->tooltip#show mouseleave->tooltip#hide focus->tooltip#show blur->tooltip#hide"', $output); + // When no HTML Attr Type has been defined, the very last takes precedence + $this->assertStringContainsString('data-no-html-attr-type="trigger"', $output); + $this->assertStringContainsString('data-html-attr-type-cst="dialog, trigger"', $output); + } + private function renderComponent(string $name, array $data = []): string { return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [