diff --git a/src/Icons/doc/index.rst b/src/Icons/doc/index.rst index 3c9e42bdd02..6667f902267 100644 --- a/src/Icons/doc/index.rst +++ b/src/Icons/doc/index.rst @@ -481,6 +481,51 @@ of the following attributes: ``aria-label``, ``aria-labelledby`` or ``title``. +**Accessibility: Descriptive Titles and Descriptions** + +.. versionadded:: 2.28 + +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. + +**How it works:** + +When you pass a `title` and/or `desc` attribute, they are rendered inside the `` 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 + + + 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: + +- `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 ----------- diff --git a/src/Icons/src/Icon.php b/src/Icons/src/Icon.php index e7a9e73b04e..69c0c8001f4 100644 --- a/src/Icons/src/Icon.php +++ b/src/Icons/src/Icon.php @@ -139,7 +139,45 @@ 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']); + + $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)); + $labelledByIds[] = $titleId; + $a11yContent .= sprintf('%s', $titleId, htmlspecialchars((string) $title, ENT_QUOTES)); + } else { + $a11yContent .= sprintf('%s', htmlspecialchars((string) $title, ENT_QUOTES)); + } + } + + if ($desc) { + 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)); + } + } + + if ($shouldSetLabelledBy) { + $attributes['aria-labelledby'] = implode(' ', $labelledByIds); + } + + foreach ($attributes as $name => $value) { if (false === $value) { continue; } @@ -159,7 +197,7 @@ public function toHtml(): string $htmlAttributes .= '="'.$value.'"'; } - return ''.$this->innerSvg.''; + return '' . $a11yContent . $innerSvg . ''; } public function getInnerSvg(): string 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); + } +}