Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions src/Icons/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,51 @@ of the following attributes: ``aria-label``, ``aria-labelledby`` or ``title``.

<twig:ux:icon name="user-profile" aria-hidden="false" />

**Accessibility: Descriptive Titles and Descriptions**

.. versionadded:: 2.28

The `ux_icon()` function and the `<twig:ux:icon>` component now support accessible SVG metadata via the `title` and `desc` attributes in 2.28.

These are automatically injected into the ``<svg>`` markup as child elements, and properly referenced using ``aria-labelledby`` for improved screen reader support.

**How it works:**

When you pass a `title` and/or `desc` attribute, they are rendered inside the `<svg>` as follows:

.. code-block:: twig

{{ ux_icon('bi:plus-square-dotted', {
width: '16px',
height: '16px',
class: 'text-success',
title: 'Add Stock',
desc: 'This icon indicates stock entry functionality.'
}) }}

Renders:

.. code-block:: html

<svg class="text-success" width="16px" height="16px" aria-labelledby="icon-title-abc icon-desc-def">
<title id="icon-title-abc">Add Stock</title>
<desc id="icon-desc-def">This icon indicates stock entry functionality.</desc>
Comment on lines +510 to +512
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The id attributes do no match the actual behaviour

<!-- inner SVG content -->
</svg>

.. note::

- If ``aria-labelledby`` is already defined in your attributes, it will **not** be overwritten.
- ``role="img"`` is **not added automatically**. You may choose to include it if your use case requires.
- When neither ``title``, ``desc``, ``aria-label``, nor ``aria-labelledby`` are provided, ``aria-hidden="true"`` will still be automatically applied.

This feature brings UX Icons in line with modern accessibility recommendations and helps developers build more inclusive user interfaces.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not so sure about that..

If an SVG is used in a context where it adds meaning to the content then it is not being used as an icon, and requires a different markup pattern

<svg role="img" focusable="false">
   <title>Accessible Name</title>
    <!-- child elements of the inline SVG would go here -->
</svg>

https://design-system.w3.org/styles/svg-icons.html#non-decorative-svg-accessibility

I mean, "then it is not being used as an icon" is pretty much outside the scope of this bundle.

Please correct me if i'm outdated on this :)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also

If an element can be described by visible text, it is recommended to reference that text with an aria-labelledby attribute rather than using the <title> element.

So let's push for this recommendation no ?


To learn more about accessible SVG elements:

- `MDN: <title>`\_ — [https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title)
- `MDN: <desc>`\_ — [https://developer.mozilla.org/en-US/docs/Web/SVG/Element/desc](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/desc)

Performance
-----------

Expand Down
63 changes: 52 additions & 11 deletions src/Icons/src/Icon.php
Original file line number Diff line number Diff line change
Expand Up @@ -139,27 +139,68 @@ public function __construct(
public function toHtml(): string
{
$htmlAttributes = '';
foreach ($this->attributes as $name => $value) {
if (false === $value) {
$innerSvg = $this->innerSvg;
$attributes = $this->attributes;

// Extract and remove title/desc attributes if present
$title = $attributes['title'] ?? null;
$desc = $attributes['desc'] ?? null;
unset($attributes['title'], $attributes['desc']);

$labelledByIds = [];
$a11yContent = '';

// Check if aria-labelledby should be added automatically
$shouldSetLabelledBy = !isset($attributes['aria-labelledby']) && ($title || $desc);

if ($title) {
if ($shouldSetLabelledBy) {
$titleId = 'title-' . bin2hex(random_bytes(4));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will create unpredictable HTML

$labelledByIds[] = $titleId;
$a11yContent .= sprintf('<title id="%s">%s</title>', $titleId, htmlspecialchars((string) $title, ENT_QUOTES));
} else {
$a11yContent .= sprintf('<title>%s</title>', htmlspecialchars((string) $title, ENT_QUOTES));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will add another <title> tag in the SVG if one is already present, generating invalid markup

}
}

if ($desc) {
if ($shouldSetLabelledBy) {
$descId = 'desc-' . bin2hex(random_bytes(4));
$labelledByIds[] = $descId;
$a11yContent .= sprintf('<desc id="%s">%s</desc>', $descId, htmlspecialchars((string) $desc, ENT_QUOTES));
} else {
$a11yContent .= sprintf('<desc>%s</desc>', htmlspecialchars((string) $desc, ENT_QUOTES));
}
}

if ($shouldSetLabelledBy) {
$attributes['aria-labelledby'] = implode(' ', $labelledByIds);
}

// Build final attributes string
foreach ($attributes as $name => $value) {
if ($value === false) {
continue;
}

// Special case for aria-* attributes
// https://www.w3.org/TR/wai-aria-1.1/#state_prop_def
if (true === $value && str_starts_with($name, 'aria-')) {
if ($value === true && str_starts_with($name, 'aria-')) {
$value = 'true';
}

$htmlAttributes .= ' '.$name;
if (true === $value) {

$htmlAttributes .= ' ' . $name;

if ($value === true) {
continue;
}

$value = htmlspecialchars($value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8');
$htmlAttributes .= '="'.$value.'"';
$htmlAttributes .= '="' . $value . '"';
}

return '<svg'.$htmlAttributes.'>'.$this->innerSvg.'</svg>';

// Inject <title> and <desc> before inner content
return '<svg' . $htmlAttributes . '>' . $a11yContent . $innerSvg . '</svg>';
}

public function getInnerSvg(): string
Expand Down
Loading