feat(packages/angular): add new @lucide/angular package (#3897)

* Add new lucide angular package

* feat(packages/angular): added initial @lucide/angular package

* feat(packages/angular): update readme

* feat(packages/angular): update angular.json

* docs(packages/angular): added (for now) full documentation for @lucide/angular

* docs(packages/angular): added migration guide from lucide-angular

* fix(github): fix package label syntax 😅

* fix(lint): fix linting issues

* fix(github/angular): add prebuild stage

* fix(github/angular): add prebuild stage & fix tests

* fix(github/angular): fix LucideIconComponentType, update with _real_ public members

* fix(github/angular): add prebuild to build step manually

* fix(github/angular): downgrade vitest

* fix(packages/angular): fix migration guide code example

* fix(packages): add vitest + @vitest/* to pnpm overrides

* fix(packages): update pnpm-lock with merged version

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix(packages): fix aria-hidden logic

* fix(packages): update pnpm-lock

* fix(packages): extract vitest and jsdom to root devDependencies

* Fix copy utils script

* Format code

* feat(packages/angular): switched to self-describing IconData object from separate node+name – no more toKebabCase hackery
feat(packages/angular): renamed LucideIconComponentType => LucideIcon, and LucideIcon => LucideDynamicIcon
feat(packages/angular): added backwards compatible CSS class support
feat(packages/angular): switched to vector-effect: non-scaling-stroke implementation from computed stroke width
feat(packages/angular): rewrote icon provider to only accept a list of self-described icons – no more toKebabCase hackery & as an added bonus automatic backwards compatible alias support 🚀
feat(packages/angular): added legacy icon node helper function for passing legacy icons to providers
test(packages/angular): added unit tests on LUCIDE_CONFIG provider usage

* fix(packages/angular): fix linting issues

* feat(packages/angular): extract createLucideIcon logic into helper function, refactor export template to use the iconData object as defined in ExportTemplate

* Replace author

* Remove private field

* fix(packages/angular): remove createLucideIcon, it breaks the package :'(

* fix(packages/angular): fix rendering order of child elements (_before_ projected content)

* Format package.json

* Update docs/guide/packages/angular.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update packages/angular/MIGRATION.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Eric Fennis <eric.fennis@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Karsa
2026-03-20 15:31:34 +01:00
committed by GitHub
parent d4424888de
commit a0e202d759
54 changed files with 6672 additions and 462 deletions

View File

@@ -8,3 +8,4 @@ node_modules
docs/images
docs/**/examples/
packages/lucide-react/dynamicIconImports.js
packages/angular/.angular

View File

@@ -13,13 +13,17 @@ body:
description: Which Lucide packages are affected? You may select more than one.
options:
- label: lucide
- label: lucide-angular
- label: lucide-angular (old version)
- label: '@lucide/angular (new version)'
- label: '@lucide/astro'
- label: lucide-flutter
- label: lucide-preact
- label: lucide-react
- label: lucide-react-native
- label: lucide-solid
- label: lucide-svelte
- label: lucide-static
- label: lucide-svelte (old version)
- label: '@lucide/svelte (new version)'
- label: lucide-vue
- label: lucide-vue-next
- label: lucide-astro

View File

@@ -13,13 +13,17 @@ body:
description: Which Lucide project do you wish this feature were added to? You may select more than one.
options:
- label: lucide
- label: lucide-angular
- label: lucide-angular (old version)
- label: '@lucide/angular (new version)'
- label: '@lucide/astro'
- label: lucide-flutter
- label: lucide-preact
- label: lucide-react
- label: lucide-react-native
- label: lucide-solid
- label: lucide-svelte
- label: lucide-static
- label: lucide-svelte (old version)
- label: '@lucide/svelte (new version)'
- label: lucide-vue
- label: lucide-vue-next
- label: lucide-astro
@@ -27,6 +31,7 @@ body:
- label: Figma plugin
- label: all JS packages
- label: site
- label: other/not relevant
validations:
required: true
- type: textarea

1
.github/labeler.yml vendored
View File

@@ -59,6 +59,7 @@
🅰️ angular package:
- changed-files:
- any-glob-to-any-file:
- 'packages/angular/*'
- 'packages/lucide-angular/*'
# For changes in the lucide preact package

41
.github/workflows/angular.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Lucide Angular checks
on:
pull_request:
paths:
- packages/angular/**
- tools/build-icons/**
- pnpm-lock.yaml
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
cache: 'pnpm'
node-version-file: 'package.json'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm --filter @lucide/angular build
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
cache: 'pnpm'
node-version-file: 'package.json'
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Test
run: pnpm --filter @lucide/angular test

View File

@@ -58,6 +58,7 @@ jobs:
'lucide-preact',
'lucide-solid',
'lucide-svelte',
'@lucide/angular',
'@lucide/astro',
'@lucide/svelte',
'@lucide/icons',

View File

@@ -87,9 +87,25 @@
}
]
},
"lucide-angular": {
"@lucide/angular": {
"order": 6,
"icon": "angular",
"shields": [
{
"alt": "npm",
"src": "https://img.shields.io/npm/v/@lucide/angular",
"href": "https://www.npmjs.com/package/@lucide/angular"
},
{
"alt": "npm",
"src": "https://img.shields.io/npm/dw/@lucide/angular",
"href": "https://www.npmjs.com/package/@lucide/angular"
}
]
},
"lucide-angular": {
"order": 7,
"icon": "angular",
"shields": [
{
"alt": "npm",
@@ -104,7 +120,7 @@
]
},
"lucide-preact": {
"order": 7,
"order": 8,
"icon": "preact",
"shields": [
{
@@ -122,7 +138,7 @@
"@lucide/astro": {
"docsAlias": "lucide-astro",
"packageDirname": "astro",
"order": 8,
"order": 9,
"icon": "astro",
"iconDark": "astro-dark",
"shields": [
@@ -139,7 +155,7 @@
]
},
"lucide-static": {
"order": 9,
"order": 10,
"icon": "svg",
"shields": [
{

View File

@@ -69,39 +69,39 @@ const sidebar: UserConfig<DefaultTheme.Config>['themeConfig']['sidebar'] = {
link: '/guide/packages/lucide',
},
{
text: 'Lucide React',
text: 'React',
link: '/guide/packages/lucide-react',
},
{
text: 'Lucide Vue',
text: 'Vue',
link: '/guide/packages/lucide-vue',
},
{
text: 'Lucide Svelte',
text: 'Svelte',
link: '/guide/packages/lucide-svelte',
},
{
text: 'Lucide Solid',
text: 'Solid',
link: '/guide/packages/lucide-solid',
},
{
text: 'Lucide React Native',
text: 'React Native',
link: '/guide/packages/lucide-react-native',
},
{
text: 'Lucide Angular',
link: '/guide/packages/lucide-angular',
text: 'Angular',
link: '/guide/packages/angular',
},
{
text: 'Lucide Preact',
text: 'Preact',
link: '/guide/packages/lucide-preact',
},
{
text: 'Lucide Astro',
text: 'Astro',
link: '/guide/packages/lucide-astro',
},
{
text: 'Lucide Static',
text: 'Static',
link: '/guide/packages/lucide-static',
},
{

View File

@@ -33,7 +33,7 @@ export default {
label: 'Lucide documentation for Preact',
},
{
name: 'lucide-angular',
name: 'angular',
logo: '/framework-logos/angular.svg',
label: 'Lucide documentation for Angular',
},

View File

@@ -29,7 +29,7 @@ However, not everyone can understand them easily. Read more about [how to use Lu
## Official Packages
Lucide's official packages are designed to work on different platforms, making it easier for users to integrate icons into their projects. The packages are available for various technologies, including [Web (Vanilla)](https://lucide.dev/guide/packages/lucide), [React](https://lucide.dev/guide/packages/lucide-react), [React Native](https://lucide.dev/guide/packages/lucide-react-native), [Vue](https://lucide.dev/guide/packages/lucide-vue), [Vue 3](https://lucide.dev/guide/packages/lucide-vue-next), [Svelte](https://lucide.dev/guide/packages/lucide-svelte), [Preact](https://lucide.dev/guide/packages/lucide-preact), [Solid](https://lucide.dev/guide/packages/lucide-solid), [Angular](https://lucide.dev/guide/packages/lucide-angular), [Astro](https://lucide.dev/guide/packages/lucide-astro), and [NodeJS](https://lucide.dev/guide/packages/lucide-static#nodejs).
Lucide's official packages are designed to work on different platforms, making it easier for users to integrate icons into their projects. The packages are available for various technologies, including [Web (Vanilla)](https://lucide.dev/guide/packages/lucide), [React](https://lucide.dev/guide/packages/lucide-react), [React Native](https://lucide.dev/guide/packages/lucide-react-native), [Vue](https://lucide.dev/guide/packages/lucide-vue), [Vue 3](https://lucide.dev/guide/packages/lucide-vue-next), [Svelte](https://lucide.dev/guide/packages/lucide-svelte), [Preact](https://lucide.dev/guide/packages/lucide-preact), [Solid](https://lucide.dev/guide/packages/lucide-solid), [Angular](https://lucide.dev/guide/packages/angular), [Astro](https://lucide.dev/guide/packages/lucide-astro), and [NodeJS](https://lucide.dev/guide/packages/lucide-static#nodejs).
## Community

View File

@@ -0,0 +1,277 @@
# `@lucide/angular`
::: warning
This documentation is for `@lucide/angular`.
To learn about our legacy package for Angular, please refer to [`lucide-angular`](./lucide-angular).
:::
A standalone, signal-based, zoneless implementation of Lucide icons for Angular.
**What you can accomplish:**
- Use icons as standalone Angular components with full dependency injection support
- Configure icons globally through modern Angular providers
- Integrate with Angular's reactive forms and data binding
- Build scalable applications with tree-shaken icons and lazy loading support
## Prerequisites
This package requires Angular 17+ and uses standalone components, signals, and zoneless change detection.
## Installation
::: code-group
```sh [pnpm]
pnpm add @lucide/angular
```
```sh [yarn]
yarn add @lucide/angular
```
```sh [npm]
npm install @lucide/angular
```
```sh [bun]
bun add @lucide/angular
```
:::
## How to use
### Standalone icons
Every icon can be imported as a ready-to-use standalone component:
```html
<svg lucideFileText></svg>
```
```ts{2,7}
import { Component } from '@angular/core';
import { LucideFileText } from '@lucide/angular';
@Component({
selector: 'app-foobar',
templateUrl: './foobar.html',
imports: [LucideFileText],
})
export class Foobar { }
```
::: tip
Standalone icon components use the selector `svg[lucide{PascalCaseIconName}]`.
This ensures minimal bloating of the DOM and the ability to directly manipulate all attributes of the resulting SVG element.
:::
### Dynamic icon component
You may also use the dynamic `LucideIcon` component to dynamically render icons.
#### With tree-shaken imports
You may pass imported icons directly to the component:
```html{3}
@for (item of items) {
<a navbarItem [routerLink]="item.routerLink">
<svg [lucideIcon]="item.icon"></svg>
{{ item.title }}
</a>
}
```
```ts{2,8,14,19}
import { Component } from '@angular/core';
import { LucideIcon, LucideHouse, LucideUsersRound } from '@lucide/angular';
import { NavbarItem, NavbarItemModel } from './navbar-item';
@Component({
selector: 'app-navbar',
templateUrl: './navbar.html',
imports: [LucideIcon, NavbarItem],
})
export class Navbar {
readonly items: NavbarItemModel[] = [
{
title: 'Home',
icon: LucideHouse,
routerLink: [''],
},
{
title: 'Users',
icon: LucideUsersRound,
routerLink: ['admin/users'],
},
];
}
```
#### With icons provided via dependency injection
Alternatively, the component also accepts string inputs.
To use icons this way, first, you have to provide icons via `provideLucideIcons`:
:::code-group
```ts{7-10} [app.config.ts]
import { ApplicationConfig } from '@angular/core';
import { provideLucideIcons, LucideCircleCheck, LucideCircleX } from '@lucide/angular';
export const appConfig: ApplicationConfig = {
providers: [
// ...
provideLucideIcons(
LucideCircleCheck,
LucideCircleX,
),
]
};
```
```html [foobar.html]
<svg lucideIcon="circle-check"></svg>
```
```ts{7} [foobar.ts]
import { Component } from '@angular/core';
import { LucideIcon } from '@lucide/angular';
@Component({
selector: 'app-foobar',
templateUrl: './template-url',
imports: [LucideIcon],
})
export class Foobar { }
```
:::
::: tip
For optimal bundle size, provide icons at the highest appropriate level in your application.
Providing all icons at the root level may increase your initial bundle size, while providing them at feature module level enables better code splitting.
:::
::: warning
While you may provide your icons at any level of the dependency injection tree, be aware that [Angular's DI system is hierarchical](https://angular.dev/guide/di/defining-dependency-providers#injector-hierarchy-in-angular): `LucideIcon` will only have access to the icons provided closest to it in the tree.
:::
## Accessible labels
You can use the `title` input property to set the [accessible name element](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/title) on the SVG:
```html
<svg lucideIcon="house" title="Go to dashboard"></svg>
```
This will result in the following output:
```html{2}
<svg class="lucide lucide-house" ...>
<title>Go to dashboard</title>
<!-- SVG paths -->
</svg>
```
## Props
You can pass additional props to adjust the icon appearance.
| name | type | default |
|-----------------------|-----------|--------------|
| `size` | *number* | 24 |
| `color` | *string* | currentColor |
| `strokeWidth` | *number* | 2 |
| `absoluteStrokeWidth` | *boolean* | false |
```html
<svg lucideHouse size="48" color="red" strokeWidth="1"></svg>
```
## Global configuration
You can use `provideLucideConfig` to configure the default property values as defined above:
```ts{2,7-9}
import { ApplicationConfig } from '@angular/core';
import { provideLucideConfig } from '@lucide/angular';
export const appConfig: ApplicationConfig = {
providers: [
// ...
provideLucideConfig({
strokeWidth: 1.5
}),
]
};
```
## Styling via CSS
Icons can also be styled by using custom CSS classes:
```html
<svg lucideHousePlus class="my-icon"></svg>
```
```css
svg.my-icon {
width: 12px;
height: 12px;
stroke-width: 3;
}
```
## With Lucide lab or custom icons
[Lucide lab](https://github.com/lucide-icons/lucide-lab) is a collection of icons that are not part of the Lucide main library.
While they aren't provided as standalone components, they can be still be passed to the `LucideIcon` component the same way as official icons:
```html
<!-- Directly as LucideIconData: -->
<svg [lucideIcon]="CoconutIcon"></svg>
<!-- As a provided icon by name: -->
<svg lucideIcon="coconut"></svg>
```
```ts{2,6-7,11-12}
import { provideLucideIcons } from '@lucide/angular';
import { coconut } from '@lucide/lab';
@Component({
templateUrl: './foobar.html',
// For using by name via provider:
providers: [provideLucideIcons({ coconut })],
imports: [LucideIcon]
})
export class Foobar {
// For passing directly as LucideIconData:
readonly CoconutIcon = coconut;
}
```
## Troubleshooting
### The icon is not being displayed
If using per-icon-components:
1. Ensure that the icon component is being imported, if using per-icon-components
2. Check that the icon name matches exactly (case-sensitive)
If using the dynamic component:
1. Ensure the icon is provided via `provideLucideIcons()` if using string names
2. Verify the icon is imported from `@lucide/angular` and not the legacy package
### TypeScript errors?
Make sure you're importing from `@lucide/angular` and not `lucide-angular`.
### Icons render with wrong defaults
Ensure `provideLucideConfig()` is used at the right level.
## Migration guide
Migrating from `lucide-angular`? Read our [comprehensive migration guide](https://github.com/lucide-icons/lucide/blob/main/packages/angular/MIGRATION.md).

View File

@@ -1,5 +1,11 @@
# Lucide Angular
::: warning
This documentation is for our legacy package for Angular.
For our modern, standalone-first implementation, please refer to [`@lucide/angular`](./angular).
:::
Angular components and services for Lucide icons that integrate with Angular's dependency injection and component system. Provides both traditional module-based and modern standalone component approaches for maximum flexibility in Angular applications.
**What you can accomplish:**

View File

@@ -49,6 +49,8 @@
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@vitest/coverage-v8": "4.0.12",
"@vitest/ui": "4.0.12",
"ajv-cli": "^5.0.0",
"dotenv": "^17.0.0",
"eslint": "^8.57.1",
@@ -60,6 +62,7 @@
"eslint-import-resolver-typescript": "^3.10.1",
"eslint-plugin-import": "^2.31.0",
"husky": "^8.0.3",
"jsdom": "^27.3.0",
"lint-staged": "^13.3.0",
"minimist": "^1.2.8",
"openai": "^5.8.1",
@@ -70,6 +73,7 @@
"simple-git": "^3.32.3",
"svgo": "^3.3.2",
"svgson": "^5.3.1",
"vitest": "4.0.12",
"yargs": "^17.7.2",
"zod": "^3.25.67"
},
@@ -86,13 +90,13 @@
}
},
"overrides": {
"cross-spawn": "7.0.5",
"form-data": "^4.0.4",
"fast-json-patch": "^3.1.1",
"webpack-dev-middleware": "^5.3.4",
"semver": "^7.7.3",
"axios": "^1.12.0",
"vite-prerender-plugin": "0.5.12"
"cross-spawn": "7.0.5",
"fast-json-patch": "^3.1.1",
"form-data": "^4.0.4",
"semver": "^7.7.3",
"vite-prerender-plugin": "0.5.12",
"webpack-dev-middleware": "^5.3.4"
}
}
}

View File

@@ -0,0 +1,38 @@
module.exports = {
root: true,
overrides: [
{
files: ['*.ts'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@angular-eslint/recommended',
'plugin:@angular-eslint/template/process-inline-templates',
'prettier',
],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lucide',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'attribute',
prefix: ['lucide'],
style: 'camelCase',
},
],
},
},
{
files: ['*.html'],
extends: ['plugin:@angular-eslint/template/recommended'],
rules: {},
},
],
};

View File

@@ -0,0 +1,4 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
"recommendations": ["angular.ng-template"]
}

20
packages/angular/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "ng serve",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: start",
"url": "http://localhost:4200/"
},
{
"name": "ng test",
"type": "chrome",
"request": "launch",
"preLaunchTask": "npm: test",
"url": "http://localhost:9876/debug.html"
}
]
}

42
packages/angular/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
"version": "2.0.0",
"tasks": [
{
"type": "npm",
"script": "start",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
},
{
"type": "npm",
"script": "test",
"isBackground": true,
"problemMatcher": {
"owner": "typescript",
"pattern": "$tsc",
"background": {
"activeOnStart": true,
"beginsPattern": {
"regexp": "(.*?)"
},
"endsPattern": {
"regexp": "bundle generation complete"
}
}
}
}
]
}

View File

@@ -0,0 +1,187 @@
# Migrating from `lucide-angular` ⇒ `@lucide/angular`
## What changed
`@lucide/angular` moves from a module + single component based API to a more modern Angular approach:
- The library defines modern signal-based, standalone components, without zone.js based change detection.
- Icons are consumed as standalone imports (one component per icon).
- Dynamic icon registration is done via `provideLucideIcons()`, not using `NgModule`.
- Static icons use per-icon components for better tree-shaking.
- Dynamic icons still use a single dynamic component (`svg[lucideIcon]`).
- Global defaults are configured via `provideLucideConfig()`.
---
## Step 1 Update dependencies
Remove `lucide-angular`, add `@lucide/angular`, see http://lucide.dev/guide/packages/angular#installation
---
## Step 2 Replace `LucideAngularModule.pick(...)` with `provideLucideIcons(...)`
> Notes:
> - Old imports like `AirVentIcon` / `AlarmClock` from `lucide-angular` should be replaced with the new per-icon exports `LucideAirVent` and `LucideAlarmClock`.
> - If you mostly used static icons, you may not need to provide them **at all**, please refer to Step 3.
### Before
#### NgModule based
```ts
import { BrowserModule, NgModule } from '@angular/core';
import { LucideAngularModule, AirVent, AlarmClock } from 'lucide-angular';
@NgModule({
imports: [
BrowserModule,
LucideAngularModule.pick({ AirVent, AlarmClock }),
],
})
export class AppModule {}
```
#### Standalone
```ts
import { ApplicationConfig } from '@angular/core';
import { LucideAngularModule, AirVent, AlarmClock } from 'lucide-angular';
export const appConfig: ApplicationConfig = {
providers: [
// ...
importProvidersFrom(LucideAngularModule.pick({ AirVent, AlarmClock })),
]
};
```
### After
```ts
import { ApplicationConfig } from '@angular/core';
import { provideLucideIcons, LucideAirVent, LucideAlarmClock } from '@lucide/angular';
export const appConfig: ApplicationConfig = {
providers: [
// ...
provideLucideIcons(
LucideAirVent,
LucideAlarmClock,
),
]
};
```
---
## Step 3 Replace `<lucide-angular>` / `<lucide-icon>` / `<i-lucide>` / `<span-lucide>`
The legacy package rendered everything through a single component. All of these selectors must be migrated to `<svg>` usage.
### A. Static icons by name
If the icon is known at build time, just use a static import:
#### Before
```html
<lucide-angular name="circle-check"></lucide-angular>
```
#### After
```html
<svg lucideCircleCheck></svg>
```
### B. Static icons with icon data binding
#### Before
```ts
import { CircleCheck } from 'lucide-angular';
```
```html
<lucide-icon [img]="CircleCheck"></lucide-icon>
```
#### After
```ts
import { LucideCircleCheck } from '@lucide/angular';
```
```html
<svg lucideCircleCheck></svg>
```
...and import `LucideCircleCheck` from `@lucide/angular`.
---
### C. Dynamic icons
If the icon varies at runtime, use the dynamic component:
#### Before
```html
<lucide-icon [name]="item.icon"></lucide-icon>
```
#### After
```html
<svg [lucideIcon]="item.icon"></svg>
```
---
## Step 4 Replace `LucideIconConfig` with `provideLucideConfig()`
### Before
```ts
import { inject } from '@angular/core';
import { LucideIconConfig } from 'lucide-angular';
inject(LucideIconConfig).size = 12;
```
### After
```ts
import { provideLucideConfig } from '@lucide/angular';
providers: [
provideLucideConfig({ size: 12 }),
]
```
### Where to place it
- App-wide: `AppModule.providers` or `bootstrapApplication(...providers)`
- Feature-level: feature module providers
- Component-level (standalone): component `providers`
---
## Troubleshooting
### The icon is not being displayed
If using per-icon-components:
1. Ensure that the icon component is being imported, if using per-icon-components
2. Check that the icon name matches exactly (case-sensitive)
If using the dynamic component:
1. Ensure the icon is provided via `provideLucideIcons()` if using string names
2. Verify the icon is imported from `@lucide/angular` and not the legacy package
### TypeScript errors?
Make sure you're importing from `@lucide/angular` and not `lucide-angular`.
### Icons render with wrong defaults
Ensure `provideLucideConfig()` is used at the right level.
---
## TL;DR
- `LucideAngularModule` ⇒ static: removed; dynamic: `LucideIcon`
- `LucideAngularModule.pick(...)``provideLucideIcons(...)`
- `<lucide-angular name="foo-bar">``<svg lucideFooBar>`
- `<lucide-icon [name]="expr">``<svg [lucideIcon]="expr">`
- `<lucide-icon [img]="expr">``<svg [lucideIcon]="expr">`
- `LucideIconConfig``provideLucideConfig(...)`

View File

@@ -0,0 +1,77 @@
<p align="center">
<a href="https://github.com/lucide-icons/lucide">
<img src="https://lucide.dev/package-logos/lucide-angular.svg" alt="Lucide icon library for Angular applications." width="540">
</a>
</p>
<p align="center">
Lucide icon library for Angular applications.
</p>
<div align="center">
[![npm](https://img.shields.io/npm/v/@lucide/angular?color=blue)](https://www.npmjs.com/package/@lucide/angular)
![NPM Downloads](https://img.shields.io/npm/dw/@lucide/angular)
[![GitHub](https://img.shields.io/github/license/lucide-icons/lucide)](https://lucide.dev/license)
</div>
<p align="center">
<a href="https://lucide.dev/guide/">About</a>
·
<a href="https://lucide.dev/icons/">Icons</a>
·
<a href="https://lucide.dev/guide/packages/angular">Documentation</a>
·
<a href="https://lucide.dev/license">License</a>
</p>
# Lucide Angular
A standalone, signal based, zoneless implementation of the Lucide icon library for Angular applications.
## Installation
```sh
pnpm add @lucide/angular
```
```sh
npm install @lucide/angular
```
```sh
yarn add @lucide/angular
```
```sh
bun add @lucide/angular
```
## Documentation
For full documentation, visit [lucide.dev](https://lucide.dev/guide/packages/angular)
## Migration guide
Migrating from `lucide-angular`? Read our [comprehensive migration guide](./MIGRATION.md).
## Community
Join the [Discord server](https://discord.gg/EH6nSts) to chat with the maintainers and other users.
## License
Lucide is licensed under the ISC license. See [LICENSE](https://lucide.dev/license).
## Sponsors
<a href="https://vercel.com?utm_source=lucide&utm_campaign=oss">
<img src="https://lucide.dev/vercel.svg" alt="Powered by Vercel" width="200" />
</a>
<a href="https://www.digitalocean.com/?refcode=b0877a2caebd&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge"><img src="https://lucide.dev/digitalocean.svg" width="200" alt="DigitalOcean Referral Badge" /></a>
### Awesome backers 🍺
<a href="https://www.scipress.io?utm_source=lucide"><img src="https://lucide.dev/sponsors/scipress.svg" width="180" alt="Scipress sponsor badge" /></a>
<a href="https://github.com/pdfme/pdfme"><img src="https://lucide.dev/sponsors/pdfme.svg" width="180" alt="pdfme sponsor badge" /></a>

View File

@@ -0,0 +1,51 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "pnpm"
},
"newProjectRoot": ".",
"projects": {
"@lucide/angular": {
"projectType": "library",
"root": ".",
"sourceRoot": "./src",
"prefix": "lib",
"architect": {
"build": {
"builder": "@angular/build:ng-packagr",
"configurations": {
"production": {
"tsConfig": "./tsconfig.lib.prod.json"
},
"development": {
"tsConfig": "./tsconfig.lib.json"
}
},
"defaultConfiguration": "production"
},
"test": {
"builder": "@angular/build:unit-test",
"options": {
"tsConfig": "./tsconfig.spec.json",
"coverage": true,
"coverageReporters": ["html", "lcov"],
"coverageExclude": ["src/icons/*"],
"coverageThresholds": {
"statements": 80,
"branches": 80,
"functions": 80,
"lines": 80
}
}
},
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
}
}
}
}
}
}

View File

@@ -0,0 +1,7 @@
{
"$schema": "./node_modules/ng-packagr/ng-package.schema.json",
"dest": "./dist",
"lib": {
"entryFile": "./src/public-api.ts"
}
}

View File

@@ -0,0 +1,72 @@
{
"name": "@lucide/angular",
"description": "A Lucide icon library package for Angular applications.",
"version": "0.0.1",
"author": "karsa-mistmere",
"license": "ISC",
"homepage": "https://lucide.dev",
"bugs": "https://github.com/lucide-icons/lucide/issues",
"repository": {
"type": "git",
"url": "https://github.com/lucide-icons/lucide.git",
"directory": "packages/angular"
},
"publishConfig": {
"directory": "dist"
},
"scripts": {
"ng": "ng",
"watch": "ng build --watch --configuration development",
"prebuild": "pnpm clean && pnpm copy:license && pnpm build:icons",
"build": "pnpm prebuild && pnpm build:ng",
"copy:license": "cp ../../LICENSE ./LICENSE",
"clean": "rm -rf dist && rm -rf ./src/icons/*.ts",
"build:icons": "build-icons --output=./src --templateSrc=./scripts/exportTemplate.mts --renderUniqueKey --iconFileExtension=.ts --exportFileName=lucide-angular.ts --useDefaultExports=0",
"build:ng": "ng build --configuration production",
"test": "pnpm prebuild && ng test --no-watch",
"test:watch": "ng test",
"lint": "npx eslint 'src/**/*.{js,jsx,ts,tsx,html,css,scss}' --quiet --fix",
"e2e": "ng e2e",
"version": "pnpm version --git-tag-version=false"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"devDependencies": {
"@angular-eslint/builder": "~21.1.0",
"@angular-eslint/eslint-plugin": "~21.1.0",
"@angular-eslint/eslint-plugin-template": "~21.1.0",
"@angular-eslint/schematics": "~21.1.0",
"@angular-eslint/template-parser": "~21.1.0",
"@angular/build": "^21.0.3",
"@angular/cli": "^21.0.3",
"@angular/common": "^21.0.0",
"@angular/compiler": "^21.0.0",
"@angular/compiler-cli": "^21.0.0",
"@angular/core": "^21.0.0",
"@angular/forms": "^21.0.0",
"@angular/platform-browser": "^21.0.0",
"@angular/router": "^21.0.0",
"@lucide/build-icons": "workspace:*",
"@lucide/helpers": "workspace:*",
"@vitest/browser-playwright": "^4.0.12",
"angular-eslint": "21.1.0",
"ng-packagr": "^21.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"typescript": "~5.9.2"
},
"peerDependencies": {
"@angular/common": "17.x - 21.x",
"@angular/core": "17.x - 21.x"
}
}

