diff --git a/extra/html-extra/HtmlExtension.php b/extra/html-extra/HtmlExtension.php index d8a6c0036d1..b01cc49e56a 100644 --- a/extra/html-extra/HtmlExtension.php +++ b/extra/html-extra/HtmlExtension.php @@ -12,8 +12,10 @@ namespace Twig\Extra\Html; use Symfony\Component\Mime\MimeTypes; +use Twig\Environment; use Twig\Error\RuntimeError; use Twig\Extension\AbstractExtension; +use Twig\Runtime\EscaperRuntime; use Twig\TwigFilter; use Twig\TwigFunction; @@ -30,6 +32,7 @@ public function getFilters(): array { return [ new TwigFilter('data_uri', [$this, 'dataUri']), + new TwigFilter('html_attr_merge', [self::class, 'htmlAttrMerge']), ]; } @@ -38,6 +41,7 @@ public function getFunctions(): array return [ new TwigFunction('html_classes', [self::class, 'htmlClasses']), new TwigFunction('html_cva', [self::class, 'htmlCva']), + new TwigFunction('html_attr', [self::class, 'htmlAttr'], ['needs_environment' => true, 'is_safe' => ['html']]), ]; } @@ -124,4 +128,96 @@ public static function htmlCva(array|string $base = [], array $variants = [], ar { return new Cva($base, $variants, $compoundVariants, $defaultVariant); } + + public static function htmlAttrMerge(...$arrays): array + { + $result = []; + + foreach ($arrays as $argNumber => $array) { + if (!$array) { + continue; + } + + if (!is_iterable($array)) { + throw new RuntimeError(sprintf('The "attr_merge" filter only works with mappings or "Traversable", got "%s" for argument %d.', \gettype($array), $argNumber + 1)); + } + + $array = (array) ($array); + + foreach (['data', 'aria'] as $flatOutKey) { + if (!isset($array[$flatOutKey])) { + continue; + } + $values = (array) $array[$flatOutKey]; + foreach ($values as $key => $value) { + $result[$flatOutKey.'-'.$key] = $value; + } + unset($array[$flatOutKey]); + } + + foreach (['class', 'style'] as $deepMergeKey) { + if (!isset($array[$deepMergeKey])) { + continue; + } + + $value = $array[$deepMergeKey]; + unset($array[$deepMergeKey]); + + if (!is_iterable($value)) { + $value = (array) $value; + } + + $result[$deepMergeKey] = [...$result[$deepMergeKey] ?? [], ...$value]; + } + + $result = [...$result, ...$array]; + } + + return $result; + } + + public static function htmlAttr(Environment $env, ...$args): string + { + $attr = self::htmlAttrMerge(...$args); + + if (isset($attr['class'])) { + $attr['class'] = trim(implode(' ', $attr['class'])); + } + + if (isset($attr['style'])) { + $style = ''; + foreach ($attr['style'] as $name => $value) { + if (is_numeric($name)) { + $style .= $value.'; '; + } else { + $style .= $name.': '.$value.'; '; + } + } + $attr['style'] = trim($style); + } + + if (isset($attr['data'])) { + foreach ($attr['data'] as $name => $value) { + $attr['data-'.$name] = $value; + } + unset($attr['data']); + } + + $result = ''; + $runtime = $env->getRuntime(EscaperRuntime::class); + + foreach ($attr as $name => $value) { + if ($value === false) { + continue; + } + + if ($value === true) { + $value = $name; + } + + $result .= $runtime->escape($name, 'html_attr').'="'.$runtime->escape($value).'" '; + } + + return trim($result); + } } diff --git a/extra/html-extra/Tests/Fixtures/html_attr_merge.test b/extra/html-extra/Tests/Fixtures/html_attr_merge.test new file mode 100644 index 00000000000..108f216ac6b --- /dev/null +++ b/extra/html-extra/Tests/Fixtures/html_attr_merge.test @@ -0,0 +1,19 @@ +--TEST-- +"html_attr_merge" function +--TEMPLATE-- +{% autoescape false %} +#1 - {{ { foo: 'bar' } | html_attr_merge({ bar: 'baz' }) | json_encode }} +#2 - {{ { class: 'foo' } | html_attr_merge({ class: 'bar' }) | json_encode }} +#3 - {{ { class: 'foo' } | html_attr_merge({ class: ['bar', 'baz'] }) | json_encode }} +#4 - {{ { class: { special: 'foo' } } + | html_attr_merge({ class: ['bar', 'baz'] }) + | html_attr_merge({ class: { special: 'qux' } }) + | json_encode }} +{% endautoescape %} +--DATA-- +return [] +--EXPECT-- +#1 - {"foo":"bar","bar":"baz"} +#2 - {"class":["foo","bar"]} +#3 - {"class":["foo","bar","baz"]} +#4 - {"class":{"special":"qux","0":"bar","1":"baz"}} diff --git a/extra/html-extra/Tests/HtmlAttrMergeTest.php b/extra/html-extra/Tests/HtmlAttrMergeTest.php new file mode 100644 index 00000000000..28839c448f5 --- /dev/null +++ b/extra/html-extra/Tests/HtmlAttrMergeTest.php @@ -0,0 +1,184 @@ + [ + ['id' => 'some-id', 'label' => 'some-label'], + [ + ['id' => 'some-id'], + ['label' => 'some-label'], + ] + ]; + + yield 'merging different attributes from three arrays' => [ + ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'], + [ + ['id' => 'some-id'], + ['label' => 'some-label'], + ['role' => 'main'], + ] + ]; + + yield 'merging different attributes from Traversables' => [ + ['id' => 'some-id', 'label' => 'some-label', 'role' => 'main'], + [ + new \ArrayIterator(['id' => 'some-id']), + new \ArrayIterator(['label' => 'some-label']), + new \ArrayIterator(['role' => 'main']), + ] + ]; + + yield 'later keys override previous ones' => [ + ['id' => 'other'], + [ + ['id' => 'this'], + ['id' => 'that'], + ['id' => 'other'], + ] + ]; + + yield 'ignore empty strings or arrays passed as arguments' => [ + ['some' => 'attribute'], + [ + ['some' => 'attribute'], + [], // empty array + '', // empty string + ] + ]; + + yield 'keep "true" and "false" boolean values' => [ + ['disabled' => true, 'enabled' => false], + [ + ['disabled' => true], + ['enabled' => false], + ] + ]; + + yield 'consolidate values for the "class" key' => [ + ['class' => ['foo', 'bar', 'baz']], + [ + ['class' => ['foo']], + ['class' => 'bar'], // string, not array + ['class' => ['baz']], + ] + ]; + + yield 'class values can be overridden when they use names (array keys)' => [ + ['class' => ['foo', 'bar', 'importance' => 'high']], + [ + ['class' => 'foo'], + ['class' => ['bar', 'importance' => 'low']], + ['class' => ['importance' => 'high']], + ] + ]; + + yield 'inline style values with numerical keys are merely collected' => [ + ['style' => ['font-weight: light', 'color: green', 'font-weight: bold']], + [ + ['style' => ['font-weight: light']], + ['style' => ['color: green', 'font-weight: bold']], + ] + ]; + + yield 'inline style values can be overridden when they use names (array keys)' => [ + ['style' => ['font-weight' => 'bold', 'color' => 'red']], + [ + ['style' => ['font-weight' => 'light']], + ['style' => ['color' => 'green', 'font-weight' => 'bold']], + ['style' => ['color' => 'red']], + ] + ]; + + yield 'no merging happens when mixing numerically indexed inline styles with named ones' => [ + ['style' => ['color: green', 'color' => 'red']], + [ + ['style' => ['color: green']], + ['style' => ['color' => 'red']], + ] + ]; + + yield 'turning aria attributes from array to flat keys' => [ + ['aria-role' => 'banner'], + [ + ['aria' => ['role' => 'main']], + ['aria' => ['role' => 'banner']], + ] + ]; + + yield 'using aria attributes from a sub-array' => [ + ['aria-role' => 'main', 'aria-label' => 'none'], + [ + ['aria' => ['role' => 'main', 'label' => 'none']], + ] + ]; + + yield 'merging aria attributes, where the array values overrides the flat one' => [ + ['aria-role' => 'navigation'], + [ + ['aria-role' => 'main'], + ['aria' => ['role' => 'banner']], + ['aria' => ['role' => 'navigation']], + ] + ]; + + yield 'merging aria attributes, where the flat ones overrides the array' => [ + ['aria-role' => 'navigation'], + [ + ['aria' => ['role' => 'main']], + ['aria-role' => 'banner'], + ['aria-role' => 'navigation'], + ] + ]; + + yield 'using data attributes in a sub-array' => [ + ['data-foo' => 'this', 'data-bar' => 'that'], + [ + ['data' => ['foo' => 'this']], + ['data' => ['bar' => 'that']], + ] + ]; + + yield 'turning data attributes from array to flat keys' => [ + ['data-test' => 'bar'], + [ + ['data' => ['test' => 'foo']], + ['data' => ['test' => 'bar']], + ] + ]; + + yield 'merging data attributes, where the array values overrides the flat one' => [ + ['data-test' => 'baz'], + [ + ['data-test' => 'foo'], + ['data' => ['test' => 'bar']], + ['data' => ['test' => 'baz']], + ] + ]; + + yield 'merging data attributes, where the flat ones overrides the array' => [ + ['data-test' => 'baz'], + [ + ['data' => ['test' => 'foo']], + ['data-test' => 'bar'], + ['data-test' => 'baz'], + ] + ]; + } +}