diff --git a/projects/igniteui-angular/src/lib/checkbox/checkbox.component.ts b/projects/igniteui-angular/src/lib/checkbox/checkbox.component.ts index f53bb062cbd..9556f9b18ec 100644 --- a/projects/igniteui-angular/src/lib/checkbox/checkbox.component.ts +++ b/projects/igniteui-angular/src/lib/checkbox/checkbox.component.ts @@ -14,15 +14,16 @@ import { Self, booleanAttribute, inject, - DestroyRef + DestroyRef, + Inject } from '@angular/core'; import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import { IgxRippleDirective } from '../directives/ripple/ripple.directive'; -import { IBaseEventArgs, mkenum } from '../core/utils'; +import { IBaseEventArgs, getComponentTheme, mkenum } from '../core/utils'; import { EditorProvider, EDITOR_PROVIDER } from '../core/edit-provider'; -import { noop, Subject, Subscription } from 'rxjs'; +import { noop, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; -import { IgxTheme, ThemeService } from '../services/theme/theme.service'; +import { IgxTheme, THEME_TOKEN, ThemeToken } from '../services/theme/theme.token'; export const LabelPosition = /*@__PURE__*/mkenum({ BEFORE: 'before', @@ -492,28 +493,40 @@ export class IgxCheckboxComponent implements EditorProvider, AfterViewInit, Cont */ private _required = false; private elRef = inject(ElementRef); - private _theme$ = new Subject(); - private _subscription: Subscription; private destroyRef = inject(DestroyRef); constructor( protected cdr: ChangeDetectorRef, protected renderer: Renderer2, - protected themeService: ThemeService, + @Inject(THEME_TOKEN) + protected themeToken: ThemeToken, @Optional() @Self() public ngControl: NgControl, ) { if (this.ngControl !== null) { this.ngControl.valueAccessor = this; } - this.theme = this.themeService.globalTheme; + this.theme = this.themeToken.theme; - this._subscription = this._theme$.asObservable().subscribe(value => { - this.theme = value as IgxTheme; - this.cdr.detectChanges(); + const { unsubscribe } = this.themeToken.onChange((theme) => { + if (this.theme !== theme) { + this.theme = theme; + this.cdr.detectChanges(); + } }); - this.destroyRef.onDestroy(() => this._subscription.unsubscribe()); + this.destroyRef.onDestroy(() => unsubscribe); + } + + private setComponentTheme() { + if(!this.themeToken.preferToken) { + const theme = getComponentTheme(this.elRef.nativeElement); + + if (theme && theme !== this.theme) { + this.theme = theme; + this.cdr.markForCheck(); + } + } } /** @@ -530,12 +543,7 @@ export class IgxCheckboxComponent implements EditorProvider, AfterViewInit, Cont } } - const theme = this.themeService.getComponentTheme(this.elRef); - - if (theme) { - this._theme$.next(theme); - this.cdr.markForCheck(); - } + this.setComponentTheme(); } /** @hidden @internal */ diff --git a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts index f8e3ed163df..a19fff8f529 100644 --- a/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/combo/combo.component.spec.ts @@ -13,7 +13,6 @@ import { IBaseCancelableBrowserEventArgs } from '../core/utils'; import { SortingDirection } from '../data-operations/sorting-strategy'; import { IForOfState } from '../directives/for-of/for_of.directive'; import { IgxInputState } from '../directives/input/input.directive'; -import { IgxIconService } from '../icon/public_api'; import { IgxLabelDirective } from '../input-group/public_api'; import { AbsoluteScrollStrategy, ConnectedPositioningStrategy } from '../services/public_api'; import { configureTestSuite } from '../test-utils/configure-suite'; @@ -87,7 +86,6 @@ describe('igxCombo', () => { get: mockNgControl }); mockSelection.get.and.returnValue(new Set([])); - const mockIconService = new IgxIconService(null, null, null, null); const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'defaultView': { getComputedStyle: () => null }}); it('should correctly implement interface methods - ControlValueAccessor ', () => { @@ -98,11 +96,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); expect(mockInjector.get).toHaveBeenCalledWith(NgControl, null); combo.registerOnChange(mockNgControl.registerOnChangeCb); @@ -146,12 +142,10 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['open', 'close', 'toggle']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.dropdown = dropdown; dropdown.collapsed = true; @@ -179,12 +173,10 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdownContainer = { nativeElement: { focus: () => { } } }; combo['dropdownContainer'] = dropdownContainer; - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); spyOn(combo, 'focusSearchInput'); combo.autoFocusSearch = false; @@ -207,11 +199,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['toggle']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.dropdown = dropdown; const defaultSettings = (combo as any)._overlaySettings; @@ -234,10 +224,8 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.valueKey = 'field'; expect(combo.displayKey).toEqual(combo.valueKey); @@ -253,10 +241,8 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; mockSelection.select_items.calls.reset(); @@ -281,11 +267,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = complexData; combo.valueKey = 'country'; @@ -336,11 +320,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -375,10 +357,8 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); spyOn(combo.opening, 'emit').and.callThrough(); spyOn(combo.closing, 'emit').and.callThrough(); @@ -423,11 +403,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -524,11 +502,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = complexData; combo.valueKey = 'country'; @@ -570,11 +546,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = complexData; combo.valueKey = 'country'; @@ -637,11 +611,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -666,11 +638,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -720,11 +690,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -745,10 +713,8 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); let errorMessage = ''; try { @@ -769,10 +735,8 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); let errorMessage = ''; try { @@ -793,11 +757,9 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -841,10 +803,8 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.disableFiltering = false; @@ -866,12 +826,10 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['open', 'close', 'toggle']); const spyObj = jasmine.createSpyObj('event', ['stopPropagation', 'preventDefault']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.dropdown = dropdown; dropdown.collapsed = true; @@ -889,12 +847,10 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); const spyObj = jasmine.createSpyObj('event', ['stopPropagation']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -916,8 +872,7 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); const mockVirtDir = jasmine.createSpyObj('virtDir', ['scrollTo']); @@ -925,7 +880,6 @@ describe('igxCombo', () => { nativeElement: jasmine.createSpyObj('mockElement', ['focus']) }); spyOn(combo.addition, 'emit').and.callThrough(); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); const subParams: { cancel: boolean; newValue: string; modify: boolean } = { cancel: false, modify: false, @@ -1023,8 +977,7 @@ describe('igxCombo', () => { mockComboService, mockDocument, null, - mockInjector, - mockIconService + mockInjector ); combo.ngOnDestroy(); expect(mockComboService.clear).toHaveBeenCalled(); diff --git a/projects/igniteui-angular/src/lib/core/utils.ts b/projects/igniteui-angular/src/lib/core/utils.ts index e0b9c75f8f5..487dce78d8d 100644 --- a/projects/igniteui-angular/src/lib/core/utils.ts +++ b/projects/igniteui-angular/src/lib/core/utils.ts @@ -4,6 +4,7 @@ import { mergeWith } from 'lodash-es'; import { NEVER, Observable } from 'rxjs'; import { setImmediate } from './setImmediate'; import { isDevMode } from '@angular/core'; +import { IgxTheme } from '../services/theme/theme.token'; /** @hidden @internal */ export const ELEMENTS_TOKEN = /*@__PURE__*/new InjectionToken('elements environment'); @@ -658,3 +659,10 @@ export function getComponentCssSizeVar(size: string) { export function normalizeURI(path: string) { return path?.split('/').map(encodeURI).join('/'); } + +export function getComponentTheme(el: Element) { + return globalThis.window + ?.getComputedStyle(el) + .getPropertyValue('--theme') + .trim() as IgxTheme; +} diff --git a/projects/igniteui-angular/src/lib/icon/icon.component.ts b/projects/igniteui-angular/src/lib/icon/icon.component.ts index 8c3e2c94714..7f62dfd82e5 100644 --- a/projects/igniteui-angular/src/lib/icon/icon.component.ts +++ b/projects/igniteui-angular/src/lib/icon/icon.component.ts @@ -15,7 +15,6 @@ import { filter, takeUntil } from "rxjs/operators"; import { Subject } from "rxjs"; import { SafeHtml } from "@angular/platform-browser"; import { NgIf, NgTemplateOutlet } from "@angular/common"; -import { ThemeService } from "../services/theme/theme.service"; /** * Icon provides a way to include material icons to markup @@ -135,11 +134,9 @@ export class IgxIconComponent implements OnInit, OnChanges, OnDestroy { constructor( public el: ElementRef, private iconService: IgxIconService, - private themeService: ThemeService, private ref: ChangeDetectorRef, ) { this.family = this.iconService.defaultFamily.name; - this.iconService.setRefsByTheme(this.themeService.globalTheme); this.iconService.iconLoaded .pipe( diff --git a/projects/igniteui-angular/src/lib/icon/icon.service.spec.ts b/projects/igniteui-angular/src/lib/icon/icon.service.spec.ts index 501c71e217b..2474450df60 100644 --- a/projects/igniteui-angular/src/lib/icon/icon.service.spec.ts +++ b/projects/igniteui-angular/src/lib/icon/icon.service.spec.ts @@ -5,9 +5,10 @@ import { IgxIconService } from './icon.service'; import { configureTestSuite } from '../test-utils/configure-suite'; import { first } from 'rxjs/operators'; import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; -import { Component } from "@angular/core"; +import { Component, inject } from "@angular/core"; import { IgxIconComponent } from "./icon.component"; import { By } from "@angular/platform-browser"; +import { IgxTheme, THEME_TOKEN, ThemeToken } from "igniteui-angular"; describe("Icon Service", () => { configureTestSuite(); @@ -215,6 +216,36 @@ describe("Icon Service", () => { iconService.addSvgIcon(iconName, "test.svg", familyName); }); + + it('should change icon references dynamically when the value of THEME_TOKEN changes', () => { + const fixture = TestBed.createComponent(IconWithThemeTokenComponent); + fixture.detectChanges(); + + let arrow_prev = fixture.debugElement.query(By.css("igx-icon[name='arrow_prev']")); + let expand_more = fixture.debugElement.query(By.css("igx-icon[name='expand_more']")); + + expect(fixture.componentInstance.themeToken.theme).toBe('material'); + expect(arrow_prev).toBeTruthy(); + expect(arrow_prev.classes['material-icons']).toBeTrue(); + expect(expand_more).toBeTruthy(); + expect(expand_more.classes['material-icons']).toBeTrue(); + + fixture.componentInstance.setTheme('indigo'); + fixture.detectChanges(); + + arrow_prev = fixture.debugElement.query(By.css("igx-icon[name='arrow_prev']")); + expand_more = fixture.debugElement.query(By.css("igx-icon[name='expand_more']")); + + expect(fixture.componentInstance.themeToken.theme).toBe('indigo'); + + // The class change should be reflected as the family changes + expect(arrow_prev).toBeTruthy(); + expect(arrow_prev.classes['internal_indigo']).toBeTrue(); + + // The expand_more shouldn't change as its reference is set explicitly + expect(expand_more).toBeTruthy(); + expect(expand_more.classes['material-icons']).toBeTrue(); + }); }); @Component({ @@ -231,3 +262,29 @@ class IconTestComponent { } imports: [IgxIconComponent] }) class IconRefComponent { } + +@Component({ + template: ` + + + `, + providers: [ + { + provide: THEME_TOKEN, + useFactory: () => new ThemeToken() + }, + IgxIconService + ], + imports: [IgxIconComponent] +}) +class IconWithThemeTokenComponent { + public themeToken = inject(THEME_TOKEN); + + constructor(public iconService: IgxIconService) { + this.iconService.setIconRef('expand_more', 'default', { family: 'material', name: 'home' }); + } + + public setTheme(theme: IgxTheme) { + this.themeToken.set(theme); + } +} diff --git a/projects/igniteui-angular/src/lib/icon/icon.service.ts b/projects/igniteui-angular/src/lib/icon/icon.service.ts index 4a5ba94f0bc..4bc94e2e006 100644 --- a/projects/igniteui-angular/src/lib/icon/icon.service.ts +++ b/projects/igniteui-angular/src/lib/icon/icon.service.ts @@ -1,4 +1,4 @@ -import { Injectable, SecurityContext, Inject, Optional } from "@angular/core"; +import { DestroyRef, Inject, Injectable, Optional, SecurityContext } from "@angular/core"; import { DomSanitizer, SafeHtml } from "@angular/platform-browser"; import { DOCUMENT } from "@angular/common"; import { HttpClient } from "@angular/common/http"; @@ -7,7 +7,7 @@ import { PlatformUtil } from "../core/utils"; import { iconReferences } from './icon.references' import { IconFamily, IconMeta, FamilyMeta } from "./types"; import type { IconType, IconReference } from './types'; -import { IgxTheme } from "../services/theme/theme.service"; +import { IgxTheme, THEME_TOKEN, ThemeToken } from "../services/theme/theme.token"; import { IndigoIcons } from "./icons.indigo"; /** @@ -59,17 +59,25 @@ export class IgxIconService { private _cachedIcons = new Map>(); private _iconLoaded = new Subject(); private _domParser: DOMParser; - private theme!: IgxTheme; constructor( @Optional() private _sanitizer: DomSanitizer, @Optional() private _httpClient: HttpClient, @Optional() private _platformUtil: PlatformUtil, + @Optional() @Inject(THEME_TOKEN) private _themeToken: ThemeToken, + @Optional() @Inject(DestroyRef) private _destroyRef: DestroyRef, @Optional() @Inject(DOCUMENT) protected document: Document, ) { + this.iconLoaded = this._iconLoaded.asObservable(); this.setFamily(this._defaultFamily.name, this._defaultFamily.meta); + const { unsubscribe } = this._themeToken?.onChange((theme) => { + this.setRefsByTheme(theme); + }); + + this._destroyRef.onDestroy(() => unsubscribe); + if (this._platformUtil?.isBrowser) { this._domParser = new DOMParser(); @@ -133,13 +141,21 @@ export class IgxIconService { /** @hidden @internal */ public setRefsByTheme(theme: IgxTheme) { - if (this.theme !== theme) { - this.theme = theme; + for (const { alias, target } of iconReferences) { + const external = this._iconRefs.get(alias.family)?.get(alias.name)?.external; - for (const { alias, target } of iconReferences) { - const icon = target.get(theme) ?? target.get('default')!; - this.addIconRef(alias.name, alias.family, icon); - } + const _ref = this._iconRefs.get('default')?.get(alias.name) ?? {}; + const _target = target.get(theme) ?? target.get('default')!; + + const icon = target.get(theme) ?? target.get('default')!; + const overwrite = !external && !(JSON.stringify(_ref) === JSON.stringify(_target)); + + this._setIconRef( + alias.name, + alias.family, + icon, + overwrite + ); } } @@ -169,6 +185,15 @@ export class IgxIconService { } } + private _setIconRef(name: string, family: string, icon: IconMeta, overwrite = false) { + if (overwrite) { + this.setIconRef(name, family, { + ...icon, + external: false + }); + } + } + /** * Similar to addIconRef, but always sets the icon reference meta for an icon in a meta family. * ```typescript @@ -183,8 +208,9 @@ export class IgxIconService { this._iconRefs.set(family, familyRef); } + const external = icon.external ?? true; const familyType = this.familyType(icon?.family); - familyRef.set(name, { ...icon, type: icon.type ?? familyType }); + familyRef.set(name, { ...icon, type: icon.type ?? familyType, external }); this._iconLoaded.next({ name, family }); } diff --git a/projects/igniteui-angular/src/lib/icon/types.ts b/projects/igniteui-angular/src/lib/icon/types.ts index 75154ba3e44..1a22ff1d421 100644 --- a/projects/igniteui-angular/src/lib/icon/types.ts +++ b/projects/igniteui-angular/src/lib/icon/types.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/consistent-type-definitions */ -import { IgxTheme } from "../services/theme/theme.service"; +import { IgxTheme } from "../services/theme/theme.token"; // Exported internal types export type IconThemeKey = IgxTheme | 'default'; @@ -23,6 +23,8 @@ export interface IconMeta { name: string; family: string; type?: IconType; + /** @hidden @internal */ + external?: boolean; } export interface FamilyMeta { diff --git a/projects/igniteui-angular/src/lib/input-group/input-group.component.ts b/projects/igniteui-angular/src/lib/input-group/input-group.component.ts index a9104a90c77..c706f98f0df 100644 --- a/projects/igniteui-angular/src/lib/input-group/input-group.component.ts +++ b/projects/igniteui-angular/src/lib/input-group/input-group.component.ts @@ -1,18 +1,19 @@ import { DOCUMENT, NgIf, NgTemplateOutlet, NgClass, NgSwitch, NgSwitchCase, NgSwitchDefault } from '@angular/common'; import { - AfterViewChecked, + AfterViewInit, ChangeDetectorRef, Component, ContentChild, ContentChildren, + DestroyRef, ElementRef, HostBinding, HostListener, Inject, Input, - OnDestroy, - Optional, QueryList, booleanAttribute + Optional, QueryList, booleanAttribute, + inject } from '@angular/core'; import { IInputResourceStrings, InputResourceStringsEN } from '../core/i18n/input-resources'; -import { PlatformUtil } from '../core/utils'; +import { PlatformUtil, getComponentTheme } from '../core/utils'; import { IgxButtonDirective } from '../directives/button/button.directive'; import { IgxHintDirective } from '../directives/hint/hint.directive'; import { @@ -26,8 +27,7 @@ import { IgxInputGroupBase } from './input-group.common'; import { IgxInputGroupType, IGX_INPUT_GROUP_TYPE } from './inputGroupType'; import { IgxIconComponent } from '../icon/icon.component'; import { getCurrentResourceStrings } from '../core/i18n/resources'; -import { IgxTheme, ThemeService } from '../services/theme/theme.service'; -import { Subject, Subscription } from 'rxjs'; +import { IgxTheme, THEME_TOKEN, ThemeToken } from '../services/theme/theme.token'; @Component({ selector: 'igx-input-group', @@ -35,7 +35,7 @@ import { Subject, Subscription } from 'rxjs'; providers: [{ provide: IgxInputGroupBase, useExisting: IgxInputGroupComponent }], imports: [NgIf, NgTemplateOutlet, IgxPrefixDirective, IgxButtonDirective, NgClass, IgxSuffixDirective, IgxIconComponent, NgSwitch, NgSwitchCase, NgSwitchDefault] }) -export class IgxInputGroupComponent implements IgxInputGroupBase, AfterViewChecked, OnDestroy { +export class IgxInputGroupComponent implements IgxInputGroupBase, AfterViewInit { /** * Sets the resource strings. * By default it uses EN resources. @@ -120,11 +120,10 @@ export class IgxInputGroupComponent implements IgxInputGroupBase, AfterViewCheck @ContentChild(IgxInputDirective, { read: IgxInputDirective, static: true }) protected input: IgxInputDirective; + private _destroyRef = inject(DestroyRef); private _type: IgxInputGroupType = null; private _filled = false; private _theme: IgxTheme; - private _theme$ = new Subject(); - private _subscription: Subscription; private _resourceStrings = getCurrentResourceStrings(InputResourceStringsEN); /** @hidden */ @@ -216,14 +215,19 @@ export class IgxInputGroupComponent implements IgxInputGroupBase, AfterViewCheck private document: any, private platform: PlatformUtil, private cdr: ChangeDetectorRef, - private themeService: ThemeService, + @Inject(THEME_TOKEN) + private themeToken: ThemeToken ) { - this._theme = this.themeService.globalTheme; + this._theme = this.themeToken.theme; - this._subscription = this._theme$.asObservable().subscribe(value => { - this._theme = value as IgxTheme; - this.cdr.detectChanges(); + const { unsubscribe } = this.themeToken.onChange((theme) => { + if (this._theme !== theme) { + this._theme = theme; + this.cdr.detectChanges(); + } }); + + this._destroyRef.onDestroy(() => unsubscribe); } /** @hidden */ @@ -439,18 +443,19 @@ export class IgxInputGroupComponent implements IgxInputGroupBase, AfterViewCheck this._filled = val; } - /** @hidden @internal */ - public ngAfterViewChecked() { - const theme = this.themeService.getComponentTheme(this.element); + private setComponentTheme() { + if (!this.themeToken.preferToken) { + const theme = getComponentTheme(this.element.nativeElement); - if (theme) { - this._theme$.next(theme); - this.cdr.markForCheck(); + if (theme && theme !== this._theme) { + this.theme = theme; + this.cdr.markForCheck(); + } } } /** @hidden @internal */ - public ngOnDestroy() { - this._subscription.unsubscribe(); + public ngAfterViewInit() { + this.setComponentTheme(); } } diff --git a/projects/igniteui-angular/src/lib/services/public_api.ts b/projects/igniteui-angular/src/lib/services/public_api.ts index 2afbfccbe07..a838c7a92fe 100644 --- a/projects/igniteui-angular/src/lib/services/public_api.ts +++ b/projects/igniteui-angular/src/lib/services/public_api.ts @@ -19,4 +19,5 @@ export * from './transaction/igx-hierarchical-transaction'; export * from './transaction/igx-transaction'; export * from './transaction/transaction'; export * from './transaction/transaction-factory.service'; +export * from './theme/theme.token'; diff --git a/projects/igniteui-angular/src/lib/services/theme/theme.service.ts b/projects/igniteui-angular/src/lib/services/theme/theme.service.ts deleted file mode 100644 index e7836e47aca..00000000000 --- a/projects/igniteui-angular/src/lib/services/theme/theme.service.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { ElementRef, Inject, Injectable } from "@angular/core"; -import { mkenum } from "../../core/utils"; -import { BehaviorSubject } from "rxjs"; -import { DOCUMENT } from "@angular/common"; - -const Theme = /*@__PURE__*/ mkenum({ - Material: "material", - Fluent: "fluent", - Bootstrap: "bootstrap", - IndigoDesign: "indigo", -}); - -/** - * Determines the component theme. - */ -export type IgxTheme = (typeof Theme)[keyof typeof Theme]; - -@Injectable({ - providedIn: "root", -}) -export class ThemeService { - /** - * Sets the theme of the component. - * Allowed values of type IgxTheme. - */ - public globalTheme: IgxTheme; - private theme$ = new BehaviorSubject("material"); - - constructor( - @Inject(DOCUMENT) - private document: any, - ) { - this.theme$.asObservable().subscribe((value) => { - this.globalTheme = value as IgxTheme; - }); - - this.init(); - } - - private init() { - const theme = globalThis.window - ?.getComputedStyle(this.document.body) - .getPropertyValue("--ig-theme") - .trim(); - - if (theme !== "") { - this.theme$.next(theme as IgxTheme); - } - } - - public getComponentTheme(el: ElementRef) { - return globalThis.window - ?.getComputedStyle(el.nativeElement) - .getPropertyValue('--theme') - .trim() as IgxTheme; - } -} diff --git a/projects/igniteui-angular/src/lib/services/theme/theme.token.ts b/projects/igniteui-angular/src/lib/services/theme/theme.token.ts new file mode 100644 index 00000000000..1ea02f7aa99 --- /dev/null +++ b/projects/igniteui-angular/src/lib/services/theme/theme.token.ts @@ -0,0 +1,52 @@ +import { inject, InjectionToken } from "@angular/core"; +import { mkenum } from "../../core/utils"; +import { BehaviorSubject } from "rxjs"; +import { DOCUMENT } from "@angular/common"; + +export class ThemeToken { + private document = inject(DOCUMENT); + public subject: BehaviorSubject; + + constructor(private t?: IgxTheme) { + const globalTheme = globalThis.window + ?.getComputedStyle(this.document.body) + .getPropertyValue("--ig-theme") + .trim() || 'material' as IgxTheme; + + const _theme = t ?? globalTheme as IgxTheme; + this.subject = new BehaviorSubject(_theme); + } + + public onChange(callback: (theme: IgxTheme) => void) { + return this.subject.subscribe(callback); + } + + public set(theme: IgxTheme) { + this.subject.next(theme); + } + + public get theme() { + return this.subject.getValue(); + } + + public get preferToken() { + return !!this.t; + } +} + +export const THEME_TOKEN = new InjectionToken('ThemeToken', { + providedIn: 'root', + factory: () => new ThemeToken() +}); + +const Theme = /*@__PURE__*/ mkenum({ + Material: "material", + Fluent: "fluent", + Bootstrap: "bootstrap", + IndigoDesign: "indigo", +}); + +/** + * Determines the component theme. + */ +export type IgxTheme = (typeof Theme)[keyof typeof Theme]; diff --git a/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts b/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts index 0f6f6996616..0b400781774 100644 --- a/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts +++ b/projects/igniteui-angular/src/lib/simple-combo/simple-combo.component.spec.ts @@ -10,7 +10,6 @@ import { IComboSelectionChangingEventArgs, IgxComboFooterDirective, IgxComboHead import { IgxSelectionAPIService } from '../core/selection'; import { IBaseCancelableBrowserEventArgs } from '../core/utils'; import { IgxIconComponent } from '../icon/icon.component'; -import { IgxIconService } from '../icon/icon.service'; import { IgxInputState, IgxLabelDirective } from '../input-group/public_api'; import { AbsoluteScrollStrategy, AutoPositionStrategy, ConnectedPositioningStrategy } from '../services/public_api'; import { configureTestSuite } from '../test-utils/configure-suite'; @@ -76,9 +75,9 @@ describe('IgxSimpleCombo', () => { get: mockNgControl }); mockSelection.get.and.returnValue(new Set([])); - const mockIconService = new IgxIconService(null, null, null, null); const platformUtil = null; const mockDocument = jasmine.createSpyObj('DOCUMENT', [], { 'defaultView': { getComputedStyle: () => null }}); + it('should properly call dropdown methods on toggle', () => { combo = new IgxSimpleComboComponent( elementRef, @@ -90,7 +89,6 @@ describe('IgxSimpleCombo', () => { mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['open', 'close', 'toggle']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.dropdown = dropdown; dropdown.collapsed = true; @@ -121,7 +119,6 @@ describe('IgxSimpleCombo', () => { mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['toggle']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.dropdown = dropdown; const defaultSettings = (combo as any)._overlaySettings; @@ -146,7 +143,6 @@ describe('IgxSimpleCombo', () => { null, mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.valueKey = 'field'; expect(combo.displayKey).toEqual(combo.valueKey); @@ -168,7 +164,6 @@ describe('IgxSimpleCombo', () => { ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.comboInput = comboInput; combo.data = complexData; @@ -207,7 +202,6 @@ describe('IgxSimpleCombo', () => { null, mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); spyOn(combo.opening, 'emit').and.callThrough(); spyOn(combo.closing, 'emit').and.callThrough(); @@ -257,7 +251,6 @@ describe('IgxSimpleCombo', () => { mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -309,7 +302,6 @@ describe('IgxSimpleCombo', () => { mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = complexData; combo.valueKey = 'country'; @@ -353,7 +345,6 @@ describe('IgxSimpleCombo', () => { mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -379,7 +370,6 @@ describe('IgxSimpleCombo', () => { null, mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); let errorMessage = ''; try { @@ -403,7 +393,6 @@ describe('IgxSimpleCombo', () => { null, mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); let errorMessage = ''; try { @@ -428,7 +417,6 @@ describe('IgxSimpleCombo', () => { mockInjector ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem', 'navigateFirst']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; @@ -473,7 +461,6 @@ describe('IgxSimpleCombo', () => { null, mockInjector ); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.searchInputUpdate.subscribe((e) => { @@ -504,7 +491,6 @@ describe('IgxSimpleCombo', () => { ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['open', 'close', 'toggle']); const spyObj = jasmine.createSpyObj('event', ['stopPropagation', 'preventDefault']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); const comboInput = jasmine.createSpyObj('IgxInputDirective', ['value']); comboInput.value = 'test'; combo.comboInput = comboInput; @@ -530,7 +516,6 @@ describe('IgxSimpleCombo', () => { ); const dropdown = jasmine.createSpyObj('IgxComboDropDownComponent', ['selectItem', 'focusedItem']); const spyObj = jasmine.createSpyObj('event', ['stopPropagation']); - spyOn(mockIconService, 'addSvgIconFromText').and.returnValue(null); combo.ngOnInit(); combo.data = data; combo.dropdown = dropdown; diff --git a/src/app/icon/ThemedComponent/themed-icon.component.html b/src/app/icon/ThemedComponent/themed-icon.component.html new file mode 100644 index 00000000000..65cd2972a5b --- /dev/null +++ b/src/app/icon/ThemedComponent/themed-icon.component.html @@ -0,0 +1,17 @@ +
+ + + +
+ + + @for (theme of themes; track theme) { + + } + diff --git a/src/app/icon/ThemedComponent/themed-icon.component.ts b/src/app/icon/ThemedComponent/themed-icon.component.ts new file mode 100644 index 00000000000..e63d7a5d22b --- /dev/null +++ b/src/app/icon/ThemedComponent/themed-icon.component.ts @@ -0,0 +1,35 @@ +import { Component, inject } from '@angular/core'; +import { + IgxButtonGroupComponent, + IgxButtonDirective, + IgxIconComponent, + IgxIconService, + type IgxTheme, + THEME_TOKEN, + ThemeToken, +} from 'igniteui-angular'; + +@Component({ + selector: 'app-themed-icon', + templateUrl: 'themed-icon.component.html', + providers: [ + { + provide: THEME_TOKEN, + useFactory: () => new ThemeToken() + }, + IgxIconService, // Create New Icon Service Scoped to this component + ], + imports: [IgxIconComponent, IgxButtonDirective, IgxButtonGroupComponent] +}) +export class ThemedIconComponent { + protected themeToken = inject(THEME_TOKEN); + protected themes: IgxTheme[] = ['material', 'bootstrap', 'indigo', 'fluent']; + + constructor(private iconService: IgxIconService) { + this.iconService.setIconRef('expand_more', 'default', { family: 'material', name: 'home' }); + } + + protected setTheme(theme: IgxTheme) { + this.themeToken.set(theme); + } +} diff --git a/src/app/icon/icon.sample.html b/src/app/icon/icon.sample.html index c65cfbb8e80..178c6b5a625 100644 --- a/src/app/icon/icon.sample.html +++ b/src/app/icon/icon.sample.html @@ -5,8 +5,8 @@

Default

- - + + @@ -94,5 +94,10 @@

Lazy loaded module with cached SVG icons

+
+

Separate Instance of Icon Service w/ Runtime Theme Changes

+ The first two icons (left and rigth chevron) should change to the theme-specific reference when the buttons bellow are pressed. The third icon is set in the component instance by the end user via the Icon Service, thus it shouldn't change. + +
diff --git a/src/app/icon/icon.sample.ts b/src/app/icon/icon.sample.ts index 38b56f095c9..10b2f88c32a 100644 --- a/src/app/icon/icon.sample.ts +++ b/src/app/icon/icon.sample.ts @@ -1,13 +1,15 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnInit, ViewEncapsulation } from '@angular/core'; import { Router } from '@angular/router'; import { IgxButtonDirective, IgxIconComponent, IgxIconService } from 'igniteui-angular'; +import { ThemedIconComponent } from './ThemedComponent/themed-icon.component'; @Component({ selector: 'app-icon-sample', styleUrls: ['./icon.sample.scss'], templateUrl: 'icon.sample.html', - imports: [IgxIconComponent, IgxButtonDirective] + encapsulation: ViewEncapsulation.None, + imports: [IgxIconComponent, IgxButtonDirective, ThemedIconComponent] }) export class IconSampleComponent implements OnInit { constructor(private _iconService: IgxIconService, public router: Router) {}