View File

@@ -0,0 +1,72 @@
import base64SVG from '@lucide/build-icons/utils/base64SVG';
import defineExportTemplate from '@lucide/build-icons/utils/defineExportTemplate';
import { toPascalCase } from '@lucide/helpers';
export default defineExportTemplate(async ({
componentName,
iconName,
getSvg,
deprecated,
deprecationReason,
iconData,
}) => {
const svgContents = await getSvg();
const svgBase64 = base64SVG(svgContents);
const angularComponentName = `Lucide${componentName}`;
const selectors = [`svg[lucide${toPascalCase(iconName)}]`];
const aliasComponentNames: string[] = [];
for (const alias of iconData.aliases ?? []) {
const aliasComponentName = `Lucide${toPascalCase(alias)}`;
const aliasSelector = `svg[lucide${toPascalCase(alias)}]`;
if (!selectors.includes(aliasSelector)) {
selectors.push(aliasSelector);
}
if (aliasComponentName !== angularComponentName && !aliasComponentNames.includes(aliasComponentName)) {
aliasComponentNames.push(aliasComponentName);
}
}
return `\
import { LucideIconBase } from '../lucide-icon-base';
import { lucideIconTemplate } from '../lucide-icon-template';
import { LucideIconData } from '../types';
import {
ChangeDetectionStrategy,
Component,
ViewEncapsulation,
signal,
} from '@angular/core';
/**
* @component @name ${componentName}
* @description Lucide SVG icon component, renders SVG Element with children.
*
* @preview ![img](data:image/svg+xml;base64,${svgBase64}) - https://lucide.dev/icons/${iconName}
* @see https://lucide.dev/guide/packages/angular - Documentation
*
* @param {Object} props - Lucide icons props and any valid SVG attribute
* ${deprecated ? `@deprecated ${deprecationReason}` : ''}
*/
@Component({
selector: '${selectors.join(', ')}',
template: lucideIconTemplate,
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ${angularComponentName} extends LucideIconBase {
static readonly icon: LucideIconData = ${JSON.stringify(iconData)};
protected override readonly icon = signal(${angularComponentName}.icon);
}
${aliasComponentNames.map((aliasComponentName) => {
return `
/**
* @deprecated
* @see ${angularComponentName}
*/
export const ${aliasComponentName} = ${angularComponentName};
`;
}).join(`\n\n`)}
`;
});

