Skip to content

[Icons][Map] Render attributes through twig/html-extra's html_attr() logic - #3821

Open
Kocal wants to merge 2 commits into
symfony:3.xfrom
Kocal:twig-component-icons-map-html-attr
Open

[Icons][Map] Render attributes through twig/html-extra's html_attr() logic#3821
Kocal wants to merge 2 commits into
symfony:3.xfrom
Kocal:twig-component-icons-map-html-attr

Conversation

@Kocal

@Kocal Kocal commented Aug 26, 2026

Copy link
Copy Markdown
Member
Q A
Bug fix? yes
New feature? no
Deprecations? no
Issues Fix #3469
License MIT

This builds on #3820: UX Icons and UX Map now share the same attribute rendering, through HtmlExtension::htmlAttrValue(), which fixes #3469 where the Toolkit's typed attribute values crashed <twig:ux:icon> and <twig:ux:map>. It needs twigphp/Twig#4895 and adds twig/html-extra ^3.29 to both packages.

  • Also fixes a separate bug in the same diff: an omitted aria-label, aria-labelledby or title no longer suppresses UX Icons' automatic aria-hidden="true", which used to leave the icon with no textual alternative and not hidden from assistive technology (predates this PR).

Ignore the first commit during the review, it's basically #3820

Kocal added 2 commits August 26, 2026 08:01
… html_attr()

| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Deprecations? | no
| Issues        | symfony#3261
| License       | MIT

`ComponentAttributes::__toString()` currently hand-rolls its own HTML attribute rendering: it throws on a `null` value, treats `aria-*` asymmetrically (`true` becomes `"true"` but `false` is silently dropped), renders a boolean `true` as a bare attribute, and throws on arrays, iterables and enums. It's an older, partial, and in places invalid reimplementation of a job Twig now does natively.

`twig/html-extra` ships `html_attr()`, which does the same job completely and to spec: `null`/`false` omit the attribute, `aria-*` booleans render `"true"`/`"false"` symmetrically, `data-*` `true` renders `"true"`, non-scalar `data-*` values are JSON-encoded, iterables become token lists, and `BackedEnum`/`Stringable` are handled. It's the exact rendering plain Twig and Symfony users already get.

