Skip to content

Commit 4a66db6

Browse files
committed
feat: Add ariaLabel property to icon component
1 parent 3cdecff commit 4a66db6

File tree

4 files changed

+60
-5
lines changed

4 files changed

+60
-5
lines changed

src/__tests__/snapshot-tests/__snapshots__/documenter.test.ts.snap

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8890,12 +8890,19 @@ exports[`Documenter definition for icon matches the snapshot: icon 1`] = `
88908890
"name": "Icon",
88918891
"properties": [
88928892
{
8893-
"description": "Specifies alternate text for a custom icon (using the \`url\` attribute). We recommend that you provide this for accessibility.
8893+
"deprecatedTag": "Use \`ariaLabel\` instead.",
8894+
"description": "Specifies alternate text for a custom icon (using the \`url\` attribute).
88948895
This property is ignored if you use a predefined icon or if you set your custom icon using the \`svg\` slot.",
88958896
"name": "alt",
88968897
"optional": true,
88978898
"type": "string",
88988899
},
8900+
{
8901+
"description": "Specifies alternate text for the icon. We recommend that you provide this for accessibility.",
8902+
"name": "ariaLabel",
8903+
"optional": true,
8904+
"type": "string",
8905+
},
88998906
{
89008907
"deprecatedTag": "Custom CSS is not supported. For testing and other use cases, use [data attributes](https://developer.mozilla.org/en-US/docs/Learn/HTML/Howto/Use_data_attributes).",
89018908
"description": "Adds the specified classes to the root element of the component.",

src/icon/__tests__/icon.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,28 @@ describe('Icon Component', () => {
8585
expect(img).toHaveAttribute('alt', 'custom icon');
8686
});
8787

88+
test('should render a custom icon with alternate text when a url and ariaLabel are provided', () => {
89+
const { container } = render(<Icon url={url} ariaLabel="custom icon" />);
90+
const wrapper = createWrapper(container);
91+
expect(wrapper.findIcon()!.getElement()).not.toHaveAttribute('aria-label');
92+
expect(container.querySelector('img')).toHaveAttribute('alt', 'custom icon');
93+
});
94+
95+
test('should prefer ariaLabel when alt is also provided', () => {
96+
const { container } = render(<Icon url={url} alt="icon alt" ariaLabel="custom icon" />);
97+
const wrapper = createWrapper(container);
98+
expect(wrapper.findIcon()!.getElement()).not.toHaveAttribute('aria-label');
99+
expect(container.querySelector('img')).toHaveAttribute('alt', 'custom icon');
100+
});
101+
102+
test('should not set aria-hidden="true" for custom svg icons if an ariaLabel is provided', () => {
103+
const { container } = render(<Icon svg={svg} ariaLabel="custom icon" />);
104+
const wrapper = createWrapper(container);
105+
expect(wrapper.findIcon()!.getElement()).not.toHaveAttribute('aria-hidden', 'true');
106+
expect(wrapper.findIcon()!.getElement()).toHaveAttribute('role', 'img');
107+
expect(wrapper.findIcon()!.getElement()).toHaveAttribute('aria-label', 'custom icon');
108+
});
109+
88110
test('should render a custom icon when both name and url are provided', () => {
89111
const { container } = render(<Icon url={url} name="calendar" />);
90112
const img = container.querySelector('img');
@@ -126,6 +148,20 @@ describe('Icon Component', () => {
126148
expect(container.firstElementChild).toBeEmptyDOMElement();
127149
});
128150

151+
test('sets role="img" and the label on the wrapper element when provided', () => {
152+
const { container } = render(<Icon name="calendar" ariaLabel="Calendar" />);
153+
const wrapper = createWrapper(container);
154+
expect(wrapper.findIcon()!.getElement()).toHaveAttribute('role', 'img');
155+
expect(wrapper.findIcon()!.getElement()).toHaveAttribute('aria-label', 'Calendar');
156+
});
157+
158+
test('sets role="img" and the label on the wrapper element even when ariaLabel is an empty string', () => {
159+
const { container } = render(<Icon name="calendar" ariaLabel="" />);
160+
const wrapper = createWrapper(container);
161+
expect(wrapper.findIcon()!.getElement()).toHaveAttribute('role', 'img');
162+
expect(wrapper.findIcon()!.getElement()).toHaveAttribute('aria-label', '');
163+
});
164+
129165
describe('Prototype Pollution attack', () => {
130166
beforeEach(() => {
131167
(Object.prototype as any).attack = '<b>vulnerable</b>';

src/icon/interfaces.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export interface IconProps extends BaseComponentProps {
99
* Specifies the icon to be displayed.
1010
*/
1111
name?: IconProps.Name;
12+
1213
/**
1314
* Specifies the size of the icon.
1415
*
@@ -32,12 +33,20 @@ export interface IconProps extends BaseComponentProps {
3233
* If you set both `url` and `svg`, `svg` will take precedence.
3334
*/
3435
url?: string;
36+
3537
/**
36-
* Specifies alternate text for a custom icon (using the `url` attribute). We recommend that you provide this for accessibility.
38+
* Specifies alternate text for a custom icon (using the `url` attribute).
3739
* This property is ignored if you use a predefined icon or if you set your custom icon using the `svg` slot.
40+
*
41+
* @deprecated Use `ariaLabel` instead.
3842
*/
3943
alt?: string;
4044

45+
/**
46+
* Specifies alternate text for the icon. We recommend that you provide this for accessibility.
47+
*/
48+
ariaLabel?: string;
49+
4150
/**
4251
* Specifies the SVG of a custom icon.
4352
*

src/icon/internal.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const InternalIcon = ({
4444
variant = 'normal',
4545
url,
4646
alt,
47+
ariaLabel,
4748
svg,
4849
badge,
4950
__internalRootRef = null,
@@ -82,6 +83,8 @@ const InternalIcon = ({
8283
});
8384

8485
const mergedRef = useMergeRefs(iconRef, __internalRootRef);
86+
const hasAriaLabel = typeof ariaLabel === 'string';
87+
const labelAttributes = hasAriaLabel ? { role: 'img', 'aria-label': ariaLabel } : {};
8588

8689
if (svg) {
8790
if (url) {
@@ -91,7 +94,7 @@ const InternalIcon = ({
9194
);
9295
}
9396
return (
94-
<span {...baseProps} ref={mergedRef} aria-hidden="true" style={inlineStyles}>
97+
<span {...baseProps} {...labelAttributes} ref={mergedRef} aria-hidden={!hasAriaLabel} style={inlineStyles}>
9598
{svg}
9699
</span>
97100
);
@@ -100,7 +103,7 @@ const InternalIcon = ({
100103
if (url) {
101104
return (
102105
<span {...baseProps} ref={mergedRef} style={inlineStyles}>
103-
<img src={url} alt={alt} />
106+
<img src={url} alt={ariaLabel ?? alt} />
104107
</span>
105108
);
106109
}
@@ -131,7 +134,7 @@ const InternalIcon = ({
131134
}
132135

133136
return (
134-
<span {...baseProps} ref={mergedRef} style={inlineStyles}>
137+
<span {...baseProps} {...labelAttributes} ref={mergedRef} style={inlineStyles}>
135138
{validIcon ? iconMap(name) : undefined}
136139
</span>
137140
);

0 commit comments

Comments
 (0)