View File

@@ -0,0 +1,11 @@
export default {
xmlns: 'http://www.w3.org/2000/svg',
width: '24',
height: '24',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
};

View File

@@ -0,0 +1,25 @@
import { TestBed } from '@angular/core/testing';
import { LUCIDE_CONFIG, lucideDefaultConfig, provideLucideConfig } from './lucide-config';
describe('Lucide config', () => {
describe('LUCIDE_CONFIG', () => {
it('should use default', () => {
expect(TestBed.inject(LUCIDE_CONFIG)).toBe(lucideDefaultConfig);
});
});
describe('provideLucideConfig', () => {
it('should use defaults', () => {
TestBed.configureTestingModule({
providers: [
provideLucideConfig({
size: 18,
}),
],
});
expect(TestBed.inject(LUCIDE_CONFIG)).toEqual({
...lucideDefaultConfig,
size: 18,
});
});
});
});

View File

@@ -0,0 +1,67 @@
import { InjectionToken, Provider } from '@angular/core';
/**
* Lucide icon configuration options.
*/
export interface LucideConfig {
/**
* Stroke color.
* @default currentColor
*/
color: string;
/**
* Width and height.
* @default 24
*/
size: number;
/**
* Stroke width
* @default 2
*/
strokeWidth: number;
/**
* Whether stroke width should be scaled to appear uniform regardless of icon size.
* @default false
*
* @remarks
* Use CSS to set on SVG paths instead:
* ```css
* .lucide * {
* vector-effect: non-scaling-stroke;
* }
* ```
*/
absoluteStrokeWidth: boolean;
}
/**
* Default icon configuration options.
*/
export const lucideDefaultConfig: LucideConfig = {
color: 'currentColor',
size: 24,
strokeWidth: 2,
absoluteStrokeWidth: false,
};
/**
* Injection token for providing default configuration options.
*
* @internal Use {@link provideLucideConfig}
*/
export const LUCIDE_CONFIG = new InjectionToken<LucideConfig>('Lucide icon config', {
factory: () => lucideDefaultConfig,
});
/**
* Provider for default icon configuration options.
*/
export function provideLucideConfig(config: Partial<LucideConfig>): Provider {
return {
provide: LUCIDE_CONFIG,
useValue: {
...lucideDefaultConfig,
...config,
},
};
}

View File

@@ -0,0 +1,132 @@
import { Component, input, inputBinding, signal, WritableSignal } from '@angular/core';
import { LucideDynamicIcon } from './lucide-dynamic-icon';
import { LucideIconData, LucideIconInput } from './types';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { provideLucideIcons } from './lucide-icons';
import { LucideActivity } from './icons/activity';
import { By } from '@angular/platform-browser';
@Component({
template: `@if (icon(); as iconData) {
<svg [lucideIcon]="iconData">
<rect x="1" y="1" width="22" height="22" />
</svg>
}`,
imports: [LucideDynamicIcon],
})
class TestHostComponent {
readonly icon = input<LucideIconData>();
}
describe('LucideDynamicIcon', () => {
let component: LucideDynamicIcon;
let fixture: ComponentFixture<LucideDynamicIcon>;
let icon: WritableSignal<LucideIconInput | null | undefined>;
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
const testIcon: LucideIconData = {
name: 'demo',
node: [['polyline', { points: '1 1 22 22' }]],
};
const testIcon2: LucideIconData = {
name: 'demo-other',
node: [
['circle', { cx: 12, cy: 12, r: 8 }],
['polyline', { points: '1 1 22 22' }],
],
aliases: ['demo-2'],
};
function createComponent() {
return TestBed.createComponent(LucideDynamicIcon, {
inferTagName: true,
bindings: [inputBinding('lucideIcon', icon)],
});
}
beforeEach(async () => {
TestBed.configureTestingModule({
providers: [provideLucideIcons(testIcon)],
});
icon = signal('demo');
fixture = createComponent();
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should render children', () => {
icon.set(testIcon2);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe(
'<!--container--><circle cx="12" cy="12" r="8"></circle><polyline points="1 1 22 22"></polyline><!--ng-container-->',
);
});
it('should remove children on change', () => {
icon.set(null);
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe('<!--container--><!--ng-container-->');
});
describe('iconInput', () => {
it('should support LucideIconData input', () => {
icon.set(testIcon);
fixture.detectChanges();
expect(component['icon']()).toBe(testIcon);
expect(fixture.nativeElement.innerHTML).toBe(
'<!--container--><polyline points="1 1 22 22"></polyline><!--ng-container-->',
);
});
it('should support LucideIcon input', () => {
icon.set(LucideActivity);
fixture.detectChanges();
expect(component['icon']()).toBe(LucideActivity.icon);
});
it('should support string icon name', () => {
icon.set('demo');
fixture.detectChanges();
expect(component['icon']()).toBe(testIcon);
});
it('should throw error if no icon found', () => {
icon.set('invalid');
expect(() => fixture.detectChanges()).toThrowError(`Unable to resolve icon 'invalid'`);
});
});
describe('class', () => {
it('should add all classes', () => {
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-demo');
});
it('should add backwards compatible classes from aliases', () => {
icon.set(testIcon2);
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-demo-other lucide-demo-2');
});
it('should add class icon if available', () => {
icon.set(LucideActivity);
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-activity');
});
it('should remove class on change', () => {
icon.set(null);
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide');
});
});
describe('content projection', () => {
it('should project content', () => {
const hostFixture = TestBed.createComponent(TestHostComponent);
hostFixture.componentRef.setInput('icon', testIcon);
hostFixture.detectChanges();
hostFixture.componentRef.setInput('icon', testIcon2);
hostFixture.detectChanges();
const rect = hostFixture.debugElement.query(By.css('svg :last-child')).nativeElement;
expect(rect).toBeInstanceOf(SVGElement);
expect(rect.outerHTML).toBe('<rect x="1" y="1" width="22" height="22"></rect>');
});
});
});

View File

@@ -0,0 +1,43 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
ViewEncapsulation,
} from '@angular/core';
import { isLucideIconComponent, isLucideIconData, LucideIconInput } from './types';
import { LucideIconBase } from './lucide-icon-base';
import { LUCIDE_ICONS } from './lucide-icons';
import { lucideIconTemplate } from './lucide-icon-template';
/**
* Generic icon component for rendering LucideIconData.
*/
@Component({
selector: 'svg[lucideIcon]',
template: lucideIconTemplate,
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LucideDynamicIcon extends LucideIconBase {
protected readonly icons = inject(LUCIDE_ICONS);
public readonly lucideIcon = input.required<LucideIconInput | null>();
protected override readonly icon = computed(() => {
const icon = this.lucideIcon();
if (isLucideIconData(icon)) {
return icon;
} else if (isLucideIconComponent(icon)) {
return icon.icon;
} else if (typeof icon === 'string') {
if (icon in this.icons) {
return this.icons[icon];
} else {
throw new Error(`Unable to resolve icon '${icon}'`);
}
}
return null;
});
}

View File

@@ -0,0 +1,249 @@
import {
ChangeDetectionStrategy,
Component,
inputBinding,
signal,
ViewEncapsulation,
WritableSignal,
} from '@angular/core';
import { provideLucideConfig } from './lucide-config';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { lucideIconTemplate } from './lucide-icon-template';
import { LucideIconBase } from './lucide-icon-base';
import { LucideIconData } from './types';
@Component({
selector: 'svg[lucideCircleCheck], svg[lucideCheckCircle2]',
template: lucideIconTemplate,
standalone: true,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class LucideCircleCheck extends LucideIconBase {
static readonly icon: LucideIconData = {
name: 'circle-check',
size: 24,
node: [
['circle', { cx: '12', cy: '12', r: '10' }],
['path', { d: 'm9 12 2 2 4-4' }],
],
aliases: ['check-circle-2'],
};
protected override readonly icon = signal(LucideCircleCheck.icon);
}
describe('LucideIconBase', () => {
let component: LucideCircleCheck;
let fixture: ComponentFixture<LucideCircleCheck>;
let title: WritableSignal<string | undefined>;
let color: WritableSignal<string | undefined>;
let size: WritableSignal<string | number | undefined>;
let strokeWidth: WritableSignal<string | number | undefined>;
let absoluteStrokeWidth: WritableSignal<boolean | undefined>;
const getSvgAttribute = (attr: string) => fixture.nativeElement.getAttribute(attr);
function createComponent() {
return TestBed.createComponent(LucideCircleCheck, {
inferTagName: true,
bindings: [
inputBinding('title', title),
inputBinding('color', color),
inputBinding('size', size),
inputBinding('strokeWidth', strokeWidth),
inputBinding('absoluteStrokeWidth', absoluteStrokeWidth),
],
});
}
beforeEach(async () => {
title = signal(undefined);
color = signal(undefined);
size = signal(undefined);
strokeWidth = signal(undefined);
absoluteStrokeWidth = signal(undefined);
fixture = createComponent();
component = fixture.componentInstance;
});
it('should create', () => {
fixture.detectChanges();
expect(component).toBeTruthy();
});
it('should render children', () => {
fixture.detectChanges();
expect(fixture.nativeElement.innerHTML).toBe(
'<!--container--><circle cx="12" cy="12" r="10"></circle><path d="m9 12 2 2 4-4"></path><!--ng-container-->',
);
});
describe('class', () => {
it('should add all classes', () => {
fixture.detectChanges();
expect(getSvgAttribute('class')).toBe('lucide lucide-circle-check lucide-check-circle-2');
});
});
describe('color', () => {
it('should default to currentColor', () => {
fixture.detectChanges();
expect(getSvgAttribute('stroke')).toBe('currentColor');
});
it('should set color', () => {
color.set('red');
fixture.detectChanges();
expect(getSvgAttribute('stroke')).toBe('red');
});
});
describe('size', () => {
it('should default to 24', () => {
fixture.detectChanges();
expect(getSvgAttribute('width')).toBe('24');
expect(getSvgAttribute('height')).toBe('24');
});
it('should set size', () => {
size.set(12);
fixture.detectChanges();
expect(getSvgAttribute('width')).toBe('12');
expect(getSvgAttribute('height')).toBe('12');
});
it('should allow string size', () => {
size.set('18');
fixture.detectChanges();
expect(getSvgAttribute('width')).toBe('18');
expect(getSvgAttribute('height')).toBe('18');
});
it('should use default on invalid string', () => {
size.set('large');
fixture.detectChanges();
expect(getSvgAttribute('width')).toBe('24');
expect(getSvgAttribute('height')).toBe('24');
});
});
describe('strokeWidth', () => {
it('should default to 2', () => {
fixture.detectChanges();
expect(getSvgAttribute('stroke-width')).toBe('2');
});
it('should set stroke width', () => {
strokeWidth.set(1.41);
fixture.detectChanges();
expect(getSvgAttribute('stroke-width')).toBe('1.41');
});
it('should allow string stroke width', () => {
strokeWidth.set('1px');
fixture.detectChanges();
expect(getSvgAttribute('stroke-width')).toBe('1');
});
});
describe('absoluteStrokeWidth', () => {
it('should not adjust stroke width', () => {
strokeWidth.set(2);
size.set(12);
absoluteStrokeWidth.set(true);
fixture.detectChanges();
expect(getSvgAttribute('stroke-width')).toBe('2');
});
it('should not set vector-effect on children', () => {
absoluteStrokeWidth.set(false);
for (const child of fixture.nativeElement.children) {
expect(child.getAttribute('vector-effect')).toBeNull();
}
});
it('should set vector-effect on children', () => {
absoluteStrokeWidth.set(true);
for (const child of fixture.nativeElement.children) {
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
}
});
});
describe('title', () => {
it('should set title if provided', () => {
title.set('Foobar');
fixture.detectChanges();
const titleEl = fixture.debugElement.query(By.css('title')).nativeElement;
expect(titleEl).toBeDefined();
expect(titleEl.textContent).toBe('Foobar');
});
it('should not set aria-hidden when title is set', () => {
title.set('Foobar');
fixture.detectChanges();
expect(getSvgAttribute('aria-hidden')).toBe('false');
});
it('should set aria-hidden if no title is provided', () => {
title.set(undefined);
fixture.detectChanges();
expect(getSvgAttribute('aria-hidden')).toBe('true');
});
});
describe('LUCIDE_CONFIG', () => {
beforeEach(async () => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideLucideConfig({
color: 'red',
strokeWidth: 1,
size: 12,
absoluteStrokeWidth: true,
}),
],
});
await TestBed.compileComponents();
fixture = createComponent();
component = fixture.componentInstance;
});
describe('color', () => {
it('should use color from config', () => {
fixture.detectChanges();
expect(getSvgAttribute('stroke')).toBe('red');
});
it('should use override color from config', () => {
color.set('pink');
fixture.detectChanges();
expect(getSvgAttribute('stroke')).toBe('pink');
});
});
describe('strokeWidth', () => {
it('should use stroke width from config', () => {
fixture.detectChanges();
expect(getSvgAttribute('stroke-width')).toBe('1');
});
it('should use override stroke width from config', () => {
strokeWidth.set(3);
fixture.detectChanges();
expect(getSvgAttribute('stroke-width')).toBe('3');
});
});
describe('size', () => {
it('should use size from config', () => {
fixture.detectChanges();
expect(getSvgAttribute('width')).toBe('12');
expect(getSvgAttribute('height')).toBe('12');
});
it('should use override size from config', () => {
size.set('48');
fixture.detectChanges();
expect(getSvgAttribute('width')).toBe('48');
expect(getSvgAttribute('height')).toBe('48');
});
});
describe('absoluteStrokeWidth', () => {
it('should use absoluteStrokeWidth from config', () => {
for (const child of fixture.nativeElement.children) {
expect(child.getAttribute('vector-effect')).toBe('non-scaling-stroke');
}
});
it('should override absoluteStrokeWidth', () => {
absoluteStrokeWidth.set(false);
for (const child of fixture.nativeElement.children) {
expect(child.getAttribute('vector-effect')).toBeNull();
}
});
});
});
});

View File

@@ -0,0 +1,134 @@
import {
Component,
effect,
ElementRef,
inject,
input,
Renderer2,
Signal,
viewChild,
} from '@angular/core';
import { LUCIDE_CONFIG } from './lucide-config';
import { LucideIconData, Nullable } from './types';
import defaultAttributes from './default-attributes';
import { lucideIconTemplate } from './lucide-icon-template';
function transformNumericStringInput(
value: Nullable<string | number>,
defaultValue: number,
): number {
if (typeof value === 'string') {
const parsedValue = parseInt(value, 10);
if (isNaN(parsedValue)) {
return defaultValue;
}
return parsedValue;
}
return value ?? defaultValue;
}
/**
* @internal
*/
@Component({
// eslint-disable-next-line @angular-eslint/component-selector
selector: 'svg[lucideIcon]',
template: lucideIconTemplate,
host: {
...defaultAttributes,
class: 'lucide',
'[attr.width]': 'size().toString(10)',
'[attr.height]': 'size().toString(10)',
'[attr.stroke]': 'color()',
'[attr.stroke-width]': 'strokeWidth().toString(10)',
'[attr.aria-hidden]': '!title()',
},
})
export abstract class LucideIconBase {
protected abstract readonly icon: Signal<Nullable<LucideIconData>>;
protected readonly iconConfig = inject(LUCIDE_CONFIG);
protected readonly elRef = inject(ElementRef);
protected readonly renderer = inject(Renderer2);
protected readonly contentRef = viewChild.required<ElementRef>('contentRef');
/**
* An optional accessible label for the icon.
* - If provided, it will add the title as an [`<svg:title>` element](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/title).
* - If not provided, the component will add an `aria-hidden="true"` attribute automatically.
*
* @remarks
* Please refer to our [Accessibility guide](https://lucide.dev/guide/advanced/accessibility) regarding this matter.
* Adding accessible labels to icons is normally not necessary:
* - If your icon is decorative (as most icons are) just leave it as hidden from screen readers.
* - If your icon is interactive, it should be contained within an interactive element (e.g. button), and you should probably set your accessible label on that element.
* - If your icon is functional (e.g. used in place of a label), feel free to use this property.
*/
readonly title = input<Nullable<string>>();
/**
* Width and height.
* @default 24
*/
readonly size = input(this.iconConfig.size, {
transform: (value: Nullable<string | number>) =>
transformNumericStringInput(value, this.iconConfig.size),
});
/**
* Stroke color.
* @default currentColor
*/
readonly color = input(this.iconConfig.color, {
transform: (value: Nullable<string>) => value ?? this.iconConfig.color,
});
/**
* Stroke width
* @default 2
*/
readonly strokeWidth = input(this.iconConfig.strokeWidth, {
transform: (value: Nullable<string | number>) =>
transformNumericStringInput(value, this.iconConfig.strokeWidth),
});
/**
* If set to true, it adds [`vector-effect="non-scaling-stroke"`](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/vector-effect) to child elements.
*/
readonly absoluteStrokeWidth = input(this.iconConfig.absoluteStrokeWidth, {
transform: (value: Nullable<boolean>) => value ?? this.iconConfig.absoluteStrokeWidth,
});
constructor() {
effect((onCleanup) => {
const icon = this.icon();
if (icon) {
const absoluteStrokeWidth = this.absoluteStrokeWidth();
const { name, node, aliases = [] } = icon;
const classes = [name, ...aliases].map((item) => `lucide-${item}`);
for (const cssClass of classes) {
this.renderer.addClass(this.elRef.nativeElement, cssClass);
}
const contentRef = this.contentRef();
const refChild = contentRef.nativeElement;
const elements = node.map(([name, attrs]) => {
const element = this.renderer.createElement(name, 'http://www.w3.org/2000/svg');
if (absoluteStrokeWidth) {
this.renderer.setAttribute(element, 'vector-effect', 'non-scaling-stroke');
}
Object.entries(attrs).forEach(([name, value]) =>
this.renderer.setAttribute(
element,
name,
typeof value === 'number' ? value.toString(10) : value,
),
);
this.renderer.insertBefore(this.elRef.nativeElement, element, refChild);
return element;
});
onCleanup(() => {
elements.forEach((element) =>
this.renderer.removeChild(this.elRef.nativeElement, element),
);
for (const cssClass of classes) {
this.renderer.removeClass(this.elRef.nativeElement, cssClass);
}
});
}
});
}
}

