Skip to content

Commit 82180e5

Browse files
committed
fix(icon): cIcon directive [name] binding does not refresh icon for Angular 17 #203, refactor
1 parent cf954d3 commit 82180e5

File tree

4 files changed

+146
-98
lines changed

4 files changed

+146
-98
lines changed

projects/coreui-icons-angular/src/lib/icon/icon.component.svg

+1-1
Loading
Original file line numberDiff line numberDiff line change
@@ -1,96 +1,122 @@
11
import { NgClass } from '@angular/common';
2-
import { AfterViewInit, Component, ElementRef, Input, Renderer2, ViewChild } from '@angular/core';
3-
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
2+
import {
3+
AfterViewInit,
4+
Component,
5+
computed,
6+
ElementRef,
7+
inject,
8+
Input,
9+
Renderer2,
10+
signal,
11+
ViewChild
12+
} from '@angular/core';
13+
import { DomSanitizer } from '@angular/platform-browser';
414

515
import { HtmlAttributesDirective } from '../shared/html-attr.directive';
616
import { IconSetService } from '../icon-set';
717
import { IconSize, IIcon } from './icon.interface';
8-
import { toCamelCase } from './icon.utils';
18+
import { transformName } from './icon.utils';
919

1020
@Component({
21+
exportAs: 'cIconComponent',
22+
imports: [NgClass, HtmlAttributesDirective],
1123
selector: 'c-icon',
12-
templateUrl: './icon.component.svg',
13-
styleUrls: ['./icon.component.scss'],
1424
standalone: true,
15-
imports: [NgClass, HtmlAttributesDirective],
25+
styleUrls: ['./icon.component.scss'],
26+
templateUrl: './icon.component.svg',
1627
// eslint-disable-next-line @angular-eslint/no-host-metadata-property
1728
host: { ngSkipHydration: 'true' }
1829
})
1930
export class IconComponent implements IIcon, AfterViewInit {
2031

32+
readonly #renderer = inject(Renderer2);
33+
readonly #elementRef = inject(ElementRef);
34+
readonly #sanitizer = inject(DomSanitizer);
35+
readonly #iconSet = inject(IconSetService);
36+
37+
constructor() {
38+
this.#renderer.setStyle(this.#elementRef.nativeElement, 'display', 'none');
39+
}
40+
41+
@Input()
42+
set content(value: string | string[] | any[]) {
43+
this.#content.set(value);
44+
};
45+
46+
readonly #content = signal<string | string[] | any[]>('');
47+
2148
@Input() attributes: any = { role: 'img' };
22-
@Input() content?: string | string[] | any[];
49+
@Input() customClasses?: string | string[] | Set<string> | { [klass: string]: any };
2350
@Input() size: IconSize = '';
2451
@Input() title?: string;
2552
@Input() use = '';
26-
@Input() customClasses?: string | string[] | Set<string> | { [klass: string]: any } = '';
27-
@Input() width?: string;
2853
@Input() height?: string;
54+
@Input() width?: string;
2955

30-
@Input({ transform: (value: string) => value && value.includes('-') ? toCamelCase(value) : value }) name!: string;
56+
@Input({ transform: transformName })
57+
set name(value: string) {
58+
this.#name.set(value);
59+
};
60+
61+
get name() {
62+
return this.#name();
63+
}
64+
65+
readonly #name = signal('');
3166

3267
@Input()
3368
set viewBox(viewBox: string) {
3469
this._viewBox = viewBox;
3570
}
3671

3772
get viewBox(): string {
38-
return this._viewBox ?? this.scale;
73+
return this._viewBox ?? this.scale();
3974
}
4075

4176
private _viewBox!: string;
4277

4378
@ViewChild('svgElement', { read: ElementRef }) svgElementRef!: ElementRef;
4479

45-
get innerHtml(): SafeHtml {
46-
const code = Array.isArray(this.code) ? this.code[1] || this.code[0] : this.code ?? '';
47-
// todo proper sanitize
48-
// const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, code);
49-
return this.sanitizer.bypassSecurityTrustHtml((this.titleCode + code) ?? '');
50-
}
51-
52-
constructor(
53-
private renderer: Renderer2,
54-
private elementRef: ElementRef,
55-
private sanitizer: DomSanitizer,
56-
private iconSet: IconSetService
57-
) {
58-
this.renderer.setStyle(this.elementRef.nativeElement, 'display', 'none');
59-
}
60-
6180
ngAfterViewInit(): void {
62-
this.elementRef.nativeElement.classList.forEach((item: string) => {
63-
this.renderer.addClass(this.svgElementRef.nativeElement, item);
81+
this.#elementRef.nativeElement.classList.forEach((item: string) => {
82+
this.#renderer.addClass(this.svgElementRef.nativeElement, item);
6483
});
65-
const parentElement = this.renderer.parentNode(this.elementRef.nativeElement);
84+
const parentElement = this.#renderer.parentNode(this.#elementRef.nativeElement);
6685
const svgElement = this.svgElementRef.nativeElement;
67-
this.renderer.insertBefore(parentElement, svgElement, this.elementRef.nativeElement);
68-
this.renderer.removeChild(parentElement, this.elementRef.nativeElement);
86+
this.#renderer.insertBefore(parentElement, svgElement, this.#elementRef.nativeElement);
87+
this.#renderer.removeChild(parentElement, this.#elementRef.nativeElement);
6988
}
7089

90+
readonly innerHtml = computed(() => {
91+
const code = Array.isArray(this.code()) ? (this.code()[1] ?? this.code()[0] ?? '') : this.code() || '';
92+
// todo proper sanitize
93+
// const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, code);
94+
return this.#sanitizer.bypassSecurityTrustHtml((this.titleCode + code) || '');
95+
});
96+
7197
get titleCode(): string {
7298
return this.title ? `<title>${this.title}</title>` : '';
7399
}
74100

75-
get code(): string | string[] | undefined {
76-
if (this.content) {
77-
return this.content;
101+
readonly code = computed(() => {
102+
if (this.#content()) {
103+
return this.#content();
78104
}
79-
if (this.iconSet && this.name) {
80-
return this.iconSet.getIcon(this.name);
105+
if (this.#iconSet && this.#name()) {
106+
return this.#iconSet.getIcon(this.#name());
81107
}
82-
if (this.name && !this.iconSet?.icons[this.name]) {
83-
console.warn(`c-icon component: icon name '${this.name}' does not exist for IconSet service. ` +
108+
if (this.#name() && !this.#iconSet?.icons[this.#name()]) {
109+
console.warn(`c-icon component: icon name '${this.#name()}' does not exist for IconSet service. ` +
84110
`To use icon by 'name' prop you need to add it to IconSet service. \n`,
85-
this.name
111+
this.#name()
86112
);
87113
}
88-
return undefined;
89-
}
114+
return '';
115+
});
90116

91-
get scale(): string {
92-
return Array.isArray(this.code) && this.code.length > 1 ? `0 0 ${this.code[0]}` : '0 0 64 64';
93-
}
117+
readonly scale = computed(() => {
118+
return Array.isArray(this.code()) && this.code().length > 1 ? `0 0 ${this.code()[0]}` : '0 0 64 64';
119+
});
94120

95121
get computedSize(): Exclude<IconSize, 'custom'> | undefined {
96122
const addCustom = !this.size && (this.width || this.height);
@@ -102,10 +128,7 @@ export class IconComponent implements IIcon, AfterViewInit {
102128
icon: true,
103129
[`icon-${this.computedSize}`]: !!this.computedSize
104130
};
105-
return !this.customClasses ? classes : this.customClasses;
131+
return this.customClasses ?? classes;
106132
}
107133

108-
toCamelCase(str: string): string {
109-
return toCamelCase(str);
110-
}
111134
}
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,60 @@
1-
import { afterNextRender, AfterRenderPhase, Directive, ElementRef, HostBinding, Input, Renderer2 } from '@angular/core';
1+
import {
2+
afterNextRender,
3+
AfterRenderPhase,
4+
computed,
5+
Directive,
6+
ElementRef,
7+
HostBinding,
8+
inject,
9+
Input,
10+
signal
11+
} from '@angular/core';
212
import { DomSanitizer } from '@angular/platform-browser';
313

414
import { IconSetService } from '../icon-set';
515
import { IconSize, IIcon } from './icon.interface';
6-
import { toCamelCase } from './icon.utils';
16+
import { transformName } from './icon.utils';
717

818
@Directive({
9-
selector: 'svg[cIcon]',
1019
exportAs: 'cIcon',
20+
selector: 'svg[cIcon]',
1121
standalone: true
1222
})
1323
export class IconDirective implements IIcon {
1424

15-
@Input('cIcon') content?: string | string[] | any[];
25+
readonly #elementRef = inject(ElementRef);
26+
readonly #sanitizer = inject(DomSanitizer);
27+
readonly #iconSet = inject(IconSetService);
28+
29+
constructor() {
30+
afterNextRender(() => {
31+
this.#elementRef.nativeElement.innerHTML = this.innerHtml();
32+
}, { phase: AfterRenderPhase.Write });
33+
}
34+
35+
@Input('cIcon')
36+
set content(value: string | string[] | any[]) {
37+
this.#content.set(value);
38+
};
39+
40+
readonly #content = signal<string | string[] | any[]>('');
41+
42+
@Input() customClasses?: string | string[] | Set<string> | { [klass: string]: any };
1643
@Input() size: IconSize = '';
1744
@Input() title?: string;
18-
@Input() customClasses?: string | string[] | Set<string> | { [klass: string]: any };
19-
@Input() width?: string;
2045
@Input() height?: string;
46+
@Input() width?: string;
47+
48+
@Input({ transform: transformName })
49+
set name(value: string) {
50+
this.#name.set(value);
51+
};
52+
53+
get name() {
54+
return this.#name();
55+
}
2156

22-
@Input({ transform: (value: string) => value && value.includes('-') ? toCamelCase(value) : value }) name!: string;
57+
readonly #name = signal('');
2358

2459
@HostBinding('attr.viewBox')
2560
@Input()
@@ -28,7 +63,7 @@ export class IconDirective implements IIcon {
2863
}
2964

3065
get viewBox(): string {
31-
return this._viewBox ?? this.scale;
66+
return this._viewBox ?? this.scale();
3267
}
3368

3469
private _viewBox!: string;
@@ -46,55 +81,44 @@ export class IconDirective implements IIcon {
4681

4782
@HostBinding('class')
4883
get hostClasses() {
49-
const classes = {
50-
icon: true,
51-
[`icon-${this.computedSize}`]: !!this.computedSize
52-
};
53-
return this.customClasses ?? classes;
84+
return this.computedClasses;
5485
}
5586

56-
// @HostBinding('innerHtml')
57-
get innerHtml() {
58-
const code = Array.isArray(this.code) ? this.code[1] || this.code[0] : this.code ?? '';
59-
// todo proper sanitize
60-
// const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, code);
61-
return this.sanitizer.bypassSecurityTrustHtml((this.titleCode + code) ?? '');
87+
@HostBinding('innerHtml')
88+
get bindInnerHtml() {
89+
return this.innerHtml();
6290
}
6391

64-
constructor(
65-
private renderer: Renderer2,
66-
private elementRef: ElementRef,
67-
private sanitizer: DomSanitizer,
68-
private iconSet: IconSetService
69-
) {
70-
afterNextRender(() => {
71-
this.elementRef.nativeElement.innerHTML = this.innerHtml;
72-
}, { phase: AfterRenderPhase.Write });
73-
}
92+
readonly innerHtml = computed(() => {
93+
const code = Array.isArray(this.code()) ? (this.code()[1] ?? this.code()[0] ?? '') : this.code() || '';
94+
// todo proper sanitize
95+
// const sanitized = this.sanitizer.sanitize(SecurityContext.HTML, code);
96+
return this.#sanitizer.bypassSecurityTrustHtml((this.titleCode + code) || '');
97+
});
7498

7599
get titleCode(): string {
76100
return this.title ? `<title>${this.title}</title>` : '';
77101
}
78102

79-
get code(): string | string[] | undefined {
80-
if (this.content) {
81-
return this.content;
103+
readonly code = computed(() => {
104+
if (this.#content()) {
105+
return this.#content();
82106
}
83-
if (this.iconSet && this.name) {
84-
return this.iconSet.getIcon(this.name);
107+
if (this.#iconSet && this.#name()) {
108+
return this.#iconSet.getIcon(this.#name());
85109
}
86-
if (this.name && !this.iconSet?.icons[this.name]) {
87-
console.warn(`c-icon component: icon name '${this.name}' does not exist for IconSet service. ` +
110+
if (this.#name() && !this.#iconSet?.icons[this.#name()]) {
111+
console.warn(`c-icon component: icon name '${this.#name()}' does not exist for IconSet service. ` +
88112
`To use icon by 'name' prop you need to add it to IconSet service. \n`,
89-
this.name
113+
this.#name()
90114
);
91115
}
92-
return undefined;
93-
}
116+
return '';
117+
});
94118

95-
get scale(): string {
96-
return Array.isArray(this.code) && this.code.length > 1 ? `0 0 ${this.code[0]}` : '0 0 64 64';
97-
}
119+
readonly scale = computed(() => {
120+
return Array.isArray(this.code()) && this.code().length > 1 ? `0 0 ${this.code()[0]}` : '0 0 64 64';
121+
});
98122

99123
get computedSize(): Exclude<IconSize, 'custom'> | undefined {
100124
const addCustom = !this.size && (this.width || this.height);
@@ -106,10 +130,7 @@ export class IconDirective implements IIcon {
106130
icon: true,
107131
[`icon-${this.computedSize}`]: !!this.computedSize
108132
};
109-
return !this.customClasses ? classes : this.customClasses;
133+
return this.customClasses ?? classes;
110134
}
111135

112-
toCamelCase(str: string): string {
113-
return toCamelCase(str);
114-
}
115136
}
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
export function toCamelCase(str: string) {
2-
return str.replace(/([-_][a-z0-9])/ig, ($1: string) => {
1+
export function toCamelCase(value: string) {
2+
return value.replace(/([-_][a-z0-9])/ig, ($1: string) => {
33
return $1.toUpperCase().replace('-', '');
44
});
55
}
6+
7+
export function transformName(value: string) {
8+
return value && value.includes('-') ? toCamelCase(value) : value;
9+
}

0 commit comments

Comments
 (0)