Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion api-goldens/element-ng/icon/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
```ts

import * as _angular_core from '@angular/core';
import * as _angular_platform_browser from '@angular/platform-browser';
import { EntityStatusType } from '@siemens/element-ng/common';
import { InjectionToken } from '@angular/core';
import { Provider } from '@angular/core';
Expand Down
3 changes: 1 addition & 2 deletions api-goldens/element-ng/theme/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import { EventEmitter } from '@angular/core';
import * as i0 from '@angular/core';
import { Observable } from 'rxjs';
import { SafeHtml } from '@angular/platform-browser';

// @public (undocumented)
export const ELEMENT_THEME_NAME = "element";
Expand Down Expand Up @@ -54,7 +53,7 @@ export class SiThemeService {
get resolvedColorScheme(): ThemeType | undefined;
setActiveTheme(name?: string, type?: ThemeType): Observable<boolean>;
readonly themeChange: EventEmitter<Theme | undefined>;
readonly themeIcons: i0.WritableSignal<Record<string, SafeHtml>>;
readonly themeIcons: i0.WritableSignal<Record<string, string>>;
readonly themeNames$: Observable<string[]>;
get themeNames(): string[];
updateProperty(name: string, value: string, type: keyof ThemeColorSchemes): void;
Expand Down
13 changes: 3 additions & 10 deletions projects/element-ng/avatar/si-avatar.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,7 @@ img {
inset-inline-end: var(--indicator-offset-x);
font-size: var(--indicator-size);

.drop-shadow {
::ng-deep {
svg {
filter: drop-shadow(1px 0 0 variables.$element-base-1)
drop-shadow(-1px 0 0 variables.$element-base-1)
drop-shadow(0 1px 0 variables.$element-base-1)
drop-shadow(0 -1px 0 variables.$element-base-1);
}
}
}
filter: drop-shadow(1px 0 0 variables.$element-base-1)
drop-shadow(-1px 0 0 variables.$element-base-1) drop-shadow(0 1px 0 variables.$element-base-1)
drop-shadow(0 -1px 0 variables.$element-base-1);
}
14 changes: 9 additions & 5 deletions projects/element-ng/icon/si-icon.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@
font-weight: normal;
vertical-align: middle;
line-height: 1;
}

::ng-deep svg {
display: block;
block-size: 1em;
fill: currentColor;
}
.svg-element-icon {
background-color: currentColor;
mask-image: var(--svg-element-icon);
mask-repeat: no-repeat;
mask-size: 100% 100%;
mask-position: center;
block-size: 1em;
inline-size: 1em;
}
15 changes: 10 additions & 5 deletions projects/element-ng/icon/si-icon.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ describe('SiSvgIconComponent', () => {
let component: TestHostComponent;
let fixture: ComponentFixture<TestHostComponent>;

const getIconHostStyle = (): string =>
(fixture.nativeElement.querySelector('si-icon') as HTMLElement).style.getPropertyValue(
'--svg-element-icon'
);

@Component({
imports: [SiIconComponent],
template: ` <si-icon [icon]="icon()" />`
Expand Down Expand Up @@ -60,7 +65,7 @@ describe('SiSvgIconComponent', () => {
});

it('should load icon', () => {
expect(document.getElementById('svg-2')).toBeTruthy();
expect(getIconHostStyle()).toContain('svg-2');
});

it('should load icon override from theme', () => {
Expand All @@ -74,7 +79,7 @@ describe('SiSvgIconComponent', () => {
}
});
fixture.detectChanges();
expect(document.getElementById('svg-oem')).toBeTruthy();
expect(getIconHostStyle()).toContain('svg-oem');
});
});

Expand All @@ -85,7 +90,7 @@ describe('SiSvgIconComponent', () => {
});

it('should load icon', () => {
expect(document.getElementById('svg')).toBeTruthy();
expect(getIconHostStyle()).toContain('svg');
});

it('should load icon override from theme', () => {
Expand All @@ -99,7 +104,7 @@ describe('SiSvgIconComponent', () => {
}
});
fixture.detectChanges();
expect(document.getElementById('svg-oem')).toBeTruthy();
expect(getIconHostStyle()).toContain('svg-oem');
});
});
});
Expand All @@ -114,7 +119,7 @@ describe('SiSvgIconComponent', () => {
it('should always use the icon-font', () => {
component.icon.set('element-svg');
fixture.detectChanges();
expect(document.getElementById('svg')).toBeFalsy();
expect(getIconHostStyle()).toBe('');
expect(fixture.debugElement.query(By.css('.element-svg'))).toBeTruthy();
});
});
Expand Down
9 changes: 3 additions & 6 deletions projects/element-ng/icon/si-icon.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,15 +56,12 @@ export const provideIconConfig = (config: IconConfig): Provider => ({
*/
@Component({
selector: 'si-icon',
template: ` <div
aria-hidden="true"
[class]="svgIcon() ? '' : fontIcon()"
[innerHTML]="svgIcon()"
></div>`,
template: ` <div aria-hidden="true" [class]="svgIcon() ? 'svg-element-icon' : fontIcon()"></div>`,
styleUrl: './si-icon.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.data-icon]': 'icon()'
'[attr.data-icon]': 'icon()',
'[style.--svg-element-icon]': 'svgIcon()'
}
})
export class SiIconComponent {
Expand Down
19 changes: 5 additions & 14 deletions projects/element-ng/icon/si-icons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,15 @@
* SPDX-License-Identifier: MIT
*/
import { DestroyRef, inject, Injectable } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { SiThemeService } from '@siemens/element-ng/theme';

interface RegisteredIcon {
content: SafeHtml | undefined;
content: string | undefined;
// Count how often an icon was registered to only remove it if it is no longer in use.
referenceCount: number;
}

const parseDataSvgIcon = (icon: string, domSanitizer: DomSanitizer): SafeHtml => {
const parsed = /^data:image\/svg\+xml;utf8,(.*)$/.exec(icon);
if (!parsed) {
console.error('Failed to parse icon', icon);
return '';
}
return domSanitizer.bypassSecurityTrustHtml(parsed[1]);
};
const parseDataSvgIcon = (icon: string): string => `url("${icon}")`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of parseDataSvgIcon does not handle double quotes within the icon string, which can lead to broken image URLs. If an icon's SVG data contains a double quote, it will prematurely terminate the string within the url() function, causing the icon to fail to render. To fix this, you should escape any double quotes within the icon string.

Suggested change
const parseDataSvgIcon = (icon: string): string => `url("${icon}")`;
const parseDataSvgIcon = (icon: string): string => `url("${icon.replace(/"/g, '\\"')}")`;