This PR makes `__toString()` resolve every attribute value through `HtmlExtension::htmlAttrValue()`, the escaper-free, per-value building block behind `html_attr()`, added in twig/html-extra 3.29 (this PR depends on the companion twigphp/Twig#4895), then escapes the result itself. Requires `twig/html-extra` `^3.29`.

Both rendering paths go through it: `{{ attributes }}` and the single-attribute `attributes.render(...)`, which used to carry its own partial copy of the same rules. Attribute merging (`attributes.defaults(...)`, including the `class`/`data-controller`/`data-action` concatenation) is unchanged. The two component-specific pre-filters (skipping already-rendered attributes and nested component-prop keys like `foo:bar`) are preserved. The constructor is untouched, so nothing else in the codebase changes.

Now that a native first-party solution exists, keeping our own incomplete one makes no sense. One source of truth, so UX users and plain Twig users get identical attribute rendering instead of two implementations to keep in sync.

## Behavior changes

- `null` value now omits the attribute instead of throwing (it has been deprecated since 2.8)
- `aria-*` `false` now renders `"false"` instead of being dropped, matching React and Vue, and resolving the long-standing discussion in symfony#3261
- boolean `true` now renders `name=""`
- `data-*` `true` now renders `"true"`
- array, iterable and `BackedEnum` values are now supported
- `attributes.render()` returns `null` for an omitted attribute instead of throwing on a non-string value, and resolves the value like `{{ attributes }}` does
- LiveComponent's internal `data-live-preserve` marker now renders as `data-live-preserve="true"` instead of a bare attribute; harmless, since its JavaScript matches on presence
</content>
</invoke>
…logic

| Q             | A
| ------------- | ---
| Bug fix?      | yes
| New feature?  | no
| Deprecations? | no
| Issues        | Fix symfony#3469
| License       | MIT

Components rendered by a custom renderer, like `<twig:ux:icon>` or `<twig:ux:map>`, never go through a template: their raw props are passed straight to the package runtime, which only knows how to deal with scalars. Spreading a Toolkit's typed attributes on them, for example `<twig:ux:icon name="..." {{ ...dropdown_menu_trigger_attrs }} />` where `data-action` holds an `html_attr_type()` value, crashes with `Invalid value type for attribute "data-action". Boolean, string, int or float allowed, "Twig\Extra\Html\HtmlAttr\SeparatedTokenList" provided.` A user ran into this in symfony#3469.

`UXIconRuntime::renderIcon()` and `MapRuntime::renderMap()` now resolve their attribute values through `HtmlExtension::htmlAttrValue()`, the escaper-free building block behind `html_attr()` that handles one value at a time. Both methods are the single point every code path goes through, so the `ux_icon()`/`ux_map()` functions and the component syntax are covered at once.

Only attributes go through this resolution: `MapRuntime` splits domain props (`map`, `center`, `markers`, ...) from attributes before touching anything, so a `Map` object never reaches `htmlAttrValue()`.

`htmlAttrValue()` doesn't need a Twig `Environment`, so `IconRendererInterface` and `RendererInterface` keep their scalar-only contracts and stay Twig-independent. Decorators such as icon caches aren't affected. A resolved `null` becomes `false`, which both renderers already treat as "omit this attribute", so this also makes it possible to drop an attribute from the renderer defaults.

End result: icons, maps and Twig components now render attributes the same way as plain Twig templates using `html_attr()`.

This builds on the ComponentAttributes PR (symfony#3820) and needs the companion Twig PR (twigphp/Twig#4895). It also requires `twig/html-extra` `^3.29`, a new dependency for both packages.

## Behavior changes

- typed attribute values (`html_attr_type()`, `tailwind_classes`) are now accepted instead of throwing
- `null` now omits an attribute, and also drops it from the renderer defaults
- `aria-*` booleans now render `"true"`/`"false"` symmetrically
- boolean `true` now renders `name=""`, and `data-*` `true` renders `"true"`
- array, iterable and `BackedEnum` values are now supported, and non-scalar `data-*` values are JSON-encoded
@Kocal
Kocal force-pushed the twig-component-icons-map-html-attr branch from c79f62a to cefea93 Compare August 26, 2026 06:08
@Kocal
Kocal requested review from kbond and smnandre August 26, 2026 06:22
fabpot added a commit to twigphp/Twig that referenced this pull request Aug 26, 2026
…ne attribute rendering (Kocal)

This PR was squashed before being merged into the 3.x branch.

Discussion
----------

Extract `htmlAttrValue()` from `html_attr` for standalone attribute rendering

The per-value resolution behind `html_attr` is extracted into a new public `HtmlExtension::htmlAttrValue()`, returning the unescaped value or `null` to omit the attribute; `html_attr()` now delegates to it, output unchanged, existing tests untouched. This lets third parties render a single attribute exactly like `html_attr` without a Twig `Environment`, since the resolution is escaper-free. symfony/ux#3820 and symfony/ux#3821 depend on this PR.

The `data-*` branch only tested `is_scalar()`, so a `\Stringable` was JSON-encoded instead of using its string representation; the same object already rendered its string form in `title` or `class`, and `AttributeValueInterface` was already excluded from that branch.

| Value in `data-value` | Before | After |
| --- | --- | --- |
| a `\Stringable` | `data-value="{}"` | `data-value="hello"` |
| a `\Stringable` that is also `JsonSerializable` | `data-value="&quot;01JABC&quot;"` | `data-value="01JABC"` |

Commits
-------

9b18e37 Extract `htmlAttrValue()` from `html_attr` for standalone attribute rendering
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug Bug Fix Icons Map Status: Needs Review Needs to be reviewed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants