Skip to content

Commit a11ba9e

Browse files
authored
fix(react): added aria-hidden fallback for decorative icons (#2158)
1 parent 99d9926 commit a11ba9e

File tree

6 files changed

+83
-1
lines changed

6 files changed

+83
-1
lines changed

packages/lucide-react/src/Icon.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createElement, forwardRef } from 'react';
22
import defaultAttributes from './defaultAttributes';
33
import { IconNode, LucideProps } from './types';
4-
import { mergeClasses } from '@lucide/shared';
4+
import { mergeClasses, hasA11yProp } from '@lucide/shared';
55

66
interface IconComponentProps extends LucideProps {
77
iconNode: IconNode;
@@ -46,6 +46,7 @@ const Icon = forwardRef<SVGSVGElement, IconComponentProps>(
4646
stroke: color,
4747
strokeWidth: absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth,
4848
className: mergeClasses('lucide', className),
49+
...(!children && !hasA11yProp(rest) && { 'aria-hidden': 'true' }),
4950
...rest,
5051
},
5152
[

packages/lucide-react/tests/Icon.spec.tsx

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,63 @@ describe('Using Icon Component', () => {
3131
expect(container.firstChild).toMatchSnapshot();
3232
});
3333
});
34+
35+
describe('Icon Component Accessibility', () => {
36+
it('should not have aria-hidden prop when aria prop is present', async () => {
37+
const { container } = render(
38+
<Icon
39+
iconNode={airVent}
40+
size={48}
41+
stroke="red"
42+
absoluteStrokeWidth
43+
aria-label="Air conditioning"
44+
/>,
45+
);
46+
47+
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
48+
});
49+
50+
it('should not have aria-hidden prop when title prop is present', async () => {
51+
const { container } = render(
52+
<Icon
53+
iconNode={airVent}
54+
size={48}
55+
stroke="red"
56+
absoluteStrokeWidth
57+
// @ts-expect-error
58+
title="Air conditioning"
59+
/>,
60+
);
61+
62+
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
63+
});
64+
65+
it('should not have aria-hidden prop when there are children that could be a <title> element', async () => {
66+
const { container } = render(
67+
<Icon
68+
iconNode={airVent}
69+
size={48}
70+
stroke="red"
71+
absoluteStrokeWidth
72+
>
73+
<title>Some title</title>
74+
</Icon>,
75+
);
76+
77+
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
78+
});
79+
80+
it('should never override aria-hidden prop', async () => {
81+
const { container } = render(
82+
<Icon
83+
iconNode={airVent}
84+
size={48}
85+
stroke="red"
86+
absoluteStrokeWidth
87+
aria-hidden={false}
88+
/>,
89+
);
90+
91+
expect(container.firstChild).toHaveAttribute('aria-hidden', 'false');
92+
});
93+
});

packages/lucide-react/tests/__snapshots__/Icon.spec.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
exports[`Using Icon Component > should render icon and match snapshot 1`] = `
44
<svg
5+
aria-hidden="true"
56
class="lucide"
67
fill="none"
78
height="48"

packages/lucide-react/tests/__snapshots__/createLucideIcon.spec.tsx.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
exports[`Using createLucideIcon > should create a component from an iconNode 1`] = `
44
<svg
5+
aria-hidden="true"
56
class="lucide lucide-air-vent lucide-AirVent"
67
fill="none"
78
height="24"
@@ -30,6 +31,7 @@ exports[`Using createLucideIcon > should create a component from an iconNode 1`]
3031

3132
exports[`Using createLucideIcon > should create a component from an iconNode with iconName 1`] = `
3233
<svg
34+
aria-hidden="true"
3335
class="lucide lucide-air-vent"
3436
fill="none"
3537
height="24"
@@ -58,6 +60,7 @@ exports[`Using createLucideIcon > should create a component from an iconNode wit
5860

5961
exports[`Using createLucideIcon > should include backwards compatible className 1`] = `
6062
<svg
63+
aria-hidden="true"
6164
class="lucide lucide-layout2 lucide-layout-2"
6265
fill="none"
6366
height="24"

packages/lucide-react/tests/__snapshots__/lucide-react.spec.tsx.snap

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ exports[`Using lucide icon components > should adjust the size, stroke color and
1111
stroke-linecap="round"
1212
stroke-linejoin="round"
1313
class="lucide lucide-grid3x3 lucide-grid-3x3"
14+
aria-hidden="true"
1415
>
1516
<rect width="18"
1617
height="18"
@@ -41,6 +42,7 @@ exports[`Using lucide icon components > should not scale the strokeWidth when ab
4142
stroke-linecap="round"
4243
stroke-linejoin="round"
4344
class="lucide lucide-grid3x3 lucide-grid-3x3"
45+
aria-hidden="true"
4446
>
4547
<rect width="18"
4648
height="18"
@@ -71,6 +73,7 @@ exports[`Using lucide icon components > should render an component 1`] = `
7173
stroke-linecap="round"
7274
stroke-linejoin="round"
7375
class="lucide lucide-grid3x3 lucide-grid-3x3"
76+
aria-hidden="true"
7477
>
7578
<rect width="18"
7679
height="18"

packages/shared/src/utils.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,17 @@ export const mergeClasses = <ClassType = string | undefined | null>(...classes:
4949
})
5050
.join(' ')
5151
.trim();
52+
53+
/**
54+
* Check if a component has an accessibility prop
55+
*
56+
* @param {object} props
57+
* @returns {boolean} Whether the component has an accessibility prop
58+
*/
59+
export const hasA11yProp = (props: Record<string, any>) => {
60+
for (const prop in props) {
61+
if (prop.startsWith('aria-') || prop === 'role' || prop === 'title') {
62+
return true;
63+
}
64+
}
65+
};

0 commit comments

Comments
 (0)