const registeredIcons = new Map<string, RegisteredIcon>();

Expand All @@ -46,10 +38,9 @@ const registeredIcons = new Map<string, RegisteredIcon>();
*/
export const addIcons = <T extends string>(icons: Record<T, string>): Record<T, string> => {
const iconMap = {} as Record<T, string>;
const domSanitizer = inject(DomSanitizer);
for (const [key, rawContent] of Object.entries<string>(icons)) {
const registeredIcon = registeredIcons.get(key) ?? {
content: parseDataSvgIcon(rawContent, domSanitizer),
content: parseDataSvgIcon(rawContent),
referenceCount: 0
};
registeredIcon.referenceCount++;
Expand All @@ -74,13 +65,13 @@ export const addIcons = <T extends string>(icons: Record<T, string>): Record<T,
return iconMap;
};

const getIcon = (key: string): SafeHtml | undefined => registeredIcons.get(key)?.content;
const getIcon = (key: string): string | undefined => registeredIcons.get(key)?.content;

@Injectable({ providedIn: 'root' })
export class IconService {
private themeService = inject(SiThemeService);

getIcon(name: string): SafeHtml | undefined {
getIcon(name: string): string | undefined {
const camelCaseName = this.kebabToCamelCase(name);
return this.themeService.themeIcons()[camelCaseName] ?? getIcon(camelCaseName);
}
Expand Down
20 changes: 5 additions & 15 deletions projects/element-ng/theme/si-theme.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
* SPDX-License-Identifier: MIT
*/
import { isPlatformBrowser } from '@angular/common';
import { EventEmitter, inject, Injectable, PLATFORM_ID, signal, DOCUMENT } from '@angular/core';
import { DomSanitizer, Meta, SafeHtml } from '@angular/platform-browser';
import { DOCUMENT, EventEmitter, inject, Injectable, PLATFORM_ID, signal } from '@angular/core';
import { Meta } from '@angular/platform-browser';
import { Observable, of, ReplaySubject, throwError } from 'rxjs';
import { map, switchMap, take, tap } from 'rxjs/operators';

Expand Down Expand Up @@ -75,7 +75,7 @@ export class SiThemeService {
* {}
* ```
*/
readonly themeIcons = signal<Record<string, SafeHtml>>({});
readonly themeIcons = signal<Record<string, string>>({});

private themes: Map<string, Theme> = new Map();
private darkMediaQuery?: MediaQueryList;
Expand All @@ -87,7 +87,6 @@ export class SiThemeService {
inject(SiThemeStore, { optional: true }) ?? new SiDefaultThemeStore(this.isBrowser);
private meta = inject(Meta);
private document = inject(DOCUMENT);
private domSanitizer = inject(DomSanitizer);

constructor() {
this.resolvedColorScheme$.subscribe(scheme => (this._resolvedColorScheme = scheme));
Expand Down Expand Up @@ -468,17 +467,8 @@ export class SiThemeService {
);
}

private parseDataSvgIcon(icon: string): SafeHtml {
private parseDataSvgIcon(icon: string): string {
// This method is currently a copy of parseDataSvgIcon in si-icon.registry.ts.
// Those are likely to diverge in the future, as this version will get support for other formats like:
// - URLs
// - Plain SVG data
// - Promises to enable lazy icon loading using import()
const parsed = /^data:image\/svg\+xml;utf8,(.*)$/.exec(icon);
if (!parsed) {
console.error('Failed to parse icon', icon);
return '';
}
return this.domSanitizer.bypassSecurityTrustHtml(parsed[1]);
return `url("${icon}")`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The current implementation of parseDataSvgIcon does not handle double quotes within the icon string, which can lead to broken image URLs. If an icon's SVG data contains a double quote, it will prematurely terminate the string within the url() function, causing the icon to fail to render. To fix this, you should escape any double quotes within the icon string.

Suggested change
return `url("${icon}")`;
return `url("${icon.replace(/"/g, '\\"')}")`;

}
}
Loading