Compare commits

...

23 Commits

Author SHA1 Message Date
Karsa
bf3749502a fix(packages/lucide-angular): fix hasA11yProp import path (non-relative import path will not get properly resolved by ng-packagr) 2025-12-10 12:40:01 +01:00
Karsa
c53decf3a1 test(shared): fix hasA11yProp unit test 2025-12-10 10:19:56 +01:00
Eric Fennis
589460a924 update tests 2025-11-17 11:05:18 +01:00
Eric Fennis
b033c511c3 Merge branch 'main' of https://github.com/lucide-icons/lucide into add-aria-hidden-to-all-packages 2025-11-17 10:35:08 +01:00
Eric Fennis
b11377b9f0 Merge branch 'main' of https://github.com/lucide-icons/lucide into add-aria-hidden-to-all-packages 2025-09-19 11:46:40 +02:00
Eric Fennis
f100048627 Fix import 2025-09-19 11:44:02 +02:00
Eric Fennis
a553dc7f3e Adjusted docs 2025-09-19 10:06:19 +02:00
Eric Fennis
8fe4fe160c Merge branch 'add-aria-hidden-to-all-packages' of https://github.com/lucide-icons/lucide into add-aria-hidden-to-all-packages 2025-09-11 15:23:38 +02:00
Eric Fennis
0780ce75ba Merge branch 'main' into add-aria-hidden-to-all-packages 2025-09-11 15:22:16 +02:00
Eric Fennis
3b1bbb7453 Update packages/shared/src/utils/hasA11yProp.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-11 15:22:07 +02:00
Eric Fennis
0d8868f5c1 Update packages/lucide-svelte/tests/Icon.spec.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-11 15:21:49 +02:00
Eric Fennis
2ab2a5fc13 Update packages/svelte/tests/Icon.spec.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-11 15:21:42 +02:00
Eric Fennis
2f33a7ff1a Format code 2025-09-11 15:21:24 +02:00
Eric Fennis
483d4a6e5d Add notice about aria-label in docs 2025-09-11 15:19:17 +02:00
Eric Fennis
6527cc17b4 Fix builds 2025-09-05 15:18:19 +02:00
Eric Fennis
557b94e825 Merge branch 'main' of https://github.com/lucide-icons/lucide into add-aria-hidden-to-all-packages 2025-09-05 14:21:32 +02:00
Eric Fennis
59e3ddab09 Add aria-hidden prop to angular package 2025-09-05 14:14:19 +02:00
Eric Fennis
e7e61064c0 Add aria-hidden to lucide-vue-next 2025-08-29 15:44:50 +02:00
Eric Fennis
30699b7769 add aria prop to lucide-solid 2025-08-29 11:39:48 +02:00
Eric Fennis
d2f20bb752 Fix test for props 2025-08-18 15:43:53 +02:00
Eric Fennis
43950764dd Add tests for shared package 2025-08-18 11:42:41 +02:00
Eric Fennis
4b5cd54be5 Add aria-hidden to astro package 2025-08-18 10:29:51 +02:00
Eric Fennis
e5ad189845 Add aria hidden to lucide package 2025-08-08 14:18:24 +02:00
77 changed files with 1960 additions and 2495 deletions

2
.gitignore vendored
View File

@@ -20,6 +20,8 @@ packages/**/src/icons/*.tsx
packages/**/src/aliases/*.ts
packages/**/src/aliases.ts
!packages/**/src/aliases/index.ts
packages/**/src/utils/*.ts
!packages/shared/src/utils/*.ts
packages/**/src/dynamicIconImports.ts
packages/**/DynamicIcon.d.ts
packages/**/dynamicIconImports.js

View File

@@ -10,6 +10,11 @@ because they can quickly give information.
However, not everyone can understand them easily. When using icons it is very important to consider
the following aspects of accessibility.
::: info
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can make them accessible yourself by following the guidelines below.
:::
## Provide visible labels
Icons are a helpful tool to improve perception, but they aren't a replacement for text.

View File

@@ -167,3 +167,15 @@ import { coconut } from '@lucide/lab';
})
export class AppModule { }
```
## Accessibility
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can add accessibility attributes using aria-labels.
```html
<lucide-icon name="check" aria-label="Task completed"></lucide-icon>
```
For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md).

View File

@@ -192,3 +192,19 @@ import LucideIcon from './LucideIcon.astro';
<LucideIcon name="Menu" />
```
## Accessibility
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can add accessibility attributes using aria-labels.
```jsx
---
import { Check } from '@lucide/astro';
---
<Check aria-label="Task completed" />
```
For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md).

View File

@@ -131,3 +131,19 @@ const App = () => {
export default App;
```
## Accessibility
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can add accessibility attributes using aria-labels.
```jsx
import { Check } from 'lucide-preact';
const App = () => {
return <Check aria-label="Task completed" />;
};
```
For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md).

View File

@@ -109,3 +109,19 @@ const App = () => (
<DynamicIcon name="camera" color="red" size={48} />
);
```
## Accessibility
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can add accessibility attributes using aria-labels.
```jsx
import { Check } from 'lucide-react';
const App = () => {
return <Check aria-label="Task completed" />;
};
```
For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md).

View File

@@ -144,3 +144,19 @@ const App = () => {
export default App;
```
## Accessibility
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can add accessibility attributes using aria-labels.
```jsx
import { Check } from 'lucide-solid';
const App = () => {
return <Check aria-label="Task completed" />;
};
```
For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md).

View File

@@ -331,3 +331,19 @@ The example below imports all ES Modules, so exercise caution when using it. Imp
<LucideIcon name="Menu" />
```
## Accessibility
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can add accessibility attributes using aria-labels.
```svelte
<script>
import { Check } from '@lucide/svelte';
</script>
<Check aria-label="Task completed" />
```
For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md).

View File

@@ -146,3 +146,25 @@ All other props listed above also work on the `Icon` Component.
</div>
</template>
```
## Accessibility
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can add accessibility attributes using aria-labels.
```vue
<script setup>
import { Check } from 'lucide-vue-next';
</script>
<template>
<Check
color="red"
:size="32"
aria-label="Task completed"
/>
</template>
```
For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md).

View File

@@ -203,3 +203,23 @@ createIcons({
}
});
```
## Accessibility
By default, we hide icons from screen readers using `aria-hidden="true"`.
You can add accessibility attributes using aria-labels.
```html
<!DOCTYPE html>
<body>
<i data-lucide="house" aria-label="Home icon"></i>
<script src="https://unpkg.com/lucide@latest"></script>
<script>
lucide.createIcons();
</script>
</body>
```
For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md).

View File