View File

@@ -0,0 +1,10 @@
/**
* @internal
* The template of all Lucide icon components.
*/
export const lucideIconTemplate = `@if (title(); as titleValue) {
<title>{{ titleValue }}</title>
}
<ng-content select="title"></ng-content>
<ng-container #contentRef></ng-container>
<ng-content />`;

View File

@@ -0,0 +1,64 @@
import { TestBed } from '@angular/core/testing';
import {
LUCIDE_ICONS,
lucideLegacyIcon,
lucideLegacyIconMap,
provideLucideIcons,
} from './lucide-icons';
import { LucideIconData } from './types';
import { LucideCircle } from './icons/circle';
describe('Lucide icons', () => {
describe('LUCIDE_ICONS', () => {
it('should default to empty map', () => {
expect(TestBed.inject(LUCIDE_ICONS)).toEqual({});
});
});
describe('provideLucideIcons', () => {
const mockIcon: LucideIconData = {
name: 'mock-icon',
node: [['polyline', { points: '1 1 22 22' }]],
};
const mockIcon2: LucideIconData = {
name: 'mock-icon-circle',
node: [['circle', { cx: 12, cy: 12, r: 8 }]],
aliases: ['mock-icon-2'],
};
const legacyIconNode: LucideIconData['node'] = [['circle', { cx: 12, cy: 12, r: 8 }]];
const legacyAlias = 'legacy-old-name';
const OtherLegacyIcon = legacyIconNode;
it('should accept list of icon object, icon components or legacy icons', () => {
TestBed.configureTestingModule({
providers: [
provideLucideIcons(
mockIcon,
mockIcon2,
LucideCircle,
lucideLegacyIcon('legacy-icon', legacyIconNode, [legacyAlias]),
...lucideLegacyIconMap({ OtherLegacyIcon }),
),
],
});
const legacyIconData = {
name: 'legacy-icon',
node: legacyIconNode,
aliases: [legacyAlias],
};
const otherLegacyIconData = {
name: 'other-legacy-icon',
node: legacyIconNode,
aliases: ['OtherLegacyIcon'],
};
expect(TestBed.inject(LUCIDE_ICONS)).toEqual({
'mock-icon': mockIcon,
'mock-icon-circle': mockIcon2,
'mock-icon-2': mockIcon2,
'legacy-icon': legacyIconData,
'legacy-old-name': legacyIconData,
'other-legacy-icon': otherLegacyIconData,
OtherLegacyIcon: otherLegacyIconData,
['circle']: LucideCircle.icon,
});
});
});
});

View File

@@ -0,0 +1,105 @@
import { InjectionToken, Provider } from '@angular/core';
import { isLucideIconComponent, LucideIcon, LucideIconData, LucideIcons } from './types';
/**
* Injection token for providing Lucide icons by name.
*
* @internal Use {@link provideLucideIcons}
*/
export const LUCIDE_ICONS = new InjectionToken<LucideIcons>('Lucide icons', {
factory: () => ({}),
});
/**
* Provide Lucide icons by name.
*
* @remarks
* Warning! This provider will convert dictionary keys to lower-kebab-case.
*
* @param icons Either a dictionary of icons or a list of Angular icon components.
*
* @usage
* ```ts
* import { provideLucideIcons, SquareCheck } from '@lucide/angular';
* import { MyCustomIcon } from './custom-icons/my-custom-icon';
*
* providers: [
* provideLucideIcons({
* SquareCheck,
* MyCustomIcon, // LucideIconData
* }),
* ]
* ```
*
* ```html
* <svg lucideIcon="my-custom-icon" />
* ```
*/
export function provideLucideIcons(...icons: Array<LucideIcon | LucideIconData>): Provider {
return {
provide: LUCIDE_ICONS,
useValue: icons.reduce((acc, icon) => {
const iconData = isLucideIconComponent(icon) ? icon.icon : icon;
acc[iconData.name] = iconData;
for (const alias of iconData.aliases ?? []) {
acc[alias] = iconData;
}
return acc;
}, {} as LucideIcons),
};
}
/**
* Converts a legacy icon node to the new format, for custom icon (e.g. `@lucide/lab`) support.
*
* @usage
* ```ts
* import { provideLucideIcons, lucideLegacyIcon } from '@lucide/angular';
* import { UserRoundX } from 'lucide-angular';
* import { burger } from '@lucide/lab';
*
* provideLucideIcons(
* lucideLegacyIcon('user-round-x', UserRoundX, ['user-circle-x']),
* lucideLegacyIcon('burger', burger, ['hamburger']),
* ),
* ```
*/
export function lucideLegacyIcon(
name: string,
node: LucideIconData['node'],
aliases: string[] = [],
): LucideIconData {
return {
name,
node,
aliases,
};
}
/**
* Converts a map of legacy icon nodes to a list of icon data objects.
*
* @usage
* ```ts
* import { provideLucideIcons, lucideLegacyIconMap, LucideCircle } from '@lucide/angular';
* import { UserRoundX } from 'lucide-angular';
* import { burger } from '@lucide/lab';
*
* provideLucideIcons(
* LucideCircle,
* ...lucideLegacyIconMap({ UserRoundX, burger }),
* ),
* ```
*/
export function lucideLegacyIconMap(
icons: Record<string, LucideIconData['node']>,
): LucideIconData[] {
return Object.entries(icons).map(([pascalName, node]) => {
const name: string = pascalName.replaceAll(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
return {
name,
node,
aliases: [pascalName],
};
});
}

View File

@@ -0,0 +1,10 @@
import * as icons from './icons/lucide-angular';
export * from './lucide-config';
export * from './lucide-dynamic-icon';
export * from './lucide-icon-base';
export * from './lucide-icon-template';
export * from './lucide-icons';
export * from './types';
export * from './icons/lucide-angular';
export { icons };

View File

@@ -0,0 +1,61 @@
import { Signal, Type } from '@angular/core';
type HtmlAttributes = { [key: string]: string | number };
export type LucideIconNode = readonly [string, HtmlAttributes];
export type LucideIcons = { [key: string]: LucideIconData };
/**
* A Lucide icon object that fully describes an icon to be displayed.
*/
export type LucideIconData = {
name: string;
size?: number;
node: LucideIconNode[];
aliases?: string[];
};
/**
* Input signal map of Lucide icon components.
*/
interface LucideIconProps {
title: Signal<Nullable<string>>;
size: Signal<Nullable<number>>;
color: Signal<Nullable<string>>;
strokeWidth: Signal<Nullable<number>>;
absoluteStrokeWidth: Signal<Nullable<boolean>>;
}
/**
* Represents a Lucide icon component type that has `iconName` and `iconData` signals inherited from `LucideIconBase` and respective static members accessible without instantiating the component.
*/
export interface LucideIcon extends Type<LucideIconProps> {
icon: LucideIconData;
}
/**
* Type guard for {@link LucideIconData}
*/
export function isLucideIconData(icon: unknown): icon is LucideIconData {
return (
!!icon &&
typeof icon === 'object' &&
'name' in icon &&
typeof icon.name === 'string' &&
'node' in icon &&
Array.isArray(icon.node)
);
}
/**
* Type guard for {@link LucideIcon}
*/
export function isLucideIconComponent(icon: unknown): icon is LucideIcon {
return icon instanceof Type && 'icon' in icon && isLucideIconData(icon.icon);
}
export type LucideIconInput = LucideIcon | LucideIconData | string;
/**
* @internal
*/
export type Nullable<T> = T | null | undefined;

View File

@@ -0,0 +1,38 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"compileOnSave": false,
"compilerOptions": {
"paths": {
"@lucide/angular": ["./dist"],
},
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"declaration": false,
"downlevelIteration": true,
"experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
"module": "preserve",
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true,
},
"files": [],
"references": [
{
"path": "./tsconfig.lib.json",
},
{
"path": "./tsconfig.spec.json",
},
],
}

