diff --git a/.gitignore b/.gitignore index 41a9f0839..a4016ec89 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/docs/guide/advanced/accessibility.md b/docs/guide/advanced/accessibility.md index 131928c2a..b868a9d63 100644 --- a/docs/guide/advanced/accessibility.md +++ b/docs/guide/advanced/accessibility.md @@ -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. diff --git a/docs/guide/packages/lucide-angular.md b/docs/guide/packages/lucide-angular.md index 737ccc01f..240f75472 100644 --- a/docs/guide/packages/lucide-angular.md +++ b/docs/guide/packages/lucide-angular.md @@ -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 + +``` + +For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md). diff --git a/docs/guide/packages/lucide-astro.md b/docs/guide/packages/lucide-astro.md index 0ae1525d4..937da2918 100644 --- a/docs/guide/packages/lucide-astro.md +++ b/docs/guide/packages/lucide-astro.md @@ -192,3 +192,19 @@ import LucideIcon from './LucideIcon.astro'; ``` + +## 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'; +--- + + +``` + +For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md). diff --git a/docs/guide/packages/lucide-preact.md b/docs/guide/packages/lucide-preact.md index 817acbdf1..4585af11f 100644 --- a/docs/guide/packages/lucide-preact.md +++ b/docs/guide/packages/lucide-preact.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 ; +}; +``` + +For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md). diff --git a/docs/guide/packages/lucide-react.md b/docs/guide/packages/lucide-react.md index ac34ba186..79e9dcb4c 100644 --- a/docs/guide/packages/lucide-react.md +++ b/docs/guide/packages/lucide-react.md @@ -109,3 +109,19 @@ const 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-react'; + +const App = () => { + return ; +}; +``` + +For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md). diff --git a/docs/guide/packages/lucide-solid.md b/docs/guide/packages/lucide-solid.md index 5f76d4fc3..57641c287 100644 --- a/docs/guide/packages/lucide-solid.md +++ b/docs/guide/packages/lucide-solid.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 ; +}; +``` + +For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md). diff --git a/docs/guide/packages/lucide-svelte.md b/docs/guide/packages/lucide-svelte.md index eebe1ebac..e3ba6d25d 100644 --- a/docs/guide/packages/lucide-svelte.md +++ b/docs/guide/packages/lucide-svelte.md @@ -331,3 +331,19 @@ The example below imports all ES Modules, so exercise caution when using it. Imp ``` + +## Accessibility + +By default, we hide icons from screen readers using `aria-hidden="true"`. + +You can add accessibility attributes using aria-labels. + +```svelte + + + +``` + +For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md). diff --git a/docs/guide/packages/lucide-vue-next.md b/docs/guide/packages/lucide-vue-next.md index 3c4c09ae2..fffd005f9 100644 --- a/docs/guide/packages/lucide-vue-next.md +++ b/docs/guide/packages/lucide-vue-next.md @@ -146,3 +146,25 @@ All other props listed above also work on the `Icon` Component. ``` + +## Accessibility + +By default, we hide icons from screen readers using `aria-hidden="true"`. + +You can add accessibility attributes using aria-labels. + +```vue + + + +``` + +For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md). diff --git a/docs/guide/packages/lucide.md b/docs/guide/packages/lucide.md index 5125865e2..6b1a62b62 100644 --- a/docs/guide/packages/lucide.md +++ b/docs/guide/packages/lucide.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 + + + + + + + +``` + +For best practices on accessibility, please see our [accessibility guide](../advanced/accessibility.md). diff --git a/packages/astro/package.json b/packages/astro/package.json index b5f1e36cb..4bb49e7c8 100644 --- a/packages/astro/package.json +++ b/packages/astro/package.json @@ -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", diff --git a/packages/astro/src/Icon.astro b/packages/astro/src/Icon.astro index 415395730..eb504dbad 100644 --- a/packages/astro/src/Icon.astro +++ b/packages/astro/src/Icon.astro @@ -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]} diff --git a/packages/astro/src/createLucideIcon.ts b/packages/astro/src/createLucideIcon.ts index ab6b0fd50..7ff99d5ec 100644 --- a/packages/astro/src/createLucideIcon.ts +++ b/packages/astro/src/createLucideIcon.ts @@ -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( diff --git a/packages/astro/src/types.ts b/packages/astro/src/types.ts index 805ababc7..71f1d527f 100644 --- a/packages/astro/src/types.ts +++ b/packages/astro/src/types.ts @@ -10,6 +10,7 @@ export interface IconProps extends SVGAttributes { absoluteStrokeWidth?: boolean; class?: string; iconNode?: IconNode; + title?: string; } export type SVGAttributes = HTMLAttributes<'svg'>; diff --git a/packages/astro/tests/Icon.spec.ts b/packages/astro/tests/Icon.spec.ts index e1d125a08..af2ce9ebb 100644 --- a/packages/astro/tests/Icon.spec.ts +++ b/packages/astro/tests/Icon.spec.ts @@ -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'); + }); +}); diff --git a/packages/astro/tests/__snapshots__/Icon.spec.ts.snap b/packages/astro/tests/__snapshots__/Icon.spec.ts.snap index f96b087eb..a550ccb86 100644 --- a/packages/astro/tests/__snapshots__/Icon.spec.ts.snap +++ b/packages/astro/tests/__snapshots__/Icon.spec.ts.snap @@ -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" > diff --git a/packages/astro/tests/__snapshots__/createLucideIcon.spec.ts.snap b/packages/astro/tests/__snapshots__/createLucideIcon.spec.ts.snap index 7c01619e8..13a285420 100644 --- a/packages/astro/tests/__snapshots__/createLucideIcon.spec.ts.snap +++ b/packages/astro/tests/__snapshots__/createLucideIcon.spec.ts.snap @@ -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" > diff --git a/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap b/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap index fcf978635..a6ff7f2e4 100644 --- a/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap +++ b/packages/astro/tests/__snapshots__/lucide-astro.spec.ts.snap @@ -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" > 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" > @@ -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" > should pass children to the icon slot 1` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + aria-hidden="true" class="lucide lucide-smile" > should render a component 1`] = ` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + aria-hidden="true" class="lucide lucide-grid-3x3" > should render the icon with default attr stroke-width="2" stroke-linecap="round" stroke-linejoin="round" + aria-hidden="true" class="lucide lucide-grid-3x3" > { let testHostComponent: TestHostComponent; let testHostFixture: ComponentFixture; - const getSvgAttribute = (attr: string) => + const getAttribute = (attr: string) => testHostFixture.nativeElement.querySelector('svg').getAttribute(attr); + const getRootAttribute = (attr: string) => + testHostFixture.nativeElement.querySelector('i-lucide').getAttribute(attr); const testIcons: LucideIcons = { Demo: [['polyline', { points: '1 1 22 22' }]], }; @@ -31,28 +33,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 +64,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(getRootAttribute('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(getRootAttribute('aria-label')).toBe(ariaLabel); + expect(getRootAttribute('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(getRootAttribute('title')).toBe(ariaLabel); + expect(getRootAttribute('aria-hidden')).toBeNull(); + }); + + it('should never override aria-hidden prop', async () => { + testHostComponent.setAriaHidden(true); + testHostFixture.detectChanges(); + expect(getRootAttribute('aria-hidden')).toBe('true'); + }); + @Component({ selector: 'lucide-spec-host-component', - template: ` `, + [attr.aria-label]="ariaLabel" + [attr.title]="title" + [attr.aria-hidden]="ariaHidden" + > + `, }) class TestHostComponent { color?: string; size?: number; strokeWidth?: number; absoluteStrokeWidth = true; + ariaLabel?: string = undefined; + title?: string = undefined; + ariaHidden?: boolean = undefined; setColor(color: string): void { this.color = color; @@ -99,5 +135,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; + } } }); diff --git a/packages/lucide-angular/src/lib/lucide-angular.component.ts b/packages/lucide-angular/src/lib/lucide-angular.component.ts index 931239cba..42952cea0 100644 --- a/packages/lucide-angular/src/lib/lucide-angular.component.ts +++ b/packages/lucide-angular/src/lib/lucide-angular.component.ts @@ -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 extends SimpleChange { previousValue: T; @@ -99,6 +100,12 @@ export class LucideAngularComponent implements OnChanges { this.strokeWidth = this.parseNumber(this.strokeWidth ?? this.iconConfig.strokeWidth); this.absoluteStrokeWidth = this.absoluteStrokeWidth ?? this.iconConfig.absoluteStrokeWidth; const nameOrIcon = this.img ?? this.name; + const restAttributes = this.getRestAttributes(); + + if (!hasA11yProp(restAttributes)) { + this.renderer.setAttribute(this.elem.nativeElement, 'aria-hidden', 'true'); + } + if (typeof nameOrIcon === 'string') { const icoOfName = this.getIcon(this.toPascalCase(nameOrIcon)); if (icoOfName) { @@ -119,6 +126,8 @@ export class LucideAngularComponent implements OnChanges { } replaceElement(img: LucideIconData): void { + const childElements = this.elem.nativeElement.childNodes; + const attributes = { ...defaultAttributes, width: this.size, @@ -133,6 +142,7 @@ export class LucideAngularComponent implements OnChanges { if (typeof this.name === 'string') { icoElement.classList.add(`lucide-${this.name.replace('_', '-')}`); } + if (this.class) { icoElement.classList.add( ...this.class @@ -141,13 +151,21 @@ export class LucideAngularComponent implements OnChanges { .filter((a) => a.length > 0), ); } - const childElements = this.elem.nativeElement.childNodes; + for (const child of childElements) { this.renderer.removeChild(this.elem.nativeElement, child); } this.renderer.appendChild(this.elem.nativeElement, icoElement); } + getRestAttributes(): Record { + const restAttributeMap: NamedNodeMap = this.elem.nativeElement.attributes; + const restAttributes = Object.fromEntries( + Array.from(restAttributeMap).map((item) => [item.name, item.value]), + ); + return restAttributes; + } + toPascalCase(str: string): string { return str.replace( /(\w)([a-z0-9]*)(_|-|\s*)/g, diff --git a/packages/lucide-preact/package.json b/packages/lucide-preact/package.json index b73f6d6d3..c6ddb6ae4 100644 --- a/packages/lucide-preact/package.json +++ b/packages/lucide-preact/package.json @@ -46,7 +46,7 @@ "@lucide/rollup-plugins": "workspace:*", "@lucide/shared": "workspace:*", "@preact/preset-vite": "^2.10.2", - "@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.27.3", diff --git a/packages/lucide-preact/src/Icon.ts b/packages/lucide-preact/src/Icon.ts index bfbda2297..ee654721c 100644 --- a/packages/lucide-preact/src/Icon.ts +++ b/packages/lucide-preact/src/Icon.ts @@ -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)], diff --git a/packages/lucide-preact/tests/Icon.spec.tsx b/packages/lucide-preact/tests/Icon.spec.tsx index d43760ba9..a78874888 100644 --- a/packages/lucide-preact/tests/Icon.spec.tsx +++ b/packages/lucide-preact/tests/Icon.spec.tsx @@ -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( + , + ); + + expect(container.firstChild).not.toHaveAttribute('aria-hidden'); + }); + + it('should not have aria-hidden prop when title prop is present', async () => { + const { container } = render( + , + ); + + expect(container.firstChild).not.toHaveAttribute('aria-hidden'); + }); + + it('should not have aria-hidden prop when there are children that could be a element', async () => { + const { container } = render( + <Icon + iconNode={airVent} + size={48} + stroke="red" + absoluteStrokeWidth + > + <title>Some title + , + ); + + expect(container.firstChild).not.toHaveAttribute('aria-hidden'); + }); + + it('should never override aria-hidden prop', async () => { + const { container } = render( + , + ); + + expect(container.firstChild).toHaveAttribute('aria-hidden', 'false'); + }); +}); diff --git a/packages/lucide-preact/tests/__snapshots__/Icon.spec.tsx.snap b/packages/lucide-preact/tests/__snapshots__/Icon.spec.tsx.snap index 2e30bf9a6..0908247fd 100644 --- a/packages/lucide-preact/tests/__snapshots__/Icon.spec.tsx.snap +++ b/packages/lucide-preact/tests/__snapshots__/Icon.spec.tsx.snap @@ -2,6 +2,7 @@ exports[`Using Icon Component > should render icon and match snapshot 1`] = `