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 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 and child elements.
Each and 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 and elements
+ $labelledByIds = [];
+ $a11yContent = '';
+
+ if ($title) {
+ $titleId = 'title-' . bin2hex(random_bytes(4));
+ $labelledByIds[] = $titleId;
+ $a11yContent .= sprintf('%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 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``.
+## Accessibility: Descriptive Titles and Descriptions
+
+.. versionadded:: NEXT\_VERSION
+
+The `ux_icon()` function and the `` component now support accessible SVG metadata via the `title` and `desc` attributes.
+
+These are automatically injected into the `` 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 `` 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
+
+```
+
+ 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: `\_ — [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``.
-## Accessibility: Descriptive Titles and Descriptions
+**Accessibility: Descriptive Titles and Descriptions**
-.. versionadded:: NEXT\_VERSION
+.. versionadded:: 2.28
-The `ux_icon()` function and the `` component now support accessible SVG metadata via the `title` and `desc` attributes.
+The `ux_icon()` function and the `` component now support accessible SVG metadata via the `title` and `desc` attributes in 2.28.
-These are automatically injected into the `` markup as child elements, and properly referenced using `aria-labelledby` for improved screen reader support.
+These are automatically injected into the ```` 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
-```
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 before inner content
+ return '' . $a11yContent . $innerSvg . ' ';
}
public function getInnerSvg(): string
From 59703cfe6eaffd8c41c41621ba893fb75595b6db Mon Sep 17 00:00:00 2001
From: DeSwa
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('%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 before inner content
+
return '' . $a11yContent . $innerSvg . ' ';
}
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 @@
+', ['title' => 'Test Icon']);
+ $html = $icon->toHtml();
+ $this->assertMatchesRegularExpression('/Test Icon<\/title>/', $html);
+ }
+
+ public function testDescIsIncludedInOutput()
+ {
+ $icon = new Icon(' ', ['desc' => 'This is a test circle']);
+ $html = $icon->toHtml();
+ $this->assertMatchesRegularExpression('/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(' ', $attributes);
+
+ $html = $icon->toHtml();
+ $this->assertStringContainsString('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);
+ }
+}