View File

@@ -0,0 +1,14 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/lib",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": ["**/*.spec.ts"]
}

View File

@@ -0,0 +1,11 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.lib.json",
"compilerOptions": {
"declarationMap": false
},
"angularCompilerOptions": {
"compilationMode": "partial"
}
}

View File

@@ -0,0 +1,10 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": ["vitest/globals"]
},
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
}

View File

@@ -58,7 +58,6 @@
"prettier": "^3.4.2",
"typescript": "^5.8.3",
"vite": "^6.3.6",
"vitest": "^4.0.12",
"astro": "^5.16.0"
},
"peerDependencies": {

View File

@@ -52,8 +52,7 @@
"rollup": "^4.59.0",
"rollup-plugin-dts": "^6.2.3",
"typescript": "^5.8.3",
"vite": "^7.2.4",
"vitest": "^4.0.12"
"vite": "^7.2.4"
},
"peerDependencies": {
"preact": "^10.27.2"

View File

@@ -72,8 +72,7 @@
"rollup": "^4.59.0",
"rollup-plugin-dts": "^6.2.3",
"typescript": "^5.8.3",
"vite": "^7.2.4",
"vitest": "^4.0.12"
"vite": "^7.2.4"
},
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0",

View File

@@ -63,8 +63,7 @@
"rollup-plugin-dts": "^6.2.3",
"rollup-plugin-preserve-directives": "^0.4.0",
"typescript": "^5.8.3",
"vite": "^7.2.4",
"vitest": "^4.0.12"
"vite": "^7.2.4"
},
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"

View File

@@ -85,7 +85,6 @@
"typescript": "^5.8.3",
"vite": "^7.2.4",
"vite-plugin-solid": "^2.11.6",
"vitest": "^4.0.12",
"esbuild": "^0.25.0"
},
"peerDependencies": {

View File

@@ -49,7 +49,7 @@
"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",
"clean": "rm -rf dist && rm -rf stats && rm -rf ./src/icons/*.svelte && rm -rf ./src/icons/*.ts && 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",
@@ -66,13 +66,11 @@
"@testing-library/svelte": "^4.0.2",
"@tsconfig/svelte": "^5.0.0",
"jest-serializer-html": "^7.1.0",
"jsdom": "^20.0.3",
"svelte": "^4.2.19",
"svelte-check": "^3.4.4",
"svelte-preprocess": "^5.0.4",
"typescript": "^5.8.3",
"vite": "^7.2.4",
"vitest": "^4.0.12"
"vite": "^7.2.4"
},
"peerDependencies": {
"svelte": "^3 || ^4 || ^5.0.0-next.42"

View File

@@ -52,7 +52,6 @@
"rollup": "^4.59.0",
"rollup-plugin-dts": "^6.2.3",
"typescript": "^5.8.3",
"vite": "^7.2.4",
"vitest": "^4.0.12"
"vite": "^7.2.4"
}
}

View File

@@ -13,7 +13,6 @@
"test:watch": "vitest watch"
},
"devDependencies": {
"vite": "^7.2.4",
"vitest": "^4.0.12"
"vite": "^7.2.4"
}
}

View File

@@ -72,8 +72,7 @@
"svelte-check": "^4.3.4",
"svelte-preprocess": "^6.0.3",
"typescript": "^5.8.3",
"vite": "^6.3.6",
"vitest": "^4.0.12"
"vite": "^6.3.6"
},
"peerDependencies": {
"svelte": "^5"

View File

@@ -54,7 +54,6 @@
"rollup": "^4.59.0",
"typescript": "^5.8.3",
"vite": "^7.2.4",
"vitest": "^4.0.12",
"vue": "^3.4.21"
},
"peerDependencies": {

5134
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ export default async function generateExportFile(
iconNodes: Record<string, INode>,
exportModuleNameCasing: 'camel' | 'pascal',
iconFileExtension = '',
useDefaultExports = true,
) {
const fileName = path.basename(inputEntry);
@@ -25,7 +26,9 @@ export default async function generateExportFile(
} else if (exportModuleNameCasing === 'pascal') {
componentName = toPascalCase(iconName);
}
const importString = `export { default as ${componentName} } from './${iconName}${iconFileExtension}';\n`;
const importString = `export ${
useDefaultExports ? `{ default as ${componentName} }` : `*`
} from './${iconName}${iconFileExtension}';\n`;
return appendFile(importString, fileName, outputDirectory);
});

View File

@@ -84,7 +84,7 @@ function generateIconFiles({
singleQuote: true,
trailingComma: 'all',
printWidth: 100,
parser: 'babel',
parser: iconFileExtension.endsWith('.ts') ? 'babel-ts' : 'babel',
})
: elementTemplate;

View File

@@ -31,6 +31,7 @@ interface CliArguments {
separateIconFileExportExtension?: string;
aliasesFileExtension?: string;
aliasImportFileExtension?: string;
useDefaultExports?: boolean;
pretty?: boolean;
output: string | undefined;
}
@@ -62,6 +63,7 @@ const {
separateIconFileExportExtension = undefined,
aliasesFileExtension = '.js',
aliasImportFileExtension = '',
useDefaultExports = true,
pretty = true,
} = cliArguments;
@@ -125,6 +127,7 @@ async function buildIcons() {
icons,
exportModuleNameCasing,
importImportFileExtension,
useDefaultExports,
);
}

View File

@@ -19,7 +19,6 @@ export interface ExportTemplate {
getSvg: () => Promise<string>;
deprecated: boolean;
deprecationReason: string;
aliases?: (string | AliasDeprecation)[];
iconData: IconData;
}