From b9f41c9278eeaf6343366b22e06c3eaf371b222e Mon Sep 17 00:00:00 2001 From: Sait KURT <33868586+xDeSwa@users.noreply.github.com> Date: Sat, 12 Jul 2025 00:50:02 +0300 Subject: [PATCH 1/6] Add support for and <desc> elements in SVG for accessibility When passing title and/or desc as attributes to ux_icon(), they are now embedded directly into the SVG as <title> and <desc> child elements. Each <title> and <desc> element is given a unique id and automatically referenced via aria-labelledby, if no such attribute was already set. This ensures compliance with accessibility best practices for inline SVG content. See: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/title https://developer.mozilla.org/en-US/docs/Web/SVG/Element/desc --- src/Icons/src/Icon.php | 40 +++++++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/Icons/src/Icon.php b/src/Icons/src/Icon.php index e7a9e73b04e..244801f8801 100644 --- a/src/Icons/src/Icon.php +++ b/src/Icons/src/Icon.php @@ -139,27 +139,53 @@ public function __construct( public function toHtml(): string { $htmlAttributes = ''; - foreach ($this->attributes as $name => $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']); + + // Prepare <title> and <desc> elements + $labelledByIds = []; + $a11yContent = ''; + + if ($title) { + $titleId = 'title-' . bin2hex(random_bytes(4)); + $labelledByIds[] = $titleId; + $a11yContent .= sprintf('<title id="%s">%s', $titleId, htmlspecialchars((string) $title, ENT_QUOTES)); + } + + if ($desc) { + $descId = 'desc-' . bin2hex(random_bytes(4)); + $labelledByIds[] = $descId; + $a11yContent .= sprintf('%s', $descId, htmlspecialchars((string) $desc, ENT_QUOTES)); + } + + // Only add aria-labelledby if not already present and we have content + if ($a11yContent !== '' && !isset($attributes['aria-labelledby'])) { + $attributes['aria-labelledby'] = implode(' ', $labelledByIds); + } + + // Build final attributes string + foreach ($attributes as $name => $value) { if (false === $value) { 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-')) { $value = 'true'; } $htmlAttributes .= ' '.$name; + if (true === $value) { continue; } - $value = htmlspecialchars($value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + $value = htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); $htmlAttributes .= '="'.$value.'"'; - } - - return ''.$this->innerSvg.''; } public function getInnerSvg(): string From 423937e8bc635f1207a551967fc023150d990ede Mon Sep 17 00:00:00 2001 From: Sait KURT <33868586+xDeSwa@users.noreply.github.com> Date: Sat, 12 Jul 2025 00:52:05 +0300 Subject: [PATCH 2/6] Add support for and <desc> elements in SVG for accessibility --- src/Icons/doc/index.rst | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index 3c9e42bdd02..b56f0b6a6ff 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -481,6 +481,47 @@ 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:: NEXT\_VERSION + +The `ux_icon()` function and the `<twig:ux:icon>` component now support accessible SVG metadata via the `title` and `desc` attributes. + +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:: html+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 + This icon indicates stock entry functionality. + + +``` + +To learn more about accessible SVG elements: + +- `MDN: `\_ — [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 ----------- From 317f73248a50c669779965f2c2e6d6894ddc243d Mon Sep 17 00:00:00 2001 From: Sait KURT <33868586+xDeSwa@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:12:43 +0300 Subject: [PATCH 3/6] Update for suggestions --- src/Icons/doc/index.rst | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index b56f0b6a6ff..f6073e1bea9 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -481,13 +481,13 @@ of the following attributes: ``aria-label``, ``aria-labelledby`` or ``title``. <twig:ux:icon name="user-profile" aria-hidden="false" /> -## Accessibility: Descriptive Titles and Descriptions +**Accessibility: Descriptive Titles and Descriptions** -.. versionadded:: NEXT\_VERSION +.. versionadded:: 2.28 -The `ux_icon()` function and the `<twig:ux:icon>` component now support accessible SVG metadata via the `title` and `desc` attributes. +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. +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:** @@ -495,7 +495,6 @@ When you pass a `title` and/or `desc` attribute, they are rendered inside the `< .. code-block:: html+twig -``` {{ ux_icon('bi:plus-square-dotted', { width: '16px', height: '16px', @@ -503,19 +502,24 @@ When you pass a `title` and/or `desc` attribute, they are rendered inside the `< 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 This icon indicates stock entry functionality. -``` + +.. 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. To learn more about accessible SVG elements: From 8b8296deaa01022da57a8cd686e285e1f2a5322e Mon Sep 17 00:00:00 2001 From: Sait KURT <33868586+xDeSwa@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:16:30 +0300 Subject: [PATCH 4/6] Update index.rst --- src/Icons/doc/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index f6073e1bea9..6667f902267 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -493,7 +493,7 @@ These are automatically injected into the ```` markup as child elements, an When you pass a `title` and/or `desc` attribute, they are rendered inside the `` as follows: -.. code-block:: html+twig +.. code-block:: twig {{ ux_icon('bi:plus-square-dotted', { width: '16px', From 27d316b4ab4022239a12127f1da705505de062ca Mon Sep 17 00:00:00 2001 From: Sait KURT <33868586+xDeSwa@users.noreply.github.com> Date: Sat, 12 Jul 2025 13:36:22 +0300 Subject: [PATCH 5/6] Update Icon.php --- src/Icons/src/Icon.php | 65 ++++++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/src/Icons/src/Icon.php b/src/Icons/src/Icon.php index 244801f8801..5f4e6db760a 100644 --- a/src/Icons/src/Icon.php +++ b/src/Icons/src/Icon.php @@ -141,51 +141,66 @@ public function toHtml(): string $htmlAttributes = ''; $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']); - - // Prepare and elements + $labelledByIds = []; $a11yContent = ''; - + + // Check if aria-labelledby should be added automatically + $shouldSetLabelledBy = !isset($attributes['aria-labelledby']) && ($title || $desc); + if ($title) { - $titleId = 'title-' . bin2hex(random_bytes(4)); - $labelledByIds[] = $titleId; - $a11yContent .= sprintf('%s', $titleId, htmlspecialchars((string) $title, ENT_QUOTES)); + if ($shouldSetLabelledBy) { + $titleId = 'title-' . bin2hex(random_bytes(4)); + $labelledByIds[] = $titleId; + $a11yContent .= sprintf('%s', $titleId, htmlspecialchars((string) $title, ENT_QUOTES)); + } else { + $a11yContent .= sprintf('%s', htmlspecialchars((string) $title, ENT_QUOTES)); + } } - + if ($desc) { - $descId = 'desc-' . bin2hex(random_bytes(4)); - $labelledByIds[] = $descId; - $a11yContent .= sprintf('%s', $descId, htmlspecialchars((string) $desc, ENT_QUOTES)); + if ($shouldSetLabelledBy) { + $descId = 'desc-' . bin2hex(random_bytes(4)); + $labelledByIds[] = $descId; + $a11yContent .= sprintf('%s', $descId, htmlspecialchars((string) $desc, ENT_QUOTES)); + } else { + $a11yContent .= sprintf('%s', htmlspecialchars((string) $desc, ENT_QUOTES)); + } } - - // Only add aria-labelledby if not already present and we have content - if ($a11yContent !== '' && !isset($attributes['aria-labelledby'])) { + + if ($shouldSetLabelledBy) { $attributes['aria-labelledby'] = implode(' ', $labelledByIds); } - + // Build final attributes string foreach ($attributes as $name => $value) { - if (false === $value) { + if ($value === false) { continue; } - - if (true === $value && str_starts_with($name, 'aria-')) { + + // Special case for aria-* attributes + // https://www.w3.org/TR/wai-aria-1.1/#state_prop_def + if ($value === true && str_starts_with($name, 'aria-')) { $value = 'true'; } - - $htmlAttributes .= ' '.$name; - - if (true === $value) { + + $htmlAttributes .= ' ' . $name; + + if ($value === true) { continue; } - - $value = htmlspecialchars((string) $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); - $htmlAttributes .= '="'.$value.'"'; + + $value = htmlspecialchars($value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); + $htmlAttributes .= '="' . $value . '"'; + } + + // Inject and <desc> before inner content + return '<svg' . $htmlAttributes . '>' . $a11yContent . $innerSvg . '</svg>'; } public function getInnerSvg(): string From 59703cfe6eaffd8c41c41621ba893fb75595b6db Mon Sep 17 00:00:00 2001 From: DeSwa <saitkurt41@gmail.com> Date: Sun, 13 Jul 2025 22:07:28 +0300 Subject: [PATCH 6/6] Add title/desc accessibility support and related tests --- src/Icons/src/Icon.php | 35 +++++++-------- .../tests/Unit/IconAccessibilityTest.php | 44 +++++++++++++++++++ 2 files changed, 60 insertions(+), 19 deletions(-) create mode 100644 src/Icons/tests/Unit/IconAccessibilityTest.php diff --git a/src/Icons/src/Icon.php b/src/Icons/src/Icon.php index 5f4e6db760a..69c0c8001f4 100644 --- a/src/Icons/src/Icon.php +++ b/src/Icons/src/Icon.php @@ -141,18 +141,18 @@ public function toHtml(): string $htmlAttributes = ''; $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)); @@ -162,7 +162,7 @@ public function toHtml(): string $a11yContent .= sprintf('<title>%s', htmlspecialchars((string) $title, ENT_QUOTES)); } } - + if ($desc) { if ($shouldSetLabelledBy) { $descId = 'desc-' . bin2hex(random_bytes(4)); @@ -172,34 +172,31 @@ public function toHtml(): string $a11yContent .= sprintf('%s', htmlspecialchars((string) $desc, ENT_QUOTES)); } } - + if ($shouldSetLabelledBy) { $attributes['aria-labelledby'] = implode(' ', $labelledByIds); } - - // Build final attributes string + foreach ($attributes as $name => $value) { - if ($value === false) { + if (false === $value) { continue; } - + // Special case for aria-* attributes // https://www.w3.org/TR/wai-aria-1.1/#state_prop_def - if ($value === true && str_starts_with($name, 'aria-')) { + if (true === $value && str_starts_with($name, 'aria-')) { $value = 'true'; } - - $htmlAttributes .= ' ' . $name; - - if ($value === true) { + + $htmlAttributes .= ' '.$name; + if (true === $value) { continue; } - + $value = htmlspecialchars($value, \ENT_QUOTES | \ENT_SUBSTITUTE, 'UTF-8'); - $htmlAttributes .= '="' . $value . '"'; + $htmlAttributes .= '="'.$value.'"'; } - - // Inject and <desc> before inner content + return '<svg' . $htmlAttributes . '>' . $a11yContent . $innerSvg . '</svg>'; } diff --git a/src/Icons/tests/Unit/IconAccessibilityTest.php b/src/Icons/tests/Unit/IconAccessibilityTest.php new file mode 100644 index 00000000000..705f39aebb2 --- /dev/null +++ b/src/Icons/tests/Unit/IconAccessibilityTest.php @@ -0,0 +1,44 @@ +<?php + +namespace Symfony\UX\Icons\Tests\Unit; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\Icons\Icon; + +class IconAccessibilityTest extends TestCase +{ + public function testTitleIsIncludedInOutput() + { + $icon = new Icon('<path d="M0 0h24v24H0z" fill="none"/>', ['title' => 'Test Icon']); + $html = $icon->toHtml(); + $this->assertMatchesRegularExpression('/<title( id="[^"]*")?>Test Icon<\/title>/', $html); + } + + public function testDescIsIncludedInOutput() + { + $icon = new Icon('<circle cx="12" cy="12" r="10"/>', ['desc' => 'This is a test circle']); + $html = $icon->toHtml(); + $this->assertMatchesRegularExpression('/<desc( id="[^"]*")?>This is a test circle<\/desc>/', $html); + } + + public function testTitleAndDescWithCustomAriaLabelledBy() + { + $attributes = [ + 'title' => 'My Line', + 'desc' => 'This is a diagonal line', + 'aria-labelledby' => 'custom-id', + ]; + $icon = new Icon('<line x1="0" y1="0" x2="10" y2="10"/>', $attributes); + + $html = $icon->toHtml(); + $this->assertStringContainsString('<title>My Line', $html); + $this->assertStringContainsString('This is a diagonal line', $html); + $this->assertStringContainsString('aria-labelledby="custom-id"', $html); + } + + public function testToStringReturnsHtml() + { + $icon = new Icon(''); + $this->assertSame($icon->toHtml(), (string) $icon); + } +}