[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 `<twig:Button>` 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',
} -%}

<twig:Button
    {{ ...dialog_trigger_attrs }}
    {{ ...tooltip_trigger_attrs }}
/>
```

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
<twig:Button
    {{ ...{}|html_attr_merge(dialog_trigger_attrs, tooltip_trigger_attrs) }}
/>
```
This commit is contained in:
Hugo Alliaume
2026-03-23 13:51:56 +01:00
parent f9942e3224
commit e2e686be45
6 changed files with 45 additions and 1 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -13,6 +13,7 @@ namespace Symfony\UX\TwigComponent;
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 @@ final class ComponentAttributes implements \Stringable, \IteratorAggregate, \Cou
$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)));
}

View File

@@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\Kernel as BaseKernel;
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 <kevinbond@gmail.com>
@@ -33,6 +34,7 @@ final class Kernel extends BaseKernel
yield new FrameworkBundle();
yield new TwigBundle();
yield new TwigComponentBundle();
yield new TwigExtraBundle();
yield new AcmeBundle();
}

View File

@@ -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'),
} -%}
<twig:Button
{{ ...{}|html_attr_merge(dialog_trigger_attrs, tooltip_trigger_attrs) }}
label="A beautiful button"
/>

View File

@@ -16,6 +16,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\TwigComponent\Tests\Fixtures\User;
use Twig\Environment;
use Twig\Error\RuntimeError;
use Twig\Extra\Html\HtmlAttr\AttributeValueInterface;
/**
* @author Kevin Bond <kevinbond@gmail.com>
@@ -624,6 +625,21 @@ final class ComponentExtensionTest extends KernelTestCase
$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-&gt;dialog#open mouseenter-&gt;tooltip#show mouseleave-&gt;tooltip#hide focus-&gt;tooltip#show blur-&gt;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', [