@@ -39,18 +39,20 @@
}
},
"scripts": {
"build": "pnpm clean && pnpm copy:license && pnpm build:icons",
"build": "pnpm clean && pnpm copy:license && pnpm copy:utils && pnpm build:icons",
"build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --renderUniqueKey --withAliases --aliasesFileExtension=.ts --iconFileExtension=.ts --exportFileName=index.ts --pretty=false",
"clean": "rm -rf dist && rm -rf stats && rm -rf ./src/icons/*.ts",
"copy:license": "cp ../../LICENSE ./LICENSE",
"test": "pnpm build:icons && vitest run",
"copy:utils": "mkdir -p ./src/utils && for f in hasA11yProp toKebabCase mergeClasses; do cp -f ../../packages/shared/src/utils/$f.ts ./src/utils/; done",
"test": "pnpm copy:utils && pnpm build:icons && vitest run",
"test:watch": "pnpm build:icons && vitest run --watch",
"version": "pnpm version --git-tag-version=false"
},
"devDependencies": {
"@astrojs/ts-plugin": "^1.10.4",
"@lucide/build-icons": "workspace:*",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/jest-dom": "^6.8.0",
"jest-serializer-html": "^7.1.0",
"linkedom": "^0.18.5",
"prettier": "^3.4.2",

View File

@@ -1,6 +1,8 @@
---
import defaultAttributes from './defaultAttributes';
import type { IconProps as Props } from './types';
import { hasA11yProp } from './utils/hasA11yProp';
const {
color = 'currentColor',
@@ -20,6 +22,7 @@ const {
height: size,
stroke: color,
'stroke-width': absoluteStrokeWidth ? (Number(strokeWidth) * 24) / Number(size) : strokeWidth,
...(!hasA11yProp(rest) && { 'aria-hidden': 'true' }),
...rest,
}}
class:list={['lucide', className]}

View File

@@ -1,8 +1,9 @@
import { mergeClasses, toKebabCase } from './utils';
import type { AstroComponentFactory } from 'astro/runtime/server/render/astro/factory.js';
import type { IconNode } from './types';
import { render, renderSlot, createComponent, renderComponent } from 'astro/compiler-runtime';
import Icon from './Icon.astro';
import { mergeClasses } from './utils/mergeClasses';
import { toKebabCase } from './utils/toKebabCase';
export default (iconName: string, iconNode: IconNode): AstroComponentFactory => {
const Component = createComponent(

View File

@@ -10,6 +10,7 @@ export interface IconProps extends SVGAttributes {
absoluteStrokeWidth?: boolean;
class?: string;
iconNode?: IconNode;
title?: string;
}
export type SVGAttributes = HTMLAttributes<'svg'>;

View File

@@ -1,7 +1,7 @@
import { describe, it, expect } from 'vitest';
import { airVent } from './testIconNodes';
import { render } from './utils';
import { Icon } from '../src/lucide-astro';
import { createAstroHTMLString, render } from './utils';
import { Icon, Rocket } from '../src/lucide-astro';
describe('Using Icon Component', async () => {
const { container } = await render(Icon, {
@@ -16,3 +16,44 @@ describe('Using Icon Component', async () => {
expect(container.innerHTML).toBeDefined();
});
});
describe('Icon Component Accessibility', () => {
it('should have aria-hidden prop when no aria prop is present', async () => {
const { container } = await render(Icon, {
props: {
iconNode: airVent,
size: 48,
stroke: 'red',
absoluteStrokeWidth: true,
},
});
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
});
it('should not have aria-hidden prop when aria prop is present', async () => {
const { container } = await render(Rocket, {
props: {
size: 48,
stroke: 'red',
absoluteStrokeWidth: true,
'aria-label': 'Release icon',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when title prop is present', async () => {
const { container } = await render(Rocket, {
props: {
size: 48,
stroke: 'red',
absoluteStrokeWidth: true,
title: 'Release icon',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
});

View File

@@ -10,6 +10,7 @@ exports[`Using Icon Component > should render icon and match snapshot 1`] = `
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="lucide"
>
<path d="M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2">

View File

@@ -10,6 +10,7 @@ exports[`Using createLucideIcon > should create a component from an iconNode 1`]
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="lucide lucide-air-vent"
>
<path d="M6 12H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2">

View File

@@ -10,6 +10,7 @@ exports[`Using lucide icon components > should add a non-default attribute to th
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
style="position: absolute"
class="lucide lucide-smile"
>
@@ -45,6 +46,7 @@ exports[`Using lucide icon components > should adjust the size, stroke color and
stroke-width="4"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="lucide lucide-grid-3x3"
>
<rect width="18"
@@ -75,6 +77,7 @@ exports[`Using lucide icon components > should apply all classes to the element
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="lucide lucide-droplet my-icon"
>
<path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z">
@@ -92,6 +95,7 @@ exports[`Using lucide icon components > should not scale the strokeWidth when ab
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="lucide lucide-grid-3x3"
>
<rect width="18"
@@ -122,6 +126,7 @@ exports[`Using lucide icon components > should pass children to the icon slot 1`
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="lucide lucide-smile"
>
<circle cx="12"
@@ -159,6 +164,7 @@ exports[`Using lucide icon components > should render a component 1`] = `
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="lucide lucide-grid-3x3"
>
<rect width="18"
@@ -189,6 +195,7 @@ exports[`Using lucide icon components > should render the icon with default attr
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
class="lucide lucide-grid-3x3"
>
<rect width="18"

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { Pen, Edit2, Grid, Droplet, Smile } from '../src/lucide-astro';
import { Pen, Edit2, Grid, Droplet, Smile, Rocket } from '../src/lucide-astro';
import defaultAttributes from '../src/defaultAttributes';
import { createAstroHTMLString, render } from './utils';

View File

@@ -26,12 +26,13 @@
"Font Awesome"
],
"scripts": {
"build": "pnpm clean && pnpm copy:license && pnpm build:icons && pnpm build:ng",
"build": "pnpm clean && pnpm copy:license && pnpm copy:utils &&pnpm build:icons && pnpm build:ng",
"copy:license": "cp ../../LICENSE ./LICENSE",
"copy:utils": "mkdir -p ./src/utils && cp -rf ../../packages/shared/src/utils/hasA11yProp.ts ./src/utils/",
"clean": "rm -rf dist && rm -rf ./src/icons/*.ts",
"build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --renderUniqueKey --withAliases --aliasesFileExtension=.ts --iconFileExtension=.ts --exportFileName=lucide-icons.ts",
"build:ng": "ng build --configuration production",
"test": "ng test --no-watch --no-progress --browsers=ChromeHeadlessCI",
"test": "pnpm copy:utils && ng test --no-watch --no-progress --browsers=ChromeHeadlessCI",
"test:watch": "ng test",
"lint": "npx eslint 'src/**/*.{js,jsx,ts,tsx,html,css,scss}' --quiet --fix",
"e2e": "ng e2e",

View File

@@ -8,7 +8,7 @@ import { LucideIcons } from '../icons/types';
describe('LucideAngularComponent', () => {
let testHostComponent: TestHostComponent;
let testHostFixture: ComponentFixture<TestHostComponent>;
const getSvgAttribute = (attr: string) =>
const getAttribute = (attr: string) =>
testHostFixture.nativeElement.querySelector('svg').getAttribute(attr);
const testIcons: LucideIcons = {
Demo: [['polyline', { points: '1 1 22 22' }]],
@@ -31,28 +31,28 @@ describe('LucideAngularComponent', () => {
it('should add all classes', () => {
testHostFixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-demo my-icon');
expect(getAttribute('class')).toBe('lucide lucide-demo my-icon');
});
it('should set color', () => {
const color = 'red';
testHostComponent.setColor(color);
testHostFixture.detectChanges();
expect(getSvgAttribute('stroke')).toBe(color);
expect(getAttribute('stroke')).toBe(color);
});
it('should set size', () => {
const size = 12;
testHostComponent.setSize(size);
testHostFixture.detectChanges();
expect(getSvgAttribute('width')).toBe(size.toString(10));
expect(getAttribute('width')).toBe(size.toString(10));
});
it('should set stroke width', () => {
const strokeWidth = 1.41;
testHostComponent.setStrokeWidth(strokeWidth);
testHostFixture.detectChanges();
expect(getSvgAttribute('stroke-width')).toBe(strokeWidth.toString(10));
expect(getAttribute('stroke-width')).toBe(strokeWidth.toString(10));
});
it('should adjust stroke width', () => {
@@ -62,27 +62,61 @@ describe('LucideAngularComponent', () => {
testHostComponent.setSize(12);
testHostComponent.setAbsoluteStrokeWidth(true);
testHostFixture.detectChanges();
expect(getSvgAttribute('stroke-width')).toBe(
expect(getAttribute('stroke-width')).toBe(
formatFixed(strokeWidth / (size / defaultAttributes.height)),
);
});
it('should have aria-hidden prop when no aria prop is present', async () => {
testHostFixture.detectChanges();
expect(getAttribute('aria-hidden')).toBe('true');
});
it('should not have aria-hidden prop when aria prop is present', async () => {
const ariaLabel = 'Demo icon';
testHostComponent.setAriaLabel(ariaLabel);
testHostFixture.detectChanges();
expect(getAttribute('aria-label')).toBe(ariaLabel);
expect(getAttribute('aria-hidden')).toBeNull();
});
it('should not have aria-hidden prop when title prop is present', async () => {
const ariaLabel = 'Demo icon';
testHostComponent.setTitle(ariaLabel);
testHostFixture.detectChanges();
expect(getAttribute('title')).toBe(ariaLabel);
expect(getAttribute('aria-hidden')).toBeNull();
});
it('should never override aria-hidden prop', async () => {
testHostComponent.setAriaHidden(true);
testHostFixture.detectChanges();
expect(getAttribute('aria-hidden')).toBe('true');
});
@Component({
selector: 'lucide-spec-host-component',
template: ` <i-lucide
template: `<i-lucide
name="demo"
class="my-icon"
[color]="color"
[size]="size"
[strokeWidth]="strokeWidth"
[absoluteStrokeWidth]="absoluteStrokeWidth"
></i-lucide>`,
[attr.aria-label]="ariaLabel"
[attr.title]="title"
[attr.aria-hidden]="ariaHidden"
>
</i-lucide>`,
})
class TestHostComponent {
color?: string;
size?: number;
strokeWidth?: number;
absoluteStrokeWidth = true;
ariaLabel?: string;
title?: string;
ariaHidden?: boolean;
setColor(color: string): void {
this.color = color;
@@ -99,5 +133,17 @@ describe('LucideAngularComponent', () => {
setAbsoluteStrokeWidth(absoluteStrokeWidth: boolean): void {
this.absoluteStrokeWidth = absoluteStrokeWidth;
}
setAriaLabel(label: string): void {
this.ariaLabel = label;
}
setTitle(title: string): void {
this.title = title;
}
setAriaHidden(ariaHidden: boolean): void {
this.ariaHidden = ariaHidden;
}
}
});

View File

@@ -12,6 +12,7 @@ import { LucideIconData } from '../icons/types';
import defaultAttributes from '../icons/constants/default-attributes';
import { LUCIDE_ICONS, LucideIconProviderInterface } from './lucide-icon.provider';
import { LucideIconConfig } from './lucide-icon.config';
import { hasA11yProp } from '../utils/hasA11yProp';
interface TypedChange<T> extends SimpleChange {
previousValue: T;
@@ -51,7 +52,7 @@ export class LucideAngularComponent implements OnChanges {
@Inject(Renderer2) private renderer: Renderer2,
@Inject(ChangeDetectorRef) private changeDetector: ChangeDetectorRef,
@Inject(LUCIDE_ICONS) private iconProviders: LucideIconProviderInterface[],
@Inject(LucideIconConfig) private iconConfig: LucideIconConfig,
@Inject(LucideIconConfig) private iconConfig: LucideIconConfig
) {
this.defaultSize = defaultAttributes.height;
}
@@ -105,7 +106,7 @@ export class LucideAngularComponent implements OnChanges {
this.replaceElement(icoOfName);
} else {
throw new Error(
`The "${nameOrIcon}" icon has not been provided by any available icon providers.`,
`The "${nameOrIcon}" icon has not been provided by any available icon providers.`
);
}
} else if (Array.isArray(nameOrIcon)) {
@@ -119,11 +120,19 @@ export class LucideAngularComponent implements OnChanges {
}
replaceElement(img: LucideIconData): void {
const childElements = this.elem.nativeElement.childNodes;
const restAttributeMap: NamedNodeMap = this.elem.nativeElement.attributes;
const restAttributes = Object.fromEntries(
Array.from(restAttributeMap).map((item) => [item.name, item.value])
);
const hasChildren = childElements.length > 0;
const attributes = {
...defaultAttributes,
width: this.size,
height: this.size,
stroke: this.color ?? this.iconConfig.color,
...(!hasChildren && !hasA11yProp(restAttributes) && { 'aria-hidden': 'true' }),
'stroke-width': this.absoluteStrokeWidth
? formatFixed(this.strokeWidth / (this.size / this.defaultSize))
: this.strokeWidth.toString(10),
@@ -133,15 +142,23 @@ export class LucideAngularComponent implements OnChanges {
if (typeof this.name === 'string') {
icoElement.classList.add(`lucide-${this.name.replace('_', '-')}`);
}
// Forward aria-* and role from host to svg
Object.entries(restAttributes).forEach(([attr, value]) => {
if (attr.startsWith('aria-') || attr === 'role' || attr === 'title') {
this.renderer.setAttribute(icoElement, attr, value as string);
}
});
if (this.class) {
icoElement.classList.add(
...this.class
.split(/ /)
.map((a) => a.trim())
.filter((a) => a.length > 0),
.filter((a) => a.length > 0)
);
}
const childElements = this.elem.nativeElement.childNodes;
for (const child of childElements) {
this.renderer.removeChild(this.elem.nativeElement, child);
}
@@ -151,7 +168,7 @@ export class LucideAngularComponent implements OnChanges {
toPascalCase(str: string): string {
return str.replace(
/(\w)([a-z0-9]*)(_|-|\s*)/g,
(g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase(),
(g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase()
);
}
@@ -180,7 +197,7 @@ export class LucideAngularComponent implements OnChanges {
private createElement([tag, attrs, children = []]: readonly [
string,
SvgAttributes,
LucideIconData?,
LucideIconData?
]) {
const element = this.renderer.createElement(tag, 'http://www.w3.org/2000/svg');

View File

@@ -46,7 +46,7 @@
"@lucide/rollup-plugins": "workspace:*",
"@lucide/shared": "workspace:*",
"@preact/preset-vite": "^2.7.0",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/preact": "^3.2.3",
"jest-serializer-html": "^7.1.0",
"preact": "^10.19.2",

View File

@@ -1,6 +1,7 @@
import { h, toChildArray } from 'preact';
import defaultAttributes from './defaultAttributes';
import type { IconNode, LucideProps } from './types';
import { hasA11yProp } from '@lucide/shared';
interface IconComponentProps extends LucideProps {
iconNode: IconNode;
@@ -42,6 +43,7 @@ const Icon = ({
? (Number(strokeWidth) * 24) / Number(size)
: strokeWidth,
class: ['lucide', classes].join(' '),
...(!children && !hasA11yProp(rest) && { 'aria-hidden': 'true' }),
...rest,
},
[...iconNode.map(([tag, attrs]) => h(tag, attrs)), ...toChildArray(children)],

View File

@@ -31,3 +31,62 @@ describe('Using Icon Component', () => {
expect(container.firstChild).toMatchSnapshot();
});
});
describe('Icon Component Accessibility', () => {
it('should not have aria-hidden prop when aria prop is present', async () => {
const { container } = render(
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
aria-label="Air conditioning"
/>,
);
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when title prop is present', async () => {
const { container } = render(
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
title="Air conditioning"
/>,
);
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when there are children that could be a <title> element', async () => {
const { container } = render(
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
>
<title>Some title</title>
</Icon>,
);
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should never override aria-hidden prop', async () => {
const { container } = render(
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
aria-hidden={false}
/>,
);
expect(container.firstChild).toHaveAttribute('aria-hidden', 'false');
});
});

View File

@@ -2,6 +2,7 @@
exports[`Using Icon Component > should render icon and match snapshot 1`] = `
<svg
aria-hidden="true"
class="lucide "
fill="none"
height="48"

View File

@@ -2,6 +2,7 @@
exports[`Using createLucideIcon > should create a component from an iconNode 1`] = `
<svg
aria-hidden="true"
class="lucide lucide-air-vent"
fill="none"
height="24"
@@ -30,6 +31,7 @@ exports[`Using createLucideIcon > should create a component from an iconNode 1`]
exports[`Using createLucideIcon > should create a component from an iconNode with iconName 1`] = `
<svg
aria-hidden="true"
class="lucide lucide-air-vent"
fill="none"
height="24"
@@ -58,6 +60,7 @@ exports[`Using createLucideIcon > should create a component from an iconNode wit
exports[`Using createLucideIcon > should include backwards compatible className 1`] = `
<svg
aria-hidden="true"
class="lucide lucide-layout2 lucide-layout-2"
fill="none"
height="24"

View File

@@ -11,6 +11,7 @@ exports[`Using lucide icon components > should adjust the size, stroke color and
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-grid3x3 lucide-grid-3x3"
aria-hidden="true"
>
<rect width="18"
height="18"
@@ -41,6 +42,7 @@ exports[`Using lucide icon components > should not scale the strokeWidth when ab
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-grid3x3 lucide-grid-3x3"
aria-hidden="true"
>
<rect width="18"
height="18"
@@ -71,6 +73,7 @@ exports[`Using lucide icon components > should render an component 1`] = `
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-grid3x3 lucide-grid-3x3"
aria-hidden="true"
>
<rect width="18"
height="18"

View File

@@ -46,7 +46,7 @@
"@lucide/rollup-plugins": "workspace:*",
"@lucide/build-icons": "workspace:*",
"@lucide/shared": "workspace:*",
"@testing-library/jest-dom": "^6.1.6",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^14.1.2",
"@types/prop-types": "^15.7.5",
"@types/react": "^18.0.21",

View File

@@ -54,7 +54,7 @@
"@lucide/build-icons": "workspace:*",
"@lucide/rollup-plugins": "workspace:*",
"@lucide/shared": "workspace:*",
"@testing-library/jest-dom": "^6.1.6",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^14.1.2",
"@types/react": "^18.2.37",
"@vitejs/plugin-react": "^4.4.1",

View File

@@ -33,6 +33,19 @@ describe('Using Icon Component', () => {
});
describe('Icon Component Accessibility', () => {
it('should have aria-hidden prop when no aria prop is present', async () => {
const { container } = render(
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
/>,
);
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
});
it('should not have aria-hidden prop when aria prop is present', async () => {
const { container } = render(
<Icon

View File

@@ -22,5 +22,5 @@
"types": ["@testing-library/jest-dom"],
},
"exclude": ["**/node_modules"],
"include": ["src"],
"include": ["src", "tests"],
}

View File

@@ -63,6 +63,7 @@
"build:bundle": "rollup -c rollup.config.mjs",
"build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --renderUniqueKey --withAliases --separateAliasesFile --separateAliasesFileIgnore=fingerprint --aliasesFileExtension=.ts --iconFileExtension=.tsx --exportFileName=index.ts",
"test": "pnpm build:icons && vitest run",
"test:watch": "vitest watch",
"version": "pnpm version --git-tag-version=false"
},
"devDependencies": {
@@ -74,7 +75,7 @@
"@lucide/shared": "workspace:*",
"@rollup/plugin-babel": "^6.0.4",
"@solidjs/testing-library": "^0.8.10",
"@testing-library/jest-dom": "^6.4.2",
"@testing-library/jest-dom": "^6.8.0",
"babel-preset-solid": "^1.8.12",
"jest-serializer-html": "^7.1.0",
"rollup": "^4.22.4",

View File

@@ -2,7 +2,7 @@ import { For, splitProps } from 'solid-js';
import { Dynamic } from 'solid-js/web';
import defaultAttributes from './defaultAttributes';
import { IconNode, LucideProps } from './types';
import { mergeClasses, toKebabCase, toPascalCase } from '@lucide/shared';
import { hasA11yProp, mergeClasses, toKebabCase, toPascalCase } from '@lucide/shared';
interface IconProps {
name?: string;
@@ -44,6 +44,7 @@ const Icon = (props: LucideProps & IconProps) => {
: []),
localProps.class != null ? localProps.class : '',
)}
aria-hidden={!localProps.children && !hasA11yProp(rest) ? 'true' : undefined}
{...rest}
>
<For each={localProps.iconNode}>

View File

@@ -31,3 +31,76 @@ describe('Using Icon Component', () => {
expect(container.firstChild).toMatchSnapshot();
});
});
describe('Icon Component Accessibility', () => {
it('should have aria-hidden prop when no aria prop is present', async () => {
const { container } = render(() => (
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
/>
));
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
});
it('should not have aria-hidden prop when aria prop is present', async () => {
const { container } = render(() => (
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
aria-label="Air conditioning"
/>
));
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when title prop is present', async () => {
const { container } = render(() => (
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
// @ts-expect-error
title="Air conditioning"
/>
));
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when there are children that could be a <title> element', async () => {
const { container } = render(() => (
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
>
<title>Some title</title>
</Icon>
));
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should never override aria-hidden prop', async () => {
const { container } = render(() => (
<Icon
iconNode={airVent}
size={48}
stroke="red"
absoluteStrokeWidth
aria-hidden={false}
/>
));
expect(container.firstChild).toHaveAttribute('aria-hidden', 'false');
});
});

View File

@@ -2,6 +2,7 @@
exports[`Using Icon Component > should render icon and match snapshot 1`] = `
<svg
aria-hidden="true"
class="lucide lucide-icon"
fill="none"
height="48"

View File

@@ -11,6 +11,7 @@ exports[`Using lucide icon components > should adjust the size, stroke color and
stroke="red"
stroke-width="4"
class="lucide lucide-icon lucide-grid3x3 lucide-grid-3x3"
aria-hidden="true"
data-testid="grid-icon"
>
<rect width="18"
@@ -51,6 +52,7 @@ exports[`Using lucide icon components > should not scale the strokeWidth when ab
stroke="red"
stroke-width="1"
class="lucide lucide-icon lucide-grid3x3 lucide-grid-3x3"
aria-hidden="true"
data-testid="grid-icon"
>
<rect width="18"
@@ -91,6 +93,7 @@ exports[`Using lucide icon components > should render a component 1`] = `
stroke="currentColor"
stroke-width="2"
class="lucide lucide-icon lucide-grid3x3 lucide-grid-3x3"
aria-hidden="true"
>
<rect width="18"
height="18"

View File

@@ -46,13 +46,14 @@
"dist"
],
"scripts": {
"build": "pnpm clean && pnpm copy:license && pnpm build:icons && pnpm build:package && pnpm build:license",
"build": "pnpm clean && pnpm copy:license && pnpm copy:utils && pnpm build:icons && pnpm build:package && pnpm build:license",
"copy:license": "cp ../../LICENSE ./LICENSE",
"copy:utils": "mkdir -p ./src/utils && for f in hasA11yProp mergeClasses; do cp -f ../../packages/shared/src/utils/$f.ts ./src/utils/; done",
"clean": "rm -rf dist && rm -rf stats && rm -rf ./src/icons/*.svelte && rm -f index.js",
"build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --exportFileName=index.ts --iconFileExtension=.svelte --importImportFileExtension=.svelte --separateIconFileExport --separateIconFileExportExtension=.ts --withAliases --aliasesFileExtension=.ts --separateAliasesFile --separateAliasesFileExtension=.ts --aliasImportFileExtension=.js --pretty=false",
"build:package": "svelte-package --input ./src",
"build:license": "node ./scripts/appendBlockComments.mts",
"test": "pnpm copy:license && pnpm build:icons && vitest run",
"test": "pnpm copy:license && pnpm copy:utils && pnpm build:icons && vitest run",
"test:watch": "vitest watch",
"version": "pnpm version --git-tag-version=false"
},
@@ -61,7 +62,7 @@
"@lucide/helpers": "workspace:*",
"@sveltejs/package": "^2.2.3",
"@sveltejs/vite-plugin-svelte": "^2.4.2",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/svelte": "^4.0.2",
"@tsconfig/svelte": "^5.0.0",
"jest-serializer-html": "^7.1.0",

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import defaultAttributes from './defaultAttributes'
import type { IconNode } from './types';
import { hasA11yProp } from './utils/hasA11yProp';
import { mergeClasses } from './utils/mergeClasses';
export let name: string | undefined = undefined
export let color = 'currentColor'
@@ -8,18 +10,11 @@
export let strokeWidth: number | string = 2
export let absoluteStrokeWidth: boolean = false
export let iconNode: IconNode = []
const mergeClasses = <ClassType = string | undefined | null>(
...classes: ClassType[]
) => classes.filter((className, index, array) => {
return Boolean(className) && array.indexOf(className) === index;
})
.join(' ');
</script>
<svg
{...defaultAttributes}
{...(!hasA11yProp($$restProps) ? {'aria-hidden': 'true'} : undefined)}
{...$$restProps}
width={size}
height={size}

View File

@@ -31,3 +31,60 @@ describe('Using Icon Component', () => {
expect(container.firstChild).toMatchSnapshot();
});
});
describe('Icon Component Accessibility', () => {
it('should have aria-hidden prop when no aria prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
},
});
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
});
it('should not have aria-hidden prop when aria prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
'aria-label': 'Air conditioning',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when title prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
title: 'Air conditioning',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should never override aria-hidden prop', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
'aria-hidden': 'false',
},
});
expect(container.firstChild).toHaveAttribute('aria-hidden', 'false');
});
});

View File

@@ -2,6 +2,7 @@
exports[`Using Icon Component > should render icon and match snapshot 1`] = `
<svg
aria-hidden="true"
class="lucide-icon lucide"
fill="none"
height="48"

View File

@@ -2,6 +2,7 @@
exports[`Using lucide icon components > should add a class to the element 1`] = `
<svg
aria-hidden="true"
class="lucide-icon lucide lucide-smile my-icon"
fill="none"
height="24"
@@ -44,6 +45,7 @@ exports[`Using lucide icon components > should add a class to the element 1`] =
exports[`Using lucide icon components > should adjust the size, stroke color and stroke width 1`] = `
<div>
<svg
aria-hidden="true"
class="lucide-icon lucide lucide-smile"
fill="none"
height="48"
@@ -94,6 +96,7 @@ exports[`Using lucide icon components > should not scale the strokeWidth when ab
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
data-testid="smile-icon"
class="lucide-icon lucide lucide-smile"
>
@@ -122,6 +125,7 @@ exports[`Using lucide icon components > should not scale the strokeWidth when ab
exports[`Using lucide icon components > should render an component 1`] = `
<div>
<svg
aria-hidden="true"
class="lucide-icon lucide lucide-smile"
fill="none"
height="24"
@@ -165,6 +169,7 @@ exports[`Using lucide icon components > should render an component 1`] = `
exports[`Using lucide icon components > should render an icon slot 1`] = `
<div>
<svg
aria-hidden="true"
class="lucide-icon lucide lucide-smile"
fill="none"
height="24"

View File

@@ -48,15 +48,15 @@
"@lucide/build-icons": "workspace:*",
"@lucide/rollup-plugins": "workspace:*",
"@lucide/shared": "workspace:*",
"@testing-library/jest-dom": "^6.1.6",
"@testing-library/vue": "^8.0.3",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/vue": "^8.1.0",
"@vitejs/plugin-vue": "^4.6.2",
"@vue/test-utils": "2.4.5",
"rollup": "^4.22.4",
"rollup-plugin-dts": "^6.1.0",
"vite": "^6.3.6",
"vitest": "^3.1.3",
"vue": "^3.4.21"
"vite": "^6.3.4",
"vitest": "^3.2.4",
"vue": "^3.5.20"
},
"peerDependencies": {
"vue": ">=3.0.1"

View File

@@ -1,5 +1,11 @@
import { type FunctionalComponent, h } from 'vue';
import { mergeClasses, toKebabCase, toPascalCase, isEmptyString } from '@lucide/shared';
import {
mergeClasses,
toKebabCase,
toPascalCase,
isEmptyString,
hasA11yProp,
} from '@lucide/shared';
import defaultAttributes from './defaultAttributes';
import { IconNode, LucideProps } from './types';
@@ -46,6 +52,7 @@ const Icon: FunctionalComponent<LucideProps & IconProps> = (
? [`lucide-${toKebabCase(toPascalCase(name))}-icon`, `lucide-${toKebabCase(name)}`]
: ['lucide-icon']),
),
...(!slots.default && !hasA11yProp(props) && { 'aria-hidden': 'true' }),
},
[...iconNode.map((child) => h(...child)), ...(slots.default ? [slots.default()] : [])],
);

View File

@@ -31,3 +31,80 @@ describe('Using Icon Component', () => {
expect(container.firstChild).toMatchSnapshot();
});
});
describe('Icon Component Accessibility', () => {
it('should have aria-hidden prop when no aria prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
},
});
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
});
it('should not have aria-hidden prop when aria prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
'aria-label': 'Air conditioning',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when title prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
title: 'Air conditioning',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when there are children that could be a <title> element', async () => {
const template = {
name: 'Stub',
template: `<title>Some title</title>`,
};
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
},
slots: {
default: template,
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should never override aria-hidden prop', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
'aria-hidden': false,
},
});
expect(container.firstChild).toHaveAttribute('aria-hidden', 'false');
});
});

View File

@@ -2,6 +2,7 @@
exports[`Using Icon Component > should render icon and match snapshot 1`] = `
<svg
aria-hidden="true"
class="lucide lucide-icon"
fill="none"
height="48"

View File

@@ -3,6 +3,7 @@
exports[`Using lucide icon components > should add a class to the element 1`] = `
<div>
<svg
aria-hidden="true"
class="lucide my-icon lucide-smile-icon lucide-smile my-icon"
fill="none"
height="24"
@@ -41,6 +42,7 @@ exports[`Using lucide icon components > should add a class to the element 1`] =
exports[`Using lucide icon components > should add a style attribute to the element 1`] = `
<div>
<svg
aria-hidden="true"
class="lucide lucide-smile-icon lucide-smile"
fill="none"
height="24"
@@ -80,6 +82,7 @@ exports[`Using lucide icon components > should add a style attribute to the elem
exports[`Using lucide icon components > should adjust the size, stroke color and stroke width 1`] = `
<div>
<svg
aria-hidden="true"
class="lucide lucide-smile-icon lucide-smile"
fill="none"
height="48"
@@ -161,6 +164,7 @@ exports[`Using lucide icon components > should pass children to the icon slot 1`
exports[`Using lucide icon components > should render an component 1`] = `
<div>
<svg
aria-hidden="true"
class="lucide lucide-smile-icon lucide-smile"
fill="none"
height="24"

View File

@@ -46,7 +46,7 @@
"@lucide/build-icons": "workspace:*",
"@lucide/rollup-plugins": "workspace:*",
"@lucide/shared": "workspace:*",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/vue": "^5.9.0",
"@vitejs/plugin-vue2": "2.2.0",
"@vue/test-utils": "1.3.0",

View File

@@ -44,9 +44,10 @@
},
"devDependencies": {
"@lucide/build-icons": "workspace:*",
"@lucide/shared": "workspace:*",
"@lucide/rollup-plugins": "workspace:*",
"@rollup/plugin-replace": "^6.0.2",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/jest-dom": "^6.8.0",
"jest-serializer-html": "^7.1.0",
"rollup": "^4.40.0",
"rollup-plugin-dts": "^6.2.1",

View File

@@ -1,6 +1,7 @@
import createElement from './createElement';
import defaultAttributes from './defaultAttributes';
import { Icons, SVGProps } from './types';
import { hasA11yProp, mergeClasses, toPascalCase } from '@lucide/shared';
export type CustomAttrs = { [attr: string]: any };
@@ -34,26 +35,6 @@ export const getClassNames = (
return '';
};
/**
* Combines the classNames of array of classNames to a String
* @param {array} arrayOfClassnames
* @returns {string}
*/
export const combineClassNames = (
arrayOfClassnames: (string | Record<string, number | string | string[]>)[],
) => {
const classNameArray = arrayOfClassnames.flatMap(getClassNames);
return classNameArray
.map((classItem) => classItem.trim())
.filter(Boolean)
.filter((value, index, self) => self.indexOf(value) === index)
.join(' ');
};
const toPascalCase = (string: string): string =>
string.replace(/(\w)(\w*)(_|-|\s*)/g, (g0, g1, g2) => g1.toUpperCase() + g2.toLowerCase());
interface ReplaceElementOptions {
nameAttr: string;
icons: Icons;
@@ -83,14 +64,25 @@ const replaceElement = (element: Element, { nameAttr, icons, attrs }: ReplaceEle
const elementAttrs = getAttrs(element);
const ariaProps = hasA11yProp(elementAttrs) ? {} : { 'aria-hidden': 'true' };
const iconAttrs = {
...defaultAttributes,
'data-lucide': iconName,
...ariaProps,
...attrs,
...elementAttrs,
};
} satisfies SVGProps;
const classNames = combineClassNames(['lucide', `lucide-${iconName}`, elementAttrs, attrs]);
const elementClassNames = getClassNames(elementAttrs);
const className = getClassNames(attrs);
const classNames = mergeClasses(
'lucide',
`lucide-${iconName}`,
...elementClassNames,
...className,
);
if (classNames) {
Object.assign(iconAttrs, {

View File

@@ -1,5 +1,5 @@
// className is not supported in svg elements
export type SVGProps = Record<string, string | number>;
export type SVGProps = Record<string, string | number | undefined>;
export type IconNode = [tag: string, attrs: SVGProps][];
export type Icons = { [key: string]: IconNode };

View File

@@ -11,6 +11,7 @@ exports[`createIcons > should add custom attributes 1`] = `
stroke-linecap="round"
stroke-linejoin="round"
data-lucide="volume-2"
aria-hidden="true"
class="lucide lucide-volume-2 icon custom-class"
>
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z">
@@ -33,6 +34,7 @@ exports[`createIcons > should read elements from DOM and replace icon with alias
stroke-linecap="round"
stroke-linejoin="round"
data-lucide="grid"
aria-hidden="true"
class="lucide lucide-grid"
>
<rect width="18"
@@ -64,6 +66,7 @@ exports[`createIcons > should read elements from DOM and replace it with icons 1
stroke-linecap="round"
stroke-linejoin="round"
data-lucide="volume-2"
aria-hidden="true"
class="lucide lucide-volume-2"
>
<path d="M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z">

View File

@@ -1,3 +0,0 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`combineClassNames > should returns a string of classNames 1`] = `"item item1 item2 item3 item4 item5 item6 item7 item8 item9"`;

View File

@@ -10,6 +10,7 @@ export const getOriginalSvg = (iconName: string, aliasName?: string, setAttrs =
if (setAttrs) {
svgParsed.attributes['data-lucide'] = aliasName ?? iconName;
svgParsed.attributes['aria-hidden'] = 'true';
svgParsed.attributes['class'] = `lucide lucide-${aliasName ?? iconName}`;
}

View File

@@ -54,20 +54,12 @@ describe('createIcons', () => {
createIcons({ icons, attrs });
const element = document.querySelector('svg') as SVGSVGElement;
const attributes = element.getAttributeNames();
const attributesAndValues = attributes.reduce(
(acc, item) => {
acc[item] = element.getAttribute(item);
return acc;
},
{} as Record<string, string | null>,
);
expect(document.body.innerHTML).toMatchSnapshot();
expect(attributesAndValues).toEqual(expect.objectContaining(attrs));
for (const [name, value] of Object.entries(attrs)) {
expect(element).toHaveAttribute(name, value);
}
});
it('should inherit elements attributes', () => {
@@ -75,23 +67,16 @@ describe('createIcons', () => {
const attrs = {
'data-theme-switcher': 'light',
'aria-hidden': 'true',
};
createIcons({ icons });
const element = document.querySelector('svg') as SVGSVGElement;
const attributes = element.getAttributeNames();
const attributesAndValues = attributes.reduce(
(acc, item) => {
acc[item] = element.getAttribute(item);
return acc;
},
{} as Record<string, string | null>,
);
expect(attributesAndValues).toEqual(expect.objectContaining(attrs));
for (const [name, value] of Object.entries(attrs)) {
expect(element).toHaveAttribute(name, value);
}
});
it('should read elements from DOM and replace icon with alias name', () => {
@@ -105,6 +90,27 @@ describe('createIcons', () => {
expect(document.body.innerHTML).toMatchSnapshot();
});
it('should add aria-hidden attribute when no a11y props are present', () => {
document.body.innerHTML = `<i data-lucide="volume-2" class="lucide"></i>`;
createIcons({ icons });
const element = document.querySelector('svg') as SVGSVGElement;
expect(element).toHaveAttribute('aria-hidden', 'true');
});
it('should not add aria-hidden attribute when a11y props are present', () => {
document.body.innerHTML = `<i data-lucide="volume-2" class="lucide" aria-label="Volume"></i>`;
createIcons({ icons });
const element = document.querySelector('svg') as SVGSVGElement;
expect(element).not.toHaveAttribute('aria-hidden');
expect(element).toHaveAttribute('aria-label', 'Volume');
});
it('should not replace icons inside template elements by default', () => {
document.body.innerHTML = `<template><i data-lucide="house"></i></template>`;

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { getAttrs, getClassNames, combineClassNames } from '../src/replaceElement';
import { getAttrs, getClassNames } from '../src/replaceElement';
describe('getAtts', () => {
it('should returns attrbrutes of an element', () => {
@@ -41,24 +41,3 @@ describe('getClassNames', () => {
expect(JSON.stringify(attrs)).toBe(JSON.stringify(['item1', 'item2', 'item3']));
});
});
describe('combineClassNames', () => {
it('should returns a string of classNames', () => {
const arrayOfClassnames: (string | Record<string, string[]>)[] = [
'item',
{
class: ['item1', 'item2', 'item3'],
},
{
class: ['item4', 'item5', 'item6'],
},
{
class: ['item7', 'item8', 'item9'],
},
];
const combinedClassNames = combineClassNames(arrayOfClassnames);
expect(combinedClassNames).toMatchSnapshot();
});
});

View File

@@ -1,2 +1,7 @@
export * from './utils';
export * from './utils/hasA11yProp';
export * from './utils/isEmptyString';
export * from './utils/mergeClasses';
export * from './utils/toCamelCase';
export * from './utils/toKebabCase';
export * from './utils/toPascalCase';
export * from './utility-types';

View File

@@ -1,73 +0,0 @@
import { CamelToPascal } from './utility-types';
/**
* Converts string to kebab case
*
* @param {string} string
* @returns {string} A kebabized string
*/
export const toKebabCase = (string: string) =>
string.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
/**
* Converts string to camel case
*
* @param {string} string
* @returns {string} A camelized string
*/
export const toCamelCase = <T extends string>(string: T) =>
string.replace(/^([A-Z])|[\s-_]+(\w)/g, (match, p1, p2) =>
p2 ? p2.toUpperCase() : p1.toLowerCase(),
);
/**
* Converts string to pascal case
*
* @param {string} string
* @returns {string} A pascalized string
*/
export const toPascalCase = <T extends string>(string: T): CamelToPascal<T> => {
const camelCase = toCamelCase(string);
return (camelCase.charAt(0).toUpperCase() + camelCase.slice(1)) as CamelToPascal<T>;
};
/**
* Merges classes into a single string
*
* @param {array} classes
* @returns {string} A string of classes
*/
export const mergeClasses = <ClassType = string | undefined | null>(...classes: ClassType[]) =>
classes
.filter((className, index, array) => {
return (
Boolean(className) &&
(className as string).trim() !== '' &&
array.indexOf(className) === index
);
})
.join(' ')
.trim();
/**
* Is empty string
*
* @param {unknown} value
* @returns {boolean} Whether the value is an empty string
*/
export const isEmptyString = (value: unknown): boolean => value === '';
/**
* Check if a component has an accessibility prop
*
* @param {object} props
* @returns {boolean} Whether the component has an accessibility prop
*/
export const hasA11yProp = (props: Record<string, any>) => {
for (const prop in props) {
if (prop.startsWith('aria-') || prop === 'role' || prop === 'title') {
return true;
}
}
};

View File

@@ -0,0 +1,15 @@
/**
* Check if a component has an accessibility prop
*
* @param {object} props
* @returns {boolean} Whether the component has an accessibility prop
*/
export const hasA11yProp = (props: Record<string, any>) => {
for (const prop in props) {
if (prop.startsWith('aria-') || prop === 'role' || prop === 'title') {
return true;
}
}
return false;
};

View File

@@ -0,0 +1,7 @@
/**
* Is empty string
*
* @param {unknown} value
* @returns {boolean} Whether the value is an empty string
*/
export const isEmptyString = (value: unknown): boolean => value === '';

View File

@@ -1,21 +1,10 @@
/**
* Converts string to kebab case
*
* @param {string} string
* @returns {string} A kebabized string
*/
export const toKebabCase = (string: string): string =>
string.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
/**
* Merges classes into a single string
*
* @param {array} classes
* @returns {string} A string of classes
*/
export const mergeClasses = <ClassType = string | undefined | null>(
...classes: ClassType[]
): string =>
export const mergeClasses = <ClassType = string | undefined | null>(...classes: ClassType[]) =>
classes
.filter((className, index, array) => {
return (

View File

@@ -0,0 +1,10 @@
/**
* Converts string to camel case
*
* @param {string} string
* @returns {string} A camelized string
*/
export const toCamelCase = <T extends string>(string: T) =>
string.replace(/^([A-Z])|[\s-_]+(\w)/g, (match, p1, p2) =>
p2 ? p2.toUpperCase() : p1.toLowerCase(),
);

View File

@@ -0,0 +1,8 @@
/**
* Converts string to kebab case
*
* @param {string} string
* @returns {string} A kebabized string
*/
export const toKebabCase = (string: string) =>
string.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();

View File

@@ -0,0 +1,14 @@
import { CamelToPascal } from '../utility-types';
import { toCamelCase } from './toCamelCase';
/**
* Converts string to pascal case
*
* @param {string} string
* @returns {string} A pascalized string
*/
export const toPascalCase = <T extends string>(string: T): CamelToPascal<T> => {
const camelCase = toCamelCase(string);
return (camelCase.charAt(0).toUpperCase() + camelCase.slice(1)) as CamelToPascal<T>;
};

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { hasA11yProp } from '../src/utils/hasA11yProp';
describe('hasA11yProp', () => {
it('returns true if props contain an aria- attribute', () => {
expect(hasA11yProp({ 'aria-label': 'Close' })).toBe(true);
expect(hasA11yProp({ 'aria-hidden': true })).toBe(true);
});
it('returns true if props contain a role attribute', () => {
expect(hasA11yProp({ role: 'button' })).toBe(true);
});
it('returns true if props contain a title attribute', () => {
expect(hasA11yProp({ title: 'Icon' })).toBe(true);
});
it('returns false if props do not contain any a11y attributes', () => {
expect(hasA11yProp({ className: 'foo', id: 'bar' })).toBe(false);
expect(hasA11yProp({})).toBe(false);
});
it('returns true if multiple a11y props are present', () => {
expect(hasA11yProp({ 'aria-label': 'Close', role: 'button', title: 'Close icon' })).toBe(true);
});
it('returns true if a11y prop is present among other props', () => {
expect(hasA11yProp({ className: 'foo', 'aria-label': 'Close' })).toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest';
import { isEmptyString } from '../src/utils/isEmptyString';
describe('isEmptyString', () => {
it('should return true for an empty string', () => {
expect(isEmptyString('')).toBe(true);
});
it('should return false for a non-empty string', () => {
expect(isEmptyString('hello')).toBe(false);
expect(isEmptyString(' ')).toBe(false);
expect(isEmptyString('0')).toBe(false);
});
it('should return false for null', () => {
expect(isEmptyString(null)).toBe(false);
});
it('should return false for undefined', () => {
expect(isEmptyString(undefined)).toBe(false);
});
it('should return false for numbers', () => {
expect(isEmptyString(0)).toBe(false);
expect(isEmptyString(123)).toBe(false);
expect(isEmptyString(NaN)).toBe(false);
});
it('should return false for objects', () => {
expect(isEmptyString({})).toBe(false);
expect(isEmptyString([])).toBe(false);
});
it('should return false for boolean values', () => {
expect(isEmptyString(true)).toBe(false);
expect(isEmptyString(false)).toBe(false);
});
it('should return false for symbols', () => {
expect(isEmptyString(Symbol())).toBe(false);
});
it('should return false for functions', () => {
expect(isEmptyString(() => {})).toBe(false);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { mergeClasses } from '../src/utils';
import { mergeClasses } from '../src/utils/mergeClasses';
describe('mergeClasses', () => {
it('merges classes', async () => {

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { toCamelCase } from '../src/utils/toCamelCase';
describe('toCamelCase', () => {
it('should convert kebab-case to camelCase', () => {
expect(toCamelCase('hello-world')).toBe('helloWorld');
expect(toCamelCase('foo-bar-baz')).toBe('fooBarBaz');
});
it('should convert snake_case to camelCase', () => {
expect(toCamelCase('hello_world')).toBe('helloWorld');
expect(toCamelCase('foo_bar_baz')).toBe('fooBarBaz');
});
it('should convert space separated to camelCase', () => {
expect(toCamelCase('hello world')).toBe('helloWorld');
expect(toCamelCase('foo bar baz')).toBe('fooBarBaz');
});
it('should handle mixed separators', () => {
expect(toCamelCase('hello-world_foo bar')).toBe('helloWorldFooBar');
});
it('should lowercase the first character if uppercase', () => {
expect(toCamelCase('HelloWorld')).toBe('helloWorld');
expect(toCamelCase('Hello-World')).toBe('helloWorld');
});
it('should return the same string if already camelCase', () => {
expect(toCamelCase('alreadyCamelCase')).toBe('alreadyCamelCase');
});
it('should handle single word', () => {
expect(toCamelCase('word')).toBe('word');
expect(toCamelCase('Word')).toBe('word');
});
it('should handle empty string', () => {
expect(toCamelCase('')).toBe('');
});
});

View File

@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { toKebabCase } from '../src/utils/toKebabCase';
describe('toKebabCase', () => {
it('should convert camelCase to kebab-case', () => {
expect(toKebabCase('camelCase')).toBe('camel-case');
});
it('should convert PascalCase to kebab-case', () => {
expect(toKebabCase('PascalCase')).toBe('pascal-case');
});
it('should handle strings with numbers', () => {
expect(toKebabCase('test123String')).toBe('test123-string');
});
it('should handle already kebab-case strings', () => {
expect(toKebabCase('already-kebab-case')).toBe('already-kebab-case');
});
it('should handle single lowercase word', () => {
expect(toKebabCase('word')).toBe('word');
});
it('should handle empty string', () => {
expect(toKebabCase('')).toBe('');
});
});

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from 'vitest';
import { toPascalCase } from '../src/utils/toPascalCase';
describe('toPascalCase', () => {
it('should convert kebab-case to PascalCase', () => {
expect(toPascalCase('hello-world')).toBe('HelloWorld');
});
it('should convert snake_case to PascalCase', () => {
expect(toPascalCase('hello_world')).toBe('HelloWorld');
});
it('should convert camelCase to PascalCase', () => {
expect(toPascalCase('helloWorld')).toBe('HelloWorld');
});
it('should convert already PascalCase to PascalCase', () => {
expect(toPascalCase('HelloWorld')).toBe('HelloWorld');
});
it('should handle single word', () => {
expect(toPascalCase('hello')).toBe('Hello');
});
it('should handle empty string', () => {
expect(toPascalCase('')).toBe('');
});
it('should handle strings with multiple delimiters', () => {
expect(toPascalCase('hello-world_test')).toBe('HelloWorldTest');
});
it('should handle strings with spaces', () => {
expect(toPascalCase('hello world')).toBe('HelloWorld');
});
});

View File

@@ -48,30 +48,31 @@
"scripts": {
"build": "pnpm clean && pnpm copy:license && pnpm build:icons && pnpm build:package && pnpm build:license",
"copy:license": "cp ../../LICENSE ./LICENSE",
"copy:utils": "mkdir -p ./src/utils && cp -rf ../../packages/shared/src/utils/hasA11yProp.ts ./src/utils/",
"clean": "rm -rf dist stats ./src/icons/*.{ts,svelte} ./src/aliases/{aliases,prefixed,suffixed}.ts",
"build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --exportFileName=index.ts --iconFileExtension=.svelte --importImportFileExtension=.svelte --separateIconFileExport --separateIconFileExportExtension=.ts --withAliases --aliasesFileExtension=.ts --separateAliasesFile --separateAliasesFileExtension=.ts --aliasImportFileExtension=.js --pretty=false",
"build:package": "svelte-package --input ./src",
"build:license": "node ./scripts/appendBlockComments.mts",
"test": "pnpm copy:license && pnpm build:icons && vitest run",
"test": "pnpm copy:license && pnpm copy:utils && pnpm build:icons && vitest run",
"test:watch": "vitest watch",
"version": "pnpm version --git-tag-version=false"
},
"devDependencies": {
"@lucide/build-icons": "workspace:*",
"@lucide/helpers": "workspace:*",
"@sveltejs/package": "^2.3.10",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/svelte": "^5.2.7",
"@sveltejs/package": "^2.5.0",
"@sveltejs/vite-plugin-svelte": "^6.1.3",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/svelte": "^5.2.8",
"@tsconfig/svelte": "^5.0.4",
"jest-serializer-html": "^7.1.0",
"jsdom": "^20.0.3",
"svelte": "^5.20.5",
"svelte-check": "^4.1.4",
"svelte": "^5.38.6",
"svelte-check": "^4.3.1",
"svelte-preprocess": "^6.0.3",
"typescript": "^5.8.3",
"vite": "6.1.6",
"vitest": "^3.1.3"
"vitest": "^3.2.4"
},
"peerDependencies": {
"svelte": "^5"

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import defaultAttributes from './defaultAttributes.js';
import type { IconProps } from './types.js';
import { hasA11yProp } from './utils/hasA11yProp.js';
const {
name,
@@ -16,6 +17,7 @@
<svg
{...defaultAttributes}
{...(!children && !hasA11yProp(props) && { 'aria-hidden': 'true' })}
{...props}
width={size}
height={size}

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/svelte';
import { Icon } from '../src/lucide-svelte.js';
import Icon from '../src/Icon.svelte';
import TestSlotsTitle from './TestSlotsTitle.svelte';
import { airVent } from './testIconNodes.js';
@@ -31,3 +32,74 @@ describe('Using Icon Component', () => {
expect(container.firstChild).toMatchSnapshot();
});
});
describe('Icon Component Accessibility', () => {
it('should have aria-hidden prop when no aria prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
},
});
expect(container.firstChild).toHaveAttribute('aria-hidden', 'true');
});
it('should not have aria-hidden prop when aria prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
'aria-label': 'Air conditioning',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when title prop is present', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
title: 'Air conditioning',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should not have aria-hidden prop when there are children that could be a <title> element', async () => {
const { container } = render(TestSlotsTitle, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
'aria-label': 'Air conditioning',
},
});
expect(container.firstChild).not.toHaveAttribute('aria-hidden');
});
it('should override aria-hidden prop', async () => {
const { container } = render(Icon, {
props: {
iconNode: airVent,
size: 48,
color: 'red',
absoluteStrokeWidth: true,
'aria-hidden': 'false',
},
});
expect(container.firstChild).toHaveAttribute('aria-hidden', 'false');
});
});

View File

@@ -0,0 +1,7 @@
<script lang="ts">
import Smile from '../src/icons/smile.svelte'
</script>
<Smile>
<title>Air conditioning</title>
</Smile>

View File

@@ -2,6 +2,7 @@
exports[`Using Icon Component > should render icon and match snapshot 1`] = `
<svg
aria-hidden="true"
class="lucide-icon lucide"
fill="none"
height="48"

View File

@@ -1,12 +1,13 @@
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { svelteTesting } from '@testing-library/svelte/vite';
import { defineConfig } from 'vitest/config';
// @ts-expect-error - type mismatch
export default defineConfig(({ mode }) => ({
plugins: [
svelte({
compilerOptions: { hmr: false },
}),
svelteTesting(),
],
resolve: {
conditions: mode === 'test' ? ['browser'] : [],

3236
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff