mirror of
https://github.com/lucide-icons/lucide.git
synced 2025-12-24 11:59:22 +01:00
Compare commits
23 Commits
angular-pa
...
add-aria-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3749502a | ||
|
|
c53decf3a1 | ||
|
|
589460a924 | ||
|
|
b033c511c3 | ||
|
|
b11377b9f0 | ||
|
|
f100048627 | ||
|
|
a553dc7f3e | ||
|
|
8fe4fe160c | ||
|
|
0780ce75ba | ||
|
|
3b1bbb7453 | ||
|
|
0d8868f5c1 | ||
|
|
2ab2a5fc13 | ||
|
|
2f33a7ff1a | ||
|
|
483d4a6e5d | ||
|
|
6527cc17b4 | ||
|
|
557b94e825 | ||
|
|
59e3ddab09 | ||
|
|
e7e61064c0 | ||
|
|
30699b7769 | ||
|
|
d2f20bb752 | ||
|
|
43950764dd | ||
|
|
4b5cd54be5 | ||
|
|
e5ad189845 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface IconProps extends SVGAttributes {
|
||||
absoluteStrokeWidth?: boolean;
|
||||
class?: string;
|
||||
iconNode?: IconNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export type SVGAttributes = HTMLAttributes<'svg'>;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -22,5 +22,5 @@
|
||||
"types": ["@testing-library/jest-dom"],
|
||||
},
|
||||
"exclude": ["**/node_modules"],
|
||||
"include": ["src"],
|
||||
"include": ["src", "tests"],
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()] : [])],
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"`;
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
15
packages/shared/src/utils/hasA11yProp.ts
Normal file
15
packages/shared/src/utils/hasA11yProp.ts
Normal 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;
|
||||
};
|
||||
7
packages/shared/src/utils/isEmptyString.ts
Normal file
7
packages/shared/src/utils/isEmptyString.ts
Normal 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 === '';
|
||||
@@ -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 (
|
||||
10
packages/shared/src/utils/toCamelCase.ts
Normal file
10
packages/shared/src/utils/toCamelCase.ts
Normal 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(),
|
||||
);
|
||||
8
packages/shared/src/utils/toKebabCase.ts
Normal file
8
packages/shared/src/utils/toKebabCase.ts
Normal 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();
|
||||
14
packages/shared/src/utils/toPascalCase.ts
Normal file
14
packages/shared/src/utils/toPascalCase.ts
Normal 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>;
|
||||
};
|
||||
30
packages/shared/tests/hasA11yProp.spec.ts
Normal file
30
packages/shared/tests/hasA11yProp.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
46
packages/shared/tests/isEmptyString.spec.ts
Normal file
46
packages/shared/tests/isEmptyString.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
41
packages/shared/tests/toCamelCase.spec.ts
Normal file
41
packages/shared/tests/toCamelCase.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
28
packages/shared/tests/toKebabCase.spec.ts
Normal file
28
packages/shared/tests/toKebabCase.spec.ts
Normal 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('');
|
||||
});
|
||||
});
|
||||
36
packages/shared/tests/toPascalCase.spec.ts
Normal file
36
packages/shared/tests/toPascalCase.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
7
packages/svelte/tests/TestSlotsTitle.svelte
Normal file
7
packages/svelte/tests/TestSlotsTitle.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import Smile from '../src/icons/smile.svelte'
|
||||
</script>
|
||||
|
||||
<Smile>
|
||||
<title>Air conditioning</title>
|
||||
</Smile>
|
||||
@@ -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"
|
||||
|
||||
@@ -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
3236
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user